From 3e07f5a6a412be551e1d74fe4600a31e5ef557c4 Mon Sep 17 00:00:00 2001 From: Ibrahim Elkamali <126423069+Marve10s@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:11:59 +0300 Subject: [PATCH 01/36] feat: add Claude Opus 4.7 to built-in models (#2072) Co-authored-by: Julius Marminge --- apps/server/package.json | 2 +- .../src/git/Layers/CodexTextGeneration.ts | 1 + .../src/provider/Layers/ClaudeAdapter.test.ts | 47 +++++ .../src/provider/Layers/ClaudeAdapter.ts | 15 +- .../src/provider/Layers/ClaudeProvider.ts | 68 +++++++- .../provider/Layers/ProviderRegistry.test.ts | 63 +++++++ apps/server/src/provider/cliVersion.test.ts | 17 ++ apps/server/src/provider/cliVersion.ts | 123 +++++++++++++ apps/server/src/provider/codexCliVersion.ts | 124 +------------ apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/composerDraftStore.ts | 36 ++-- bun.lock | 164 +++++++++++++++++- packages/contracts/src/model.ts | 25 ++- packages/shared/src/model.ts | 4 +- 14 files changed, 520 insertions(+), 173 deletions(-) create mode 100644 apps/server/src/provider/cliVersion.test.ts create mode 100644 apps/server/src/provider/cliVersion.ts diff --git a/apps/server/package.json b/apps/server/package.json index 950079a4dc1d..af6450a88a94 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -23,7 +23,7 @@ "test": "vitest run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.77", + "@anthropic-ai/claude-agent-sdk": "^0.2.111", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 52ddf554532e..be1c6798c943 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -166,6 +166,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { [ "exec", "--ephemeral", + "--skip-git-repo-check", "-s", "read-only", "--model", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index b9bf61dca2b5..6f0d4a352f5b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -351,6 +351,53 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("defaults Claude Opus 4.7 sessions to xhigh effort", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("forwards xhigh effort for Claude Opus 4.7", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-7", + options: { + effort: "xhigh", + }, + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 8d3de8e5eb61..feacfa99ea25 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -18,7 +18,6 @@ import { type SettingSource, type SDKUserMessage, ModelUsage, - NonNullableUsage, } from "@anthropic-ai/claude-agent-sdk"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { @@ -41,7 +40,7 @@ import { ThreadId, TurnId, type UserInputQuestion, - ClaudeCodeEffort, + ClaudeAgentEffort, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -216,9 +215,9 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray return squashed.length > 0 ? [squashed] : []; } -function getEffectiveClaudeCodeEffort( - effort: ClaudeCodeEffort | null | undefined, -): Exclude | null { +function getEffectiveClaudeAgentEffort( + effort: ClaudeAgentEffort | null | undefined, +): Exclude | null { if (!effort) { return null; } @@ -290,7 +289,7 @@ function maxClaudeContextWindowFromModelUsage( } function normalizeClaudeTokenUsage( - value: NonNullableUsage | undefined, + value: unknown, contextWindow?: number, ): ThreadTokenUsageSnapshot | undefined { if (!value || typeof value !== "object") { @@ -2749,13 +2748,13 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const caps = getClaudeModelCapabilities(modelSelection?.model); const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? - null) as ClaudeCodeEffort | null; + null) as ClaudeAgentEffort | null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; const thinking = typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle ? modelSelection.options.thinking : undefined; - const effectiveEffort = getEffectiveClaudeCodeEffort(effort); + const effectiveEffort = getEffectiveClaudeAgentEffort(effort); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", "full-access": "bypassPermissions", diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b7c3c3140eac..f76c4250eb48 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -26,6 +26,7 @@ import { spawnAndCollect, type CommandResult, } from "../providerSnapshot"; +import { compareCliVersions } from "../cliVersion"; import { makeManagedServerProvider } from "../makeManagedServerProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; import { ServerSettingsService } from "../../serverSettings"; @@ -40,7 +41,30 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { }; const PROVIDER = "claudeAgent" as const; +const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + promptInjectedEffortLevels: ["ultrathink"], + } satisfies ModelCapabilities, + }, { slug: "claude-opus-4-6", name: "Claude Opus 4.6", @@ -96,6 +120,24 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +function supportsClaudeOpus47(version: string | null | undefined): boolean { + return version ? compareCliVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; +} + +function getBuiltInClaudeModelsForVersion( + version: string | null | undefined, +): ReadonlyArray { + if (supportsClaudeOpus47(version)) { + return BUILT_IN_MODELS; + } + return BUILT_IN_MODELS.filter((model) => model.slug !== "claude-opus-4-7"); +} + +function formatClaudeOpus47UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; +} + export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); return ( @@ -484,7 +526,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( Effect.map((settings) => settings.providers.claudeAgent), ); const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings( + const allModels = providerModelsFromSettings( BUILT_IN_MODELS, PROVIDER, claudeSettings.customModels, @@ -496,7 +538,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: false, checkedAt, - models, + models: allModels, probe: { installed: false, version: null, @@ -518,7 +560,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: !isCommandMissingCause(error), version: null, @@ -536,7 +578,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: true, version: null, @@ -556,7 +598,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: allModels, probe: { installed: true, version: parsedVersion, @@ -569,6 +611,16 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const models = providerModelsFromSettings( + getBuiltInClaudeModelsForVersion(parsedVersion), + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + const opus47UpgradeMessage = supportsClaudeOpus47(parsedVersion) + ? undefined + : formatClaudeOpus47UpgradeMessage(parsedVersion); + const slashCommands = (resolveSlashCommands ? yield* resolveSlashCommands(claudeSettings.binaryPath).pipe( @@ -658,7 +710,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...parsed.auth, ...(authMetadata ? authMetadata : {}), }, - ...(parsed.message ? { message: parsed.message } : {}), + ...(parsed.message + ? { message: parsed.message } + : opus47UpgradeMessage + ? { message: opus47UpgradeMessage } + : {}), }, }); }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 93d146acf964..d03fffe82f61 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -973,6 +973,69 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect( + "includes Claude Opus 4.7 with xhigh as the default effort on supported versions", + () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + const opus47 = status.models.find((model) => model.slug === "claude-opus-4-7"); + if (!opus47) { + assert.fail("Expected Claude Opus 4.7 to be present for Claude Code v2.1.111."); + } + if (!opus47.capabilities) { + assert.fail( + "Expected Claude Opus 4.7 capabilities to be present for Claude Code v2.1.111.", + ); + } + assert.deepStrictEqual( + opus47.capabilities.reasoningEffortLevels.find((level) => level.isDefault), + { value: "xhigh", label: "Extra High", isDefault: true }, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.111\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Opus 4.7 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-opus-4-7"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.110 is too old for Claude Opus 4.7. Upgrade to v2.1.111 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.110\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan")); diff --git a/apps/server/src/provider/cliVersion.test.ts b/apps/server/src/provider/cliVersion.test.ts new file mode 100644 index 000000000000..a9c1721c4e87 --- /dev/null +++ b/apps/server/src/provider/cliVersion.test.ts @@ -0,0 +1,17 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { compareCliVersions, normalizeCliVersion } from "./cliVersion"; + +describe("cliVersion", () => { + it("normalizes versions with a missing patch segment", () => { + assert.strictEqual(normalizeCliVersion("2.1"), "2.1.0"); + }); + + it("compares prerelease versions before stable versions", () => { + assert.isTrue(compareCliVersions("2.1.111-beta.1", "2.1.111") < 0); + }); + + it("rejects malformed numeric segments", () => { + assert.isTrue(compareCliVersions("1.2.3abc", "1.2.10") > 0); + }); +}); diff --git a/apps/server/src/provider/cliVersion.ts b/apps/server/src/provider/cliVersion.ts new file mode 100644 index 000000000000..6308a2ff5258 --- /dev/null +++ b/apps/server/src/provider/cliVersion.ts @@ -0,0 +1,123 @@ +interface ParsedCliSemver { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: ReadonlyArray; +} + +const CLI_VERSION_NUMBER_SEGMENT = /^\d+$/; + +export function normalizeCliVersion(version: string): string { + const [main, prerelease] = version.trim().split("-", 2); + const segments = (main ?? "") + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + + if (segments.length === 2) { + segments.push("0"); + } + + return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); +} + +function parseCliSemver(version: string): ParsedCliSemver | null { + const normalized = normalizeCliVersion(version); + const [main = "", prerelease] = normalized.split("-", 2); + const segments = main.split("."); + if (segments.length !== 3) { + return null; + } + + const [majorSegment, minorSegment, patchSegment] = segments; + if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { + return null; + } + if ( + !CLI_VERSION_NUMBER_SEGMENT.test(majorSegment) || + !CLI_VERSION_NUMBER_SEGMENT.test(minorSegment) || + !CLI_VERSION_NUMBER_SEGMENT.test(patchSegment) + ) { + return null; + } + + const major = Number.parseInt(majorSegment, 10); + const minor = Number.parseInt(minorSegment, 10); + const patch = Number.parseInt(patchSegment, 10); + if (![major, minor, patch].every(Number.isInteger)) { + return null; + } + + return { + major, + minor, + patch, + prerelease: + prerelease + ?.split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) ?? [], + }; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + + if (leftNumeric && rightNumeric) { + return Number.parseInt(left, 10) - Number.parseInt(right, 10); + } + if (leftNumeric) { + return -1; + } + if (rightNumeric) { + return 1; + } + return left.localeCompare(right); +} + +export function compareCliVersions(left: string, right: string): number { + const parsedLeft = parseCliSemver(left); + const parsedRight = parseCliSemver(right); + if (!parsedLeft || !parsedRight) { + return left.localeCompare(right); + } + + if (parsedLeft.major !== parsedRight.major) { + return parsedLeft.major - parsedRight.major; + } + if (parsedLeft.minor !== parsedRight.minor) { + return parsedLeft.minor - parsedRight.minor; + } + if (parsedLeft.patch !== parsedRight.patch) { + return parsedLeft.patch - parsedRight.patch; + } + + if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { + return 0; + } + if (parsedLeft.prerelease.length === 0) { + return 1; + } + if (parsedRight.prerelease.length === 0) { + return -1; + } + + const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = parsedLeft.prerelease[index]; + const rightIdentifier = parsedRight.prerelease[index]; + if (leftIdentifier === undefined) { + return -1; + } + if (rightIdentifier === undefined) { + return 1; + } + const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); + if (comparison !== 0) { + return comparison; + } + } + + return 0; +} diff --git a/apps/server/src/provider/codexCliVersion.ts b/apps/server/src/provider/codexCliVersion.ts index 544020016c62..871948335017 100644 --- a/apps/server/src/provider/codexCliVersion.ts +++ b/apps/server/src/provider/codexCliVersion.ts @@ -1,121 +1,10 @@ +import { compareCliVersions, normalizeCliVersion } from "./cliVersion"; + const CODEX_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/; export const MINIMUM_CODEX_CLI_VERSION = "0.37.0"; -interface ParsedSemver { - readonly major: number; - readonly minor: number; - readonly patch: number; - readonly prerelease: ReadonlyArray; -} - -function normalizeCodexVersion(version: string): string { - const [main, prerelease] = version.trim().split("-", 2); - const segments = (main ?? "") - .split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0); - - if (segments.length === 2) { - segments.push("0"); - } - - return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); -} - -function parseSemver(version: string): ParsedSemver | null { - const normalized = normalizeCodexVersion(version); - const [main = "", prerelease] = normalized.split("-", 2); - const segments = main.split("."); - if (segments.length !== 3) { - return null; - } - - const [majorSegment, minorSegment, patchSegment] = segments; - if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { - return null; - } - - const major = Number.parseInt(majorSegment, 10); - const minor = Number.parseInt(minorSegment, 10); - const patch = Number.parseInt(patchSegment, 10); - if (![major, minor, patch].every(Number.isInteger)) { - return null; - } - - return { - major, - minor, - patch, - prerelease: - prerelease - ?.split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0) ?? [], - }; -} - -function comparePrereleaseIdentifier(left: string, right: string): number { - const leftNumeric = /^\d+$/.test(left); - const rightNumeric = /^\d+$/.test(right); - - if (leftNumeric && rightNumeric) { - return Number.parseInt(left, 10) - Number.parseInt(right, 10); - } - if (leftNumeric) { - return -1; - } - if (rightNumeric) { - return 1; - } - return left.localeCompare(right); -} - -export function compareCodexCliVersions(left: string, right: string): number { - const parsedLeft = parseSemver(left); - const parsedRight = parseSemver(right); - if (!parsedLeft || !parsedRight) { - return left.localeCompare(right); - } - - if (parsedLeft.major !== parsedRight.major) { - return parsedLeft.major - parsedRight.major; - } - if (parsedLeft.minor !== parsedRight.minor) { - return parsedLeft.minor - parsedRight.minor; - } - if (parsedLeft.patch !== parsedRight.patch) { - return parsedLeft.patch - parsedRight.patch; - } - - if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { - return 0; - } - if (parsedLeft.prerelease.length === 0) { - return 1; - } - if (parsedRight.prerelease.length === 0) { - return -1; - } - - const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = parsedLeft.prerelease[index]; - const rightIdentifier = parsedRight.prerelease[index]; - if (leftIdentifier === undefined) { - return -1; - } - if (rightIdentifier === undefined) { - return 1; - } - const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); - if (comparison !== 0) { - return comparison; - } - } - - return 0; -} +export const compareCodexCliVersions = compareCliVersions; export function parseCodexCliVersion(output: string): string | null { const match = CODEX_VERSION_PATTERN.exec(output); @@ -123,12 +12,7 @@ export function parseCodexCliVersion(output: string): string | null { return null; } - const parsed = parseSemver(match[1]); - if (!parsed) { - return null; - } - - return normalizeCodexVersion(match[1]); + return normalizeCliVersion(match[1]); } export function isCodexCliVersionSupported(version: string): boolean { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c16aab4fbe7f..46061915bf5f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,7 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, - type ClaudeCodeEffort, + type ClaudeAgentEffort, type EnvironmentId, type MessageId, type ModelSelection, @@ -303,7 +303,7 @@ function formatOutgoingPrompt(params: { }): string { const caps = getProviderModelCapabilities(params.models, params.model, params.provider); if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { - return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); + return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeAgentEffort | null); } return params.text; } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 20f7cbe032c4..da24f500e174 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,7 +1,6 @@ import { - CODEX_REASONING_EFFORT_OPTIONS, - type ClaudeCodeEffort, - type CodexReasoningEffort, + ClaudeAgentEffort, + CodexReasoningEffort, DEFAULT_MODEL_BY_PROVIDER, type EnvironmentId, ModelSelection, @@ -105,7 +104,7 @@ const PersistedComposerThreadDraftState = Schema.Struct({ type PersistedComposerThreadDraftState = typeof PersistedComposerThreadDraftState.Type; const LegacyCodexFields = Schema.Struct({ - effort: Schema.optionalKey(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + effort: Schema.optionalKey(CodexReasoningEffort), codexFastMode: Schema.optionalKey(Schema.Boolean), serviceTier: Schema.optionalKey(Schema.String), }); @@ -546,19 +545,13 @@ function normalizeProviderModelOptions( ? (candidate.claudeAgent as Record) : null; - const codexReasoningEffort: CodexReasoningEffort | undefined = - codexCandidate?.reasoningEffort === "low" || - codexCandidate?.reasoningEffort === "medium" || - codexCandidate?.reasoningEffort === "high" || - codexCandidate?.reasoningEffort === "xhigh" - ? codexCandidate.reasoningEffort - : provider === "codex" && - (legacy?.effort === "low" || - legacy?.effort === "medium" || - legacy?.effort === "high" || - legacy?.effort === "xhigh") + const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort) + ? codexCandidate.reasoningEffort + : provider === "codex" + ? Schema.is(CodexReasoningEffort)(legacy?.effort) ? legacy.effort - : undefined; + : undefined + : undefined; const codexFastMode = codexCandidate?.fastMode === true ? true @@ -582,14 +575,9 @@ function normalizeProviderModelOptions( : claudeCandidate?.thinking === false ? false : undefined; - const claudeEffort: ClaudeCodeEffort | undefined = - claudeCandidate?.effort === "low" || - claudeCandidate?.effort === "medium" || - claudeCandidate?.effort === "high" || - claudeCandidate?.effort === "max" || - claudeCandidate?.effort === "ultrathink" - ? claudeCandidate.effort - : undefined; + const claudeEffort = Schema.is(ClaudeAgentEffort)(claudeCandidate?.effort) + ? claudeCandidate.effort + : undefined; const claudeFastMode = claudeCandidate?.fastMode === true ? true diff --git a/bun.lock b/bun.lock index 2322e3126759..64f5a5b916ff 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,7 @@ "t3": "./dist/bin.mjs", }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.77", + "@anthropic-ai/claude-agent-sdk": "^0.2.111", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", @@ -210,7 +210,9 @@ "vitest": "^4.0.0", }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.77", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-t+R1BW3ahCFMNM7/8WJq7+Gw9KPA9Cl7UUK8fWPokJZ75cf/xwEd9MqB+MVNoQT45dJiom/wxybT7tqYPkCqyg=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.111", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-DwXyJpVL8JXB8L2toSw1by7uIt1p8hPGi0P+hqr5tL+Ae7DcK9O3tUd6XcGown3LZ49zNCUAIpqX3wDmOhqp0Q=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], "@astrojs/check": ["@astrojs/check@0.9.8", "", { "dependencies": { "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw=="], @@ -390,6 +392,8 @@ "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -508,6 +512,8 @@ "@lexical/yjs": ["@lexical/yjs@0.41.0", "", { "dependencies": { "@lexical/offset": "0.41.0", "@lexical/selection": "0.41.0", "lexical": "0.41.0" }, "peerDependencies": { "yjs": ">=13.5.22" } }, "sha512-PaKTxSbVC4fpqUjQ7vUL9RkNF1PjL8TFl5jRe03PqoPYpE33buf3VXX6+cOUEfv9+uknSqLCPHoBS/4jN3a97w=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -850,12 +856,16 @@ "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -892,6 +902,8 @@ "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], @@ -908,12 +920,18 @@ "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -954,12 +972,22 @@ "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], @@ -994,6 +1022,8 @@ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -1022,6 +1052,10 @@ "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@4.0.0-beta.45", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-vvNrUWqnzBIW1hRMa+zw0CLRW6HLgdu7hQ6K7PT/rS+UY/73Ma11O+Oi9oc9zwL8KcN37M47UDseAdlF0bGNWw=="], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], @@ -1036,6 +1070,8 @@ "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], @@ -1050,12 +1086,16 @@ "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -1064,10 +1104,20 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -1084,6 +1134,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], @@ -1092,14 +1144,24 @@ "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], @@ -1124,6 +1186,10 @@ "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], @@ -1148,6 +1214,8 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], + "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], @@ -1158,16 +1226,26 @@ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1196,14 +1274,20 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1212,8 +1296,12 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], @@ -1284,6 +1372,8 @@ "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], @@ -1318,6 +1408,10 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1376,6 +1470,10 @@ "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -1396,6 +1494,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], @@ -1418,6 +1518,10 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], @@ -1426,6 +1530,8 @@ "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], @@ -1456,8 +1562,12 @@ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1470,6 +1580,8 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], @@ -1486,16 +1598,24 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -1566,8 +1686,12 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.5", "", { "dependencies": { "@babel/generator": "8.0.0-rc.2", "@babel/helper-validator-identifier": "8.0.0-rc.2", "@babel/parser": "8.0.0-rc.2", "@babel/types": "8.0.0-rc.2", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.6", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0 || ^6.0.0-beta", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1576,16 +1700,34 @@ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], "seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="], "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -1666,6 +1808,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], @@ -1678,6 +1822,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tsdown": ["tsdown@0.20.3", "", { "dependencies": { "ansis": "^4.2.0", "cac": "^6.7.14", "defu": "^6.1.4", "empathic": "^2.0.0", "hookable": "^6.0.1", "import-without-cache": "^0.2.5", "obug": "^2.1.1", "picomatch": "^4.0.3", "rolldown": "1.0.0-rc.3", "rolldown-plugin-dts": "^0.22.1", "semver": "^7.7.3", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", "unconfig-core": "^7.4.2", "unrun": "^0.2.27" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@vitejs/devtools": "*", "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", "unplugin-unused": "^0.5.0" }, "optionalPeers": ["@arethetypeswrong/core", "@vitejs/devtools", "publint", "typescript", "unplugin-lightningcss", "unplugin-unused"], "bin": { "tsdown": "dist/run.mjs" } }, "sha512-qWOUXSbe4jN8JZEgrkc/uhJpC8VN2QpNu3eZkBWwNuTEjc/Ik1kcc54ycfcQ5QPRHeu9OQXaLfCI3o7pEJgB2w=="], @@ -1702,6 +1848,8 @@ "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -1744,6 +1892,8 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "unrun": ["unrun@0.2.32", "", { "dependencies": { "rolldown": "1.0.0-rc.9" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-opd3z6791rf281JdByf0RdRQrpcc7WyzqittqIXodM/5meNWdTwrVxeyzbaCp4/Rgls/um14oUaif1gomO8YGg=="], @@ -1758,6 +1908,8 @@ "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], @@ -1810,6 +1962,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -1846,6 +2000,8 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -1926,6 +2082,8 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -1940,6 +2098,8 @@ "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 7d87d882d923..c87224cf25b3 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -2,21 +2,28 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; import type { ProviderKind } from "./orchestration"; -export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; -export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; -export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const; -export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; -export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeEffort; +export const CodexReasoningEffort = Schema.Literals(["xhigh", "high", "medium", "low"]); +export type CodexReasoningEffort = typeof CodexReasoningEffort.Type; +export const ClaudeAgentEffort = Schema.Literals([ + "low", + "medium", + "high", + "xhigh", + "max", + "ultrathink", +]); +export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type; +export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeAgentEffort; export const CodexModelOptions = Schema.Struct({ - reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + reasoningEffort: Schema.optional(CodexReasoningEffort), fastMode: Schema.optional(Schema.Boolean), }); export type CodexModelOptions = typeof CodexModelOptions.Type; export const ClaudeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), - effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), + effort: Schema.optional(ClaudeAgentEffort), fastMode: Schema.optional(Schema.Boolean), contextWindow: Schema.optional(Schema.String), }); @@ -74,7 +81,9 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record Date: Thu, 16 Apr 2026 23:43:59 +0530 Subject: [PATCH 02/36] fix(web): prevent composer controls overlap on narrow windows (make plan sidebar responsive) (#1198) --- apps/web/src/components/ChatView.tsx | 34 +++++++++++++---- apps/web/src/components/PlanSidebar.tsx | 11 +++++- apps/web/src/components/RightPanelSheet.tsx | 30 +++++++++++++++ apps/web/src/rightPanelLayout.ts | 2 + .../routes/_chat.$environmentId.$threadId.tsx | 38 +++---------------- 5 files changed, 75 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/RightPanelSheet.tsx create mode 100644 apps/web/src/rightPanelLayout.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46061915bf5f..ee69ae2063c6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -92,6 +92,8 @@ import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { useMediaQuery } from "../hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; @@ -171,6 +173,7 @@ import { } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; +import { RightPanelSheet } from "./RightPanelSheet"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -675,6 +678,7 @@ export default function ChatView(props: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -1897,6 +1901,11 @@ export default function ChatView(props: ChatViewProps) { return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + const closePlanSidebar = useCallback(() => { + setPlanSidebarOpen(false); + planSidebarDismissedForTurnRef.current = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -3394,7 +3403,7 @@ export default function ChatView(props: ChatViewProps) { {/* end chat column */} {/* Plan sidebar */} - {planSidebarOpen ? ( + {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( { - setPlanSidebarOpen(false); - // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - planSidebarDismissedForTurnRef.current = - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - }} + mode="sidebar" + onClose={closePlanSidebar} /> ) : null} @@ -3430,6 +3435,21 @@ export default function ChatView(props: ChatViewProps) { onAddTerminalContext={addTerminalContextToDraft} /> ))} + {shouldUsePlanSidebarSheet ? ( + + + + ) : null} {expandedImage && ( diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 489e38f48d98..00b9da2b0c87 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -59,6 +59,7 @@ interface PlanSidebarProps { markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; + mode?: "sheet" | "sidebar"; onClose: () => void; } @@ -70,6 +71,7 @@ const PlanSidebar = memo(function PlanSidebar({ markdownCwd, workspaceRoot, timestampFormat, + mode = "sidebar", onClose, }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); @@ -123,7 +125,14 @@ const PlanSidebar = memo(function PlanSidebar({ }, [environmentId, planMarkdown, workspaceRoot]); return ( -
+
{/* Header */}
diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx new file mode 100644 index 000000000000..ebc4aa0a698f --- /dev/null +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -0,0 +1,30 @@ +import { type ReactNode } from "react"; + +import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout"; +import { Sheet, SheetPopup } from "./ui/sheet"; + +export function RightPanelSheet(props: { + children: ReactNode; + open: boolean; + onClose: () => void; +}) { + return ( + { + if (!open) { + props.onClose(); + } + }} + > + + {props.children} + + + ); +} diff --git a/apps/web/src/rightPanelLayout.ts b/apps/web/src/rightPanelLayout.ts new file mode 100644 index 000000000000..c94f52a9cb21 --- /dev/null +++ b/apps/web/src/rightPanelLayout.ts @@ -0,0 +1,2 @@ +export const RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; +export const RIGHT_PANEL_SHEET_CLASS_NAME = "w-[min(88vw,820px)] max-w-[820px] p-0"; diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index fa3f59b93f33..ff20673e2deb 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,5 +1,5 @@ import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; @@ -17,45 +17,19 @@ import { stripDiffSearchParams, } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; -import { Sheet, SheetPopup } from "../components/ui/sheet"; +import { RightPanelSheet } from "../components/RightPanelSheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; const DiffPanel = lazy(() => import("../components/DiffPanel")); -const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; -const DiffPanelSheet = (props: { - children: ReactNode; - diffOpen: boolean; - onCloseDiff: () => void; -}) => { - return ( - { - if (!open) { - props.onCloseDiff(); - } - }} - > - - {props.children} - - - ); -}; - const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { return ( }> @@ -192,7 +166,7 @@ function ChatThreadRouteView() { const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; - const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); + const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ threadKey: currentThreadKey, @@ -293,9 +267,9 @@ function ChatThreadRouteView() { routeKind="server" /> - + {shouldRenderDiffContent ? : null} - + ); } From 7a08fcf2e832da5969aeb373c46d5249b4e820b7 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:31:35 +0530 Subject: [PATCH 03/36] fix(server): drop stale text generation options when resetting text-gen model selection (#2076) --- apps/server/src/serverSettings.test.ts | 29 +++++++++++ apps/server/src/serverSettings.ts | 20 +++++++- apps/web/src/hooks/useSettings.ts | 4 +- packages/shared/src/serverSettings.test.ts | 59 ++++++++++++++++++++++ packages/shared/src/serverSettings.ts | 34 ++++++++++++- 5 files changed, 141 insertions(+), 5 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d8a992f0ec3c..26479d61bd61 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -142,6 +142,35 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("drops stale text generation options when resetting model selection", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + yield* serverSettings.updateSettings({ + textGenerationModelSelection: { + provider: "codex", + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + options: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + + const next = yield* serverSettings.updateSettings({ + textGenerationModelSelection: { + provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("trims provider path settings when updates are applied", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5a708d5c2302..bdb1d5e0efc4 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -42,6 +42,7 @@ import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; export interface ServerSettingsShape { /** Start the settings runtime and attach file watching. */ @@ -80,7 +81,20 @@ export class ServerSettingsService extends Context.Service< getSettings: Ref.get(currentSettingsRef), updateSettings: (patch) => Ref.get(currentSettingsRef).pipe( - Effect.map((currentSettings) => deepMerge(currentSettings, patch)), + Effect.flatMap((currentSettings) => + Schema.decodeEffect(ServerSettings)( + applyServerSettingsPatch(currentSettings, patch), + ).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath: "", + detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ), + ), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), ), streamChanges: Stream.empty, @@ -314,7 +328,9 @@ const makeServerSettings = Effect.gen(function* () { writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( + const next = yield* Schema.decodeEffect(ServerSettings)( + applyServerSettingsPatch(current, patch), + ).pipe( Effect.mapError( (cause) => new ServerSettingsError({ diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3dc2cf9b2011..664d5ee3bb01 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -19,7 +19,7 @@ import { } from "@t3tools/contracts/settings"; import { ensureLocalApi } from "~/localApi"; import { Struct } from "effect"; -import { deepMerge } from "@t3tools/shared/Struct"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -154,7 +154,7 @@ export function useUpdateSettings() { if (Object.keys(serverPatch).length > 0) { const currentServerConfig = getServerConfig(); if (currentServerConfig) { - applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); + applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); } // Fire-and-forget RPC — push will reconcile on success void ensureLocalApi().server.updateSettings(serverPatch); diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 0ac5e415dff0..3d4a0da0bb10 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,7 @@ +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { + applyServerSettingsPatch, extractPersistedServerObservabilitySettings, normalizePersistedServerSettingString, parsePersistedServerObservabilitySettings, @@ -50,4 +52,61 @@ describe("serverSettings helpers", () => { otlpMetricsUrl: undefined, }); }); + + it("replaces text generation selection when provider/model are provided", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { + provider: "codex" as const, + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high" as const, + fastMode: true, + }, + }, + }; + + expect( + applyServerSettingsPatch(current, { + textGenerationModelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, + }).textGenerationModelSelection, + ).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + }); + }); + + it("still deep merges text generation selection when only options are provided", () => { + const current = { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { + provider: "codex" as const, + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high" as const, + fastMode: true, + }, + }, + }; + + expect( + applyServerSettingsPatch(current, { + textGenerationModelSelection: { + options: { + fastMode: false, + }, + }, + }).textGenerationModelSelection, + ).toEqual({ + provider: "codex", + model: "gpt-5.4-mini", + options: { + reasoningEffort: "high", + fastMode: false, + }, + }); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e7b25606dc12..db9bdcc591e5 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,5 +1,6 @@ -import { ServerSettings } from "@t3tools/contracts"; +import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; import { Schema } from "effect"; +import { deepMerge } from "./Struct"; import { fromLenientJson } from "./schemaJson"; const ServerSettingsJson = fromLenientJson(ServerSettings); @@ -38,3 +39,34 @@ export function parsePersistedServerObservabilitySettings( return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } } + +function shouldReplaceTextGenerationModelSelection( + patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, +): boolean { + return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); +} + +/** + * Applies a server settings patch while treating textGenerationModelSelection as + * replace-on-provider/model updates. This prevents stale nested options from + * surviving a reset patch that intentionally omits options. + */ +export function applyServerSettingsPatch( + current: ServerSettings, + patch: ServerSettingsPatch, +): ServerSettings { + const selectionPatch = patch.textGenerationModelSelection; + const next = deepMerge(current, patch); + if (!selectionPatch || !shouldReplaceTextGenerationModelSelection(selectionPatch)) { + return next; + } + + return { + ...next, + textGenerationModelSelection: { + provider: selectionPatch.provider ?? current.textGenerationModelSelection.provider, + model: selectionPatch.model ?? current.textGenerationModelSelection.model, + ...(selectionPatch.options ? { options: selectionPatch.options } : {}), + }, + }; +} From 188a40c315bceaeec6237f05ae253941ad6bb4dc Mon Sep 17 00:00:00 2001 From: Tristan <121109260+tlh38@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:29:25 -0400 Subject: [PATCH 04/36] feat: configurable project grouping (#2055) Co-authored-by: tlh38 Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/desktop/src/clientPersistence.test.ts | 4 + apps/desktop/src/main.ts | 85 +- .../Layers/RepositoryIdentityResolver.test.ts | 28 + .../Layers/RepositoryIdentityResolver.ts | 4 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 8 +- .../src/provider/Layers/ClaudeAdapter.ts | 11 +- apps/web/src/components/ChatView.browser.tsx | 29 +- apps/web/src/components/ChatView.tsx | 19 +- apps/web/src/components/Sidebar.tsx | 807 +++++++++++++----- apps/web/src/contextMenuFallback.test.ts | 221 +++++ apps/web/src/contextMenuFallback.ts | 162 +++- apps/web/src/environmentGrouping.test.ts | 169 +++- apps/web/src/environments/runtime/service.ts | 7 +- apps/web/src/hooks/useHandleNewThread.ts | 11 +- apps/web/src/localApi.test.ts | 59 +- apps/web/src/logicalProject.ts | 148 +++- apps/web/src/routes/__root.tsx | 30 +- apps/web/src/sidebarProjectGrouping.ts | 118 +++ packages/contracts/src/environment.ts | 1 + packages/contracts/src/ipc.ts | 1 + packages/contracts/src/settings.ts | 15 + 21 files changed, 1592 insertions(+), 345 deletions(-) create mode 100644 apps/web/src/contextMenuFallback.test.ts create mode 100644 apps/web/src/sidebarProjectGrouping.ts diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index df2178c0b0dd..fa263b18ff1b 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -52,6 +52,10 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "environment-1:/tmp/project-a": "separate", + }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4386c340f9ca..6bdce564b4fb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -160,6 +160,35 @@ const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linu const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { + const normalizedItems: ContextMenuItem[] = []; + + for (const sourceItem of source) { + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { + continue; + } + + const normalizedItem: ContextMenuItem = { + id: sourceItem.id, + label: sourceItem.label, + destructive: sourceItem.destructive === true, + disabled: sourceItem.disabled === true, + }; + + if (sourceItem.children) { + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); + if (normalizedChildren.length === 0) { + continue; + } + normalizedItem.children = normalizedChildren; + } + + normalizedItems.push(normalizedItem); + } + + return normalizedItems; +} + type WindowTitleBarOptions = Pick< BrowserWindowConstructorOptions, "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" @@ -1715,14 +1744,7 @@ function registerIpcHandlers(): void { ipcMain.handle( CONTEXT_MENU_CHANNEL, async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = items - .filter((item) => typeof item.id === "string" && typeof item.label === "string") - .map((item) => ({ - id: item.id, - label: item.label, - destructive: item.destructive === true, - disabled: item.disabled === true, - })); + const normalizedItems = normalizeContextMenuItems(items); if (normalizedItems.length === 0) { return null; } @@ -1743,28 +1765,37 @@ function registerIpcHandlers(): void { if (!window) return null; return new Promise((resolve) => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of normalizedItems) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - click: () => resolve(item.id), - }; - if (item.destructive) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; + const buildTemplate = ( + entries: readonly ContextMenuItem[], + ): MenuItemConstructorOptions[] => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + const itemOption: MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children); + } else { + itemOption.click = () => resolve(item.id); } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (destructiveIcon) { + itemOption.icon = destructiveIcon; + } + } + template.push(itemOption); } - template.push(itemOption); - } + return template; + }; - const menu = Menu.buildFromTemplate(template); + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); menu.popup({ window, ...popupPosition, diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 57f4464804d9..c6ab7b860b5c 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -1,3 +1,5 @@ +import { realpathSync } from "node:fs"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { Duration, Effect, FileSystem, Layer } from "effect"; @@ -10,6 +12,10 @@ import { RepositoryIdentityResolverLive, } from "./RepositoryIdentityResolver.ts"; +const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); +const normalizeResolvedPath = (value: string) => + normalizePathSeparators(realpathSync.native(value)); + const git = (cwd: string, args: ReadonlyArray) => Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); @@ -41,6 +47,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity).not.toBeNull(); expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(cwd)); expect(identity?.displayName).toBe("t3tools/t3code"); expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); @@ -48,6 +55,27 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolverLive)), ); + it.effect("returns the git top-level root path when resolving from a nested workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const repoRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-root-test-", + }); + const nestedWorkspace = `${repoRoot}/packages/web`; + + yield* fileSystem.makeDirectory(nestedWorkspace, { recursive: true }); + yield* git(repoRoot, ["init"]); + yield* git(repoRoot, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(nestedWorkspace); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(normalizeResolvedPath(identity?.rootPath ?? "")).toBe(normalizeResolvedPath(repoRoot)); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + it.effect("returns null for non-git folders and repos without remotes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 531737ec66c4..e439fa19a66a 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -42,6 +42,7 @@ function pickPrimaryRemote( function buildRepositoryIdentity(input: { readonly remoteName: string; readonly remoteUrl: string; + readonly rootPath: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); @@ -57,6 +58,7 @@ function buildRepositoryIdentity(input: { remoteName: input.remoteName, remoteUrl: input.remoteUrl, }, + rootPath: input.rootPath, ...(repositoryPath ? { displayName: repositoryPath } : {}), ...(hostingProvider ? { provider: hostingProvider.kind } : {}), ...(owner ? { owner } : {}), @@ -108,7 +110,7 @@ async function resolveRepositoryIdentityFromCacheKey( } const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); - return remote ? buildRepositoryIdentity(remote) : null; + return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null; } catch { return null; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 6f0d4a352f5b..1fe080bdaf74 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -351,7 +351,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("defaults Claude Opus 4.7 sessions to xhigh effort", () => { + it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -366,14 +366,14 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); + assert.equal(createInput?.options.effort, "max"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), ); }); - it.effect("forwards xhigh effort for Claude Opus 4.7", () => { + it.effect("maps xhigh effort for Claude Opus 4.7 to the SDK-supported max value", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -391,7 +391,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, "xhigh"); + assert.equal(createInput?.options.effort, "max"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index feacfa99ea25..2b3a9faeea07 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -84,6 +84,7 @@ type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, "command_output" | "file_change_output" >; +type ClaudeSdkEffort = NonNullable; type PromptQueueItem = | { @@ -217,11 +218,17 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause): ReadonlyArray function getEffectiveClaudeAgentEffort( effort: ClaudeAgentEffort | null | undefined, -): Exclude | null { +): ClaudeSdkEffort | null { if (!effort) { return null; } - return effort === "ultrathink" ? null : effort; + if (effort === "ultrathink") { + return null; + } + if (effort === "xhigh") { + return "max"; + } + return effort; } function isClaudeInterruptedMessage(message: string): boolean { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1774a15e3ec6..41f627332e3a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -17,12 +17,7 @@ import { OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; -import { - scopedProjectKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -52,6 +47,7 @@ import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -78,7 +74,18 @@ const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; -const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); +const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( + { + environmentId: LOCAL_ENVIRONMENT_ID, + id: PROJECT_ID, + cwd: "/repo/project", + repositoryIdentity: null, + }, + { + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingOverrides, + }, +); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -1638,12 +1645,12 @@ describe("ChatView timeline estimator parity (full app)", () => { customWsRpcResolver = null; document.body.innerHTML = ""; }); - it("re-expands the bootstrap project using its scoped key", async () => { + it("re-expands the bootstrap project using its logical key", async () => { useUiStateStore.setState({ projectExpandedById: { - [PROJECT_KEY]: false, + [PROJECT_LOGICAL_KEY]: false, }, - projectOrder: [PROJECT_KEY], + projectOrder: [PROJECT_LOGICAL_KEY], threadLastVisitedAtById: {}, }); @@ -1658,7 +1665,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { await vi.waitFor( () => { - expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); + expect(useUiStateStore.getState().projectExpandedById[PROJECT_LOGICAL_KEY]).toBe(true); }, { timeout: 8_000, interval: 16 }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ee69ae2063c6..76431368f305 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -113,7 +113,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, @@ -847,10 +847,16 @@ export default function ChatView(props: ChatViewProps) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; - const logicalKey = deriveLogicalProjectKey(activeProject); - const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey); + const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const memberProjects = allProjects.filter( + (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, + ); const seen = new Set(); const envs: Array<{ environmentId: EnvironmentId; @@ -886,6 +892,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, allProjects, + projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -915,7 +922,10 @@ export default function ChatView(props: ChatViewProps) { throw new Error("No active project is available for this pull request."); } const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKey(activeProject); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); if (storedDraftSession) { setDraftThreadContext(storedDraftSession.draftId, input); @@ -976,6 +986,7 @@ export default function ChatView(props: ChatViewProps) { getDraftSessionByLogicalProjectKey, isServerThread, navigate, + projectGroupingSettings, routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cff71cf62acc..1c66af57be04 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -31,10 +31,11 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { + type ContextMenuItem, type DesktopUpdateState, ProjectId, - type ScopedProjectRef, type ScopedThreadRef, + type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, type GitStatusResult, @@ -59,7 +60,6 @@ import { isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRef, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, @@ -102,7 +102,26 @@ import { } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -142,12 +161,18 @@ import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import type { Project, SidebarThreadSummary } from "../types"; +import type { SidebarThreadSummary } from "../types"; +import { + buildPhysicalToLogicalProjectKeyMap, + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -163,6 +188,11 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; function threadJumpLabelMapsEqual( left: ReadonlyMap, @@ -182,6 +212,28 @@ function threadJumpLabelMapsEqual( return true; } +function formatProjectMemberActionLabel( + member: SidebarProjectGroupMember, + groupedProjectCount: number, +): string { + if (groupedProjectCount <= 1) { + return member.name; + } + + return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; +} + +function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { + switch (mode) { + case "repository": + return "Projects from the same repository share one sidebar row."; + case "repository_path": + return "Projects group only when both the repository and repo-relative path match."; + case "separate": + return "Every project path gets its own sidebar row."; + } +} + function buildThreadJumpLabelMap(input: { keybindings: ReturnType; platform: string; @@ -212,15 +264,6 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } -type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; - -type SidebarProjectSnapshot = Project & { - projectKey: string; - environmentPresence: EnvironmentPresence; - memberProjectRefs: readonly ScopedProjectRef[]; - /** Labels for remote environments this project lives in. */ - remoteEnvironmentLabels: readonly string[]; -}; interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; @@ -996,6 +1039,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const defaultThreadEnvMode = useSettings( (settings) => settings.defaultThreadEnvMode, ); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); + const { updateSettings } = useUpdateSettings(); const router = useRouter(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const toggleProject = useUiStateStore((state) => state.toggleProject); @@ -1073,58 +1121,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec useShallow( useMemo( () => (state: import("../store").AppState) => - selectSidebarThreadsForProjectRef( - state, - scopeProjectRef(project.environmentId, project.id), - ), - [project.environmentId, project.id], - ), - ), - ); - // For grouped projects that span multiple environments, also fetch - // threads from the other member project refs. - const otherMemberRefs = useMemo( - () => - project.memberProjectRefs.filter( - (ref) => ref.environmentId !== project.environmentId || ref.projectId !== project.id, - ), - [project.memberProjectRefs, project.environmentId, project.id], - ); - const otherMemberThreads = useStore( - useShallow( - useMemo( - () => - otherMemberRefs.length === 0 - ? () => [] as SidebarThreadSummary[] - : (state: import("../store").AppState) => - selectSidebarThreadsForProjectRefs(state, otherMemberRefs), - [otherMemberRefs], + selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs), + [project.memberProjectRefs], ), ), ); - const allSidebarThreads = useMemo( - () => - otherMemberThreads.length === 0 ? sidebarThreads : [...sidebarThreads, ...otherMemberThreads], - [sidebarThreads, otherMemberThreads], - ); const sidebarThreadByKey = useMemo( () => new Map( - allSidebarThreads.map( + sidebarThreads.map( (thread) => [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, ), ), - [allSidebarThreads], + [sidebarThreads], ); // Keep a ref so callbacks can read the latest map without appearing in // dependency arrays (avoids invalidating every thread-row memo on each // thread-list change). const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; - // All threads from the representative + other member environments are - // already fetched into allSidebarThreads, so we can use them directly. - const projectThreads = allSidebarThreads; + const projectThreads = sidebarThreads; const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); @@ -1141,9 +1158,43 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); + const [projectRenameTarget, setProjectRenameTarget] = useState( + null, + ); + const [projectRenameTitle, setProjectRenameTitle] = useState(""); + const [projectGroupingTarget, setProjectGroupingTarget] = + useState(null); + const [projectGroupingSelection, setProjectGroupingSelection] = useState< + SidebarProjectGroupingMode | "inherit" + >("inherit"); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); + const memberProjectByScopedKey = useMemo( + () => + new Map( + project.memberProjects.map((member) => [ + scopedProjectKey(scopeProjectRef(member.environmentId, member.id)), + member, + ]), + ), + [project.memberProjects], + ); + const memberThreadCountByPhysicalKey = useMemo(() => { + const counts = new Map( + project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const), + ); + for (const thread of projectThreads) { + const member = memberProjectByScopedKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + if (!member) { + continue; + } + counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1); + } + return counts; + }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( @@ -1318,6 +1369,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef], ); + const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { + setProjectRenameTarget(member); + setProjectRenameTitle(member.name); + }, []); + + const openProjectGroupingDialog = useCallback( + (member: SidebarProjectGroupMember) => { + const overrideKey = deriveProjectGroupingOverrideKey(member); + setProjectGroupingTarget(member); + setProjectGroupingSelection( + projectGroupingSettings.sidebarProjectGroupingOverrides[overrideKey] ?? "inherit", + ); + }, + [projectGroupingSettings.sidebarProjectGroupingOverrides], + ); + + const handleRemoveProject = useCallback( + async (member: SidebarProjectGroupMember) => { + const api = readLocalApi(); + if (!api) { + return; + } + + if ((memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0) { + toastManager.add({ + type: "warning", + title: "Project is not empty", + description: "Delete all threads in this project before removing it.", + }); + return; + } + + const message = [ + `Remove project "${member.name}"?`, + `Path: ${member.cwd}`, + ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), + "This removes only this project entry.", + ].join("\n"); + const confirmed = await api.dialogs.confirm(message); + if (!confirmed) { + return; + } + + const memberProjectRef = scopeProjectRef(member.environmentId, member.id); + + try { + const projectDraftThread = getDraftThreadByProjectRef(memberProjectRef); + if (projectDraftThread) { + clearComposerDraftForThread(projectDraftThread.draftId); + } + clearProjectDraftThreadId(memberProjectRef); + const projectApi = readEnvironmentApi(member.environmentId); + if (!projectApi) { + throw new Error("Project API unavailable."); + } + await projectApi.orchestration.dispatchCommand({ + type: "project.delete", + commandId: newCommandId(), + projectId: member.id, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error removing project."; + console.error("Failed to remove project", { + projectId: member.id, + environmentId: member.environmentId, + error, + }); + toastManager.add({ + type: "error", + title: `Failed to remove "${member.name}"`, + description: message, + }); + } + }, + [ + clearComposerDraftForThread, + clearProjectDraftThreadId, + getDraftThreadByProjectRef, + memberThreadCountByPhysicalKey, + ], + ); + const handleProjectButtonContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -1326,73 +1459,103 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const api = readLocalApi(); if (!api) return; + const actionHandlers = new Map Promise | void>(); + const makeLeaf = ( + action: "rename" | "grouping" | "copy-path" | "delete", + member: SidebarProjectGroupMember, + options?: { + destructive?: boolean; + disabled?: boolean; + }, + ): ContextMenuItem => { + const id = `${action}:${member.physicalProjectKey}`; + actionHandlers.set(id, () => { + switch (action) { + case "rename": + openProjectRenameDialog(member); + return; + case "grouping": + openProjectGroupingDialog(member); + return; + case "copy-path": + copyPathToClipboard(member.cwd, { path: member.cwd }); + return; + case "delete": + return handleRemoveProject(member); + } + }); + + return { + id, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.disabled ? { disabled: true } : {}), + }; + }; + + const buildTargetedItem = ( + action: "rename" | "grouping" | "copy-path" | "delete", + label: string, + options?: { + destructive?: boolean; + isDisabled?: (member: SidebarProjectGroupMember) => boolean; + }, + ): ContextMenuItem => { + if (project.memberProjects.length === 1) { + const singleMember = project.memberProjects[0]!; + return { + ...makeLeaf(action, singleMember, { + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.isDisabled?.(singleMember) ? { disabled: true } : {}), + }), + label, + }; + } + + return { + id: `${action}:submenu`, + label, + children: project.memberProjects.map((member) => + makeLeaf(action, member, { + ...(options?.destructive ? { destructive: true } : {}), + ...(options?.isDisabled?.(member) ? { disabled: true } : {}), + }), + ), + }; + }; + const clicked = await api.contextMenu.show( [ - { id: "copy-path", label: "Copy Project Path" }, - { id: "delete", label: "Remove project", destructive: true }, + buildTargetedItem("rename", "Rename project"), + buildTargetedItem("grouping", "Project grouping…"), + buildTargetedItem("copy-path", "Copy Project Path"), + buildTargetedItem("delete", "Remove project", { + destructive: true, + isDisabled: (member) => + (memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0) > 0, + }), ], { x: event.clientX, y: event.clientY, }, ); - if (clicked === "copy-path") { - copyPathToClipboard(project.cwd, { path: project.cwd }); - return; - } - if (clicked !== "delete") return; - if (projectThreads.length > 0) { - toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", - }); + if (!clicked) { return; } - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; - - try { - const projectDraftThread = getDraftThreadByProjectRef( - scopeProjectRef(project.environmentId, project.id), - ); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.draftId); - } - clearProjectDraftThreadId(scopeProjectRef(project.environmentId, project.id)); - const projectApi = readEnvironmentApi(project.environmentId); - if (!projectApi) { - throw new Error("Project API unavailable."); - } - await projectApi.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId: project.id, - }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { projectId: project.id, error }); - toastManager.add({ - type: "error", - title: `Failed to remove "${project.name}"`, - description: message, - }); - } + await actionHandlers.get(clicked)?.(); })(); }, [ - clearComposerDraftForThread, - clearProjectDraftThreadId, copyPathToClipboard, - getDraftThreadByProjectRef, - project.cwd, - project.environmentId, - project.id, - project.name, - projectThreads.length, + handleRemoveProject, + memberThreadCountByPhysicalKey, + openProjectGroupingDialog, + openProjectRenameDialog, + project.groupedProjectCount, + project.memberProjects, suppressProjectClickForContextMenuRef, ], ); @@ -1503,10 +1666,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); - const handleCreateThreadClick = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); + const createThreadForProjectMember = useCallback( + (member: SidebarProjectGroupMember) => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); @@ -1522,12 +1683,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? (draftStore.getDraftSession(currentRouteTarget.draftId) ?? null) : null; const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: project.id, + projectId: member.id, defaultEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: defaultThreadEnvMode, }), activeThread: - currentActiveThread && currentActiveThread.projectId === project.id + currentActiveThread && currentActiveThread.projectId === member.id ? { projectId: currentActiveThread.projectId, branch: currentActiveThread.branch, @@ -1535,7 +1696,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } : null, activeDraftThread: - currentActiveDraftThread && currentActiveDraftThread.projectId === project.id + currentActiveDraftThread && currentActiveDraftThread.projectId === member.id ? { projectId: currentActiveDraftThread.projectId, branch: currentActiveDraftThread.branch, @@ -1544,7 +1705,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } : null, }); - void handleNewThread(scopeProjectRef(project.environmentId, project.id), { + void handleNewThread(scopeProjectRef(member.environmentId, member.id), { ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), ...(seedContext.worktreePath !== undefined ? { worktreePath: seedContext.worktreePath } @@ -1552,7 +1713,47 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec envMode: seedContext.envMode, }); }, - [defaultThreadEnvMode, handleNewThread, project.environmentId, project.id, router], + [defaultThreadEnvMode, handleNewThread, router], + ); + + const handleCreateThreadClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (project.memberProjects.length === 1) { + createThreadForProjectMember(project.memberProjects[0]!); + return; + } + + void (async () => { + const api = readLocalApi(); + if (!api) { + return; + } + const clicked = await api.contextMenu.show( + project.memberProjects.map((member) => ({ + id: member.physicalProjectKey, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + })), + { + x: event.clientX, + y: event.clientY, + }, + ); + if (!clicked) { + return; + } + const targetMember = project.memberProjects.find( + (member) => member.physicalProjectKey === clicked, + ); + if (!targetMember) { + return; + } + createThreadForProjectMember(targetMember); + })(); + }, + [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects], ); const attemptArchiveThread = useCallback( @@ -1623,6 +1824,88 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [], ); + const closeProjectRenameDialog = useCallback(() => { + setProjectRenameTarget(null); + setProjectRenameTitle(""); + }, []); + + const submitProjectRename = useCallback(async () => { + if (!projectRenameTarget) { + return; + } + + const trimmed = projectRenameTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Project title cannot be empty", + }); + return; + } + + if (trimmed === projectRenameTarget.name) { + closeProjectRenameDialog(); + return; + } + + const api = readEnvironmentApi(projectRenameTarget.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: "Project API unavailable.", + }); + return; + } + + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: projectRenameTarget.id, + title: trimmed, + }); + closeProjectRenameDialog(); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + + const closeProjectGroupingDialog = useCallback(() => { + setProjectGroupingTarget(null); + setProjectGroupingSelection("inherit"); + }, []); + + const saveProjectGroupingPreference = useCallback(() => { + if (!projectGroupingTarget) { + return; + } + + const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget); + const nextOverrides = { + ...projectGroupingSettings.sidebarProjectGroupingOverrides, + }; + if (projectGroupingSelection === "inherit") { + delete nextOverrides[overrideKey]; + } else { + nextOverrides[overrideKey] = projectGroupingSelection; + } + updateSettings({ + sidebarProjectGroupingOverrides: nextOverrides, + }); + closeProjectGroupingDialog(); + }, [ + closeProjectGroupingDialog, + projectGroupingSelection, + projectGroupingSettings.sidebarProjectGroupingOverrides, + projectGroupingTarget, + updateSettings, + ]); + const handleThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { const api = readLocalApi(); @@ -1630,7 +1913,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadKey = scopedThreadKey(threadRef); const thread = sidebarThreadByKeyRef.current.get(threadKey) ?? null; if (!thread) return; - const threadWorkspacePath = thread.worktreePath ?? project.cwd ?? null; + const threadProject = memberProjectByScopedKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, @@ -1689,6 +1975,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, markThreadUnread, + memberProjectByScopedKey, project.cwd, ], ); @@ -1732,8 +2019,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec /> )} - - {project.name} + + + {project.displayName} + + {project.groupedProjectCount > 1 ? ( + + {project.groupedProjectCount} projects + + ) : null} {/* Environment badge – visible by default, crossfades with the @@ -1766,7 +2060,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
+ + + + + + { + if (!open) { + closeProjectGroupingDialog(); + } + }} + > + + + Project grouping + + {projectGroupingTarget + ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` + : "Choose how this project should be grouped in the sidebar."} + + + +
+ Grouping rule + +
+

+ {projectGroupingSelection === "inherit" + ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) + : projectGroupingModeDescription(projectGroupingSelection)} +

+
+ + + + +
+
); }); @@ -1853,13 +2265,17 @@ type SortableProjectHandleProps = Pick< function ProjectSortMenu({ projectSortOrder, threadSortOrder, + projectGroupingMode, onProjectSortOrderChange, onThreadSortOrderChange, + onProjectGroupingModeChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; }) { return ( @@ -1912,6 +2328,30 @@ function ProjectSortMenu({ ))} + + +
+ Group projects +
+ { + if (value === "repository" || value === "repository_path" || value === "separate") { + onProjectGroupingModeChange(value); + } + }} + > + {( + Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array< + [SidebarProjectGroupingMode, string] + > + ).map(([value, label]) => ( + + {label} + + ))} + +
); @@ -2029,6 +2469,7 @@ interface SidebarProjectsContentProps { handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + projectGroupingMode: SidebarProjectGroupingMode; updateSettings: ReturnType["updateSettings"]; openAddProject: () => void; isManualProjectSorting: boolean; @@ -2068,6 +2509,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, + projectGroupingMode, updateSettings, openAddProject, isManualProjectSorting, @@ -2108,6 +2550,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleProjectGroupingModeChange = useCallback( + (groupingMode: SidebarProjectGroupingMode) => { + updateSettings({ sidebarProjectGroupingMode: groupingMode }); + }, + [updateSettings], + ); return ( @@ -2166,8 +2614,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const sidebarProjectGroupingMode = useSettings((s) => s.sidebarProjectGroupingMode); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2319,79 +2774,36 @@ export default function Sidebar() { // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. const physicalToLogicalKey = useMemo(() => { - const mapping = new Map(); - for (const project of orderedProjects) { - const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); - mapping.set(physicalKey, deriveLogicalProjectKey(project)); - } - return mapping; - }, [orderedProjects]); + return buildPhysicalToLogicalProjectKeyMap({ + projects: orderedProjects, + settings: projectGroupingSettings, + }); + }, [orderedProjects, projectGroupingSettings]); + const projectPhysicalKeyByScopedRef = useMemo( + () => + new Map( + orderedProjects.map((project) => [ + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + derivePhysicalProjectKey(project), + ]), + ), + [orderedProjects], + ); const sidebarProjects = useMemo(() => { - // Group projects by logical key while preserving insertion order from - // orderedProjects. - const groupedMembers = new Map(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - const existing = groupedMembers.get(logicalKey); - if (existing) { - existing.push(project); - } else { - groupedMembers.set(logicalKey, [project]); - } - } - - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of orderedProjects) { - const logicalKey = deriveLogicalProjectKey(project); - if (seen.has(logicalKey)) continue; - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey)!; - // Prefer the primary environment's project as the representative. - const representative: Project | undefined = - (primaryEnvironmentId - ? members.find((p) => p.environmentId === primaryEnvironmentId) - : undefined) ?? members[0]; - if (!representative) continue; - const hasLocal = - primaryEnvironmentId !== null && - members.some((p) => p.environmentId === primaryEnvironmentId); - const hasRemote = - primaryEnvironmentId !== null - ? members.some((p) => p.environmentId !== primaryEnvironmentId) - : false; - - const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); - const remoteLabels = members - .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) - .map((p) => { - const rt = savedEnvironmentRuntimeById[p.environmentId]; - const saved = savedEnvironmentRegistry[p.environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; - }); - const snapshot: SidebarProjectSnapshot = { - id: representative.id, - environmentId: representative.environmentId, - name: representative.name, - cwd: representative.cwd, - repositoryIdentity: representative.repositoryIdentity ?? null, - defaultModelSelection: representative.defaultModelSelection, - createdAt: representative.createdAt, - updatedAt: representative.updatedAt, - scripts: representative.scripts, - projectKey: logicalKey, - environmentPresence: - hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", - memberProjectRefs: refs, - remoteEnvironmentLabels: remoteLabels, - }; - result.push(snapshot); - } - return result; + return buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => { + const rt = savedEnvironmentRuntimeById[environmentId]; + const saved = savedEnvironmentRegistry[environmentId]; + return rt?.descriptor?.label ?? saved?.label ?? null; + }, + }); }, [ orderedProjects, + projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, @@ -2419,18 +2831,22 @@ export default function Sidebar() { } const activeThread = sidebarThreadByKey.get(routeThreadKey); if (!activeThread) return null; - const physicalKey = scopedProjectKey( - scopeProjectRef(activeThread.environmentId, activeThread.projectId), - ); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)); return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); // Group threads by logical project key so all threads from grouped projects // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreads) { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const existing = next.get(logicalKey); if (existing) { @@ -2440,7 +2856,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey]); + }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -2507,8 +2923,10 @@ export default function Sidebar() { const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjectRefs.map(scopedProjectKey); - const overMemberKeys = overProject.memberProjectRefs.map(scopedProjectKey); + const activeMemberKeys = activeProject.memberProjects.map( + (member) => member.physicalProjectKey, + ); + const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); reorderProjects(activeMemberKeys, overMemberKeys); }, [sidebarProjectSortOrder, reorderProjects, sidebarProjects], @@ -2557,7 +2975,10 @@ export default function Sidebar() { id: project.projectKey, })); const sortableThreads = visibleThreads.map((thread) => { - const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const physicalKey = + projectPhysicalKeyByScopedRef.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); return { ...thread, projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, @@ -2574,6 +2995,7 @@ export default function Sidebar() { }, [ sidebarProjectSortOrder, physicalToLogicalKey, + projectPhysicalKeyByScopedRef, sidebarProjectByKey, sidebarProjects, visibleThreads, @@ -2978,6 +3400,7 @@ export default function Sidebar() { handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} + projectGroupingMode={sidebarProjectGroupingMode} updateSettings={updateSettings} openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts new file mode 100644 index 000000000000..598d0d8bbeda --- /dev/null +++ b/apps/web/src/contextMenuFallback.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { showContextMenuFallback } from "./contextMenuFallback"; + +type FakeListener = (event: FakeDomEvent) => void; + +class FakeDomEvent { + defaultPrevented = false; + + constructor( + readonly type: string, + init: Record = {}, + ) { + Object.assign(this, init); + } + + preventDefault() { + this.defaultPrevented = true; + } +} + +class FakeElement { + children: FakeElement[] = []; + parent: FakeElement | null = null; + style: Record & { cssText?: string } = {}; + dataset: Record = {}; + className = ""; + disabled = false; + type = ""; + private textValue = ""; + private readonly listeners = new Map(); + + constructor(readonly tagName: string) {} + + appendChild(child: FakeElement) { + child.parent = this; + this.children.push(child); + return child; + } + + remove() { + if (!this.parent) { + return; + } + const index = this.parent.children.indexOf(this); + if (index >= 0) { + this.parent.children.splice(index, 1); + } + this.parent = null; + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + dispatchEvent(event: FakeDomEvent) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + + set textContent(value: string) { + this.textValue = value; + } + + get textContent() { + return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`; + } + + querySelectorAll(tagName: string): FakeElement[] { + const matches: FakeElement[] = []; + if (this.tagName === tagName) { + matches.push(this); + } + for (const child of this.children) { + matches.push(...child.querySelectorAll(tagName)); + } + return matches; + } + + getBoundingClientRect() { + const left = Number.parseInt(this.style.left ?? "0", 10) || 0; + const top = Number.parseInt(this.style.top ?? "0", 10) || 0; + const width = this.tagName === "div" ? 180 : 140; + const height = this.tagName === "div" ? 120 : 28; + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; + } +} + +class FakeBody extends FakeElement { + private html = ""; + + constructor() { + super("body"); + } + + set innerHTML(value: string) { + this.html = value; + this.children = []; + } + + get innerHTML() { + return this.html; + } +} + +class FakeDocument { + body = new FakeBody(); + private readonly listeners = new Map(); + + createElement(tagName: string) { + return new FakeElement(tagName); + } + + addEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type) ?? []; + existing.push(listener); + this.listeners.set(type, existing); + } + + removeEventListener(type: string, listener: FakeListener) { + const existing = this.listeners.get(type); + if (!existing) { + return; + } + const index = existing.indexOf(listener); + if (index >= 0) { + existing.splice(index, 1); + } + } + + querySelectorAll(tagName: string) { + return this.body.querySelectorAll(tagName); + } +} + +function findButton(label: string): FakeElement | undefined { + return (document as unknown as FakeDocument) + .querySelectorAll("button") + .find((button) => button.textContent.includes(label)); +} + +beforeEach(() => { + vi.stubGlobal("document", new FakeDocument()); + vi.stubGlobal("window", { + innerWidth: 1280, + innerHeight: 800, + }); + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + callback(0); + return 0; + }); + vi.stubGlobal( + "MouseEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); + vi.stubGlobal( + "KeyboardEvent", + class extends FakeDomEvent { + constructor(type: string, init: Record = {}) { + super(type, init); + } + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("showContextMenuFallback", () => { + it("resolves a clicked flat menu item", async () => { + const selectionPromise = showContextMenuFallback([ + { id: "rename", label: "Rename" }, + { id: "delete", label: "Delete", destructive: true }, + ]); + + const renameButton = findButton("Rename"); + expect(renameButton).toBeTruthy(); + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename"); + }); + + it("opens nested submenus and resolves the clicked leaf id", async () => { + const selectionPromise = showContextMenuFallback([ + { + id: "rename:submenu", + label: "Rename project", + children: [ + { id: "rename:project-a", label: "/tmp/project-a" }, + { id: "rename:project-b", label: "/tmp/project-b" }, + ], + }, + ]); + + const parentButton = findButton("Rename project"); + expect(parentButton).toBeTruthy(); + parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + + const childButton = findButton("/tmp/project-b"); + expect(childButton).toBeTruthy(); + childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename:project-b"); + }); +}); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 9fd1a12956e8..cda90df5d116 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -1,9 +1,22 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +function clampMenuPosition(menu: HTMLDivElement, preferredLeft: number, preferredTop: number) { + const rect = menu.getBoundingClientRect(); + const left = Math.min( + Math.max(4, preferredLeft), + Math.max(4, window.innerWidth - rect.width - 4), + ); + const top = Math.min( + Math.max(4, preferredTop), + Math.max(4, window.innerHeight - rect.height - 4), + ); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; +} + /** * Imperative DOM-based context menu for non-Electron environments. - * Shows a positioned dropdown and returns a promise that resolves - * with the clicked item id, or null if dismissed. + * Supports nested submenus and resolves with the clicked leaf item id. */ export function showContextMenuFallback( items: readonly ContextMenuItem[], @@ -13,62 +26,117 @@ export function showContextMenuFallback( const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;z-index:9999"; - const menu = document.createElement("div"); - menu.className = - "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; - - const x = position?.x ?? 0; - const y = position?.y ?? 0; - menu.style.top = `${y}px`; - menu.style.left = `${x}px`; + const menuStack: HTMLDivElement[] = []; - function cleanup(result: T | null) { + const cleanup = (result: T | null) => { document.removeEventListener("keydown", onKeyDown); overlay.remove(); - menu.remove(); + for (const menu of menuStack) { + menu.remove(); + } resolve(result); - } + }; - function onKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); cleanup(null); } - } + }; - overlay.addEventListener("mousedown", () => cleanup(null)); - document.addEventListener("keydown", onKeyDown); - - for (const item of items) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = item.label; - const isDestructiveAction = item.destructive === true || item.id === "delete"; - const isDisabled = item.disabled === true; - btn.disabled = isDisabled; - btn.className = isDisabled - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" - : isDestructiveAction - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" - : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; - if (!isDisabled) { - btn.addEventListener("click", () => cleanup(item.id)); + const closeMenusFromLevel = (level: number) => { + while (menuStack.length > level) { + menuStack.pop()?.remove(); } - menu.appendChild(btn); - } + }; - document.body.appendChild(overlay); - document.body.appendChild(menu); + const openMenu = ( + entries: readonly ContextMenuItem[], + preferredLeft: number, + preferredTop: number, + level: number, + ) => { + closeMenusFromLevel(level); - // Adjust if menu overflows viewport - requestAnimationFrame(() => { - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 4}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 4}px`; + const menu = document.createElement("div"); + menu.className = + "fixed z-[10000] min-w-[160px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95"; + menu.style.left = `${preferredLeft}px`; + menu.style.top = `${preferredTop}px`; + menu.dataset.level = String(level); + + for (const item of entries) { + const button = document.createElement("button"); + button.type = "button"; + const hasChildren = Array.isArray(item.children) && item.children.length > 0; + const isLeafDestructive = + !hasChildren && (item.destructive === true || item.id === ("delete" as T)); + const isDisabled = item.disabled === true; + button.disabled = isDisabled; + button.className = isDisabled + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" + : isLeafDestructive + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" + : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; + + const label = document.createElement("span"); + label.className = "min-w-0 flex-1 truncate"; + label.textContent = item.label; + button.appendChild(label); + + if (hasChildren) { + const chevron = document.createElement("span"); + chevron.className = "shrink-0 text-muted-foreground/70"; + chevron.textContent = "›"; + button.appendChild(chevron); + } + + if (!isDisabled) { + if (hasChildren) { + button.addEventListener("mouseenter", () => { + const rect = button.getBoundingClientRect(); + const nextLeft = rect.right + 4; + const nextTop = rect.top; + openMenu(item.children!, nextLeft, nextTop, level + 1); + + const childMenu = menuStack[level + 1]; + if (!childMenu) { + return; + } + const childRect = childMenu.getBoundingClientRect(); + if (childRect.right > window.innerWidth) { + clampMenuPosition(childMenu, rect.left - childRect.width - 4, rect.top); + } + }); + button.addEventListener("click", (event) => { + event.preventDefault(); + }); + } else { + button.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + button.addEventListener("click", () => cleanup(item.id)); + } + } + + menu.appendChild(button); } - }); + + menu.addEventListener("mouseenter", () => { + closeMenusFromLevel(level + 1); + }); + + document.body.appendChild(menu); + menuStack[level] = menu; + + requestAnimationFrame(() => { + clampMenuPosition(menu, preferredLeft, preferredTop); + }); + }; + + overlay.addEventListener("mousedown", () => cleanup(null)); + document.addEventListener("keydown", onKeyDown); + document.body.appendChild(overlay); + openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); }); } diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 4e8473188a16..9ae26fe8b461 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -10,7 +10,12 @@ import { type AppState, type EnvironmentState, } from "./store"; -import { deriveLogicalProjectKey } from "./logicalProject"; +import { + deriveLogicalProjectKey, + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + resolveProjectGroupingMode, +} from "./logicalProject"; import type { Project, SidebarThreadSummary } from "./types"; import { DEFAULT_INTERACTION_MODE } from "./types"; @@ -31,6 +36,10 @@ const threadL1 = ThreadId.make("thread-local-only-1"); const threadRO1 = ThreadId.make("thread-remote-only-1"); const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; +const DEFAULT_GROUPING_SETTINGS = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, +}; // ── Factory Helpers ────────────────────────────────────────────────── @@ -238,9 +247,7 @@ describe("environment grouping", () => { environmentId: primaryEnvId, name: "local-only", }); - const key = deriveLogicalProjectKey(project); - expect(key).toContain(primaryEnvId); - expect(key).toContain(localOnlyProjectId); + expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project)); }); it("groups projects from different environments that share the same canonical key", () => { @@ -273,6 +280,134 @@ describe("environment grouping", () => { expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); }); + it("groups repo root and nested projects from the same repository by default", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); + expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY); + }); + + it("uses repository path grouping when requested", () => { + const rootProject = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + cwd: "/workspace/repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const nestedProject = makeProject({ + id: localOnlyProjectId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect( + deriveLogicalProjectKey(rootProject, { + groupingMode: "repository_path", + }), + ).toBe(SHARED_REPO_CANONICAL_KEY); + expect( + deriveLogicalProjectKey(nestedProject, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + }); + + it("groups matching nested project paths across environments when repo roots differ", () => { + const primary = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "web", + cwd: "/workspace/repo/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/workspace/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + const remote = makeProject({ + id: sharedProjectRemoteId, + environmentId: remoteEnvId, + name: "web", + cwd: "/srv/checkout/apps/web", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + rootPath: "/srv/checkout", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); + expect( + deriveLogicalProjectKey(primary, { + groupingMode: "repository_path", + }), + ).toBe( + deriveLogicalProjectKey(remote, { + groupingMode: "repository_path", + }), + ); + }); + it("does NOT group projects without shared canonical key", () => { const local = makeProject({ id: localOnlyProjectId, @@ -286,6 +421,32 @@ describe("environment grouping", () => { }); expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); }); + + it("uses per-project overrides from settings", () => { + const project = makeProject({ + id: sharedProjectPrimaryId, + environmentId: primaryEnvId, + name: "shared-repo", + repositoryIdentity: { + canonicalKey: SHARED_REPO_CANONICAL_KEY, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + }); + + expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository"); + expect( + deriveLogicalProjectKeyFromSettings(project, { + ...DEFAULT_GROUPING_SETTINGS, + sidebarProjectGroupingOverrides: { + [derivePhysicalProjectKey(project)]: "separate", + }, + }), + ).toBe(derivePhysicalProjectKey(project)); + }); }); describe("selectProjectsAcrossEnvironments", () => { diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 086bff6b3776..779678a403f9 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -13,9 +13,7 @@ import { Throttler } from "@tanstack/react-pacer"; import { createKnownEnvironment, getKnownEnvironmentWsBaseUrl, - scopedProjectKey, scopedThreadKey, - scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; @@ -62,6 +60,7 @@ import { useTerminalStateStore } from "~/terminalStateStore"; import { useUiStateStore } from "~/uiStateStore"; import { WsTransport } from "../../rpc/wsTransport"; import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; +import { derivePhysicalProjectKey } from "../../logicalProject"; type EnvironmentServiceState = { readonly queryClient: QueryClient; @@ -470,7 +469,7 @@ function syncProjectUiFromStore() { const projects = selectProjectsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncProjects( projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + key: derivePhysicalProjectKey(project), cwd: project.cwd, })), ); @@ -543,7 +542,7 @@ function applyRecoveredEventBatch( const projects = selectProjectsAcrossEnvironments(useStore.getState()); useUiStateStore.getState().syncProjects( projects.map((project) => ({ - key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + key: derivePhysicalProjectKey(project), cwd: project.cwd, })), ); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index f567735691c4..d512b6c7e760 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,14 +10,19 @@ import { } from "../composerDraftStore"; import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; -import { deriveLogicalProjectKey } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; +import { useSettings } from "./useSettings"; function useNewThreadState() { const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -48,7 +53,7 @@ function useNewThreadState() { candidate.environmentId === projectRef.environmentId, ); const logicalProjectKey = project - ? deriveLogicalProjectKey(project) + ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; @@ -129,7 +134,7 @@ function useNewThreadState() { }); })(); }, - [getCurrentRouteTarget, router, projects], + [getCurrentRouteTarget, projectGroupingSettings, router, projects], ); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 06b163137b45..4258ccb38028 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -528,13 +528,20 @@ describe("wsApi", () => { }); it("reads and writes persistence through the desktop bridge when available", async () => { - const getClientSettings = vi.fn().mockResolvedValue({ + const clientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", + sidebarProjectGroupingMode: "repository_path" as const, + sidebarProjectGroupingOverrides: { + "environment-local:/tmp/project": "separate" as const, + }, + sidebarProjectSortOrder: "manual" as const, + sidebarThreadSortOrder: "created_at" as const, + timestampFormat: "24-hour" as const, + }; + const getClientSettings = vi.fn().mockResolvedValue({ + ...clientSettings, }); const setClientSettings = vi.fn().mockResolvedValue(undefined); const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); @@ -556,14 +563,7 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); await api.persistence.getClientSettings(); - await api.persistence.setClientSettings({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + await api.persistence.setClientSettings(clientSettings); await api.persistence.getSavedEnvironmentRegistry(); await api.persistence.setSavedEnvironmentRegistry([]); await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")); @@ -574,14 +574,7 @@ describe("wsApi", () => { await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); expect(getClientSettings).toHaveBeenCalledWith(); - expect(setClientSettings).toHaveBeenCalledWith({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + expect(setClientSettings).toHaveBeenCalledWith(clientSettings); expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); @@ -592,15 +585,20 @@ describe("wsApi", () => { it("falls back to browser storage for persistence when the desktop bridge is missing", async () => { const { createLocalApi } = await import("./localApi"); const api = createLocalApi(rpcClientMock as never); - - await api.persistence.setClientSettings({ + const clientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + sidebarProjectGroupingMode: "repository_path" as const, + sidebarProjectGroupingOverrides: { + "environment-local:/tmp/project": "separate" as const, + }, + sidebarProjectSortOrder: "manual" as const, + sidebarThreadSortOrder: "created_at" as const, + timestampFormat: "24-hour" as const, + }; + + await api.persistence.setClientSettings(clientSettings); await api.persistence.setSavedEnvironmentRegistry([ { environmentId: EnvironmentId.make("environment-local"), @@ -616,14 +614,7 @@ describe("wsApi", () => { "bearer-token", ); - await expect(api.persistence.getClientSettings()).resolves.toEqual({ - confirmThreadArchive: true, - confirmThreadDelete: false, - diffWordWrap: true, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", - }); + await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings); await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ { environmentId: EnvironmentId.make("environment-local"), diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 789441877bce..6b84fa6dc342 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,19 +1,157 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; -import type { ScopedProjectRef } from "@t3tools/contracts"; +import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "./lib/projectPaths"; import type { Project } from "./types"; +export interface ProjectGroupingSettings { + sidebarProjectGroupingMode: SidebarProjectGroupingMode; + sidebarProjectGroupingOverrides: Record; +} + +export type ProjectGroupingMode = SidebarProjectGroupingMode; + +function uniqueNonEmptyValues(values: ReadonlyArray): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + return unique; +} + +function deriveRepositoryRelativeProjectPath( + project: Pick, +): string | null { + const rootPath = project.repositoryIdentity?.rootPath?.trim(); + if (!rootPath) { + return null; + } + + const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd); + const normalizedRootPath = normalizeProjectPathForComparison(rootPath); + if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { + return null; + } + + if (normalizedProjectPath === normalizedRootPath) { + return ""; + } + + const separator = normalizedRootPath.includes("\\") ? "\\" : "/"; + const rootPrefix = `${normalizedRootPath}${separator}`; + if (!normalizedProjectPath.startsWith(rootPrefix)) { + return null; + } + + return normalizedProjectPath.slice(rootPrefix.length).replaceAll("\\", "/"); +} + +export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: string): string { + return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; +} + +export function derivePhysicalProjectKey(project: Pick): string { + return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd); +} + +export function deriveProjectGroupingOverrideKey( + project: Pick, +): string { + return derivePhysicalProjectKey(project); +} + +export function resolveProjectGroupingMode( + project: Pick, + settings: ProjectGroupingSettings, +): SidebarProjectGroupingMode { + return ( + settings.sidebarProjectGroupingOverrides[deriveProjectGroupingOverrideKey(project)] ?? + settings.sidebarProjectGroupingMode + ); +} + +function deriveRepositoryScopedKey( + project: Pick, + groupingMode: SidebarProjectGroupingMode, +): string | null { + const canonicalKey = project.repositoryIdentity?.canonicalKey; + if (!canonicalKey) { + return null; + } + + if (groupingMode === "repository") { + return canonicalKey; + } + + const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); + if (relativeProjectPath === null) { + return canonicalKey; + } + + return relativeProjectPath.length === 0 + ? canonicalKey + : `${canonicalKey}::${relativeProjectPath}`; +} + export function deriveLogicalProjectKey( - project: Pick, + project: Pick, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { + const groupingMode = options?.groupingMode ?? "repository"; + if (groupingMode === "separate") { + return derivePhysicalProjectKey(project); + } + return ( - project.repositoryIdentity?.canonicalKey ?? + deriveRepositoryScopedKey(project, groupingMode) ?? + derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); } +export function deriveLogicalProjectKeyFromSettings( + project: Pick, + settings: ProjectGroupingSettings, +): string { + return deriveLogicalProjectKey(project, { + groupingMode: resolveProjectGroupingMode(project, settings), + }); +} + export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, - project: Pick | null | undefined, + project: Pick | null | undefined, + options?: { + groupingMode?: SidebarProjectGroupingMode; + }, ): string { - return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); + return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); +} + +export function deriveProjectGroupLabel(input: { + representative: Pick; + members: ReadonlyArray>; +}): string { + const sharedDisplayNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.displayName), + ); + if (sharedDisplayNames.length === 1) { + return sharedDisplayNames[0]!; + } + + const sharedRepositoryNames = uniqueNonEmptyValues( + input.members.map((member) => member.repositoryIdentity?.name), + ); + if (sharedRepositoryNames.length === 1) { + return sharedRepositoryNames[0]!; + } + + return input.representative.name; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8c5046af362b..b0c0713fe3a2 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -22,6 +22,11 @@ import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; +import { useSettings } from "../hooks/useSettings"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKeyFromPath, +} from "../logicalProject"; import { getServerConfigUpdatedNotification, ServerConfigUpdatedNotification, @@ -204,6 +209,10 @@ function EventRouter() { const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); + const projectGroupingSettings = useSettings((settings) => ({ + sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, + sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, + })); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); @@ -224,14 +233,21 @@ function EventRouter() { if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - useUiStateStore - .getState() - .setProjectExpanded( - scopedProjectKey( - scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), - ), - true, + const bootstrapEnvironmentState = + useStore.getState().environmentStateById[payload.environment.environmentId]; + const bootstrapProject = + bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null; + const bootstrapProjectKey = + (bootstrapProject + ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) + : null) ?? + (serverConfig?.cwd + ? derivePhysicalProjectKeyFromPath(payload.environment.environmentId, serverConfig.cwd) + : null) ?? + scopedProjectKey( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), ); + useUiStateStore.getState().setProjectExpanded(bootstrapProjectKey, true); if (readPathname() !== "/") { return; diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts new file mode 100644 index 000000000000..8909c1bf7552 --- /dev/null +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -0,0 +1,118 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + deriveProjectGroupLabel, + type ProjectGroupingSettings, +} from "./logicalProject"; +import type { Project } from "./types"; + +export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + +export interface SidebarProjectGroupMember extends Project { + physicalProjectKey: string; + environmentLabel: string | null; +} + +export interface SidebarProjectSnapshot extends Project { + projectKey: string; + displayName: string; + groupedProjectCount: number; + environmentPresence: EnvironmentPresence; + memberProjects: readonly SidebarProjectGroupMember[]; + memberProjectRefs: readonly ScopedProjectRef[]; + remoteEnvironmentLabels: readonly string[]; +} + +export function buildPhysicalToLogicalProjectKeyMap(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; +}): Map { + const mapping = new Map(); + for (const project of input.projects) { + mapping.set( + derivePhysicalProjectKey(project), + deriveLogicalProjectKeyFromSettings(project, input.settings), + ); + } + return mapping; +} + +export function buildSidebarProjectSnapshots(input: { + projects: ReadonlyArray; + settings: ProjectGroupingSettings; + primaryEnvironmentId: EnvironmentId | null; + resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; +}): SidebarProjectSnapshot[] { + const groupedMembers = new Map(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + const member: SidebarProjectGroupMember = { + ...project, + physicalProjectKey: derivePhysicalProjectKey(project), + environmentLabel: input.resolveEnvironmentLabel(project.environmentId), + }; + const existing = groupedMembers.get(logicalKey); + if (existing) { + existing.push(member); + } else { + groupedMembers.set(logicalKey, [member]); + } + } + + const result: SidebarProjectSnapshot[] = []; + const seen = new Set(); + for (const project of input.projects) { + const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); + if (seen.has(logicalKey)) { + continue; + } + seen.add(logicalKey); + + const members = groupedMembers.get(logicalKey) ?? []; + const representative = + (input.primaryEnvironmentId + ? members.find((member) => member.environmentId === input.primaryEnvironmentId) + : null) ?? members[0]; + if (!representative) { + continue; + } + + const hasLocal = + input.primaryEnvironmentId !== null && + members.some((member) => member.environmentId === input.primaryEnvironmentId); + const hasRemote = + input.primaryEnvironmentId !== null + ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) + : false; + const remoteEnvironmentLabels = members + .filter( + (member) => + input.primaryEnvironmentId !== null && + member.environmentId !== input.primaryEnvironmentId, + ) + .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) + .filter((label, index, labels) => labels.indexOf(label) === index); + + result.push({ + ...representative, + projectKey: logicalKey, + displayName: + members.length > 1 + ? deriveProjectGroupLabel({ + representative, + members, + }) + : representative.name, + groupedProjectCount: members.length, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjects: members, + memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)), + remoteEnvironmentLabels, + }); + } + + return result; +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc3b5459cd9a..28adbf21781f 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -51,6 +51,7 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; export const RepositoryIdentity = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, locator: RepositoryIdentityLocator, + rootPath: Schema.optionalKey(TrimmedNonEmptyString), displayName: Schema.optionalKey(TrimmedNonEmptyString), provider: Schema.optionalKey(TrimmedNonEmptyString), owner: Schema.optionalKey(TrimmedNonEmptyString), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index c2d681330152..e9cc28736a53 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -60,6 +60,7 @@ export interface ContextMenuItem { label: string; destructive?: boolean; disabled?: boolean; + children?: readonly ContextMenuItem[]; } export type DesktopUpdateStatus = diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 375f4ed27e20..28723cf254d3 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,10 +23,25 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const SidebarProjectGroupingMode = Schema.Literals([ + "repository", + "repository_path", + "separate", +]); +export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; +export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; + export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), + ), + sidebarProjectGroupingOverrides: Schema.Record( + TrimmedNonEmptyString, + SidebarProjectGroupingMode, + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER)), ), From e0117b271a79f66d0ac793da334991e7a2ccb800 Mon Sep 17 00:00:00 2001 From: Claudio Vasquez <105945231+crafael23@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:30:56 -0600 Subject: [PATCH 05/36] Fix Claude Process leak[MEMORY INTENSIVE], archiving, and stale claude session monitoring. (#2042) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/server/package.json | 3 +- apps/server/src/codexAppServerManager.test.ts | 148 +++++ apps/server/src/codexAppServerManager.ts | 38 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 65 +++ .../src/provider/Layers/ClaudeAdapter.ts | 116 +++- .../src/provider/Layers/CodexAdapter.test.ts | 1 + .../provider/Layers/ProviderService.test.ts | 37 ++ .../src/provider/Layers/ProviderService.ts | 38 ++ .../Layers/ProviderSessionDirectory.test.ts | 72 +++ .../Layers/ProviderSessionDirectory.ts | 50 +- .../Layers/ProviderSessionReaper.test.ts | 522 ++++++++++++++++++ .../provider/Layers/ProviderSessionReaper.ts | 133 +++++ .../Services/ProviderSessionDirectory.ts | 9 + .../Services/ProviderSessionReaper.ts | 14 + apps/server/src/server.test.ts | 421 +++++++++++++- apps/server/src/server.ts | 23 +- apps/server/src/serverRuntimeStartup.ts | 7 +- apps/server/src/ws.ts | 37 ++ package.json | 1 + 19 files changed, 1686 insertions(+), 49 deletions(-) create mode 100644 apps/server/src/provider/Layers/ProviderSessionReaper.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderSessionReaper.ts create mode 100644 apps/server/src/provider/Services/ProviderSessionReaper.ts diff --git a/apps/server/package.json b/apps/server/package.json index af6450a88a94..aefbb4317d37 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -20,7 +20,8 @@ "start": "node dist/bin.mjs", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "test:process-reaper": "vitest run src/server.test.ts src/provider/Layers/ClaudeAdapter.test.ts src/provider/Layers/ProviderSessionDirectory.test.ts src/provider/Layers/ProviderSessionReaper.test.ts src/provider/Layers/CodexAdapter.test.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.111", diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index ab3b7a569dec..919aacf728dc 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -470,6 +470,154 @@ describe("startSession", () => { manager.stopAll(); } }); + + it("disposes an existing session before starting a replacement for the same thread", async () => { + const manager = new CodexAppServerManager(); + const existingContext = { + session: { + provider: "codex", + status: "ready", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + ( + manager as unknown as { + sessions: Map; + } + ).sessions.set(asThreadId("thread-1"), existingContext); + + const disposeSession = vi + .spyOn( + manager as unknown as { + disposeSession: ( + context: typeof existingContext, + options?: { readonly emitLifecycleEvent?: boolean }, + ) => void; + }, + "disposeSession", + ) + .mockImplementation(() => {}); + const assertSupportedCodexCliVersion = vi + .spyOn( + manager as unknown as { + assertSupportedCodexCliVersion: (input: { + binaryPath: string; + cwd: string; + homePath?: string; + }) => void; + }, + "assertSupportedCodexCliVersion", + ) + .mockImplementation(() => {}); + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("cwd missing"); + }); + + try { + await expect( + manager.startSession({ + threadId: asThreadId("thread-1"), + provider: "codex", + binaryPath: "codex", + runtimeMode: "full-access", + }), + ).rejects.toThrow("cwd missing"); + + expect(disposeSession).toHaveBeenCalledWith(existingContext, { + emitLifecycleEvent: false, + }); + expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); + } finally { + disposeSession.mockRestore(); + assertSupportedCodexCliVersion.mockRestore(); + processCwd.mockRestore(); + ( + manager as unknown as { + sessions: Map; + } + ).sessions.clear(); + manager.stopAll(); + } + }); + + it("continues replacement start when existing session disposal fails", async () => { + const manager = new CodexAppServerManager(); + const existingContext = { + session: { + provider: "codex", + status: "ready", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + ( + manager as unknown as { + sessions: Map; + } + ).sessions.set(asThreadId("thread-1"), existingContext); + + const disposeSession = vi + .spyOn( + manager as unknown as { + disposeSession: ( + context: typeof existingContext, + options?: { readonly emitLifecycleEvent?: boolean }, + ) => void; + }, + "disposeSession", + ) + .mockImplementation(() => { + throw new Error("dispose failed"); + }); + const assertSupportedCodexCliVersion = vi + .spyOn( + manager as unknown as { + assertSupportedCodexCliVersion: (input: { + binaryPath: string; + cwd: string; + homePath?: string; + }) => void; + }, + "assertSupportedCodexCliVersion", + ) + .mockImplementation(() => {}); + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("cwd missing"); + }); + + try { + await expect( + manager.startSession({ + threadId: asThreadId("thread-1"), + provider: "codex", + binaryPath: "codex", + runtimeMode: "full-access", + }), + ).rejects.toThrow("cwd missing"); + + expect(disposeSession).toHaveBeenCalledWith(existingContext, { + emitLifecycleEvent: false, + }); + expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); + } finally { + disposeSession.mockRestore(); + assertSupportedCodexCliVersion.mockRestore(); + processCwd.mockRestore(); + ( + manager as unknown as { + sessions: Map; + } + ).sessions.clear(); + manager.stopAll(); + } + }); }); describe("sendTurn", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 230ba8e36412..1e6a7fdb6a3e 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -450,6 +450,25 @@ export class CodexAppServerManager extends EventEmitter { ); }); + it.effect("closes the previous session before replacing an existing thread session", () => { + const queries: FakeClaudeQuery[] = []; + const layer = makeClaudeAdapterLive({ + createQuery: () => { + const query = new FakeClaudeQuery(); + queries.push(query); + return query; + }, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const firstSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + const secondSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + resumeCursor: firstSession.resumeCursor, + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const activeSessions = yield* adapter.listSessions(); + + assert.equal(queries.length, 2); + assert.equal(queries[0]?.closeCalls, 1); + assert.equal(queries[1]?.closeCalls, 0); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(activeSessions.length, 1); + assert.deepEqual(activeSessions[0]?.resumeCursor, secondSession.resumeCursor); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "session.started", + "session.configured", + "session.state.changed", + ], + ); + assert.equal( + runtimeEvents.some((event) => event.type === "session.exited"), + false, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("stopSession does not throw into the SDK prompt consumer", () => { // The SDK consumes user messages via `for await (... of prompt)`. // Stopping a session must end that loop cleanly — not throw an error. diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2b3a9faeea07..b59ac444cf81 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -470,7 +470,10 @@ function isTodoTool(toolName: string): boolean { return toolName.toLowerCase().includes("todowrite"); } -type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" }; +type PlanStep = { + step: string; + status: "pending" | "inProgress" | "completed"; +}; function extractPlanStepsFromTodoInput(input: Record): PlanStep[] | null { // TodoWrite format: { todos: [{ content, status, activeForm? }] } @@ -973,7 +976,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ((input: { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; - }) => query({ prompt: input.prompt, options: input.options }) as ClaudeQueryRuntime); + }) => + query({ + prompt: input.prompt, + options: input.options, + }) as ClaudeQueryRuntime); const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); @@ -1012,7 +1019,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof message.session_id === "string" ? { providerThreadId: message.session_id } : {}), - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), ...(itemId ? { itemId: ProviderItemId.make(itemId) } : {}), payload: message, }, @@ -1401,7 +1412,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof accumulatedTotalProcessedTokens === "number" && Number.isFinite(accumulatedTotalProcessedTokens) && accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens - ? { totalProcessedTokens: accumulatedTotalProcessedTokens } + ? { + totalProcessedTokens: accumulatedTotalProcessedTokens, + } : {}), } : accumulatedSnapshot; @@ -1465,7 +1478,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: tool.input, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/result", @@ -1587,7 +1602,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( threadId: context.session.threadId, turnId: context.turnState.turnId, ...(assistantBlockEntry?.block - ? { itemId: asRuntimeItemId(assistantBlockEntry.block.itemId) } + ? { + itemId: asRuntimeItemId(assistantBlockEntry.block.itemId), + } : {}), payload: { streamKind, @@ -1646,7 +1663,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: stamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), itemId: asRuntimeItemId(nextTool.itemId), payload: { itemType: nextTool.itemType, @@ -1658,7 +1679,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: nextTool.input, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: nextTool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/stream_event/content_block_delta/input_json_delta", @@ -1677,7 +1700,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: planStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), payload: { plan: planSteps, }, @@ -1747,7 +1774,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: toolInput, }, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/stream_event/content_block_start", @@ -1819,7 +1848,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), data: toolData, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -1842,7 +1873,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( streamKind, delta: toolResult.text, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -1867,7 +1900,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(tool.detail ? { detail: tool.detail } : {}), data: toolData, }, - providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }), + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), raw: { source: "claude.sdk.message", method: "claude/user", @@ -2223,7 +2258,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( payload: { summary: message.summary, ...(message.preceding_tool_use_ids.length > 0 - ? { precedingToolUseIds: message.preceding_tool_use_ids } + ? { + precedingToolUseIds: message.preceding_tool_use_ids, + } : {}), }, }); @@ -2440,6 +2477,27 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + const existingContext = sessions.get(input.threadId); + if (existingContext) { + yield* Effect.logWarning("claude.session.replacing", { + threadId: input.threadId, + existingSessionStatus: existingContext.session.status, + reason: "startSession called with existing active session", + }); + yield* stopSessionInternal(existingContext, { + emitExitEvent: false, + }).pipe( + // Replacement cleanup is best-effort: never block the new session on + // either typed failures or unexpected defects from tearing down the old one. + Effect.catchCause((cause) => + Effect.logWarning("claude.session.replace.stop-failed", { + threadId: input.threadId, + cause, + }), + ), + ); + } + const startedAt = yield* nowIso; const resumeState = readClaudeResumeState(input.resumeCursor); const threadId = input.threadId; @@ -2475,7 +2533,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const handleAskUserQuestion = Effect.fn("handleAskUserQuestion")(function* ( context: ClaudeSessionContext, toolInput: Record, - callbackOptions: { readonly signal: AbortSignal; readonly toolUseID?: string }, + callbackOptions: { + readonly signal: AbortSignal; + readonly toolUseID?: string; + }, ) { const requestId = ApprovalRequestId.make(yield* Random.nextUUIDv4); @@ -2511,7 +2572,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: requestedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), requestId: asRuntimeRequestId(requestId), payload: { questions }, providerRefs: nativeProviderRefs(context, { @@ -2520,7 +2585,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( raw: { source: "claude.sdk.permission", method: "canUseTool/AskUserQuestion", - payload: { toolName: "AskUserQuestion", input: toolInput }, + payload: { + toolName: "AskUserQuestion", + input: toolInput, + }, }, }); @@ -2535,7 +2603,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( pendingUserInputs.delete(requestId); runFork(Deferred.succeed(answersDeferred, {} as ProviderUserInputAnswers)); }; - callbackOptions.signal.addEventListener("abort", onAbort, { once: true }); + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); // Block until the user provides answers. const answers = yield* Deferred.await(answersDeferred); @@ -2549,7 +2619,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, createdAt: resolvedStamp.createdAt, threadId: context.session.threadId, - ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + ...(context.turnState + ? { + turnId: asCanonicalTurnId(context.turnState.turnId), + } + : {}), requestId: asRuntimeRequestId(requestId), payload: { answers }, providerRefs: nativeProviderRefs(context, { @@ -2719,7 +2793,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( behavior: "allow", updatedInput: toolInput, ...(decision === "acceptForSession" && pendingApproval.suggestions - ? { updatedPermissions: [...pendingApproval.suggestions] } + ? { + updatedPermissions: [...pendingApproval.suggestions], + } : {}), } satisfies PermissionResult; } diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c4ee33b7768c..03ba0ce4e80b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -146,6 +146,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getBinding: () => Effect.succeed(Option.none()), remove: () => Effect.void, listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), }); const validationManager = new FakeCodexManager(); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 56f9f8d65c4a..011b7741777f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -654,6 +654,43 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("stops stale sessions in other providers after a successful replacement start", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const threadId = asThreadId("thread-provider-replacement"); + + const codexSession = yield* provider.startSession(threadId, { + provider: "codex", + threadId, + cwd: "/tmp/project-provider-replacement", + runtimeMode: "full-access", + }); + + routing.codex.stopSession.mockClear(); + routing.claude.stopSession.mockClear(); + + const claudeSession = yield* provider.startSession(threadId, { + provider: "claudeAgent", + threadId, + cwd: "/tmp/project-provider-replacement", + runtimeMode: "full-access", + }); + + assert.equal(codexSession.provider, "codex"); + assert.equal(claudeSession.provider, "claudeAgent"); + assert.deepEqual(routing.codex.stopSession.mock.calls, [[threadId]]); + assert.equal(routing.claude.stopSession.mock.calls.length, 0); + + const sessions = yield* provider.listSessions(); + assert.deepEqual( + sessions + .filter((session) => session.threadId === threadId) + .map((session) => session.provider), + ["claudeAgent"], + ); + }), + ); + it.effect("recovers stale sessions for sendTurn using persisted cwd", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 85fe9fbc326a..4dbd264289ba 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -297,6 +297,40 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return { adapter: recovered.adapter, threadId: input.threadId, isActive: true } as const; }); + const stopStaleSessionsForThread = Effect.fn("stopStaleSessionsForThread")(function* (input: { + readonly threadId: ThreadId; + readonly currentProvider: ProviderSession["provider"]; + }) { + yield* Effect.forEach( + adapters, + (adapter) => + adapter.provider === input.currentProvider + ? Effect.void + : Effect.gen(function* () { + const hasSession = yield* adapter.hasSession(input.threadId); + if (!hasSession) { + return; + } + + yield* adapter.stopSession(input.threadId).pipe( + Effect.tap(() => + analytics.record("provider.session.stopped", { + provider: adapter.provider, + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("provider.session.stop-stale-failed", { + threadId: input.threadId, + provider: adapter.provider, + cause, + }), + ), + ); + }), + { discard: true }, + ); + }); + const startSession: ProviderServiceShape["startSession"] = Effect.fn("startSession")( function* (threadId, rawInput) { const parsed = yield* decodeInputOrValidationError({ @@ -351,6 +385,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } + yield* stopStaleSessionsForThread({ + threadId, + currentProvider: adapter.provider, + }); yield* upsertSessionBinding(session, threadId, { modelSelection: input.modelSelection, }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 3ffd6941ade7..30bc387b859e 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -133,6 +133,78 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); + it("lists persisted bindings with metadata in oldest-first order", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + const olderThreadId = ThreadId.make("thread-runtime-older"); + const newerThreadId = ThreadId.make("thread-runtime-newer"); + + yield* runtimeRepository.upsert({ + threadId: newerThreadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T12:05:00.000Z", + resumeCursor: { + opaque: "resume-newer", + }, + runtimePayload: { + cwd: "/tmp/newer", + }, + }); + + yield* runtimeRepository.upsert({ + threadId: olderThreadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "approval-required", + status: "starting", + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: { + opaque: "resume-older", + }, + runtimePayload: { + cwd: "/tmp/older", + }, + }); + + const bindings = yield* directory.listBindings(); + + assert.deepEqual(bindings, [ + { + threadId: olderThreadId, + provider: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "approval-required", + status: "starting", + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: { + opaque: "resume-older", + }, + runtimePayload: { + cwd: "/tmp/older", + }, + }, + { + threadId: newerThreadId, + provider: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T12:05:00.000Z", + resumeCursor: { + opaque: "resume-newer", + }, + runtimePayload: { + cwd: "/tmp/newer", + }, + }, + ]); + })); + it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d69610..da4e32ac6348 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,11 +1,13 @@ import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; import { Effect, Layer, Option } from "effect"; +import type { ProviderSessionRuntime } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; import { ProviderSessionDirectory, type ProviderRuntimeBinding, + type ProviderRuntimeBindingWithMetadata, type ProviderSessionDirectoryShape, } from "../Services/ProviderSessionDirectory.ts"; @@ -50,6 +52,27 @@ function mergeRuntimePayload( return next; } +function toRuntimeBinding( + runtime: ProviderSessionRuntime, + operation: string, +): Effect.Effect { + return decodeProviderKind(runtime.providerName, operation).pipe( + Effect.map( + (provider) => + ({ + threadId: runtime.threadId, + provider, + adapterKey: runtime.adapterKey, + runtimeMode: runtime.runtimeMode, + status: runtime.status, + resumeCursor: runtime.resumeCursor, + runtimePayload: runtime.runtimePayload, + lastSeenAt: runtime.lastSeenAt, + }) satisfies ProviderRuntimeBindingWithMetadata, + ), + ); +} + const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; @@ -60,18 +83,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Option.match(runtime, { onNone: () => Effect.succeed(Option.none()), onSome: (value) => - decodeProviderKind(value.providerName, "ProviderSessionDirectory.getBinding").pipe( - Effect.map((provider) => - Option.some({ - threadId: value.threadId, - provider, - adapterKey: value.adapterKey, - runtimeMode: value.runtimeMode, - status: value.status, - resumeCursor: value.resumeCursor, - runtimePayload: value.runtimePayload, - }), - ), + toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( + Effect.map((binding) => Option.some(binding)), ), }), ), @@ -145,12 +158,25 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => row.threadId)), ); + const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => + repository.list().pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => toRuntimeBinding(row, "ProviderSessionDirectory.listBindings"), + { concurrency: "unbounded" }, + ), + ), + ); + return { upsert, getProvider, getBinding, remove, listThreadIds, + listBindings, } satisfies ProviderSessionDirectoryShape; }); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts new file mode 100644 index 000000000000..45199a02b2af --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -0,0 +1,522 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../orchestration/Services/OrchestrationEngine.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; +import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import { ProviderValidationError } from "../Errors.ts"; +import { ProviderSessionReaper } from "../Services/ProviderSessionReaper.ts"; +import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; +import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +import { makeProviderSessionReaperLive } from "./ProviderSessionReaper.ts"; + +const defaultModelSelection = { + provider: "codex", + model: "gpt-5-codex", +} as const; + +async function waitFor( + predicate: () => boolean | Promise, + timeoutMs = 2_000, +): Promise { + const deadline = Date.now() + timeoutMs; + const poll = async (): Promise => { + if (await predicate()) { + return; + } + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for expectation."); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return poll(); + }; + + return poll(); +} + +const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + +function makeReadModel( + threads: ReadonlyArray<{ + readonly id: ThreadId; + readonly session: { + readonly threadId: ThreadId; + readonly status: "starting" | "running" | "ready" | "interrupted" | "stopped" | "error"; + readonly providerName: "codex" | "claudeAgent"; + readonly runtimeMode: "approval-required" | "full-access" | "auto-accept-edits"; + readonly activeTurnId: TurnId | null; + readonly lastError: string | null; + readonly updatedAt: string; + } | null; + }>, +) { + const now = new Date().toISOString(); + const projectId = ProjectId.make("project-provider-session-reaper"); + + return { + snapshotSequence: 0, + updatedAt: now, + projects: [ + { + id: projectId, + title: "Provider Reaper Project", + workspaceRoot: "/tmp/provider-reaper-project", + defaultModelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], + threads: threads.map((thread) => ({ + id: thread.id, + projectId, + title: `Thread ${thread.id}`, + modelSelection: defaultModelSelection, + interactionMode: "default" as const, + runtimeMode: "full-access" as const, + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + latestTurn: null, + messages: [], + session: thread.session, + activities: [], + proposedPlans: [], + checkpoints: [], + deletedAt: null, + })), + }; +} + +describe("ProviderSessionReaper", () => { + let runtime: ManagedRuntime.ManagedRuntime< + ProviderSessionReaper | ProviderSessionRuntimeRepository, + unknown + > | null = null; + let scope: Scope.Closeable | null = null; + + afterEach(async () => { + if (scope) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + scope = null; + if (runtime) { + await runtime.dispose(); + } + runtime = null; + }); + + async function createHarness(input: { + readonly readModel: ReturnType; + readonly stopSessionImplementation?: (input: { + readonly threadId: ThreadId; + }) => ReturnType; + }) { + const stoppedThreadIds = new Set(); + const stopSession = vi.fn( + (request) => + (input.stopSessionImplementation + ? input.stopSessionImplementation(request) + : Effect.sync(() => { + stoppedThreadIds.add(request.threadId); + })) as ReturnType, + ); + + const providerService: ProviderServiceShape = { + startSession: () => unsupported(), + sendTurn: () => unsupported(), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession, + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + rollbackConversation: () => unsupported(), + streamEvents: Stream.empty, + }; + + const orchestrationEngine: OrchestrationEngineShape = { + getReadModel: () => Effect.succeed(input.readModel), + readEvents: () => Stream.empty, + dispatch: () => unsupported(), + streamDomainEvents: Stream.empty, + }; + + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const layer = makeProviderSessionReaperLive({ + inactivityThresholdMs: 1_000, + sweepIntervalMs: 60_000, + }).pipe( + Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge(runtimeRepositoryLayer), + Layer.provideMerge(Layer.succeed(ProviderService, providerService)), + Layer.provideMerge(Layer.succeed(OrchestrationEngineService, orchestrationEngine)), + Layer.provideMerge(NodeServices.layer), + ); + + runtime = ManagedRuntime.make(layer); + return { stopSession, stoppedThreadIds }; + } + + it("reaps stale persisted sessions without active turns", async () => { + const threadId = ThreadId.make("thread-reaper-stale"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-stale", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 1); + + expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ threadId }); + expect(harness.stoppedThreadIds.has(threadId)).toBe(true); + }); + + it("skips stale sessions when the thread still has an active turn", async () => { + const threadId = ThreadId.make("thread-reaper-active-turn"); + const turnId = TurnId.make("turn-reaper-active"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "running", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: turnId, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-active-turn", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("does not reap sessions that are still within the inactivity threshold", async () => { + const threadId = ThreadId.make("thread-reaper-fresh"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: now, + resumeCursor: { + opaque: "resume-fresh", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("skips persisted sessions that are already marked stopped", async () => { + const threadId = ThreadId.make("thread-reaper-stopped"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: threadId, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-stopped", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(harness.stopSession).not.toHaveBeenCalled(); + const remaining = await runtime!.runPromise(repository.getByThreadId({ threadId })); + expect(Option.isSome(remaining)).toBe(true); + }); + + it("continues reaping other sessions when one stop attempt fails", async () => { + const failedThreadId = ThreadId.make("thread-reaper-stop-failure"); + const reapedThreadId = ThreadId.make("thread-reaper-stop-success"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: failedThreadId, + session: { + threadId: failedThreadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + { + id: reapedThreadId, + session: { + threadId: reapedThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + stopSessionImplementation: (request) => + request.threadId === failedThreadId + ? Effect.fail( + new ProviderValidationError({ + operation: "ProviderSessionReaper.test", + issue: "simulated stop failure", + }), + ) + : Effect.void, + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId: failedThreadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-failure", + }, + runtimePayload: null, + }), + ); + await runtime!.runPromise( + repository.upsert({ + threadId: reapedThreadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:01:00.000Z", + resumeCursor: { + opaque: "resume-success", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ + failedThreadId, + reapedThreadId, + ]); + }); + + it("continues reaping other sessions when one stop attempt defects", async () => { + const defectThreadId = ThreadId.make("thread-reaper-stop-defect"); + const reapedThreadId = ThreadId.make("thread-reaper-stop-after-defect"); + const now = new Date().toISOString(); + const harness = await createHarness({ + readModel: makeReadModel([ + { + id: defectThreadId, + session: { + threadId: defectThreadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + { + id: reapedThreadId, + session: { + threadId: reapedThreadId, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }, + ]), + stopSessionImplementation: (request) => + request.threadId === defectThreadId + ? Effect.die(new Error("simulated stop defect")) + : Effect.void, + }); + const repository = await runtime!.runPromise(Effect.service(ProviderSessionRuntimeRepository)); + + await runtime!.runPromise( + repository.upsert({ + threadId: defectThreadId, + providerName: "claudeAgent", + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:00:00.000Z", + resumeCursor: { + opaque: "resume-defect", + }, + runtimePayload: null, + }), + ); + await runtime!.runPromise( + repository.upsert({ + threadId: reapedThreadId, + providerName: "codex", + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-04-14T00:01:00.000Z", + resumeCursor: { + opaque: "resume-after-defect", + }, + runtimePayload: null, + }), + ); + + const reaper = await runtime!.runPromise(Effect.service(ProviderSessionReaper)); + scope = await Effect.runPromise(Scope.make("sequential")); + await Effect.runPromise(reaper.start().pipe(Scope.provide(scope))); + + await waitFor(() => harness.stopSession.mock.calls.length === 2); + + expect(harness.stopSession.mock.calls.map(([request]) => request.threadId)).toEqual([ + defectThreadId, + reapedThreadId, + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts new file mode 100644 index 000000000000..aa31c8c7d7a9 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -0,0 +1,133 @@ +import { Duration, Effect, Layer, Schedule } from "effect"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { + ProviderSessionReaper, + type ProviderSessionReaperShape, +} from "../Services/ProviderSessionReaper.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; + +const DEFAULT_INACTIVITY_THRESHOLD_MS = 30 * 60 * 1000; +const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; + +export interface ProviderSessionReaperLiveOptions { + readonly inactivityThresholdMs?: number; + readonly sweepIntervalMs?: number; +} + +const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) => + Effect.gen(function* () { + const providerService = yield* ProviderService; + const directory = yield* ProviderSessionDirectory; + const orchestrationEngine = yield* OrchestrationEngineService; + + const inactivityThresholdMs = Math.max( + 1, + options?.inactivityThresholdMs ?? DEFAULT_INACTIVITY_THRESHOLD_MS, + ); + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + + const sweep = Effect.gen(function* () { + const readModel = yield* orchestrationEngine.getReadModel(); + const threadsById = new Map(readModel.threads.map((thread) => [thread.id, thread] as const)); + const bindings = yield* directory.listBindings(); + const now = Date.now(); + let reapedCount = 0; + + for (const binding of bindings) { + if (binding.status === "stopped") { + continue; + } + + const lastSeenMs = Date.parse(binding.lastSeenAt); + if (Number.isNaN(lastSeenMs)) { + yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { + threadId: binding.threadId, + provider: binding.provider, + lastSeenAt: binding.lastSeenAt, + }); + continue; + } + + const idleDurationMs = now - lastSeenMs; + if (idleDurationMs < inactivityThresholdMs) { + continue; + } + + const thread = threadsById.get(binding.threadId); + if (thread?.session?.activeTurnId != null) { + yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", { + threadId: binding.threadId, + activeTurnId: thread.session.activeTurnId, + idleDurationMs, + }); + continue; + } + + const reaped = yield* providerService.stopSession({ threadId: binding.threadId }).pipe( + Effect.tap(() => + Effect.logInfo("provider.session.reaped", { + threadId: binding.threadId, + provider: binding.provider, + idleDurationMs, + reason: "inactivity_threshold", + }), + ), + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("provider.session.reaper.stop-failed", { + threadId: binding.threadId, + provider: binding.provider, + idleDurationMs, + cause, + }).pipe(Effect.as(false)), + ), + ); + + if (reaped) { + reapedCount += 1; + } + } + + if (reapedCount > 0) { + yield* Effect.logInfo("provider.session.reaper.sweep-complete", { + reapedCount, + totalBindings: bindings.length, + }); + } + }); + + const start: ProviderSessionReaperShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep.pipe( + Effect.catch((error: unknown) => + Effect.logWarning("provider.session.reaper.sweep-failed", { + error, + }), + ), + Effect.catchDefect((defect: unknown) => + Effect.logWarning("provider.session.reaper.sweep-defect", { + defect, + }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + + yield* Effect.logInfo("provider.session.reaper.started", { + inactivityThresholdMs, + sweepIntervalMs, + }); + }); + + return { + start, + } satisfies ProviderSessionReaperShape; + }); + +export const makeProviderSessionReaperLive = (options?: ProviderSessionReaperLiveOptions) => + Layer.effect(ProviderSessionReaper, makeProviderSessionReaper(options)); + +export const ProviderSessionReaperLive = makeProviderSessionReaperLive(); diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index aa0483620b40..a5be4d63e31f 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -22,6 +22,10 @@ export interface ProviderRuntimeBinding { readonly runtimeMode?: RuntimeMode; } +export interface ProviderRuntimeBindingWithMetadata extends ProviderRuntimeBinding { + readonly lastSeenAt: string; +} + export type ProviderSessionDirectoryReadError = ProviderSessionDirectoryPersistenceError; export type ProviderSessionDirectoryWriteError = @@ -49,6 +53,11 @@ export interface ProviderSessionDirectoryShape { ReadonlyArray, ProviderSessionDirectoryPersistenceError >; + + readonly listBindings: () => Effect.Effect< + ReadonlyArray, + ProviderSessionDirectoryPersistenceError + >; } export class ProviderSessionDirectory extends Context.Service< diff --git a/apps/server/src/provider/Services/ProviderSessionReaper.ts b/apps/server/src/provider/Services/ProviderSessionReaper.ts new file mode 100644 index 000000000000..b13b6f7e0c7b --- /dev/null +++ b/apps/server/src/provider/Services/ProviderSessionReaper.ts @@ -0,0 +1,14 @@ +import { Context } from "effect"; +import type { Effect, Scope } from "effect"; + +export interface ProviderSessionReaperShape { + /** + * Start the background provider session reaper within the provided scope. + */ + readonly start: () => Effect.Effect; +} + +export class ProviderSessionReaper extends Context.Service< + ProviderSessionReaper, + ProviderSessionReaperShape +>()("t3/provider/Services/ProviderSessionReaper") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b2c16abb427c..f4e3b7a730a7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -11,6 +11,7 @@ import { KeybindingRule, MessageId, OpenError, + type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, type OrchestrationEvent, @@ -166,6 +167,32 @@ const makeDefaultOrchestrationReadModel = () => { }; }; +const makeDefaultOrchestrationThreadShell = ( + overrides: Partial = {}, +): OrchestrationThreadShell => { + const now = new Date().toISOString(); + return { + id: defaultThreadId, + projectId: defaultProjectId, + title: "Default Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +}; + const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), @@ -2945,21 +2972,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("closes thread terminals after a successful archive command", () => + it.effect("stops the provider session and closes thread terminals after archive", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive"); - const closeInputs: Array[0]> = []; + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); yield* buildAppUnderTest({ layers: { terminalManager: { close: (input) => Effect.sync(() => { - closeInputs.push(input); + effects.push(`terminal.close:${input.threadId}`); }), }, orchestrationEngine: { - dispatch: () => Effect.succeed({ sequence: 8 }), + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), }, }, }); @@ -2975,8 +3029,363 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assert.equal(dispatchResult.sequence, 8); - assert.deepEqual(closeInputs, [{ threadId }]); + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + const sessionStopCommand = dispatchedCommands[1]; + assert.equal(sessionStopCommand?.type, "thread.session.stop"); + if (sessionStopCommand?.type === "thread.session.stop") { + assert.equal(sessionStopCommand.threadId, threadId); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("checks session status before archiving removes the thread from active lookups", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-precheck"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + let archived = false; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.archive") { + archived = true; + } + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.sync(() => { + effects.push(`query:thread-shell:${archived ? "archived" : "active"}`); + return archived + ? Option.none() + : Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-precheck"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "query:thread-shell:active", + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives without dispatching session stop when the thread has no session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-no-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-no-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "archives without dispatching session stop when the thread session is already stopped", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stopped-session"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stopped-session"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, ["dispatch:thread.archive", `terminal.close:${threadId}`]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives and still closes terminals when session stop fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stop-failure"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.session.stop") { + return Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "simulated archive stop failure", + }), + ); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stop-failure"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("archives and still closes terminals when session stop defects", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-archive-stop-defect"); + const effects: string[] = []; + const dispatchedCommands: Array = []; + const now = new Date().toISOString(); + + yield* buildAppUnderTest({ + layers: { + terminalManager: { + close: (input) => + Effect.sync(() => { + effects.push(`terminal.close:${input.threadId}`); + }), + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + effects.push(`dispatch:${command.type}`); + if (command.type === "thread.session.stop") { + return Effect.die(new Error("simulated archive stop defect")); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const dispatchResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.archive", + commandId: CommandId.make("cmd-thread-archive-stop-defect"), + threadId, + }), + ), + ); + + assert.equal(dispatchResult.sequence, 1); + assert.deepEqual(effects, [ + "dispatch:thread.archive", + "dispatch:thread.session.stop", + `terminal.close:${threadId}`, + ]); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.archive", "thread.session.stop"], + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 23c53ad07fd5..3a8a7c5b5439 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -23,6 +23,7 @@ import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; +import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { GitCoreLive } from "./git/Layers/GitCore"; @@ -134,6 +135,10 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive), ); +const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), +); + const ProviderLayerLive = Layer.unwrap( Effect.gen(function* () { const { providerEventLogPath } = yield* ServerConfig; @@ -143,9 +148,6 @@ const ProviderLayerLive = Layer.unwrap( const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { stream: "canonical", }); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); const codexAdapterLayer = makeCodexAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); @@ -155,11 +157,14 @@ const ProviderLayerLive = Layer.unwrap( const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), - Layer.provideMerge(providerSessionDirectoryLayer), + Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); return makeProviderServiceLive( canonicalEventLogger ? { canonicalEventLogger } : undefined, - ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); + ).pipe( + Layer.provide(adapterRegistryLayer), + Layer.provideMerge(ProviderSessionDirectoryLayerLive), + ); }), ); @@ -194,12 +199,16 @@ const AuthLayerLive = ServerAuthLive.pipe( Layer.provide(ServerSecretStoreLive), ); +const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( + Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(OrchestrationLayerLive), +); + const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(GitLayerLive), - Layer.provideMerge(OrchestrationLayerLive), - Layer.provideMerge(ProviderLayerLive), + Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 823e3b4771ed..919da67b7b95 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -32,6 +32,7 @@ import { ServerSettingsService } from "./serverSettings"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -281,6 +282,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; + const providerSessionReaper = yield* ProviderSessionReaper; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; @@ -325,7 +327,10 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: starting orchestration reactors"); yield* runStartupPhase( "reactors.start", - orchestrationReactor.start().pipe(Scope.provide(reactorScope)), + Effect.gen(function* () { + yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); + yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + }), ); const welcomeBase = yield* resolveWelcomeBase; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 96b5b54d71b5..ff9592b5b345 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -550,8 +550,45 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); + const shouldStopSessionAfterArchive = + normalizedCommand.type === "thread.archive" + ? yield* projectionSnapshotQuery + .getThreadShellById(normalizedCommand.threadId) + .pipe( + Effect.map( + Option.match({ + onNone: () => false, + onSome: (thread) => + thread.session !== null && thread.session.status !== "stopped", + }), + ), + Effect.catch(() => Effect.succeed(false)), + ) + : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); if (normalizedCommand.type === "thread.archive") { + if (shouldStopSessionAfterArchive) { + yield* Effect.gen(function* () { + const stopCommand = yield* normalizeDispatchCommand({ + type: "thread.session.stop", + commandId: CommandId.make( + `session-stop-for-archive:${normalizedCommand.commandId}`, + ), + threadId: normalizedCommand.threadId, + createdAt: new Date().toISOString(), + }); + + yield* dispatchNormalizedCommand(stopCommand); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to stop provider session during archive", { + threadId: normalizedCommand.threadId, + cause, + }), + ), + ); + } + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( Effect.catch((error) => Effect.logWarning("failed to close thread terminals after archive", { diff --git a/package.json b/package.json index 97b30e6d4dcd..884a512d30f7 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "typecheck": "turbo run typecheck", "lint": "oxlint --report-unused-disable-directives", "test": "turbo run test", + "test:process-reaper": "bun run --cwd apps/server test:process-reaper", "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", "fmt": "oxfmt", "fmt:check": "oxfmt --check", From d90e15d1d7ecba302d6977315977d5c2a748bf36 Mon Sep 17 00:00:00 2001 From: m9d5m <43361569+m-mohamed@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:57:39 -0700 Subject: [PATCH 06/36] fix(server): extend negative repository identity cache ttl (#2083) --- .../src/project/Layers/RepositoryIdentityResolver.test.ts | 8 +++++--- .../src/project/Layers/RepositoryIdentityResolver.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index c6ab7b860b5c..98257b97e5a3 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -140,7 +140,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ); it.effect( - "refreshes cached null identities after the negative TTL when a remote is configured later", + "keeps null identities cached across repeated resolves until the negative TTL expires", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -156,8 +156,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).toBeNull(); + for (const _attempt of [1, 2, 3]) { + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + } yield* TestClock.adjust(Duration.millis(120)); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index e439fa19a66a..307123551bb4 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -68,7 +68,7 @@ function buildRepositoryIdentity(input: { const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); -const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; From 6891c77d3e88167bd1dde5647b5d781f2d16bd2a Mon Sep 17 00:00:00 2001 From: Evan Yu Date: Thu, 16 Apr 2026 20:01:27 -0400 Subject: [PATCH 07/36] Build for Windows ARM (#2080) Co-authored-by: Julius Marminge --- .github/workflows/release.yml | 43 +++- CLAUDE.md | 2 +- bun.lock | 6 +- package.json | 10 +- scripts/build-desktop-artifact.ts | 10 +- scripts/lib/build-target-arch.test.ts | 61 ++++++ scripts/lib/build-target-arch.ts | 50 +++++ .../update-manifest.ts} | 126 ++++++------ scripts/merge-mac-update-manifests.test.ts | 108 ---------- scripts/merge-update-manifests.test.ts | 190 ++++++++++++++++++ scripts/merge-update-manifests.ts | 91 +++++++++ scripts/release-smoke.ts | 78 ++++++- 12 files changed, 581 insertions(+), 194 deletions(-) create mode 100644 scripts/lib/build-target-arch.test.ts create mode 100644 scripts/lib/build-target-arch.ts rename scripts/{merge-mac-update-manifests.ts => lib/update-manifest.ts} (58%) delete mode 100644 scripts/merge-mac-update-manifests.test.ts create mode 100644 scripts/merge-update-manifests.test.ts create mode 100644 scripts/merge-update-manifests.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d72680d3e48..cd7387e9663d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,6 +160,11 @@ jobs: platform: win target: nsis arch: x64 + - label: Windows arm64 + runner: windows-11-arm + platform: win + target: nsis + arch: arm64 steps: - name: Checkout uses: actions/checkout@v6 @@ -272,6 +277,17 @@ jobs: done fi + # Windows updater metadata is channel-specific (for example + # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the + # release job can merge matching arm64/x64 manifests back into one + # canonical manifest per channel. + if [[ "${{ matrix.platform }}" == "win" ]]; then + shopt -s nullglob + for manifest in release-publish/*.yml; do + mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" + done + fi + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: @@ -342,11 +358,36 @@ jobs: for x64_manifest in release-assets/*-mac-x64.yml; do arm64_manifest="${x64_manifest%-x64.yml}.yml" if [[ -f "$arm64_manifest" ]]; then - node scripts/merge-mac-update-manifests.ts "$arm64_manifest" "$x64_manifest" + node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" rm -f "$x64_manifest" fi done + - name: Merge Windows updater manifests + run: | + shopt -s nullglob + found_windows_manifest=false + for x64_manifest in release-assets/*-win-x64.yml; do + arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" + output_manifest="${x64_manifest/-win-x64.yml/.yml}" + if [[ ! -f "$arm64_manifest" ]]; then + echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 + exit 1 + fi + + found_windows_manifest=true + node scripts/merge-update-manifests.ts --platform win \ + "$arm64_manifest" \ + "$x64_manifest" \ + "$output_manifest" + rm -f "$arm64_manifest" "$x64_manifest" + done + + if [[ "$found_windows_manifest" != true ]]; then + echo "No Windows updater manifests found to merge." >&2 + exit 1 + fi + - name: Publish release if: needs.preflight.outputs.previous_tag != '' uses: softprops/action-gh-release@v2 diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d863c..c3170642553f 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/bun.lock b/bun.lock index 64f5a5b916ff..b5b7c6c9cb04 100644 --- a/bun.lock +++ b/bun.lock @@ -202,7 +202,7 @@ "@effect/platform-node-shared": "4.0.0-beta.45", "@effect/sql-sqlite-bun": "4.0.0-beta.45", "@effect/vitest": "4.0.0-beta.45", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.11", "@types/node": "^24.10.13", "effect": "4.0.0-beta.45", "tsdown": "^0.20.3", @@ -770,7 +770,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], @@ -916,7 +916,7 @@ "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], diff --git a/package.json b/package.json index 884a512d30f7..ade7bcce44a6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@effect/sql-sqlite-bun": "4.0.0-beta.45", "@effect/vitest": "4.0.0-beta.45", "@effect/language-service": "0.84.2", - "@types/bun": "^1.3.9", + "@types/bun": "^1.3.11", "@types/node": "^24.10.13", "tsdown": "^0.20.3", "typescript": "^5.7.3", @@ -50,7 +50,9 @@ "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", "dist:desktop:dmg:x64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch x64", "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", - "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", + "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis", + "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", + "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:smoke": "node scripts/release-smoke.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" @@ -73,10 +75,10 @@ "vite": "^8.0.0" }, "engines": { - "bun": "^1.3.9", + "bun": "^1.3.11", "node": "^24.13.1" }, - "packageManager": "bun@1.3.9", + "packageManager": "bun@1.3.11", "msw": { "workerDirectory": [ "apps/web/public" diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 5d3a437d217d..5df46e3a1db0 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -9,6 +9,7 @@ import desktopPackageJson from "../apps/desktop/package.json" with { type: "json import serverPackageJson from "../apps/server/package.json" with { type: "json" }; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; +import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -82,14 +83,7 @@ function getDefaultArch(platform: typeof BuildPlatform.Type): typeof BuildArch.T return "x64"; } - if (process.arch === "arm64" && config.archChoices.includes("arm64")) { - return "arm64"; - } - if (process.arch === "x64" && config.archChoices.includes("x64")) { - return "x64"; - } - - return config.archChoices[0] ?? "x64"; + return getDefaultBuildArch(platform, process.arch, process.env, config); } class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts new file mode 100644 index 000000000000..56251d3ffd1e --- /dev/null +++ b/scripts/lib/build-target-arch.test.ts @@ -0,0 +1,61 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { getDefaultBuildArch, resolveHostProcessArch } from "./build-target-arch.ts"; + +describe("build-target-arch", () => { + it("prefers arm64 for Windows-on-Arm hosts running x64 emulation", () => { + // Windows-on-Arm can run an x64 Node process under emulation while still + // exposing the real host CPU via PROCESSOR_ARCHITEW6432. + const hostArch = resolveHostProcessArch("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // Windows exposes the real host CPU here when x64 runs under ARM emulation. + }); + + assert.equal(hostArch, "arm64"); + }); + + it("falls back to x64 for native x64 Windows hosts", () => { + const hostArch = resolveHostProcessArch("win32", "x64", { + PROCESSOR_ARCHITECTURE: "AMD64", // Both the process and the Windows host are native x64. + }); + + assert.equal(hostArch, "x64"); + }); + + it("keeps arm64 when the current process is already native arm64", () => { + const hostArch = resolveHostProcessArch("win32", "arm64", {}); + + assert.equal(hostArch, "arm64"); + }); + + it("uses the resolved host arch when selecting the default Windows build arch", () => { + // This mirrors the packaging script's default-path behavior: the current + // process is x64, but the machine itself is ARM64, so the default build + // target should be win-arm64 rather than win-x64. + const arch = getDefaultBuildArch( + "win", + "x64", + { + PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. + PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. + }, + { archChoices: ["x64", "arm64"] }, + ); + + assert.equal(arch, "arm64"); + }); + + it("does not apply Windows host env heuristics for non-Windows targets", () => { + const arch = getDefaultBuildArch( + "linux", + "x64", + { + PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITEW6432: "ARM64", + }, + { archChoices: ["x64", "arm64"] }, + ); + + assert.equal(arch, "x64"); + }); +}); diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts new file mode 100644 index 000000000000..8c39648414ac --- /dev/null +++ b/scripts/lib/build-target-arch.ts @@ -0,0 +1,50 @@ +export type BuildArch = "arm64" | "x64" | "universal"; +export type BuildPlatform = "mac" | "linux" | "win"; + +interface PlatformConfig { + readonly archChoices: ReadonlyArray; +} + +function normalizeWindowsArch(value: string | undefined): BuildArch | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return undefined; + if (normalized.includes("arm64") || normalized === "aarch64") return "arm64"; + if (normalized.includes("amd64") || normalized.includes("x64")) return "x64"; + return undefined; +} + +export function resolveHostProcessArch( + platform: NodeJS.Platform, + processArch: NodeJS.Architecture, + env: NodeJS.ProcessEnv, +): BuildArch | undefined { + if (processArch === "arm64") return "arm64"; + if (processArch === "x64") { + if (platform !== "win32") return "x64"; + + // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host + // still reports ARM64 via the processor environment variables. + return ( + normalizeWindowsArch(env.PROCESSOR_ARCHITEW6432) ?? + normalizeWindowsArch(env.PROCESSOR_ARCHITECTURE) ?? + "x64" + ); + } + return undefined; +} + +export function getDefaultBuildArch( + platform: BuildPlatform, + processArch: NodeJS.Architecture, + env: NodeJS.ProcessEnv, + platformConfig: PlatformConfig, +): BuildArch { + const hostPlatform: NodeJS.Platform = + platform === "win" ? "win32" : platform === "mac" ? "darwin" : "linux"; + const hostArch = resolveHostProcessArch(hostPlatform, processArch, env); + if (hostArch && platformConfig.archChoices.includes(hostArch)) { + return hostArch; + } + + return platformConfig.archChoices[0] ?? "x64"; +} diff --git a/scripts/merge-mac-update-manifests.ts b/scripts/lib/update-manifest.ts similarity index 58% rename from scripts/merge-mac-update-manifests.ts rename to scripts/lib/update-manifest.ts index c59bc76b9b00..191a3c0e5353 100644 --- a/scripts/merge-mac-update-manifests.ts +++ b/scripts/lib/update-manifest.ts @@ -1,23 +1,19 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -interface MacUpdateFile { +export interface UpdateManifestFile { readonly url: string; readonly sha512: string; readonly size: number; } -type MacUpdateScalar = string | number | boolean; +export type UpdateManifestScalar = string | number | boolean; -interface MacUpdateManifest { +export interface UpdateManifest { readonly version: string; readonly releaseDate: string; - readonly files: ReadonlyArray; - readonly extras: Readonly>; + readonly files: ReadonlyArray; + readonly extras: Readonly>; } -interface MutableMacUpdateFile { +interface MutableUpdateManifestFile { url?: string; sha512?: string; size?: number; @@ -31,10 +27,11 @@ function stripSingleQuotes(value: string): string { } function parseFileRecord( - currentFile: MutableMacUpdateFile | null, + currentFile: MutableUpdateManifestFile | null, sourcePath: string, lineNumber: number, -): MacUpdateFile | null { + platformLabel: string, +): UpdateManifestFile | null { if (currentFile === null) { return null; } @@ -44,7 +41,7 @@ function parseFileRecord( typeof currentFile.size !== "number" ) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: incomplete file entry.`, ); } return { @@ -54,7 +51,7 @@ function parseFileRecord( }; } -function parseScalarValue(rawValue: string): MacUpdateScalar { +function parseScalarValue(rawValue: string): UpdateManifestScalar { const trimmed = rawValue.trim(); const isQuoted = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2; const value = isQuoted ? trimmed.slice(1, -1).replace(/''/g, "'") : trimmed; @@ -67,14 +64,18 @@ function parseScalarValue(rawValue: string): MacUpdateScalar { return value; } -export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpdateManifest { +export function parseUpdateManifest( + raw: string, + sourcePath: string, + platformLabel: string, +): UpdateManifest { const lines = raw.split(/\r?\n/); - const files: MacUpdateFile[] = []; - const extras: Record = {}; + const files: UpdateManifestFile[] = []; + const extras: Record = {}; let version: string | null = null; let releaseDate: string | null = null; let inFiles = false; - let currentFile: MutableMacUpdateFile | null = null; + let currentFile: MutableUpdateManifestFile | null = null; for (const [index, rawLine] of lines.entries()) { const lineNumber = index + 1; @@ -83,7 +84,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda const fileUrlMatch = line.match(/^ - url:\s*(.+)$/); if (fileUrlMatch?.[1]) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); if (finalized) files.push(finalized); currentFile = { url: stripSingleQuotes(fileUrlMatch[1].trim()) }; inFiles = true; @@ -94,7 +95,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (fileShaMatch?.[1]) { if (currentFile === null) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: sha512 without a file entry.`, ); } currentFile.sha512 = stripSingleQuotes(fileShaMatch[1].trim()); @@ -105,7 +106,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (fileSizeMatch?.[1]) { if (currentFile === null) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: size without a file entry.`, ); } currentFile.size = Number(fileSizeMatch[1]); @@ -118,7 +119,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda } if (inFiles && currentFile !== null) { - const finalized = parseFileRecord(currentFile, sourcePath, lineNumber); + const finalized = parseFileRecord(currentFile, sourcePath, lineNumber, platformLabel); if (finalized) files.push(finalized); currentFile = null; } @@ -127,7 +128,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda const topLevelMatch = line.match(/^([A-Za-z][A-Za-z0-9]*):\s*(.+)$/); if (!topLevelMatch?.[1] || topLevelMatch[2] === undefined) { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: unsupported line '${line}'.`, ); } @@ -137,7 +138,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (key === "version") { if (typeof value !== "string") { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: version must be a string.`, ); } version = value; @@ -147,7 +148,7 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda if (key === "releaseDate") { if (typeof value !== "string") { throw new Error( - `Invalid macOS update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, + `Invalid ${platformLabel} update manifest at ${sourcePath}:${lineNumber}: releaseDate must be a string.`, ); } releaseDate = value; @@ -161,17 +162,19 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda extras[key] = value; } - const finalized = parseFileRecord(currentFile, sourcePath, lines.length); + const finalized = parseFileRecord(currentFile, sourcePath, lines.length, platformLabel); if (finalized) files.push(finalized); if (!version) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing version.`); + throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing version.`); } if (!releaseDate) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing releaseDate.`); + throw new Error( + `Invalid ${platformLabel} update manifest at ${sourcePath}: missing releaseDate.`, + ); } if (files.length === 0) { - throw new Error(`Invalid macOS update manifest at ${sourcePath}: missing files.`); + throw new Error(`Invalid ${platformLabel} update manifest at ${sourcePath}: missing files.`); } return { @@ -183,16 +186,17 @@ export function parseMacUpdateManifest(raw: string, sourcePath: string): MacUpda } function mergeExtras( - primary: Readonly>, - secondary: Readonly>, -): Record { - const merged: Record = { ...primary }; + primary: Readonly>, + secondary: Readonly>, + platformLabel: string, +): Record { + const merged: Record = { ...primary }; for (const [key, value] of Object.entries(secondary)) { const existing = merged[key]; if (existing !== undefined && existing !== value) { throw new Error( - `Cannot merge macOS update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, + `Cannot merge ${platformLabel} update manifests: conflicting '${key}' values ('${existing}' vs '${value}').`, ); } merged[key] = value; @@ -201,22 +205,23 @@ function mergeExtras( return merged; } -export function mergeMacUpdateManifests( - primary: MacUpdateManifest, - secondary: MacUpdateManifest, -): MacUpdateManifest { +export function mergeUpdateManifests( + primary: UpdateManifest, + secondary: UpdateManifest, + platformLabel: string, +): UpdateManifest { if (primary.version !== secondary.version) { throw new Error( - `Cannot merge macOS update manifests with different versions (${primary.version} vs ${secondary.version}).`, + `Cannot merge ${platformLabel} update manifests with different versions (${primary.version} vs ${secondary.version}).`, ); } - const filesByUrl = new Map(); + const filesByUrl = new Map(); for (const file of [...primary.files, ...secondary.files]) { const existing = filesByUrl.get(file.url); if (existing && (existing.sha512 !== file.sha512 || existing.size !== file.size)) { throw new Error( - `Cannot merge macOS update manifests: conflicting file entry for ${file.url}.`, + `Cannot merge ${platformLabel} update manifests: conflicting file entry for ${file.url}.`, ); } filesByUrl.set(file.url, file); @@ -227,7 +232,7 @@ export function mergeMacUpdateManifests( releaseDate: primary.releaseDate >= secondary.releaseDate ? primary.releaseDate : secondary.releaseDate, files: [...filesByUrl.values()], - extras: mergeExtras(primary.extras, secondary.extras), + extras: mergeExtras(primary.extras, secondary.extras, platformLabel), }; } @@ -235,15 +240,20 @@ function quoteYamlString(value: string): string { return `'${value.replace(/'/g, "''")}'`; } -function serializeScalarValue(value: MacUpdateScalar): string { +function serializeScalarValue(value: UpdateManifestScalar): string { if (typeof value === "string") { return quoteYamlString(value); } return String(value); } -export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string { - const lines = [`version: ${manifest.version}`, "files:"]; +export function serializeUpdateManifest( + manifest: UpdateManifest, + options: { + readonly platformLabel: string; + }, +): string { + const lines = [`version: ${quoteYamlString(manifest.version)}`, "files:"]; for (const file of manifest.files) { lines.push(` - url: ${file.url}`); @@ -254,7 +264,9 @@ export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string for (const key of Object.keys(manifest.extras).toSorted()) { const value = manifest.extras[key]; if (value === undefined) { - throw new Error(`Cannot serialize macOS update manifest: missing value for '${key}'.`); + throw new Error( + `Cannot serialize ${options.platformLabel} update manifest: missing value for '${key}'.`, + ); } lines.push(`${key}: ${serializeScalarValue(value)}`); } @@ -263,25 +275,3 @@ export function serializeMacUpdateManifest(manifest: MacUpdateManifest): string lines.push(""); return lines.join("\n"); } - -function main(args: ReadonlyArray): void { - const [arm64PathArg, x64PathArg, outputPathArg] = args; - if (!arm64PathArg || !x64PathArg) { - throw new Error( - "Usage: node scripts/merge-mac-update-manifests.ts [output-path]", - ); - } - - const arm64Path = resolve(arm64PathArg); - const x64Path = resolve(x64PathArg); - const outputPath = resolve(outputPathArg ?? arm64PathArg); - - const arm64Manifest = parseMacUpdateManifest(readFileSync(arm64Path, "utf8"), arm64Path); - const x64Manifest = parseMacUpdateManifest(readFileSync(x64Path, "utf8"), x64Path); - const merged = mergeMacUpdateManifests(arm64Manifest, x64Manifest); - writeFileSync(outputPath, serializeMacUpdateManifest(merged)); -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - main(process.argv.slice(2)); -} diff --git a/scripts/merge-mac-update-manifests.test.ts b/scripts/merge-mac-update-manifests.test.ts deleted file mode 100644 index 22d2e7627e91..000000000000 --- a/scripts/merge-mac-update-manifests.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { - mergeMacUpdateManifests, - parseMacUpdateManifest, - serializeMacUpdateManifest, -} from "./merge-mac-update-manifests.ts"; - -describe("merge-mac-update-manifests", () => { - it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => { - const arm64 = parseMacUpdateManifest( - `version: 0.0.4 -files: - - url: T3-Code-0.0.4-arm64.zip - sha512: arm64zip - size: 125621344 - - url: T3-Code-0.0.4-arm64.dmg - sha512: arm64dmg - size: 131754935 -path: T3-Code-0.0.4-arm64.zip -sha512: arm64zip -releaseDate: '2026-03-07T10:32:14.587Z' -`, - "latest-mac.yml", - ); - - const x64 = parseMacUpdateManifest( - `version: 0.0.4 -files: - - url: T3-Code-0.0.4-x64.zip - sha512: x64zip - size: 132000112 - - url: T3-Code-0.0.4-x64.dmg - sha512: x64dmg - size: 138148807 -path: T3-Code-0.0.4-x64.zip -sha512: x64zip -releaseDate: '2026-03-07T10:36:07.540Z' -`, - "latest-mac-x64.yml", - ); - - const merged = mergeMacUpdateManifests(arm64, x64); - - assert.equal(merged.version, "0.0.4"); - assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); - assert.deepStrictEqual( - merged.files.map((file) => file.url), - [ - "T3-Code-0.0.4-arm64.zip", - "T3-Code-0.0.4-arm64.dmg", - "T3-Code-0.0.4-x64.zip", - "T3-Code-0.0.4-x64.dmg", - ], - ); - - const serialized = serializeMacUpdateManifest(merged); - assert.ok(!serialized.includes("path:")); - assert.equal((serialized.match(/- url:/g) ?? []).length, 4); - }); - - it("rejects mismatched manifest versions", () => { - const arm64 = parseMacUpdateManifest( - `version: 0.0.4 -files: - - url: T3-Code-0.0.4-arm64.zip - sha512: arm64zip - size: 1 -releaseDate: '2026-03-07T10:32:14.587Z' -`, - "latest-mac.yml", - ); - - const x64 = parseMacUpdateManifest( - `version: 0.0.5 -files: - - url: T3-Code-0.0.5-x64.zip - sha512: x64zip - size: 1 -releaseDate: '2026-03-07T10:36:07.540Z' -`, - "latest-mac-x64.yml", - ); - - assert.throws(() => mergeMacUpdateManifests(arm64, x64), /different versions/); - }); - - it("preserves quoted scalars as strings", () => { - const manifest = parseMacUpdateManifest( - `version: '1.0' -files: - - url: T3-Code-1.0-x64.zip - sha512: zipsha - size: 1 -releaseName: 'true' -minimumSystemVersion: '13.0' -stagingPercentage: 50 -releaseDate: '2026-03-07T10:36:07.540Z' -`, - "latest-mac.yml", - ); - - assert.equal(manifest.version, "1.0"); - assert.equal(manifest.extras.releaseName, "true"); - assert.equal(manifest.extras.minimumSystemVersion, "13.0"); - assert.equal(manifest.extras.stagingPercentage, 50); - }); -}); diff --git a/scripts/merge-update-manifests.test.ts b/scripts/merge-update-manifests.test.ts new file mode 100644 index 000000000000..33ebacfb059a --- /dev/null +++ b/scripts/merge-update-manifests.test.ts @@ -0,0 +1,190 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + mergePlatformUpdateManifests, + parsePlatformUpdateManifest, + serializePlatformUpdateManifest, +} from "./merge-update-manifests.ts"; + +describe("merge-update-manifests", () => { + it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => { + const arm64 = parsePlatformUpdateManifest( + "mac", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 125621344 + - url: T3-Code-0.0.4-arm64.dmg + sha512: arm64dmg + size: 131754935 +path: T3-Code-0.0.4-arm64.zip +sha512: arm64zip +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-mac.yml", + ); + + const x64 = parsePlatformUpdateManifest( + "mac", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.zip + sha512: x64zip + size: 132000112 + - url: T3-Code-0.0.4-x64.dmg + sha512: x64dmg + size: 138148807 +path: T3-Code-0.0.4-x64.zip +sha512: x64zip +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac-x64.yml", + ); + + const merged = mergePlatformUpdateManifests("mac", arm64, x64); + + assert.equal(merged.version, "0.0.4"); + assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); + assert.deepStrictEqual( + merged.files.map((file) => file.url), + [ + "T3-Code-0.0.4-arm64.zip", + "T3-Code-0.0.4-arm64.dmg", + "T3-Code-0.0.4-x64.zip", + "T3-Code-0.0.4-x64.dmg", + ], + ); + + const serialized = serializePlatformUpdateManifest("mac", merged); + assert.ok(!serialized.includes("path:")); + assert.equal((serialized.match(/- url:/g) ?? []).length, 4); + }); + + it("merges arm64 and x64 Windows update manifests into one multi-arch manifest", () => { + const arm64 = parsePlatformUpdateManifest( + "win", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 125621344 + - url: T3-Code-0.0.4-arm64.exe.blockmap + sha512: arm64blockmap + size: 131754 +path: T3-Code-0.0.4-arm64.exe +sha512: arm64exe +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-win-arm64.yml", + ); + + const x64 = parsePlatformUpdateManifest( + "win", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.exe + sha512: x64exe + size: 132000112 + - url: T3-Code-0.0.4-x64.exe.blockmap + sha512: x64blockmap + size: 138148 +path: T3-Code-0.0.4-x64.exe +sha512: x64exe +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + const merged = mergePlatformUpdateManifests("win", arm64, x64); + + assert.equal(merged.version, "0.0.4"); + assert.equal(merged.releaseDate, "2026-03-07T10:36:07.540Z"); + assert.deepStrictEqual( + merged.files.map((file) => file.url), + [ + "T3-Code-0.0.4-arm64.exe", + "T3-Code-0.0.4-arm64.exe.blockmap", + "T3-Code-0.0.4-x64.exe", + "T3-Code-0.0.4-x64.exe.blockmap", + ], + ); + + const serialized = serializePlatformUpdateManifest("win", merged); + assert.ok(!serialized.includes("path:")); + assert.equal((serialized.match(/- url:/g) ?? []).length, 4); + }); + + it("rejects mismatched manifest versions", () => { + const primary = parsePlatformUpdateManifest( + "win", + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 1 +releaseDate: '2026-03-07T10:32:14.587Z' +`, + "latest-win-arm64.yml", + ); + + const secondary = parsePlatformUpdateManifest( + "win", + `version: 0.0.5 +files: + - url: T3-Code-0.0.5-x64.exe + sha512: x64exe + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + assert.throws( + () => mergePlatformUpdateManifests("win", primary, secondary), + /different versions/, + ); + }); + + it("preserves quoted scalars as strings", () => { + const manifest = parsePlatformUpdateManifest( + "mac", + `version: '1.0' +files: + - url: T3-Code-1.0-x64.zip + sha512: zipsha + size: 1 +releaseName: 'true' +minimumSystemVersion: '13.0' +stagingPercentage: 50 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-mac.yml", + ); + + assert.equal(manifest.version, "1.0"); + assert.equal(manifest.extras.releaseName, "true"); + assert.equal(manifest.extras.minimumSystemVersion, "13.0"); + assert.equal(manifest.extras.stagingPercentage, 50); + }); + + it("round-trips numeric-looking versions as strings", () => { + const original = parsePlatformUpdateManifest( + "win", + `version: '1.0' +files: + - url: T3-Code-1.0-x64.exe + sha512: exesha + size: 1 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + "latest-win-x64.yml", + ); + + const serialized = serializePlatformUpdateManifest("win", original); + assert.ok(serialized.includes("version: '1.0'")); + + const reparsed = parsePlatformUpdateManifest("win", serialized, "latest-win-x64.yml"); + assert.equal(reparsed.version, "1.0"); + }); +}); diff --git a/scripts/merge-update-manifests.ts b/scripts/merge-update-manifests.ts new file mode 100644 index 000000000000..1ff74d95e8e1 --- /dev/null +++ b/scripts/merge-update-manifests.ts @@ -0,0 +1,91 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + mergeUpdateManifests, + parseUpdateManifest, + serializeUpdateManifest, + type UpdateManifest, +} from "./lib/update-manifest.ts"; + +export type UpdateManifestPlatform = "mac" | "win"; + +function getPlatformLabel(platform: UpdateManifestPlatform): string { + return platform === "mac" ? "macOS" : "Windows"; +} + +export function parsePlatformUpdateManifest( + platform: UpdateManifestPlatform, + raw: string, + sourcePath: string, +): UpdateManifest { + return parseUpdateManifest(raw, sourcePath, getPlatformLabel(platform)); +} + +export function mergePlatformUpdateManifests( + platform: UpdateManifestPlatform, + primary: UpdateManifest, + secondary: UpdateManifest, +): UpdateManifest { + return mergeUpdateManifests(primary, secondary, getPlatformLabel(platform)); +} + +export function serializePlatformUpdateManifest( + platform: UpdateManifestPlatform, + manifest: UpdateManifest, +): string { + return serializeUpdateManifest(manifest, { + platformLabel: getPlatformLabel(platform), + }); +} + +function parseArgs(args: ReadonlyArray): { + platform: UpdateManifestPlatform; + primaryPath: string; + secondaryPath: string; + outputPath: string; +} { + const [platformFlag, platformValue, primaryPathArg, secondaryPathArg, outputPathArg] = args; + if (platformFlag !== "--platform" || (platformValue !== "mac" && platformValue !== "win")) { + throw new Error( + "Usage: node scripts/merge-update-manifests.ts --platform [output-path]", + ); + } + if (!primaryPathArg || !secondaryPathArg) { + throw new Error( + "Usage: node scripts/merge-update-manifests.ts --platform [output-path]", + ); + } + + const primaryPath = resolve(primaryPathArg); + const secondaryPath = resolve(secondaryPathArg); + const outputPath = resolve(outputPathArg ?? primaryPathArg); + + return { + platform: platformValue, + primaryPath, + secondaryPath, + outputPath, + }; +} + +function main(args: ReadonlyArray): void { + const { platform, primaryPath, secondaryPath, outputPath } = parseArgs(args); + const primaryManifest = parsePlatformUpdateManifest( + platform, + readFileSync(primaryPath, "utf8"), + primaryPath, + ); + const secondaryManifest = parsePlatformUpdateManifest( + platform, + readFileSync(secondaryPath, "utf8"), + secondaryPath, + ); + const merged = mergePlatformUpdateManifests(platform, primaryManifest, secondaryManifest); + writeFileSync(outputPath, serializePlatformUpdateManifest(platform, merged)); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)); +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 960730a3547c..9c4a41510689 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -70,6 +70,48 @@ releaseDate: '2026-03-08T10:36:07.540Z' return { arm64Path, x64Path }; } +function writeWindowsManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { + const assetDirectory = resolve(targetRoot, "release-assets"); + mkdirSync(assetDirectory, { recursive: true }); + + const arm64Path = resolve(assetDirectory, "latest-win-arm64.yml"); + const x64Path = resolve(assetDirectory, "latest-win-x64.yml"); + + writeFileSync( + arm64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-arm64.exe + sha512: arm64exe + size: 126621344 + - url: T3-Code-9.9.9-smoke.0-arm64.exe.blockmap + sha512: arm64blockmap + size: 152344 +path: T3-Code-9.9.9-smoke.0-arm64.exe +sha512: arm64exe +releaseDate: '2026-03-08T10:32:14.587Z' +`, + ); + + writeFileSync( + x64Path, + `version: 9.9.9-smoke.0 +files: + - url: T3-Code-9.9.9-smoke.0-x64.exe + sha512: x64exe + size: 132000112 + - url: T3-Code-9.9.9-smoke.0-x64.exe.blockmap + sha512: x64blockmap + size: 160112 +path: T3-Code-9.9.9-smoke.0-x64.exe +sha512: x64exe +releaseDate: '2026-03-08T10:36:07.540Z' +`, + ); + + return { arm64Path, x64Path }; +} + function assertContains(haystack: string, needle: string, message: string): void { if (!haystack.includes(needle)) { throw new Error(message); @@ -144,7 +186,13 @@ try { const { arm64Path, x64Path } = writeMacManifestFixtures(tempRoot); execFileSync( process.execPath, - [resolve(repoRoot, "scripts/merge-mac-update-manifests.ts"), arm64Path, x64Path], + [ + resolve(repoRoot, "scripts/merge-update-manifests.ts"), + "--platform", + "mac", + arm64Path, + x64Path, + ], { cwd: repoRoot, stdio: "inherit", @@ -163,6 +211,34 @@ try { "Merged manifest is missing the x64 asset.", ); + const { arm64Path: winArm64Path, x64Path: winX64Path } = writeWindowsManifestFixtures(tempRoot); + execFileSync( + process.execPath, + [ + resolve(repoRoot, "scripts/merge-update-manifests.ts"), + "--platform", + "win", + winArm64Path, + winX64Path, + ], + { + cwd: repoRoot, + stdio: "inherit", + }, + ); + + const mergedWindowsManifest = readFileSync(winArm64Path, "utf8"); + assertContains( + mergedWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged Windows manifest is missing the x64 asset.", + ); + console.log("Release smoke checks passed."); } finally { rmSync(tempRoot, { recursive: true, force: true }); From b7df3dfca0b6368587ce1c0322111c4df49ff3e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 17:56:16 -0700 Subject: [PATCH 08/36] [codex] Fix Windows release manifest publishing (#2095) --- .github/workflows/release.yml | 4 + scripts/release-smoke.ts | 157 +++++++++++++++++++++++++++++++--- 2 files changed, 149 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd7387e9663d..857d951d7500 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -368,6 +368,10 @@ jobs: shopt -s nullglob found_windows_manifest=false for x64_manifest in release-assets/*-win-x64.yml; do + if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then + continue + fi + arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" output_manifest="${x64_manifest/-win-x64.yml/.yml}" if [[ ! -f "$arm64_manifest" ]]; then diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 9c4a41510689..0d95b4945d49 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -1,5 +1,13 @@ import { execFileSync } from "node:child_process"; -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -70,12 +78,15 @@ releaseDate: '2026-03-08T10:36:07.540Z' return { arm64Path, x64Path }; } -function writeWindowsManifestFixtures(targetRoot: string): { arm64Path: string; x64Path: string } { +function writeWindowsManifestFixtures( + targetRoot: string, + channel: string, +): { arm64Path: string; x64Path: string } { const assetDirectory = resolve(targetRoot, "release-assets"); mkdirSync(assetDirectory, { recursive: true }); - const arm64Path = resolve(assetDirectory, "latest-win-arm64.yml"); - const x64Path = resolve(assetDirectory, "latest-win-x64.yml"); + const arm64Path = resolve(assetDirectory, `${channel}-win-arm64.yml`); + const x64Path = resolve(assetDirectory, `${channel}-win-x64.yml`); writeFileSync( arm64Path, @@ -112,12 +123,46 @@ releaseDate: '2026-03-08T10:36:07.540Z' return { arm64Path, x64Path }; } +function writeWindowsBuilderDebugFixtures(targetRoot: string): { + arm64Path: string; + x64Path: string; +} { + const assetDirectory = resolve(targetRoot, "release-assets"); + mkdirSync(assetDirectory, { recursive: true }); + + const arm64Path = resolve(assetDirectory, "builder-debug-win-arm64.yml"); + const x64Path = resolve(assetDirectory, "builder-debug-win-x64.yml"); + const debugFixture = `arm64: + firstOrDefaultFilePatterns: + - '**/*' +nsis: + script: |- + !include "example.nsh" +`; + + writeFileSync(arm64Path, debugFixture); + writeFileSync(x64Path, debugFixture); + + return { arm64Path, x64Path }; +} function assertContains(haystack: string, needle: string, message: string): void { if (!haystack.includes(needle)) { throw new Error(message); } } +function assertExists(path: string, message: string): void { + if (!existsSync(path)) { + throw new Error(message); + } +} + +function assertMissing(path: string, message: string): void { + if (existsSync(path)) { + throw new Error(message); + } +} + const tempRoot = mkdtempSync(join(tmpdir(), "t3-release-smoke-")); try { @@ -211,15 +256,52 @@ try { "Merged manifest is missing the x64 asset.", ); - const { arm64Path: winArm64Path, x64Path: winX64Path } = writeWindowsManifestFixtures(tempRoot); + const { arm64Path: winArm64Path, x64Path: winX64Path } = writeWindowsManifestFixtures( + tempRoot, + "latest", + ); + const mergedWindowsManifestPath = resolve(tempRoot, "release-assets/latest.yml"); + const { arm64Path: nightlyWinArm64Path, x64Path: nightlyWinX64Path } = + writeWindowsManifestFixtures(tempRoot, "nightly"); + const mergedNightlyWindowsManifestPath = resolve(tempRoot, "release-assets/nightly.yml"); + const { arm64Path: previewWinArm64Path, x64Path: previewWinX64Path } = + writeWindowsManifestFixtures(tempRoot, "preview"); + const mergedPreviewWindowsManifestPath = resolve(tempRoot, "release-assets/preview.yml"); + const { arm64Path: winDebugArm64Path, x64Path: winDebugX64Path } = + writeWindowsBuilderDebugFixtures(tempRoot); execFileSync( - process.execPath, + "bash", [ - resolve(repoRoot, "scripts/merge-update-manifests.ts"), - "--platform", - "win", - winArm64Path, - winX64Path, + "-lc", + ` + release_assets_dir=${JSON.stringify(resolve(tempRoot, "release-assets"))} + shopt -s nullglob + found_windows_manifest=false + for x64_manifest in "$release_assets_dir"/*-win-x64.yml; do + if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then + continue + fi + + arm64_manifest="\${x64_manifest/-x64.yml/-arm64.yml}" + output_manifest="\${x64_manifest/-win-x64.yml/.yml}" + if [[ ! -f "$arm64_manifest" ]]; then + echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 + exit 1 + fi + + found_windows_manifest=true + node ${JSON.stringify(resolve(repoRoot, "scripts/merge-update-manifests.ts"))} --platform win \ + "$arm64_manifest" \ + "$x64_manifest" \ + "$output_manifest" + rm -f "$arm64_manifest" "$x64_manifest" + done + + if [[ "$found_windows_manifest" != true ]]; then + echo "No Windows updater manifests found to merge." >&2 + exit 1 + fi + `, ], { cwd: repoRoot, @@ -227,7 +309,7 @@ try { }, ); - const mergedWindowsManifest = readFileSync(winArm64Path, "utf8"); + const mergedWindowsManifest = readFileSync(mergedWindowsManifestPath, "utf8"); assertContains( mergedWindowsManifest, "T3-Code-9.9.9-smoke.0-arm64.exe", @@ -238,6 +320,57 @@ try { "T3-Code-9.9.9-smoke.0-x64.exe", "Merged Windows manifest is missing the x64 asset.", ); + const mergedNightlyWindowsManifest = readFileSync(mergedNightlyWindowsManifestPath, "utf8"); + assertContains( + mergedNightlyWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged nightly Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedNightlyWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged nightly Windows manifest is missing the x64 asset.", + ); + const mergedPreviewWindowsManifest = readFileSync(mergedPreviewWindowsManifestPath, "utf8"); + assertContains( + mergedPreviewWindowsManifest, + "T3-Code-9.9.9-smoke.0-arm64.exe", + "Merged preview Windows manifest is missing the arm64 asset.", + ); + assertContains( + mergedPreviewWindowsManifest, + "T3-Code-9.9.9-smoke.0-x64.exe", + "Merged preview Windows manifest is missing the x64 asset.", + ); + assertMissing( + winArm64Path, + "Windows release smoke unexpectedly kept the arm64 updater manifest.", + ); + assertMissing(winX64Path, "Windows release smoke unexpectedly kept the x64 updater manifest."); + assertMissing( + nightlyWinArm64Path, + "Windows release smoke unexpectedly kept the nightly arm64 updater manifest.", + ); + assertMissing( + nightlyWinX64Path, + "Windows release smoke unexpectedly kept the nightly x64 updater manifest.", + ); + assertMissing( + previewWinArm64Path, + "Windows release smoke unexpectedly kept the preview arm64 updater manifest.", + ); + assertMissing( + previewWinX64Path, + "Windows release smoke unexpectedly kept the preview x64 updater manifest.", + ); + assertExists( + winDebugArm64Path, + "Windows release smoke unexpectedly removed the arm64 builder debug fixture.", + ); + assertExists( + winDebugX64Path, + "Windows release smoke unexpectedly removed the x64 builder debug fixture.", + ); console.log("Release smoke checks passed."); } finally { From 549043863a3ca1f955c66fd31bda27ea3dc97c18 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 19:36:39 -0700 Subject: [PATCH 09/36] fix: guard against missing sidebarProjectGroupingOverrides in client settings (#2099) --- apps/desktop/src/clientPersistence.ts | 17 +++++++++++++++-- apps/web/src/components/Sidebar.tsx | 2 +- apps/web/src/hooks/useSettings.ts | 2 +- apps/web/src/logicalProject.ts | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts index 183de1a971b1..ad08a0036f13 100644 --- a/apps/desktop/src/clientPersistence.ts +++ b/apps/desktop/src/clientPersistence.ts @@ -1,8 +1,13 @@ import * as FS from "node:fs"; import * as Path from "node:path"; -import type { ClientSettings, PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { + ClientSettingsSchema, + type ClientSettings, + type PersistedSavedEnvironmentRecord, +} from "@t3tools/contracts"; import { Predicate } from "effect"; +import * as Schema from "effect/Schema"; interface ClientSettingsDocument { readonly settings: ClientSettings; @@ -83,7 +88,15 @@ function toPersistedSavedEnvironmentRecord( } export function readClientSettings(settingsPath: string): ClientSettings | null { - return readJsonFile(settingsPath)?.settings ?? null; + const raw = readJsonFile(settingsPath)?.settings; + if (!raw) { + return null; + } + try { + return Schema.decodeUnknownSync(ClientSettingsSchema)(raw); + } catch { + return null; + } } export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1c66af57be04..c3fae158b149 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1379,7 +1379,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const overrideKey = deriveProjectGroupingOverrideKey(member); setProjectGroupingTarget(member); setProjectGroupingSelection( - projectGroupingSettings.sidebarProjectGroupingOverrides[overrideKey] ?? "inherit", + projectGroupingSettings.sidebarProjectGroupingOverrides?.[overrideKey] ?? "inherit", ); }, [projectGroupingSettings.sidebarProjectGroupingOverrides], diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 664d5ee3bb01..c2301b23a3fc 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -64,7 +64,7 @@ async function hydrateClientSettings(): Promise { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); if (persistedSettings) { - replaceClientSettingsSnapshot(persistedSettings); + replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } } catch (error) { console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 6b84fa6dc342..d30bb60ca06b 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -70,7 +70,7 @@ export function resolveProjectGroupingMode( settings: ProjectGroupingSettings, ): SidebarProjectGroupingMode { return ( - settings.sidebarProjectGroupingOverrides[deriveProjectGroupingOverrideKey(project)] ?? + settings.sidebarProjectGroupingOverrides?.[deriveProjectGroupingOverrideKey(project)] ?? settings.sidebarProjectGroupingMode ); } From b2cca674dfdf93430460fe08e1ce0d857e30bd83 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 19:42:17 -0700 Subject: [PATCH 10/36] ci(release): install deps before finalize version bump (#2100) --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 857d951d7500..3f61235ae8dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -474,6 +474,9 @@ jobs: with: node-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile + - id: update_versions name: Update version strings env: From 2d87574e62d616d890497d5b7d48201aa06d4dce Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 03:00:57 +0000 Subject: [PATCH 11/36] chore(release): prepare v0.0.20 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- bun.lock | 8 ++++---- packages/contracts/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a38ffd2df123..86e61f18206f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.17", + "version": "0.0.20", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/server/package.json b/apps/server/package.json index aefbb4317d37..a5d5d986ecd2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.17", + "version": "0.0.20", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 362eeecc023f..b18defebbe34 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.17", + "version": "0.0.20", "private": true, "type": "module", "scripts": { diff --git a/bun.lock b/bun.lock index b5b7c6c9cb04..24918d9e0586 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.17", + "version": "0.0.20", "dependencies": { "effect": "catalog:", "electron": "40.6.0", @@ -42,7 +42,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.17", + "version": "0.0.20", "bin": { "t3": "./dist/bin.mjs", }, @@ -71,7 +71,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.17", + "version": "0.0.20", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -136,7 +136,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.17", + "version": "0.0.20", "dependencies": { "effect": "catalog:", }, diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 63ce74a1ab04..8b499267f663 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.17", + "version": "0.0.20", "private": true, "files": [ "dist" From 505db9f69614804da733105d390f0f12b04708f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 20:12:09 -0700 Subject: [PATCH 12/36] try out blacksmith for releases (#2101) --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 95 ++++++++++++++++++----------------- 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3ad2d93548c..7d2cb7479a9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: jobs: quality: name: Format, Lint, Typecheck, Test, Browser Test, Build - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -76,7 +76,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f61235ae8dc..1cf200051b50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ permissions: jobs: preflight: name: Preflight - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: release_channel: ${{ steps.release_meta.outputs.release_channel }} @@ -141,30 +141,30 @@ jobs: matrix: include: - label: macOS arm64 - runner: macos-14 + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: arm64 - label: macOS x64 - runner: macos-15-intel + runner: blacksmith-12vcpu-macos-26 platform: mac target: dmg arch: x64 - label: Linux x64 - runner: ubuntu-24.04 + runner: blacksmith-32vcpu-ubuntu-2404 platform: linux target: AppImage arch: x64 - label: Windows x64 - runner: windows-2022 + runner: blacksmith-32vcpu-windows-2025 platform: win target: nsis arch: x64 - - label: Windows arm64 - runner: windows-11-arm - platform: win - target: nsis - arch: arm64 + # - label: Windows arm64 + # runner: windows-11-arm + # platform: win + # target: nsis + # arch: arm64 steps: - name: Checkout uses: actions/checkout@v6 @@ -277,16 +277,17 @@ jobs: done fi + # Enable if Windows arm64 builds are enabled. # Windows updater metadata is channel-specific (for example # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the # release job can merge matching arm64/x64 manifests back into one # canonical manifest per channel. - if [[ "${{ matrix.platform }}" == "win" ]]; then - shopt -s nullglob - for manifest in release-publish/*.yml; do - mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" - done - fi + # if [[ "${{ matrix.platform }}" == "win" ]]; then + # shopt -s nullglob + # for manifest in release-publish/*.yml; do + # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" + # done + # fi - name: Upload build artifacts uses: actions/upload-artifact@v7 @@ -298,7 +299,7 @@ jobs: publish_cli: name: Publish CLI to npm needs: [preflight, build] - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -332,7 +333,7 @@ jobs: release: name: Publish GitHub Release needs: [preflight, build, publish_cli] - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -363,34 +364,34 @@ jobs: fi done - - name: Merge Windows updater manifests - run: | - shopt -s nullglob - found_windows_manifest=false - for x64_manifest in release-assets/*-win-x64.yml; do - if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then - continue - fi - - arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" - output_manifest="${x64_manifest/-win-x64.yml/.yml}" - if [[ ! -f "$arm64_manifest" ]]; then - echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 - exit 1 - fi - - found_windows_manifest=true - node scripts/merge-update-manifests.ts --platform win \ - "$arm64_manifest" \ - "$x64_manifest" \ - "$output_manifest" - rm -f "$arm64_manifest" "$x64_manifest" - done - - if [[ "$found_windows_manifest" != true ]]; then - echo "No Windows updater manifests found to merge." >&2 - exit 1 - fi + # - name: Merge Windows updater manifests + # run: | + # shopt -s nullglob + # found_windows_manifest=false + # for x64_manifest in release-assets/*-win-x64.yml; do + # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then + # continue + # fi + + # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" + # output_manifest="${x64_manifest/-win-x64.yml/.yml}" + # if [[ ! -f "$arm64_manifest" ]]; then + # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 + # exit 1 + # fi + + # found_windows_manifest=true + # node scripts/merge-update-manifests.ts --platform win \ + # "$arm64_manifest" \ + # "$x64_manifest" \ + # "$output_manifest" + # rm -f "$arm64_manifest" "$x64_manifest" + # done + + # if [[ "$found_windows_manifest" != true ]]; then + # echo "No Windows updater manifests found to merge." >&2 + # exit 1 + # fi - name: Publish release if: needs.preflight.outputs.previous_tag != '' @@ -435,7 +436,7 @@ jobs: name: Finalize release if: needs.preflight.outputs.release_channel == 'stable' needs: [preflight, release] - runs-on: ubuntu-24.04 + runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - id: app_token From b991b9b9e635a55facda987c74957fab932e842e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 20:35:56 -0700 Subject: [PATCH 13/36] Revert to Github Runner for Windows (#2103) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cf200051b50..1545059f460d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,7 +156,7 @@ jobs: target: AppImage arch: x64 - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 + runner: windows-2022 # blacksmith-32vcpu-windows-2025 platform: win target: nsis arch: x64 From ed6b7fbf27973c8a5180e7074ba448d912c3cce4 Mon Sep 17 00:00:00 2001 From: Nathan Harmon Date: Thu, 16 Apr 2026 22:19:14 -0600 Subject: [PATCH 14/36] fix(server): honor gitignored files in workspace search (#2078) Co-authored-by: Julius Marminge --- apps/server/src/server.test.ts | 81 +++++++++++++++++++++++++++++++--- apps/server/src/server.ts | 17 ++++--- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index f4e3b7a730a7..66d776152e97 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -370,9 +370,32 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); + const gitCoreLayer = Layer.mock(GitCore)({ + isInsideWorkTree: () => Effect.succeed(false), + listWorkspaceFiles: () => + Effect.succeed({ + paths: [], + truncated: false, + }), + filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), + ...options?.layers?.gitCore, + }); const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); + const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(gitCoreLayer), + ); + const workspaceAndProjectServicesLayer = Layer.mergeAll( + WorkspacePathsLive, + workspaceEntriesLayer, + WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(workspaceEntriesLayer), + ), + ProjectFaviconResolverLive, + ); const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster ? Layer.mock(GitStatusBroadcaster)({ ...options.layers.gitStatusBroadcaster, @@ -416,11 +439,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.open, }), ), - Layer.provide( - Layer.mock(GitCore)({ - ...options?.layers?.gitCore, - }), - ), + Layer.provide(gitCoreLayer), Layer.provide(gitManagerLayer), Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( @@ -2017,6 +2036,58 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-search-gitignored-", + }); + yield* fs.writeFileString(path.join(workspaceDir, ".gitignore"), ".venv/\n"); + yield* fs.makeDirectory(path.join(workspaceDir, ".venv", "lib"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, ".venv", "lib", "ignored-search-target.ts"), + "export const ignored = true;", + ); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, "src", "tracked.ts"), + "export const ok = 1;", + ); + + yield* buildAppUnderTest({ + layers: { + gitCore: { + isInsideWorkTree: () => Effect.succeed(true), + listWorkspaceFiles: () => + Effect.succeed({ + paths: ["src/tracked.ts"], + truncated: false, + }), + filterIgnoredPaths: (_cwd, relativePaths) => + Effect.succeed( + relativePaths.filter((relativePath) => !relativePath.startsWith(".venv/")), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsSearchEntries]({ + cwd: workspaceDir, + query: "ignored-search-target", + limit: 10, + }), + ), + ); + + assert.equal(response.entries.length, 0); + assert.equal(response.truncated, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries errors", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3a8a7c5b5439..71d7d889218e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -185,13 +185,20 @@ const GitLayerLive = Layer.empty.pipe( const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(GitCoreLive), +); + +const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspaceEntriesLayerLive), +); + const WorkspaceLayerLive = Layer.mergeAll( WorkspacePathsLive, - WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), - ), + WorkspaceEntriesLayerLive, + WorkspaceFileSystemLayerLive, ); const AuthLayerLive = ServerAuthLive.pipe( From 8dba2d6484e283a4211b53fe6d3273e6e4c962d0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 22:14:35 -0700 Subject: [PATCH 15/36] Adopt Node-native TypeScript for desktop and server (#2098) --- .github/workflows/ci.yml | 4 +- apps/desktop/package.json | 7 +- apps/desktop/scripts/dev-electron.mjs | 8 +- apps/desktop/scripts/electron-launcher.mjs | 44 +- apps/desktop/scripts/smoke-test.mjs | 2 +- apps/desktop/scripts/start-electron.mjs | 2 +- apps/desktop/src/appBranding.test.ts | 2 +- apps/desktop/src/appBranding.ts | 2 +- apps/desktop/src/backendPort.test.ts | 2 +- apps/desktop/src/backendReadiness.test.ts | 2 +- apps/desktop/src/clientPersistence.test.ts | 2 +- apps/desktop/src/confirmDialog.test.ts | 2 +- apps/desktop/src/desktopSettings.test.ts | 2 +- apps/desktop/src/desktopSettings.ts | 2 +- apps/desktop/src/main.ts | 28 +- apps/desktop/src/runtimeArch.test.ts | 2 +- apps/desktop/src/serverExposure.test.ts | 2 +- .../src/serverListeningDetector.test.ts | 2 +- apps/desktop/src/syncShellEnvironment.test.ts | 2 +- apps/desktop/src/syncShellEnvironment.ts | 2 +- apps/desktop/src/updateChannels.test.ts | 2 +- apps/desktop/src/updateMachine.test.ts | 2 +- apps/desktop/src/updateMachine.ts | 2 +- apps/desktop/src/updateState.test.ts | 2 +- apps/desktop/tsconfig.json | 2 +- apps/desktop/tsdown.config.ts | 2 +- apps/server/package.json | 3 +- apps/server/scripts/cli.ts | 35 +- .../src/auth/Layers/AuthControlPlane.test.ts | 2 +- .../src/auth/Layers/AuthControlPlane.ts | 4 +- .../src/auth/Services/AuthControlPlane.ts | 2 +- apps/server/src/auth/utils.test.ts | 2 +- apps/server/src/bin.ts | 6 +- apps/server/src/bootstrap.test.ts | 2 +- apps/server/src/cli-config.test.ts | 4 +- apps/server/src/cli.ts | 23 +- apps/server/src/codexAppServerManager.test.ts | 2 +- apps/server/src/codexAppServerManager.ts | 10 +- .../environment/Layers/ServerEnvironment.ts | 4 +- apps/server/src/git/Layers/GitHubCli.test.ts | 2 +- apps/server/src/git/Layers/GitHubCli.ts | 2 +- apps/server/src/git/Layers/GitManager.ts | 3 +- apps/server/src/git/Services/GitHubCli.ts | 2 +- apps/server/src/http.ts | 8 +- apps/server/src/keybindings.test.ts | 4 +- apps/server/src/keybindings.ts | 4 +- .../src/observability/LocalFileTracer.ts | 11 +- apps/server/src/open.test.ts | 2 +- .../orchestration/Layers/CheckpointReactor.ts | 4 +- .../Layers/ProviderCommandReactor.ts | 3 +- apps/server/src/orchestration/Normalizer.ts | 8 +- apps/server/src/os-jank.test.ts | 2 +- apps/server/src/processRunner.test.ts | 2 +- .../src/provider/Layers/ClaudeAdapter.ts | 2 +- .../src/provider/Layers/ClaudeProvider.ts | 10 +- .../src/provider/Layers/CodexProvider.ts | 14 +- .../Layers/ProviderAdapterRegistry.test.ts | 6 +- .../provider/Layers/ProviderRegistry.test.ts | 12 +- .../src/provider/Layers/ProviderRegistry.ts | 18 +- .../src/provider/Services/ClaudeProvider.ts | 2 +- .../src/provider/Services/CodexProvider.ts | 2 +- apps/server/src/provider/cliVersion.test.ts | 2 +- apps/server/src/provider/codexAppServer.ts | 2 +- apps/server/src/provider/codexCliVersion.ts | 2 +- .../src/provider/makeManagedServerProvider.ts | 2 +- apps/server/src/provider/providerSnapshot.ts | 2 +- .../src/provider/providerStatusCache.test.ts | 2 +- apps/server/src/server.ts | 98 +- apps/server/src/serverLogger.ts | 2 +- apps/server/src/serverRuntimeStartup.ts | 26 +- apps/server/src/serverRuntimeState.ts | 4 +- apps/server/src/serverSettings.test.ts | 4 +- apps/server/src/serverSettings.ts | 2 +- apps/server/src/startupAccess.test.ts | 2 +- apps/server/src/startupAccess.ts | 4 +- apps/server/src/telemetry/Identify.ts | 2 +- .../src/telemetry/Layers/AnalyticsService.ts | 4 +- apps/server/src/terminal/Layers/BunPTY.ts | 7 +- .../src/terminal/Layers/Manager.test.ts | 16 +- apps/server/src/terminal/Layers/Manager.ts | 10 +- .../src/terminal/Layers/NodePTY.test.ts | 2 +- apps/server/src/terminal/Layers/NodePTY.ts | 9 +- apps/server/src/terminal/Services/Manager.ts | 2 +- apps/server/src/ws.ts | 52 +- apps/server/tsconfig.json | 4 +- apps/server/vitest.config.ts | 2 +- apps/web/tsconfig.json | 4 + docs/observability.md | 8 +- package.json | 3 +- packages/client-runtime/src/index.ts | 4 +- .../src/knownEnvironment.test.ts | 4 +- packages/contracts/src/auth.ts | 2 +- packages/contracts/src/editor.ts | 2 +- packages/contracts/src/environment.ts | 2 +- packages/contracts/src/filesystem.ts | 2 +- packages/contracts/src/git.test.ts | 2 +- packages/contracts/src/git.ts | 2 +- packages/contracts/src/index.ts | 34 +- packages/contracts/src/ipc.ts | 20 +- packages/contracts/src/keybindings.test.ts | 2 +- packages/contracts/src/keybindings.ts | 2 +- packages/contracts/src/model.ts | 4 +- packages/contracts/src/orchestration.test.ts | 2 +- packages/contracts/src/orchestration.ts | 6 +- packages/contracts/src/project.ts | 2 +- packages/contracts/src/provider.test.ts | 2 +- packages/contracts/src/provider.ts | 6 +- .../contracts/src/providerRuntime.test.ts | 2 +- packages/contracts/src/providerRuntime.ts | 4 +- packages/contracts/src/rpc.ts | 24 +- packages/contracts/src/server.test.ts | 2 +- packages/contracts/src/server.ts | 16 +- packages/contracts/src/settings.ts | 6 +- packages/contracts/src/terminal.test.ts | 2 +- packages/contracts/src/terminal.ts | 2 +- packages/shared/src/DrainableWorker.test.ts | 2 +- .../shared/src/KeyedCoalescingWorker.test.ts | 2 +- packages/shared/src/Net.test.ts | 2 +- packages/shared/src/String.test.ts | 2 +- packages/shared/src/cliArgs.test.ts | 2 +- packages/shared/src/git.test.ts | 2 +- packages/shared/src/model.test.ts | 2 +- packages/shared/src/path.test.ts | 2 +- packages/shared/src/qrCode.ts | 1711 +++++++++-------- packages/shared/src/searchRanking.test.ts | 2 +- packages/shared/src/serverSettings.test.ts | 2 +- packages/shared/src/serverSettings.ts | 4 +- packages/shared/src/shell.test.ts | 2 +- scripts/build-desktop-artifact.ts | 111 +- scripts/dev-runner.test.ts | 17 +- scripts/dev-runner.ts | 15 +- scripts/merge-update-manifests.test.ts | 116 ++ scripts/merge-update-manifests.ts | 97 +- scripts/mock-update-server.test.ts | 104 + scripts/mock-update-server.ts | 184 +- scripts/resolve-previous-release-tag.ts | 12 +- scripts/tsconfig.json | 6 +- .../update-release-package-versions.test.ts | 256 ++- scripts/update-release-package-versions.ts | 139 +- tsconfig.base.json | 11 +- 140 files changed, 2071 insertions(+), 1526 deletions(-) create mode 100644 scripts/mock-update-server.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d2cb7479a9e..e3329b1dad90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,8 +71,8 @@ jobs: - name: Verify preload bundle output run: | - test -f apps/desktop/dist-electron/preload.js - grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js + test -f apps/desktop/dist-electron/preload.cjs + grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs release_smoke: name: Release Smoke diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 86e61f18206f..5fbd3021ae48 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,13 +2,14 @@ "name": "@t3tools/desktop", "version": "0.0.20", "private": true, - "main": "dist-electron/main.js", + "type": "module", + "main": "dist-electron/main.cjs", "scripts": { "dev": "bun run --parallel dev:bundle dev:electron", "dev:bundle": "tsdown --watch", - "dev:electron": "bun run scripts/dev-electron.mjs", + "dev:electron": "node scripts/dev-electron.mjs", "build": "tsdown", - "start": "bun run scripts/start-electron.mjs", + "start": "node scripts/start-electron.mjs", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 7c0d55ac9a71..9a7e68dfbbb3 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -17,12 +17,12 @@ if (!Number.isInteger(port) || port <= 0) { } const requiredFiles = [ - "dist-electron/main.js", - "dist-electron/preload.js", + "dist-electron/main.cjs", + "dist-electron/preload.cjs", "../server/dist/bin.mjs", ]; const watchedDirectories = [ - { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, + { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) }, { directory: "../server/dist", files: new Set(["bin.mjs"]) }, ]; const forcedShutdownTimeoutMs = 1_500; @@ -69,7 +69,7 @@ function startApp() { const app = spawn( resolveElectronPath(), - [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], + [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"], { cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 77d9df314227..1453cbe666e2 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -8,7 +8,6 @@ import { mkdirSync, mkdtempSync, readFileSync, - readdirSync, rmSync, statSync, writeFileSync, @@ -20,7 +19,7 @@ import { fileURLToPath } from "node:url"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; -const LAUNCHER_VERSION = 1; +const LAUNCHER_VERSION = 2; const __dirname = dirname(fileURLToPath(import.meta.url)); export const desktopDir = resolve(__dirname, ".."); @@ -121,40 +120,6 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { copyFileSync(iconPath, join(resourcesDir, "electron.icns")); } -function patchHelperBundleInfoPlists(appBundlePath) { - const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); - if (!existsSync(frameworksDir)) { - return; - } - - for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.endsWith(".app")) { - continue; - } - if (!entry.name.startsWith("Electron Helper")) { - continue; - } - - const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist"); - if (!existsSync(helperPlistPath)) { - continue; - } - - const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim(); - const helperName = suffix - ? `${APP_DISPLAY_NAME} Helper ${suffix}` - : `${APP_DISPLAY_NAME} Helper`; - const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-"); - const helperBundleId = helperIdSuffix - ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` - : `${APP_BUNDLE_ID}.helper`; - - setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); - setPlistString(helperPlistPath, "CFBundleName", helperName); - setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); - } -} - function readJson(path) { try { return JSON.parse(readFileSync(path, "utf8")); @@ -192,7 +157,6 @@ function buildMacLauncher(electronBinaryPath) { rmSync(targetAppBundlePath, { recursive: true, force: true }); cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); patchMainBundleInfoPlist(targetAppBundlePath, iconPath); - patchHelperBundleInfoPlists(targetAppBundlePath); writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); return targetBinaryPath; @@ -206,5 +170,11 @@ export function resolveElectronPath() { return electronBinaryPath; } + // Dev launches do not need a renamed app bundle badly enough to risk breaking + // Electron helper resource lookup on macOS. + if (isDevelopment) { + return electronBinaryPath; + } + return buildMacLauncher(electronBinaryPath); } diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 883da7203a53..fdbe69b77800 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const desktopDir = resolve(__dirname, ".."); const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); -const mainJs = resolve(desktopDir, "dist-electron/main.js"); +const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); console.log("\nLaunching Electron smoke test..."); diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index bf93adb6b0db..375dbfe575f9 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -5,7 +5,7 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { +const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { stdio: "inherit", cwd: desktopDir, env: childEnv, diff --git a/apps/desktop/src/appBranding.test.ts b/apps/desktop/src/appBranding.test.ts index 93e872fb0484..5e3e3a5a1597 100644 --- a/apps/desktop/src/appBranding.test.ts +++ b/apps/desktop/src/appBranding.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding"; +import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts"; describe("resolveDesktopAppStageLabel", () => { it("uses Dev in desktop development", () => { diff --git a/apps/desktop/src/appBranding.ts b/apps/desktop/src/appBranding.ts index 49cbcc6780f7..3cb1539f7617 100644 --- a/apps/desktop/src/appBranding.ts +++ b/apps/desktop/src/appBranding.ts @@ -1,6 +1,6 @@ import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts"; -import { isNightlyDesktopVersion } from "./updateChannels"; +import { isNightlyDesktopVersion } from "./updateChannels.ts"; const APP_BASE_NAME = "T3 Code"; diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts index 8f586deb702f..774e31b80661 100644 --- a/apps/desktop/src/backendPort.test.ts +++ b/apps/desktop/src/backendPort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveDesktopBackendPort } from "./backendPort"; +import { resolveDesktopBackendPort } from "./backendPort.ts"; describe("resolveDesktopBackendPort", () => { it("returns the starting port when it is available", async () => { diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts index 33a5ef6b715e..0d49842acbaa 100644 --- a/apps/desktop/src/backendReadiness.test.ts +++ b/apps/desktop/src/backendReadiness.test.ts @@ -4,7 +4,7 @@ import { BackendReadinessAbortedError, isBackendReadinessAborted, waitForHttpReady, -} from "./backendReadiness"; +} from "./backendReadiness.ts"; describe("waitForHttpReady", () => { it("returns once the backend serves the requested readiness path", async () => { diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index fa263b18ff1b..27f1e1d91aef 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -18,7 +18,7 @@ import { writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret, type DesktopSecretStorage, -} from "./clientPersistence"; +} from "./clientPersistence.ts"; const tempDirectories: string[] = []; diff --git a/apps/desktop/src/confirmDialog.test.ts b/apps/desktop/src/confirmDialog.test.ts index 4a4c0ddbed6c..de1d23eb178a 100644 --- a/apps/desktop/src/confirmDialog.test.ts +++ b/apps/desktop/src/confirmDialog.test.ts @@ -11,7 +11,7 @@ vi.mock("electron", () => ({ }, })); -import { showDesktopConfirmDialog } from "./confirmDialog"; +import { showDesktopConfirmDialog } from "./confirmDialog.ts"; describe("showDesktopConfirmDialog", () => { beforeEach(() => { diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts index 7c8be53f8276..9b467d22cabf 100644 --- a/apps/desktop/src/desktopSettings.test.ts +++ b/apps/desktop/src/desktopSettings.test.ts @@ -11,7 +11,7 @@ import { setDesktopServerExposurePreference, setDesktopUpdateChannelPreference, writeDesktopSettings, -} from "./desktopSettings"; +} from "./desktopSettings.ts"; const tempDirectories: string[] = []; diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts index cb0829a8b650..6ece5189cced 100644 --- a/apps/desktop/src/desktopSettings.ts +++ b/apps/desktop/src/desktopSettings.ts @@ -2,7 +2,7 @@ import * as FS from "node:fs"; import * as Path from "node:path"; import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels"; +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6bdce564b4fb..3ef80f5c0a0b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -36,14 +36,14 @@ import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort"; +import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, setDesktopServerExposurePreference, setDesktopUpdateChannelPreference, writeDesktopSettings, -} from "./desktopSettings"; +} from "./desktopSettings.ts"; import { readClientSettings, readSavedEnvironmentRegistry, @@ -52,14 +52,14 @@ import { writeClientSettings, writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret, -} from "./clientPersistence"; -import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; -import { showDesktopConfirmDialog } from "./confirmDialog"; -import { resolveDesktopServerExposure } from "./serverExposure"; -import { syncShellEnvironment } from "./syncShellEnvironment"; -import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; -import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels"; -import { ServerListeningDetector } from "./serverListeningDetector"; +} from "./clientPersistence.ts"; +import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; +import { showDesktopConfirmDialog } from "./confirmDialog.ts"; +import { resolveDesktopServerExposure } from "./serverExposure.ts"; +import { syncShellEnvironment } from "./syncShellEnvironment.ts"; +import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; +import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; +import { ServerListeningDetector } from "./serverListeningDetector.ts"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -71,9 +71,9 @@ import { reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine"; -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; -import { resolveDesktopAppBranding } from "./appBranding"; +} from "./updateMachine.ts"; +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; +import { resolveDesktopAppBranding } from "./appBranding.ts"; syncShellEnvironment(); @@ -1967,7 +1967,7 @@ function createWindow(): BrowserWindow { title: APP_DISPLAY_NAME, ...getWindowTitleBarOptions(), webPreferences: { - preload: Path.join(__dirname, "preload.js"), + preload: Path.join(__dirname, "preload.cjs"), contextIsolation: true, nodeIntegration: false, sandbox: true, diff --git a/apps/desktop/src/runtimeArch.test.ts b/apps/desktop/src/runtimeArch.test.ts index 258a8fb21520..a3173598949d 100644 --- a/apps/desktop/src/runtimeArch.test.ts +++ b/apps/desktop/src/runtimeArch.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; describe("resolveDesktopRuntimeInfo", () => { it("detects Rosetta-translated Intel builds on Apple Silicon", () => { diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts index b1ae4bef4f54..c83bbc210e0c 100644 --- a/apps/desktop/src/serverExposure.test.ts +++ b/apps/desktop/src/serverExposure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure"; +import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts"; describe("resolveLanAdvertisedHost", () => { it("prefers an explicit host override", () => { diff --git a/apps/desktop/src/serverListeningDetector.test.ts b/apps/desktop/src/serverListeningDetector.test.ts index b7c66b6312c2..fcf9f50ae96a 100644 --- a/apps/desktop/src/serverListeningDetector.test.ts +++ b/apps/desktop/src/serverListeningDetector.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ServerListeningDetector } from "./serverListeningDetector"; +import { ServerListeningDetector } from "./serverListeningDetector.ts"; describe("ServerListeningDetector", () => { it("resolves when the server logs the listening line", async () => { diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index 7d4578895faa..abaeeb2b2a52 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { syncShellEnvironment } from "./syncShellEnvironment"; +import { syncShellEnvironment } from "./syncShellEnvironment.ts"; describe("syncShellEnvironment", () => { it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 7e031b1116b6..11a9e6930cab 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -3,8 +3,8 @@ import { mergePathEntries, readPathFromLaunchctl, readEnvironmentFromLoginShell, - ShellEnvironmentReader, } from "@t3tools/shared/shell"; +import type { ShellEnvironmentReader } from "@t3tools/shared/shell"; const LOGIN_SHELL_ENV_NAMES = [ "PATH", diff --git a/apps/desktop/src/updateChannels.test.ts b/apps/desktop/src/updateChannels.test.ts index bd1dcc0c73ca..f815fbd81cc8 100644 --- a/apps/desktop/src/updateChannels.test.ts +++ b/apps/desktop/src/updateChannels.test.ts @@ -4,7 +4,7 @@ import { doesVersionMatchDesktopUpdateChannel, isNightlyDesktopVersion, resolveDefaultDesktopUpdateChannel, -} from "./updateChannels"; +} from "./updateChannels.ts"; describe("isNightlyDesktopVersion", () => { it("detects packaged nightly versions", () => { diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updateMachine.test.ts index a6fbcfb5d73a..e2f0519d350c 100644 --- a/apps/desktop/src/updateMachine.test.ts +++ b/apps/desktop/src/updateMachine.test.ts @@ -11,7 +11,7 @@ import { reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine"; +} from "./updateMachine.ts"; const runtimeInfo = { hostArch: "x64", diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updateMachine.ts index c767dfd2fee7..7d5ed271e05f 100644 --- a/apps/desktop/src/updateMachine.ts +++ b/apps/desktop/src/updateMachine.ts @@ -4,7 +4,7 @@ import type { DesktopUpdateState, } from "@t3tools/contracts"; -import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; +import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts"; export function createInitialDesktopUpdateState( currentVersion: string, diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index 9d7fe5b7abaa..c2bb4ba12dd0 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -6,7 +6,7 @@ import { getAutoUpdateDisabledReason, nextStatusAfterDownloadFailure, shouldBroadcastDownloadProgress, -} from "./updateState"; +} from "./updateState.ts"; const baseState: DesktopUpdateState = { enabled: true, diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 0ca5bcaa76ac..ff3e4cd0f389 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node", "electron"], - "lib": ["ES2023", "DOM", "esnext.disposable"] + "lib": ["ESNext", "DOM", "esnext.disposable"] }, "include": ["src", "tsdown.config.ts"] } diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index f3ebc9732533..53b00393439e 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -4,7 +4,7 @@ const shared = { format: "cjs" as const, outDir: "dist-electron", sourcemap: true, - outExtensions: () => ({ js: ".js" }), + outExtensions: () => ({ js: ".cjs" }), }; export default defineConfig([ diff --git a/apps/server/package.json b/apps/server/package.json index a5d5d986ecd2..038134bd52fa 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,8 +15,9 @@ ], "type": "module", "scripts": { - "dev": "bun run src/bin.ts", + "dev": "node --watch src/bin.ts", "build": "node scripts/cli.ts build", + "build:bundle": "tsdown", "start": "node dist/bin.mjs", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 299da67faba3..efaa2b3b6cfc 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -147,13 +147,13 @@ const buildCmd = Command.make( yield* Effect.log("[cli] Running tsdown..."); yield* runCommand( - ChildProcess.make({ + ChildProcess.make(process.execPath, ["--run", "build:bundle"], { cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). + // Windows needs shell mode to resolve `.cmd` shims on PATH. shell: process.platform === "win32", - })`bun tsdown`, + }), ); const webDist = path.join(repoRoot, "apps/web/dist"); @@ -203,10 +203,8 @@ const publishCmd = Command.make( } yield* Effect.acquireUseRelease( - // Acquire: backup package.json, resolve catalog: deps, strip devDependencies/scripts + // Acquire: backup package.json, resolve catalog dependencies, and strip devDependencies/scripts Effect.gen(function* () { - // Resolve catalog dependencies before any file mutations. If this throws, - // acquire fails and no release hook runs, so filesystem must still be untouched. const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); const pkg: PackageJson = { name: serverPackageJson.name, @@ -216,25 +214,22 @@ const publishCmd = Command.make( version, engines: serverPackageJson.engines, files: serverPackageJson.files, - dependencies: serverPackageJson.dependencies, - overrides: rootPackageJson.overrides, + dependencies: resolveCatalogDependencies( + serverPackageJson.dependencies, + rootPackageJson.workspaces.catalog, + "apps/server", + ), + overrides: resolveCatalogDependencies( + rootPackageJson.overrides, + rootPackageJson.workspaces.catalog, + "apps/server", + ), }; - pkg.dependencies = resolveCatalogDependencies( - pkg.dependencies, - rootPackageJson.workspaces.catalog, - "apps/server dependencies", - ); - pkg.overrides = resolveCatalogDependencies( - pkg.overrides, - rootPackageJson.workspaces.catalog, - "root overrides", - ); - const original = yield* fs.readFileString(packageJsonPath); yield* fs.writeFileString(backupPath, original); yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); - yield* Effect.log("[cli] Resolved package.json for publish"); + yield* Effect.log("[cli] Prepared package.json for publish"); const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir); return { iconBackups }; diff --git a/apps/server/src/auth/Layers/AuthControlPlane.test.ts b/apps/server/src/auth/Layers/AuthControlPlane.test.ts index 9fc091124bef..280fbc1604fe 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.test.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.test.ts @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { ServerConfigShape } from "../../config.ts"; +import type { ServerConfigShape } from "../../config.ts"; import { ServerConfig } from "../../config.ts"; import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; diff --git a/apps/server/src/auth/Layers/AuthControlPlane.ts b/apps/server/src/auth/Layers/AuthControlPlane.ts index 98b2107800cd..1bf4909e82c9 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.ts @@ -10,8 +10,10 @@ import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Lay import { AuthControlPlane, AuthControlPlaneError, - AuthControlPlaneShape, DEFAULT_SESSION_SUBJECT, +} from "../Services/AuthControlPlane.ts"; +import type { + AuthControlPlaneShape, IssuedBearerSession, IssuedPairingLink, } from "../Services/AuthControlPlane.ts"; diff --git a/apps/server/src/auth/Services/AuthControlPlane.ts b/apps/server/src/auth/Services/AuthControlPlane.ts index b59e330bcaa4..4b3cf474feab 100644 --- a/apps/server/src/auth/Services/AuthControlPlane.ts +++ b/apps/server/src/auth/Services/AuthControlPlane.ts @@ -5,7 +5,7 @@ import type { AuthSessionId, } from "@t3tools/contracts"; import { Data, DateTime, Duration, Effect, Context } from "effect"; -import { SessionRole } from "./SessionCredentialService"; +import type { SessionRole } from "./SessionCredentialService.ts"; export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index a767b77de113..e7a540d81bac 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { deriveAuthClientMetadata } from "./utils"; +import { deriveAuthClientMetadata } from "./utils.ts"; describe("deriveAuthClientMetadata", () => { it("labels Electron user agents as Electron instead of Chrome", () => { diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 063d43326c33..874898d86e9f 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -5,12 +5,12 @@ import * as Layer from "effect/Layer"; import { Command } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; -import { cli } from "./cli"; -import { version } from "../package.json" with { type: "json" }; +import { cli } from "./cli.ts"; +import packageJson from "../package.json" with { type: "json" }; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -Command.run(cli, { version }).pipe( +Command.run(cli, { version: packageJson.version }).pipe( Effect.scoped, Effect.provide(CliRuntimeLayer), NodeRuntime.runMain, diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 3fce6af9c42b..e4cdfab1dbb1 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -10,7 +10,7 @@ import * as Fiber from "effect/Fiber"; import { TestClock } from "effect/testing"; import { vi } from "vitest"; -import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; +import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index 6fa6e0c96b67..5adece730201 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -5,8 +5,8 @@ import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect" import { NetService } from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { deriveServerPaths } from "./config"; -import { resolveServerConfig } from "./cli"; +import { deriveServerPaths } from "./config.ts"; +import { resolveServerConfig } from "./cli.ts"; it.layer(NodeServices.layer)("cli config resolution", (it) => { const defaultObservabilityConfig = { diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 5f737509202b..4fc23a1ded09 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -40,30 +40,31 @@ import { RuntimeMode, type ServerConfigShape, type StartupPresentation, -} from "./config"; -import { readBootstrapEnvelope } from "./bootstrap"; -import { expandHomePath, resolveBaseDir } from "./os-jank"; -import { runServer } from "./server"; +} from "./config.ts"; +import { readBootstrapEnvelope } from "./bootstrap.ts"; +import { expandHomePath, resolveBaseDir } from "./os-jank.ts"; +import { runServer } from "./server.ts"; import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; import { formatIssuedPairingCredential, formatIssuedSession, formatPairingCredentialList, formatSessionList, -} from "./cliAuthFormat"; -import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; +} from "./cliAuthFormat.ts"; +import { AuthControlPlane } from "./auth/Services/AuthControlPlane.ts"; +import type { AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; -import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup"; +import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup.ts"; import { clearPersistedServerRuntimeState, readPersistedServerRuntimeState, -} from "./serverRuntimeState"; -import { WorkspacePaths } from "./workspace/Services/WorkspacePaths"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; +} from "./serverRuntimeState.ts"; +import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 919aacf728dc..84225ef1ea6b 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -15,7 +15,7 @@ import { normalizeCodexModelSlug, readCodexAccountSnapshot, resolveCodexModelForAccount, -} from "./codexAppServerManager"; +} from "./codexAppServerManager.ts"; const asThreadId = (value: string): ThreadId => ThreadId.make(value); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 1e6a7fdb6a3e..6d98264c9108 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -25,16 +25,16 @@ import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, parseCodexCliVersion, -} from "./provider/codexCliVersion"; +} from "./provider/codexCliVersion.ts"; import { readCodexAccountSnapshot, resolveCodexModelForAccount, type CodexAccountSnapshot, -} from "./provider/codexAccount"; -import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; +} from "./provider/codexAccount.ts"; +import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer.ts"; -export { buildCodexInitializeParams } from "./provider/codexAppServer"; -export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; +export { buildCodexInitializeParams } from "./provider/codexAppServer.ts"; +export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount.ts"; type PendingRequestKey = string; diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts index 506fc45af795..9208a6d8ed56 100644 --- a/apps/server/src/environment/Layers/ServerEnvironment.ts +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -3,7 +3,7 @@ import { Effect, FileSystem, Layer, Path, Random } from "effect"; import { ServerConfig } from "../../config.ts"; import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; -import { version } from "../../../package.json" with { type: "json" }; +import packageJson from "../../../package.json" with { type: "json" }; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { @@ -77,7 +77,7 @@ export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function os: platformOs(), arch: platformArch(), }, - serverVersion: version, + serverVersion: packageJson.version, capabilities: { repositoryIdentity: true, }, diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts index 0ee4b3f09aca..5a7b9cb8b1d0 100644 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ b/apps/server/src/git/Layers/GitHubCli.test.ts @@ -6,7 +6,7 @@ vi.mock("../../processRunner", () => ({ runProcess: vi.fn(), })); -import { runProcess } from "../../processRunner"; +import { runProcess } from "../../processRunner.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { GitHubCliLive } from "./GitHubCli.ts"; diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 1a687b0e8dd5..dbacdf632262 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -1,7 +1,7 @@ import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; import { TrimmedNonEmptyString } from "@t3tools/contracts"; -import { runProcess } from "../../processRunner"; +import { runProcess } from "../../processRunner.ts"; import { GitHubCliError } from "@t3tools/contracts"; import { GitHubCli, diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index a84427a194aa..dadf2f7e79b4 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -38,7 +38,8 @@ import { type GitManagerShape, type GitRunStackedActionOptions, } from "../Services/GitManager.ts"; -import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; +import { GitCore } from "../Services/GitCore.ts"; +import type { GitStatusDetails } from "../Services/GitCore.ts"; import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index 216c24bf7c5b..81a53761a3b8 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -8,7 +8,7 @@ import { Context } from "effect"; import type { Effect } from "effect"; -import type { ProcessRunResult } from "../../processRunner"; +import type { ProcessRunResult } from "../../processRunner.ts"; import type { GitHubCliError } from "@t3tools/contracts"; export interface GitHubPullRequestSummary { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 7420156b2e7c..88cc5adae927 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -15,12 +15,12 @@ import { ATTACHMENTS_ROUTE_PREFIX, normalizeAttachmentRelativePath, resolveAttachmentRelativePath, -} from "./attachmentPaths"; -import { resolveAttachmentPathById } from "./attachmentStore"; -import { resolveStaticDir, ServerConfig } from "./config"; +} from "./attachmentPaths.ts"; +import { resolveAttachmentPathById } from "./attachmentStore.ts"; +import { resolveStaticDir, ServerConfig } from "./config.ts"; import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; -import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; +import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import { respondToAuthError } from "./auth/http.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index e3f190ff061b..15edd4295df5 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { DEFAULT_KEYBINDINGS, @@ -13,7 +13,7 @@ import { compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, parseKeybindingShortcut, -} from "./keybindings"; +} from "./keybindings.ts"; import { KeybindingsConfigError } from "@t3tools/contracts"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index b473f77ca1b4..07ae9156c1c7 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -19,7 +19,7 @@ import { THREAD_JUMP_KEYBINDING_COMMANDS, type ServerConfigIssue, } from "@t3tools/contracts"; -import { Mutable } from "effect/Types"; +import type { Mutable } from "effect/Types"; import { Array, Cache, @@ -44,7 +44,7 @@ import { Stream, } from "effect"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; type WhenToken = diff --git a/apps/server/src/observability/LocalFileTracer.ts b/apps/server/src/observability/LocalFileTracer.ts index cde5a176e88e..a3d43ea118ca 100644 --- a/apps/server/src/observability/LocalFileTracer.ts +++ b/apps/server/src/observability/LocalFileTracer.ts @@ -1,7 +1,8 @@ import type * as Exit from "effect/Exit"; import { Effect, Option, Tracer } from "effect"; -import { EffectTraceRecord, spanToTraceRecord } from "./TraceRecord.ts"; +import { spanToTraceRecord } from "./TraceRecord.ts"; +import type { EffectTraceRecord } from "./TraceRecord.ts"; import { makeTraceSink, type TraceSink } from "./TraceSink.ts"; export interface LocalFileTracerOptions { @@ -27,12 +28,16 @@ class LocalFileSpan implements Tracer.Span { status: Tracer.SpanStatus; attributes: Map; events: Array<[name: string, startTime: bigint, attributes: Record]>; + private readonly delegate: Tracer.Span; + private readonly push: (record: EffectTraceRecord) => void; constructor( options: Parameters[0], - private readonly delegate: Tracer.Span, - private readonly push: (record: EffectTraceRecord) => void, + delegate: Tracer.Span, + push: (record: EffectTraceRecord) => void, ) { + this.delegate = delegate; + this.push = push; this.name = delegate.name; this.spanId = delegate.spanId; this.traceId = delegate.traceId; diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 382daab2d03a..77e85072a8a0 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -8,7 +8,7 @@ import { launchDetached, resolveAvailableEditors, resolveEditorLaunch, -} from "./open"; +} from "./open.ts"; it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { it.effect("returns commands for command-based editors", () => diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 0b1b203ba24c..71445b4671dc 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -21,8 +21,8 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; -import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; -import { OrchestrationDispatchError } from "../Errors.ts"; +import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; +import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e4e772dc757f..db2bd2d43528 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -19,7 +19,8 @@ import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; -import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index 177a23ec0015..811d9b8a1c77 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -6,10 +6,10 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore"; -import { ServerConfig } from "../config"; -import { parseBase64DataUrl } from "../imageMime"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths"; +import { createAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { parseBase64DataUrl } from "../imageMime.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index 89eba62d2ae1..9006644bdf71 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { fixPath } from "./os-jank"; +import { fixPath } from "./os-jank.ts"; describe("fixPath", () => { it("hydrates PATH on linux using the resolved login shell", () => { diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index dd909116d4d6..15ad4daf09bb 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { runProcess } from "./processRunner"; +import { runProcess } from "./processRunner.ts"; describe("runProcess", () => { it("fails when output exceeds max buffer in default mode", async () => { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b59ac444cf81..8caeda07377b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -17,7 +17,7 @@ import { type SDKResultMessage, type SettingSource, type SDKUserMessage, - ModelUsage, + type ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f76c4250eb48..c6135fe247b0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -25,11 +25,11 @@ import { providerModelsFromSettings, spawnAndCollect, type CommandResult, -} from "../providerSnapshot"; -import { compareCliVersions } from "../cliVersion"; -import { makeManagedServerProvider } from "../makeManagedServerProvider"; -import { ClaudeProvider } from "../Services/ClaudeProvider"; -import { ServerSettingsService } from "../../serverSettings"; +} from "../providerSnapshot.ts"; +import { compareCliVersions } from "../cliVersion.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index d3f8c742ef51..de4aceeac966 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -32,22 +32,22 @@ import { providerModelsFromSettings, spawnAndCollect, type CommandResult, -} from "../providerSnapshot"; -import { makeManagedServerProvider } from "../makeManagedServerProvider"; +} from "../providerSnapshot.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, parseCodexCliVersion, -} from "../codexCliVersion"; +} from "../codexCliVersion.ts"; import { adjustCodexModelsForAccount, codexAuthSubLabel, codexAuthSubType, type CodexAccountSnapshot, -} from "../codexAccount"; -import { probeCodexDiscovery } from "../codexAppServer"; -import { CodexProvider } from "../Services/CodexProvider"; -import { ServerSettingsService } from "../../serverSettings"; +} from "../codexAccount.ts"; +import { probeCodexDiscovery } from "../codexAppServer.ts"; +import { CodexProvider } from "../Services/CodexProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; const DEFAULT_CODEX_MODEL_CAPABILITIES: ModelCapabilities = { diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0feaa..953a49fe2ffe 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,8 +4,10 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; -import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; +import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index d03fffe82f61..170521d2d273 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -28,12 +28,12 @@ import { hasCustomModelProvider, parseAuthStatusFromOutput, readCodexConfigModelProvider, -} from "./CodexProvider"; -import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; -import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; -import { ServerConfig } from "../../config"; -import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; -import { ProviderRegistry } from "../Services/ProviderRegistry"; +} from "./CodexProvider.ts"; +import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; +import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; +import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; // ── Test helpers ──────────────────────────────────────────────────── diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 41bb81e74f8e..62207ff079e1 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -6,14 +6,14 @@ import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; -import { ServerConfig } from "../../config"; -import { ClaudeProviderLive } from "./ClaudeProvider"; -import { CodexProviderLive } from "./CodexProvider"; -import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; -import { ClaudeProvider } from "../Services/ClaudeProvider"; -import type { CodexProviderShape } from "../Services/CodexProvider"; -import { CodexProvider } from "../Services/CodexProvider"; -import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; +import { ServerConfig } from "../../config.ts"; +import { ClaudeProviderLive } from "./ClaudeProvider.ts"; +import { CodexProviderLive } from "./CodexProvider.ts"; +import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts"; +import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; +import type { CodexProviderShape } from "../Services/CodexProvider.ts"; +import { CodexProvider } from "../Services/CodexProvider.ts"; +import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; import { hydrateCachedProvider, PROVIDER_CACHE_IDS, @@ -21,7 +21,7 @@ import { readProviderStatusCache, resolveProviderStatusCachePath, writeProviderStatusCache, -} from "../providerStatusCache"; +} from "../providerStatusCache.ts"; const loadProviders = ( codexProvider: CodexProviderShape, diff --git a/apps/server/src/provider/Services/ClaudeProvider.ts b/apps/server/src/provider/Services/ClaudeProvider.ts index 7f90c549c635..7e21ac56d9ee 100644 --- a/apps/server/src/provider/Services/ClaudeProvider.ts +++ b/apps/server/src/provider/Services/ClaudeProvider.ts @@ -1,6 +1,6 @@ import { Context } from "effect"; -import type { ServerProviderShape } from "./ServerProvider"; +import type { ServerProviderShape } from "./ServerProvider.ts"; export interface ClaudeProviderShape extends ServerProviderShape {} diff --git a/apps/server/src/provider/Services/CodexProvider.ts b/apps/server/src/provider/Services/CodexProvider.ts index 6820d4cb4f9d..e116f1a761b7 100644 --- a/apps/server/src/provider/Services/CodexProvider.ts +++ b/apps/server/src/provider/Services/CodexProvider.ts @@ -1,6 +1,6 @@ import { Context } from "effect"; -import type { ServerProviderShape } from "./ServerProvider"; +import type { ServerProviderShape } from "./ServerProvider.ts"; export interface CodexProviderShape extends ServerProviderShape {} diff --git a/apps/server/src/provider/cliVersion.test.ts b/apps/server/src/provider/cliVersion.test.ts index a9c1721c4e87..ffb42cf5ccdb 100644 --- a/apps/server/src/provider/cliVersion.test.ts +++ b/apps/server/src/provider/cliVersion.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { compareCliVersions, normalizeCliVersion } from "./cliVersion"; +import { compareCliVersions, normalizeCliVersion } from "./cliVersion.ts"; describe("cliVersion", () => { it("normalizes versions with a missing patch segment", () => { diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index 7b3c9eeb79f3..24a9e29c5927 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; import type { ServerProviderSkill } from "@t3tools/contracts"; -import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; +import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount.ts"; interface JsonRpcProbeResponse { readonly id?: unknown; diff --git a/apps/server/src/provider/codexCliVersion.ts b/apps/server/src/provider/codexCliVersion.ts index 871948335017..33f7cf85d2a5 100644 --- a/apps/server/src/provider/codexCliVersion.ts +++ b/apps/server/src/provider/codexCliVersion.ts @@ -1,4 +1,4 @@ -import { compareCliVersions, normalizeCliVersion } from "./cliVersion"; +import { compareCliVersions, normalizeCliVersion } from "./cliVersion.ts"; const CODEX_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/; diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 856594c1f025..1d3bf52f4ba2 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -2,7 +2,7 @@ import type { ServerProvider } from "@t3tools/contracts"; import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; import * as Semaphore from "effect/Semaphore"; -import type { ServerProviderShape } from "./Services/ServerProvider"; +import type { ServerProviderShape } from "./Services/ServerProvider.ts"; import { ServerSettingsError } from "@t3tools/contracts"; export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* < diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 40246563aefb..068b7c115783 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -10,7 +10,7 @@ import type { import { Effect, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; -import { isWindowsCommandNotFound } from "../processRunner"; +import { isWindowsCommandNotFound } from "../processRunner.ts"; export const DEFAULT_TIMEOUT_MS = 4_000; diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 5f0d88322e1c..a82cb4ae5042 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -8,7 +8,7 @@ import { readProviderStatusCache, resolveProviderStatusCachePath, writeProviderStatusCache, -} from "./providerStatusCache"; +} from "./providerStatusCache.ts"; const makeProvider = ( provider: ServerProvider["provider"], diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 71d7d889218e..50d2d62aa724 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,7 +1,7 @@ import { Effect, Layer } from "effect"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { attachmentsRouteLayer, otlpTracesProxyRouteLayer, @@ -9,46 +9,46 @@ import { serverEnvironmentRouteLayer, staticAndDevRouteLayer, browserApiCorsLayer, -} from "./http"; -import { fixPath } from "./os-jank"; -import { websocketRpcRouteLayer } from "./ws"; -import { OpenLive } from "./open"; -import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite"; -import { ServerLifecycleEventsLive } from "./serverLifecycleEvents"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; -import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; -import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; -import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; -import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; -import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; -import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; -import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper"; -import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; -import { GitCoreLive } from "./git/Layers/GitCore"; -import { GitHubCliLive } from "./git/Layers/GitHubCli"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; -import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; -import { TerminalManagerLive } from "./terminal/Layers/Manager"; -import { GitManagerLive } from "./git/Layers/GitManager"; -import { KeybindingsLive } from "./keybindings"; -import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup"; -import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; -import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; -import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; -import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; -import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; -import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; -import { ServerSettingsLive } from "./serverSettings"; -import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; -import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; -import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; -import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; -import { ObservabilityLive } from "./observability/Layers/Observability"; -import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; +} from "./http.ts"; +import { fixPath } from "./os-jank.ts"; +import { websocketRpcRouteLayer } from "./ws.ts"; +import { OpenLive } from "./open.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; +import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; +import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger.ts"; +import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; +import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; +import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; +import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; +import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; +import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; +import { GitCoreLive } from "./git/Layers/GitCore.ts"; +import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; +import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration.ts"; +import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; +import { GitManagerLive } from "./git/Layers/GitManager.ts"; +import { KeybindingsLive } from "./keybindings.ts"; +import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; +import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; +import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; +import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; +import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; +import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; +import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; +import { ServerSettingsLive } from "./serverSettings.ts"; +import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; +import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; +import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; +import { ObservabilityLive } from "./observability/Layers/Observability.ts"; +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; import { authBearerBootstrapRouteLayer, authBootstrapRouteLayer, @@ -60,27 +60,27 @@ import { authPairingCredentialRouteLayer, authSessionRouteLayer, authWebSocketTokenRouteLayer, -} from "./auth/http"; -import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; -import { ServerAuthLive } from "./auth/Layers/ServerAuth"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer"; +} from "./auth/http.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, persistServerRuntimeState, -} from "./serverRuntimeState"; +} from "./serverRuntimeState.ts"; import { orchestrationDispatchRouteLayer, orchestrationSnapshotRouteLayer, -} from "./orchestration/http"; +} from "./orchestration/http.ts"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { - const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY")); + const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY.ts")); return BunPTY.layer; } else { - const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY")); + const NodePTY = yield* Effect.promise(() => import("./terminal/Layers/NodePTY.ts")); return NodePTY.layer; } }), diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index ea098dcbbea5..57d51b2a9e88 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,6 +1,6 @@ import { Effect, Logger, References, Layer } from "effect"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; export const ServerLoggerLive = Effect.gen(function* () { const config = yield* ServerConfig; diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 919da67b7b95..99728f681f4b 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -21,24 +21,24 @@ import { Console, } from "effect"; -import { ServerConfig } from "./config"; -import { Keybindings } from "./keybindings"; -import { Open } from "./open"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; -import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents"; -import { ServerSettingsService } from "./serverSettings"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; -import { ServerAuth } from "./auth/Services/ServerAuth"; -import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper"; +import { ServerConfig } from "./config.ts"; +import { Keybindings } from "./keybindings.ts"; +import { Open } from "./open.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor.ts"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; import { formatHeadlessServeOutput, formatHostForUrl, isWildcardHost, issueHeadlessServeAccessInfo, -} from "./startupAccess"; +} from "./startupAccess.ts"; export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ readonly message: string; diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 00c838446824..569e4ac11790 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -1,7 +1,7 @@ import { Effect, FileSystem, Option, Path, Schema } from "effect"; -import { type ServerConfigShape } from "./config"; -import { formatHostForUrl, isWildcardHost } from "./startupAccess"; +import { type ServerConfigShape } from "./config.ts"; +import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; export const PersistedServerRuntimeState = Schema.Struct({ version: Schema.Literal(1), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 26479d61bd61..406e1e85056c 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -2,8 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Schema } from "effect"; -import { ServerConfig } from "./config"; -import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; +import { ServerConfig } from "./config.ts"; +import { ServerSettingsLive, ServerSettingsService } from "./serverSettings.ts"; const makeServerSettingsLayer = () => ServerSettingsLive.pipe( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index bdb1d5e0efc4..d5636d5c04f0 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -39,7 +39,7 @@ import { Cause, } from "effect"; import * as Semaphore from "effect/Semaphore"; -import { ServerConfig } from "./config"; +import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index ef6ece31e285..03c01170f158 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -7,7 +7,7 @@ import { resolveHeadlessConnectionHost, resolveHeadlessConnectionString, resolveListeningPort, -} from "./startupAccess"; +} from "./startupAccess.ts"; it("prefers localhost when no explicit host is configured", () => { expect(resolveHeadlessConnectionHost(undefined)).toBe("localhost"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index a350d729d016..32791901418c 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -4,8 +4,8 @@ import { QrCode } from "@t3tools/shared/qrCode"; import { Effect } from "effect"; import { HttpServer } from "effect/unstable/http"; -import { ServerConfig } from "./config"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ServerConfig } from "./config.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; export interface HeadlessServeAccessInfo { readonly connectionString: string; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index d7784eb88b46..e81393bbbc39 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,7 +1,7 @@ import { Effect, FileSystem, Path, Random, Schema } from "effect"; import * as Crypto from "node:crypto"; import { homedir } from "node:os"; -import { ServerConfig } from "../config"; +import { ServerConfig } from "../config.ts"; const CodexAuthJsonSchema = Schema.Struct({ tokens: Schema.Struct({ diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index e933576dffaf..9067b71a5526 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -13,7 +13,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { ServerConfig } from "../../config.ts"; import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; import { getTelemetryIdentifier } from "../Identify.ts"; -import { version } from "../../../package.json" with { type: "json" }; +import packageJson from "../../../package.json" with { type: "json" }; interface BufferedAnalyticsEvent { readonly event: string; @@ -86,7 +86,7 @@ const makeAnalyticsService = Effect.gen(function* () { platform: process.platform, wsl: process.env.WSL_DISTRO_NAME, arch: process.arch, - t3CodeVersion: version, + t3CodeVersion: packageJson.version, clientType, }, timestamp: event.capturedAt, diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index 1fb4bdd63672..f0aab813c7c8 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -1,13 +1,16 @@ import { Effect, Layer } from "effect"; -import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; +import { PtyAdapter } from "../Services/PTY.ts"; +import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; class BunPtyProcess implements PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); private readonly decoder = new TextDecoder(); + private readonly process: Bun.Subprocess; private didExit = false; - constructor(private readonly process: Bun.Subprocess) { + constructor(process: Bun.Subprocess) { + this.process = process; void this.process.exited .then((exitCode) => { this.emitExit({ diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 8207861e2065..cce0a162729d 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -24,25 +24,28 @@ import { import { TestClock } from "effect/testing"; import { expect } from "vitest"; -import type { TerminalManagerShape } from "../Services/Manager"; +import type { TerminalManagerShape } from "../Services/Manager.ts"; import { type PtyAdapterShape, type PtyExitEvent, type PtyProcess, type PtySpawnInput, PtySpawnError, -} from "../Services/PTY"; -import { makeTerminalManagerWithOptions } from "./Manager"; +} from "../Services/PTY.ts"; +import { makeTerminalManagerWithOptions } from "./Manager.ts"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; readonly resizeCalls: Array<{ cols: number; rows: number }> = []; readonly killSignals: Array = []; + readonly pid: number; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); killed = false; - constructor(readonly pid: number) {} + constructor(pid: number) { + this.pid = pid; + } write(data: string): void { this.writes.push(data); @@ -88,9 +91,12 @@ class FakePtyAdapter implements PtyAdapterShape { readonly spawnInputs: PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; + private readonly mode: "sync" | "async"; private nextPid = 9000; - constructor(private readonly mode: "sync" | "async" = "sync") {} + constructor(mode: "sync" | "async" = "sync") { + this.mode = mode; + } spawn(input: PtySpawnInput): Effect.Effect { this.spawnInputs.push(input); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 4bdeba68e16b..409e4397cc10 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -22,13 +22,13 @@ import { SynchronizedRef, } from "effect"; -import { ServerConfig } from "../../config"; +import { ServerConfig } from "../../config.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, -} from "../../observability/Metrics"; -import { runProcess } from "../../processRunner"; +} from "../../observability/Metrics.ts"; +import { runProcess } from "../../processRunner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -36,14 +36,14 @@ import { TerminalNotRunningError, TerminalSessionLookupError, type TerminalManagerShape, -} from "../Services/Manager"; +} from "../Services/Manager.ts"; import { PtyAdapter, PtySpawnError, type PtyAdapterShape, type PtyExitEvent, type PtyProcess, -} from "../Services/PTY"; +} from "../Services/PTY.ts"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; diff --git a/apps/server/src/terminal/Layers/NodePTY.test.ts b/apps/server/src/terminal/Layers/NodePTY.test.ts index 58fcc70e4e56..06f186312aa8 100644 --- a/apps/server/src/terminal/Layers/NodePTY.test.ts +++ b/apps/server/src/terminal/Layers/NodePTY.test.ts @@ -1,7 +1,7 @@ import { FileSystem, Path, Effect } from "effect"; import { assert, it } from "@effect/vitest"; -import { ensureNodePtySpawnHelperExecutable } from "./NodePTY"; +import { ensureNodePtySpawnHelperExecutable } from "./NodePTY.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; it.layer(NodeServices.layer)("ensureNodePtySpawnHelperExecutable", (it) => { diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index cf1fdd219824..67aecb9fa6a2 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -1,7 +1,8 @@ import { createRequire } from "node:module"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { PtyAdapter, PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY"; +import { PtyAdapter } from "../Services/PTY.ts"; +import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; let didEnsureSpawnHelperExecutable = false; @@ -46,7 +47,11 @@ export const ensureNodePtySpawnHelperExecutable = Effect.fn(function* (explicitP }); class NodePtyProcess implements PtyProcess { - constructor(private readonly process: import("node-pty").IPty) {} + private readonly process: import("node-pty").IPty; + + constructor(process: import("node-pty").IPty) { + this.process = process; + } get pid(): number { return this.process.pid; diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index b59c4721cd3e..fb7a7da7b64b 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -22,7 +22,7 @@ import { TerminalSessionStatus, TerminalWriteInput, } from "@t3tools/contracts"; -import { PtyProcess } from "./PTY"; +import type { PtyProcess } from "./PTY.ts"; import { Effect, Context } from "effect"; export { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ff9592b5b345..aac716cfeb63 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -27,42 +27,42 @@ import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; -import { ServerConfig } from "./config"; -import { GitCore } from "./git/Services/GitCore"; -import { GitManager } from "./git/Services/GitManager"; -import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; -import { Keybindings } from "./keybindings"; -import { Open, resolveAvailableEditors } from "./open"; -import { normalizeDispatchCommand } from "./orchestration/Normalizer"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; +import { ServerConfig } from "./config.ts"; +import { GitCore } from "./git/Services/GitCore.ts"; +import { GitManager } from "./git/Services/GitManager.ts"; +import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster.ts"; +import { Keybindings } from "./keybindings.ts"; +import { Open, resolveAvailableEditors } from "./open.ts"; +import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { observeRpcEffect, observeRpcStream, observeRpcStreamEffect, -} from "./observability/RpcInstrumentation"; -import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; -import { ServerLifecycleEvents } from "./serverLifecycleEvents"; -import { ServerRuntimeStartup } from "./serverRuntimeStartup"; -import { ServerSettingsService } from "./serverSettings"; -import { TerminalManager } from "./terminal/Services/Manager"; -import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; -import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; -import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; -import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; -import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; -import { ServerAuth } from "./auth/Services/ServerAuth"; +} from "./observability/RpcInstrumentation.ts"; +import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; +import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; +import { TerminalManager } from "./terminal/Services/Manager.ts"; +import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; +import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; +import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; +import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import { BootstrapCredentialService, type BootstrapCredentialChange, -} from "./auth/Services/BootstrapCredentialService"; +} from "./auth/Services/BootstrapCredentialService.ts"; import { SessionCredentialService, type SessionCredentialChange, -} from "./auth/Services/SessionCredentialService"; -import { respondToAuthError } from "./auth/http"; +} from "./auth/Services/SessionCredentialService.ts"; +import { respondToAuthError } from "./auth/http.ts"; function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 07d52467f51c..c19bdbf4565c 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,9 +3,7 @@ "compilerOptions": { "composite": true, "types": ["node", "bun"], - "lib": ["ES2023", "esnext.disposable"], - "noEmit": true, - "allowImportingTsExtensions": true, + "lib": ["ESNext", "esnext.disposable"], "plugins": [ { "name": "@effect/language-service", diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 1c5b2f0d38d0..660d69423d90 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -1,6 +1,6 @@ import { defineConfig, mergeConfig } from "vitest/config"; -import baseConfig from "../../vitest.config"; +import baseConfig from "../../vitest.config.ts"; export default mergeConfig( baseConfig, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 178f4bcbabfa..4dd68d7213fc 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -2,6 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, + "module": "Preserve", + "moduleResolution": "Bundler", + "erasableSyntaxOnly": false, + "verbatimModuleSyntax": false, "jsx": "react-jsx", "lib": ["ES2023", "DOM", "DOM.Iterable"], "types": ["vite/client"], diff --git a/docs/observability.md b/docs/observability.md index dde109357097..5b98d1163ea4 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -69,11 +69,11 @@ npx t3 ``` ```bash -bun dev +node --run dev ``` ```bash -bun dev:desktop +node --run dev:desktop ``` ### Option 2: Run With A Local LGTM Stack @@ -122,13 +122,13 @@ npx t3 Monorepo web/server dev: ```bash -bun dev +node --run dev ``` Monorepo desktop dev: ```bash -bun dev:desktop +node --run dev:desktop ``` Packaged desktop app: diff --git a/package.json b/package.json index ade7bcce44a6..9e412b5ba228 100644 --- a/package.json +++ b/package.json @@ -33,14 +33,13 @@ "start": "turbo run start --filter=t3", "start:desktop": "turbo run start --filter=@t3tools/desktop", "start:marketing": "turbo run preview --filter=@t3tools/marketing", - "start:mock-update-server": "bun run scripts/mock-update-server.ts", + "start:mock-update-server": "node scripts/mock-update-server.ts", "build": "turbo run build", "build:marketing": "turbo run build --filter=@t3tools/marketing", "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", "typecheck": "turbo run typecheck", "lint": "oxlint --report-unused-disable-directives", "test": "turbo run test", - "test:process-reaper": "bun run --cwd apps/server test:process-reaper", "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", "fmt": "oxfmt", "fmt:check": "oxfmt --check", diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts index 5dd6b9afa573..9ca76328a8ec 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -1,2 +1,2 @@ -export * from "./knownEnvironment"; -export * from "./scoped"; +export * from "./knownEnvironment.ts"; +export * from "./scoped.ts"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/knownEnvironment.test.ts index dca56c1e6da1..a40161e9b04d 100644 --- a/packages/client-runtime/src/knownEnvironment.test.ts +++ b/packages/client-runtime/src/knownEnvironment.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment"; +import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment.ts"; import { parseScopedProjectKey, parseScopedThreadKey, @@ -10,7 +10,7 @@ import { scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "./scoped"; +} from "./scoped.ts"; describe("known environment bootstrap helpers", () => { it("creates known environments from explicit server base urls", () => { diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 73327a45af15..8110104e1984 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas"; +import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Declares the server's overall authentication posture. diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 569f096df499..8444e8b1068f 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); export type EditorLaunchStyle = typeof EditorLaunchStyle.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 28adbf21781f..aa34c339a393 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect"; -import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; +import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 41b1eb2b6f4d..a518e2e9acdb 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; const FILESYSTEM_PATH_MAX_LENGTH = 512; diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index d5b2d7dfd899..ebd5324fb9f5 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -7,7 +7,7 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, -} from "./git"; +} from "./git.ts"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 345208acf9ec..47d74dc35678 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; +import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const GIT_LIST_BRANCHES_MAX_LIMIT = 200; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f2327d25a0a..47081d8df1be 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,17 +1,17 @@ -export * from "./baseSchemas"; -export * from "./auth"; -export * from "./environment"; -export * from "./ipc"; -export * from "./terminal"; -export * from "./provider"; -export * from "./providerRuntime"; -export * from "./model"; -export * from "./keybindings"; -export * from "./server"; -export * from "./settings"; -export * from "./git"; -export * from "./orchestration"; -export * from "./editor"; -export * from "./project"; -export * from "./filesystem"; -export * from "./rpc"; +export * from "./baseSchemas.ts"; +export * from "./auth.ts"; +export * from "./environment.ts"; +export * from "./ipc.ts"; +export * from "./terminal.ts"; +export * from "./provider.ts"; +export * from "./providerRuntime.ts"; +export * from "./model.ts"; +export * from "./keybindings.ts"; +export * from "./server.ts"; +export * from "./settings.ts"; +export * from "./git.ts"; +export * from "./orchestration.ts"; +export * from "./editor.ts"; +export * from "./project.ts"; +export * from "./filesystem.ts"; +export * from "./rpc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e9cc28736a53..a1abc0fa4a00 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -17,19 +17,19 @@ import type { GitStatusInput, GitStatusResult, GitCreateBranchResult, -} from "./git"; -import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem"; +} from "./git.ts"; +import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, ProjectWriteFileResult, -} from "./project"; +} from "./project.ts"; import type { ServerConfig, ServerProviderUpdatedPayload, ServerUpsertKeybindingResult, -} from "./server"; +} from "./server.ts"; import type { TerminalClearInput, TerminalCloseInput, @@ -39,8 +39,8 @@ import type { TerminalRestartInput, TerminalSessionSnapshot, TerminalWriteInput, -} from "./terminal"; -import type { ServerUpsertKeybindingInput } from "./server"; +} from "./terminal.ts"; +import type { ServerUpsertKeybindingInput } from "./server.ts"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -50,10 +50,10 @@ import type { OrchestrationShellStreamItem, OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, -} from "./orchestration"; -import type { EnvironmentId } from "./baseSchemas"; -import { EditorId } from "./editor"; -import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings"; +} from "./orchestration.ts"; +import type { EnvironmentId } from "./baseSchemas.ts"; +import { EditorId } from "./editor.ts"; +import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; export interface ContextMenuItem { id: T; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 092d5344f2b0..79c2feb8baaa 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -7,7 +7,7 @@ import { KeybindingRule, ResolvedKeybindingRule, ResolvedKeybindingsConfig, -} from "./keybindings"; +} from "./keybindings.ts"; const decode = ( schema: S, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 72067eac8a80..1296e74c1b90 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedString } from "./baseSchemas"; +import { TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; const MAX_KEYBINDING_WHEN_LENGTH = 256; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index c87224cf25b3..8e53015980d1 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; -import type { ProviderKind } from "./orchestration"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import type { ProviderKind } from "./orchestration.ts"; export const CodexReasoningEffort = Schema.Literals(["xhigh", "high", "medium", "low"]); export type CodexReasoningEffort = typeof CodexReasoningEffort.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index ad46e380a388..223efd6d2700 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -19,7 +19,7 @@ import { ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, -} from "./orchestration"; +} from "./orchestration.ts"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2745cdcdb796..6cd63ab17890 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,6 +1,6 @@ import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; -import { ClaudeModelOptions, CodexModelOptions } from "./model"; -import { RepositoryIdentity } from "./environment"; +import { ClaudeModelOptions, CodexModelOptions } from "./model.ts"; +import { RepositoryIdentity } from "./environment.ts"; import { ApprovalRequestId, CheckpointRef, @@ -14,7 +14,7 @@ import { ThreadId, TrimmedNonEmptyString, TurnId, -} from "./baseSchemas"; +} from "./baseSchemas.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 2851120d1d41..d089951bc07f 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 37469984de4e..bd20b7e9b346 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider"; +import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider.ts"; const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 16102920d712..e27e3aa7ef96 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ApprovalRequestId, EventId, @@ -7,7 +7,7 @@ import { ProviderItemId, ThreadId, TurnId, -} from "./baseSchemas"; +} from "./baseSchemas.ts"; import { ChatAttachment, ModelSelection, @@ -21,7 +21,7 @@ import { ProviderSandboxMode, ProviderUserInputAnswers, RuntimeMode, -} from "./orchestration"; +} from "./orchestration.ts"; const ProviderSessionStatus = Schema.Literals([ "connecting", diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 9d9c395c3d51..7b822a2860b0 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { ProviderRuntimeEvent } from "./providerRuntime"; +import { ProviderRuntimeEvent } from "./providerRuntime.ts"; const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 6b03d70d56c0..5f2673e81de5 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -11,8 +11,8 @@ import { ThreadId, TrimmedNonEmptyString, TurnId, -} from "./baseSchemas"; -import { ProviderKind } from "./orchestration"; +} from "./baseSchemas.ts"; +import { ProviderKind } from "./orchestration.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ebdab2c45d3c..5dec716a7257 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,9 +2,13 @@ import { Schema } from "effect"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import { OpenError, OpenInEditorInput } from "./editor"; -import { AuthAccessStreamEvent } from "./auth"; -import { FilesystemBrowseInput, FilesystemBrowseResult, FilesystemBrowseError } from "./filesystem"; +import { OpenError, OpenInEditorInput } from "./editor.ts"; +import { AuthAccessStreamEvent } from "./auth.ts"; +import { + FilesystemBrowseInput, + FilesystemBrowseResult, + FilesystemBrowseError, +} from "./filesystem.ts"; import { GitActionProgressEvent, GitCheckoutInput, @@ -29,8 +33,8 @@ import { GitStatusInput, GitStatusResult, GitStatusStreamEvent, -} from "./git"; -import { KeybindingsConfigError } from "./keybindings"; +} from "./git.ts"; +import { KeybindingsConfigError } from "./keybindings.ts"; import { ClientOrchestrationCommand, ORCHESTRATION_WS_METHODS, @@ -43,7 +47,7 @@ import { OrchestrationReplayEventsError, OrchestrationReplayEventsInput, OrchestrationRpcSchemas, -} from "./orchestration"; +} from "./orchestration.ts"; import { ProjectSearchEntriesError, ProjectSearchEntriesInput, @@ -51,7 +55,7 @@ import { ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, -} from "./project"; +} from "./project.ts"; import { TerminalClearInput, TerminalCloseInput, @@ -62,7 +66,7 @@ import { TerminalRestartInput, TerminalSessionSnapshot, TerminalWriteInput, -} from "./terminal"; +} from "./terminal.ts"; import { ServerConfigStreamEvent, ServerConfig, @@ -70,8 +74,8 @@ import { ServerProviderUpdatedPayload, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, -} from "./server"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings"; +} from "./server.ts"; +import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; export const WS_METHODS = { // Project registry methods diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 6e5f70c2e4d1..2603d51d6a96 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,7 +1,7 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { ServerProvider } from "./server"; +import { ServerProvider } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 50db737c6ae6..c08dfa6cd1c5 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,18 +1,18 @@ import { Effect, Schema } from "effect"; -import { ExecutionEnvironmentDescriptor } from "./environment"; -import { ServerAuthDescriptor } from "./auth"; +import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ServerAuthDescriptor } from "./auth.ts"; import { IsoDateTime, NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString, -} from "./baseSchemas"; -import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; -import { EditorId } from "./editor"; -import { ModelCapabilities } from "./model"; -import { ProviderKind } from "./orchestration"; -import { ServerSettings } from "./settings"; +} from "./baseSchemas.ts"; +import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts"; +import { EditorId } from "./editor.ts"; +import { ModelCapabilities } from "./model.ts"; +import { ProviderKind } from "./orchestration.ts"; +import { ServerSettings } from "./settings.ts"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 28723cf254d3..fada38eb1cad 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,13 +1,13 @@ import { Effect } from "effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; +import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { ClaudeModelOptions, CodexModelOptions, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, -} from "./model"; -import { ModelSelection } from "./orchestration"; +} from "./model.ts"; +import { ModelSelection } from "./orchestration.ts"; // ── Client Settings (local-only) ─────────────────────────────── diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 1bef8db3a0ba..3feae6749242 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -11,7 +11,7 @@ import { TerminalSessionSnapshot, TerminalThreadInput, TerminalWriteInput, -} from "./terminal"; +} from "./terminal.ts"; function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 3fe883b44204..21bd74a09990 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,5 @@ import { Effect, Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const DEFAULT_TERMINAL_ID = "default"; diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index 1d7a3a83c78f..0033038d0c5b 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -2,7 +2,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; import { Deferred, Effect } from "effect"; -import { makeDrainableWorker } from "./DrainableWorker"; +import { makeDrainableWorker } from "./DrainableWorker.ts"; describe("makeDrainableWorker", () => { it.live("waits for work enqueued during active processing before draining", () => diff --git a/packages/shared/src/KeyedCoalescingWorker.test.ts b/packages/shared/src/KeyedCoalescingWorker.test.ts index 2226bbd003ee..78c3a6b91025 100644 --- a/packages/shared/src/KeyedCoalescingWorker.test.ts +++ b/packages/shared/src/KeyedCoalescingWorker.test.ts @@ -2,7 +2,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; import { Deferred, Effect } from "effect"; -import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker"; +import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts"; describe("makeKeyedCoalescingWorker", () => { it.live("waits for latest work enqueued during active processing before draining the key", () => diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 137a9416fd15..19033a082b4a 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -3,7 +3,7 @@ import * as Net from "node:net"; import { assert, describe, it } from "@effect/vitest"; import { Effect } from "effect"; -import { NetError, NetService } from "./Net"; +import { NetError, NetService } from "./Net.ts"; const closeServer = (server: Net.Server) => Effect.sync(() => { diff --git a/packages/shared/src/String.test.ts b/packages/shared/src/String.test.ts index d70bfe840f21..92730cd596e4 100644 --- a/packages/shared/src/String.test.ts +++ b/packages/shared/src/String.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { truncate } from "./String"; +import { truncate } from "./String.ts"; describe("truncate", () => { it("trims surrounding whitespace", () => { diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts index 02c0b48805b6..62544c682c0a 100644 --- a/packages/shared/src/cliArgs.test.ts +++ b/packages/shared/src/cliArgs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseCliArgs } from "./cliArgs"; +import { parseCliArgs } from "./cliArgs.ts"; describe("parseCliArgs", () => { it("returns empty result for empty string", () => { diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index ba3af6c7768d..2160c460dc51 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -8,7 +8,7 @@ import { normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, WORKTREE_BRANCH_PREFIX, -} from "./git"; +} from "./git.ts"; describe("normalizeGitRemoteUrl", () => { it("canonicalizes equivalent GitHub remotes across protocol variants", () => { diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 62313f360b78..312ce04337a1 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -18,7 +18,7 @@ import { resolveModelSlugForProvider, resolveSelectableModel, trimOrNull, -} from "./model"; +} from "./model.ts"; const codexCaps: ModelCapabilities = { reasoningEffortLevels: [ diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 912e1e13d758..1c74c59a36f3 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -4,7 +4,7 @@ import { isUncPath, isWindowsAbsolutePath, isWindowsDrivePath, -} from "./path"; +} from "./path.ts"; describe("path helpers", () => { it("detects windows drive paths", () => { diff --git a/packages/shared/src/qrCode.ts b/packages/shared/src/qrCode.ts index 678d38c11141..490e11fa04f1 100644 --- a/packages/shared/src/qrCode.ts +++ b/packages/shared/src/qrCode.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /* oxlint-disable eslint/no-useless-escape */ /* * QR Code generator library (TypeScript) @@ -25,960 +24,962 @@ "use strict"; -namespace qrcodegen { - type bit = number; - type byte = number; - type int = number; - - /*---- QR Code symbol class ----*/ - - /* - * A QR Code symbol, which is a type of two-dimension barcode. - * Invented by Denso Wave and described in the ISO/IEC 18004 standard. - * Instances of this class represent an immutable square grid of dark and light cells. - * The class provides static factory functions to create a QR Code from text or binary data. - * The class covers the QR Code Model 2 specification, supporting all versions (sizes) - * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. - * - * Ways to create a QR Code object: - * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). - * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). - * - Low level: Custom-make the array of data codeword bytes (including - * segment headers and final padding, excluding error correction codewords), - * supply the appropriate version number, and call the QrCode() constructor. - * (Note that all ways require supplying the desired error correction level.) - */ - export class QrCode { - /*-- Static factory functions (high level) --*/ - - // Returns a QR Code representing the given Unicode text string at the given error correction level. - // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer - // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible - // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the - // ecl argument if it can be done without increasing the version. - public static encodeText(text: string, ecl: QrCode.Ecc): QrCode { - const segs: Array = qrcodegen.QrSegment.makeSegments(text); - return QrCode.encodeSegments(segs, ecl); - } +type bit = number; +type byte = number; +type int = number; - // Returns a QR Code representing the given binary data at the given error correction level. - // This function always encodes using the binary segment mode, not any text mode. The maximum number of - // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. - // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. - public static encodeBinary(data: Readonly>, ecl: QrCode.Ecc): QrCode { - const seg: QrSegment = qrcodegen.QrSegment.makeBytes(data); - return QrCode.encodeSegments([seg], ecl); - } +/*---- QR Code symbol class ----*/ - /*-- Static factory functions (mid level) --*/ - - // Returns a QR Code representing the given segments with the given encoding parameters. - // The smallest possible QR Code version within the given range is automatically - // chosen for the output. Iff boostEcl is true, then the ECC level of the result - // may be higher than the ecl argument if it can be done without increasing the - // version. The mask number is either between 0 to 7 (inclusive) to force that - // mask, or -1 to automatically choose an appropriate mask (which may be slow). - // This function allows the user to create a custom sequence of segments that switches - // between modes (such as alphanumeric and byte) to encode text in less space. - // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). - public static encodeSegments( - segs: Readonly>, - ecl: QrCode.Ecc, - minVersion: int = 1, - maxVersion: int = 40, - mask: int = -1, - boostEcl: boolean = true, - ): QrCode { - if ( - !( - QrCode.MIN_VERSION <= minVersion && - minVersion <= maxVersion && - maxVersion <= QrCode.MAX_VERSION - ) || - mask < -1 || - mask > 7 - ) - throw new RangeError("Invalid value"); - - // Find the minimal version number to use - let version: int; - let dataUsedBits: int; - for (version = minVersion; ; version++) { - const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available - const usedBits: number = QrSegment.getTotalBits(segs, version); - if (usedBits <= dataCapacityBits) { - dataUsedBits = usedBits; - break; // This version number is found to be suitable - } - if (version >= maxVersion) - // All versions in the range could not fit the given data - throw new RangeError("Data too long"); - } +/* + * A QR Code symbol, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * Instances of this class represent an immutable square grid of dark and light cells. + * The class provides static factory functions to create a QR Code from text or binary data. + * The class covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). + * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). + * - Low level: Custom-make the array of data codeword bytes (including + * segment headers and final padding, excluding error correction codewords), + * supply the appropriate version number, and call the QrCode() constructor. + * (Note that all ways require supplying the desired error correction level.) + */ +export class QrCode { + public static Ecc: typeof QrCodeEcc; + + /*-- Static factory functions (high level) --*/ + + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + public static encodeText(text: string, ecl: QrCodeEcc): QrCode { + const segs: Array = QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } - // Increase the error correction level while the data still fits in the current version number - for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { - // From low to high - if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) - ecl = newEcl; - } + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + public static encodeBinary(data: Readonly>, ecl: QrCodeEcc): QrCode { + const seg: QrSegment = QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } - // Concatenate all segments to create the data bit string - let bb: Array = []; - for (const seg of segs) { - appendBits(seg.mode.modeBits, 4, bb); - appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); - for (const b of seg.getData()) bb.push(b); + /*-- Static factory functions (mid level) --*/ + + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + public static encodeSegments( + segs: Readonly>, + ecl: QrCodeEcc, + minVersion: int = 1, + maxVersion: int = 40, + mask: int = -1, + boostEcl: boolean = true, + ): QrCode { + if ( + !( + QrCode.MIN_VERSION <= minVersion && + minVersion <= maxVersion && + maxVersion <= QrCode.MAX_VERSION + ) || + mask < -1 || + mask > 7 + ) + throw new RangeError("Invalid value"); + + // Find the minimal version number to use + let version: int; + let dataUsedBits: int; + for (version = minVersion; ; version++) { + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available + const usedBits: number = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits) { + dataUsedBits = usedBits; + break; // This version number is found to be suitable } - assert(bb.length == dataUsedBits); - - // Add terminator and pad up to a byte if applicable - const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; - assert(bb.length <= dataCapacityBits); - appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); - appendBits(0, (8 - (bb.length % 8)) % 8, bb); - assert(bb.length % 8 == 0); - - // Pad with alternating bytes until data capacity is reached - for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11) - appendBits(padByte, 8, bb); - - // Pack bits into bytes in big endian - let dataCodewords: Array = []; - while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); - bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7)))); - - // Create the QR Code object - return new QrCode(version, ecl, dataCodewords, mask); + if (version >= maxVersion) + // All versions in the range could not fit the given data + throw new RangeError("Data too long"); } - /*-- Fields --*/ - - // The width and height of this QR Code, measured in modules, between - // 21 and 177 (inclusive). This is equal to version * 4 + 17. - public readonly size: int; - - // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). - // Even if a QR Code is created with automatic masking requested (mask = -1), - // the resulting object still has a mask value between 0 and 7. - public readonly mask: int; - - // The modules of this QR Code (false = light, true = dark). - // Immutable after constructor finishes. Accessed through getModule(). - private readonly modules: Array> = []; - - // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. - private readonly isFunction: Array> = []; - - /*-- Constructor (low level) and fields --*/ - - // Creates a new QR Code with the given version number, - // error correction level, data codeword bytes, and mask number. - // This is a low-level API that most users should not use directly. - // A mid-level API is the encodeSegments() function. - public constructor( - // The version number of this QR Code, which is between 1 and 40 (inclusive). - // This determines the size of this barcode. - public readonly version: int, - - // The error correction level used in this QR Code. - public readonly errorCorrectionLevel: QrCode.Ecc, - - dataCodewords: Readonly>, - - msk: int, - ) { - // Check scalar arguments - if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) - throw new RangeError("Version value out of range"); - if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); - this.size = version * 4 + 17; - - // Initialize both grids to be size*size arrays of Boolean false - let row: Array = []; - for (let i = 0; i < this.size; i++) row.push(false); - for (let i = 0; i < this.size; i++) { - this.modules.push(row.slice()); // Initially all light - this.isFunction.push(row.slice()); - } - - // Compute ECC, draw modules - this.drawFunctionPatterns(); - const allCodewords: Array = this.addEccAndInterleave(dataCodewords); - this.drawCodewords(allCodewords); - - // Do masking - if (msk == -1) { - // Automatically choose best mask - let minPenalty: int = 1000000000; - for (let i = 0; i < 8; i++) { - this.applyMask(i); - this.drawFormatBits(i); - const penalty: int = this.getPenaltyScore(); - if (penalty < minPenalty) { - msk = i; - minPenalty = penalty; - } - this.applyMask(i); // Undoes the mask due to XOR - } - } - assert(0 <= msk && msk <= 7); - this.mask = msk; - this.applyMask(msk); // Apply the final choice of mask - this.drawFormatBits(msk); // Overwrite old format bits + // Increase the error correction level while the data still fits in the current version number + for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { + // From low to high + if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) ecl = newEcl; + } - this.isFunction = []; + // Concatenate all segments to create the data bit string + let bb: Array = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) bb.push(b); } + assert(bb.length == dataUsedBits); - /*-- Accessor methods --*/ + // Add terminator and pad up to a byte if applicable + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - (bb.length % 8)) % 8, bb); + assert(bb.length % 8 == 0); - // Returns the color of the module (pixel) at the given coordinates, which is false - // for light or true for dark. The top left corner has the coordinates (x=0, y=0). - // If the given coordinates are out of bounds, then false (light) is returned. - public getModule(x: int, y: int): boolean { - return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; - } + // Pad with alternating bytes until data capacity is reached + for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11) + appendBits(padByte, 8, bb); - /*-- Private helper methods for constructor: Drawing function modules --*/ + // Pack bits into bytes in big endian + let dataCodewords: Array = []; + while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); + bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3]! |= b << (7 - (i & 7)))); - // Reads this object's version field, and draws and marks all function modules. - private drawFunctionPatterns(): void { - // Draw horizontal and vertical timing patterns - for (let i = 0; i < this.size; i++) { - this.setFunctionModule(6, i, i % 2 == 0); - this.setFunctionModule(i, 6, i % 2 == 0); - } + // Create the QR Code object + return new QrCode(version, ecl, dataCodewords, mask); + } - // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) - this.drawFinderPattern(3, 3); - this.drawFinderPattern(this.size - 4, 3); - this.drawFinderPattern(3, this.size - 4); - - // Draw numerous alignment patterns - const alignPatPos: Array = this.getAlignmentPatternPositions(); - const numAlign: int = alignPatPos.length; - for (let i = 0; i < numAlign; i++) { - for (let j = 0; j < numAlign; j++) { - // Don't draw on the three finder corners - if ( - !((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)) - ) - this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + /*-- Fields --*/ + + // The width and height of this QR Code, measured in modules, between + // 21 and 177 (inclusive). This is equal to version * 4 + 17. + public readonly version: int; + public readonly errorCorrectionLevel: QrCodeEcc; + public readonly size: int; + + // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). + // Even if a QR Code is created with automatic masking requested (mask = -1), + // the resulting object still has a mask value between 0 and 7. + public readonly mask: int; + + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + private readonly modules: Array> = []; + + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + private readonly isFunction: Array> = []; + + /*-- Constructor (low level) and fields --*/ + + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + public constructor( + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + version: int, + + // The error correction level used in this QR Code. + errorCorrectionLevel: QrCodeEcc, + + dataCodewords: Readonly>, + + msk: int, + ) { + this.version = version; + this.errorCorrectionLevel = errorCorrectionLevel; + + // Check scalar arguments + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + + // Initialize both grids to be size*size arrays of Boolean false + let row: Array = []; + for (let i = 0; i < this.size; i++) row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); // Initially all light + this.isFunction.push(row.slice()); + } + + // Compute ECC, draw modules + this.drawFunctionPatterns(); + const allCodewords: Array = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + + // Do masking + if (msk == -1) { + // Automatically choose best mask + let minPenalty: int = 1000000000; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty: int = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; } + this.applyMask(i); // Undoes the mask due to XOR } - - // Draw configuration data - this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor - this.drawVersion(); } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); // Apply the final choice of mask + this.drawFormatBits(msk); // Overwrite old format bits - // Draws two copies of the format bits (with its own error correction code) - // based on the given mask and this object's error correction level field. - private drawFormatBits(mask: int): void { - // Calculate error correction code and pack bits - const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 - let rem: int = data; - for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); - const bits = ((data << 10) | rem) ^ 0x5412; // uint15 - assert(bits >>> 15 == 0); - - // Draw first copy - for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i)); - this.setFunctionModule(8, 7, getBit(bits, 6)); - this.setFunctionModule(8, 8, getBit(bits, 7)); - this.setFunctionModule(7, 8, getBit(bits, 8)); - for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i)); - - // Draw second copy - for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); - for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); - this.setFunctionModule(8, this.size - 8, true); // Always dark - } + this.isFunction = []; + } + + /*-- Accessor methods --*/ + + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + public getModule(x: int, y: int): boolean { + return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y]![x]!; + } - // Draws two copies of the version bits (with its own error correction code), - // based on this object's version field, iff 7 <= version <= 40. - private drawVersion(): void { - if (this.version < 7) return; - - // Calculate error correction code and pack bits - let rem: int = this.version; // version is uint6, in the range [7, 40] - for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); - const bits: int = (this.version << 12) | rem; // uint18 - assert(bits >>> 18 == 0); - - // Draw two copies - for (let i = 0; i < 18; i++) { - const color: boolean = getBit(bits, i); - const a: int = this.size - 11 + (i % 3); - const b: int = Math.floor(i / 3); - this.setFunctionModule(a, b, color); - this.setFunctionModule(b, a, color); + /*-- Private helper methods for constructor: Drawing function modules --*/ + + // Reads this object's version field, and draws and marks all function modules. + private drawFunctionPatterns(): void { + // Draw horizontal and vertical timing patterns + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + + // Draw numerous alignment patterns + const alignPatPos: Array = this.getAlignmentPatternPositions(); + const numAlign: int = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if (!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))) + this.drawAlignmentPattern(alignPatPos[i]!, alignPatPos[j]!); } } - // Draws a 9*9 finder pattern including the border separator, - // with the center module at (x, y). Modules can be out of bounds. - private drawFinderPattern(x: int, y: int): void { - for (let dy = -4; dy <= 4; dy++) { - for (let dx = -4; dx <= 4; dx++) { - const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm - const xx: int = x + dx; - const yy: int = y + dy; - if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) - this.setFunctionModule(xx, yy, dist != 2 && dist != 4); - } - } + // Draw configuration data + this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawVersion(); + } + + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + private drawFormatBits(mask: int): void { + // Calculate error correction code and pack bits + const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 + let rem: int = data; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((data << 10) | rem) ^ 0x5412; // uint15 + assert(bits >>> 15 == 0); + + // Draw first copy + for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i)); + + // Draw second copy + for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); // Always dark + } + + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + private drawVersion(): void { + if (this.version < 7) return; + + // Calculate error correction code and pack bits + let rem: int = this.version; // version is uint6, in the range [7, 40] + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits: int = (this.version << 12) | rem; // uint18 + assert(bits >>> 18 == 0); + + // Draw two copies + for (let i = 0; i < 18; i++) { + const color: boolean = getBit(bits, i); + const a: int = this.size - 11 + (i % 3); + const b: int = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); } + } - // Draws a 5*5 alignment pattern, with the center module - // at (x, y). All modules must be in bounds. - private drawAlignmentPattern(x: int, y: int): void { - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) - this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + private drawFinderPattern(x: int, y: int): void { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const xx: int = x + dx; + const yy: int = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); } } + } - // Sets the color of a module and marks it as a function module. - // Only used by the constructor. Coordinates must be in bounds. - private setFunctionModule(x: int, y: int, isDark: boolean): void { - this.modules[y][x] = isDark; - this.isFunction[y][x] = true; + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + private drawAlignmentPattern(x: int, y: int): void { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); } + } - /*-- Private helper methods for constructor: Codewords and masking --*/ - - // Returns a new byte string representing the given data with the appropriate error correction - // codewords appended to it, based on this object's version and error correction level. - private addEccAndInterleave(data: Readonly>): Array { - const ver: int = this.version; - const ecl: QrCode.Ecc = this.errorCorrectionLevel; - if (data.length != QrCode.getNumDataCodewords(ver, ecl)) - throw new RangeError("Invalid argument"); - - // Calculate parameter numbers - const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; - const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; - const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); - const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); - const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); - - // Split data into blocks and append ECC to each block - let blocks: Array> = []; - const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); - for (let i = 0, k = 0; i < numBlocks; i++) { - let dat: Array = data.slice( - k, - k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1), - ); - k += dat.length; - const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); - if (i < numShortBlocks) dat.push(0); - blocks.push(dat.concat(ecc)); - } + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + private setFunctionModule(x: int, y: int, isDark: boolean): void { + this.modules[y]![x] = isDark; + this.isFunction[y]![x] = true; + } - // Interleave (not concatenate) the bytes from every block into a single sequence - let result: Array = []; - for (let i = 0; i < blocks[0].length; i++) { - blocks.forEach((block, j) => { - // Skip the padding byte in short blocks - if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]); - }); - } - assert(result.length == rawCodewords); - return result; - } + /*-- Private helper methods for constructor: Codewords and masking --*/ + + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + private addEccAndInterleave(data: Readonly>): Array { + const ver: int = this.version; + const ecl: QrCodeEcc = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + + // Calculate parameter numbers + const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal]![ver]!; + const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal]![ver]!; + const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); + const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); + + // Split data into blocks and append ECC to each block + let blocks: Array> = []; + const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat: Array = data.slice( + k, + k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1), + ); + k += dat.length; + const ecc: Array = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) dat.push(0); + blocks.push(dat.concat(ecc)); + } + + // Interleave (not concatenate) the bytes from every block into a single sequence + let result: Array = []; + for (let i = 0; i < blocks[0]!.length; i++) { + blocks.forEach((block, j) => { + // Skip the padding byte in short blocks + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]!); + }); + } + assert(result.length == rawCodewords); + return result; + } - // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire - // data area of this QR Code. Function modules need to be marked off before this is called. - private drawCodewords(data: Readonly>): void { - if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) - throw new RangeError("Invalid argument"); - let i: int = 0; // Bit index into the data - // Do the funny zigzag scan - for (let right = this.size - 1; right >= 1; right -= 2) { - // Index of right column in each column pair - if (right == 6) right = 5; - for (let vert = 0; vert < this.size; vert++) { - // Vertical counter - for (let j = 0; j < 2; j++) { - const x: int = right - j; // Actual x coordinate - const upward: boolean = ((right + 1) & 2) == 0; - const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate - if (!this.isFunction[y][x] && i < data.length * 8) { - this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); - i++; - } - // If this QR Code has any remainder bits (0 to 7), they were assigned as - // 0/false/light by the constructor and are left unchanged by this method + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + private drawCodewords(data: Readonly>): void { + if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) + throw new RangeError("Invalid argument"); + let i: int = 0; // Bit index into the data + // Do the funny zigzag scan + for (let right = this.size - 1; right >= 1; right -= 2) { + // Index of right column in each column pair + if (right == 6) right = 5; + for (let vert = 0; vert < this.size; vert++) { + // Vertical counter + for (let j = 0; j < 2; j++) { + const x: int = right - j; // Actual x coordinate + const upward: boolean = ((right + 1) & 2) == 0; + const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate + if (!this.isFunction[y]![x]! && i < data.length * 8) { + this.modules[y]![x] = getBit(data[i >>> 3]!, 7 - (i & 7)); + i++; } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method } } - assert(i == data.length * 8); } + assert(i == data.length * 8); + } - // XORs the codeword modules in this QR Code with the given mask pattern. - // The function modules must be marked and the codeword bits must be drawn - // before masking. Due to the arithmetic of XOR, calling applyMask() with - // the same mask value a second time will undo the mask. A final well-formed - // QR Code needs exactly one (not zero, two, etc.) mask applied. - private applyMask(mask: int): void { - if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); - for (let y = 0; y < this.size; y++) { - for (let x = 0; x < this.size; x++) { - let invert: boolean; - switch (mask) { - case 0: - invert = (x + y) % 2 == 0; - break; - case 1: - invert = y % 2 == 0; - break; - case 2: - invert = x % 3 == 0; - break; - case 3: - invert = (x + y) % 3 == 0; - break; - case 4: - invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; - break; - case 5: - invert = ((x * y) % 2) + ((x * y) % 3) == 0; - break; - case 6: - invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0; - break; - case 7: - invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0; - break; - default: - throw new Error("Unreachable"); - } - if (!this.isFunction[y][x] && invert) this.modules[y][x] = !this.modules[y][x]; + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + private applyMask(mask: int): void { + if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert: boolean; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = ((x * y) % 2) + ((x * y) % 3) == 0; + break; + case 6: + invert = (((x * y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + case 7: + invert = (((x + y) % 2) + ((x * y) % 3)) % 2 == 0; + break; + default: + throw new Error("Unreachable"); } + if (!this.isFunction[y]![x]! && invert) this.modules[y]![x] = !this.modules[y]![x]!; } } + } - // Calculates and returns the penalty score based on state of this QR Code's current modules. - // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. - private getPenaltyScore(): int { - let result: int = 0; + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + private getPenaltyScore(): int { + let result: int = 0; - // Adjacent modules in row having same color, and finder-like patterns - for (let y = 0; y < this.size; y++) { - let runColor = false; - let runX = 0; - let runHistory = [0, 0, 0, 0, 0, 0, 0]; - for (let x = 0; x < this.size; x++) { - if (this.modules[y][x] == runColor) { - runX++; - if (runX == 5) result += QrCode.PENALTY_N1; - else if (runX > 5) result++; - } else { - this.finderPenaltyAddHistory(runX, runHistory); - if (!runColor) - result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; - runColor = this.modules[y][x]; - runX = 1; - } - } - result += - this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; - } - // Adjacent modules in column having same color, and finder-like patterns + // Adjacent modules in row having same color, and finder-like patterns + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; for (let x = 0; x < this.size; x++) { - let runColor = false; - let runY = 0; - let runHistory = [0, 0, 0, 0, 0, 0, 0]; - for (let y = 0; y < this.size; y++) { - if (this.modules[y][x] == runColor) { - runY++; - if (runY == 5) result += QrCode.PENALTY_N1; - else if (runY > 5) result++; - } else { - this.finderPenaltyAddHistory(runY, runHistory); - if (!runColor) - result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; - runColor = this.modules[y][x]; - runY = 1; - } + if (this.modules[y]![x] === runColor) { + runX++; + if (runX == 5) result += QrCode.PENALTY_N1; + else if (runX > 5) result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y]![x]!; + runX = 1; } - result += - this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; } - - // 2*2 blocks of modules having same color - for (let y = 0; y < this.size - 1; y++) { - for (let x = 0; x < this.size - 1; x++) { - const color: boolean = this.modules[y][x]; - if ( - color == this.modules[y][x + 1] && - color == this.modules[y + 1][x] && - color == this.modules[y + 1][x + 1] - ) - result += QrCode.PENALTY_N2; + result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y]![x] === runColor) { + runY++; + if (runY == 5) result += QrCode.PENALTY_N1; + else if (runY > 5) result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y]![x]!; + runY = 1; } } - - // Balance of dark and light modules - let dark: int = 0; - for (const row of this.modules) - dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); - const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 - // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% - const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; - assert(0 <= k && k <= 9); - result += k * QrCode.PENALTY_N4; - assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 - return result; - } - - /*-- Private helper functions --*/ - - // Returns an ascending list of positions of alignment patterns for this version number. - // Each position is in the range [0,177), and are used on both the x and y axes. - // This could be implemented as lookup table of 40 variable-length lists of integers. - private getAlignmentPatternPositions(): Array { - if (this.version == 1) return []; - else { - const numAlign: int = Math.floor(this.version / 7) + 2; - const step: int = - this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; - let result: Array = [6]; - for (let pos = this.size - 7; result.length < numAlign; pos -= step) - result.splice(1, 0, pos); - return result; + result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color: boolean = this.modules[y]![x]!; + if ( + color == this.modules[y]![x + 1]! && + color == this.modules[y + 1]![x]! && + color == this.modules[y + 1]![x + 1]! + ) + result += QrCode.PENALTY_N2; } } - // Returns the number of data bits that can be stored in a QR Code of the given version number, after - // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. - // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. - private static getNumRawDataModules(ver: int): int { - if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) - throw new RangeError("Version number out of range"); - let result: int = (16 * ver + 128) * ver + 64; - if (ver >= 2) { - const numAlign: int = Math.floor(ver / 7) + 2; - result -= (25 * numAlign - 10) * numAlign - 55; - if (ver >= 7) result -= 36; - } - assert(208 <= result && result <= 29648); + // Balance of dark and light modules + let dark: int = 0; + for (const row of this.modules) dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; + } + + /*-- Private helper functions --*/ + + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + private getAlignmentPatternPositions(): Array { + if (this.version == 1) return []; + else { + const numAlign: int = Math.floor(this.version / 7) + 2; + const step: int = + this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; + let result: Array = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos); return result; } + } - // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any - // QR Code of the given version number and error correction level, with remainder bits discarded. - // This stateless pure function could be implemented as a (40*4)-cell lookup table. - private static getNumDataCodewords(ver: int, ecl: QrCode.Ecc): int { - return ( - Math.floor(QrCode.getNumRawDataModules(ver) / 8) - - QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * - QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver] - ); - } + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + private static getNumRawDataModules(ver: int): int { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result: int = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign: int = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } - // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be - // implemented as a lookup table over all possible parameter values, instead of as an algorithm. - private static reedSolomonComputeDivisor(degree: int): Array { - if (degree < 1 || degree > 255) throw new RangeError("Degree out of range"); - // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. - // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. - let result: Array = []; - for (let i = 0; i < degree - 1; i++) result.push(0); - result.push(1); // Start off with the monomial x^0 - - // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), - // and drop the highest monomial term which is always 1x^degree. - // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). - let root = 1; - for (let i = 0; i < degree; i++) { - // Multiply the current product by (x - r^i) - for (let j = 0; j < result.length; j++) { - result[j] = QrCode.reedSolomonMultiply(result[j], root); - if (j + 1 < result.length) result[j] ^= result[j + 1]; - } - root = QrCode.reedSolomonMultiply(root, 0x02); - } - return result; - } + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + private static getNumDataCodewords(ver: int, ecl: QrCodeEcc): int { + return ( + Math.floor(QrCode.getNumRawDataModules(ver) / 8) - + QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal]![ver]! * + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal]![ver]! + ); + } - // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. - private static reedSolomonComputeRemainder( - data: Readonly>, - divisor: Readonly>, - ): Array { - let result: Array = divisor.map((_) => 0); - for (const b of data) { - // Polynomial division - const factor: byte = b ^ (result.shift() as byte); - result.push(0); - divisor.forEach((coef, i) => (result[i] ^= QrCode.reedSolomonMultiply(coef, factor))); + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + private static reedSolomonComputeDivisor(degree: int): Array { + if (degree < 1 || degree > 255) throw new RangeError("Degree out of range"); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. + let result: Array = []; + for (let i = 0; i < degree - 1; i++) result.push(0); + result.push(1); // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // and drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + let root = 1; + for (let i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j]!, root); + if (j + 1 < result.length) result[j]! ^= result[j + 1]!; } - return result; + root = QrCode.reedSolomonMultiply(root, 0x02); } + return result; + } - // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result - // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. - private static reedSolomonMultiply(x: byte, y: byte): byte { - if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range"); - // Russian peasant multiplication - let z: int = 0; - for (let i = 7; i >= 0; i--) { - z = (z << 1) ^ ((z >>> 7) * 0x11d); - z ^= ((y >>> i) & 1) * x; - } - assert(z >>> 8 == 0); - return z as byte; - } + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + private static reedSolomonComputeRemainder( + data: Readonly>, + divisor: Readonly>, + ): Array { + let result: Array = divisor.map((_) => 0); + for (const b of data) { + // Polynomial division + const factor: byte = b ^ (result.shift() as byte); + result.push(0); + divisor.forEach((coef, i) => (result[i]! ^= QrCode.reedSolomonMultiply(coef, factor))); + } + return result; + } - // Can only be called immediately after a light run is added, and - // returns either 0, 1, or 2. A helper function for getPenaltyScore(). - private finderPenaltyCountPatterns(runHistory: Readonly>): int { - const n: int = runHistory[1]; - assert(n <= this.size * 3); - const core: boolean = - n > 0 && - runHistory[2] == n && - runHistory[3] == n * 3 && - runHistory[4] == n && - runHistory[5] == n; - return ( - (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + - (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0) - ); - } + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + private static reedSolomonMultiply(x: byte, y: byte): byte { + if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range"); + // Russian peasant multiplication + let z: int = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + assert(z >>> 8 == 0); + return z as byte; + } - // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). - private finderPenaltyTerminateAndCount( - currentRunColor: boolean, - currentRunLength: int, - runHistory: Array, - ): int { - if (currentRunColor) { - // Terminate dark run - this.finderPenaltyAddHistory(currentRunLength, runHistory); - currentRunLength = 0; - } - currentRunLength += this.size; // Add light border to final run + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + private finderPenaltyCountPatterns(runHistory: Readonly>): int { + const n: int = runHistory[1]!; + assert(n <= this.size * 3); + const core: boolean = + n > 0 && + runHistory[2] === n && + runHistory[3] === n * 3 && + runHistory[4] === n && + runHistory[5] === n; + return ( + (core && runHistory[0]! >= n * 4 && runHistory[6]! >= n ? 1 : 0) + + (core && runHistory[6]! >= n * 4 && runHistory[0]! >= n ? 1 : 0) + ); + } + + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + private finderPenaltyTerminateAndCount( + currentRunColor: boolean, + currentRunLength: int, + runHistory: Array, + ): int { + if (currentRunColor) { + // Terminate dark run this.finderPenaltyAddHistory(currentRunLength, runHistory); - return this.finderPenaltyCountPatterns(runHistory); + currentRunLength = 0; } + currentRunLength += this.size; // Add light border to final run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } - // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). - private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { - if (runHistory[0] == 0) currentRunLength += this.size; // Add light border to initial run - runHistory.pop(); - runHistory.unshift(currentRunLength); - } + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { + if (runHistory[0] === 0) currentRunLength += this.size; // Add light border to initial run + runHistory.pop(); + runHistory.unshift(currentRunLength); + } - /*-- Constants and tables --*/ - - // The minimum version number supported in the QR Code Model 2 standard. - public static readonly MIN_VERSION: int = 1; - // The maximum version number supported in the QR Code Model 2 standard. - public static readonly MAX_VERSION: int = 40; - - // For use in getPenaltyScore(), when evaluating which mask is best. - private static readonly PENALTY_N1: int = 3; - private static readonly PENALTY_N2: int = 3; - private static readonly PENALTY_N3: int = 40; - private static readonly PENALTY_N4: int = 10; - - private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level - [ - -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, - 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // Low - [ - -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, - 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, - ], // Medium - [ - -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, - 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // Quartile - [ - -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, - 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // High - ]; - - private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level - [ - -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, - 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, - ], // Low - [ - -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, - 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, - ], // Medium - [ - -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, - 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, - ], // Quartile - [ - -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, - 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, - ], // High - ]; - } - - // Appends the given number of low-order bits of the given value - // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. - function appendBits(val: int, len: int, bb: Array): void { - if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range"); - for ( - let i = len - 1; - i >= 0; - i-- // Append bit by bit - ) - bb.push((val >>> i) & 1); - } - - // Returns true iff the i'th bit of x is set to 1. - function getBit(x: int, i: int): boolean { - return ((x >>> i) & 1) != 0; - } - - // Throws an exception if the given condition is false. - function assert(cond: boolean): void { - if (!cond) throw new Error("Assertion error"); - } - - /*---- Data segment class ----*/ - - /* - * A segment of character/binary/control data in a QR Code symbol. - * Instances of this class are immutable. - * The mid-level way to create a segment is to take the payload data - * and call a static factory function such as QrSegment.makeNumeric(). - * The low-level way to create a segment is to custom-make the bit buffer - * and call the QrSegment() constructor with appropriate values. - * This segment class imposes no length restrictions, but QR Codes have restrictions. - * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. - * Any segment longer than this is meaningless for the purpose of generating QR Codes. - */ - export class QrSegment { - /*-- Static factory functions (mid level) --*/ - - // Returns a segment representing the given binary data encoded in - // byte mode. All input byte arrays are acceptable. Any text string - // can be converted to UTF-8 bytes and encoded as a byte mode segment. - public static makeBytes(data: Readonly>): QrSegment { - let bb: Array = []; - for (const b of data) appendBits(b, 8, bb); - return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); - } + /*-- Constants and tables --*/ + + // The minimum version number supported in the QR Code Model 2 standard. + public static readonly MIN_VERSION: int = 1; + // The maximum version number supported in the QR Code Model 2 standard. + public static readonly MAX_VERSION: int = 40; + + // For use in getPenaltyScore(), when evaluating which mask is best. + private static readonly PENALTY_N1: int = 3; + private static readonly PENALTY_N2: int = 3; + private static readonly PENALTY_N3: int = 40; + private static readonly PENALTY_N4: int = 10; + + private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, + 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Low + [ + -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, + 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, + ], // Medium + [ + -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, + 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // Quartile + [ + -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, + 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, + ], // High + ]; + + private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [ + -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, + 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, + ], // Low + [ + -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, + 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, + ], // Medium + [ + -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, + 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, + ], // Quartile + [ + -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, + 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, + ], // High + ]; +} - // Returns a segment representing the given string of decimal digits encoded in numeric mode. - public static makeNumeric(digits: string): QrSegment { - if (!QrSegment.isNumeric(digits)) - throw new RangeError("String contains non-numeric characters"); - let bb: Array = []; - for (let i = 0; i < digits.length; ) { - // Consume up to 3 digits per iteration - const n: int = Math.min(digits.length - i, 3); - appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); - i += n; - } - return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); - } +// Appends the given number of low-order bits of the given value +// to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. +function appendBits(val: int, len: int, bb: Array): void { + if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range"); + for ( + let i = len - 1; + i >= 0; + i-- // Append bit by bit + ) + bb.push((val >>> i) & 1); +} - // Returns a segment representing the given text string encoded in alphanumeric mode. - // The characters allowed are: 0 to 9, A to Z (uppercase only), space, - // dollar, percent, asterisk, plus, hyphen, period, slash, colon. - public static makeAlphanumeric(text: string): QrSegment { - if (!QrSegment.isAlphanumeric(text)) - throw new RangeError("String contains unencodable characters in alphanumeric mode"); - let bb: Array = []; - let i: int; - for (i = 0; i + 2 <= text.length; i += 2) { - // Process groups of 2 - let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; - temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); - appendBits(temp, 11, bb); - } - if (i < text.length) - // 1 character remaining - appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); - return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); - } +// Returns true iff the i'th bit of x is set to 1. +function getBit(x: int, i: int): boolean { + return ((x >>> i) & 1) != 0; +} - // Returns a new mutable list of zero or more segments to represent the given Unicode text string. - // The result may use various segment modes and switch modes to optimize the length of the bit stream. - public static makeSegments(text: string): Array { - // Select the most efficient segment encoding automatically - if (text == "") return []; - else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; - else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)]; - else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; - } +// Throws an exception if the given condition is false. +function assert(cond: boolean): void { + if (!cond) throw new Error("Assertion error"); +} - // Returns a segment representing an Extended Channel Interpretation - // (ECI) designator with the given assignment value. - public static makeEci(assignVal: int): QrSegment { - let bb: Array = []; - if (assignVal < 0) throw new RangeError("ECI assignment value out of range"); - else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb); - else if (assignVal < 1 << 14) { - appendBits(0b10, 2, bb); - appendBits(assignVal, 14, bb); - } else if (assignVal < 1000000) { - appendBits(0b110, 3, bb); - appendBits(assignVal, 21, bb); - } else throw new RangeError("ECI assignment value out of range"); - return new QrSegment(QrSegment.Mode.ECI, 0, bb); - } +/*---- Data segment class ----*/ - // Tests whether the given string can be encoded as a segment in numeric mode. - // A string is encodable iff each character is in the range 0 to 9. - public static isNumeric(text: string): boolean { - return QrSegment.NUMERIC_REGEX.test(text); - } +/* + * A segment of character/binary/control data in a QR Code symbol. + * Instances of this class are immutable. + * The mid-level way to create a segment is to take the payload data + * and call a static factory function such as QrSegment.makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and call the QrSegment() constructor with appropriate values. + * This segment class imposes no length restrictions, but QR Codes have restrictions. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + */ +export class QrSegment { + public static Mode: typeof QrSegmentMode; + + /*-- Static factory functions (mid level) --*/ + + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + public static makeBytes(data: Readonly>): QrSegment { + let bb: Array = []; + for (const b of data) appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } - // Tests whether the given string can be encoded as a segment in alphanumeric mode. - // A string is encodable iff each character is in the following set: 0 to 9, A to Z - // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. - public static isAlphanumeric(text: string): boolean { - return QrSegment.ALPHANUMERIC_REGEX.test(text); - } + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + public static makeNumeric(digits: string): QrSegment { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb: Array = []; + for (let i = 0; i < digits.length; ) { + // Consume up to 3 digits per iteration + const n: int = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } - /*-- Constructor (low level) and fields --*/ - - // Creates a new QR Code segment with the given attributes and data. - // The character count (numChars) must agree with the mode and the bit buffer length, - // but the constraint isn't checked. The given bit buffer is cloned and stored. - public constructor( - // The mode indicator of this segment. - public readonly mode: QrSegment.Mode, - - // The length of this segment's unencoded data. Measured in characters for - // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. - // Always zero or positive. Not the same as the data's bit length. - public readonly numChars: int, - - // The data bits of this segment. Accessed through getData(). - private readonly bitData: Array, - ) { - if (numChars < 0) throw new RangeError("Invalid argument"); - this.bitData = bitData.slice(); // Make defensive copy - } + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static makeAlphanumeric(text: string): QrSegment { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError("String contains unencodable characters in alphanumeric mode"); + let bb: Array = []; + let i: int; + for (i = 0; i + 2 <= text.length; i += 2) { + // Process groups of 2 + let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) + // 1 character remaining + appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } - /*-- Methods --*/ + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + public static makeSegments(text: string): Array { + // Select the most efficient segment encoding automatically + if (text == "") return []; + else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)]; + else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } - // Returns a new copy of the data bits of this segment. - public getData(): Array { - return this.bitData.slice(); // Make defensive copy - } + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + public static makeEci(assignVal: int): QrSegment { + let bb: Array = []; + if (assignVal < 0) throw new RangeError("ECI assignment value out of range"); + else if (assignVal < 1 << 7) appendBits(assignVal, 8, bb); + else if (assignVal < 1 << 14) { + appendBits(0b10, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1000000) { + appendBits(0b110, 3, bb); + appendBits(assignVal, 21, bb); + } else throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } - // (Package-private) Calculates and returns the number of bits needed to encode the given segments at - // the given version. The result is infinity if a segment has too many characters to fit its length field. - public static getTotalBits(segs: Readonly>, version: int): number { - let result: number = 0; - for (const seg of segs) { - const ccbits: int = seg.mode.numCharCountBits(version); - if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width - result += 4 + ccbits + seg.bitData.length; - } - return result; + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + public static isNumeric(text: string): boolean { + return QrSegment.NUMERIC_REGEX.test(text); + } + + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + public static isAlphanumeric(text: string): boolean { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + + /*-- Constructor (low level) and fields --*/ + + public readonly mode: QrSegmentMode; + public readonly numChars: int; + private readonly bitData: Array; + + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + public constructor( + // The mode indicator of this segment. + mode: QrSegmentMode, + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + numChars: int, + + // The data bits of this segment. Accessed through getData(). + bitData: Array, + ) { + this.mode = mode; + this.numChars = numChars; + if (numChars < 0) throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); // Make defensive copy + } + + /*-- Methods --*/ + + // Returns a new copy of the data bits of this segment. + public getData(): Array { + return this.bitData.slice(); // Make defensive copy + } + + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + public static getTotalBits(segs: Readonly>, version: int): number { + let result: number = 0; + for (const seg of segs) { + const ccbits: int = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width + result += 4 + ccbits + seg.bitData.length; } + return result; + } - // Returns a new array of bytes representing the given string encoded in UTF-8. - private static toUtf8ByteArray(str: string): Array { - str = encodeURI(str); - let result: Array = []; - for (let i = 0; i < str.length; i++) { - if (str.charAt(i) != "%") result.push(str.charCodeAt(i)); - else { - result.push(parseInt(str.substring(i + 1, i + 3), 16)); - i += 2; - } + // Returns a new array of bytes representing the given string encoded in UTF-8. + private static toUtf8ByteArray(str: string): Array { + str = encodeURI(str); + let result: Array = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) != "%") result.push(str.charCodeAt(i)); + else { + result.push(parseInt(str.substring(i + 1, i + 3), 16)); + i += 2; } - return result; } + return result; + } - /*-- Constants --*/ + /*-- Constants --*/ - // Describes precisely all strings that are encodable in numeric mode. - private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; + // Describes precisely all strings that are encodable in numeric mode. + private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; - // Describes precisely all strings that are encodable in alphanumeric mode. - private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; + // Describes precisely all strings that are encodable in alphanumeric mode. + private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; - // The set of all legal characters in alphanumeric mode, - // where each character value maps to the index in the string. - private static readonly ALPHANUMERIC_CHARSET: string = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; - } + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + private static readonly ALPHANUMERIC_CHARSET: string = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; } /*---- Public helper enumeration ----*/ -namespace qrcodegen.QrCode { - type int = number; +class QrCodeEcc { + /*-- Constants --*/ - /* - * The error correction level in a QR Code symbol. Immutable. - */ - export class Ecc { - /*-- Constants --*/ + public static readonly LOW = new QrCodeEcc(0, 1); // The QR Code can tolerate about 7% erroneous codewords + public static readonly MEDIUM = new QrCodeEcc(1, 0); // The QR Code can tolerate about 15% erroneous codewords + public static readonly QUARTILE = new QrCodeEcc(2, 3); // The QR Code can tolerate about 25% erroneous codewords + public static readonly HIGH = new QrCodeEcc(3, 2); // The QR Code can tolerate about 30% erroneous codewords - public static readonly LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords - public static readonly MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords - public static readonly QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords - public static readonly HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + public readonly ordinal: int; + public readonly formatBits: int; - /*-- Constructor and fields --*/ + /*-- Constructor and fields --*/ - private constructor( - // In the range 0 to 3 (unsigned 2-bit integer). - public readonly ordinal: int, - // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). - public readonly formatBits: int, - ) {} + private constructor( + // In the range 0 to 3 (unsigned 2-bit integer). + ordinal: int, + // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). + formatBits: int, + ) { + this.ordinal = ordinal; + this.formatBits = formatBits; } } /*---- Public helper enumeration ----*/ -namespace qrcodegen.QrSegment { - type int = number; +class QrSegmentMode { + /*-- Constants --*/ - /* - * Describes how a segment's data bits are interpreted. Immutable. - */ - export class Mode { - /*-- Constants --*/ + public static readonly NUMERIC = new QrSegmentMode(0x1, [10, 12, 14]); + public static readonly ALPHANUMERIC = new QrSegmentMode(0x2, [9, 11, 13]); + public static readonly BYTE = new QrSegmentMode(0x4, [8, 16, 16]); + public static readonly KANJI = new QrSegmentMode(0x8, [8, 10, 12]); + public static readonly ECI = new QrSegmentMode(0x7, [0, 0, 0]); - public static readonly NUMERIC = new Mode(0x1, [10, 12, 14]); - public static readonly ALPHANUMERIC = new Mode(0x2, [9, 11, 13]); - public static readonly BYTE = new Mode(0x4, [8, 16, 16]); - public static readonly KANJI = new Mode(0x8, [8, 10, 12]); - public static readonly ECI = new Mode(0x7, [0, 0, 0]); + public readonly modeBits: int; + private readonly numBitsCharCount: [int, int, int]; - /*-- Constructor and fields --*/ + /*-- Constructor and fields --*/ - private constructor( - // The mode indicator bits, which is a uint4 value (range 0 to 15). - public readonly modeBits: int, - // Number of character count bits for three different version ranges. - private readonly numBitsCharCount: [int, int, int], - ) {} + private constructor( + // The mode indicator bits, which is a uint4 value (range 0 to 15). + modeBits: int, + // Number of character count bits for three different version ranges. + numBitsCharCount: [int, int, int], + ) { + this.modeBits = modeBits; + this.numBitsCharCount = numBitsCharCount; + } - /*-- Method --*/ + /*-- Method --*/ - // (Package-private) Returns the bit width of the character count field for a segment in - // this mode in a QR Code at the given version number. The result is in the range [0, 16]. - public numCharCountBits(ver: int): int { - return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; - } + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + public numCharCountBits(ver: int): int { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]!; } } -export const QrCode = qrcodegen.QrCode; -export const QrSegment = qrcodegen.QrSegment; +QrCode.Ecc = QrCodeEcc; +QrSegment.Mode = QrSegmentMode; diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts index d8c4b3d6ca40..dc43770bdddc 100644 --- a/packages/shared/src/searchRanking.test.ts +++ b/packages/shared/src/searchRanking.test.ts @@ -6,7 +6,7 @@ import { normalizeSearchQuery, scoreQueryMatch, scoreSubsequenceMatch, -} from "./searchRanking"; +} from "./searchRanking.ts"; describe("normalizeSearchQuery", () => { it("trims and lowercases queries", () => { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 3d4a0da0bb10..89aa161c6253 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -5,7 +5,7 @@ import { extractPersistedServerObservabilitySettings, normalizePersistedServerSettingString, parsePersistedServerObservabilitySettings, -} from "./serverSettings"; +} from "./serverSettings.ts"; describe("serverSettings helpers", () => { it("normalizes optional persisted strings", () => { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index db9bdcc591e5..769485cebc00 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,7 +1,7 @@ import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; import { Schema } from "effect"; -import { deepMerge } from "./Struct"; -import { fromLenientJson } from "./schemaJson"; +import { deepMerge } from "./Struct.ts"; +import { fromLenientJson } from "./schemaJson.ts"; const ServerSettingsJson = fromLenientJson(ServerSettings); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 1c6494a53d33..1223ad3f0fb8 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -7,7 +7,7 @@ import { readEnvironmentFromLoginShell, readPathFromLaunchctl, readPathFromLoginShell, -} from "./shell"; +} from "./shell.ts"; describe("extractPathFromShellOutput", () => { it("extracts the path between capture markers", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 5df46e3a1db0..110b19a85c42 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1,9 +1,5 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; - import rootPackageJson from "../package.json" with { type: "json" }; import desktopPackageJson from "../apps/desktop/package.json" with { type: "json" }; import serverPackageJson from "../apps/server/package.json" with { type: "json" }; @@ -14,7 +10,18 @@ import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Config, Data, Effect, FileSystem, Layer, Logger, Option, Path, Schema } from "effect"; +import { + Config, + Data, + Effect, + FileSystem, + Layer, + Logger, + Option, + Path, + Schema, + Stream, +} from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -91,12 +98,49 @@ class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ readonly cause?: unknown; }> {} -function resolveGitCommitHash(repoRoot: string): string { - const result = spawnSync("git", ["rev-parse", "--short=12", "HEAD"], { - cwd: repoRoot, - encoding: "utf8", - }); - if (result.status !== 0) { +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const spawnAndCollectOutput = Effect.fn("spawnAndCollectOutput")(function* ( + command: ChildProcess.Command, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, exitCode } as const; +}); + +const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRoot: string) { + const result = yield* spawnAndCollectOutput( + ChildProcess.make("git", ["rev-parse", "--short=12", "HEAD"], { + cwd: repoRoot, + }), + ).pipe( + Effect.catch(() => + Effect.succeed({ + stdout: "", + stderr: "", + exitCode: 1, + }), + ), + ); + + if (result.exitCode !== 0) { return "unknown"; } const hash = result.stdout.trim(); @@ -104,11 +148,13 @@ function resolveGitCommitHash(repoRoot: string): string { return "unknown"; } return hash.toLowerCase(); -} +}); -function resolvePythonForNodeGyp(): string | undefined { +const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const configured = process.env.npm_config_python ?? process.env.PYTHON; - if (configured && existsSync(configured)) { + if (configured && (yield* fs.exists(configured))) { return configured; } @@ -116,28 +162,37 @@ function resolvePythonForNodeGyp(): string | undefined { const localAppData = process.env.LOCALAPPDATA; if (localAppData) { for (const version of ["Python313", "Python312", "Python311", "Python310"]) { - const candidate = join(localAppData, "Programs", "Python", version, "python.exe"); - if (existsSync(candidate)) { + const candidate = path.join(localAppData, "Programs", "Python", version, "python.exe"); + if (yield* fs.exists(candidate)) { return candidate; } } } } - const probe = spawnSync("python", ["-c", "import sys;print(sys.executable)"], { - encoding: "utf8", - }); - if (probe.status !== 0) { + const probe = yield* spawnAndCollectOutput( + ChildProcess.make("python", ["-c", "import sys;print(sys.executable)"]), + ).pipe( + Effect.catch(() => + Effect.succeed({ + stdout: "", + stderr: "", + exitCode: 1, + }), + ), + ); + + if (probe.exitCode !== 0) { return undefined; } const executable = probe.stdout.trim(); - if (!executable || !existsSync(executable)) { + if (!executable || !(yield* fs.exists(executable))) { return undefined; } return executable; -} +}); interface ResolvedBuildOptions { readonly platform: typeof BuildPlatform.Type; @@ -654,7 +709,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const appVersion = options.version ?? serverPackageJson.version; const iconAssets = resolveDesktopBuildIconAssets(appVersion); - const commitHash = resolveGitCommitHash(repoRoot); + const commitHash = yield* resolveGitCommitHash(repoRoot); const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped; const stageRoot = yield* mkdir({ prefix: `t3code-desktop-${options.platform}-stage-`, @@ -709,9 +764,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.platform, stageResourcesDir, { - macIconPng: join(repoRoot, iconAssets.macIconPng), - linuxIconPng: join(repoRoot, iconAssets.linuxIconPng), - windowsIconIco: join(repoRoot, iconAssets.windowsIconIco), + macIconPng: path.join(repoRoot, iconAssets.macIconPng), + linuxIconPng: path.join(repoRoot, iconAssets.linuxIconPng), + windowsIconIco: path.join(repoRoot, iconAssets.windowsIconIco), }, options.verbose, ); @@ -727,7 +782,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( private: true, description: "T3 Code desktop build", author: "T3 Tools", - main: "apps/desktop/dist-electron/main.js", + main: "apps/desktop/dist-electron/main.cjs", build: yield* createBuildConfig( options.platform, options.target, @@ -777,7 +832,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } if (process.platform === "win32") { - const python = resolvePythonForNodeGyp(); + const python = yield* resolvePythonForNodeGyp(); if (python) { buildEnv.PYTHON = python; buildEnv.npm_config_python = python; diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index b880f1bca45c..ce4865ecedee 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,8 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { homedir } from "node:os"; -import { resolve } from "node:path"; +import * as NodeOS from "node:os"; import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Path } from "effect"; import { checkPortAvailabilityOnHosts, @@ -49,6 +48,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { describe("createDevRunnerEnv", () => { it.effect("defaults T3CODE_HOME to ~/.t3 when not provided", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev", baseEnv: {}, @@ -63,12 +63,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.T3CODE_HOME, resolve(homedir(), ".t3")); + assert.equal(env.T3CODE_HOME, path.resolve(NodeOS.homedir(), ".t3")); }), ); it.effect("supports explicit typed overrides", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev:server", baseEnv: {}, @@ -83,7 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: new URL("http://localhost:7331"), }); - assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3")); + assert.equal(env.T3CODE_HOME, path.resolve("/tmp/custom-t3")); assert.equal(env.T3CODE_PORT, "4222"); assert.equal(env.VITE_HTTP_URL, "http://localhost:4222"); assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); @@ -142,6 +143,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { it.effect("uses custom t3Home when provided", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev", baseEnv: {}, @@ -156,12 +158,13 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); + assert.equal(env.T3CODE_HOME, path.resolve("/tmp/my-t3")); }), ); it.effect("pins desktop dev to a stable backend port and websocket url", () => Effect.gen(function* () { + const path = yield* Path.Path; const env = yield* createDevRunnerEnv({ mode: "dev:desktop", baseEnv: { @@ -182,7 +185,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); + assert.equal(env.T3CODE_HOME, path.resolve("/tmp/my-t3")); assert.equal(env.PORT, "5733"); assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733"); assert.equal(env.HOST, "127.0.0.1"); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 4d34fe389e93..1621b60da732 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { homedir } from "node:os"; +import * as NodeOS from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -17,7 +17,7 @@ const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => - path.join(homedir(), ".t3"), + path.join(NodeOS.homedir(), ".t3"), ); const MODE_ARGS = { @@ -523,11 +523,10 @@ const cliRuntimeLayer = Layer.mergeAll( NetService.layer, ); -const runtimeProgram = Command.run(devRunnerCli, { version: "0.0.0" }).pipe( - Effect.scoped, - Effect.provide(cliRuntimeLayer), -); - if (import.meta.main) { - NodeRuntime.runMain(runtimeProgram); + Command.run(devRunnerCli, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(cliRuntimeLayer), + NodeRuntime.runMain, + ); } diff --git a/scripts/merge-update-manifests.test.ts b/scripts/merge-update-manifests.test.ts index 33ebacfb059a..3f2e3b087134 100644 --- a/scripts/merge-update-manifests.test.ts +++ b/scripts/merge-update-manifests.test.ts @@ -1,11 +1,17 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { Command, CliError } from "effect/unstable/cli"; import { mergePlatformUpdateManifests, + mergeUpdateManifestsCommand, parsePlatformUpdateManifest, serializePlatformUpdateManifest, } from "./merge-update-manifests.ts"; +const runCli = Command.runWith(mergeUpdateManifestsCommand, { version: "0.0.0" }); + describe("merge-update-manifests", () => { it("merges arm64 and x64 macOS update manifests into one multi-arch manifest", () => { const arm64 = parsePlatformUpdateManifest( @@ -188,3 +194,113 @@ releaseDate: '2026-03-07T10:36:07.540Z' assert.equal(reparsed.version, "1.0"); }); }); + +it.layer(NodeServices.layer)("merge-update-manifests cli", (it) => { + const arm64MacManifest = `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.zip + sha512: arm64zip + size: 125621344 + - url: T3-Code-0.0.4-arm64.dmg + sha512: arm64dmg + size: 131754935 +path: T3-Code-0.0.4-arm64.zip +sha512: arm64zip +releaseDate: '2026-03-07T10:32:14.587Z' +`; + + const x64MacManifest = `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.zip + sha512: x64zip + size: 132000112 + - url: T3-Code-0.0.4-x64.dmg + sha512: x64dmg + size: 138148807 +path: T3-Code-0.0.4-x64.zip +sha512: x64zip +releaseDate: '2026-03-07T10:36:07.540Z' +`; + + it.effect("writes the merged manifest back to the primary path by default", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "merge-update-manifests-cli-", + }); + const primaryPath = path.join(baseDir, "latest-mac.yml"); + const secondaryPath = path.join(baseDir, "latest-mac-x64.yml"); + + yield* fs.writeFileString(primaryPath, arm64MacManifest); + yield* fs.writeFileString(secondaryPath, x64MacManifest); + + yield* runCli(["--platform", "mac", primaryPath, secondaryPath]); + + const merged = yield* fs.readFileString(primaryPath); + assert.ok(merged.includes("T3-Code-0.0.4-arm64.zip")); + assert.ok(merged.includes("T3-Code-0.0.4-x64.zip")); + assert.ok(!merged.includes("path:")); + }), + ); + + it.effect("writes the merged manifest to an explicit output path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "merge-update-manifests-cli-output-", + }); + const primaryPath = path.join(baseDir, "latest-win-arm64.yml"); + const secondaryPath = path.join(baseDir, "latest-win-x64.yml"); + const outputPath = path.join(baseDir, "latest-win.yml"); + + yield* fs.writeFileString( + primaryPath, + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-arm64.exe + sha512: arm64exe + size: 125621344 +releaseDate: '2026-03-07T10:32:14.587Z' +`, + ); + yield* fs.writeFileString( + secondaryPath, + `version: 0.0.4 +files: + - url: T3-Code-0.0.4-x64.exe + sha512: x64exe + size: 132000112 +releaseDate: '2026-03-07T10:36:07.540Z' +`, + ); + + yield* runCli(["--platform", "win", primaryPath, secondaryPath, outputPath]); + + const merged = yield* fs.readFileString(outputPath); + assert.ok(merged.includes("T3-Code-0.0.4-arm64.exe")); + assert.ok(merged.includes("T3-Code-0.0.4-x64.exe")); + }), + ); + + it.effect("rejects invalid platform values during cli parsing", () => + Effect.gen(function* () { + const error = yield* runCli(["--platform", "linux", "a.yml", "b.yml"]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + + const platformError = + error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error; + + if (!platformError || platformError._tag !== "InvalidValue") { + assert.fail(`Expected InvalidValue, got ${String(platformError?._tag)}`); + } + + assert.equal(platformError.option, "platform"); + assert.equal(platformError.value, "linux"); + }), + ); +}); diff --git a/scripts/merge-update-manifests.ts b/scripts/merge-update-manifests.ts index 1ff74d95e8e1..1913cd7113f8 100644 --- a/scripts/merge-update-manifests.ts +++ b/scripts/merge-update-manifests.ts @@ -1,6 +1,9 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; import { mergeUpdateManifests, @@ -9,7 +12,8 @@ import { type UpdateManifest, } from "./lib/update-manifest.ts"; -export type UpdateManifestPlatform = "mac" | "win"; +const UpdateManifestPlatform = Schema.Literals(["mac", "win"]); +export type UpdateManifestPlatform = typeof UpdateManifestPlatform.Type; function getPlatformLabel(platform: UpdateManifestPlatform): string { return platform === "mac" ? "macOS" : "Windows"; @@ -40,52 +44,65 @@ export function serializePlatformUpdateManifest( }); } -function parseArgs(args: ReadonlyArray): { - platform: UpdateManifestPlatform; - primaryPath: string; - secondaryPath: string; - outputPath: string; -} { - const [platformFlag, platformValue, primaryPathArg, secondaryPathArg, outputPathArg] = args; - if (platformFlag !== "--platform" || (platformValue !== "mac" && platformValue !== "win")) { - throw new Error( - "Usage: node scripts/merge-update-manifests.ts --platform [output-path]", - ); - } - if (!primaryPathArg || !secondaryPathArg) { - throw new Error( - "Usage: node scripts/merge-update-manifests.ts --platform [output-path]", - ); - } - - const primaryPath = resolve(primaryPathArg); - const secondaryPath = resolve(secondaryPathArg); - const outputPath = resolve(outputPathArg ?? primaryPathArg); +export const mergeUpdateManifestFiles = Effect.fn("mergeUpdateManifestFiles")(function* ( + platform: UpdateManifestPlatform, + primaryPathArg: string, + secondaryPathArg: string, + outputPathArg: string | undefined, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - return { - platform: platformValue, - primaryPath, - secondaryPath, - outputPath, - }; -} + const primaryPath = path.resolve(primaryPathArg); + const secondaryPath = path.resolve(secondaryPathArg); + const outputPath = path.resolve(outputPathArg ?? primaryPathArg); -function main(args: ReadonlyArray): void { - const { platform, primaryPath, secondaryPath, outputPath } = parseArgs(args); const primaryManifest = parsePlatformUpdateManifest( platform, - readFileSync(primaryPath, "utf8"), + yield* fs.readFileString(primaryPath), primaryPath, ); const secondaryManifest = parsePlatformUpdateManifest( platform, - readFileSync(secondaryPath, "utf8"), + yield* fs.readFileString(secondaryPath), secondaryPath, ); const merged = mergePlatformUpdateManifests(platform, primaryManifest, secondaryManifest); - writeFileSync(outputPath, serializePlatformUpdateManifest(platform, merged)); -} -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - main(process.argv.slice(2)); + yield* fs.writeFileString(outputPath, serializePlatformUpdateManifest(platform, merged)); +}); + +export const mergeUpdateManifestsCommand = Command.make( + "merge-update-manifests", + { + platform: Flag.choice("platform", UpdateManifestPlatform.literals).pipe( + Flag.withDescription("Update manifest platform."), + ), + primaryPath: Argument.string("primary-path").pipe( + Argument.withDescription("Primary update manifest path. Defaults to the output path."), + ), + secondaryPath: Argument.string("secondary-path").pipe( + Argument.withDescription( + "Secondary update manifest path to merge into the primary manifest.", + ), + ), + outputPath: Argument.string("output-path").pipe( + Argument.withDescription("Optional output path for the merged manifest."), + Argument.optional, + ), + }, + ({ platform, primaryPath, secondaryPath, outputPath }) => + mergeUpdateManifestFiles( + platform, + primaryPath, + secondaryPath, + Option.getOrUndefined(outputPath), + ), +).pipe(Command.withDescription("Merge two Electron updater manifests into a multi-arch manifest.")); + +if (import.meta.main) { + Command.run(mergeUpdateManifestsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); } diff --git a/scripts/mock-update-server.test.ts b/scripts/mock-update-server.test.ts new file mode 100644 index 000000000000..218dcd224f4d --- /dev/null +++ b/scripts/mock-update-server.test.ts @@ -0,0 +1,104 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { NodeHttpServer } from "@effect/platform-node"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { HttpClient, HttpRouter } from "effect/unstable/http"; + +import { makeMockUpdateRouteLayer } from "./mock-update-server.ts"; + +const withMockUpdateServer = (rootRealPath: string, effect: Effect.Effect) => + effect.pipe( + Effect.provide( + HttpRouter.serve(makeMockUpdateRouteLayer(rootRealPath), { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), + ), + ); + +it.layer(NodeServices.layer)("mock-update-server", (it) => { + it.effect("serves files from the configured root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-root-", + }); + const rootRealPath = yield* fileSystem.realPath(root); + const filePath = path.join(root, "latest.yml"); + + yield* fileSystem.writeFileString(filePath, "version: 0.0.1\n"); + + yield* withMockUpdateServer( + rootRealPath, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("/latest.yml"); + + assert.equal(response.status, 200); + assert.equal(response.headers["content-type"], "text/yaml"); + assert.equal(yield* response.text, "version: 0.0.1\n"); + }), + ); + }), + ); + + it.effect("rejects encoded path traversal outside the configured root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-outside-", + }); + const rootRealPath = yield* fileSystem.realPath(root); + + yield* fileSystem.writeFileString(path.join(outside, "secret.txt"), "nope\n"); + + yield* withMockUpdateServer( + rootRealPath, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("/%2e%2e/secret.txt"); + + assert.equal(response.status, 404); + assert.equal(yield* response.text, "Not Found"); + }), + ); + }), + ); + + it.effect("rejects symlinked files that escape the configured root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-root-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "mock-update-server-outside-", + }); + const rootRealPath = yield* fileSystem.realPath(root); + const outsideFile = path.join(outside, "outside.yml"); + const linksDir = path.join(root, "links"); + const symlinkPath = path.join(linksDir, "outside.yml"); + + yield* fileSystem.writeFileString(outsideFile, "version: outside\n"); + yield* fileSystem.makeDirectory(linksDir, { recursive: true }); + yield* fileSystem.symlink(outsideFile, symlinkPath); + + yield* withMockUpdateServer( + rootRealPath, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get("/links/outside.yml"); + + assert.equal(response.status, 404); + assert.equal(yield* response.text, "Not Found"); + }), + ); + }), + ); +}); diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts index 57dab49ffa37..8062f01b12fd 100644 --- a/scripts/mock-update-server.ts +++ b/scripts/mock-update-server.ts @@ -1,44 +1,154 @@ -import { resolve, relative } from "node:path"; -import { realpathSync } from "node:fs"; +import * as NodeHttp from "node:http"; -const port = Number(process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000); -const root = - process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT ?? - resolve(import.meta.dirname, "..", "release-mock"); +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Effect, FileSystem, Layer, Path } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -const mockServerLog = (level: "info" | "warn" | "error" = "info", message: string) => { - console[level](`[mock-update-server] ${message}`); -}; - -function isWithinRoot(filePath: string): boolean { - try { - return !relative(realpathSync(root), realpathSync(filePath)).startsWith("."); - } catch (error) { - mockServerLog("error", `Error checking if file is within root: ${error}`); - return false; - } +interface MockUpdateServerConfig { + readonly port: number; + readonly rootRealPath: string; } -Bun.serve({ - port, - hostname: "localhost", - fetch: async (request) => { - const url = new URL(request.url); - const path = url.pathname; - mockServerLog("info", `Request received for path: ${path}`); - const filePath = resolve(root, `.${path}`); - if (!isWithinRoot(filePath)) { - mockServerLog("warn", `Attempted to access file outside of root: ${filePath}`); - return new Response("Not Found", { status: 404 }); +const resolveMockUpdateServerConfig = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* Config.all({ + port: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.withDefault(3000)), + root: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT").pipe( + Config.withDefault("../release-mock"), + ), + }).asEffect(); + + const resolvedRoot = path.resolve(import.meta.dirname, config.root); + + return { + port: config.port, + rootRealPath: yield* fileSystem.realPath(resolvedRoot), + } satisfies MockUpdateServerConfig; +}); + +const isOutsideRoot = (rootRealPath: string, filePath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const relativePath = path.relative(rootRealPath, filePath); + return ( + relativePath === ".." || relativePath.startsWith("../") || relativePath.startsWith("..\\") + ); + }); + +const isWithinRoot = (rootRealPath: string, filePath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const resolvedFilePath = yield* fileSystem.realPath(filePath).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (resolvedPath) => resolvedPath, + }), + ); + + return ( + resolvedFilePath !== undefined && !(yield* isOutsideRoot(rootRealPath, resolvedFilePath)) + ); + }); + +const resolveRequestedFilePath = (rootRealPath: string, requestUrl: string | undefined) => + Effect.gen(function* () { + const path = yield* Path.Path; + const rawPath = (requestUrl ?? "/").split("?", 1)[0] ?? "/"; + const decodedPath = yield* Effect.try({ + try: () => decodeURIComponent(rawPath), + catch: () => null, + }).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (value) => value, + }), + ); + + if (!decodedPath) { + return undefined; } - const file = Bun.file(filePath); - if (!(await file.exists())) { - mockServerLog("warn", `Attempted to access non-existent file: ${filePath}`); - return new Response("Not Found", { status: 404 }); + + if (decodedPath.includes("\0")) { + return undefined; } - mockServerLog("info", `Serving file: ${filePath}`); - return new Response(file.stream()); - }, -}); -mockServerLog("info", `running on http://localhost:${port}`); + const filePath = path.resolve( + rootRealPath, + `.${decodedPath.startsWith("/") ? decodedPath : `/${decodedPath}`}`, + ); + + return (yield* isOutsideRoot(rootRealPath, filePath)) ? undefined : filePath; + }); + +const isServableFile = (rootRealPath: string, filePath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const stat = yield* fileSystem.stat(filePath).pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (info) => info, + }), + ); + + if (stat?.type !== "File") { + return false; + } + + return yield* isWithinRoot(rootRealPath, filePath); + }); + +export const makeMockUpdateRouteLayer = (rootRealPath: string) => { + return HttpRouter.add( + "*", + "*", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const requestPath = (request.url ?? "/").split("?", 1)[0] ?? "/"; + yield* Effect.logInfo(`Request received for path: ${requestPath}`); + + const filePath = yield* resolveRequestedFilePath(rootRealPath, request.url); + if (!filePath) { + yield* Effect.logWarning(`Attempted to access file outside of root: ${request.url ?? "/"}`); + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + if (!(yield* isServableFile(rootRealPath, filePath))) { + yield* Effect.logWarning(`Attempted to access invalid file: ${filePath}`); + return HttpServerResponse.text("Not Found", { status: 404 }); + } + + yield* Effect.logInfo(`Serving file: ${filePath}`); + return yield* HttpServerResponse.file(filePath, { status: 200 }); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError(`Unhandled mock update request failure: ${cause}`); + return HttpServerResponse.text("Internal Server Error", { status: 500 }); + }), + ), + ), + ); +}; + +const makeMockUpdateServerLayer = (config: MockUpdateServerConfig) => + HttpRouter.serve(makeMockUpdateRouteLayer(config.rootRealPath)).pipe( + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "localhost", + port: config.port, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + +if (import.meta.main) { + resolveMockUpdateServerConfig.pipe( + Effect.map(makeMockUpdateServerLayer), + Layer.unwrap, + Layer.launch, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 22fd4f2e6e82..93f932821ff6 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -196,8 +196,10 @@ const command = Command.make( ), ).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); -Command.run(command, { version: "0.0.0" }).pipe( - Effect.scoped, - Effect.provide(NodeServices.layer), - NodeRuntime.runMain, -); +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index e9ed7c8ae53f..3b189a7671a9 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -2,10 +2,8 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "composite": true, - "types": ["node", "bun"], - "lib": ["ES2023", "esnext.disposable"], - "noEmit": true, - "allowImportingTsExtensions": true, + "types": ["node"], + "lib": ["ESNext", "esnext.disposable"], "plugins": [ { "name": "@effect/language-service" diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts index 9e31c7675b39..df2b194ce344 100644 --- a/scripts/update-release-package-versions.test.ts +++ b/scripts/update-release-package-versions.test.ts @@ -1,71 +1,213 @@ -import { describe, expect, it } from "vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Path, Schema, SchemaGetter } from "effect"; +import { Command, CliError } from "effect/unstable/cli"; +import * as TestConsole from "effect/testing/TestConsole"; -import { parseArgs } from "./update-release-package-versions.ts"; +import { + releasePackageFiles, + updateReleasePackageVersions, + updateReleasePackageVersionsCommand, +} from "./update-release-package-versions.ts"; -describe("parseArgs", () => { - it("parses version only", () => { - expect(parseArgs(["1.2.3"])).toEqual({ - version: "1.2.3", - rootDir: undefined, - writeGithubOutput: false, - }); - }); +const ScriptTestLayer = Layer.mergeAll(NodeServices.layer, TestConsole.layer); +const runCli = Command.runWith(updateReleasePackageVersionsCommand, { version: "0.0.0" }); +const PackageJsonSchema = Schema.Record(Schema.String, Schema.Unknown); +const PrettyJsonString = SchemaGetter.parseJson().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); +const PackageJsonPrettyJson = Schema.fromJsonString(PackageJsonSchema).pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), +); +const decodePackageJson = Schema.decodeUnknownEffect(PackageJsonPrettyJson); +const encodePackageJson = Schema.encodeSync(PackageJsonPrettyJson); - it("parses version with --root", () => { - expect(parseArgs(["1.2.3", "--root", "/path"])).toEqual({ - version: "1.2.3", - rootDir: "/path", - writeGithubOutput: false, - }); - }); +const writePackageJsonFixtures = Effect.fn("writePackageJsonFixtures")(function* ( + rootDir: string, + version: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - it("parses version with --github-output", () => { - expect(parseArgs(["1.2.3", "--github-output"])).toEqual({ - version: "1.2.3", - rootDir: undefined, - writeGithubOutput: true, - }); - }); + for (const relativePath of releasePackageFiles) { + const filePath = path.join(rootDir, relativePath); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString( + filePath, + `${encodePackageJson({ + name: relativePath, + version, + private: true, + })}\n`, + ); + } +}); - it("parses version with --root and --github-output", () => { - expect(parseArgs(["1.2.3", "--root", "/path", "--github-output"])).toEqual({ - version: "1.2.3", - rootDir: "/path", - writeGithubOutput: true, - }); - }); +const readReleaseVersions = Effect.fn("readReleaseVersions")(function* (rootDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const versions = new Map(); - it("accepts flags before the version positional", () => { - expect(parseArgs(["--github-output", "--root", "/path", "1.2.3"])).toEqual({ - version: "1.2.3", - rootDir: "/path", - writeGithubOutput: true, - }); - }); + for (const relativePath of releasePackageFiles) { + const filePath = path.join(rootDir, relativePath); + const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson)); + versions.set(relativePath, String(packageJson.version)); + } - it("throws on missing version", () => { - expect(() => parseArgs([])).toThrow("Usage:"); - }); + return versions; +}); - it("throws on duplicate version", () => { - expect(() => parseArgs(["1.2.3", "2.0.0"])).toThrow( - "Only one release version can be provided.", +const captureLogs = (effect: Effect.Effect) => + Effect.gen(function* () { + const result = yield* effect; + const logs = (yield* TestConsole.logLines).filter( + (line): line is string => typeof line === "string", ); + return { result, logs }; }); - it("throws on unknown flag", () => { - expect(() => parseArgs(["1.2.3", "--unknown"])).toThrow("Unknown argument: --unknown"); - }); +it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { + it.effect("updates all release package versions under the provided root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-", + }); - it("throws on --root without value", () => { - expect(() => parseArgs(["1.2.3", "--root"])).toThrow("Missing value for --root."); - }); + yield* writePackageJsonFixtures(baseDir, "0.0.1"); - it("does not consume version as --github-output value", () => { - expect(parseArgs(["--github-output", "1.2.3"])).toEqual({ - version: "1.2.3", - rootDir: undefined, - writeGithubOutput: true, - }); - }); + const result = yield* updateReleasePackageVersions("1.2.3", { rootDir: baseDir }); + const versions = yield* readReleaseVersions(baseDir); + + assert.deepStrictEqual(result, { changed: true }); + assert.deepStrictEqual( + Array.from(versions.entries()), + releasePackageFiles.map((relativePath) => [relativePath, "1.2.3"]), + ); + }), + ); + + it.effect("returns changed=false when all versions already match", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-unchanged-", + }); + + yield* writePackageJsonFixtures(baseDir, "1.2.3"); + + const result = yield* updateReleasePackageVersions("1.2.3", { rootDir: baseDir }); + + assert.deepStrictEqual(result, { changed: false }); + }), + ); + + it.effect("accepts flags before the version positional and appends changed output", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-", + }); + const githubOutputPath = path.join(baseDir, "github-output.txt"); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + yield* runCli(["--github-output", "--root", baseDir, "2.0.0"]).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + GITHUB_OUTPUT: githubOutputPath, + }, + }), + ), + ), + ); + + const githubOutput = yield* fs.readFileString(githubOutputPath); + assert.equal(githubOutput, "changed=true\n"); + }), + ); + + it.effect("logs when nothing changed", () => + captureLogs( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-log-", + }); + + yield* writePackageJsonFixtures(baseDir, "3.0.0"); + yield* runCli(["3.0.0", "--root", baseDir]); + }), + ).pipe( + Effect.tap(({ logs }) => { + assert.deepStrictEqual(logs, ["All package.json versions already match release version."]); + return Effect.void; + }), + ), + ); + + it.effect("requires GITHUB_OUTPUT when --github-output is set", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "update-release-package-versions-cli-missing-output-", + }); + + yield* writePackageJsonFixtures(baseDir, "0.0.1"); + + const error = yield* runCli(["4.0.0", "--root", baseDir, "--github-output"]).pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + Effect.flip, + ); + + assert.equal( + error.message, + 'SchemaError(Expected string, got undefined\n at ["GITHUB_OUTPUT"])', + ); + }), + ); + + it.effect("rejects unknown flags during cli parsing", () => + Effect.gen(function* () { + const error = yield* runCli(["1.2.3", "--unknown"]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + + const optionError = + error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error; + + if (!optionError || optionError._tag !== "UnrecognizedOption") { + assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`); + } + + assert.equal(optionError.option, "--unknown"); + }), + ); + + it.effect("rejects a missing version positional during cli parsing", () => + Effect.gen(function* () { + const error = yield* runCli(["--github-output"]).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + + const versionError = + error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error; + + if (!versionError || versionError._tag !== "MissingArgument") { + assert.fail(`Expected MissingArgument, got ${String(versionError?._tag)}`); + } + + assert.equal(versionError.argument, "version"); + }), + ); }); diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index cefeef33ea21..d2baa85a1624 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -1,8 +1,9 @@ -import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +#!/usr/bin/env node -import { parseCliArgs } from "@t3tools/shared/cliArgs"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Console, Effect, FileSystem, Option, Path, Schema, SchemaGetter } from "effect"; +import { Argument, Command, Flag } from "effect/unstable/cli"; export const releasePackageFiles = [ "apps/server/package.json", @@ -12,88 +13,82 @@ export const releasePackageFiles = [ ] as const; interface UpdateReleasePackageVersionsOptions { - readonly rootDir?: string; + readonly rootDir?: string | undefined; } -interface MutablePackageJson { - version?: string; - [key: string]: unknown; -} - -export function updateReleasePackageVersions( +const PackageJsonSchema = Schema.Record(Schema.String, Schema.Unknown); +const PrettyJsonString = SchemaGetter.parseJson().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); +const PackageJsonPrettyJson = Schema.fromJsonString(PackageJsonSchema).pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), +); +const decodePackageJson = Schema.decodeUnknownEffect(PackageJsonPrettyJson); +const encodePackageJson = Schema.encodeSync(PackageJsonPrettyJson); + +export const updateReleasePackageVersions = Effect.fn("updateReleasePackageVersions")(function* ( version: string, options: UpdateReleasePackageVersionsOptions = {}, -): { changed: boolean } { - const rootDir = resolve(options.rootDir ?? process.cwd()); +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const rootDir = path.resolve(options.rootDir ?? process.cwd()); let changed = false; for (const relativePath of releasePackageFiles) { - const filePath = resolve(rootDir, relativePath); - const packageJson = JSON.parse(readFileSync(filePath, "utf8")) as MutablePackageJson; + const filePath = path.join(rootDir, relativePath); + const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson)); if (packageJson.version === version) { continue; } - packageJson.version = version; - writeFileSync(filePath, `${JSON.stringify(packageJson, null, 2)}\n`); + yield* fs.writeFileString(filePath, `${encodePackageJson({ ...packageJson, version })}\n`); changed = true; } return { changed }; -} - -export function parseArgs(argv: ReadonlyArray): { - version: string; - rootDir: string | undefined; - writeGithubOutput: boolean; -} { - const { flags, positionals } = parseCliArgs(argv, { booleanFlags: ["github-output"] }); - - const unknownFlags = Object.keys(flags).filter((k) => k !== "github-output" && k !== "root"); - if (unknownFlags.length > 0) { - throw new Error(`Unknown argument: --${unknownFlags[0]}`); - } - - if ("root" in flags && flags.root === null) { - throw new Error("Missing value for --root."); - } - - if (positionals.length > 1) { - throw new Error("Only one release version can be provided."); - } - - if (positionals.length !== 1 || !positionals[0]) { - throw new Error( - "Usage: node scripts/update-release-package-versions.ts [--root ] [--github-output]", - ); - } - - return { - version: positionals[0], - rootDir: flags.root ?? undefined, - writeGithubOutput: "github-output" in flags, - }; -} - -const isMain = - process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); - -if (isMain) { - const { version, rootDir, writeGithubOutput } = parseArgs(process.argv.slice(2)); - const { changed } = updateReleasePackageVersions( - version, - rootDir === undefined ? {} : { rootDir }, +}); + +const writeGithubOutput = Effect.fn("writeGithubOutput")(function* (changed: boolean) { + const fs = yield* FileSystem.FileSystem; + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); + yield* fs.writeFileString(githubOutputPath, `changed=${changed}\n`, { flag: "a" }); +}); + +export const updateReleasePackageVersionsCommand = Command.make( + "update-release-package-versions", + { + version: Argument.string("version").pipe( + Argument.withDescription("Release version to write into each releasable package.json."), + ), + root: Flag.string("root").pipe( + Flag.withDescription("Workspace root used to resolve the release package manifests."), + Flag.optional, + ), + githubOutput: Flag.boolean("github-output").pipe( + Flag.withDescription("Append changed= to GITHUB_OUTPUT."), + Flag.withDefault(false), + ), + }, + ({ version, root, githubOutput }) => + updateReleasePackageVersions(version, { + rootDir: Option.getOrUndefined(root), + }).pipe( + Effect.tap(({ changed }) => + changed + ? Effect.void + : Console.log("All package.json versions already match release version."), + ), + Effect.tap(({ changed }) => (githubOutput ? writeGithubOutput(changed) : Effect.void)), + ), +).pipe(Command.withDescription("Update release package versions across the workspace.")); + +if (import.meta.main) { + Command.run(updateReleasePackageVersionsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, ); - - if (!changed) { - console.log("All package.json versions already match release version."); - } - - if (writeGithubOutput) { - const githubOutputPath = process.env.GITHUB_OUTPUT; - if (!githubOutputPath) { - throw new Error("GITHUB_OUTPUT is required when --github-output is set."); - } - appendFileSync(githubOutputPath, `changed=${changed}\n`); - } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 538fa0f0eb3c..8d481cc7f818 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,8 +1,13 @@ { "compilerOptions": { - "target": "ES2023", - "module": "ESNext", - "moduleResolution": "Bundler", + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, From 54179c86b8b3ce54dc3abcfb3467ff330f03be2f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 22:16:13 -0700 Subject: [PATCH 16/36] Update workflow to use ubuntu-24.04 runner (#2110) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1545059f460d..ebfc26d8ba2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -299,7 +299,7 @@ jobs: publish_cli: name: Publish CLI to npm needs: [preflight, build] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout From d8d329691a3db759d6054af35e373005e9efcabd Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Fri, 17 Apr 2026 17:21:57 +1200 Subject: [PATCH 17/36] Show thread status in command palette (#2107) --- .../src/components/CommandPalette.logic.ts | 33 ++- apps/web/src/components/CommandPalette.tsx | 3 + .../src/components/CommandPaletteResults.tsx | 10 +- apps/web/src/components/Sidebar.tsx | 114 +-------- .../src/components/ThreadStatusIndicators.tsx | 241 ++++++++++++++++++ 5 files changed, 281 insertions(+), 120 deletions(-) create mode 100644 apps/web/src/components/ThreadStatusIndicators.tsx diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 3e2f1ec890cb..866db58fb47b 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -17,6 +17,10 @@ export interface CommandPaletteItem { readonly description?: string; readonly timestamp?: string; readonly icon: ReactNode; + /** Optional content rendered inline before the title text. */ + readonly titleLeadingContent?: ReactNode; + /** Optional content rendered inline after the title text (before the timestamp). */ + readonly titleTrailingContent?: ReactNode; readonly shortcutCommand?: KeybindingCommand; } @@ -102,20 +106,24 @@ export function buildProjectActionItems(input: { })); } -export function buildThreadActionItems(input: { - threads: ReadonlyArray< - Pick< - SidebarThreadSummary, - "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" - > & { - updatedAt?: string | undefined; - latestUserMessageAt?: string | null; - } - >; +export type BuildThreadActionItemsThread = Pick< + SidebarThreadSummary, + "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" +> & { + updatedAt?: string | undefined; + latestUserMessageAt?: string | null; +}; + +export function buildThreadActionItems(input: { + threads: ReadonlyArray; activeThreadId?: Thread["id"]; projectTitleById: ReadonlyMap; sortOrder: SidebarThreadSortOrder; icon: ReactNode; + /** Optional content rendered inline before the title text per-thread. */ + renderLeadingContent?: (thread: TThread) => ReactNode; + /** Optional content rendered inline after the title text per-thread. */ + renderTrailingContent?: (thread: TThread) => ReactNode; runThread: (thread: Pick) => Promise; limit?: number; }): CommandPaletteActionItem[] { @@ -140,6 +148,9 @@ export function buildThreadActionItems(input: { descriptionParts.push("Current thread"); } + const leadingContent = input.renderLeadingContent?.(thread); + const trailingContent = input.renderTrailingContent?.(thread); + return { kind: "action", value: `thread:${thread.id}`, @@ -150,6 +161,8 @@ export function buildThreadActionItems(input: { thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ), icon: input.icon, + ...(leadingContent ? { titleLeadingContent: leadingContent } : {}), + ...(trailingContent ? { titleTrailingContent: trailingContent } : {}), run: async () => { await input.runThread(thread); }, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fbbeda10139f..929a9f87e9c9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -89,6 +89,7 @@ import { import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { useServerKeybindings } from "../rpc/serverState"; import { resolveShortcutCommand } from "../keybindings"; import { @@ -504,6 +505,8 @@ function OpenCommandPaletteDialog() { projectTitleById, sortOrder: settings.sidebarThreadSortOrder, icon: , + renderLeadingContent: (thread) => , + renderTrailingContent: (thread) => , runThread: async (thread) => { await navigate({ to: "/$environmentId/$threadId", diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index e2841d588056..8cdf0694a082 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -86,14 +86,20 @@ function CommandPaletteResultRow(props: { {props.item.icon} {props.item.description ? ( - {props.item.title} + + {props.item.titleLeadingContent} + {props.item.title} + {props.item.titleTrailingContent} + {props.item.description} ) : ( - + + {props.item.titleLeadingContent} {props.item.title} + {props.item.titleTrailingContent} )} {props.item.timestamp ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c3fae158b149..9939833a951b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,6 +11,12 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; +import { + prStatusIndicator, + resolveThreadPr, + terminalStatusFromRunningIds, + ThreadStatusLabel, +} from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -38,7 +44,6 @@ import { type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, - type GitStatusResult, } from "@t3tools/contracts"; import { parseScopedThreadKey, @@ -264,113 +269,6 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } -interface TerminalStatusIndicator { - label: "Terminal process running"; - colorClass: string; - pulse: boolean; -} - -interface PrStatusIndicator { - label: "PR open" | "PR closed" | "PR merged"; - colorClass: string; - tooltip: string; - url: string; -} - -type ThreadPr = GitStatusResult["pr"]; - -function ThreadStatusLabel({ - status, - compact = false, -}: { - status: ThreadStatusPill; - compact?: boolean; -}) { - if (compact) { - return ( - - - {status.label} - - ); - } - - return ( - - - {status.label} - - ); -} - -function terminalStatusFromRunningIds( - runningTerminalIds: string[], -): TerminalStatusIndicator | null { - if (runningTerminalIds.length === 0) { - return null; - } - return { - label: "Terminal process running", - colorClass: "text-teal-600 dark:text-teal-300/90", - pulse: true, - }; -} - -function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { - if (!pr) return null; - - if (pr.state === "open") { - return { - label: "PR open", - colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} PR open: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "closed") { - return { - label: "PR closed", - colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} PR closed: ${pr.title}`, - url: pr.url, - }; - } - if (pr.state === "merged") { - return { - label: "PR merged", - colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} PR merged: ${pr.title}`, - url: pr.url, - }; - } - return null; -} - -function resolveThreadPr( - threadBranch: string | null, - gitStatus: GitStatusResult | null, -): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { - return null; - } - - return gitStatus.pr ?? null; -} - interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx new file mode 100644 index 000000000000..497e0f883398 --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -0,0 +1,241 @@ +import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import type { GitStatusResult } from "@t3tools/contracts"; +import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { useMemo } from "react"; +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; +import { useGitStatus } from "../lib/gitStatusState"; +import { type AppState, selectProjectByRef, useStore } from "../store"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useUiStateStore } from "../uiStateStore"; +import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; +import type { SidebarThreadSummary } from "../types"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export interface PrStatusIndicator { + label: "PR open" | "PR closed" | "PR merged"; + colorClass: string; + tooltip: string; + url: string; +} + +export interface TerminalStatusIndicator { + label: "Terminal process running"; + colorClass: string; + pulse: boolean; +} + +export type ThreadPr = GitStatusResult["pr"]; + +export function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { + if (!pr) return null; + + if (pr.state === "open") { + return { + label: "PR open", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + tooltip: `#${pr.number} PR open: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "closed") { + return { + label: "PR closed", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltip: `#${pr.number} PR closed: ${pr.title}`, + url: pr.url, + }; + } + if (pr.state === "merged") { + return { + label: "PR merged", + colorClass: "text-violet-600 dark:text-violet-300/90", + tooltip: `#${pr.number} PR merged: ${pr.title}`, + url: pr.url, + }; + } + return null; +} + +export function resolveThreadPr( + threadBranch: string | null, + gitStatus: GitStatusResult | null, +): ThreadPr | null { + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } + + return gitStatus.pr ?? null; +} + +export function terminalStatusFromRunningIds( + runningTerminalIds: string[], +): TerminalStatusIndicator | null { + if (runningTerminalIds.length === 0) { + return null; + } + return { + label: "Terminal process running", + colorClass: "text-teal-600 dark:text-teal-300/90", + pulse: true, + }; +} + +export function ThreadStatusLabel({ + status, + compact = false, +}: { + status: ThreadStatusPill; + compact?: boolean; +}) { + if (compact) { + return ( + + + {status.label} + + ); + } + + return ( + + + {status.label} + + ); +} + +/** + * Non-interactive leading status icons for a thread row in compact contexts + * like the command palette. Shows the PR state icon (if present) and the + * thread status dot, matching the sidebar's leading indicators. + */ +export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const lastVisitedAt = useUiStateStore( + (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], + ); + const threadProjectCwd = useStore( + useMemo( + () => (state: AppState) => + selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? + null, + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProjectCwd; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr); + const threadStatus = resolveThreadStatusPill({ + thread: { + ...thread, + lastVisitedAt, + }, + }); + + if (!prStatus && !threadStatus) { + return null; + } + + return ( + + {prStatus ? ( + + + } + > + + + {prStatus.tooltip} + + ) : null} + {threadStatus ? : null} + + ); +} + +/** + * Non-interactive trailing status icons for a thread row in compact contexts + * like the command palette. Shows a terminal-running indicator and a remote + * environment indicator, matching the sidebar's trailing indicators. + */ +export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const runningTerminalIds = useTerminalStateStore( + (state) => + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, + ); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = useSavedEnvironmentRuntimeStore( + (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null, + ); + const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( + (state) => state.byId[thread.environmentId]?.label ?? null, + ); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") + : null; + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + + if (!terminalStatus && !isRemoteThread) { + return null; + } + + return ( + + {terminalStatus ? ( + + + + ) : null} + {isRemoteThread ? ( + + + } + > + + + {threadEnvironmentLabel} + + ) : null} + + ); +} From a7a44d0662fb026009e8a5b5d58a5012e2e8665d Mon Sep 17 00:00:00 2001 From: Evan Yu Date: Fri, 17 Apr 2026 02:12:17 -0400 Subject: [PATCH 18/36] Fix Windows PATH hydration and repair (#1729) Co-authored-by: Julius Marminge --- apps/desktop/src/syncShellEnvironment.test.ts | 122 ++++++- apps/desktop/src/syncShellEnvironment.ts | 34 +- apps/server/src/open.ts | 114 +----- apps/server/src/os-jank.test.ts | 117 ++++++- apps/server/src/os-jank.ts | 32 +- .../src/terminal/Layers/Manager.test.ts | 71 +++- apps/server/src/terminal/Layers/Manager.ts | 97 ++++-- apps/server/src/terminal/Layers/NodePTY.ts | 28 +- packages/shared/src/shell.test.ts | 268 +++++++++++++++ packages/shared/src/shell.ts | 324 +++++++++++++++++- scripts/build-desktop-artifact.ts | 7 +- 11 files changed, 1049 insertions(+), 165 deletions(-) diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index abaeeb2b2a52..1c13f77256c4 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -148,7 +148,7 @@ describe("syncShellEnvironment", () => { expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); - it("does nothing outside macOS and linux", () => { + it("does nothing on unsupported platforms", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", PATH: "C:\\Windows\\System32", @@ -160,7 +160,7 @@ describe("syncShellEnvironment", () => { })); syncShellEnvironment(env, { - platform: "win32", + platform: "freebsd", readEnvironment, }); @@ -168,4 +168,122 @@ describe("syncShellEnvironment", () => { expect(env.PATH).toBe("C:\\Windows\\System32"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); + + it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn(() => ({ + PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", + })); + const isWindowsCommandAvailable = vi.fn(() => true); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1); + }); + + it("loads the PowerShell profile on Windows when node is not available", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, + ); + const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + expect(env.FNM_MULTISHELL_PATH).toBe( + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); + expect(readWindowsEnvironment).toHaveBeenNthCalledWith( + 2, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], + { loadProfile: true }, + ); + }); + + it("preserves baseline Windows env when the profile probe fails", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { + if (options?.loadProfile) { + throw new Error("profile load failed"); + } + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }, + ); + const isWindowsCommandAvailable = vi.fn(() => false); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + expect(env.SSH_AUTH_SOCK).toBeUndefined(); + }); }); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 11a9e6930cab..373187bda6d5 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -3,8 +3,18 @@ import { mergePathEntries, readPathFromLaunchctl, readEnvironmentFromLoginShell, + resolveWindowsEnvironment, } from "@t3tools/shared/shell"; -import type { ShellEnvironmentReader } from "@t3tools/shared/shell"; +import type { + CommandAvailabilityOptions, + ShellEnvironmentReader, + WindowsShellEnvironmentReader, +} from "@t3tools/shared/shell"; + +type WindowsCommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; const LOGIN_SHELL_ENV_NAMES = [ "PATH", @@ -25,19 +35,39 @@ export function syncShellEnvironment( options: { platform?: NodeJS.Platform; readEnvironment?: ShellEnvironmentReader; + readWindowsEnvironment?: WindowsShellEnvironmentReader; + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; readLaunchctlPath?: typeof readPathFromLaunchctl; userShell?: string; logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; const logWarning = options.logWarning ?? logShellEnvironmentWarning; const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; const shellEnvironment: Partial> = {}; try { + if (platform === "win32") { + const repairedEnvironment = resolveWindowsEnvironment(env, { + ...(options.readWindowsEnvironment + ? { readEnvironment: options.readWindowsEnvironment } + : {}), + ...(options.isWindowsCommandAvailable + ? { commandAvailable: options.isWindowsCommandAvailable } + : {}), + }); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } + } + return; + } + + if (platform !== "darwin" && platform !== "linux") return; + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { try { Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 698cc0080ba2..98dfcaf4caad 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -7,10 +7,9 @@ * @module Open */ import { spawn } from "node:child_process"; -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; +import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; import { Context, Effect, Layer } from "effect"; // ============================== @@ -18,6 +17,7 @@ import { Context, Effect, Layer } from "effect"; // ============================== export { OpenError }; +export { isCommandAvailable } from "@t3tools/shared/shell"; export interface OpenInEditorInput { readonly cwd: string; @@ -29,11 +29,6 @@ interface EditorLaunch { readonly args: ReadonlyArray; } -interface CommandAvailabilityOptions { - readonly platform?: NodeJS.Platform; - readonly env?: NodeJS.ProcessEnv; -} - const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; function parseTargetPathAndPosition(target: string): { @@ -106,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } -function stripWrappingQuotes(value: string): string { - return value.replace(/^"+|"+$/g, ""); -} - -function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { - return env.PATH ?? env.Path ?? env.path ?? ""; -} - -function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { - const rawValue = env.PATHEXT; - const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; - if (!rawValue) return fallback; - - const parsed = rawValue - .split(";") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); - return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; -} - -function resolveCommandCandidates( - command: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): ReadonlyArray { - if (platform !== "win32") return [command]; - const extension = extname(command); - const normalizedExtension = extension.toUpperCase(); - - if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { - const commandWithoutExtension = command.slice(0, -extension.length); - return Array.from( - new Set([ - command, - `${commandWithoutExtension}${normalizedExtension}`, - `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, - ]), - ); - } - - const candidates: string[] = []; - for (const extension of windowsPathExtensions) { - candidates.push(`${command}${extension}`); - candidates.push(`${command}${extension.toLowerCase()}`); - } - return Array.from(new Set(candidates)); -} - -function isExecutableFile( - filePath: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): boolean { - try { - const stat = statSync(filePath); - if (!stat.isFile()) return false; - if (platform === "win32") { - const extension = extname(filePath); - if (extension.length === 0) return false; - return windowsPathExtensions.includes(extension.toUpperCase()); - } - accessSync(filePath, constants.X_OK); - return true; - } catch { - return false; - } -} - -function resolvePathDelimiter(platform: NodeJS.Platform): string { - return platform === "win32" ? ";" : ":"; -} - -export function isCommandAvailable( - command: string, - options: CommandAvailabilityOptions = {}, -): boolean { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); - - if (command.includes("/") || command.includes("\\")) { - return commandCandidates.some((candidate) => - isExecutableFile(candidate, platform, windowsPathExtensions), - ); - } - - const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return false; - const pathEntries = pathValue - .split(resolvePathDelimiter(platform)) - .map((entry) => stripWrappingQuotes(entry.trim())) - .filter((entry) => entry.length > 0); - - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { - return true; - } - } - } - return false; -} - export function resolveAvailableEditors( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index 9006644bdf71..c49a4120a546 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -53,7 +53,120 @@ describe("fixPath", () => { expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); - it("does nothing outside macOS and linux even when SHELL is set", () => { + it("repairs PATH on Windows by merging PowerShell PATH with inherited PATH", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn(() => ({ + PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", + })); + const isWindowsCommandAvailable = vi.fn(() => true); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + }); + + it("applies profile-derived fnm variables on Windows when node is missing", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, + ); + const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + expect(env.FNM_MULTISHELL_PATH).toBe( + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + }); + + it("preserves baseline PATH on Windows when the profile probe fails", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { + if (options?.loadProfile) { + throw new Error("profile load failed"); + } + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }, + ); + const isWindowsCommandAvailable = vi.fn(() => false); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + }); + + it("does nothing on unsupported platforms", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", PATH: "C:\\Windows\\System32", @@ -62,7 +175,7 @@ describe("fixPath", () => { fixPath({ env, - platform: "win32", + platform: "freebsd", readPath, }); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 33b67128095d..47574c14c128 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,12 +1,21 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; import { + readPathFromLoginShell, + readEnvironmentFromWindowsShell, + resolveWindowsEnvironment, + type CommandAvailabilityOptions, + type WindowsShellEnvironmentReader, listLoginShellCandidates, mergePathEntries, readPathFromLaunchctl, - readPathFromLoginShell, } from "@t3tools/shared/shell"; +type WindowsCommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + function logPathHydrationWarning(message: string, error?: unknown): void { console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? "")); } @@ -16,19 +25,36 @@ export function fixPath( env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; readPath?: typeof readPathFromLoginShell; + readWindowsEnvironment?: WindowsShellEnvironmentReader; + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; readLaunchctlPath?: typeof readPathFromLaunchctl; userShell?: string; logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; - const env = options.env ?? process.env; const logWarning = options.logWarning ?? logPathHydrationWarning; const readPath = options.readPath ?? readPathFromLoginShell; try { + if (platform === "win32") { + const repairedEnvironment = resolveWindowsEnvironment(env, { + readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, + ...(options.isWindowsCommandAvailable + ? { commandAvailable: options.isWindowsCommandAvailable } + : {}), + }); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } + } + return; + } + + if (platform !== "darwin" && platform !== "linux") return; + let shellPath: string | undefined; for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { try { diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index cce0a162729d..9d41c3de20fa 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -194,6 +194,8 @@ function multiTerminalHistoryLogPath( interface CreateManagerOptions { shellResolver?: () => string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; subprocessChecker?: (terminalPid: number) => Effect.Effect; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -228,6 +230,8 @@ const createManager = ( historyLineLimit, ptyAdapter, ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), + ...(options.platform !== undefined ? { platform: options.platform } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), ...(options.subprocessChecker !== undefined ? { subprocessChecker: options.subprocessChecker } : {}), @@ -297,6 +301,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("preserves non-notFound cwd stat failures", () => Effect.gen(function* () { + if (process.platform === "win32") return; + const { manager, baseDir } = yield* createManager(); const blockedRoot = path.join(baseDir, "blocked-root"); const blockedCwd = path.join(blockedRoot, "cwd"); @@ -827,8 +833,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("retries with fallback shells when preferred shell spawn fails", () => Effect.gen(function* () { + const missingShell = + process.platform === "win32" + ? "C:\\definitely\\missing-shell.exe" + : "/definitely/missing-shell -l"; const { manager, ptyAdapter } = yield* createManager(5, { - shellResolver: () => "/definitely/missing-shell -l", + shellResolver: () => missingShell, }); ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed.")); @@ -836,12 +846,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( assert.equal(snapshot.status, "running"); expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); - expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); + expect(ptyAdapter.spawnInputs[0]?.shell).toBe( + process.platform === "win32" ? missingShell : "/definitely/missing-shell", + ); if (process.platform === "win32") { expect( ptyAdapter.spawnInputs.some( - (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", + (input) => + input.shell === "pwsh.exe" || + input.shell === "powershell.exe" || + input.shell === "cmd.exe", ), ).toBe(true); } else { @@ -854,6 +869,56 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("prefers PowerShell over ComSpec for Windows terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + }); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs[0]).toEqual( + expect.objectContaining({ + shell: "pwsh.exe", + args: ["-NoLogo"], + }), + ); + }), + ); + + it.effect("falls back to built-in PowerShell by absolute path on Windows", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + platform: "win32", + env: { + ComSpec: "C:\\Windows\\System32\\cmd.exe", + PATH: "C:\\Windows\\System32", + SystemRoot: "C:\\Windows", + }, + shellResolver: () => "C:\\missing\\custom-shell.exe", + }); + ptyAdapter.spawnFailures.push( + new Error("spawn custom-shell.exe ENOENT"), + new Error("spawn pwsh.exe ENOENT"), + ); + + yield* manager.open(openInput()); + + expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([ + "C:\\missing\\custom-shell.exe", + "pwsh.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ]); + expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]); + expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]); + }), + ); + it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { const originalValues = new Map(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 409e4397cc10..5e14db8e5c11 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -186,19 +186,25 @@ function enqueueProcessEvent( return true; } -function defaultShellResolver(): string { - if (process.platform === "win32") { - return process.env.ComSpec ?? "cmd.exe"; +function defaultShellResolver( + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): string { + if (platform === "win32") { + return "pwsh.exe"; } - return process.env.SHELL ?? "bash"; + return env.SHELL ?? "bash"; } -function normalizeShellCommand(value: string | undefined): string | null { +function normalizeShellCommand( + value: string | undefined, + platform: NodeJS.Platform = process.platform, +): string | null { if (!value) return null; const trimmed = value.trim(); if (trimmed.length === 0) return null; - if (process.platform === "win32") { + if (platform === "win32") { return trimmed; } @@ -207,15 +213,42 @@ function normalizeShellCommand(value: string | undefined): string | null { return firstToken.replace(/^['"]|['"]$/g, ""); } -function shellCandidateFromCommand(command: string | null): ShellCandidate | null { +function shellCandidateFromCommand( + command: string | null, + platform: NodeJS.Platform = process.platform, +): ShellCandidate | null { if (!command || command.length === 0) return null; - const shellName = path.basename(command).toLowerCase(); - if (process.platform !== "win32" && shellName === "zsh") { + const shellName = + platform === "win32" + ? path.win32.basename(command).toLowerCase() + : path.basename(command).toLowerCase(); + if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { + return { shell: command, args: ["-NoLogo"] }; + } + if (platform !== "win32" && shellName === "zsh") { return { shell: command, args: ["-o", "nopromptsp"] }; } return { shell: command }; } +function windowsSystemRoot(env: NodeJS.ProcessEnv): string { + return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; +} + +function windowsPowerShellPath(env: NodeJS.ProcessEnv): string { + return path.win32.join( + windowsSystemRoot(env), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); +} + +function windowsCmdPath(env: NodeJS.ProcessEnv): string { + return path.win32.join(windowsSystemRoot(env), "System32", "cmd.exe"); +} + function formatShellCandidate(candidate: ShellCandidate): string { if (!candidate.args || candidate.args.length === 0) return candidate.shell; return `${candidate.shell} ${candidate.args.join(" ")}`; @@ -234,27 +267,37 @@ function uniqueShellCandidates(candidates: Array): ShellC return ordered; } -function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { - const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); +function resolveShellCandidates( + shellResolver: () => string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): ShellCandidate[] { + const requested = shellCandidateFromCommand( + normalizeShellCommand(shellResolver(), platform), + platform, + ); - if (process.platform === "win32") { + if (platform === "win32") { return uniqueShellCandidates([ requested, - shellCandidateFromCommand(process.env.ComSpec ?? null), - shellCandidateFromCommand("powershell.exe"), - shellCandidateFromCommand("cmd.exe"), + shellCandidateFromCommand("pwsh.exe", platform), + shellCandidateFromCommand(windowsPowerShellPath(env), platform), + shellCandidateFromCommand("powershell.exe", platform), + shellCandidateFromCommand(env.ComSpec ?? null, platform), + shellCandidateFromCommand(windowsCmdPath(env), platform), + shellCandidateFromCommand("cmd.exe", platform), ]); } return uniqueShellCandidates([ requested, - shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), - shellCandidateFromCommand("/bin/zsh"), - shellCandidateFromCommand("/bin/bash"), - shellCandidateFromCommand("/bin/sh"), - shellCandidateFromCommand("zsh"), - shellCandidateFromCommand("bash"), - shellCandidateFromCommand("sh"), + shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform), + shellCandidateFromCommand("/bin/zsh", platform), + shellCandidateFromCommand("/bin/bash", platform), + shellCandidateFromCommand("/bin/sh", platform), + shellCandidateFromCommand("zsh", platform), + shellCandidateFromCommand("bash", platform), + shellCandidateFromCommand("sh", platform), ]); } @@ -651,6 +694,8 @@ interface TerminalManagerOptions { historyLineLimit?: number; ptyAdapter: PtyAdapterShape; shellResolver?: () => string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; subprocessChecker?: TerminalSubprocessChecker; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -674,7 +719,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - const shellResolver = options.shellResolver ?? defaultShellResolver; + const platform = options.platform ?? process.platform; + const baseEnv = options.env ?? process.env; + const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; @@ -1337,8 +1384,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith increment(terminalSessionsTotal, { lifecycle: eventType }).pipe( Effect.andThen( Effect.gen(function* () { - const shellCandidates = resolveShellCandidates(shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); + const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); + const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index 67aecb9fa6a2..1c75a4a958b8 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -2,7 +2,12 @@ import { createRequire } from "node:module"; import { Effect, FileSystem, Layer, Path } from "effect"; import { PtyAdapter } from "../Services/PTY.ts"; -import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; +import { + PtySpawnError, + type PtyAdapterShape, + type PtyExitEvent, + type PtyProcess, +} from "../Services/PTY.ts"; let didEnsureSpawnHelperExecutable = false; @@ -108,12 +113,21 @@ export const layer = Layer.effect( return { spawn: Effect.fn(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; - const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + const ptyProcess = yield* Effect.try({ + try: () => + nodePty.spawn(input.shell, input.args ?? [], { + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + env: input.env, + name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + }), + catch: (cause) => + new PtySpawnError({ + adapter: "node-pty", + message: cause instanceof Error ? cause.message : "Failed to spawn PTY process", + cause, + }), }); return new NodePtyProcess(ptyProcess); }), diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 1223ad3f0fb8..3acc8b7b4713 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,11 +2,16 @@ import { describe, expect, it, vi } from "vitest"; import { extractPathFromShellOutput, + isCommandAvailable, listLoginShellCandidates, mergePathEntries, + mergePathValues, readEnvironmentFromLoginShell, + readEnvironmentFromWindowsShell, readPathFromLaunchctl, readPathFromLoginShell, + resolveKnownWindowsCliDirs, + resolveWindowsEnvironment, } from "./shell.ts"; describe("extractPathFromShellOutput", () => { @@ -188,3 +193,266 @@ describe("mergePathEntries", () => { ); }); }); + +describe("readEnvironmentFromWindowsShell", () => { + it("extracts environment variables from a PowerShell command", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >( + () => + "__T3CODE_ENV_PATH_START__\nC:\\Users\\testuser\\AppData\\Roaming\\npm\n__T3CODE_ENV_PATH_END__\n", + ); + + expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({ + PATH: "C:\\Users\\testuser\\AppData\\Roaming\\npm", + }); + expect(execFile).toHaveBeenCalledWith( + "pwsh.exe", + expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]), + { encoding: "utf8", timeout: 5000 }, + ); + }); + + it("strips CRLF delimiters from captured PowerShell values", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >( + () => + "__T3CODE_ENV_FNM_DIR_START__\r\nC:\\Users\\testuser\\AppData\\Roaming\\fnm\r\n__T3CODE_ENV_FNM_DIR_END__\r\n", + ); + + expect(readEnvironmentFromWindowsShell(["FNM_DIR"], execFile)).toEqual({ + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }); + }); + + it("omits -NoProfile when loadProfile is enabled", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n"); + + expect(readEnvironmentFromWindowsShell(["PATH"], { loadProfile: true }, execFile)).toEqual({ + PATH: "C:\\Tools", + }); + expect(execFile).toHaveBeenCalledWith( + "pwsh.exe", + expect.arrayContaining(["-NoLogo", "-NonInteractive", "-Command"]), + { encoding: "utf8", timeout: 5000 }, + ); + expect(execFile.mock.calls[0]?.[1]).not.toContain("-NoProfile"); + }); + + it("falls back to Windows PowerShell when pwsh.exe is unavailable", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >((file) => { + if (file === "pwsh.exe") { + throw new Error("spawn pwsh.exe ENOENT"); + } + return "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n"; + }); + + expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({ + PATH: "C:\\Tools", + }); + expect(execFile).toHaveBeenNthCalledWith(1, "pwsh.exe", expect.any(Array), { + encoding: "utf8", + timeout: 5000, + }); + expect(execFile).toHaveBeenNthCalledWith(2, "powershell.exe", expect.any(Array), { + encoding: "utf8", + timeout: 5000, + }); + }); +}); + +describe("mergePathValues", () => { + it("dedupes case-insensitively on Windows while preserving preferred order", () => { + expect( + mergePathValues( + 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"', + "c:\\users\\testuser\\appdata\\roaming\\npm;C:\\Windows\\System32", + "win32", + ), + ).toBe( + 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs";C:\\Windows\\System32', + ); + }); + + it("dedupes case-sensitively on POSIX", () => { + expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe( + "/usr/local/bin:/usr/bin:/USR/BIN", + ); + }); +}); + +describe("resolveKnownWindowsCliDirs", () => { + it("returns known Windows CLI install directories in priority order", () => { + expect( + resolveKnownWindowsCliDirs({ + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }), + ).toEqual([ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + ]); + }); +}); + +describe("isCommandAvailable", () => { + it("returns false when PATH is empty", () => { + expect( + isCommandAvailable("definitely-not-installed", { + platform: "win32", + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ).toBe(false); + }); +}); + +describe("resolveWindowsEnvironment", () => { + it("returns the baseline no-profile PATH patch when node is already available", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { PATH: "C:\\Profile\\Bin" } + : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn(() => true); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + "C:\\Windows\\System32", + ].join(";"), + }); + expect(readEnvironment).toHaveBeenCalledTimes(1); + expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(commandAvailable).toHaveBeenCalledWith( + "node", + expect.objectContaining({ + platform: "win32", + }), + ); + }); + + it("loads the PowerShell profile when baseline env cannot resolve node", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn(() => false); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + }); + expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); + expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { + loadProfile: true, + }); + expect(commandAvailable).toHaveBeenCalledTimes(1); + }); + + it("keeps the baseline env when profiled probe still does not resolve node", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {}, + ); + const commandAvailable = vi.fn(() => false); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Windows\\System32", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }); + expect(commandAvailable).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 9cd206889efd..6edfdcffff39 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,9 +1,14 @@ import * as OS from "node:os"; import { execFileSync } from "node:child_process"; +import { accessSync, constants, statSync } from "node:fs"; +import { extname, join } from "node:path"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; +const WINDOWS_PATH_DELIMITER = ";"; +const POSIX_PATH_DELIMITER = ":"; +const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; type ExecFileSyncLike = ( file: string, @@ -11,6 +16,15 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; +export interface CommandAvailabilityOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; +} + +export interface WindowsEnvironmentProbeOptions { + readonly loadProfile?: boolean; +} + function trimNonEmpty(value: string | null | undefined): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -127,6 +141,24 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { .join("; "); } +function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): string { + return [ + "$ErrorActionPreference = 'Stop'", + ...names.flatMap((name) => { + if (!SHELL_ENV_NAME_PATTERN.test(name)) { + throw new Error(`Unsupported environment variable name: ${name}`); + } + + return [ + `Write-Output '${envCaptureStart(name)}'`, + `$value = [Environment]::GetEnvironmentVariable('${name}')`, + "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", + `Write-Output '${envCaptureEnd(name)}'`, + ]; + }), + ].join("; "); +} + function extractEnvironmentValue(output: string, name: string): string | undefined { const startMarker = envCaptureStart(name); const endMarker = envCaptureEnd(name); @@ -137,13 +169,10 @@ function extractEnvironmentValue(output: string, name: string): string | undefin const endIndex = output.indexOf(endMarker, valueStartIndex); if (endIndex === -1) return undefined; - let value = output.slice(valueStartIndex, endIndex); - if (value.startsWith("\n")) { - value = value.slice(1); - } - if (value.endsWith("\n")) { - value = value.slice(0, -1); - } + const value = output + .slice(valueStartIndex, endIndex) + .replace(/^\r?\n/, "") + .replace(/\r?\n$/, ""); return value.length > 0 ? value : undefined; } @@ -178,3 +207,284 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( return environment; }; + +export type WindowsShellEnvironmentReader = ( + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, +) => Partial>; + +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + execFile?: ExecFileSyncLike, +): Partial>; +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, + execFile?: ExecFileSyncLike, +): Partial>; +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + optionsOrExecFile?: WindowsEnvironmentProbeOptions | ExecFileSyncLike, + maybeExecFile?: ExecFileSyncLike, +): Partial> { + if (names.length === 0) { + return {}; + } + + const options = + typeof optionsOrExecFile === "function" + ? ({} satisfies WindowsEnvironmentProbeOptions) + : (optionsOrExecFile ?? {}); + const execFile: ExecFileSyncLike = + typeof optionsOrExecFile === "function" + ? optionsOrExecFile + : (maybeExecFile ?? (execFileSync as ExecFileSyncLike)); + const command = buildWindowsEnvironmentCaptureCommand(names); + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + command, + ]; + for (const shell of WINDOWS_SHELL_CANDIDATES) { + try { + const output = execFile(shell, args, { encoding: "utf8", timeout: 5000 }); + + const environment: Partial> = {}; + for (const name of names) { + const value = extractEnvironmentValue(output, name); + if (value !== undefined) { + environment[name] = value; + } + } + return environment; + } catch { + continue; + } + } + + return {}; +} + +function stripWrappingQuotes(value: string): string { + return value.replace(/^"+|"+$/g, ""); +} + +function pathDelimiterForPlatform(platform: NodeJS.Platform): string { + return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; +} + +function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { + const normalized = stripWrappingQuotes(entry.trim()); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + +export function mergePathValues( + preferredPath: string | undefined, + inheritedPath: string | undefined, + platform: NodeJS.Platform, +): string | undefined { + const delimiter = pathDelimiterForPlatform(platform); + const merged: string[] = []; + const seen = new Set(); + + for (const rawValue of [preferredPath, inheritedPath]) { + if (!rawValue) continue; + + for (const entry of rawValue.split(delimiter)) { + const trimmed = entry.trim(); + if (trimmed.length === 0) continue; + + const normalized = normalizePathEntryForComparison(trimmed, platform); + if (normalized.length === 0 || seen.has(normalized)) continue; + + seen.add(normalized); + merged.push(trimmed); + } + } + + return merged.length > 0 ? merged.join(delimiter) : undefined; +} + +function readEnvPath(env: NodeJS.ProcessEnv): string | undefined { + return env.PATH ?? env.Path ?? env.path; +} + +function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { + return readEnvPath(env) ?? ""; +} + +function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { + const rawValue = env.PATHEXT; + const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; + if (!rawValue) return fallback; + + const parsed = rawValue + .split(";") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); + return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; +} + +function resolveCommandCandidates( + command: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): ReadonlyArray { + if (platform !== "win32") return [command]; + const extension = extname(command); + const normalizedExtension = extension.toUpperCase(); + + if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { + const commandWithoutExtension = command.slice(0, -extension.length); + return Array.from( + new Set([ + command, + `${commandWithoutExtension}${normalizedExtension}`, + `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, + ]), + ); + } + + const candidates: string[] = []; + for (const candidateExtension of windowsPathExtensions) { + candidates.push(`${command}${candidateExtension}`); + candidates.push(`${command}${candidateExtension.toLowerCase()}`); + } + return Array.from(new Set(candidates)); +} + +function isExecutableFile( + filePath: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): boolean { + try { + const stat = statSync(filePath); + if (!stat.isFile()) return false; + if (platform === "win32") { + const extension = extname(filePath); + if (extension.length === 0) return false; + return windowsPathExtensions.includes(extension.toUpperCase()); + } + accessSync(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +export function isCommandAvailable( + command: string, + options: CommandAvailabilityOptions = {}, +): boolean { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); + + if (command.includes("/") || command.includes("\\")) { + return commandCandidates.some((candidate) => + isExecutableFile(candidate, platform, windowsPathExtensions), + ); + } + + const pathValue = resolvePathEnvironmentVariable(env); + if (pathValue.length === 0) return false; + const pathEntries = pathValue + .split(pathDelimiterForPlatform(platform)) + .map((entry) => stripWrappingQuotes(entry.trim())) + .filter((entry) => entry.length > 0); + + for (const pathEntry of pathEntries) { + for (const candidate of commandCandidates) { + if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { + return true; + } + } + } + return false; +} + +export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { + const appData = env.APPDATA?.trim(); + const localAppData = env.LOCALAPPDATA?.trim(); + const userProfile = env.USERPROFILE?.trim(); + + return [ + ...(appData ? [`${appData}\\npm`] : []), + ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), + ...(localAppData ? [`${localAppData}\\pnpm`] : []), + ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), + ]; +} + +export interface WindowsEnvironmentResolverOptions { + readonly readEnvironment?: WindowsShellEnvironmentReader; + readonly commandAvailable?: typeof isCommandAvailable; +} + +function readWindowsEnvironmentSafely( + readEnvironment: WindowsShellEnvironmentReader, + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, +): Partial> { + try { + return readEnvironment(names, options); + } catch { + return {}; + } +} + +function mergeWindowsEnv( + currentEnv: NodeJS.ProcessEnv, + patch: Partial>, +): NodeJS.ProcessEnv { + const nextEnv: NodeJS.ProcessEnv = { ...currentEnv }; + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) { + nextEnv[key] = value; + } + } + return nextEnv; +} + +export function resolveWindowsEnvironment( + env: NodeJS.ProcessEnv, + options: WindowsEnvironmentResolverOptions = {}, +): Partial { + const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; + const commandAvailable = options.commandAvailable ?? isCommandAvailable; + const inheritedPath = readEnvPath(env); + const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { + loadProfile: false, + }).PATH; + const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); + const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); + const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); + const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; + const baselineEnv = mergeWindowsEnv(env, baselinePatch); + + if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { + return baselinePatch; + } + + const profiledEnvironment = readWindowsEnvironmentSafely( + readEnvironment, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], + { loadProfile: true }, + ); + const profiledPath = mergePathValues(profiledEnvironment.PATH, baselinePath, "win32"); + const profiledPatch: Partial = { + ...(profiledPath ? { PATH: profiledPath } : {}), + ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}), + ...(profiledEnvironment.FNM_MULTISHELL_PATH + ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH } + : {}), + }; + return Object.keys(profiledPatch).length > 0 + ? { ...baselinePatch, ...profiledPatch } + : baselinePatch; +} diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 110b19a85c42..74e8bed0cb88 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -610,12 +610,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } if (platform === "win") { + buildConfig.npmRebuild = false; const winConfig: Record = { target: [target], icon: "icon.ico", }; if (signed) { winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig; + } else { + winConfig.signAndEditExecutable = false; } buildConfig.win = winConfig; } @@ -811,7 +814,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ...commandOutputOptions(options.verbose), // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). shell: process.platform === "win32", - })`bun install --production`, + })`bun install --production --omit optional`, ); const buildEnv: NodeJS.ProcessEnv = { @@ -851,7 +854,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ...commandOutputOptions(options.verbose), // Windows needs shell mode to resolve .cmd shims. shell: process.platform === "win32", - })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, + })`bun x --install=fallback electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`, ); const stageDistDir = path.join(stageAppDir, "dist"); From f297e30e4c0fe044ba5cbed7679bfcba134526fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Apr 2026 23:26:28 -0700 Subject: [PATCH 19/36] Clean up invalid pending approval projections (#2106) --- apps/server/src/persistence/Migrations.ts | 2 + ...pInvalidProjectionPendingApprovals.test.ts | 196 ++++++++++++++++++ ...leanupInvalidProjectionPendingApprovals.ts | 27 +++ 3 files changed, 225 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts create mode 100644 apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 01c649f7e96d..023e3bca051e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -37,6 +37,7 @@ import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; +import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; /** * Migration loader with all migrations defined inline. @@ -73,6 +74,7 @@ export const migrationEntries = [ [22, "AuthSessionLastConnectedAt", Migration0022], [23, "ProjectionThreadShellSummary", Migration0023], [24, "BackfillProjectionThreadShellSummary", Migration0024], + [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts new file mode 100644 index 000000000000..060cd471b30e --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts @@ -0,0 +1,196 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("025_CleanupInvalidProjectionPendingApprovals", (it) => { + it.effect("removes pending-approval rows that do not come from approval requests", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 24 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES + ( + 'thread-valid', + 'project-1', + 'Valid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-valid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 2, + 0, + 0, + NULL + ), + ( + 'thread-invalid', + 'project-1', + 'Invalid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-invalid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 1, + 0, + 0, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-approval-requested', + 'thread-valid', + 'turn-valid', + 'approval', + 'approval.requested', + 'Command approval requested', + '{"requestId":"approval-valid","requestKind":"command"}', + NULL, + '2026-04-13T00:01:00.000Z' + ), + ( + 'activity-user-input-requested', + 'thread-invalid', + 'turn-invalid', + 'info', + 'user-input.requested', + 'User input requested', + '{"requestId":"input-invalid","questions":[{"id":"scope","header":"Scope","question":"What should I inspect?","options":[{"label":"Server","description":"Inspect server code."}]}]}', + NULL, + '2026-04-13T00:02:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES + ( + 'approval-valid', + 'thread-valid', + 'turn-valid', + 'pending', + NULL, + '2026-04-13T00:01:00.000Z', + NULL + ), + ( + 'input-invalid', + 'thread-invalid', + 'turn-invalid', + 'pending', + NULL, + '2026-04-13T00:02:00.000Z', + NULL + ), + ( + 'input-invalid-resolved', + 'thread-valid', + 'turn-valid', + 'resolved', + NULL, + '2026-04-13T00:03:00.000Z', + '2026-04-13T00:04:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 25 }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + }>` + SELECT + request_id AS "requestId", + status + FROM projection_pending_approvals + ORDER BY request_id ASC + `; + assert.deepStrictEqual(approvalRows, [ + { + requestId: "approval-valid", + status: "pending", + }, + ]); + + const threadCounts = yield* sql<{ + readonly threadId: string; + readonly pendingApprovalCount: number; + }>` + SELECT + thread_id AS "threadId", + pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + ORDER BY thread_id ASC + `; + assert.deepStrictEqual(threadCounts, [ + { + threadId: "thread-invalid", + pendingApprovalCount: 0, + }, + { + threadId: "thread-valid", + pendingApprovalCount: 1, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts new file mode 100644 index 000000000000..33a6512c750b --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts @@ -0,0 +1,27 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + DELETE FROM projection_pending_approvals + WHERE NOT EXISTS ( + SELECT 1 + FROM projection_thread_activities AS activity + WHERE activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') + = projection_pending_approvals.request_id + ) + `; + + yield* sql` + UPDATE projection_threads + SET pending_approval_count = COALESCE(( + SELECT COUNT(*) + FROM projection_pending_approvals + WHERE projection_pending_approvals.thread_id = projection_threads.thread_id + AND projection_pending_approvals.status = 'pending' + ), 0) + `; +}); From df9d34004f45d529ae2fb47a590776e75578a645 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 09:23:02 -0700 Subject: [PATCH 20/36] Modernize release workflow runners (#2129) Co-authored-by: codex Co-authored-by: Cursor Agent --- .github/workflows/release.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebfc26d8ba2d..a1b3e7206133 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,7 +156,7 @@ jobs: target: AppImage arch: x64 - label: Windows x64 - runner: windows-2022 # blacksmith-32vcpu-windows-2025 + runner: blacksmith-32vcpu-windows-2025 platform: win target: nsis arch: x64 @@ -188,6 +188,23 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + - name: Install Spectre-mitigated MSVC libs + if: matrix.platform == 'win' + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installPath = & $vswhere -products * -latest -property installationPath + $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" + $proc = Start-Process -FilePath $setupExe ` + -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` + -Wait -PassThru -NoNewWindow + if ($null -eq $proc -or $proc.ExitCode -ne 0) { + $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } + Write-Error "Visual Studio Installer failed with exit code $code" + exit $code + } + - name: Build desktop artifact shell: bash env: @@ -333,7 +350,7 @@ jobs: release: name: Publish GitHub Release needs: [preflight, build, publish_cli] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: - name: Checkout @@ -341,11 +358,19 @@ jobs: with: ref: ${{ needs.preflight.outputs.ref }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - name: Setup Node uses: actions/setup-node@v6 with: node-version-file: package.json + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Download all desktop artifacts uses: actions/download-artifact@v8 with: From 40009735eea88b2266cf7d112217e85a688b3e63 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 11:09:26 -0700 Subject: [PATCH 21/36] Extract backend startup readiness coordination (#2133) --- .../src/backendStartupReadiness.test.ts | 58 ++++++++++++++++++ apps/desktop/src/backendStartupReadiness.ts | 56 ++++++++++++++++++ apps/desktop/src/main.ts | 59 ++++--------------- 3 files changed, 125 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/backendStartupReadiness.test.ts create mode 100644 apps/desktop/src/backendStartupReadiness.ts diff --git a/apps/desktop/src/backendStartupReadiness.test.ts b/apps/desktop/src/backendStartupReadiness.test.ts new file mode 100644 index 000000000000..6d1df3d3ecd6 --- /dev/null +++ b/apps/desktop/src/backendStartupReadiness.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; + +import { BackendReadinessAbortedError } from "./backendReadiness.ts"; +import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; + +describe("waitForBackendStartupReady", () => { + it("falls back to the HTTP probe when no listening signal exists", async () => { + const waitForHttpReady = vi.fn<() => Promise>().mockResolvedValue(undefined); + const cancelHttpWait = vi.fn(); + + await expect( + waitForBackendStartupReady({ + waitForHttpReady, + cancelHttpWait, + }), + ).resolves.toBe("http"); + + expect(waitForHttpReady).toHaveBeenCalledTimes(1); + expect(cancelHttpWait).not.toHaveBeenCalled(); + }); + + it("uses the listening signal and cancels the HTTP probe", async () => { + let rejectHttpWait: ((error: unknown) => void) | null = null; + const waitForHttpReady = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectHttpWait = reject; + }), + ); + const cancelHttpWait = vi.fn(() => { + rejectHttpWait?.(new BackendReadinessAbortedError()); + }); + + await expect( + waitForBackendStartupReady({ + listeningPromise: Promise.resolve(), + waitForHttpReady, + cancelHttpWait, + }), + ).resolves.toBe("listening"); + + expect(waitForHttpReady).toHaveBeenCalledTimes(1); + expect(cancelHttpWait).toHaveBeenCalledTimes(1); + }); + + it("rejects when the listening signal fails before HTTP readiness", async () => { + const error = new Error("backend exited"); + const waitForHttpReady = vi.fn(() => new Promise(() => {})); + + await expect( + waitForBackendStartupReady({ + listeningPromise: Promise.reject(error), + waitForHttpReady, + cancelHttpWait: vi.fn(), + }), + ).rejects.toBe(error); + }); +}); diff --git a/apps/desktop/src/backendStartupReadiness.ts b/apps/desktop/src/backendStartupReadiness.ts new file mode 100644 index 000000000000..37a977431d02 --- /dev/null +++ b/apps/desktop/src/backendStartupReadiness.ts @@ -0,0 +1,56 @@ +import { isBackendReadinessAborted } from "./backendReadiness.ts"; + +export interface WaitForBackendStartupReadyOptions { + readonly listeningPromise?: Promise | null; + readonly waitForHttpReady: () => Promise; + readonly cancelHttpWait: () => void; +} + +export async function waitForBackendStartupReady( + options: WaitForBackendStartupReadyOptions, +): Promise<"listening" | "http"> { + const httpReadyPromise = options.waitForHttpReady(); + const listeningPromise = options.listeningPromise; + + if (!listeningPromise) { + await httpReadyPromise; + return "http"; + } + + return await new Promise<"listening" | "http">((resolve, reject) => { + let settled = false; + + const settleResolve = (source: "listening" | "http") => { + if (settled) { + return; + } + settled = true; + if (source === "listening") { + options.cancelHttpWait(); + } + resolve(source); + }; + + const settleReject = (error: unknown) => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + + listeningPromise.then( + () => settleResolve("listening"), + (error) => settleReject(error), + ); + httpReadyPromise.then( + () => settleResolve("http"), + (error) => { + if (settled && isBackendReadinessAborted(error)) { + return; + } + settleReject(error); + }, + ); + }); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3ef80f5c0a0b..529ed55d03f7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -57,6 +57,7 @@ import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness. import { showDesktopConfirmDialog } from "./confirmDialog.ts"; import { resolveDesktopServerExposure } from "./serverExposure.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; +import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; import { ServerListeningDetector } from "./serverListeningDetector.ts"; @@ -459,51 +460,13 @@ function cancelBackendReadinessWait(): void { } async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { - const httpReadyPromise = waitForBackendHttpReady(baseUrl, { - timeoutMs: 60_000, - }); - const listeningPromise = backendListeningDetector?.promise; - - if (!listeningPromise) { - await httpReadyPromise; - return "http"; - } - - return await new Promise<"listening" | "http">((resolve, reject) => { - let settled = false; - - const settleResolve = (source: "listening" | "http") => { - if (settled) { - return; - } - settled = true; - if (source === "listening") { - cancelBackendReadinessWait(); - } - resolve(source); - }; - - const settleReject = (error: unknown) => { - if (settled) { - return; - } - settled = true; - reject(error); - }; - - listeningPromise.then( - () => settleResolve("listening"), - (error) => settleReject(error), - ); - httpReadyPromise.then( - () => settleResolve("http"), - (error) => { - if (settled && isBackendReadinessAborted(error)) { - return; - } - settleReject(error); - }, - ); + return await waitForBackendStartupReady({ + listeningPromise: backendListeningDetector?.promise ?? null, + waitForHttpReady: () => + waitForBackendHttpReady(baseUrl, { + timeoutMs: 60_000, + }), + cancelHttpWait: cancelBackendReadinessWait, }); } @@ -2119,9 +2082,9 @@ async function bootstrap(): Promise { if (isDevelopment) { mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); - void waitForBackendHttpReady(backendHttpUrl) - .then(() => { - writeDesktopLogHeader("bootstrap backend ready"); + void waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`bootstrap backend ready source=${source}`); }) .catch((error) => { if (isBackendReadinessAborted(error)) { From 721b6b4c6f596bd1d1b3af585ed9c62c4b6f72ab Mon Sep 17 00:00:00 2001 From: Hauke Schnau Date: Fri, 17 Apr 2026 20:27:12 +0200 Subject: [PATCH 22/36] Preserve provider bindings when stopping sessions (#2125) Co-authored-by: Julius Marminge Co-authored-by: codex --- .../Layers/ProviderCommandReactor.test.ts | 216 +++++++++--------- .../Layers/ProviderCommandReactor.ts | 14 +- .../src/provider/Layers/CodexAdapter.test.ts | 1 - .../provider/Layers/ProviderService.test.ts | 91 ++++++-- .../src/provider/Layers/ProviderService.ts | 9 +- .../Layers/ProviderSessionDirectory.test.ts | 15 +- .../Layers/ProviderSessionDirectory.ts | 8 - .../Services/ProviderSessionDirectory.ts | 4 - 8 files changed, 198 insertions(+), 160 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 7a4913ca32ca..c6c9b22abb16 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -750,6 +750,57 @@ describe("ProviderCommandReactor", () => { }); }); + it("preserves the active session model when in-session model switching is unsupported", async () => { + const harness = await createHarness({ sessionModelSwitch: "unsupported" }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unsupported-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-unsupported-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-2"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + }); + }); + it("rejects a first turn when requested provider conflicts with the thread model", async () => { const harness = await createHarness({ threadModelSelection: { provider: "codex", model: "gpt-5-codex" }, @@ -802,57 +853,6 @@ describe("ProviderCommandReactor", () => { }); }); - it("preserves the active session model when in-session model switching is unsupported", async () => { - const harness = await createHarness({ sessionModelSwitch: "unsupported" }); - const now = new Date().toISOString(); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-unsupported-1"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-unsupported-1"), - role: "user", - text: "first", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 1); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-unsupported-2"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-unsupported-2"), - role: "user", - text: "second", - attachments: [], - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.sendTurn.mock.calls.length === 2); - - expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - threadId: ThreadId.make("thread-1"), - modelSelection: { - provider: "codex", - model: "gpt-5-codex", - }, - }); - }); - it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -1100,23 +1100,33 @@ describe("ProviderCommandReactor", () => { }); }); - it("rejects provider changes after a thread is already bound to a session provider", async () => { + it("does not stop the active session when restart fails before rebind", async () => { const harness = await createHarness(); const now = new Date().toISOString(); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access-2"), + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + createdAt: now, + }), + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-switch-1"), + commandId: CommandId.make("cmd-turn-start-restart-failure-1"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-provider-switch-1"), + messageId: asMessageId("user-message-restart-failure-1"), role: "user", text: "first", attachments: [], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", + runtimeMode: "full-access", createdAt: now, }), ); @@ -1124,22 +1134,15 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + harness.startSession.mockImplementationOnce( + (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, + ); + await Effect.runPromise( harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-switch-2"), + type: "thread.runtime-mode.set", + commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-provider-switch-2"), - role: "user", - text: "second", - attachments: [], - }, - modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", - }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -1148,57 +1151,37 @@ describe("ProviderCommandReactor", () => { await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); + return thread?.runtimeMode === "approval-required"; }); + await waitFor(() => harness.startSession.mock.calls.length === 2); + await harness.drain(); - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.sendTurn.mock.calls.length).toBe(1); expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.sendTurn.mock.calls.length).toBe(1); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.providerName).toBe("codex"); - expect(thread?.session?.runtimeMode).toBe("approval-required"); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), - }, - }); + expect(thread?.session?.runtimeMode).toBe("full-access"); }); - it("does not stop the active session when restart fails before rebind", async () => { + it("rejects provider changes after a thread is already bound to a session provider", async () => { const harness = await createHarness(); const now = new Date().toISOString(); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-initial-full-access-2"), - threadId: ThreadId.make("thread-1"), - runtimeMode: "full-access", - createdAt: now, - }), - ); - await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-restart-failure-1"), + commandId: CommandId.make("cmd-turn-start-provider-switch-1"), threadId: ThreadId.make("thread-1"), message: { - messageId: asMessageId("user-message-restart-failure-1"), + messageId: asMessageId("user-message-provider-switch-1"), role: "user", text: "first", attachments: [], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", + runtimeMode: "approval-required", createdAt: now, }), ); @@ -1206,15 +1189,22 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - harness.startSession.mockImplementationOnce( - (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, - ); - await Effect.runPromise( harness.engine.dispatch({ - type: "thread.runtime-mode.set", - commandId: CommandId.make("cmd-runtime-mode-set-restart-failure"), + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-provider-switch-2"), threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-provider-switch-2"), + role: "user", + text: "second", + attachments: [], + }, + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), @@ -1223,18 +1213,28 @@ describe("ProviderCommandReactor", () => { await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return thread?.runtimeMode === "approval-required"; + return ( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? + false + ); }); - await waitFor(() => harness.startSession.mock.calls.length === 2); - await harness.drain(); - expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls.length).toBe(1); expect(harness.sendTurn.mock.calls.length).toBe(1); + expect(harness.stopSession.mock.calls.length).toBe(0); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.runtimeMode).toBe("full-access"); + expect(thread?.session?.providerName).toBe("codex"); + expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("cannot switch to 'claudeAgent'"), + }, + }); }); it("reacts to thread.turn.interrupt-requested by calling provider interrupt", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index db2bd2d43528..1425269f7141 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -247,7 +247,7 @@ const make = Effect.gen(function* () { detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`, }); } - const preferredProvider: ProviderKind = currentProvider ?? threadProvider; + const preferredProvider: ProviderKind = threadProvider; const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; const effectiveCwd = resolveThreadWorkspaceCwd({ thread, @@ -293,9 +293,6 @@ const make = Effect.gen(function* () { thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; - const providerChanged = - requestedModelSelection !== undefined && - requestedModelSelection.provider !== currentProvider; const sessionModelSwitch = currentProvider === undefined ? "in-session" @@ -312,17 +309,15 @@ const make = Effect.gen(function* () { if ( !runtimeModeChanged && - !providerChanged && !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { return existingSessionThreadId; } - const resumeCursor = - providerChanged || shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); + const resumeCursor = shouldRestartForModelChange + ? undefined + : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, @@ -331,7 +326,6 @@ const make = Effect.gen(function* () { currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, - providerChanged, modelChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 03ba0ce4e80b..ac272795ecbf 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -144,7 +144,6 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), - remove: () => Effect.void, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 011b7741777f..b54976589f80 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -18,7 +18,6 @@ import { TurnId, } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; -import { assertFailure } from "@effect/vitest/utils"; import { Effect, Fiber, Layer, Metric, Option, PubSub, Ref, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -574,20 +573,31 @@ routing.layer("ProviderServiceLive routing", (it) => { }); yield* provider.stopSession({ threadId: session.threadId }); - const sendAfterStop = yield* Effect.result( - provider.sendTurn({ - threadId: session.threadId, - input: "after-stop", - attachments: [], - }), - ); - assertFailure( - sendAfterStop, - new ProviderValidationError({ - operation: "ProviderService.sendTurn", - issue: `Cannot route thread '${session.threadId}' because no persisted provider binding exists.`, - }), - ); + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + + yield* provider.sendTurn({ + threadId: session.threadId, + input: "after-stop", + attachments: [], + }); + + assert.equal(routing.codex.startSession.mock.calls.length, 1); + const resumedStartInput = routing.codex.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "codex"); + assert.equal(startPayload.cwd, "/tmp/project"); + assert.deepEqual(startPayload.resumeCursor, session.resumeCursor); + assert.equal(startPayload.threadId, session.threadId); + } + assert.equal(routing.codex.sendTurn.mock.calls.length, 1); }), ); @@ -631,6 +641,57 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("preserves the persisted binding when stopping a session", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), { + provider: "codex", + threadId: asThreadId("thread-reap-preserve"), + cwd: "/tmp/project-reap-preserve", + runtimeMode: "full-access", + }); + + yield* provider.stopSession({ threadId: initial.threadId }); + + const persistedAfterStop = yield* runtimeRepository.getByThreadId({ + threadId: initial.threadId, + }); + assert.equal(Option.isSome(persistedAfterStop), true); + if (Option.isSome(persistedAfterStop)) { + assert.equal(persistedAfterStop.value.status, "stopped"); + assert.deepEqual(persistedAfterStop.value.resumeCursor, initial.resumeCursor); + } + + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + + yield* provider.sendTurn({ + threadId: initial.threadId, + input: "resume after reap", + attachments: [], + }); + + assert.equal(routing.codex.startSession.mock.calls.length, 1); + const resumedStartInput = routing.codex.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "codex"); + assert.equal(startPayload.cwd, "/tmp/project-reap-preserve"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } + assert.equal(routing.codex.sendTurn.mock.calls.length, 1); + }), + ); + it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 4dbd264289ba..20479b238c73 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -620,7 +620,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (routed.isActive) { yield* routed.adapter.stopSession(routed.threadId); } - yield* directory.remove(input.threadId); + yield* directory.upsert({ + threadId: input.threadId, + provider: routed.adapter.provider, + status: "stopped", + runtimePayload: { + activeTurnId: null, + }, + }); yield* analytics.record("provider.session.stopped", { provider: routed.adapter.provider, }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 30bc387b859e..35bdec1e37d2 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; -import { assertFailure, assertSome } from "@effect/vitest/utils"; +import { assertSome } from "@effect/vitest/utils"; import { Effect, Layer, Option } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -15,7 +15,6 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { ProviderSessionDirectoryPersistenceError } from "../Errors.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; @@ -31,7 +30,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts, reads, and removes thread bindings", () => + it("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; @@ -76,16 +75,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const threadIds = yield* directory.listThreadIds(); assert.deepEqual(threadIds, [nextThreadId]); - - yield* directory.remove(nextThreadId); - const missingProvider = yield* directory.getProvider(nextThreadId).pipe(Effect.result); - assertFailure( - missingProvider, - new ProviderSessionDirectoryPersistenceError({ - operation: "ProviderSessionDirectory.getProvider", - detail: `No persisted provider binding found for thread '${nextThreadId}'.`, - }), - ); })); it("persists runtime fields and merges payload updates", () => diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index da4e32ac6348..b9d2439eefad 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -145,13 +145,6 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => - repository - .deleteByThreadId({ threadId }) - .pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")), - ); - const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), @@ -174,7 +167,6 @@ const makeProviderSessionDirectory = Effect.gen(function* () { upsert, getProvider, getBinding, - remove, listThreadIds, listBindings, } satisfies ProviderSessionDirectoryShape; diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index a5be4d63e31f..bee7a1b37361 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -45,10 +45,6 @@ export interface ProviderSessionDirectoryShape { threadId: ThreadId, ) => Effect.Effect, ProviderSessionDirectoryReadError>; - readonly remove: ( - threadId: ThreadId, - ) => Effect.Effect; - readonly listThreadIds: () => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError From 52a60678026549f8db66165e254c49eecfb69920 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 11:39:20 -0700 Subject: [PATCH 23/36] Throttle nightly release workflow to every 3 hours (#2134) --- .github/workflows/release.yml | 39 ++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1b3e7206133..b6110d1bb404 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: tags: - "v*.*.*" schedule: - - cron: "0 9 * * *" + - cron: "0 */3 * * *" workflow_dispatch: inputs: channel: @@ -26,8 +26,45 @@ permissions: id-token: write jobs: + check_changes: + name: Check for changes since last nightly + if: github.event_name == 'schedule' + runs-on: ubuntu-24.04 + outputs: + has_changes: ${{ steps.check.outputs.has_changes }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - id: check + name: Compare HEAD to last nightly tag + run: | + last_nightly_tag=$(git tag --list 'nightly-v*' --sort=-creatordate | head -n 1) + if [[ -z "$last_nightly_tag" ]]; then + echo "No previous nightly tag found. Proceeding with release." + echo "has_changes=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") + 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 "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + preflight: name: Preflight + needs: [check_changes] + if: | + !failure() && !cancelled() && + (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 outputs: From 39ca3ee858e764dba3293aa9cb69452c2265440d Mon Sep 17 00:00:00 2001 From: Ariaj <144548552+AriajSarkar@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:41:04 +0530 Subject: [PATCH 24/36] fix(web): bypass xterm for global terminal shortcuts (#1580) --- apps/web/src/components/ChatView.tsx | 5 ++++ .../ThreadTerminalDrawer.browser.tsx | 2 ++ .../src/components/ThreadTerminalDrawer.tsx | 29 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76431368f305..7890bc0dc8e6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10,6 +10,7 @@ import { type ProjectId, type ProviderApprovalDecision, type ServerProvider, + type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type TurnId, @@ -415,6 +416,7 @@ interface PersistentThreadTerminalDrawerProps { splitShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; + keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -427,6 +429,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra splitShortcutLabel, newShortcutLabel, closeShortcutLabel, + keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); @@ -570,6 +573,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra splitShortcutLabel={visible ? splitShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} closeShortcutLabel={visible ? closeShortcutLabel : undefined} + keybindings={keybindings} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} onHeightChange={setTerminalHeight} @@ -3443,6 +3447,7 @@ export default function ChatView(props: ChatViewProps) { splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} + keybindings={keybindings} onAddTerminalContext={addTerminalContextToDraft} /> ))} diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 37e0df1cc4b5..2df2e04f5c4d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -177,6 +177,7 @@ async function mountTerminalViewport(props: { autoFocus={false} resizeEpoch={0} drawerHeight={320} + keybindings={[]} />, { container: host }, ); @@ -196,6 +197,7 @@ async function mountTerminalViewport(props: { autoFocus={false} resizeEpoch={0} drawerHeight={320} + keybindings={[]} />, ); }, diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 14f4f6405018..6c71e5eb3349 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,6 +1,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { + type ResolvedKeybindingsConfig, type ScopedThreadRef, type TerminalEvent, type TerminalSessionSnapshot, @@ -29,7 +30,12 @@ import { wrappedTerminalLinkRangeIntersectsBufferLine, } from "../terminal-links"; import { + isDiffToggleShortcut, isTerminalClearShortcut, + isTerminalCloseShortcut, + isTerminalNewShortcut, + isTerminalSplitShortcut, + isTerminalToggleShortcut, terminalDeleteShortcutData, terminalNavigationShortcutData, } from "../keybindings"; @@ -255,6 +261,7 @@ interface TerminalViewportProps { autoFocus: boolean; resizeEpoch: number; drawerHeight: number; + keybindings: ResolvedKeybindingsConfig; } export function TerminalViewport({ @@ -271,6 +278,7 @@ export function TerminalViewport({ autoFocus, resizeEpoch, drawerHeight, + keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -282,6 +290,7 @@ export function TerminalViewport({ const selectionActionRequestIdRef = useRef(0); const selectionActionOpenRef = useRef(false); const selectionActionTimerRef = useRef(null); + const keybindingsRef = useRef(keybindings); const lastAppliedTerminalEventIdRef = useRef(0); const terminalHydratedRef = useRef(false); const handleSessionExited = useEffectEvent(() => { @@ -292,6 +301,10 @@ export function TerminalViewport({ }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + useEffect(() => { + keybindingsRef.current = keybindings; + }, [keybindings]); + useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -403,6 +416,18 @@ export function TerminalViewport({ }; terminal.attachCustomKeyEventHandler((event) => { + const currentKeybindings = keybindingsRef.current; + const options = { context: { terminalFocus: true, terminalOpen: true } }; + if ( + isTerminalToggleShortcut(event, currentKeybindings, options) || + isTerminalSplitShortcut(event, currentKeybindings, options) || + isTerminalNewShortcut(event, currentKeybindings, options) || + isTerminalCloseShortcut(event, currentKeybindings, options) || + isDiffToggleShortcut(event, currentKeybindings, options) + ) { + return false; + } + const navigationData = terminalNavigationShortcutData(event); if (navigationData !== null) { event.preventDefault(); @@ -795,6 +820,7 @@ interface ThreadTerminalDrawerProps { onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; + keybindings: ResolvedKeybindingsConfig; } interface TerminalActionButtonProps { @@ -848,6 +874,7 @@ export default function ThreadTerminalDrawer({ onCloseTerminal, onHeightChange, onAddTerminalContext, + keybindings, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); @@ -1166,6 +1193,7 @@ export default function ThreadTerminalDrawer({ autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} + keybindings={keybindings} />
@@ -1188,6 +1216,7 @@ export default function ThreadTerminalDrawer({ autoFocus resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} + keybindings={keybindings} />
)} From ce94feeea156e77f465c0330e89c0cb77eb0196f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 18 Apr 2026 02:01:41 +0530 Subject: [PATCH 25/36] feat: add opencode provider support (#1758) Co-authored-by: Julius Marminge Co-authored-by: codex Co-authored-by: Claude Opus 4.6 --- apps/server/package.json | 1 + .../git/Layers/OpenCodeTextGeneration.test.ts | 259 ++++ .../src/git/Layers/OpenCodeTextGeneration.ts | 422 ++++++ .../src/git/Layers/RoutingTextGeneration.ts | 22 +- .../server/src/git/Services/TextGeneration.ts | 2 +- .../provider/Layers/OpenCodeAdapter.test.ts | 486 ++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 1344 +++++++++++++++++ .../provider/Layers/OpenCodeProvider.test.ts | 138 ++ .../src/provider/Layers/OpenCodeProvider.ts | 342 +++++ .../Layers/ProviderAdapterRegistry.test.ts | 24 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../provider/Layers/ProviderRegistry.test.ts | 39 +- .../src/provider/Layers/ProviderRegistry.ts | 34 +- .../provider/Layers/ProviderService.test.ts | 56 + .../src/provider/Layers/ProviderService.ts | 4 +- .../Layers/ProviderSessionDirectory.ts | 2 +- .../src/provider/Services/OpenCodeAdapter.ts | 12 + .../src/provider/Services/OpenCodeProvider.ts | 9 + .../src/provider/opencodeRuntime.test.ts | 38 + apps/server/src/provider/opencodeRuntime.ts | 573 +++++++ .../src/provider/providerSnapshot.test.ts | 34 + .../src/provider/providerStatusCache.test.ts | 13 + .../src/provider/providerStatusCache.ts | 8 +- apps/server/src/server.ts | 5 + apps/server/src/serverSettings.test.ts | 20 + apps/server/src/serverSettings.ts | 2 +- apps/web/src/components/ChatView.tsx | 15 +- apps/web/src/components/Icons.tsx | 6 +- .../components/KeybindingsToast.browser.tsx | 7 + apps/web/src/components/chat/ChatComposer.tsx | 60 +- .../CompactComposerControlsMenu.browser.tsx | 35 + .../chat/CompactComposerControlsMenu.tsx | 29 +- .../components/chat/ProviderModelPicker.tsx | 6 +- .../components/chat/TraitsPicker.browser.tsx | 129 +- apps/web/src/components/chat/TraitsPicker.tsx | 102 +- .../chat/composerProviderRegistry.test.tsx | 65 + .../chat/composerProviderRegistry.tsx | 122 +- .../settings/SettingsPanels.browser.tsx | 17 + .../components/settings/SettingsPanels.tsx | 126 +- apps/web/src/composerDraftStore.ts | 101 +- apps/web/src/modelSelection.ts | 25 +- apps/web/src/session-logic.test.ts | 9 +- apps/web/src/session-logic.ts | 1 + apps/web/src/store.ts | 4 +- bun.lock | 3 + packages/contracts/src/model.ts | 13 + packages/contracts/src/orchestration.ts | 17 +- packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/settings.ts | 30 + packages/shared/src/model.ts | 73 + packages/shared/src/serverSettings.test.ts | 26 + packages/shared/src/serverSettings.ts | 41 +- 52 files changed, 4727 insertions(+), 228 deletions(-) create mode 100644 apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts create mode 100644 apps/server/src/git/Layers/OpenCodeTextGeneration.ts create mode 100644 apps/server/src/provider/Layers/OpenCodeAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/OpenCodeAdapter.ts create mode 100644 apps/server/src/provider/Layers/OpenCodeProvider.test.ts create mode 100644 apps/server/src/provider/Layers/OpenCodeProvider.ts create mode 100644 apps/server/src/provider/Services/OpenCodeAdapter.ts create mode 100644 apps/server/src/provider/Services/OpenCodeProvider.ts create mode 100644 apps/server/src/provider/opencodeRuntime.test.ts create mode 100644 apps/server/src/provider/opencodeRuntime.ts create mode 100644 apps/server/src/provider/providerSnapshot.test.ts diff --git a/apps/server/package.json b/apps/server/package.json index 038134bd52fa..b5107d1b0a5d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -29,6 +29,7 @@ "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts new file mode 100644 index 000000000000..4cf25c9468d5 --- /dev/null +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts @@ -0,0 +1,259 @@ +import type { ChildProcess } from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Duration, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; +import { beforeEach, expect, vi } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration } from "../Services/TextGeneration.ts"; +import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; + +const runtimeMock = vi.hoisted(() => { + const state = { + startCalls: [] as string[], + promptUrls: [] as string[], + authHeaders: [] as Array, + closeCalls: [] as string[], + promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined, + }; + + return { + state, + reset() { + state.startCalls.length = 0; + state.promptUrls.length = 0; + state.authHeaders.length = 0; + state.closeCalls.length = 0; + state.promptResult = undefined; + }, + }; +}); + +vi.mock("../../provider/opencodeRuntime.ts", async () => { + const actual = await vi.importActual( + "../../provider/opencodeRuntime.ts", + ); + + return { + ...actual, + startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { + const index = runtimeMock.state.startCalls.length + 1; + const url = `http://127.0.0.1:${4_300 + index}`; + runtimeMock.state.startCalls.push(binaryPath); + return { + url, + process: {} as ChildProcess, + close: () => { + runtimeMock.state.closeCalls.push(url); + }, + }; + }), + createOpenCodeSdkClient: vi.fn( + ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ + session: { + create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })), + prompt: vi.fn(async () => { + runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return ( + runtimeMock.state.promptResult ?? { + data: { + info: { + structured: { + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }, + }, + }, + } + ); + }), + }, + }), + ), + }; +}); + +const DEFAULT_TEST_MODEL_SELECTION = { + provider: "opencode" as const, + model: "openai/gpt-5", +}; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + }, + }, + }), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-existing-server-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const advanceIdleClock = Effect.gen(function* () { + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); + yield* Effect.yieldNow; +}); + +it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4301", + ]); + expect(runtimeMock.state.closeCalls).toEqual([]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("starts a new server after the warm server idles out", () => + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + yield* advanceIdleClock; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4302", + ]); + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("returns a typed missing-output error when OpenCode omits info.structured", () => + Effect.gen(function* () { + runtimeMock.state.promptResult = { data: {} }; + const textGeneration = yield* TextGeneration; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode returned no structured output."); + }), + ); +}); + +it.layer(OpenCodeTextGenerationExistingServerTestLayer)( + "OpenCodeTextGenerationLive with configured server URL", + (it) => { + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + `Basic ${btoa("opencode:secret-password")}`, + ]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual([]); + }).pipe(Effect.provide(TestClock.layer())), + ); + }, +); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts new file mode 100644 index 000000000000..7721354e4dac --- /dev/null +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -0,0 +1,422 @@ +import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; +import * as Semaphore from "effect/Semaphore"; + +import { + TextGenerationError, + type ChatAttachment, + type OpenCodeModelSelection, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { ServerConfig } from "../../config.ts"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "../Prompts.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "../Utils.ts"; +import { + createOpenCodeSdkClient, + type OpenCodeServerConnection, + type OpenCodeServerProcess, + parseOpenCodeModelSlug, + startOpenCodeServerProcess, + toOpenCodeFileParts, +} from "../../provider/opencodeRuntime.ts"; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +interface SharedOpenCodeTextGenerationServerState { + server: OpenCodeServerProcess | null; + binaryPath: string | null; + activeRequests: number; + idleCloseFiber: Fiber.Fiber | null; +} + +const makeOpenCodeTextGeneration = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettingsService = yield* ServerSettingsService; + const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const sharedServerMutex = yield* Semaphore.make(1); + const sharedServerState: SharedOpenCodeTextGenerationServerState = { + server: null, + binaryPath: null, + activeRequests: 0, + idleCloseFiber: null, + }; + + const closeSharedServer = (server: OpenCodeServerProcess) => { + if (sharedServerState.server === server) { + sharedServerState.server = null; + sharedServerState.binaryPath = null; + } + server.close(); + }; + + const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { + const idleCloseFiber = sharedServerState.idleCloseFiber; + sharedServerState.idleCloseFiber = null; + if (idleCloseFiber !== null) { + yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); + } + }); + + const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( + server: OpenCodeServerProcess, + ) { + yield* cancelIdleCloseFiber(); + const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe( + Effect.andThen( + sharedServerMutex.withPermit( + Effect.sync(() => { + if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { + return; + } + sharedServerState.idleCloseFiber = null; + closeSharedServer(server); + }), + ), + ), + Effect.forkIn(idleFiberScope), + ); + sharedServerState.idleCloseFiber = fiber; + }); + + const acquireSharedServer = (input: { + readonly binaryPath: string; + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + }) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + + const existingServer = sharedServerState.server; + if (existingServer !== null) { + if ( + sharedServerState.binaryPath !== input.binaryPath && + sharedServerState.activeRequests === 0 + ) { + closeSharedServer(existingServer); + } else { + if (sharedServerState.binaryPath !== input.binaryPath) { + yield* Effect.logWarning( + "OpenCode shared server binary path mismatch: requested " + + input.binaryPath + + " but active server uses " + + sharedServerState.binaryPath + + "; reusing existing server because there are active requests", + ); + } + sharedServerState.activeRequests += 1; + return existingServer; + } + } + + const server = yield* Effect.tryPromise({ + try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }), + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.", + cause, + }), + }); + + sharedServerState.server = server; + sharedServerState.binaryPath = input.binaryPath; + sharedServerState.activeRequests = 1; + return server; + }), + ); + + const releaseSharedServer = (server: OpenCodeServerProcess) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + if (sharedServerState.server !== server) { + return; + } + sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); + if (sharedServerState.activeRequests === 0) { + yield* scheduleIdleClose(server); + } + }), + ); + + yield* Effect.addFinalizer(() => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + const server = sharedServerState.server; + sharedServerState.server = null; + sharedServerState.binaryPath = null; + sharedServerState.activeRequests = 0; + if (server !== null) { + server.close(); + } + }), + ), + ); + + const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: OpenCodeModelSelection; + readonly attachments?: ReadonlyArray | undefined; + }) { + const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + if (!parsedModel) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const settings = yield* serverSettingsService.getSettings.pipe( + Effect.map( + (value) => + value.providers?.opencode ?? { + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + }, + ), + Effect.orElseSucceed(() => ({ + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + })), + ); + + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + + const runAgainstServer = (server: Pick) => + Effect.tryPromise({ + try: async () => { + const client = createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(settings.serverUrl.length > 0 && settings.serverPassword + ? { serverPassword: settings.serverPassword } + : {}), + }); + const session = await client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }); + if (!session.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + + const result = await client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(input.modelSelection.options?.agent + ? { agent: input.modelSelection.options.agent } + : {}), + ...(input.modelSelection.options?.variant + ? { variant: input.modelSelection.options.variant } + : {}), + format: { + type: "json_schema", + schema: toJsonSchemaObject(input.outputSchemaJson) as Record, + }, + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }); + const structured = result.data?.info?.structured; + if (structured === undefined) { + throw new Error("OpenCode returned no structured output."); + } + return structured; + }, + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: + cause instanceof Error ? cause.message : "OpenCode text generation request failed.", + cause, + }), + }); + + const structuredOutput = + settings.serverUrl.length > 0 + ? yield* runAgainstServer({ url: settings.serverUrl }) + : yield* Effect.acquireUseRelease( + acquireSharedServer({ + binaryPath: settings.binaryPath, + operation: input.operation, + }), + runAgainstServer, + releaseSharedServer, + ); + + return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "OpenCodeTextGeneration.generateCommitMessage", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "OpenCodeTextGeneration.generatePrContent", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generatePrContent", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runOpenCodeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "OpenCodeTextGeneration.generateBranchName", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "OpenCodeTextGeneration.generateThreadTitle", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); + +export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 5372bc134915..f0d658b69ca4 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -18,6 +18,7 @@ import { } from "../Services/TextGeneration.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; // --------------------------------------------------------------------------- // Internal service tags so both concrete layers can coexist. @@ -31,6 +32,10 @@ class ClaudeTextGen extends Context.Service( "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", ) {} +class OpenCodeTextGen extends Context.Service()( + "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", +) {} + // --------------------------------------------------------------------------- // Routing implementation // --------------------------------------------------------------------------- @@ -38,9 +43,10 @@ class ClaudeTextGen extends Context.Service( const makeRoutingTextGeneration = Effect.gen(function* () { const codex = yield* CodexTextGen; const claude = yield* ClaudeTextGen; + const openCode = yield* OpenCodeTextGen; const route = (provider?: TextGenerationProvider): TextGenerationShape => - provider === "claudeAgent" ? claude : codex; + provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex; return { generateCommitMessage: (input) => @@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect( }), ).pipe(Layer.provide(ClaudeTextGenerationLive)); +const InternalOpenCodeLayer = Layer.effect( + OpenCodeTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(OpenCodeTextGenerationLive)); + export const RoutingTextGenerationLive = Layer.effect( TextGeneration, makeRoutingTextGeneration, -).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); +).pipe( + Layer.provide(InternalCodexLayer), + Layer.provide(InternalClaudeLayer), + Layer.provide(InternalOpenCodeLayer), +); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 6062d552d95b..2833741fb132 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "@t3tools/contracts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts new file mode 100644 index 000000000000..98691082cf26 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -0,0 +1,486 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { beforeEach, vi } from "vitest"; + +import { ThreadId } from "@t3tools/contracts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { + appendOpenCodeAssistantTextDelta, + makeOpenCodeAdapterLive, + mergeOpenCodeAssistantText, +} from "./OpenCodeAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.make(value); + +const runtimeMock = vi.hoisted(() => { + type MessageEntry = { + info: { + id: string; + role: "user" | "assistant"; + }; + parts: Array; + }; + + const state = { + startCalls: [] as string[], + sessionCreateUrls: [] as string[], + authHeaders: [] as Array, + abortCalls: [] as string[], + closeCalls: [] as string[], + revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, + promptAsyncError: null as Error | null, + closeError: null as Error | null, + messages: [] as MessageEntry[], + subscribedEvents: [] as unknown[], + }; + + return { + state, + reset() { + state.startCalls.length = 0; + state.sessionCreateUrls.length = 0; + state.authHeaders.length = 0; + state.abortCalls.length = 0; + state.closeCalls.length = 0; + state.revertCalls.length = 0; + state.promptAsyncError = null; + state.closeError = null; + state.messages = []; + state.subscribedEvents = []; + }, + }; +}); + +vi.mock("../opencodeRuntime.ts", async () => { + const actual = + await vi.importActual("../opencodeRuntime.ts"); + + return { + ...actual, + startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { + runtimeMock.state.startCalls.push(binaryPath); + return { + url: "http://127.0.0.1:4301", + process: { + once() {}, + }, + close() {}, + }; + }), + connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ + url: serverUrl ?? "http://127.0.0.1:4301", + process: null, + external: Boolean(serverUrl), + close() { + runtimeMock.state.closeCalls.push(serverUrl ?? "http://127.0.0.1:4301"); + if (runtimeMock.state.closeError) { + throw runtimeMock.state.closeError; + } + }, + })), + createOpenCodeSdkClient: vi.fn( + ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ + session: { + create: vi.fn(async () => { + runtimeMock.state.sessionCreateUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return { data: { id: `${baseUrl}/session` } }; + }), + abort: vi.fn(async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.abortCalls.push(sessionID); + }), + promptAsync: vi.fn(async () => { + if (runtimeMock.state.promptAsyncError) { + throw runtimeMock.state.promptAsyncError; + } + }), + messages: vi.fn(async () => ({ data: runtimeMock.state.messages })), + revert: vi.fn( + async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { + runtimeMock.state.revertCalls.push({ + sessionID, + ...(messageID ? { messageID } : {}), + }); + if (!messageID) { + runtimeMock.state.messages = []; + return; + } + + const targetIndex = runtimeMock.state.messages.findIndex( + (entry) => entry.info.id === messageID, + ); + runtimeMock.state.messages = + targetIndex >= 0 + ? runtimeMock.state.messages.slice(0, targetIndex + 1) + : runtimeMock.state.messages; + }, + ), + }, + event: { + subscribe: vi.fn(async () => ({ + stream: (async function* () { + for (const event of runtimeMock.state.subscribedEvents) { + yield event; + } + })(), + })), + }, + }), + ), + }; +}); + +const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { + upsert: () => Effect.void, + getProvider: () => + Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), +}); + +const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), +); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const sleep = (ms: number) => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); + +it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + const session = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-opencode"), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "opencode"); + assert.equal(session.threadId, "thread-opencode"); + assert.deepEqual(runtimeMock.state.startCalls, []); + assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + assert.deepEqual(runtimeMock.state.authHeaders, [ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + ); + + it.effect("stops a configured-server session without trying to own server lifecycle", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-opencode"), + runtimeMode: "full-access", + }); + + yield* adapter.stopSession(asThreadId("thread-opencode")); + + assert.deepEqual(runtimeMock.state.startCalls, []); + assert.deepEqual( + runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), + true, + ); + }), + ); + + it.effect("clears session state when stopAll cleanup fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-stop-all-a"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-stop-all-b"), + runtimeMode: "full-access", + }); + + runtimeMock.state.closeError = new Error("close failed"); + const error = yield* adapter.stopAll().pipe(Effect.flip); + const sessions = yield* adapter.listSessions(); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + assert.equal(error.detail, "Failed to stop 2 OpenCode sessions."); + assert.deepEqual(runtimeMock.state.closeCalls, [ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + assert.deepEqual(sessions, []); + }), + ); + + it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-send-turn-failure"), + runtimeMode: "full-access", + }); + + runtimeMock.state.promptAsyncError = new Error("prompt failed"); + const error = yield* adapter + .sendTurn({ + threadId: asThreadId("thread-send-turn-failure"), + input: "Fix it", + modelSelection: { + provider: "opencode", + model: "openai/gpt-5", + }, + }) + .pipe(Effect.flip); + const sessions = yield* adapter.listSessions(); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag !== "ProviderAdapterRequestError") { + throw new Error("Unexpected error type"); + } + assert.equal(error.detail, "prompt failed"); + assert.equal( + error.message, + "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", + ); + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.status, "ready"); + assert.equal(sessions[0]?.activeTurnId, undefined); + assert.equal(sessions[0]?.lastError, "prompt failed"); + }), + ); + + it.effect("reverts the full thread when rollback removes every assistant turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-rollback-all"); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "full-access", + }); + + runtimeMock.state.messages = [ + { + info: { id: "assistant-1", role: "assistant" }, + parts: [], + }, + { + info: { id: "assistant-2", role: "assistant" }, + parts: [], + }, + ]; + + const snapshot = yield* adapter.rollbackThread(threadId, 2); + + assert.deepEqual(runtimeMock.state.revertCalls, [ + { sessionID: "http://127.0.0.1:9999/session" }, + ]); + assert.deepEqual(snapshot.turns, []); + }), + ); + + it.effect("deduplicates overlapping assistant text deltas after part updates", () => + Effect.sync(() => { + const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); + const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); + const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hello world!"); + + assert.deepEqual( + [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], + ["Hello", " world", "!"], + ); + assert.equal(secondUpdate.latestText, "Hello world!"); + }), + ); + + it.effect("writes provider-native observability records using the session thread id", () => + Effect.gen(function* () { + const nativeEvents: Array<{ + readonly event?: { + readonly provider?: string; + readonly threadId?: string; + readonly providerThreadId?: string; + readonly type?: string; + }; + }> = []; + const nativeThreadIds: Array = []; + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + info: { + id: "msg-missing-session", + role: "assistant", + }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/other-session", + info: { + id: "msg-other-session", + role: "assistant", + }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-native-log", + role: "assistant", + }, + }, + }, + ]; + + const nativeEventLogger = { + filePath: "memory://opencode-native-events", + write: (event: unknown, threadId: ThreadId | null) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + nativeThreadIds.push(threadId ?? null); + return Effect.void; + }, + close: () => Effect.void, + }; + + const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + const session = yield* Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const started = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-native-log"), + runtimeMode: "full-access", + }); + yield* sleep(10); + return started; + }).pipe(Effect.provide(adapterLayer)); + + assert.equal(session.threadId, "thread-native-log"); + assert.equal(nativeEvents.length, 1); + assert.equal( + nativeEvents.some((record) => record.event?.provider === "opencode"), + true, + ); + assert.equal( + nativeEvents.some( + (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session", + ), + true, + ); + assert.equal( + nativeEvents.some((record) => record.event?.threadId === "thread-native-log"), + true, + ); + assert.equal( + nativeEvents.some((record) => record.event?.type === "message.updated"), + true, + ); + assert.equal( + nativeThreadIds.every((threadId) => threadId === "thread-native-log"), + true, + ); + }), + ); + + it.effect("keeps the event pump alive when native event logging fails", () => + Effect.gen(function* () { + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-native-log-failure", + role: "assistant", + }, + }, + }, + ]; + + const nativeEventLogger = { + filePath: "memory://opencode-native-events", + write: () => Effect.die(new Error("native log write failed")), + close: () => Effect.void, + }; + + const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + const sessions = yield* Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-native-log-failure"), + runtimeMode: "full-access", + }); + yield* sleep(10); + return yield* adapter.listSessions(); + }).pipe(Effect.provide(adapterLayer)); + + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.threadId, "thread-native-log-failure"); + assert.deepEqual(runtimeMock.state.closeCalls, []); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts new file mode 100644 index 000000000000..4e3c12ef5dad --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -0,0 +1,1344 @@ +import { randomUUID } from "node:crypto"; + +import { + EventId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + type ToolLifecycleItemType, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { Cause, Effect, Layer, Queue, Stream } from "effect"; +import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { + buildOpenCodePermissionRules, + connectToOpenCodeServer, + createOpenCodeSdkClient, + openCodeQuestionId, + parseOpenCodeModelSlug, + toOpenCodeFileParts, + toOpenCodePermissionReply, + toOpenCodeQuestionAnswers, + type OpenCodeServerConnection, +} from "../opencodeRuntime.ts"; + +const PROVIDER = "opencode" as const; + +interface OpenCodeTurnSnapshot { + readonly id: TurnId; + readonly items: Array; +} + +interface OpenCodeSessionContext { + session: ProviderSession; + readonly client: OpencodeClient; + readonly server: OpenCodeServerConnection; + readonly directory: string; + readonly openCodeSessionId: string; + readonly pendingPermissions: Map; + readonly pendingQuestions: Map; + readonly messageRoleById: Map; + readonly partById: Map; + readonly emittedTextByPartId: Map; + readonly completedAssistantPartIds: Set; + readonly turns: Array; + activeTurnId: TurnId | undefined; + activeAgent: string | undefined; + activeVariant: string | undefined; + stopped: boolean; + readonly eventsAbortController: AbortController; +} + +export interface OpenCodeAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function isProviderAdapterRequestError(cause: unknown): cause is ProviderAdapterRequestError { + return ( + typeof cause === "object" && + cause !== null && + "_tag" in cause && + cause._tag === "ProviderAdapterRequestError" + ); +} + +function buildEventBase(input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly createdAt?: string | undefined; + readonly raw?: unknown; +}): Pick< + ProviderRuntimeEvent, + "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" +> { + return { + eventId: EventId.make(randomUUID()), + provider: PROVIDER, + threadId: input.threadId, + createdAt: input.createdAt ?? nowIso(), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input.raw !== undefined + ? { + raw: { + source: "opencode.sdk.event", + payload: input.raw, + }, + } + : {}), + }; +} + +function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if (normalized.includes("bash") || normalized.includes("command")) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("patch") || + normalized.includes("multiedit") + ) { + return "file_change"; + } + if (normalized.includes("web")) { + return "web_search"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + if (normalized.includes("image")) { + return "image_view"; + } + if ( + normalized.includes("task") || + normalized.includes("agent") || + normalized.includes("subtask") + ) { + return "collab_agent_tool_call"; + } + return "dynamic_tool_call"; +} + +function mapPermissionToRequestType( + permission: string, +): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (permission) { + case "bash": + return "command_execution_approval"; + case "read": + return "file_read_approval"; + case "edit": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function mapPermissionDecision(reply: "once" | "always" | "reject"): string { + switch (reply) { + case "once": + return "accept"; + case "always": + return "acceptForSession"; + case "reject": + default: + return "decline"; + } +} + +function resolveTurnSnapshot( + context: OpenCodeSessionContext, + turnId: TurnId, +): OpenCodeTurnSnapshot { + const existing = context.turns.find((turn) => turn.id === turnId); + if (existing) { + return existing; + } + + const created: OpenCodeTurnSnapshot = { id: turnId, items: [] }; + context.turns.push(created); + return created; +} + +function appendTurnItem( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, + item: unknown, +): void { + if (!turnId) { + return; + } + resolveTurnSnapshot(context, turnId).items.push(item); +} + +function ensureSessionContext( + sessions: ReadonlyMap, + threadId: ThreadId, +): OpenCodeSessionContext { + const session = sessions.get(threadId); + if (!session) { + throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + if (session.stopped) { + throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); + } + return session; +} + +function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray { + return request.questions.map((question, index) => ({ + id: openCodeQuestionId(index, question), + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + ...(question.multiple ? { multiSelect: true } : {}), + })); +} + +function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" { + return part?.type === "reasoning" ? "reasoning_text" : "assistant_text"; +} + +function textFromPart(part: Part): string | undefined { + switch (part.type) { + case "text": + case "reasoning": + return part.text; + default: + return undefined; + } +} + +function commonPrefixLength(left: string, right: string): number { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) { + index += 1; + } + return index; +} + +function suffixPrefixOverlap(text: string, delta: string): number { + const maxLength = Math.min(text.length, delta.length); + for (let length = maxLength; length > 0; length -= 1) { + if (text.endsWith(delta.slice(0, length))) { + return length; + } + } + return 0; +} + +function resolveLatestAssistantText(previousText: string | undefined, nextText: string): string { + if (previousText && previousText.length > nextText.length && previousText.startsWith(nextText)) { + return previousText; + } + return nextText; +} + +export function mergeOpenCodeAssistantText( + previousText: string | undefined, + nextText: string, +): { + readonly latestText: string; + readonly deltaToEmit: string; +} { + const latestText = resolveLatestAssistantText(previousText, nextText); + return { + latestText, + deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), + }; +} + +export function appendOpenCodeAssistantTextDelta( + previousText: string, + delta: string, +): { + readonly nextText: string; + readonly deltaToEmit: string; +} { + const deltaToEmit = delta.slice(suffixPrefixOverlap(previousText, delta)); + return { + nextText: previousText + deltaToEmit, + deltaToEmit, + }; +} + +function isoFromEpochMs(value: number | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return new Date(value).toISOString(); +} + +function messageRoleForPart( + context: OpenCodeSessionContext, + part: Pick, +): "assistant" | "user" | undefined { + const known = context.messageRoleById.get(part.messageID); + if (known) { + return known; + } + return part.type === "tool" ? "assistant" : undefined; +} + +function detailFromToolPart(part: Extract): string | undefined { + switch (part.state.status) { + case "completed": + return part.state.output; + case "error": + return part.state.error; + case "running": + return part.state.title; + default: + return undefined; + } +} + +function toolStateCreatedAt(part: Extract): string | undefined { + switch (part.state.status) { + case "running": + return isoFromEpochMs(part.state.time.start); + case "completed": + case "error": + return isoFromEpochMs(part.state.time.end); + default: + return undefined; + } +} + +function sessionErrorMessage(error: unknown): string { + if (!error || typeof error !== "object") { + return "OpenCode session failed."; + } + const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null; + const message = data && "message" in data ? data.message : null; + return typeof message === "string" && message.trim().length > 0 + ? message + : "OpenCode session failed."; +} + +function updateProviderSession( + context: OpenCodeSessionContext, + patch: Partial, + options?: { + readonly clearActiveTurnId?: boolean; + readonly clearLastError?: boolean; + }, +): ProviderSession { + const nextSession = { + ...context.session, + ...patch, + updatedAt: nowIso(), + } as ProviderSession & Record; + const mutableSession = nextSession as Record; + if (options?.clearActiveTurnId) { + delete mutableSession.activeTurnId; + } + if (options?.clearLastError) { + delete mutableSession.lastError; + } + context.session = nextSession; + return nextSession; +} + +async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise { + context.stopped = true; + context.eventsAbortController.abort(); + try { + await context.client.session + .abort({ sessionID: context.openCodeSessionId }) + .catch(() => undefined); + } catch {} + context.server.close(); +} + +export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { + return Layer.effect( + OpenCodeAdapter, + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + const services = yield* Effect.context(); + const nativeEventLogger = + _options?.nativeEventLogger ?? + (_options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(_options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const emitPromise = (event: ProviderRuntimeEvent) => + emit(event).pipe(Effect.runPromiseWith(services)); + const writeNativeEventPromise = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => + (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void).pipe( + Effect.runPromiseWith(services), + ); + const writeNativeEventBestEffort = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => writeNativeEventPromise(threadId, event).catch(() => undefined); + + const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => { + if (context.stopped) { + return; + } + context.stopped = true; + sessions.delete(context.session.threadId); + context.server.close(); + const turnId = context.activeTurnId; + void emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "runtime.error", + payload: { + message, + class: "transport_error", + }, + }).catch(() => undefined); + void emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "session.exited", + payload: { + reason: message, + recoverable: false, + exitKind: "error", + }, + }).catch(() => undefined); + }; + + /** Emit content.delta and item.completed events for an assistant text part. */ + const emitAssistantTextDelta = async ( + context: OpenCodeSessionContext, + part: Part, + turnId: TurnId | undefined, + raw: unknown, + ): Promise => { + const text = textFromPart(part); + if (text === undefined) { + return; + } + const previousText = context.emittedTextByPartId.get(part.id); + const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); + context.emittedTextByPartId.set(part.id, latestText); + if (latestText !== text) { + context.partById.set( + part.id, + (part.type === "text" || part.type === "reasoning" + ? { ...part, text: latestText } + : part) satisfies Part, + ); + } + if (deltaToEmit.length > 0) { + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: + part.type === "text" || part.type === "reasoning" + ? isoFromEpochMs(part.time?.start) + : undefined, + raw, + }), + type: "content.delta", + payload: { + streamKind: resolveTextStreamKind(part), + delta: deltaToEmit, + }, + }); + } + + if ( + part.type === "text" && + part.time?.end !== undefined && + !context.completedAssistantPartIds.has(part.id) + ) { + context.completedAssistantPartIds.add(part.id); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: isoFromEpochMs(part.time.end), + raw, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(latestText.length > 0 ? { detail: latestText } : {}), + }, + }); + } + }; + + const startEventPump = (context: OpenCodeSessionContext) => { + void (async () => { + try { + const subscription = await context.client.event.subscribe(undefined, { + signal: context.eventsAbortController.signal, + }); + + for await (const event of subscription.stream) { + const payloadSessionId = + "properties" in event + ? (event.properties as { sessionID?: unknown }).sessionID + : undefined; + if (payloadSessionId !== context.openCodeSessionId) { + continue; + } + + const turnId = context.activeTurnId; + await writeNativeEventBestEffort(context.session.threadId, { + observedAt: nowIso(), + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.openCodeSessionId, + type: event.type, + ...(turnId ? { turnId } : {}), + payload: event, + }, + }); + + switch (event.type) { + case "message.updated": { + context.messageRoleById.set(event.properties.info.id, event.properties.info.role); + if (event.properties.info.role === "assistant") { + for (const part of context.partById.values()) { + if (part.messageID !== event.properties.info.id) { + continue; + } + await emitAssistantTextDelta(context, part, turnId, event); + } + } + break; + } + + case "message.removed": { + context.messageRoleById.delete(event.properties.messageID); + break; + } + + case "message.part.delta": { + const existingPart = context.partById.get(event.properties.partID); + if (!existingPart) { + break; + } + const role = messageRoleForPart(context, existingPart); + if (role !== "assistant") { + break; + } + const streamKind = resolveTextStreamKind(existingPart); + const delta = event.properties.delta; + if (delta.length === 0) { + break; + } + const previousText = + context.emittedTextByPartId.get(event.properties.partID) ?? + textFromPart(existingPart) ?? + ""; + const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta( + previousText, + delta, + ); + if (deltaToEmit.length === 0) { + break; + } + context.emittedTextByPartId.set(event.properties.partID, nextText); + if (existingPart.type === "text" || existingPart.type === "reasoning") { + context.partById.set(event.properties.partID, { + ...existingPart, + text: nextText, + }); + } + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.properties.partID, + raw: event, + }), + type: "content.delta", + payload: { + streamKind, + delta: deltaToEmit, + }, + }); + break; + } + + case "message.part.updated": { + const part = event.properties.part; + context.partById.set(part.id, part); + const messageRole = messageRoleForPart(context, part); + + if (messageRole === "assistant") { + await emitAssistantTextDelta(context, part, turnId, event); + } + + if (part.type === "tool") { + const itemType = toToolLifecycleItemType(part.tool); + const title = + part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + const detail = detailFromToolPart(part); + const payload = { + itemType, + ...(part.state.status === "error" + ? { status: "failed" as const } + : part.state.status === "completed" + ? { status: "completed" as const } + : { status: "inProgress" as const }), + ...(title ? { title } : {}), + ...(detail ? { detail } : {}), + data: { + tool: part.tool, + state: part.state, + }, + }; + const runtimeEvent: ProviderRuntimeEvent = { + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.callID, + createdAt: toolStateCreatedAt(part), + raw: event, + }), + type: + part.state.status === "pending" + ? "item.started" + : part.state.status === "completed" || part.state.status === "error" + ? "item.completed" + : "item.updated", + payload, + }; + appendTurnItem(context, turnId, part); + await emitPromise(runtimeEvent); + } + break; + } + + case "permission.asked": { + context.pendingPermissions.set(event.properties.id, event.properties); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(event.properties.permission), + detail: + event.properties.patterns.length > 0 + ? event.properties.patterns.join("\n") + : event.properties.permission, + args: event.properties.metadata, + }, + }); + break; + } + + case "permission.replied": { + context.pendingPermissions.delete(event.properties.requestID); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: mapPermissionDecision(event.properties.reply), + }, + }); + break; + } + + case "question.asked": { + context.pendingQuestions.set(event.properties.id, event.properties); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "user-input.requested", + payload: { + questions: normalizeQuestionRequest(event.properties), + }, + }); + break; + } + + case "question.replied": { + const request = context.pendingQuestions.get(event.properties.requestID); + context.pendingQuestions.delete(event.properties.requestID); + const answers = Object.fromEntries( + (request?.questions ?? []).map((question, index) => [ + openCodeQuestionId(index, question), + event.properties.answers[index]?.join(", ") ?? "", + ]), + ); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers }, + }); + break; + } + + case "question.rejected": { + context.pendingQuestions.delete(event.properties.requestID); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers: {} }, + }); + break; + } + + case "session.status": { + if (event.properties.status.type === "busy") { + updateProviderSession(context, { status: "running", activeTurnId: turnId }); + } + + if (event.properties.status.type === "retry") { + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "runtime.warning", + payload: { + message: event.properties.status.message, + detail: event.properties.status, + }, + }); + break; + } + + if (event.properties.status.type === "idle" && turnId) { + context.activeTurnId = undefined; + updateProviderSession( + context, + { status: "ready" }, + { clearActiveTurnId: true }, + ); + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + } + break; + } + + case "session.error": { + const message = sessionErrorMessage(event.properties.error); + const activeTurnId = context.activeTurnId; + context.activeTurnId = undefined; + updateProviderSession( + context, + { + status: "error", + lastError: message, + }, + { clearActiveTurnId: true }, + ); + if (activeTurnId) { + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: activeTurnId, + raw: event, + }), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: message, + }, + }); + } + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, raw: event }), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.properties.error, + }, + }); + break; + } + + default: + break; + } + } + } catch (error) { + if (context.eventsAbortController.signal.aborted || context.stopped) { + return; + } + emitUnexpectedExit( + context, + error instanceof Error ? error.message : "OpenCode event stream failed.", + ); + } + })(); + + context.server.process?.once("exit", (code, signal) => { + if (context.stopped) { + return; + } + emitUnexpectedExit( + context, + `OpenCode server exited unexpectedly (${signal ?? code ?? "unknown"}).`, + ); + }); + }; + + const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to read OpenCode settings.", + cause, + }), + ), + ); + const binaryPath = settings.providers.opencode.binaryPath; + const serverUrl = settings.providers.opencode.serverUrl; + const serverPassword = settings.providers.opencode.serverPassword; + const directory = input.cwd ?? serverConfig.cwd; + const existing = sessions.get(input.threadId); + if (existing) { + yield* Effect.tryPromise({ + try: () => stopOpenCodeContext(existing), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to stop existing OpenCode session.", + cause, + }), + }); + sessions.delete(input.threadId); + } + + const started = yield* Effect.tryPromise({ + try: async () => { + const server = await connectToOpenCodeServer({ binaryPath, serverUrl }); + const client = createOpenCodeSdkClient({ + baseUrl: server.url, + directory, + ...(server.external && serverPassword ? { serverPassword } : {}), + }); + const openCodeSession = await client.session.create({ + title: `T3 Code ${input.threadId}`, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }); + if (!openCodeSession.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + return { server, client, openCodeSession: openCodeSession.data }; + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: + cause instanceof Error ? cause.message : "Failed to start OpenCode session.", + cause, + }), + }); + + // Guard against a concurrent startSession call that may have raced + // and already inserted a session while we were awaiting async work. + const raceWinner = sessions.get(input.threadId); + if (raceWinner) { + // Another call won the race – clean up the session we just created + // (including the remote SDK session) and return the existing one. + yield* Effect.tryPromise({ + try: () => + started.client.session + .abort({ sessionID: started.openCodeSession.id }) + .catch(() => undefined), + catch: () => undefined, + }).pipe(Effect.ignore); + started.server.close(); + return raceWinner.session; + } + + const createdAt = nowIso(); + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: directory, + ...(input.modelSelection ? { model: input.modelSelection.model } : {}), + threadId: input.threadId, + createdAt, + updatedAt: createdAt, + }; + + const context: OpenCodeSessionContext = { + session, + client: started.client, + server: started.server, + directory, + openCodeSessionId: started.openCodeSession.id, + pendingPermissions: new Map(), + pendingQuestions: new Map(), + partById: new Map(), + emittedTextByPartId: new Map(), + messageRoleById: new Map(), + completedAssistantPartIds: new Set(), + turns: [], + activeTurnId: undefined, + activeAgent: undefined, + activeVariant: undefined, + stopped: false, + eventsAbortController: new AbortController(), + }; + sessions.set(input.threadId, context); + startEventPump(context); + + yield* emit({ + ...buildEventBase({ threadId: input.threadId }), + type: "session.started", + payload: { + message: "OpenCode session started", + }, + }); + yield* emit({ + ...buildEventBase({ threadId: input.threadId }), + type: "thread.started", + payload: { + providerThreadId: started.openCodeSession.id, + }, + }); + + return session; + }, + ); + + const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const context = ensureSessionContext(sessions, input.threadId); + const turnId = TurnId.make(`opencode-turn-${randomUUID()}`); + const modelSelection = + input.modelSelection ?? + (context.session.model + ? { provider: PROVIDER, model: context.session.model } + : undefined); + const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); + if (!parsedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const text = input.input?.trim(); + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + if ((!text || text.length === 0) && fileParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode turns require text input or at least one attachment.", + }); + } + + const agent = + input.modelSelection?.provider === PROVIDER + ? input.modelSelection.options?.agent + : undefined; + const variant = + input.modelSelection?.provider === PROVIDER + ? input.modelSelection.options?.variant + : undefined; + + context.activeTurnId = turnId; + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = variant; + updateProviderSession( + context, + { + status: "running", + activeTurnId: turnId, + model: modelSelection?.model ?? context.session.model, + }, + { clearLastError: true }, + ); + + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(variant ? { effort: variant } : {}), + }, + }); + + const promptExit = yield* Effect.exit( + Effect.tryPromise({ + try: async () => { + await context.client.session.promptAsync({ + sessionID: context.openCodeSessionId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.promptAsync", + detail: cause instanceof Error ? cause.message : "Failed to send OpenCode turn.", + cause, + }), + }), + ); + if (promptExit._tag === "Failure") { + const failure = Cause.squash(promptExit.cause); + const requestError = isProviderAdapterRequestError(failure) + ? failure + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.promptAsync", + detail: + failure instanceof Error ? failure.message : "Failed to send OpenCode turn.", + cause: failure, + }); + const failureMessage = requestError.detail; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: failureMessage, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.aborted", + payload: { + reason: failureMessage, + }, + }); + return yield* requestError; + } + + return { + threadId: input.threadId, + turnId, + }; + }); + + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { + const context = ensureSessionContext(sessions, threadId); + yield* Effect.tryPromise({ + try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: cause instanceof Error ? cause.message : "Failed to abort OpenCode turn.", + cause, + }), + }); + if (turnId ?? context.activeTurnId) { + yield* emit({ + ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + } + }, + ); + + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( + "respondToRequest", + )(function* (threadId, requestId, decision) { + const context = ensureSessionContext(sessions, threadId); + if (!context.pendingPermissions.has(requestId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + yield* Effect.tryPromise({ + try: () => + context.client.permission.reply({ + requestID: requestId, + reply: toOpenCodePermissionReply(decision), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: + cause instanceof Error + ? cause.message + : "Failed to submit OpenCode permission reply.", + cause, + }), + }); + }); + + const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( + "respondToUserInput", + )(function* (threadId, requestId, answers) { + const context = ensureSessionContext(sessions, threadId); + const request = context.pendingQuestions.get(requestId); + if (!request) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + yield* Effect.tryPromise({ + try: () => + context.client.question.reply({ + requestID: requestId, + answers: toOpenCodeQuestionAnswers(request, answers), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: cause instanceof Error ? cause.message : "Failed to submit OpenCode answers.", + cause, + }), + }); + }); + + const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + yield* Effect.tryPromise({ + try: () => stopOpenCodeContext(context), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode session.", + cause, + }), + }); + sessions.delete(threadId); + yield* emit({ + ...buildEventBase({ threadId }), + type: "session.exited", + payload: { + reason: "Session stopped.", + recoverable: false, + exitKind: "graceful", + }, + }); + }, + ); + + const listSessions: OpenCodeAdapterShape["listSessions"] = () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)); + + const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* Effect.tryPromise({ + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.messages", + detail: cause instanceof Error ? cause.message : "Failed to read OpenCode thread.", + cause, + }), + }); + + const turns = (messages.data ?? []) + .filter((entry) => entry.info.role === "assistant") + .map((entry) => ({ + id: TurnId.make(entry.info.id), + items: [entry.info, ...entry.parts], + })); + + return { + threadId, + turns, + }; + }, + ); + + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, numTurns) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* Effect.tryPromise({ + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.messages", + detail: + cause instanceof Error ? cause.message : "Failed to inspect OpenCode thread.", + cause, + }), + }); + + const assistantMessages = (messages.data ?? []).filter( + (entry) => entry.info.role === "assistant", + ); + const targetIndex = assistantMessages.length - numTurns - 1; + const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; + yield* Effect.tryPromise({ + try: () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + ...(target ? { messageID: target.info.id } : {}), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.revert", + detail: cause instanceof Error ? cause.message : "Failed to revert OpenCode turn.", + cause, + }), + }); + + return yield* readThread(threadId); + }, + ); + + const stopAll: OpenCodeAdapterShape["stopAll"] = () => + Effect.tryPromise({ + try: async () => { + const contexts = [...sessions.values()]; + sessions.clear(); + const results = await Promise.allSettled( + contexts.map((context) => stopOpenCodeContext(context)), + ); + const errors = results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason); + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError( + errors, + `Failed to stop ${errors.length} OpenCode sessions.`, + ); + } + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: "*", + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.", + cause, + }), + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies OpenCodeAdapterShape; + }), + ); +} + +export const OpenCodeAdapterLive = makeOpenCodeAdapterLive(); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts new file mode 100644 index 000000000000..cf3d588d9db0 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { beforeEach, vi } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts"; + +const runtimeMock = vi.hoisted(() => { + const state = { + runVersionError: null as Error | null, + inventoryError: null as Error | null, + }; + + return { + state, + reset() { + state.runVersionError = null; + state.inventoryError = null; + }, + }; +}); + +vi.mock("../opencodeRuntime.ts", async () => { + const actual = + await vi.importActual("../opencodeRuntime.ts"); + + return { + ...actual, + runOpenCodeCommand: vi.fn(async () => { + if (runtimeMock.state.runVersionError) { + throw runtimeMock.state.runVersionError; + } + return { stdout: "opencode 1.0.0\n", stderr: "", code: 0 }; + }), + connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ + url: serverUrl ?? "http://127.0.0.1:4301", + process: null, + external: Boolean(serverUrl), + close() {}, + })), + createOpenCodeSdkClient: vi.fn(() => ({})), + loadOpenCodeInventory: vi.fn(async () => { + if (runtimeMock.state.inventoryError) { + throw runtimeMock.state.inventoryError; + } + return { + providerList: { connected: [], all: [] }, + agents: [], + }; + }), + flattenOpenCodeModels: vi.fn(() => []), + }; +}); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const makeTestLayer = (settingsOverrides?: Parameters[0]) => + makeOpenCodeProviderLive().pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)), + Layer.provideMerge(NodeServices.layer), + ); + +it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { + it.effect("shows a codex-style missing binary message", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.message, "OpenCode CLI (`opencode`) is not installed or not on PATH."); + }), + ); + + it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); + }), + ); +}); + +it.layer( + makeTestLayer({ + providers: { + opencode: { + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), +)("OpenCodeProviderLive with configured server URL", (it) => { + it.effect("surfaces a friendly auth error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("401 Unauthorized"); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect("surfaces a friendly connection error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error( + "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", + ); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal( + snapshot.message, + "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts new file mode 100644 index 000000000000..f19694125723 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -0,0 +1,342 @@ +import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts"; +import { Cause, Effect, Equal, Layer, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, +} from "../providerSnapshot.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { + connectToOpenCodeServer, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + createOpenCodeSdkClient, + flattenOpenCodeModels, + loadOpenCodeInventory, + runOpenCodeCommand, +} from "../opencodeRuntime.ts"; + +const PROVIDER = "opencode" as const; + +class OpenCodeProbePromiseError extends Error { + override readonly cause: unknown; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)); + this.cause = cause; + this.name = "OpenCodeProbePromiseError"; + } +} + +function toOpenCodeProbeError(cause: unknown): OpenCodeProbePromiseError { + return new OpenCodeProbePromiseError(cause); +} + +function normalizedErrorMessage(cause: unknown): string | undefined { + if (!(cause instanceof Error)) { + return undefined; + } + + const message = cause.message.trim(); + if (message.length === 0) { + return undefined; + } + if ( + message === "An error occurred in Effect.tryPromise" || + message === "An error occurred in Effect.try" + ) { + return undefined; + } + return message; +} + +function formatOpenCodeProbeError(input: { + readonly cause: unknown; + readonly isExternalServer: boolean; + readonly serverUrl: string; +}): { readonly installed: boolean; readonly message: string } { + const lower = input.cause instanceof Error ? input.cause.message.toLowerCase() : ""; + const detail = normalizedErrorMessage(input.cause); + + if (input.isExternalServer) { + if ( + lower.includes("401") || + lower.includes("403") || + lower.includes("unauthorized") || + lower.includes("forbidden") + ) { + return { + installed: true, + message: "OpenCode server rejected authentication. Check the server URL and password.", + }; + } + + if ( + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("fetch failed") || + lower.includes("networkerror") || + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("socket hang up") + ) { + return { + installed: true, + message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, + }; + } + + return { + installed: true, + message: detail ?? "Failed to connect to the configured OpenCode server.", + }; + } + + if (input.cause instanceof Error && isCommandMissingCause(input.cause)) { + return { + installed: false, + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + }; + } + + if (lower.includes("quarantine")) { + return { + installed: true, + message: + "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", + }; + } + + if (lower.includes("invalid code signature") || lower.includes("corrupted")) { + return { + installed: true, + message: + "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", + }; + } + + return { + installed: true, + message: detail + ? `Failed to execute OpenCode CLI health check: ${detail}` + : "Failed to execute OpenCode CLI health check.", + }; +} + +const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => { + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + [], + PROVIDER, + openCodeSettings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: + openCodeSettings.serverUrl.trim().length > 0 + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode provider status has not been checked in this session yet.", + }, + }); +}; + +export function checkOpenCodeProviderStatus(input: { + readonly settings: OpenCodeSettings; + readonly cwd: string; +}): Effect.Effect { + const checkedAt = new Date().toISOString(); + const customModels = input.settings.customModels; + const isExternalServer = input.settings.serverUrl.trim().length > 0; + + const fallback = (cause: unknown, version: string | null = null) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + serverUrl: input.settings.serverUrl, + }); + return buildServerProvider({ + provider: PROVIDER, + enabled: input.settings.enabled, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: failure.installed, + version, + status: "error", + auth: { status: "unknown" }, + message: failure.message, + }, + }); + }; + + return Effect.gen(function* () { + if (!input.settings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + let version: string | null = null; + if (!isExternalServer) { + const versionExit = yield* Effect.exit( + Effect.tryPromise({ + try: () => + runOpenCodeCommand({ + binaryPath: input.settings.binaryPath, + args: ["--version"], + }), + catch: toOpenCodeProbeError, + }), + ); + if (versionExit._tag === "Failure") { + return fallback(Cause.squash(versionExit.cause)); + } + version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + } + + const inventoryExit = yield* Effect.exit( + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => + connectToOpenCodeServer({ + binaryPath: input.settings.binaryPath, + serverUrl: input.settings.serverUrl, + }), + catch: toOpenCodeProbeError, + }), + (server) => + Effect.tryPromise({ + try: async () => { + const client = createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(isExternalServer && input.settings.serverPassword + ? { serverPassword: input.settings.serverPassword } + : {}), + }); + return await loadOpenCodeInventory(client); + }, + catch: toOpenCodeProbeError, + }), + (server) => Effect.sync(() => server.close()), + ), + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version); + } + + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value), + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const connectedCount = inventoryExit.value.providerList.connected.length; + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", + }, + }); + }); +} + +export function makeOpenCodeProviderLive() { + return Layer.effect( + OpenCodeProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + + const getProviderSettings = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.opencode), + ); + + return yield* makeManagedServerProvider({ + getSettings: getProviderSettings.pipe(Effect.orDie), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.opencode), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + initialSnapshot: makePendingOpenCodeProvider, + checkProvider: getProviderSettings.pipe( + Effect.flatMap((settings) => + checkOpenCodeProviderStatus({ + settings, + cwd: serverConfig.cwd, + }), + ), + ), + }); + }), + ); +} + +export const OpenCodeProviderLive = makeOpenCodeProviderLive(); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 953a49fe2ffe..1b9b5561d634 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -47,6 +49,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; +const fakeOpenCodeAdapter: OpenCodeAdapterShape = { + provider: "opencode", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -54,6 +73,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), ), ), NodeServices.layer, @@ -66,11 +86,13 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeAgent"); + const openCode = yield* registry.getByProvider("opencode"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); + assert.equal(openCode, fakeOpenCodeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "opencode"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index b6c987c64c37..2026923b5bee 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -17,6 +17,7 @@ import { } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter]; + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* OpenCodeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 170521d2d273..6d274a14f4e2 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -31,6 +31,7 @@ import { } from "./CodexProvider.ts"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; @@ -38,6 +39,19 @@ import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +const fakeOpenCodeSnapshot: ServerProvider = { + provider: "opencode", + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: "OpenCode test stub", +}; function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ @@ -596,12 +610,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ), ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, - ), - ).pipe(Scope.provide(scope)); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; @@ -630,6 +641,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( prefix: "t3-provider-registry-", }), ), + Layer.provideMerge( + Layer.succeed(OpenCodeProvider, { + getSnapshot: Effect.succeed(fakeOpenCodeSnapshot), + refresh: Effect.succeed(fakeOpenCodeSnapshot), + streamChanges: Stream.empty, + }), + ), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { const joined = args.join(" "); @@ -646,12 +664,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ), ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, - ), - ).pipe(Scope.provide(scope)); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 62207ff079e1..c5b08ec4dcdb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -9,10 +9,13 @@ import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "eff import { ServerConfig } from "../../config.ts"; import { ClaudeProviderLive } from "./ClaudeProvider.ts"; import { CodexProviderLive } from "./CodexProvider.ts"; +import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts"; import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; import type { CodexProviderShape } from "../Services/CodexProvider.ts"; import { CodexProvider } from "../Services/CodexProvider.ts"; +import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; import { hydrateCachedProvider, @@ -26,10 +29,14 @@ import { const loadProviders = ( codexProvider: CodexProviderShape, claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { - concurrency: "unbounded", - }); + openCodeProvider: OpenCodeProviderShape, +): Effect.Effect => + Effect.all( + [codexProvider.getSnapshot, claudeProvider.getSnapshot, openCodeProvider.getSnapshot], + { + concurrency: "unbounded", + }, + ); export const haveProvidersChanged = ( previousProviders: ReadonlyArray, @@ -41,6 +48,7 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const openCodeProvider = yield* OpenCodeProvider; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -48,7 +56,7 @@ export const ProviderRegistryLive = Layer.effect( PubSub.unbounded>(), PubSub.shutdown, ); - const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider); + const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider, openCodeProvider); const cachePathByProvider = new Map( PROVIDER_CACHE_IDS.map( (provider) => @@ -156,6 +164,10 @@ export const ProviderRegistryLive = Layer.effect( return yield* claudeProvider.refresh.pipe( Effect.flatMap((nextProvider) => syncProvider(nextProvider)), ); + case "opencode": + return yield* openCodeProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ); default: return yield* Effect.all( [ @@ -165,6 +177,9 @@ export const ProviderRegistryLive = Layer.effect( claudeProvider.refresh.pipe( Effect.flatMap((nextProvider) => syncProvider(nextProvider)), ), + openCodeProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ), ], { concurrency: "unbounded", @@ -180,6 +195,9 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => syncProvider(provider), ).pipe(Effect.forkScoped); + yield* Stream.runForEach(openCodeProvider.streamChanges, (provider) => + syncProvider(provider), + ).pipe(Effect.forkScoped); return { getProviders: Ref.get(providersRef), @@ -193,4 +211,8 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); +).pipe( + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(OpenCodeProviderLive), +); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b54976589f80..d02578b996be 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -333,6 +333,62 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () ); const routing = makeProviderServiceLayer(); + +it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const canonicalEvents: ProviderRuntimeEvent[] = []; + const canonicalThreadIds: Array = []; + const registry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(codex.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = makeProviderServiceLive({ + canonicalEventLogger: { + filePath: "memory://provider-canonical-events", + write: (event, threadId) => { + canonicalEvents.push(event as ProviderRuntimeEvent); + canonicalThreadIds.push(threadId ?? null); + return Effect.void; + }, + close: () => Effect.void, + }, + }).pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + yield* Effect.gen(function* () { + yield* ProviderService; + yield* sleep(10); + codex.emit({ + eventId: asEventId("evt-canonical-thread-segment"), + provider: "codex", + threadId: asThreadId("thread-canonical-thread-segment"), + createdAt: new Date().toISOString(), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + yield* sleep(20); + }).pipe(Effect.provide(providerLayer)); + + assert.equal(canonicalEvents.length, 1); + assert.equal(canonicalEvents[0]?.threadId, "thread-canonical-thread-segment"); + assert.deepEqual(canonicalThreadIds, ["thread-canonical-thread-segment"]); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 20479b238c73..a38a24655fee 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -161,7 +161,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Effect.succeed(event).pipe( Effect.tap((canonicalEvent) => - canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void, + canonicalEventLogger + ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) + : Effect.void, ), Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), Effect.asVoid, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index b9d2439eefad..660446a6223b 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -24,7 +24,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/OpenCodeAdapter.ts b/apps/server/src/provider/Services/OpenCodeAdapter.ts new file mode 100644 index 000000000000..ad5660022bf3 --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeAdapter.ts @@ -0,0 +1,12 @@ +import { Context } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface OpenCodeAdapterShape extends ProviderAdapterShape { + readonly provider: "opencode"; +} + +export class OpenCodeAdapter extends Context.Service()( + "t3/provider/Services/OpenCodeAdapter", +) {} diff --git a/apps/server/src/provider/Services/OpenCodeProvider.ts b/apps/server/src/provider/Services/OpenCodeProvider.ts new file mode 100644 index 000000000000..a799830eec4f --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeProvider.ts @@ -0,0 +1,9 @@ +import { Context } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider.ts"; + +export interface OpenCodeProviderShape extends ServerProviderShape {} + +export class OpenCodeProvider extends Context.Service()( + "t3/provider/Services/OpenCodeProvider", +) {} diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts new file mode 100644 index 000000000000..0ea63f8d5348 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; + +import { describe, it, vi } from "vitest"; + +const childProcessMock = vi.hoisted(() => ({ + execFileSync: vi.fn((command: string, args: ReadonlyArray) => { + if (command === "which" && args[0] === "opencode") { + return "/opt/homebrew/bin/opencode\n"; + } + return ""; + }), + spawn: vi.fn(), +})); + +vi.mock("node:child_process", () => childProcessMock); + +describe("resolveOpenCodeBinaryPath", () => { + it("returns absolute binary paths without PATH lookup", async () => { + const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); + + assert.equal(resolveOpenCodeBinaryPath("/usr/local/bin/opencode"), "/usr/local/bin/opencode"); + assert.equal(childProcessMock.execFileSync.mock.calls.length, 0); + }); + + it("resolves command names through PATH", async () => { + const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); + + assert.equal(resolveOpenCodeBinaryPath("opencode"), "/opt/homebrew/bin/opencode"); + assert.deepEqual(childProcessMock.execFileSync.mock.calls[0], [ + "which", + ["opencode"], + { + encoding: "utf8", + timeout: 3_000, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts new file mode 100644 index 000000000000..4778f6eac91b --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -0,0 +1,573 @@ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import * as FS from "node:fs"; +import { createServer, type AddressInfo } from "node:net"; +import * as OS from "node:os"; +import * as Path from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { + ChatAttachment, + ModelCapabilities, + ProviderApprovalDecision, + RuntimeMode, + ServerProviderModel, +} from "@t3tools/contracts"; +import { + createOpencodeClient, + type Agent, + type FilePartInput, + type OpencodeClient, + type PermissionRuleset, + type ProviderListResponse, + type QuestionAnswer, + type QuestionRequest, +} from "@opencode-ai/sdk/v2"; + +const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; +const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; +const DEFAULT_HOSTNAME = "127.0.0.1"; + +const OPENAI_VARIANTS = ["none", "minimal", "low", "medium", "high", "xhigh"]; +const ANTHROPIC_VARIANTS = ["high", "max"]; +const GOOGLE_VARIANTS = ["low", "high"]; + +export const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + +export interface OpenCodeServerProcess { + readonly url: string; + readonly process: ChildProcess; + close(): void; +} + +export interface OpenCodeServerConnection { + readonly url: string; + readonly process: ChildProcess | null; + readonly external: boolean; + close(): void; +} + +function buildOpenCodeBasicAuthorizationHeader(password: string): string { + return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; +} + +export interface OpenCodeCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface OpenCodeInventory { + readonly providerList: ProviderListResponse; + readonly agents: ReadonlyArray; +} + +export interface ParsedOpenCodeModelSlug { + readonly providerID: string; + readonly modelID: string; +} + +function titleCaseSlug(value: string): string { + return value + .split(/[-_/]+/) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function parseServerUrlFromOutput(output: string): string | null { + for (const line of output.split("\n")) { + if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { + continue; + } + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + return match?.[1] ?? null; + } + return null; +} + +function isPrimaryAgent(agent: Agent): boolean { + return !agent.hidden && (agent.mode === "primary" || agent.mode === "all"); +} + +function inferVariantValues(providerID: string): ReadonlyArray { + if (providerID === "anthropic") { + return ANTHROPIC_VARIANTS; + } + if (providerID === "openai" || providerID === "opencode") { + return OPENAI_VARIANTS; + } + if (providerID.startsWith("google")) { + return GOOGLE_VARIANTS; + } + return []; +} + +function inferDefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + if (variants.length === 1) { + return variants[0]; + } + if (providerID === "anthropic" || providerID.startsWith("google")) { + return variants.includes("high") ? "high" : undefined; + } + if (providerID === "openai" || providerID === "opencode") { + return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; + } + return undefined; +} + +function buildVariantOptions( + providerID: string, + model: ProviderListResponse["all"][number]["models"][string], +) { + const variantValues = Object.keys(model.variants ?? {}); + const resolvedValues = + variantValues.length > 0 ? variantValues : [...inferVariantValues(providerID)]; + const defaultVariant = inferDefaultVariant(providerID, resolvedValues); + + return resolvedValues.map((value) => { + const option: { value: string; label: string; isDefault?: boolean } = { + value, + label: titleCaseSlug(value), + }; + if (defaultVariant === value) { + option.isDefault = true; + } + return option; + }); +} + +function buildAgentOptions(agents: ReadonlyArray) { + const primaryAgents = agents.filter(isPrimaryAgent); + const defaultAgent = + primaryAgents.find((agent) => agent.name === "build")?.name ?? + primaryAgents[0]?.name ?? + undefined; + return primaryAgents.map((agent) => { + const option: { value: string; label: string; isDefault?: boolean } = { + value: agent.name, + label: titleCaseSlug(agent.name), + }; + if (defaultAgent === agent.name) { + option.isDefault = true; + } + return option; + }); +} + +function openCodeCapabilitiesForModel(input: { + readonly providerID: string; + readonly model: ProviderListResponse["all"][number]["models"][string]; + readonly agents: ReadonlyArray; +}): ModelCapabilities { + const variantOptions = buildVariantOptions(input.providerID, input.model); + const agentOptions = buildAgentOptions(input.agents); + return { + ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ...(variantOptions.length > 0 ? { variantOptions } : {}), + ...(agentOptions.length > 0 ? { agentOptions } : {}), + }; +} + +export function parseOpenCodeModelSlug( + slug: string | null | undefined, +): ParsedOpenCodeModelSlug | null { + if (typeof slug !== "string") { + return null; + } + + const trimmed = slug.trim(); + const separator = trimmed.indexOf("/"); + if (separator <= 0 || separator === trimmed.length - 1) { + return null; + } + + return { + providerID: trimmed.slice(0, separator), + modelID: trimmed.slice(separator + 1), + }; +} + +export function toOpenCodeModelSlug(providerID: string, modelID: string): string { + return `${providerID}/${modelID}`; +} + +export function openCodeQuestionId( + index: number, + question: QuestionRequest["questions"][number], +): string { + const header = question.header + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-"); + return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; +} + +export function toOpenCodeFileParts(input: { + readonly attachments: ReadonlyArray | undefined; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +}): Array { + const parts: Array = []; + + for (const attachment of input.attachments ?? []) { + const attachmentPath = input.resolveAttachmentPath(attachment); + if (!attachmentPath) { + continue; + } + + parts.push({ + type: "file", + mime: attachment.mimeType, + filename: attachment.name, + url: pathToFileURL(attachmentPath).href, + }); + } + + return parts; +} + +export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { + if (runtimeMode === "full-access") { + return [{ permission: "*", pattern: "*", action: "allow" }]; + } + + return [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "webfetch", pattern: "*", action: "ask" }, + { permission: "websearch", pattern: "*", action: "ask" }, + { permission: "codesearch", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "doom_loop", pattern: "*", action: "ask" }, + { permission: "question", pattern: "*", action: "allow" }, + ]; +} + +export function toOpenCodePermissionReply( + decision: ProviderApprovalDecision, +): "once" | "always" | "reject" { + switch (decision) { + case "accept": + return "once"; + case "acceptForSession": + return "always"; + case "decline": + case "cancel": + default: + return "reject"; + } +} + +export function toOpenCodeQuestionAnswers( + request: QuestionRequest, + answers: Record, +): Array { + return request.questions.map((question, index) => { + const raw = + answers[openCodeQuestionId(index, question)] ?? + answers[question.header] ?? + answers[question.question]; + if (Array.isArray(raw)) { + return raw.filter((value): value is string => typeof value === "string"); + } + if (typeof raw === "string") { + return raw.trim().length > 0 ? [raw] : []; + } + return []; + }); +} + +export async function findAvailablePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, DEFAULT_HOSTNAME, () => resolve()); + }); + const address = server.address() as AddressInfo; + const port = address.port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +export function resolveOpenCodeBinaryPath(binaryPath: string): string { + if (Path.isAbsolute(binaryPath)) { + return binaryPath; + } + return execFileSync("which", [binaryPath], { + encoding: "utf8", + timeout: 3_000, + }).trim(); +} + +export function detectMacosSigkillHint(binaryPath: string): string | null { + try { + // Check for quarantine xattr first. + const resolvedPath = resolveOpenCodeBinaryPath(binaryPath); + const xattr = execFileSync("xattr", ["-l", resolvedPath], { + encoding: "utf8", + timeout: 3_000, + }); + if (xattr.includes("com.apple.quarantine")) { + return ( + `macOS quarantine is blocking the OpenCode binary. ` + + `Run: xattr -d com.apple.quarantine ${resolvedPath}` + ); + } + + // Look for a recent crash report with the termination reason. + const crashDir = Path.join(OS.homedir(), "Library/Logs/DiagnosticReports"); + const binaryName = Path.basename(resolvedPath); + const recentReports = FS.readdirSync(crashDir) + .filter((f) => f.startsWith(binaryName) && f.endsWith(".ips")) + .toSorted() + .toReversed() + .slice(0, 1); + + for (const report of recentReports) { + const content = FS.readFileSync(Path.join(crashDir, report), "utf8"); + if (content.includes('"namespace":"CODESIGNING"')) { + return ( + "macOS killed the process due to an invalid code signature. " + + "The binary may be corrupted — try reinstalling OpenCode." + ); + } + } + } catch { + // Best-effort detection — don't fail the original error path. + } + return null; +} + +export async function startOpenCodeServerProcess(input: { + readonly binaryPath: string; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; +}): Promise { + const hostname = input.hostname ?? DEFAULT_HOSTNAME; + const port = input.port ?? (await findAvailablePort()); + const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const child = spawn(input.binaryPath, args, { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({}), + }, + }); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + let stdout = ""; + let stderr = ""; + let closed = false; + const close = () => { + if (closed) { + return; + } + closed = true; + child.kill(); + }; + + const url = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + close(); + reject(new Error(`Timed out waiting for OpenCode server start after ${timeoutMs}ms.`)); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onStdout); + child.stderr.off("data", onStderr); + child.off("error", onError); + child.off("close", onClose); + }; + + const onStdout = (chunk: string) => { + stdout += chunk; + const parsed = parseServerUrlFromOutput(stdout); + if (!parsed) { + return; + } + cleanup(); + resolve(parsed); + }; + + const onStderr = (chunk: string) => { + stderr += chunk; + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onClose = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + const exitReason = signal ? `signal: ${signal}` : `code: ${code ?? "unknown"}`; + const hint = + signal === "SIGKILL" && process.platform === "darwin" + ? detectMacosSigkillHint(input.binaryPath) + : null; + reject( + new Error( + [ + `OpenCode server exited before startup completed (${exitReason}).`, + hint, + stdout.trim() ? `stdout:\n${stdout.trim()}` : null, + stderr.trim() ? `stderr:\n${stderr.trim()}` : null, + ] + .filter(Boolean) + .join("\n\n"), + ), + ); + }; + + child.stdout.on("data", onStdout); + child.stderr.on("data", onStderr); + child.once("error", onError); + child.once("close", onClose); + }); + + return { + url, + process: child, + close, + }; +} + +export async function connectToOpenCodeServer(input: { + readonly binaryPath: string; + readonly serverUrl?: string | null; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; +}): Promise { + const serverUrl = input.serverUrl?.trim(); + if (serverUrl) { + return { + url: serverUrl, + process: null, + external: true, + close() {}, + }; + } + + const server = await startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }); + + return { + url: server.url, + process: server.process, + external: false, + close: () => server.close(), + }; +} + +export async function runOpenCodeCommand(input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; +}): Promise { + const child = spawn(input.binaryPath, [...input.args], { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + env: process.env, + }); + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + const stdoutChunks: Array = []; + const stderrChunks: Array = []; + + child.stdout?.on("data", (chunk: string) => stdoutChunks.push(chunk)); + child.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk)); + + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (exitCode) => resolve(exitCode ?? 0)); + }); + + return { + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + code, + }; +} + +export function createOpenCodeSdkClient(input: { + readonly baseUrl: string; + readonly directory: string; + readonly serverPassword?: string; +}): OpencodeClient { + return createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword + ? { + headers: { + Authorization: buildOpenCodeBasicAuthorizationHeader(input.serverPassword), + }, + } + : {}), + throwOnError: true, + }); +} + +export async function loadOpenCodeInventory(client: OpencodeClient): Promise { + const [providerListResult, agentsResult] = await Promise.all([ + client.provider.list(), + client.app.agents(), + ]); + if (!providerListResult.data) { + throw new Error("OpenCode provider inventory was empty."); + } + return { + providerList: providerListResult.data, + agents: agentsResult.data ?? [], + }; +} + +export function flattenOpenCodeModels( + input: OpenCodeInventory, +): ReadonlyArray { + const connected = new Set(input.providerList.connected); + const models: Array = []; + + for (const provider of input.providerList.all) { + if (!connected.has(provider.id)) { + continue; + } + + for (const model of Object.values(provider.models)) { + models.push({ + slug: toOpenCodeModelSlug(provider.id, model.id), + name: `${provider.name} · ${model.name}`, + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: provider.id, + model, + agents: input.agents, + }), + }); + } + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts new file mode 100644 index 000000000000..0a0d31ccb599 --- /dev/null +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import type { ModelCapabilities } from "@t3tools/contracts"; + +import { providerModelsFromSettings } from "./providerSnapshot.ts"; + +const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + variantOptions: [{ value: "medium", label: "Medium", isDefault: true }], + agentOptions: [{ value: "build", label: "Build", isDefault: true }], +}; + +describe("providerModelsFromSettings", () => { + it("applies the provided capabilities to custom models", () => { + const models = providerModelsFromSettings( + [], + "opencode", + ["openai/gpt-5"], + OPENCODE_CUSTOM_MODEL_CAPABILITIES, + ); + + expect(models).toEqual([ + { + slug: "openai/gpt-5", + name: "openai/gpt-5", + isCustom: true, + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index a82cb4ae5042..6722f1ac04fc 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -37,6 +37,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { status: "warning", auth: { status: "unknown" }, }); + const openCodeProvider = makeProvider("opencode", { + status: "warning", + auth: { status: "unknown", type: "opencode" }, + }); const codexPath = resolveProviderStatusCachePath({ cacheDir: tempDir, provider: "codex", @@ -45,6 +49,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { cacheDir: tempDir, provider: "claudeAgent", }); + const openCodePath = resolveProviderStatusCachePath({ + cacheDir: tempDir, + provider: "opencode", + }); yield* writeProviderStatusCache({ filePath: codexPath, @@ -54,9 +62,14 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { filePath: claudePath, provider: claudeProvider, }); + yield* writeProviderStatusCache({ + filePath: openCodePath, + provider: openCodeProvider, + }); assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); + assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider); }), ); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index abedf99d1381..7e9ea7e9c9a2 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -2,9 +2,11 @@ import * as nodePath from "node:path"; import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; import { Cause, Effect, FileSystem, Path, Schema } from "effect"; -export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray< - ServerProvider["provider"] ->; +export const PROVIDER_CACHE_IDS = [ + "codex", + "claudeAgent", + "opencode", +] as const satisfies ReadonlyArray; const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 50d2d62aa724..4ae4a4fb7c47 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; +import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; @@ -154,9 +155,13 @@ const ProviderLayerLive = Layer.unwrap( const claudeAdapterLayer = makeClaudeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const openCodeAdapterLayer = makeOpenCodeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(openCodeAdapterLayer), Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); return makeProviderServiceLive( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 406e1e85056c..655ede9441f6 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -184,6 +184,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { claudeAgent: { binaryPath: " /opt/homebrew/bin/claude ", }, + opencode: { + binaryPath: " /opt/homebrew/bin/opencode ", + serverUrl: " http://127.0.0.1:4096 ", + serverPassword: " secret-password ", + }, }, }); @@ -199,6 +204,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: [], launchArgs: "", }); + assert.deepEqual(next.providers.opencode, { + enabled: true, + binaryPath: "/opt/homebrew/bin/opencode", + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + customModels: [], + }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -257,6 +269,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + opencode: { + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + }, }, }); @@ -273,6 +289,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + opencode: { + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + }, }, }); }).pipe(Effect.provide(makeServerSettingsLayer())), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index d5636d5c04f0..b18250e44861 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode"]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7890bc0dc8e6..06d8e8db5d89 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,7 +26,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; @@ -2534,16 +2534,13 @@ export default function ChatView(props: ChatViewProps) { } } const title = truncate(titleSeed); - const threadCreateModelSelection: ModelSelection = { - provider: ctxSelectedProvider, - model: - ctxSelectedModel || + const threadCreateModelSelection = createModelSelection( + ctxSelectedProvider, + ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - ...(ctxSelectedModelSelection.options - ? { options: ctxSelectedModelSelection.options } - : {}), - }; + ctxSelectedModelSelection.options, + ); // Auto-title from first message if (isFirstMessage && isServerThread) { diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index a1299c3e2cfd..9a7ac5fbb987 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -553,8 +553,10 @@ export const IntelliJIdeaIcon: Icon = (props) => { export const OpenCodeIcon: Icon = (props) => ( - - + + + + diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 6449a71587c9..c72ebecae8d3 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -99,6 +99,13 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, + opencode: { + enabled: true, + binaryPath: "", + serverUrl: "", + serverPassword: "", + customModels: [], + }, }, }, }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f1663901ced3..80e80e4f8086 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -16,7 +16,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { forwardRef, memo, @@ -70,6 +70,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { + getComposerProviderControls, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -159,6 +160,7 @@ const terminalContextIdListsEqual = ( contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { + showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; showPlanToggle: boolean; @@ -175,25 +177,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop <> - - {props.interactionMode === "plan" ? "Plan" : "Build"} - - + {props.showInteractionModeToggle ? ( + <> + - + + + ) : null} + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: { + ...settings.providers[providerCard.provider], + ...(providerCard.provider === "opencode" + ? { serverUrl: event.target.value } + : {}), + }, + }, + }) + } + placeholder={providerCard.serverUrlPlaceholder} + spellCheck={false} + /> + {providerCard.serverUrlDescription ? ( + + {providerCard.serverUrlDescription} + + ) : null} + +
+ ) : null} + + {providerCard.serverPasswordPlaceholder ? ( +
+ +
+ ) : null} + {providerCard.homePathKey ? (