diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts new file mode 100644 index 000000000000..cc7aac6cfd30 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { vi } from "vite-plus/test"; + +vi.mock("@napi-rs/keyring", () => { + throw new Error("Cannot find native binding"); +}); + +it("loads browser import code without a keyring native binding", async () => { + await expect(import("./ChromiumKeys.ts")).resolves.toBeDefined(); +}); + +it.effect("reports an unavailable keychain when the macOS binding cannot load", () => + Effect.gen(function* () { + const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise( + () => import("./ChromiumKeys.ts"), + ); + const error = yield* resolveChromiumKeys({ + platform: "darwin", + keychainService: "Chrome Safe Storage", + keychainAccount: "Chrome", + linuxSecretApplication: undefined, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ChromiumKeyError); + expect(error.reason).toBe("keychainUnavailable"); + expect(error.cause).toBeInstanceOf(Error); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("must not spawn")), + ), + ), +); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts index c6d26e7a435b..1ab34cae1382 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts @@ -7,6 +7,7 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { beforeEach, vi } from "vite-plus/test"; import { ChromiumKeyError, @@ -17,6 +18,18 @@ import { } from "./ChromiumKeys.ts"; import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; +const { getPassword } = vi.hoisted(() => ({ getPassword: vi.fn<() => string | null>() })); + +vi.mock("@napi-rs/keyring", () => ({ + Entry: class { + getPassword = getPassword; + }, +})); + +beforeEach(() => { + getPassword.mockReset(); +}); + type CapturedCommand = { readonly command: string; readonly args: ReadonlyArray; @@ -63,6 +76,47 @@ const helperLayer = (input: { ), ); +describe("macOS Chromium secrets", () => { + const request = { + platform: "darwin", + keychainService: "Chrome Safe Storage", + keychainAccount: "Chrome", + linuxSecretApplication: undefined, + } as const; + const noProcesses = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("must not spawn")), + ); + + it.effect("derives the cookie key from the keychain secret", () => + Effect.gen(function* () { + getPassword.mockReturnValue("macos-secret"); + const keys = yield* resolveChromiumKeys(request); + expect(keys.cbcV10?.toString("hex")).toBe("3df7306fb1eac353289565a2f6b64f74"); + }).pipe(Effect.provide(noProcesses)), + ); + + it.effect("reports a missing keychain entry", () => + Effect.gen(function* () { + getPassword.mockReturnValue(null); + const error = yield* resolveChromiumKeys(request).pipe(Effect.flip); + expect(error.reason).toBe("keychainItemMissing"); + }).pipe(Effect.provide(noProcesses)), + ); + + it.effect("preserves a denied keychain approval", () => + Effect.gen(function* () { + const denied = new Error("User denied access"); + getPassword.mockImplementation(() => { + throw denied; + }); + const error = yield* resolveChromiumKeys(request).pipe(Effect.flip); + expect(error.reason).toBe("needsKeychainApproval"); + expect(error.cause).toBe(denied); + }).pipe(Effect.provide(noProcesses)), + ); +}); + describe("Linux Chromium secrets", () => { it.effect("retains a missing helper failure alongside the keyring-free fallback", () => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index 88df1e88af90..9310fd1c92f7 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -17,7 +17,6 @@ * * @module ChromiumKeys */ -import * as Keyring from "@napi-rs/keyring"; import * as NodeCrypto from "node:crypto"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; @@ -102,6 +101,12 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function service: string, account: string, ) { + // Only macOS cookie imports need this binding; loading it at startup can + // prevent the desktop from opening on platforms that never use it. + const Keyring = yield* Effect.tryPromise({ + try: () => import("@napi-rs/keyring"), + catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), + }); const secret = yield* Effect.try({ try: () => new Keyring.Entry(service, account).getPassword(), catch: (cause) => { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 152948274ca3..39bce6b4b90d 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -924,4 +924,47 @@ describe("makeRelayDeviceRegistrationRequest", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(widgetMocks.start).toHaveBeenCalledTimes(1); }); + it.effect( + "does not enable notifications when a token rotates after permission is revoked", + () => { + const registrations: unknown[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "dpop", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + registrations.push(await request.json()); + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a"); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ preferences: { notificationsEnabled: true } }); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ + granted: false, + } as Awaited>); + const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0]; + listener({ type: "ios", data: "rotated" }); + yield* runBackgroundOperations(); + expect(registrations.at(-1)).toMatchObject({ + preferences: { notificationsEnabled: false }, + }); + expect(registrations.at(-1)).not.toHaveProperty("pushToken"); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide( + managedRelayClientLayer("https://permission-relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), + ), + ), + ); + }, + ); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 9ffd3178b314..14b1769c670c 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -245,9 +245,6 @@ function nativePushTokenRegistration(observedPushToken?: string) { if (!canRegisterRemoteLiveActivities() || !supportsAgentAwarenessPush()) { return { notificationsEnabled: false, pushToken: null }; } - if (observedPushToken) { - return { notificationsEnabled: true, pushToken: observedPushToken }; - } const permissions = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), catch: (cause) => @@ -259,6 +256,9 @@ function nativePushTokenRegistration(observedPushToken?: string) { if (!permissions.granted) { return { notificationsEnabled: false, pushToken: null }; } + if (observedPushToken) { + return { notificationsEnabled: true, pushToken: observedPushToken }; + } const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), catch: (cause) => diff --git a/apps/mobile/src/lib/http-response.test.ts b/apps/mobile/src/lib/http-response.test.ts new file mode 100644 index 000000000000..8b170cd9069b --- /dev/null +++ b/apps/mobile/src/lib/http-response.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; +import { Cookies, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +describe("React Native HTTP responses", () => { + it("can inspect a rejected response when native Headers has no getSetCookie", () => { + const response = new Response("Registration rejected", { status: 400 }); + Object.defineProperty(response.headers, "getSetCookie", { value: undefined }); + const result = HttpClientResponse.fromWeb( + HttpClientRequest.post("https://relay.example.test/v1/mobile/devices"), + response, + ); + expect(result.cookies).toEqual(Cookies.empty); + expect(result.status).toBe(400); + }); + + it("preserves cookies on platforms that expose Set-Cookie headers", () => { + const result = HttpClientResponse.fromWeb( + HttpClientRequest.get("https://relay.example.test"), + new Response(null, { headers: { "Set-Cookie": "session=abc; HttpOnly" } }), + ); + expect(result.cookies).toEqual(Cookies.fromSetCookie(["session=abc; HttpOnly"])); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e3d2c6ab565d..62d7444c6968 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -55,6 +55,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, + reportsContextWindow: true, } as const; function toTitleCaseWords(value: string): string { const parts: Array = []; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 48f67c993e15..1971913f1f98 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,6 +62,7 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, + reportsContextWindow: true, } as const; export interface CodexAppServerProviderSnapshot { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 8c94b8bb977d..d53367a90640 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -64,6 +64,7 @@ export interface ServerProviderPresentation { readonly displayName: string; readonly badgeLabel?: string; readonly showInteractionModeToggle?: boolean; + readonly reportsContextWindow?: boolean; readonly requiresNewThreadForModelChange?: boolean; } @@ -212,6 +213,9 @@ export function buildServerProvider(input: { ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } : {}), + ...(typeof input.presentation.reportsContextWindow === "boolean" + ? { reportsContextWindow: input.presentation.reportsContextWindow } + : {}), ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } : {}), diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d0207b901158..3e3f834658ea 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -12,7 +12,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; +import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../state/entities"; import { type EnvMode, type EnvironmentOption, @@ -465,7 +465,7 @@ export const BranchToolbar = memo(function BranchToolbar({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverThread = useThreadShell(threadRef); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 0dad406985a7..e9b5360cbc45 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -27,7 +27,7 @@ import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { usePaginatedBranches } from "../state/queries"; -import { useProject, useThread } from "../state/entities"; +import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; @@ -119,7 +119,7 @@ export function BranchToolbarBranchSelector({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverThread = useThreadShell(threadRef); const serverSession = serverThread?.session ?? null; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b754c9408904..821044f2e774 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -47,6 +47,8 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + recallCheckoutIsRepo, + rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, @@ -55,6 +57,7 @@ import { resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + threadShellHasStarted, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, @@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => { expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); }); }); + +describe("checkout Git memory", () => { + it("answers from the last status seen for the same checkout", () => { + rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false); + expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false); + rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true); + expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true); + }); + + it("does not answer for a checkout it has not seen", () => { + expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined(); + expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined(); + }); + + it("keeps environments apart", () => { + rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false); + expect( + recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"), + ).toBeUndefined(); + }); + + it("does not confuse an environment id containing the separator with a path", () => { + rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); + expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); + }); +}); + +describe("threadShellHasStarted", () => { + it("counts a thread that has a user message but no latest turn", () => { + expect( + threadShellHasStarted({ latestTurn: null, latestUserMessageAt: now, session: null }), + ).toBe(true); + }); + + it("counts a thread with a live session and nothing else", () => { + expect( + threadShellHasStarted({ + latestTurn: null, + latestUserMessageAt: null, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ).toBe(true); + }); + + it("does not count a thread that never sent anything", () => { + expect( + threadShellHasStarted({ latestTurn: null, latestUserMessageAt: null, session: null }), + ).toBe(false); + expect(threadShellHasStarted(null)).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c251e8ffba32..950a9c73fa91 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -794,12 +794,55 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean return key !== null && sessionDismissedBranchMismatchKeys.has(key); } +// Git status for a checkout arrives after the composer paints, and the branch +// strip mounts on the assumption that a project is a Git repo. Without a +// memory, a non-Git project would mount the strip and drop it on every visit. +// Keyed by environment and checkout for the session; never persisted. +const sessionCheckoutIsRepo = new Map(); + +function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string { + return JSON.stringify([environmentId, cwd]); +} + +export function rememberCheckoutIsRepo( + environmentId: EnvironmentId, + cwd: string, + isRepo: boolean, +): void { + sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo); +} + +export function recallCheckoutIsRepo( + environmentId: EnvironmentId, + cwd: string | null, +): boolean | undefined { + return cwd === null + ? undefined + : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd)); +} + export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean( thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), ); } +/** + * Whether a thread ran at least one turn, judged from its shell alone. + * + * `threadHasStarted` needs the detail: a thread whose latest turn was cleared + * still has messages, and the loading shell carries none. The shell records + * when the last user message landed, which every started thread has. + */ +export function threadShellHasStarted( + shell: Pick | null | undefined, +): boolean { + return Boolean( + shell && + (shell.latestTurn !== null || shell.latestUserMessageAt !== null || shell.session !== null), + ); +} + // Imported history has no session until its first prompt. Resolve its instance // through the environment's provider catalog before locking to a driver. export function deriveLockedProvider(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f339b0147dc..0fbef88c81e1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -403,6 +403,8 @@ import { readFileAsDataUrl, resolveFileAttachmentUrl, reconcileMountedTerminalThreadIds, + recallCheckoutIsRepo, + rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, @@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) { const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; - // Default true while loading to avoid toolbar flicker. - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + // Git status arrives after the composer paints. A checkout seen earlier in + // this session answers from memory, so a non-Git project does not mount the + // branch strip and then drop it. A never-seen checkout assumes Git, which + // is what nearly every project is. + const liveIsGitRepo = gitStatusQuery.data?.isRepo; + useEffect(() => { + if (gitStatusCwd !== null && liveIsGitRepo !== undefined) { + rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); + } + }, [environmentId, gitStatusCwd, liveIsGitRepo]); + const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; // Keep a hidden, off-flow strip mounted for existing threads so the composer // can measure whether its relocated controls fit. The visible chrome remains // content-driven: Git/environment context or controls that actually fit. @@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId={activeThreadId} activeThreadEnvironmentId={activeThread?.environmentId} activeThread={activeThread} + activeThreadShell={routeServerThreadShell} promptHistoryMessages={timelineMessages} isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} @@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} + providerCatalogKnown={serverConfig !== null} activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b0e74159da89..c575f270f0bc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -58,7 +58,7 @@ import { resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; -import { AnimatedHeight } from "./AnimatedHeight"; +import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; import { StartTruncatedPath } from "./StartTruncatedPath"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -87,7 +87,7 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { useThread } from "~/state/entities"; +import { useThreadShell } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; @@ -563,419 +563,360 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { return ( - -
- - Publish repository - - Pick where to host it, then point us at a repo to push to. - -
- {publishWizardSteps.map((label, index) => { - const isComplete = index < publishWizardStep; - const isClickable = - publishWizardStep !== 2 && - index < publishWizardSteps.length - 1 && - index <= publishWizardStep; + + + + publishWizardStep === 2 || + index >= publishWizardSteps.length - 1 || + index > publishWizardStep + } + onStepChange={setPublishWizardStep} + /> + + + +
+ + Provider + + { + setSelectedPublishProvider(value as PublishProviderKind); + setPublishRepositoryOverride(null); + }} + aria-labelledby="publish-provider-cards-label" + className="grid grid-cols-2 gap-2.5" + > + {sortedPublishProviderOptions.map((option) => { + const readiness = publishProviderReadiness[option.value]; + const isSelected = publishProvider === option.value && readiness.ready; + if (!readiness.ready) { + return ( +
+ + + {option.label} + + + { + event.preventDefault(); + event.stopPropagation(); + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {readiness.hint ?? + "Open Settings -> Source Control to configure this provider."} + + +
+ ); + } + return ( - + ); })} -
- - - - -
- - Provider + +
+ +
+
+ +
+ + + {publishHost}/ - { - setSelectedPublishProvider(value as PublishProviderKind); - setPublishRepositoryOverride(null); + { + setPublishRepositoryOverride(event.target.value); }} - aria-labelledby="publish-provider-cards-label" - className="grid grid-cols-2 gap-2.5" - > - {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness[option.value]; - const isSelected = publishProvider === option.value && readiness.ready; - if (!readiness.ready) { - return ( -
- - - {option.label} - - - { - event.preventDefault(); - event.stopPropagation(); - openSourceControlSettings(); - }} - > - Setup Required - - } - /> - - {readiness.hint ?? - "Open Settings -> Source Control to configure this provider."} - - -
- ); + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitPublishRepository(); } + }} + placeholder={publishPathPlaceholder} + disabled={publishRepositoryAction.isPending} + className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" + /> +
+
- return ( - - - +
+ + Visibility + + + setPublishVisibility(value as SourceControlRepositoryVisibility) + } + aria-labelledby="publish-visibility-cards-label" + disabled={publishRepositoryAction.isPending} + className="grid grid-cols-2 gap-2.5" + > + {[ + { + value: "private" as const, + label: "Private", + description: "Only invited people", + Icon: LockIcon, + }, + { + value: "public" as const, + label: "Public", + description: "Anyone on the web", + Icon: GlobeIcon, + }, + ].map((option) => { + const isSelected = publishVisibility === option.value; + return ( + + + + {option.label} - - ); - })} - -
+ + {option.description} + +
+
+ ); + })} + +
-
-
-
+ + + + {publishWizardStep === 2 ? ( + + ) : ( + <> + + {publishWizardStep < 1 ? ( + + ) : ( + + )} + + )} + +
); } @@ -1009,9 +950,7 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - const activeServerThread = useThread(activeThreadRef, { - waitForShell: activeDraftThread !== null, - }); + const activeServerThread = useThreadShell(activeThreadRef); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bff16c88bbcc..7b2e65f8bf3e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -63,6 +63,7 @@ import { readFileAsDataUrl, resolveComposerInteractionMode, resolveComposerProviderSelection, + threadShellHasStarted, } from "../ChatView.logic"; import { dataTransferHasComposerMention, @@ -198,10 +199,11 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; -import { ContextWindowMeter } from "./ContextWindowMeter"; +import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, + shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, @@ -866,7 +868,13 @@ import { } from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; -import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types"; +import { + type ChatMessage, + type SessionPhase, + type Thread, + type ThreadShell, + videoMimeType, +} from "../../types"; import { buildComposerPromptHistoryEntries, stepComposerPromptHistory, @@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ContextWindowSnapshot | null; + reserveContextWindowMeter: boolean; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { @@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compactDisabled={props.compactDisabled} compactDisabledReason={props.compactDisabledReason} /> + ) : props.reserveContextWindowMeter ? ( + ) : null} ; isServerThread: boolean; @@ -1298,6 +1311,8 @@ export interface ChatComposerProps { // Provider / model lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; + /** False until the environment's server config has arrived at least once. */ + providerCatalogKnown: boolean; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; @@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode: requestedInteractionMode, lockedProvider, providerStatuses, + providerCatalogKnown, activeProjectDefaultModelSelection, activeThreadModelSelection, activeContextWindow, @@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedInstanceId = selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; const noProviderAvailable = selectedProviderEntry === undefined; + // Before the catalog arrives, every thread resolves to "no provider". Send + // stays blocked either way; only the chrome waits, keeping the picker with + // the thread's own selection instead of swapping in the setup button and + // back once the catalog lands. + const providerCatalogPending = noProviderAvailable && !providerCatalogKnown; + const showProviderUnavailable = noProviderAvailable && !providerCatalogPending; const providerSetupInstanceId = noProviderAvailable ? (unavailableProviderInstanceId ?? (lockedProvider === null @@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); + const reserveContextWindowMeter = shouldReserveContextWindowMeter({ + meterEnabled: settings.contextWindowMeterEnabled, + detailLoading: props.threadSyncPhase === "loading", + threadStarted: threadShellHasStarted(props.activeThreadShell), + providerReportsContextWindow: selectedProviderStatus + ? selectedProviderStatus.reportsContextWindow === true + : null, + }); // ------------------------------------------------------------------ // Composer-local state @@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isStashMenuOpen || isDragOverComposer || isPreparingWorktree || - noProviderAvailable || + showProviderUnavailable || projectSelectionRequired || environmentUnavailable !== null || composerSubmissionError !== null || @@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const hiddenRestingBlockIds = restingBlockDefs .slice(restingBlockDefs.length - restingHiddenBlockCount) .map((def) => def.id); - const composerControls = noProviderAvailable ? ( + const composerControls = showProviderUnavailable ? ( - - - ) : ( - - )} - - - + + + ) : ( + + )} + + ); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index ca187b3475d1..5c6517e154a1 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -71,8 +71,8 @@ import { Input } from "../ui/input"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { ScrollArea } from "../ui/scroll-area"; import { Spinner } from "../ui/spinner"; -import { WizardPanel, WizardSteps } from "../ui/wizard"; -import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; +import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard"; +import { Dialog } from "../ui/dialog"; import { toastManager } from "../ui/toast"; import { cn } from "../../lib/utils"; import { formatRelativeTime } from "../../timestampFormat"; @@ -183,71 +183,71 @@ export function WelcomeWizard({ return ( event.cancel()}> - document.getElementById("onboarding-pairing-url") ?? true} > - Set up T3 Code -
- + Code
- isImporting || index >= stageIndex} - onStepChange={(index) => { - if (isImporting || index > stageIndex) return; - setStep(index === 0 ? "connection" : "agents"); + } + > + isImporting || index >= stageIndex} + onStepChange={(index) => { + if (isImporting || index > stageIndex) return; + setStep(index === 0 ? "connection" : "agents"); + }} + /> + + + + {step === "connection" ? ( + + setSelection((current) => { + const next = new Set(current ?? selectedIds); + if (checked) next.add(environmentId); + else next.delete(environmentId); + return next; + }) + } + onContinue={() => + startSetup( + environments + .filter((environment) => selectedIds.has(environment.environmentId)) + .map((environment) => environment.environmentId), + ) + } + onPaired={(environmentId) => { + setSelection(new Set([...selectedIds, environmentId])); }} /> - - - - {step === "connection" ? ( - - setSelection((current) => { - const next = new Set(current ?? selectedIds); - if (checked) next.add(environmentId); - else next.delete(environmentId); - return next; - }) - } - onContinue={() => - startSetup( - environments - .filter((environment) => selectedIds.has(environment.environmentId)) - .map((environment) => environment.environmentId), - ) - } - onPaired={(environmentId) => { - setSelection(new Set([...selectedIds, environmentId])); - }} - /> - ) : step === "agents" ? ( - setStep("import")} /> - ) : ( - - )} - - -
+ ) : step === "agents" ? ( + setStep("import")} /> + ) : ( + + )} + +
); } diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index d29d5fd04fe1..dc61f006fcab 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -15,21 +15,14 @@ import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; +import { Dialog } from "../ui/dialog"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; -import { WizardPanel } from "../ui/wizard"; +import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard"; import { ADD_PROVIDER_WIZARD_STEPS, resolveWizardNavigation, @@ -230,206 +223,204 @@ export function AddProviderInstanceDialog({ return ( - -
- - Add provider instance - + + Configure an additional provider instance on {environmentLabel} — for example, a second Codex install pointed at a different workspace. - - - + + } + > + + - -
-
- Driver -
- setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-1 gap-2 sm:grid-cols-2" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - - - {option.badgeLabel ? ( - - {option.badgeLabel} - - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - +
+
+ Driver +
+ setDriver(ProviderDriverKind.make(value))} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-1 gap-2 sm:grid-cols-2" + > + {DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + - - - {option.label} - + + + {option.badgeLabel ? ( - Coming Soon + {option.badgeLabel} - - ); - })} - -
+ ) : null} +
+ ); + })} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + + Coming Soon + + + ); + })} +
+
- -
); } diff --git a/apps/web/src/components/settings/SnapShotSetupDialog.tsx b/apps/web/src/components/settings/SnapShotSetupDialog.tsx index fdf980236395..3b4689f044b7 100644 --- a/apps/web/src/components/settings/SnapShotSetupDialog.tsx +++ b/apps/web/src/components/settings/SnapShotSetupDialog.tsx @@ -7,16 +7,8 @@ import { CircleCheckIcon } from "lucide-react"; import { useEffect, useId, useState, type ReactNode } from "react"; import { CaptureShortcutConfig } from "./CaptureShortcutConfig"; import { Button } from "../ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; -import { WizardSteps } from "../ui/wizard-steps"; +import { Dialog, DialogDescription } from "../ui/dialog"; +import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard"; import { captureSetupAccessReady, captureSetupBackend, @@ -335,21 +327,19 @@ export function SnapShotSetupDialog({ if (!open && !busy) void onClose(false); }} > - - - - {desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"} - + + ({ ...item, disabled: index > stepIndex }))} - currentStep={step} - disabled={busy} - onStepSelect={(next) => { - if (next !== step) changeStep(next); + steps={SETUP_STEPS.map((item) => item.label)} + currentStep={stepIndex} + isStepDisabled={(index) => busy || index > stepIndex} + onStepChange={(index) => { + const next = SETUP_STEPS[index]; + if (next && next.id !== step) changeStep(next.id); }} /> - - + +

{title}

@@ -479,8 +469,8 @@ export function SnapShotSetupDialog({ ) : null}
- - + + {step !== "access" ? ( ) : null} - - + + ); } diff --git a/apps/web/src/components/ui/wizard-steps.tsx b/apps/web/src/components/ui/wizard-steps.tsx deleted file mode 100644 index ccb639dd0fbc..000000000000 --- a/apps/web/src/components/ui/wizard-steps.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { CheckIcon } from "lucide-react"; -import { cn } from "~/lib/utils"; - -export function WizardSteps({ - steps, - currentStep, - disabled = false, - onStepSelect, -}: { - steps: readonly { id: Step; label: string; disabled?: boolean }[]; - currentStep: Step; - disabled?: boolean; - onStepSelect: (step: Step) => void; -}) { - const currentIndex = steps.findIndex((step) => step.id === currentStep); - return ( -
    - {steps.map((step, index) => ( -
  1. - -
  2. - ))} -
- ); -} diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index 8843e265e038..925b263f4530 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -1,19 +1,75 @@ import { CheckIcon } from "lucide-react"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { AnimatedHeight } from "../AnimatedHeight"; +import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog"; + +/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */ +export function WizardPopup({ + children, + ...props +}: Omit, "className" | "style">) { + return ( + +
{children}
+
+ ); +} + +export function WizardHeader({ + title, + description, + identity, + children, +}: { + readonly title: ReactNode; + readonly description?: ReactNode; + /** Optional branding shown in place of the visible title. The title remains accessible. */ + readonly identity?: ReactNode; + readonly children?: ReactNode; +}) { + return ( + + {title} + {identity} + {description ? {description} : null} + {children} + + ); +} + +export function WizardFooter({ + children, + leading, +}: { + readonly children: ReactNode; + readonly leading?: ReactNode; +}) { + return ( + + {leading} + {leading ? ( +
{children}
+ ) : ( + children + )} +
+ ); +} export function WizardSteps({ steps, currentStep, summaries, + showSummaries = false, onStepChange, isStepDisabled, }: { readonly steps: readonly string[]; readonly currentStep: number; readonly summaries?: readonly (string | null)[]; + readonly showSummaries?: boolean; readonly isStepDisabled?: (step: number) => boolean; readonly onStepChange?: (step: number) => void; }) { @@ -61,6 +117,9 @@ export function WizardSteps({ )} > {step} + {showSummaries && index < currentStep && summaries?.[index] + ? `: ${summaries[index]}` + : null}
@@ -70,19 +129,16 @@ export function WizardSteps({ } export function WizardPanel({ - className, children, holdHeight = false, - ...props -}: ComponentProps<"div"> & { readonly holdHeight?: boolean }) { +}: { + readonly children: ReactNode; + readonly holdHeight?: boolean; +}) { return (
{children}
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 05ad4fd87b14..063da58a90b3 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -1,5 +1,9 @@ +import { makeAggregateState } from "./agentActivityAggregate.ts"; +export { + makeAggregateState, + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, +} from "./agentActivityAggregate.ts"; import type { - RelayAgentActivityAggregateState, RelayAgentActivityState, RelayDeliveryResult, RelayPublishResponse, @@ -8,14 +12,8 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { - isExpiredAgentActivityState, - isTerminalPhase, - MAX_ACTIVITY_ROWS, - sanitizeAgentActivityAggregateState, -} from "./agentActivityPayloads.ts"; +import { isTerminalPhase } from "./agentActivityPayloads.ts"; export { isExpiredAgentActivityState } from "./agentActivityPayloads.ts"; import * as AgentActivityRows from "./AgentActivityRows.ts"; @@ -132,6 +130,7 @@ export const make = Effect.gen(function* () { target, aggregate, nowMs: now.epochMilliseconds, + replay: true, }); }), publish: Effect.fn("relay.agent_activity_publisher.publish")(function* (input) { @@ -183,122 +182,4 @@ export const make = Effect.gen(function* () { }); }); -function statusForPhase(phase: RelayAgentActivityState["phase"]): string { - switch (phase) { - case "waiting_for_approval": - return "Approval"; - case "waiting_for_input": - return "Input"; - case "completed": - return "Done"; - case "failed": - return "Failed"; - case "starting": - // Matches the web sidebar's pill wording (Sidebar.logic.ts) so the same - // thread reads the same across surfaces. - return "Connecting"; - case "running": - return "Working"; - case "stale": - return "Waiting"; - } -} - -function aggregateRowForState(state: RelayAgentActivityState) { - return { - environmentId: state.environmentId, - threadId: state.threadId, - projectTitle: state.projectTitle, - threadTitle: state.threadTitle, - modelTitle: state.modelTitle, - phase: state.phase, - status: statusForPhase(state.phase), - updatedAt: state.updatedAt, - deepLink: state.deepLink, - }; -} - -function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActivityAggregateState { - return sanitizeAgentActivityAggregateState({ - title: "T3 Code", - subtitle: state.phase === "failed" ? "Agent work failed" : "Agent work completed", - activeCount: 0, - updatedAt: state.updatedAt, - activities: [aggregateRowForState(state)], - }); -} - -// How long a finished thread keeps its Done/Failed row in the aggregate while -// other agents are still active. Long enough to be seen on the lock screen, -// short enough that the activity list stays about live work. -export const TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS = 15 * 60 * 1_000; - -function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): boolean { - if (!isTerminalPhase(state)) { - return false; - } - const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { - onNone: () => Number.NaN, - onSome: (dt) => dt.epochMilliseconds, - }); - if (Number.isNaN(updatedAtMs)) { - return false; - } - return nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; -} - -export function makeAggregateState(input: { - readonly activeStates: ReadonlyArray; - readonly terminalState: RelayAgentActivityState | null; - readonly nowMs: number; -}): RelayAgentActivityAggregateState | null { - const activeStates = input.activeStates.filter( - (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs), - ); - if (activeStates.length === 0) { - if (input.terminalState !== null) { - return terminalAggregateState(input.terminalState); - } - // With no live work, recently finished threads keep the card showing - // Done/Failed content (an armed card never renders an empty state). The - // newly-terminal alert rules key off the previously delivered aggregate, - // so replays repaint this without buzzing. Once the terminal rows age - // out, the aggregate is null and the delivery layer ends the card. - const recentTerminal = input.activeStates - .filter((state) => isRecentTerminalState(state, input.nowMs)) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - const newest = recentTerminal[0]; - if (!newest) { - return null; - } - return sanitizeAgentActivityAggregateState({ - title: "T3 Code", - subtitle: newest.phase === "failed" ? "Agent work failed" : "Agent work completed", - activeCount: 0, - updatedAt: newest.updatedAt, - activities: recentTerminal.slice(0, MAX_ACTIVITY_ROWS).map(aggregateRowForState), - }); - } - // Recently finished threads ride along after the active ones (display slots - // permitting) so a completion is visible as Done/Failed instead of the row - // silently vanishing while other agents keep the activity alive. - const recentTerminalStates = input.activeStates - .filter((state) => isRecentTerminalState(state, input.nowMs)) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - const displayedStates = [ - ...activeStates.slice(0, MAX_ACTIVITY_ROWS), - ...recentTerminalStates, - ].slice(0, MAX_ACTIVITY_ROWS); - const updatedAt = [...activeStates, ...recentTerminalStates].reduce((latest, state) => - state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest, - ).updatedAt; - return sanitizeAgentActivityAggregateState({ - title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: activeStates.length, - updatedAt, - activities: displayedStates.map(aggregateRowForState), - }); -} - export const layer = Layer.effect(AgentActivityPublisher, make); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index e34dcc55e37e..0d41bfce781a 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1862,3 +1862,113 @@ describe("live activity alert decisions", () => { ).toBeNull(); }); }); + +describe("queued iOS alert policy", () => { + for (const scenario of ["enabled", "muted", "late"] as const) { + it.effect(`checks the current policy for a ${scenario} completion`, () => { + let sent = 0; + const completed = { ...state, phase: "completed" as const }; + const prefs = JSON.parse(enabledPreferences); + if (scenario === "muted") prefs.notifyOnCompletion = false; + const payload = makeApnsDeliveryJobPayload({ + kind: "push_notification", + userId: target.user_id, + deviceId: target.device_id, + token: "push", + aggregate: null, + notification: { + title: "Thread", + body: "Done: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + phase: "completed", + updatedAt: completed.updatedAt, + }, + createdAt: completed.updatedAt, + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: `delivery-policy-${scenario}`, + }); + const signed = signApnsDeliveryJob({ secret: config.apnsDeliveryJobSigningSecret, payload }); + return Effect.gen(function* () { + if (scenario === "late") yield* TestClock.adjust("3 minutes"); + const d = yield* ApnsDeliveries.ApnsDeliveries; + yield* d.processSignedJob(signed); + expect(sent).toBe(scenario === "enabled" ? 1 : 0); + }).pipe( + Effect.provide( + makeLayer({ + attempts: [], + config: signingConfig, + currentTargets: [ + { ...target, push_token: "push", preferences_json: JSON.stringify(prefs) }, + ], + currentActivityStates: [completed], + execute: (request) => + Effect.sync(() => { + sent++; + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }), + }), + ), + ); + }); + } +}); + +describe("fast completion delivery", () => { + it.effect("keeps a completion alert when work finishes before running delivery", () => { + const queuedJobs: SignedApnsDeliveryJob[] = []; + const old = { + ...aggregate, + activeCount: 0, + activities: [ + { + ...aggregate.activities[0]!, + threadId: "old" as RelayAgentActivityState["threadId"], + phase: "completed" as const, + }, + ], + }; + const device = { ...target, last_aggregate_json: JSON.stringify(old) }; + const done = { + ...aggregate, + activeCount: 0, + activities: [{ ...aggregate.activities[0]!, phase: "completed" as const }], + }; + return Effect.gen(function* () { + const d = yield* ApnsDeliveries.ApnsDeliveries; + yield* d.sendForTarget({ target: device, aggregate, nowMs: 0 }); + yield* d.sendForTarget({ target: device, aggregate: done, nowMs: 0 }); + expect( + queuedJobs.some((x) => x.payload.alert !== null && x.payload.alert !== undefined), + ).toBe(true); + }).pipe(Effect.provide(makeLayer({ attempts: [], queuedJobs, currentTargets: [device] }))); + }); + it.effect("replays a newly visible completion without alerting", () => { + const queuedJobs: SignedApnsDeliveryJob[] = []; + const done = { + ...aggregate, + activeCount: 0, + activities: [{ ...aggregate.activities[0]!, phase: "completed" as const }], + }; + const previous = { + ...aggregate, + activities: [ + { ...aggregate.activities[0]!, threadId: "other" as RelayAgentActivityState["threadId"] }, + ], + }; + const device = { ...target, last_aggregate_json: JSON.stringify(previous) }; + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + yield* deliveries.sendForTarget({ + target: device, + aggregate: done, + nowMs: 0, + replay: true, + }); + expect(queuedJobs).toHaveLength(1); + expect(queuedJobs[0]?.payload.alert).toBeUndefined(); + }).pipe(Effect.provide(makeLayer({ attempts: [], queuedJobs }))); + }); +}); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index c9d97002e28c..45620ebbfa8b 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -20,6 +20,7 @@ import * as Schema from "effect/Schema"; import { isExpiredAgentActivityState, isTerminalPhase, + notificationForActivity, sanitizeAgentActivityAggregateState, sanitizeApnsNotificationPayload, } from "./agentActivityPayloads.ts"; @@ -42,6 +43,19 @@ import * as RelayConfiguration from "../Config.ts"; import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; import { withSpanAttributes } from "../observability.ts"; +import { + alertForAttentionTransition, + alertForNewlyTerminal, + alertForTerminalAggregate, + newlyTerminalRows, + shouldAlertForActivity, +} from "./agentActivityAlerts.ts"; +export { + alertForAttentionTransition, + alertForNewlyTerminal, + alertForTerminalAggregate, +} from "./agentActivityAlerts.ts"; + const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000; // How long a just-armed card may sit with an empty aggregate before an end is // warranted; covers the gap between arming on send and the environment's @@ -146,148 +160,6 @@ function aggregateNeedsAttention(aggregate: RelayAgentActivityAggregateState): b ); } -function isAttentionPhase(phase: string): boolean { - return phase === "waiting_for_approval" || phase === "waiting_for_input"; -} - -// Honors the same per-event notification switches the push channel uses; a -// missing/corrupt preferences blob only disables nothing (matching how the -// liveActivitiesEnabled check treats it), since every registration writes one. -function alertAllowedForPhase( - preferences: RelayAgentAwarenessPreferences | null, - phase: string, -): boolean { - if (preferences === null) { - return true; - } - switch (phase) { - case "waiting_for_approval": - return preferences.notifyOnApproval; - case "waiting_for_input": - return preferences.notifyOnInput; - case "completed": - return preferences.notifyOnCompletion; - case "failed": - return preferences.notifyOnFailure; - default: - return false; - } -} - -// Alert copy for an update whose aggregate contains threads that were NOT in an -// attention phase in the previously delivered aggregate. A null previous -// aggregate means there is no known baseline (fresh registration, replay after -// data loss) — alerting there would buzz on reconnect, not on a transition. -export function alertForAttentionTransition(input: { - readonly previousAggregate: RelayAgentActivityAggregateState | null; - readonly nextAggregate: RelayAgentActivityAggregateState; - readonly preferences: RelayAgentAwarenessPreferences | null; -}): ApnsLiveActivityAlert | null { - if (input.previousAggregate === null) { - return null; - } - const previouslyAttention = new Set( - input.previousAggregate.activities - .filter((row) => isAttentionPhase(row.phase)) - .map((row) => row.threadId), - ); - const newlyAttention = input.nextAggregate.activities.filter( - (row) => - isAttentionPhase(row.phase) && - !previouslyAttention.has(row.threadId) && - alertAllowedForPhase(input.preferences, row.phase), - ); - const first = newlyAttention[0]; - if (!first) { - return null; - } - if (newlyAttention.length === 1) { - return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; - } - return { - title: `${newlyAttention.length} agents need attention`, - body: newlyAttention.map((row) => row.threadTitle).join(", "), - }; -} - -// Alert copy for an update whose aggregate contains threads that finished -// (Done/Failed) since the previously delivered aggregate — the mid-flight -// completion buzz while other agents keep the activity alive. Requires the -// thread to have been present and non-terminal before, so a baseline-less -// replay or a row that merely fell off the display cap never rings. -function newlyTerminalRows( - previousAggregate: RelayAgentActivityAggregateState | null, - nextAggregate: RelayAgentActivityAggregateState, -): ReadonlyArray { - if (previousAggregate === null) { - return []; - } - const previousPhases = new Map( - previousAggregate.activities.map((row) => [row.threadId, row.phase]), - ); - return nextAggregate.activities.filter((row) => { - if (row.phase !== "completed" && row.phase !== "failed") { - return false; - } - const previousPhase = previousPhases.get(row.threadId); - return ( - previousPhase !== undefined && previousPhase !== "completed" && previousPhase !== "failed" - ); - }); -} - -function isFreshTerminalRow( - row: RelayAgentActivityAggregateState["activities"][number], - nowMs: number, -): boolean { - const updatedAtMs = Option.match(DateTime.make(row.updatedAt), { - onNone: () => null, - onSome: (dt) => dt.epochMilliseconds, - }); - return updatedAtMs !== null && nowMs - updatedAtMs <= TERMINAL_NOTIFICATION_FRESHNESS_MS; -} - -export function alertForNewlyTerminal(input: { - readonly previousAggregate: RelayAgentActivityAggregateState | null; - readonly nextAggregate: RelayAgentActivityAggregateState; - readonly preferences: RelayAgentAwarenessPreferences | null; - readonly nowMs: number; -}): ApnsLiveActivityAlert | null { - const newlyTerminal = newlyTerminalRows(input.previousAggregate, input.nextAggregate).filter( - (row) => - alertAllowedForPhase(input.preferences, row.phase) && - // Replays of old aggregates (server restarts, redeliveries) repaint - // state without ringing; only fresh completions buzz. - isFreshTerminalRow(row, input.nowMs), - ); - const first = newlyTerminal[0]; - if (!first) { - return null; - } - if (newlyTerminal.length === 1) { - return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; - } - return { - title: `${newlyTerminal.length} agents finished`, - body: newlyTerminal.map((row) => row.threadTitle).join(", "), - }; -} - -// Alert copy for an end event carrying a terminal (Done/Failed) aggregate. -export function alertForTerminalAggregate(input: { - readonly aggregate: RelayAgentActivityAggregateState | null; - readonly preferences: RelayAgentAwarenessPreferences | null; -}): ApnsLiveActivityAlert | null { - const row = input.aggregate?.activities[0]; - if (!row || (row.phase !== "completed" && row.phase !== "failed")) { - return null; - } - if (!alertAllowedForPhase(input.preferences, row.phase)) { - return null; - } - return { title: row.threadTitle, body: `${row.status}: ${row.projectTitle}` }; -} - function shouldUpdateLiveActivity(input: { readonly previousAggregate: RelayAgentActivityAggregateState | null; readonly nextAggregate: RelayAgentActivityAggregateState; @@ -309,7 +181,7 @@ function shouldUpdateLiveActivity(input: { // A thread finishing must never be throttled away: when a completion and a // new start land in the same window, activeCount is unchanged and the Done // transition (and its alert) would otherwise be suppressed. - if (newlyTerminalRows(input.previousAggregate, input.nextAggregate).length > 0) { + if (newlyTerminalRows(input.previousAggregate, input.nextAggregate, true).length > 0) { return true; } const lastDeliveryAtMs = @@ -328,7 +200,6 @@ function shouldUpdateLiveActivity(input: { // Completions replayed long after the fact (server restarts republish every // recently-finished thread) must not ring the device again. -const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000; function notificationForAggregate(input: { readonly target: LiveActivities.TargetRow; @@ -346,32 +217,8 @@ function notificationForAggregate(input: { if (!activity) { return null; } - if (activity.phase === "completed" || activity.phase === "failed") { - const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), { - onNone: () => null, - onSome: (dt) => dt.epochMilliseconds, - }); - if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) { - return null; - } - } - const enabled = - (activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) || - (activity.phase === "waiting_for_input" && preferences.notifyOnInput) || - (activity.phase === "completed" && preferences.notifyOnCompletion) || - (activity.phase === "failed" && preferences.notifyOnFailure); - if (!enabled) { - return null; - } - return { - title: activity.threadTitle, - body: `${activity.status}: ${activity.projectTitle}`, - environmentId: activity.environmentId, - threadId: activity.threadId, - deepLink: activity.deepLink, - phase: activity.phase, - updatedAt: activity.updatedAt, - }; + if (!shouldAlertForActivity({ ...activity, preferences, nowMs: input.nowMs })) return null; + return notificationForActivity(activity); } // "suppressed" means a Live Activity owns this state but no update is due @@ -381,6 +228,7 @@ function chooseLiveActivityDelivery(input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; readonly nowMs: number; + readonly replay?: boolean; }): ChosenLiveActivityDelivery | "suppressed" | null { const preferences = parsePreferences(input.target.preferences_json); if (preferences?.liveActivitiesEnabled === false) { @@ -439,18 +287,20 @@ function chooseLiveActivityDelivery(input: { kind: "live_activity_update", token: input.target.activity_push_token, aggregate: nextAggregate, - alert: - alertForAttentionTransition({ - previousAggregate, - nextAggregate, - preferences, - }) ?? - alertForNewlyTerminal({ - previousAggregate, - nextAggregate, - preferences, - nowMs: input.nowMs, - }), + alert: input.replay + ? null + : (alertForAttentionTransition({ + previousAggregate, + nextAggregate, + preferences, + }) ?? + alertForNewlyTerminal({ + previousAggregate, + nextAggregate, + preferences, + nowMs: input.nowMs, + includeUnobserved: true, + })), } : "suppressed"; } @@ -459,6 +309,7 @@ function chooseDelivery(input: { readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; readonly nowMs: number; + readonly replay?: boolean; }): ChosenDelivery | null { const liveActivityDelivery = chooseLiveActivityDelivery(input); if (liveActivityDelivery === "suppressed") { @@ -467,7 +318,7 @@ function chooseDelivery(input: { if (liveActivityDelivery) { return liveActivityDelivery; } - const notification = notificationForAggregate(input); + const notification = input.replay ? null : notificationForAggregate(input); return notification && input.target.push_token ? { kind: "push_notification", @@ -584,9 +435,9 @@ interface LiveActivityDeliveryTarget { // DeviceTokenNotForTopic/BadDeviceToken, so per-device values override the // relay-wide defaults when present. function credentialsForTarget( - credentials: RelayConfiguration.RelayConfiguration["Service"]["apns"], + credentials: RelayConfiguration.ApnsCredentials, target: LiveActivityDeliveryTarget, -): RelayConfiguration.RelayConfiguration["Service"]["apns"] { +): RelayConfiguration.ApnsCredentials { return { ...credentials, ...(target.bundle_id ? { bundleId: target.bundle_id } : {}), @@ -672,6 +523,7 @@ export class ApnsDeliveries extends Context.Service< readonly target: LiveActivities.TargetRow; readonly aggregate: RelayAgentActivityAggregateState | null; readonly nowMs: number; + readonly replay?: boolean; }) => Effect.Effect; readonly sendPushNotificationForTarget: (input: { readonly target: LiveActivities.TargetRow; @@ -805,7 +657,7 @@ export const make = Effect.gen(function* () { }); }); - const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { + const currentSignedJobTarget = Effect.fnUntraced(function* (input: { readonly target: LiveActivityDeliveryTarget; readonly kind: RelayDeliveryKind; readonly token: string; @@ -813,10 +665,10 @@ export const make = Effect.gen(function* () { return yield* liveActivities.listTargets({ userId: input.target.user_id }).pipe( Effect.map((targets) => { const currentTarget = targets.find((row) => row.device_id === input.target.device_id); - return ( - currentTarget !== undefined && + return currentTarget && expectedCurrentToken({ target: currentTarget, kind: input.kind }) === input.token - ); + ? currentTarget + : null; }), ); }); @@ -832,11 +684,7 @@ export const make = Effect.gen(function* () { const now = yield* DateTime.now; const aggregate = input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate); - const { epochSeconds, iso, request } = makeLiveActivityDeliveryRequest( - apns, - { ...input, aggregate } as SendLiveActivityDeliveryInput, - now, - ); + let alert = input.alert ?? null; const recoverTransportError = (cause: Apns.ApnsError) => recoverApnsDeliveryTransportError( { @@ -862,18 +710,37 @@ export const make = Effect.gen(function* () { if (claim === "in_flight") { return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); } - const tokenIsCurrent = yield* isCurrentSignedJobToken({ + const currentTarget = yield* currentSignedJobTarget({ target: input.target, kind: input.kind, token: input.token, }); - if (!tokenIsCurrent) { + if (!currentTarget) { yield* attempts.completeSourceJob({ sourceJobId: input.sourceJobId, apnsReason: "Stale APNs delivery job skipped.", }); return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); } + if (alert) { + const preferences = parsePreferences(currentTarget.preferences_json); + const previousAggregate = parseAggregate(currentTarget.last_aggregate_json); + alert = + !preferences?.notificationsEnabled || !aggregate + ? null + : (alertForAttentionTransition({ + previousAggregate, + nextAggregate: aggregate, + preferences, + }) ?? + alertForNewlyTerminal({ + previousAggregate, + nextAggregate: aggregate, + preferences, + nowMs: now.epochMilliseconds, + includeUnobserved: true, + })); + } if ( input.kind !== "live_activity_start" && aggregate !== null && @@ -905,6 +772,11 @@ export const make = Effect.gen(function* () { } return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); } + const { epochSeconds, iso, request } = makeLiveActivityDeliveryRequest( + apns, + { ...input, aggregate, alert } as SendLiveActivityDeliveryInput, + now, + ); const result = yield* apns .sendLiveActivityRequest({ credentials: credentialsForTarget(config.apns, input.target), @@ -1012,12 +884,12 @@ export const make = Effect.gen(function* () { if (claim === "in_flight") { return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); } - const tokenIsCurrent = yield* isCurrentSignedJobToken({ + const currentTarget = yield* currentSignedJobTarget({ target: input.target, kind: "push_notification", token: input.token, }); - if (!tokenIsCurrent) { + if (!currentTarget) { yield* attempts.completeSourceJob({ sourceJobId: input.sourceJobId, apnsReason: "Stale APNs delivery job skipped.", @@ -1042,6 +914,24 @@ export const make = Effect.gen(function* () { kind: "push_notification", }); } + const preferences = parsePreferences(currentTarget.preferences_json); + const alertAllowed = + notification.phase !== undefined && notification.updatedAt !== undefined + ? shouldAlertForActivity({ + ...notification, + phase: notification.phase, + updatedAt: notification.updatedAt, + preferences, + nowMs: now.epochMilliseconds, + }) + : preferences?.notificationsEnabled === true; + if (!alertAllowed) { + yield* attempts.completeSourceJob({ + sourceJobId: input.sourceJobId, + apnsReason: "Notification is disabled or no longer fresh.", + }); + return staleJobResult({ deviceId: input.target.device_id, kind: "push_notification" }); + } } const result = yield* apns .sendPushNotificationRequest({ @@ -1210,6 +1100,7 @@ export const make = Effect.gen(function* () { target: input.target, aggregate: input.aggregate, nowMs: input.nowMs, + replay: input.replay ?? false, }); if (!delivery) { return null; @@ -1225,17 +1116,20 @@ export const make = Effect.gen(function* () { }); return result; } - const notification = notificationForAggregate({ - target: input.target, - aggregate: input.aggregate, - nowMs: input.nowMs, - }); + const notification = input.replay + ? null + : notificationForAggregate({ + target: input.target, + aggregate: input.aggregate, + nowMs: input.nowMs, + }); // The end event doubles as the "task finished" moment. When a companion // push notification is about to ring the device (below), the activity end // stays silent; otherwise the end itself carries the alert so LA-only // users still get the buzz. - const alert = - delivery.kind === "live_activity_end" + const alert = input.replay + ? null + : delivery.kind === "live_activity_end" ? notification && input.target.push_token ? null : alertForTerminalAggregate({ diff --git a/infra/relay/src/agentActivity/agentActivityAggregate.ts b/infra/relay/src/agentActivity/agentActivityAggregate.ts new file mode 100644 index 000000000000..bb748be735b2 --- /dev/null +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -0,0 +1,139 @@ +import type { + RelayAgentActivityAggregateState, + RelayAgentActivityState, +} from "@t3tools/contracts/relay"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import { + isExpiredAgentActivityState, + isTerminalPhase, + MAX_ACTIVITY_ROWS, + sanitizeAgentActivityAggregateState, +} from "./agentActivityPayloads.ts"; + +export function statusForPhase(phase: RelayAgentActivityState["phase"]): string { + switch (phase) { + case "waiting_for_approval": + return "Approval"; + case "waiting_for_input": + return "Input"; + case "completed": + return "Done"; + case "failed": + return "Failed"; + case "starting": + // Matches the web sidebar's pill wording (Sidebar.logic.ts) so the same + // thread reads the same across surfaces. + return "Connecting"; + case "running": + return "Working"; + case "stale": + return "Waiting"; + } +} + +function aggregateRowForState(state: RelayAgentActivityState) { + return { + environmentId: state.environmentId, + threadId: state.threadId, + projectTitle: state.projectTitle, + threadTitle: state.threadTitle, + modelTitle: state.modelTitle, + phase: state.phase, + status: statusForPhase(state.phase), + updatedAt: state.updatedAt, + deepLink: state.deepLink, + }; +} + +function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActivityAggregateState { + return sanitizeAgentActivityAggregateState({ + title: "T3 Code", + subtitle: state.phase === "failed" ? "Agent work failed" : "Agent work completed", + activeCount: 0, + updatedAt: state.updatedAt, + activities: [aggregateRowForState(state)], + }); +} + +// How long a finished thread keeps its Done/Failed row in the aggregate while +// other agents are still active. Long enough to be seen on the lock screen, +// short enough that the activity list stays about live work. +export const TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS = 15 * 60 * 1_000; + +function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): boolean { + if (!isTerminalPhase(state)) { + return false; + } + const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { + onNone: () => Number.NaN, + onSome: (dt) => dt.epochMilliseconds, + }); + if (Number.isNaN(updatedAtMs)) { + return false; + } + return nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; +} + +export function makeAggregateState(input: { + readonly activeStates: ReadonlyArray; + readonly terminalState: RelayAgentActivityState | null; + readonly nowMs: number; +}): RelayAgentActivityAggregateState | null { + const activeStates = input.activeStates.filter( + (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs), + ); + if (activeStates.length === 0) { + if (input.terminalState !== null) { + return terminalAggregateState(input.terminalState); + } + // With no live work, recently finished threads keep the card showing + // Done/Failed content (an armed card never renders an empty state). The + // newly-terminal alert rules key off the previously delivered aggregate, + // so replays repaint this without buzzing. Once the terminal rows age + // out, the aggregate is null and the delivery layer ends the card. + const recentTerminal = input.activeStates + .filter((state) => isRecentTerminalState(state, input.nowMs)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const newest = recentTerminal[0]; + if (!newest) { + return null; + } + return sanitizeAgentActivityAggregateState({ + title: "T3 Code", + subtitle: newest.phase === "failed" ? "Agent work failed" : "Agent work completed", + activeCount: 0, + updatedAt: newest.updatedAt, + activities: recentTerminal.slice(0, MAX_ACTIVITY_ROWS).map(aggregateRowForState), + }); + } + // Recently finished threads ride along after the active ones (display slots + // permitting) so a completion is visible as Done/Failed instead of the row + // silently vanishing while other agents keep the activity alive. + const recentTerminalStates = input.activeStates + .filter((state) => isRecentTerminalState(state, input.nowMs)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const displayedStates = [ + ...activeStates + .toSorted((a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase)) + .slice(0, MAX_ACTIVITY_ROWS), + ...recentTerminalStates, + ].slice(0, MAX_ACTIVITY_ROWS); + const updatedAt = [...activeStates, ...recentTerminalStates].reduce((latest, state) => + state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest, + ).updatedAt; + return sanitizeAgentActivityAggregateState({ + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: activeStates.length, + updatedAt, + activities: displayedStates.map(aggregateRowForState), + }); +} + +export function activityPhasePriority(phase: RelayAgentActivityState["phase"]): number { + if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; + if (phase === "failed") return 1; + if (phase === "starting" || phase === "running") return 2; + return 3; +} diff --git a/infra/relay/src/agentActivity/agentActivityAlerts.ts b/infra/relay/src/agentActivity/agentActivityAlerts.ts new file mode 100644 index 000000000000..c8e7814eed1d --- /dev/null +++ b/infra/relay/src/agentActivity/agentActivityAlerts.ts @@ -0,0 +1,152 @@ +import type { + RelayAgentActivityAggregateRow, + RelayAgentActivityAggregateState, + RelayAgentAwarenessPreferences, +} from "@t3tools/contracts/relay"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; + +export interface AgentActivityAlert { + readonly title: string; + readonly body: string; +} + +export const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000; + +export function isFreshTerminalNotification(updatedAt: string, nowMs: number): boolean { + const timestamp = Option.getOrNull(DateTime.make(updatedAt)); + return ( + timestamp !== null && nowMs - timestamp.epochMilliseconds <= TERMINAL_NOTIFICATION_FRESHNESS_MS + ); +} + +type TransitionInput = { + readonly previousAggregate: RelayAgentActivityAggregateState | null; + readonly nextAggregate: RelayAgentActivityAggregateState; + readonly preferences: RelayAgentAwarenessPreferences | null; +}; + +function rowKey(row: RelayAgentActivityAggregateRow): string { + return JSON.stringify([row.environmentId, row.threadId]); +} + +function isAttentionPhase(phase: string): boolean { + return phase === "waiting_for_approval" || phase === "waiting_for_input"; +} + +export function alertAllowedForPhase( + preferences: RelayAgentAwarenessPreferences | null, + phase: string, +): boolean { + if (preferences === null) return true; + switch (phase) { + case "waiting_for_approval": + return preferences.notifyOnApproval; + case "waiting_for_input": + return preferences.notifyOnInput; + case "completed": + return preferences.notifyOnCompletion; + case "failed": + return preferences.notifyOnFailure; + default: + return false; + } +} + +// A missing baseline is a replay, not a transition that should buzz the phone. +export function attentionTransitionRows(input: TransitionInput) { + if (input.previousAggregate === null) return []; + const previouslyAttention = new Set( + input.previousAggregate.activities.filter((row) => isAttentionPhase(row.phase)).map(rowKey), + ); + return input.nextAggregate.activities.filter( + (row) => + isAttentionPhase(row.phase) && + !previouslyAttention.has(rowKey(row)) && + alertAllowedForPhase(input.preferences, row.phase), + ); +} + +// Reconciliation uses only observed transitions. Event-driven delivery can +// include fresh completions whose running update never reached the device. +export function newlyTerminalRows( + previousAggregate: RelayAgentActivityAggregateState | null, + nextAggregate: RelayAgentActivityAggregateState, + includeUnobserved = false, +): ReadonlyArray { + if (previousAggregate === null) return []; + const previousPhases = new Map( + previousAggregate.activities.map((row) => [rowKey(row), row.phase]), + ); + return nextAggregate.activities.filter((row) => { + if (row.phase !== "completed" && row.phase !== "failed") return false; + const previousPhase = previousPhases.get(rowKey(row)); + return ( + (includeUnobserved || previousPhase !== undefined) && + previousPhase !== "completed" && + previousPhase !== "failed" + ); + }); +} + +export function terminalTransitionRows( + input: TransitionInput & { readonly nowMs: number; readonly includeUnobserved?: boolean }, +) { + return newlyTerminalRows( + input.previousAggregate, + input.nextAggregate, + input.includeUnobserved, + ).filter((row) => { + return ( + alertAllowedForPhase(input.preferences, row.phase) && + isFreshTerminalNotification(row.updatedAt, input.nowMs) + ); + }); +} + +export function alertForActivityRows( + rows: ReadonlyArray, +): AgentActivityAlert | null { + const first = rows[0]; + if (!first) return null; + if (rows.length === 1) { + return { title: first.threadTitle, body: `${first.status}: ${first.projectTitle}` }; + } + return { + title: `${rows.length} agents ${isAttentionPhase(first.phase) ? "need attention" : "finished"}`, + body: rows.map((row) => row.threadTitle).join(", "), + }; +} + +export function alertForAttentionTransition(input: TransitionInput): AgentActivityAlert | null { + return alertForActivityRows(attentionTransitionRows(input)); +} + +export function alertForNewlyTerminal( + input: TransitionInput & { readonly nowMs: number; readonly includeUnobserved?: boolean }, +): AgentActivityAlert | null { + return alertForActivityRows(terminalTransitionRows(input)); +} + +export function alertForTerminalAggregate(input: { + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly preferences: RelayAgentAwarenessPreferences | null; +}): AgentActivityAlert | null { + const row = input.aggregate?.activities[0]; + if (!row || (row.phase !== "completed" && row.phase !== "failed")) return null; + return alertAllowedForPhase(input.preferences, row.phase) ? alertForActivityRows([row]) : null; +} + +export function shouldAlertForActivity(input: { + readonly phase: RelayAgentActivityAggregateRow["phase"]; + readonly updatedAt: string; + readonly preferences: RelayAgentAwarenessPreferences | null; + readonly nowMs: number; +}): boolean { + return ( + input.preferences?.notificationsEnabled === true && + alertAllowedForPhase(input.preferences, input.phase) && + ((input.phase !== "completed" && input.phase !== "failed") || + isFreshTerminalNotification(input.updatedAt, input.nowMs)) + ); +} diff --git a/infra/relay/src/agentActivity/agentActivityPayloads.ts b/infra/relay/src/agentActivity/agentActivityPayloads.ts index a5e12a4be438..235ad7b32f39 100644 --- a/infra/relay/src/agentActivity/agentActivityPayloads.ts +++ b/infra/relay/src/agentActivity/agentActivityPayloads.ts @@ -22,22 +22,29 @@ export function isTerminalPhase(state: RelayAgentActivityState): boolean { const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000; const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000; -export function isExpiredAgentActivityState( - state: RelayAgentActivityState, - nowMs: number, -): boolean { +export function agentActivityExpiresAt( + state: Pick, +): number { const updatedAtMs = Option.match(DateTime.make(state.updatedAt), { onNone: () => Number.NaN, onSome: (dt) => dt.epochMilliseconds, }); if (Number.isNaN(updatedAtMs)) { - return true; + return Number.NaN; } const ttlMs = state.phase === "running" || state.phase === "starting" ? RUNNING_AGENT_ACTIVITY_ROW_TTL_MS : WAITING_AGENT_ACTIVITY_ROW_TTL_MS; - return nowMs - updatedAtMs > ttlMs; + return updatedAtMs + ttlMs; +} + +export function isExpiredAgentActivityState( + state: RelayAgentActivityState, + nowMs: number, +): boolean { + const expiresAt = agentActivityExpiresAt(state); + return !Number.isFinite(expiresAt) || nowMs > expiresAt; } const MAX_SUMMARY_TEXT_LENGTH = 120; @@ -99,3 +106,18 @@ export function sanitizeApnsNotificationPayload( deepLink: sanitizeDeepLink(notification.deepLink), }; } + +export function notificationForActivity( + row: RelayAgentActivityAggregateRow, +): ApnsNotificationPayload { + const activity = sanitizeAgentActivityAggregateRow(row); + return sanitizeApnsNotificationPayload({ + title: activity.threadTitle, + body: `${activity.status}: ${activity.projectTitle}`, + environmentId: activity.environmentId, + threadId: activity.threadId, + deepLink: activity.deepLink, + phase: activity.phase, + updatedAt: activity.updatedAt, + }); +} diff --git a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts new file mode 100644 index 000000000000..e73cfd85145a --- /dev/null +++ b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { makeAggregateState } from "./agentActivityAggregate.ts"; +import { + attentionTransitionRows, + terminalTransitionRows, + shouldAlertForActivity, +} from "./agentActivityAlerts.ts"; + +const state: RelayAgentActivityState = { + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "Model", + headline: "Working", + phase: "running", + updatedAt: "1970-01-01T00:00:00.000Z", + deepLink: "/threads/env/thread", +}; +const preferences = { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, +}; +const aggregate = (states: RelayAgentActivityState[]) => + makeAggregateState({ activeStates: states, terminalState: null, nowMs: 0 })!; + +describe("shared agent activity policy", () => { + it.each(["waiting_for_approval", "waiting_for_input"] as const)( + "keeps an older %s ahead of five running rows", + (phase) => { + const running = Array.from({ length: 5 }, (_, i) => ({ + ...state, + threadId: ThreadId.make(`running-${i}`), + })); + const waiting = { ...state, phase, updatedAt: "1969-12-31T23:59:00.000Z" }; + const next = aggregate([...running, waiting]); + expect(next.activities[0]?.threadId).toBe(state.threadId); + expect(next.activeCount).toBe(6); + expect(next.activities).toHaveLength(5); + expect( + attentionTransitionRows({ + previousAggregate: aggregate(running), + nextAggregate: next, + preferences, + }), + ).toMatchObject([{ threadId: state.threadId }]); + }, + ); + + it("allows fresh unobserved completions for events, but not reconciliation or repeats", () => { + const previousAggregate = aggregate([ + { ...state, threadId: ThreadId.make("old"), phase: "completed" }, + ]); + const nextAggregate = aggregate([{ ...state, phase: "completed" }]); + const input = { previousAggregate, nextAggregate, preferences, nowMs: 0 }; + expect(terminalTransitionRows(input)).toEqual([]); + expect(terminalTransitionRows({ ...input, includeUnobserved: true })).toHaveLength(1); + expect( + terminalTransitionRows({ + ...input, + includeUnobserved: true, + previousAggregate: nextAggregate, + }), + ).toEqual([]); + expect(terminalTransitionRows({ ...input, includeUnobserved: true, nowMs: 180_000 })).toEqual( + [], + ); + }); + + it("distinguishes equal thread IDs across environments", () => { + const waiting = { ...state, phase: "waiting_for_input" as const }; + const other = { ...waiting, environmentId: EnvironmentId.make("other") }; + expect( + attentionTransitionRows({ + previousAggregate: aggregate([waiting]), + nextAggregate: aggregate([waiting, other]), + preferences, + }), + ).toMatchObject([{ environmentId: other.environmentId }]); + }); + + it("checks current permission, event preferences, and freshness together", () => { + const input = { ...state, phase: "completed" as const, preferences, nowMs: 0 }; + expect(shouldAlertForActivity(input)).toBe(true); + expect( + shouldAlertForActivity({ + ...input, + preferences: { ...preferences, notificationsEnabled: false }, + }), + ).toBe(false); + expect( + shouldAlertForActivity({ + ...input, + preferences: { ...preferences, notifyOnCompletion: false }, + }), + ).toBe(false); + expect(shouldAlertForActivity({ ...input, nowMs: 180_000 })).toBe(false); + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 2f171d37df85..c2fc8524ece2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -197,6 +197,9 @@ export const ServerProvider = Schema.Struct({ badgeLabel: Schema.optional(TrimmedNonEmptyString), continuation: Schema.optional(ServerProviderContinuation), showInteractionModeToggle: Schema.optional(Schema.Boolean), + // The driver streams context window usage, so a started thread will have a + // meter once its activities load. Clients reserve the meter's space on it. + reportsContextWindow: Schema.optional(Schema.Boolean), requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), supportsConversationRollback: Schema.optional(Schema.Boolean), supportsTextGeneration: Schema.optional(Schema.Boolean), diff --git a/patches/effect@4.0.0-rc.112.patch b/patches/effect@4.0.0-rc.112.patch index b0f3443fa90f..0b7b7b79326e 100644 --- a/patches/effect@4.0.0-rc.112.patch +++ b/patches/effect@4.0.0-rc.112.patch @@ -324,3 +324,19 @@ index ec78917..342f9d7 100644 /** * Represents optional client protocol hooks that run when a transport connects * and disconnects. +diff --git a/dist/unstable/http/HttpClientResponse.js b/dist/unstable/http/HttpClientResponse.js +--- a/dist/unstable/http/HttpClientResponse.js ++++ b/dist/unstable/http/HttpClientResponse.js +@@ -177,3 +177,3 @@ + } +- return this.cachedCookies = Cookies.fromSetCookie(this.source.headers.getSetCookie()); ++ return this.cachedCookies = Cookies.fromSetCookie(this.source.headers.getSetCookie?.() ?? []); + } +diff --git a/src/unstable/http/HttpClientResponse.ts b/src/unstable/http/HttpClientResponse.ts +--- a/src/unstable/http/HttpClientResponse.ts ++++ b/src/unstable/http/HttpClientResponse.ts +@@ -309,3 +309,3 @@ + } +- return this.cachedCookies = Cookies.fromSetCookie(this.source.headers.getSetCookie()) ++ return this.cachedCookies = Cookies.fromSetCookie(this.source.headers.getSetCookie?.() ?? []) + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96a35631c361..6c58c455b465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,7 +97,7 @@ patchedDependencies: '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 - effect@4.0.0-rc.112: 200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320 + effect@4.0.0-rc.112: 8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398 @@ -139,7 +139,7 @@ importers: version: 0.13.0 '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -163,7 +163,7 @@ importers: version: 0.10.2(patch_hash=cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4) effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) electron: specifier: 44.1.0 version: 44.1.0 @@ -185,7 +185,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -234,7 +234,7 @@ importers: version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(dbc31631339ce74330e188d5d7a88158) '@effect/atom-react': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(react@19.2.3)(scheduler@0.27.0) + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -312,7 +312,7 @@ importers: version: 8.0.3 effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) expo: specifier: ~57.0.18 version: 57.0.18(fc5a731e35a0144aab60c7305f29cbed) @@ -469,7 +469,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -496,13 +496,13 @@ importers: version: 0.3.260(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) @@ -511,7 +511,7 @@ importers: version: 1.15.13 effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) msgpackr-extract: specifier: 3.0.4 version: 3.0.4 @@ -533,7 +533,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -590,7 +590,7 @@ importers: version: 3.2.2(react@19.2.6) '@effect/atom-react': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(react@19.2.6)(scheduler@0.27.0) + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(react@19.2.6)(scheduler@0.27.0) '@formkit/auto-animate': specifier: ^0.9.0 version: 0.9.0 @@ -632,7 +632,7 @@ importers: version: 4.0.2 effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) heic-to: specifier: ^1.5.2 version: 1.5.2 @@ -681,10 +681,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.2.5) @@ -744,7 +744,7 @@ importers: version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -762,23 +762,23 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.76 - version: 2.0.0-beta.76(b08b66752532a52c0a8b7a6b9c974b22) + version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(c907441ce73b163f17d6db605ee03552) + version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -796,17 +796,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -821,7 +821,7 @@ importers: version: link:../shared effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) mdast-util-directive: specifier: ^3.1.0 version: 3.1.0 @@ -840,7 +840,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) micromark-util-types: specifier: ^2.0.2 version: 2.0.2 @@ -852,11 +852,11 @@ importers: dependencies: effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -865,17 +865,17 @@ importers: dependencies: effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-rc.112(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(encoding@0.1.13) + version: 4.0.0-rc.112(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(encoding@0.1.13) '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -887,17 +887,17 @@ importers: dependencies: effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-rc.112(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(encoding@0.1.13) + version: 4.0.0-rc.112(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(encoding@0.1.13) '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -918,7 +918,7 @@ importers: version: link:../contracts effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) jose: specifier: 'catalog:' version: 6.2.2 @@ -928,10 +928,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -949,14 +949,14 @@ importers: version: link:../shared effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) devDependencies: '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -971,11 +971,11 @@ importers: version: link:../shared effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -987,7 +987,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + version: 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) '@electron/asar': specifier: ^3.4.1 version: 3.4.1 @@ -1002,7 +1002,7 @@ importers: version: link:../packages/tailscale effect: specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) pngjs: specifier: 7.0.0 version: 7.0.0 @@ -1012,7 +1012,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -11351,22 +11351,22 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@alchemy.run/cloudflare-runtime@2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(rolldown@1.2.5)(typescript@7.0.2)': + '@alchemy.run/cloudflare-runtime@2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2)': dependencies: '@alchemy.run/node-utils': 2.0.0-beta.76 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + '@distilled.cloud/cloudflare': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@puppeteer/browsers': 3.2.2(yauzl@3.4.0) capnp-es: 0.0.14(typescript@7.0.2) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) magic-string: 0.30.21 sharp: 0.35.4(@types/node@24.12.4) unenv: 2.0.0-rc.24 workerd: 1.20260704.1 yauzl: 3.4.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) rolldown: 1.2.5 vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: @@ -11374,9 +11374,9 @@ snapshots: - proxy-agent - typescript - '@alchemy.run/floci@2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@alchemy.run/floci@2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) '@alchemy.run/node-utils@2.0.0-beta.76': {} @@ -12533,58 +12533,58 @@ snapshots: '@crowecawcaw/xa11y-win32-arm64-msvc': 0.13.0 '@crowecawcaw/xa11y-win32-x64-msvc': 0.13.0 - '@distilled.cloud/aws@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@distilled.cloud/aws@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1062.0 '@aws-sdk/types': 3.973.10 - '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@smithy/shared-ini-file-loader': 4.5.6 '@smithy/types': 4.14.3 '@smithy/util-base64': 4.4.6 aws4fetch: 1.0.20 - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) fast-xml-parser: 5.8.0 - '@distilled.cloud/axiom@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@distilled.cloud/axiom@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@distilled.cloud/core@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@distilled.cloud/core@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@distilled.cloud/fly-io@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@distilled.cloud/fly-io@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@distilled.cloud/hetzner@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@distilled.cloud/hetzner@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@distilled.cloud/neon@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@distilled.cloud/neon@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@distilled.cloud/planetscale@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@distilled.cloud/planetscale@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@distilled.cloud/railway@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@distilled.cloud/railway@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: @@ -12620,47 +12620,47 @@ snapshots: '@drizzle-team/brocli@0.12.0': {} - '@effect/atom-react@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(react@19.2.3)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(react@19.2.3)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) react: 19.2.3 scheduler: 0.27.0 - '@effect/atom-react@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(react@19.2.6)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(react@19.2.6)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-rc.112(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(encoding@0.1.13)': + '@effect/openapi-generator@4.0.0-rc.112(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(encoding@0.1.13)': dependencies: - '@effect/platform-node': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@effect/platform-node': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) swagger2openapi: 7.0.8(encoding@0.1.13) transitivePeerDependencies: - encoding - '@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(utf-8-validate@6.0.6) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@effect/platform-node-shared': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) ws: 8.21.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(utf-8-validate@6.0.6) - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + '@effect/platform-node-shared': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) mime: 4.1.0 redis: 6.2.1 undici: 8.10.2 @@ -12668,14 +12668,14 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-d1@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@effect/sql-d1@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: '@cloudflare/workers-types': 5.20260907.1 - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@effect/sql-pg@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) pg: 8.23.0 pg-connection-string: 2.14.0 pg-cursor: 2.22.0(pg@8.23.0) @@ -12684,13 +12684,13 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - '@effect/sql-sqlite-do@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@effect/sql-sqlite-do@4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) '@effect/tsgo-darwin-arm64@0.41.0': optional: true @@ -12723,9 +12723,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.41.0 '@effect/tsgo-win32-x64': 0.41.0 - '@effect/vitest@4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))': + '@effect/vitest@4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))': dependencies: - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) '@egjs/hammerjs@2.0.17': dependencies: @@ -16434,25 +16434,25 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.76(b08b66752532a52c0a8b7a6b9c974b22): + alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): dependencies: - '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(rolldown@1.2.5)(typescript@7.0.2) - '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2) + '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@alchemy.run/node-utils': 2.0.0-beta.76 '@aws-sdk/credential-providers': 3.1062.0 '@clack/prompts': 1.7.0 - '@distilled.cloud/aws': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@distilled.cloud/axiom': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@distilled.cloud/cloudflare': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@distilled.cloud/fly-io': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@distilled.cloud/hetzner': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@distilled.cloud/neon': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@distilled.cloud/planetscale': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@distilled.cloud/railway': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@effect/sql-d1': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@effect/sql-sqlite-do': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@effect/vitest': 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + '@distilled.cloud/aws': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@distilled.cloud/axiom': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@distilled.cloud/cloudflare': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@distilled.cloud/core': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@distilled.cloud/fly-io': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@distilled.cloud/hetzner': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@distilled.cloud/neon': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@distilled.cloud/planetscale': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@distilled.cloud/railway': 1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@effect/sql-d1': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@effect/sql-sqlite-do': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@effect/vitest': 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@octokit/webhooks': 14.2.0 @@ -16463,7 +16463,7 @@ snapshots: '@types/aws-lambda': 8.10.161 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) fast-glob: 3.3.3 fast-xml-parser: 5.8.0 ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) @@ -16476,11 +16476,11 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320))(redis@6.2.1)(utf-8-validate@6.0.6) - '@effect/sql-pg': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + '@effect/platform-bun': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) drizzle-kit: 1.0.0-rc.5-ab785fc - drizzle-orm: 1.0.0-rc.5-ab785fc(c907441ce73b163f17d6db605ee03552) + drizzle-orm: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) pg: 8.23.0 vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)' @@ -17472,17 +17472,17 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.5-ab785fc(c907441ce73b163f17d6db605ee03552): + drizzle-orm@1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-d1': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@effect/sql-pg': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@effect/sql-sqlite-bun': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) - '@effect/sql-sqlite-do': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320)) + '@effect/sql-d1': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@effect/sql-pg': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@effect/sql-sqlite-bun': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) + '@effect/sql-sqlite-do': 4.0.0-rc.112(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) '@electric-sql/pglite': 0.3.15 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 - effect: 4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320) + effect: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) expo-sqlite: 57.0.2(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) pg: 8.23.0 valibot: 1.2.0(typescript@7.0.2) @@ -17504,7 +17504,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-rc.112(patch_hash=200b70758a8f02f5a4e6a4abea5f0079ad36fe9c2006486ebaf6ab1002a38320): + effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2): dependencies: fast-check: 4.9.0 msgpackr: 2.1.0