From b47746bb9c1edd5a9a0769426d311f184a02b08c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 21:36:34 -0700 Subject: [PATCH 01/13] perf(web): load project icons as one chunk instead of ~1,700 (#20147) Co-authored-by: Claude Opus 5.5 --- .../src/components/ProjectFavicon.test.tsx | 4 --- apps/web/src/components/ProjectFavicon.tsx | 19 +++++++---- .../settings/ProjectIconPickerDialog.tsx | 19 +++++++---- apps/web/src/lib/lucideIconByName.ts | 32 +++++++++++++++++++ apps/web/src/projectIconOptions.test.ts | 11 +++++++ apps/web/src/projectIconOptions.ts | 10 +++--- 6 files changed, 72 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/lib/lucideIconByName.ts diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index b60cf3ae62c4..8fe6e0515aea 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -53,10 +53,6 @@ vi.mock("react", async (importOriginal) => { }); vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); -vi.mock("lucide-react/dynamic", () => ({ - DynamicIcon: "dynamic-icon", - iconNames: ["alarm-clock", "folder-code"], -})); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => testState.faviconUrl, })); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 482de3bb9f36..6be86a1a075d 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -4,9 +4,8 @@ import { isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; import { FolderCodeIcon } from "lucide-react"; -import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; -import { lazy, Suspense, useState } from "react"; +import { createElement, lazy, Suspense, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { projectFaviconUrlAtom } from "../state/assets"; import { deriveProjectIdentity } from "../projectIdentity"; @@ -14,8 +13,15 @@ import { projectIconColorClassName } from "../projectIconColors"; import { ProjectMonogram } from "./ProjectMonogram"; import { cn } from "~/lib/utils"; -const DynamicIcon = lazy(() => - import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), +// All lucide icons load as one chunk, only once a project uses a lucide override. +const LucideProjectIcon = lazy(() => + import("../lib/lucideIconByName").then(({ lucideIconByName }) => ({ + default: function LucideProjectIcon(props: { name: string; className: string }) { + return createElement(lucideIconByName(props.name) ?? FolderCodeIcon, { + className: props.className, + }); + }, + })), ); function DynamicProjectIconFallback() { @@ -71,10 +77,9 @@ export function ProjectFavicon(input: { return ( diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx index 18f97ffa5f89..40be6962cb69 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -6,8 +6,7 @@ import { type ProjectIconColor, type ProjectIconOverride, } from "@t3tools/contracts"; -import { DynamicIcon, type IconName } from "lucide-react/dynamic"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { createElement, useEffect, useMemo, useRef, useState } from "react"; import { filterProjectIconNames, firstEmoji, @@ -16,6 +15,7 @@ import { projectIconColorClassName, } from "../../projectIconOptions"; import { cn } from "~/lib/utils"; +import { lucideIconByName } from "../../lib/lucideIconByName"; import { Button } from "../ui/button"; import { Dialog, @@ -30,9 +30,14 @@ import { Input } from "../ui/input"; import { ScrollArea } from "../ui/scroll-area"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; -const DEFAULT_ICON: IconName = "folder-code"; +const DEFAULT_ICON = "folder-code"; const isMonogramText = Schema.is(ProjectMonogramText); +function PickerIcon({ name }: { name: string }) { + const icon = lucideIconByName(name); + return icon ? createElement(icon, { className: "size-5" }) : null; +} + function iconLabel(name: string): string { return name .split("-") @@ -55,8 +60,8 @@ export function ProjectIconPickerDialog({ }) { const automatic = deriveProjectIdentity(projectName); const [mode, setMode] = useState(current?.kind ?? "lucide"); - const [iconName, setIconName] = useState( - current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, + const [iconName, setIconName] = useState( + current?.kind === "lucide" ? current.name : DEFAULT_ICON, ); const [color, setColor] = useState( current && current.kind !== "emoji" ? current.color : automatic.color, @@ -72,7 +77,7 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { setMode(current?.kind ?? "lucide"); - setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); + setIconName(current?.kind === "lucide" ? current.name : DEFAULT_ICON); setColor(current && current.kind !== "emoji" ? current.color : automatic.color); setLetters(current?.kind === "monogram" ? current.text : automatic.monogram); setEmoji(current?.kind === "emoji" ? current.emoji : "πŸ’»"); @@ -167,7 +172,7 @@ export function ProjectIconPickerDialog({ )} onClick={() => setIconName(name)} > - + ))} diff --git a/apps/web/src/lib/lucideIconByName.ts b/apps/web/src/lib/lucideIconByName.ts new file mode 100644 index 000000000000..34fc472bafdb --- /dev/null +++ b/apps/web/src/lib/lucideIconByName.ts @@ -0,0 +1,32 @@ +/* oxlint-disable eslint/no-restricted-imports -- Resolves saved project icons by name, so it needs the whole set. */ +// Project icon overrides save lucide icons by kebab-case name. Import this module +// lazily: it holds every lucide icon as one chunk, where `lucide-react/dynamic` +// emits a separate chunk per icon (~1,900), which dominated build output. +import * as Lucide from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +const iconComponents = new Set(Object.values(Lucide.icons)); +const lucideExports: Record = Lucide; + +// Lucide derives export names this way, so it also resolves its deprecated +// aliases ("alarm-check" -> AlarmCheck) that older overrides may have saved. +function toPascalName(name: string): string { + return name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +// Inverse of toPascalName for canonical exports: "ArrowDown01" -> "arrow-down-01". +// Lucide spells a handful of digit names differently ("arrow-down-0-1"); both +// forms resolve to the same icon through toPascalName. +function toKebabName(pascalName: string): string { + return (pascalName.match(/[A-Z][a-z]*|[0-9]+[a-z]*/g) ?? []).join("-").toLowerCase(); +} + +export const lucideIconNames: ReadonlyArray = Object.keys(Lucide.icons).map(toKebabName); + +export function lucideIconByName(name: string): LucideIcon | undefined { + const icon = lucideExports[toPascalName(name)]; + return iconComponents.has(icon) ? (icon as LucideIcon) : undefined; +} diff --git a/apps/web/src/projectIconOptions.test.ts b/apps/web/src/projectIconOptions.test.ts index 15f6f738e410..29d023d7586c 100644 --- a/apps/web/src/projectIconOptions.test.ts +++ b/apps/web/src/projectIconOptions.test.ts @@ -1,4 +1,6 @@ +import { AlarmClockCheck, ArrowDown01, Layers3 } from "lucide-react"; import { describe, expect, it } from "vite-plus/test"; +import { lucideIconByName } from "./lib/lucideIconByName"; import { filterProjectIconNames, firstEmoji, @@ -11,6 +13,15 @@ describe("projectIconOptions", () => { expect(filterProjectIconNames("alarm \tclock")).toContain("alarm-clock"); }); + it("resolves every offered icon name, including saved legacy spellings", () => { + for (const name of filterProjectIconNames("")) expect(lucideIconByName(name)).toBeDefined(); + expect(lucideIconByName("layers-3")).toBe(Layers3); + expect(lucideIconByName("arrow-down-01")).toBe(ArrowDown01); + expect(lucideIconByName("arrow-down-0-1")).toBe(ArrowDown01); + expect(lucideIconByName("alarm-check")).toBe(AlarmClockCheck); + expect(lucideIconByName("create-lucide-icon")).toBeUndefined(); + }); + it("extracts one complete emoji grapheme", () => { expect(firstEmoji(" πŸ‘©πŸ½β€πŸ’» hello")).toBe("πŸ‘©πŸ½β€πŸ’»"); expect(firstEmoji("πŸ‡ΊπŸ‡Έ project")).toBe("πŸ‡ΊπŸ‡Έ"); diff --git a/apps/web/src/projectIconOptions.ts b/apps/web/src/projectIconOptions.ts index a2213fdd4bd2..e3fec35322ea 100644 --- a/apps/web/src/projectIconOptions.ts +++ b/apps/web/src/projectIconOptions.ts @@ -1,7 +1,7 @@ -import { iconNames, type IconName } from "lucide-react/dynamic"; +import { lucideIconNames } from "./lib/lucideIconByName"; export { PROJECT_ICON_COLORS, projectIconColorClassName } from "./projectIconColors"; -const POPULAR_PROJECT_ICONS = [ +const POPULAR_PROJECT_ICONS: ReadonlyArray = [ "folder-code", "code-2", "terminal", @@ -26,7 +26,7 @@ const POPULAR_PROJECT_ICONS = [ "workflow", "wrench", "layers-3", -] as const satisfies ReadonlyArray; +]; export const PROJECT_EMOJIS: ReadonlyArray<{ readonly emoji: string; readonly label: string }> = [ { emoji: "πŸ’»", label: "Computer" }, @@ -61,10 +61,10 @@ export const PROJECT_EMOJIS: ReadonlyArray<{ readonly emoji: string; readonly la { emoji: "🌱", label: "Seedling" }, ]; -export function filterProjectIconNames(query: string): ReadonlyArray { +export function filterProjectIconNames(query: string): ReadonlyArray { const normalized = query.trim().toLowerCase().replaceAll(/\s+/g, "-"); if (!normalized) return POPULAR_PROJECT_ICONS; - return iconNames.filter((name) => name.includes(normalized)).slice(0, 60); + return lucideIconNames.filter((name) => name.includes(normalized)).slice(0, 60); } export function firstEmoji(value: string): string | null { From 802b5d7c0b861bb60072b9e753477d9079cdc96a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 21:58:14 -0700 Subject: [PATCH 02/13] feat(mobile): use shared custom segmented controls (#20116) --- .../ui/SegmentedControl.android.tsx | 86 -------- .../src/components/ui/SegmentedControl.tsx | 189 ++++++++++++++---- .../components/ui/SegmentedControl.types.ts | 2 +- .../threads/CustomSnoozeSheet.android.tsx | 63 +++--- patches/@expo__ui@58.0.18.patch | 166 --------------- pnpm-lock.yaml | 8 +- 6 files changed, 192 insertions(+), 322 deletions(-) delete mode 100644 apps/mobile/src/components/ui/SegmentedControl.android.tsx diff --git a/apps/mobile/src/components/ui/SegmentedControl.android.tsx b/apps/mobile/src/components/ui/SegmentedControl.android.tsx deleted file mode 100644 index 46f9d51c94d7..000000000000 --- a/apps/mobile/src/components/ui/SegmentedControl.android.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { - Host, - SegmentedButton, - SingleChoiceSegmentedButtonRow, - Text, -} from "@expo/ui/jetpack-compose"; -import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; -import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; -import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; -import { View } from "react-native"; - -import { useControlTheme } from "./useControlTheme"; -import type { SegmentedControlProps } from "./SegmentedControl.types"; - -function HostedSegmentedControl( - props: SegmentedControlProps, -) { - const { colorScheme } = useControlTheme(); - return ( - - - - - - - - {props.options.map((option) => ( - props.onSelect(option.value)} - className="flex-1" - /> - ))} - - - ); -} - -export type { SegmentedControlProps } from "./SegmentedControl.types"; - -/** Compose content shared by screen controls and native dialogs, inside their existing Host. */ -function SegmentedControlContent( - props: SegmentedControlProps, -) { - const { themeVariables: colors } = useAppearancePreferences(); - const typography = useScaledTextRole("footnote"); - return ( - - {props.options.map((option) => ( - props.onSelect(option.value)} - modifiers={[defaultMinSize({ minHeight: 48 })]} - colors={{ - activeContainerColor: colors["--color-secondary"], - activeContentColor: colors["--color-secondary-foreground"], - inactiveContainerColor: "transparent", - inactiveContentColor: colors["--color-foreground"], - activeBorderColor: colors["--color-border"], - inactiveBorderColor: colors["--color-border"], - }} - > - - {option.label} - - - ))} - - ); -} - -/** Content is for callers already inside an Android native Host. */ -export const SegmentedControl = Object.assign(HostedSegmentedControl, { - Content: SegmentedControlContent, -}); diff --git a/apps/mobile/src/components/ui/SegmentedControl.tsx b/apps/mobile/src/components/ui/SegmentedControl.tsx index bc555c639143..8ebb515884ff 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.tsx @@ -1,51 +1,166 @@ -import { Picker } from "@expo/ui/swift-ui"; -import { View } from "react-native"; +import { useState } from "react"; +import { I18nManager, Platform, Pressable, View } from "react-native"; +import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; +import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated"; +import { AppText } from "./AppText"; import { useControlTheme } from "./useControlTheme"; -import { NativeThemeHost } from "./NativeThemeHost"; import type { SegmentedControlProps } from "./SegmentedControl.types"; export type { SegmentedControlProps } from "./SegmentedControl.types"; -/** Let UIKit own segment sizing, selection, and accessibility through SwiftUI. */ +/** Shared capsule control with native press targets and a raised selection. */ export function SegmentedControl( props: SegmentedControlProps, ) { - const { colors } = useControlTheme(); + const { colorScheme, colors } = useControlTheme(); + const [layout, setLayout] = useState({ width: 0, height: 0 }); + const { width, height } = layout; + const reduceMotion = useReducedMotion(); + const selectedIndex = props.options.findIndex((option) => option.value === props.selected); + const segmentWidth = props.options.length ? Math.max(0, width - 6) / props.options.length : 0; + const selectionOffset = selectedIndex * segmentWidth * (I18nManager.isRTL ? -1 : 1); + const selectionStyle = useAnimatedStyle(() => ({ + width: segmentWidth, + transform: [{ translateX: withTiming(selectionOffset, { duration: reduceMotion ? 0 : 160 }) }], + })); + const dark = colorScheme === "dark"; + const minHeight = props.size === "compact" ? 32 : Platform.OS === "android" ? 40 : 36; + return ( - - - - ({ - label: option.label, - value: option.value, - }))} - segmentColors={{ - background: colors["--color-card"], - selectedBackground: colors["--color-secondary"], - text: colors["--color-foreground-muted"], - selectedText: colors["--color-secondary-foreground"], - }} - /> - - - - {props.options.map((option) => ( - props.onSelect(option.value)} - className="flex-1" - /> - ))} + + setLayout(event.nativeEvent.layout)} + className="flex-row rounded-full p-[3px]" + style={{ + minHeight, + boxShadow: dark ? "0px 1px 2px #00000062" : "0px 1px 2px #00000014", + }} + > + + {selectedIndex >= 0 && width > 0 ? ( + + + + ) : null} + {props.options.map((option) => { + const selected = option.value === props.selected; + return ( + props.onSelect(option.value)} + className="flex-1 items-center justify-center rounded-full px-2 py-1 active:opacity-70" + style={{ minHeight: minHeight - 6 }} + > + + {option.label} + + + ); + })} ); } + +/** Pen's inset well and raised selection use opposite vertical edge lighting. */ +function CapsuleSurface({ + width, + height, + dark, + fillTop, + fillBottom, + edgeColor, + selected = false, +}: { + width: number; + height: number; + dark: boolean; + fillTop: string; + fillBottom: string; + edgeColor: string; + selected?: boolean; +}) { + if (width <= 0 || height <= 0) return null; + const edgeOpacity = selected + ? dark + ? [0.22, 0.02] + : [0.13, 0.01] + : dark + ? [0.02, 0.16] + : [0.27, 0.1]; + return ( + + + + + + + + + + + + + {selected && !dark ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/ui/SegmentedControl.types.ts b/apps/mobile/src/components/ui/SegmentedControl.types.ts index a259429e3748..4def9ab2ea8b 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.types.ts +++ b/apps/mobile/src/components/ui/SegmentedControl.types.ts @@ -6,7 +6,7 @@ export interface SegmentedControlProps { }[]; readonly selected: Value; readonly onSelect: (value: Value) => void; - /** Native controls determine their own sizing. Kept for existing callers. */ + /** Compact uses a 32-point capsule; default uses 36 points on iOS and 40 on Android. */ readonly size?: "default" | "compact"; /** "tab" for the view switcher; filters stay plain buttons. */ readonly role?: "tab" | "button"; diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx index 1ff270b1626e..dc65543c37b9 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx @@ -1,4 +1,4 @@ -import { SegmentedControl } from "../../components/ui/SegmentedControl.android"; +import { SegmentedControl } from "../../components/ui/SegmentedControl"; import { BasicAlertDialog, Column, @@ -6,6 +6,7 @@ import { Host, FilledTonalIconButton, Row, + RNHostView, Shape, Surface, Text, @@ -141,33 +142,37 @@ export function CustomSnoozeSheet(props: Props) { modifiers={[fillMaxWidth(), padding(24, 24, 24, 16)]} > Custom snooze - { - setMode(value); - setError(null); - }} - /> - {mode === "date" ? ( - + { + setMode(value); + setError(null); + }} /> + + {mode === "date" ? ( + + + ) : null} {mode === "date" ? ( @@ -224,7 +229,9 @@ export function CustomSnoozeSheet(props: Props) { + - + + + )} = { - * For example: 'photo', 'heart.fill', 'star.circle' - */ - systemImage?: SFSymbol; -+ /** Native options rendered by a scoped UISegmentedControl. */ -+ segments?: { label: string; value: T }[]; -+ /** Only used with segments. */ -+ segmentColors?: { -+ background?: string; -+ selectedBackground?: string; -+ text?: string; -+ selectedText?: string; -+ }; - /** - * A label displayed on the picker. - */ -diff --git a/ios/Picker/PickerView.swift b/ios/Picker/PickerView.swift -index c78f278..9b6adf8 100644 ---- a/ios/Picker/PickerView.swift -+++ b/ios/Picker/PickerView.swift -@@ -1,12 +1,27 @@ - // Copyright 2025-present 650 Industries. All rights reserved. - - import SwiftUI -+import UIKit - import ExpoModulesCore - -+internal struct PickerSegment: Record { -+ @Field var label: String = "" -+ @Field var value: Either? -+} -+ -+internal struct PickerSegmentColors: Record { -+ @Field var background: Color? -+ @Field var selectedBackground: Color? -+ @Field var text: Color? -+ @Field var selectedText: Color? -+} -+ - internal final class PickerProps: UIBaseViewProps { - @Field var label: String? - @Field var systemImage: String? - @Field var selection: Either? -+ @Field var segments: [PickerSegment]? -+ @Field var segmentColors: PickerSegmentColors? - var onSelectionChange = EventDispatcher() - } - -@@ -25,7 +40,18 @@ internal struct PickerView: ExpoSwiftUI.View { - - let labelContent = props.children?.slot("label") - -- if let systemImage = props.systemImage, let label = props.label { -+ if let segments = props.segments { -+ NativeSegmentedPicker( -+ segments: segments, -+ colors: props.segmentColors, -+ selection: Self.getHashableFromEither(props.selection), -+ onSelectionChange: { index in -+ guard segments.indices.contains(index), -+ let value = Self.getHashableFromEither(segments[index].value) else { return } -+ props.onSelectionChange(["selection": value.base]) -+ } -+ ) -+ } else if let systemImage = props.systemImage, let label = props.label { - Picker(label, systemImage: systemImage, selection: $selection) { content } - } else if let labelContent { - Picker(selection: $selection) { content } label: { labelContent } -@@ -63,7 +89,7 @@ internal struct PickerView: ExpoSwiftUI.View { - } - } - -- private static func getHashableFromEither(_ either: Either?) -> AnyHashable? { -+ fileprivate static func getHashableFromEither(_ either: Either?) -> AnyHashable? { - guard let either else { return nil } - if let stringValue: String = either.get() { - return stringValue -@@ -74,3 +100,64 @@ internal struct PickerView: ExpoSwiftUI.View { - return nil - } - } -+ -+ -+/// Instance-scoped styling: never changes UISegmentedControl.appearance(). -+private struct NativeSegmentedPicker: UIViewRepresentable { -+ let segments: [PickerSegment] -+ let colors: PickerSegmentColors? -+ let selection: AnyHashable? -+ let onSelectionChange: (Int) -> Void -+ -+ func makeCoordinator() -> Coordinator { -+ Coordinator(onSelectionChange: onSelectionChange) -+ } -+ -+ func makeUIView(context: Context) -> UISegmentedControl { -+ let control = UISegmentedControl() -+ control.addTarget(context.coordinator, action: #selector(Coordinator.changed(_:)), for: .valueChanged) -+ control.setContentHuggingPriority(.defaultLow, for: .horizontal) -+ return control -+ } -+ -+ func updateUIView(_ control: UISegmentedControl, context: Context) { -+ context.coordinator.onSelectionChange = onSelectionChange -+ let labels = segments.map(\.label) -+ if context.coordinator.labels != labels { -+ control.removeAllSegments() -+ for (index, label) in labels.enumerated() { -+ control.insertSegment(withTitle: label, at: index, animated: false) -+ } -+ context.coordinator.labels = labels -+ } -+ control.selectedSegmentIndex = segments.firstIndex { -+ PickerView.getHashableFromEither($0.value) == selection -+ } ?? UISegmentedControl.noSegment -+ control.backgroundColor = colors?.background.map { UIColor($0) } -+ control.selectedSegmentTintColor = colors?.selectedBackground.map { UIColor($0) } -+ control.setTitleTextAttributes( -+ colors?.text.map { [.foregroundColor: UIColor($0)] }, for: .normal -+ ) -+ control.setTitleTextAttributes( -+ colors?.selectedText.map { [.foregroundColor: UIColor($0)] }, for: .selected -+ ) -+ control.isEnabled = context.environment.isEnabled -+ } -+ -+ func sizeThatFits(_ proposal: ProposedViewSize, uiView: UISegmentedControl, context: Context) -> CGSize? { -+ CGSize(width: proposal.width ?? uiView.intrinsicContentSize.width, height: uiView.intrinsicContentSize.height) -+ } -+ -+ final class Coordinator: NSObject { -+ var labels: [String] = [] -+ var onSelectionChange: (Int) -> Void -+ -+ init(onSelectionChange: @escaping (Int) -> Void) { -+ self.onSelectionChange = onSelectionChange -+ } -+ -+ @objc func changed(_ control: UISegmentedControl) { -+ onSelectionChange(control.selectedSegmentIndex) -+ } -+ } -+} diff --git a/src/jetpack-compose/Host/index.tsx b/src/jetpack-compose/Host/index.tsx index e994f6e..d0ed70a 100644 --- a/src/jetpack-compose/Host/index.tsx @@ -489,23 +343,3 @@ index 77fe3d7..3fa0fb9 100644 import './MaterialSymbolsAssetsTransformer.fx'; export * from './AlertDialog'; -diff --git a/src/swift-ui/Picker/index.tsx b/src/swift-ui/Picker/index.tsx -index ab7d7ce..ceac7bf 100644 ---- a/src/swift-ui/Picker/index.tsx -+++ b/src/swift-ui/Picker/index.tsx -@@ -13,6 +13,15 @@ export type PickerProps = { - * For example: 'photo', 'heart.fill', 'star.circle' - */ - systemImage?: SFSymbol; -+ /** Native segmented options. Uses a scoped UISegmentedControl instead of SwiftUI Picker. */ -+ segments?: { label: string; value: T }[]; -+ /** Colors for the native segmented control. Only used with segments. */ -+ segmentColors?: { -+ background?: string; -+ selectedBackground?: string; -+ text?: string; -+ selectedText?: string; -+ }; - /** - * A label displayed on the picker. - */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f9e9c2f977e..e1ef8366399e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,7 +152,7 @@ patchedDependencies: '@clerk/expo-native-components@0.1.1': 5f3738f8e5d50bfa467ce8afbaf7c2aa7c7c9adb95a2307424eb908f6af0d299 '@effect/vitest@4.0.5': 2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02 '@expo/metro-config@58.0.14': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 - '@expo/ui@58.0.18': 11c3297659830464ef138e4e2b22b790350c901f57f5f01de07bcb70c43e32fc + '@expo/ui@58.0.18': 5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 '@pierre/diffs@1.5.2': 298e94372dbd51846a4e26ece2ecc3e22eb1f94b0450b1f1a9c6fec127809be7 @@ -326,7 +326,7 @@ importers: version: 58.0.14(@expo/log-box@58.0.12)(expo@58.0.11)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) '@expo/ui': specifier: 58.0.18 - version: 58.0.18(patch_hash=11c3297659830464ef138e4e2b22b790350c901f57f5f01de07bcb70c43e32fc)(@babel/core@7.29.7)(expo@58.0.11)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.18(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7)(expo@58.0.11)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) '@legendapp/list': specifier: 'catalog:' version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -14898,7 +14898,7 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@58.0.18(patch_hash=11c3297659830464ef138e4e2b22b790350c901f57f5f01de07bcb70c43e32fc)(@babel/core@7.29.7)(expo@58.0.11)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)': + '@expo/ui@58.0.18(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7)(expo@58.0.11)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)': dependencies: expo: 58.0.11(0279ba067f3e563cec3a046cc8b71592) react: 19.3.0 @@ -19639,7 +19639,7 @@ snapshots: expo-widgets@58.0.18(patch_hash=40806e85c756563935c7368b93d679bd3bb7742bff21d9c7611d9161e1aee602)(@babel/core@7.29.7)(expo@58.0.11)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: '@expo/plist': 0.10.1 - '@expo/ui': 58.0.18(patch_hash=11c3297659830464ef138e4e2b22b790350c901f57f5f01de07bcb70c43e32fc)(@babel/core@7.29.7)(expo@58.0.11)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + '@expo/ui': 58.0.18(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7)(expo@58.0.11)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) expo: 58.0.11(0279ba067f3e563cec3a046cc8b71592) react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) From ba3582222fcd378fab79d49aee6db65c4b4925b3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 15 Oct 2026 21:58:14 -0700 Subject: [PATCH 03/13] fix(mobile): extend iPad sidebar background behind header (#20149) --- apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index d86abc2a36b2..ab35ddfe5681 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -99,7 +99,7 @@ function WorkspaceRootContent() { return ( {Platform.OS === "ios" ? ( From 86534db4d3525a070589a234d660a3e0373a42ea Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 16 Oct 2026 05:37:25 +0000 Subject: [PATCH 04/13] refactor: prune narration and history comments (#19559) --- .coderabbit.config.ts | 7 +- .github/scripts/check-nightly-release.cjs | 6 - .github/scripts/mirror-play-apk.cjs | 1 - AGENTS.md | 2 +- .../gnome-extension/captureFeedback.js | 2 - .../desktop/gnome-extension/captureService.js | 1 - apps/desktop/scripts/build-browser-secret.mjs | 4 +- apps/desktop/scripts/dev-electron.mjs | 1 - apps/desktop/scripts/electron-launcher.mjs | 11 +- .../scripts/electron-launcher.test.mjs | 1 - apps/desktop/src/app/DesktopApp.ts | 13 - .../src/app/DesktopAppActivation.test.ts | 1 - apps/desktop/src/app/DesktopAppActivation.ts | 20 +- .../src/app/DesktopAppActivationBroker.ts | 1 - .../src/app/DesktopAppIdentity.test.ts | 5 - apps/desktop/src/app/DesktopAssets.ts | 2 +- apps/desktop/src/app/DesktopClerk.test.ts | 6 - apps/desktop/src/app/DesktopClerk.ts | 5 - .../desktop/src/app/DesktopCliCommand.test.ts | 8 - apps/desktop/src/app/DesktopCliCommand.ts | 25 - apps/desktop/src/app/DesktopCliShim.test.ts | 13 - apps/desktop/src/app/DesktopCliShim.ts | 34 +- .../src/app/DesktopConnectionCatalogStore.ts | 2 +- .../app/DesktopEarlyElectronStartup.test.ts | 1 - .../src/app/DesktopEarlyElectronStartup.ts | 9 - apps/desktop/src/app/DesktopEnvironment.ts | 8 - .../src/app/DesktopLegacyLocalStorage.ts | 15 +- .../src/app/DesktopLinuxUrlHandler.test.ts | 3 - .../desktop/src/app/DesktopLinuxUrlHandler.ts | 12 +- .../src/app/DesktopObservability.test.ts | 5 - apps/desktop/src/app/DesktopObservability.ts | 55 +- .../src/app/DesktopPreReadyFileSystem.ts | 5 - .../src/app/DesktopPreReadyPlatform.ts | 9 +- apps/desktop/src/app/DesktopUserData.ts | 1 - apps/desktop/src/app/DesktopWebLinks.test.ts | 4 - apps/desktop/src/app/DesktopWebLinks.ts | 13 - apps/desktop/src/app/chromiumLocalStorage.ts | 30 +- .../DesktopBackendConfiguration.test.ts | 43 -- .../backend/DesktopBackendConfiguration.ts | 139 +--- .../src/backend/DesktopBackendManager.test.ts | 33 +- .../src/backend/DesktopBackendManager.ts | 101 +-- .../src/backend/DesktopBackendPool.test.ts | 2 - .../desktop/src/backend/DesktopBackendPool.ts | 154 +--- .../DesktopLocalEnvironmentAuth.test.ts | 2 - .../backend/DesktopLocalEnvironmentAuth.ts | 5 - .../src/backend/DesktopNetworkInterfaces.ts | 2 +- .../src/backend/DesktopServerExposure.test.ts | 4 - .../src/backend/DesktopServerExposure.ts | 4 +- apps/desktop/src/boot.ts | 4 +- apps/desktop/src/compileCache.ts | 19 +- .../src/electron/ElectronProtocol.test.ts | 1 - apps/desktop/src/electron/ElectronProtocol.ts | 10 - apps/desktop/src/electron/ElectronShell.ts | 1 - apps/desktop/src/electron/ElectronWindow.ts | 3 - .../desktop/src/electron/WindowsForeground.ts | 1 - apps/desktop/src/ipc/DesktopIpc.ts | 4 - .../src/ipc/methods/localEnvironment.test.ts | 2 - apps/desktop/src/ipc/methods/preview.ts | 11 +- apps/desktop/src/ipc/methods/window.ts | 45 +- apps/desktop/src/ipc/methods/wsl.ts | 7 +- apps/desktop/src/legacyLocalStorageMerge.ts | 12 - apps/desktop/src/linuxSecretStorage.test.ts | 5 - apps/desktop/src/linuxSecretStorage.ts | 12 +- apps/desktop/src/mac-permission-preload.ts | 1 - apps/desktop/src/main.ts | 11 - .../src/permissions/MacPermissionHelper.ts | 3 - .../src/permissions/MacSettingsWindow.ts | 2 - apps/desktop/src/preload.ts | 4 +- .../BrowserImport/BrowserImport.test.ts | 13 - .../preview/BrowserImport/BrowserImport.ts | 36 +- .../BrowserImport/ChromiumCookies.test.ts | 5 - .../preview/BrowserImport/ChromiumCookies.ts | 60 +- .../src/preview/BrowserImport/ChromiumKeys.ts | 53 +- .../preview/BrowserImport/CookieDatabase.ts | 43 +- .../BrowserImport/FirefoxCookies.test.ts | 8 - .../preview/BrowserImport/FirefoxCookies.ts | 54 +- .../BrowserImport/LinuxBrowserSecret.ts | 1 - .../BrowserImport/SafariCookies.test.ts | 39 +- .../preview/BrowserImport/SafariCookies.ts | 74 +- .../preview/BrowserImport/SafariPermission.ts | 1 - .../src/preview/BrowserImport/Sources.test.ts | 42 +- .../src/preview/BrowserImport/Sources.ts | 175 +---- .../src/preview/BrowserSession.test.ts | 16 +- apps/desktop/src/preview/BrowserSession.ts | 51 +- apps/desktop/src/preview/CdpRelay.test.ts | 4 - apps/desktop/src/preview/CdpRelay.ts | 22 +- .../src/preview/DesktopBrowserHost.test.ts | 12 +- .../desktop/src/preview/DesktopBrowserHost.ts | 37 +- apps/desktop/src/preview/FaviconCapture.ts | 2 - apps/desktop/src/preview/GuestProtocol.ts | 1 - apps/desktop/src/preview/Manager.test.ts | 47 +- apps/desktop/src/preview/Manager.ts | 196 +---- .../desktop/src/preview/PasskeyAttestation.ts | 5 - apps/desktop/src/preview/PasskeyBackend.ts | 8 - apps/desktop/src/preview/PasskeyBackendMac.ts | 6 +- .../desktop/src/preview/PasskeyBridge.test.ts | 2 - apps/desktop/src/preview/PasskeyBridge.ts | 15 +- apps/desktop/src/preview/Passkeys.test.ts | 7 - apps/desktop/src/preview/Passkeys.ts | 26 +- apps/desktop/src/preview/PickPreload.ts | 27 +- .../src/preview/PickedElementPayload.ts | 12 - apps/desktop/src/preview/RecordingCursor.ts | 1 - apps/desktop/src/preview/RecordingInput.ts | 1 - .../src/preview/WebviewPreferences.test.ts | 25 - .../desktop/src/preview/WebviewPreferences.ts | 33 - .../src/settings/DesktopAppSettings.ts | 19 +- .../src/settings/DesktopSavedEnvironments.ts | 1 - .../src/shell/DesktopShellEnvironment.ts | 1 - apps/desktop/src/snapShot/ActiveWindow.ts | 9 +- .../snapShot/CaptureShortcutConfig.test.ts | 1 - .../src/snapShot/DesktopSnapShot.test.ts | 2 - apps/desktop/src/snapShot/DesktopSnapShot.ts | 11 - .../src/snapShot/GlobalShiftShortcutWorker.ts | 3 - .../desktop/src/snapShot/GnomeCaptureSetup.ts | 2 - apps/desktop/src/snapShot/KdeSnapShot.ts | 3 - .../src/snapShot/LinuxSnapShot.dbus.test.ts | 1 - apps/desktop/src/snapShot/LinuxSnapShot.ts | 2 - .../src/snapShot/NativeCaptureFeedback.ts | 1 - .../src/snapShot/NiriCaptureShortcut.ts | 1 - .../desktop/src/snapShot/NiriSnapShot.test.ts | 1 - apps/desktop/src/snapShot/NiriSnapShot.ts | 2 - .../PortalCaptureShortcut.dbus.test.ts | 1 - .../src/snapShot/PortalCaptureShortcut.ts | 8 +- apps/desktop/src/snapShot/RegionSnapShot.ts | 4 - .../src/snapShot/RegionSnapShotWorker.ts | 4 +- .../src/snapShot/SnapShotTransition.ts | 2 - .../src/snapShot/captureConfigEdit.test.ts | 1 - .../desktop/src/snapShot/captureConfigEdit.ts | 1 - apps/desktop/src/snapShot/captureConfigKdl.ts | 1 - apps/desktop/src/snapShot/snapShot.ts | 2 - .../src/telemetry/DesktopRendererHistory.ts | 1 - .../DesktopTelemetryPublisher.test.ts | 4 - .../telemetry/DesktopTelemetryPublisher.ts | 5 - .../src/updates/DesktopRemoteUpdates.test.ts | 17 +- .../src/updates/DesktopRemoteUpdates.ts | 23 +- .../src/updates/DesktopUpdates.test.ts | 3 - apps/desktop/src/updates/DesktopUpdates.ts | 28 +- apps/desktop/src/updates/releaseNotes.ts | 9 +- .../src/updates/remoteUpdateFlow.test.ts | 8 - apps/desktop/src/updates/remoteUpdateFlow.ts | 22 +- apps/desktop/src/updates/updateChannels.ts | 10 +- apps/desktop/src/updates/updateMachine.ts | 6 - .../desktop/src/updates/updatesTestHarness.ts | 10 - .../src/window/DesktopApplicationMenu.test.ts | 5 - .../src/window/DesktopApplicationMenu.ts | 6 +- apps/desktop/src/window/DesktopWindow.test.ts | 23 - apps/desktop/src/window/DesktopWindow.ts | 83 +-- apps/desktop/src/window/QuitHold.test.ts | 16 - apps/desktop/src/window/QuitHold.ts | 22 +- apps/desktop/src/wsl/DesktopWslBackend.ts | 63 +- .../src/wsl/DesktopWslEnvironment.test.ts | 76 -- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 164 +--- .../src/wsl/DesktopWslServerTree.test.ts | 6 - apps/desktop/src/wsl/DesktopWslServerTree.ts | 27 - apps/desktop/src/wsl/wslPathParsing.ts | 5 - apps/desktop/vite.config.ts | 8 +- .../scripts/stage-install-scripts.mjs | 4 - apps/marketing/src/lib/homeMotion.ts | 1 - apps/marketing/src/pages/schema/t3.json.ts | 2 - apps/mobile/app.config.ts | 12 +- apps/mobile/fingerprint.config.js | 1 - apps/mobile/metro.config.js | 2 - .../modules/t3-source-text/src/SourceText.tsx | 1 - .../src/T3SourceTextNativeComponent.ts | 20 - .../plugins/lib/addWidgetAssetCatalog.cjs | 13 +- .../plugins/withAndroidInputBackground.cjs | 2 - .../plugins/withAndroidModernAlertDialog.cjs | 12 - .../plugins/withAndroidModernPopupMenu.cjs | 22 +- .../withAndroidPredictiveBackCompat.cjs | 15 +- .../plugins/withAndroidTabletOrientation.cjs | 17 +- .../plugins/withShareExtensionDisplayName.cjs | 10 +- apps/mobile/plugins/withWidgetLogoAsset.cjs | 18 +- .../mobile/scripts/generate-device-stream.mts | 1 - .../scripts/generate-uniwind-themes.mts | 7 +- apps/mobile/src/App.tsx | 5 +- apps/mobile/src/Stack.tsx | 73 +- .../src/components/CompactBrandTitle.tsx | 7 - .../components/ComposerAttachmentStrip.tsx | 30 +- .../src/components/ComposerContextSheet.tsx | 1 - apps/mobile/src/components/ComposerEditor.tsx | 7 - .../mobile/src/components/ComposerToolbar.tsx | 12 - .../components/EnvironmentMachineSymbol.tsx | 1 - .../src/components/FilePreviewModal.tsx | 2 +- apps/mobile/src/components/LoadingScreen.tsx | 1 - .../src/components/MediaImagePreview.tsx | 1 - .../src/components/MediaSourceCaption.tsx | 1 - .../src/components/MediaVideoPlayer.tsx | 1 - apps/mobile/src/components/ProjectFavicon.tsx | 3 - apps/mobile/src/components/ProviderIcon.tsx | 8 - .../src/components/RenderErrorBoundary.tsx | 1 - .../ScrollComposeButton.android.tsx | 2 - apps/mobile/src/components/T3Wordmark.tsx | 4 - .../src/components/ui/AndroidAnchoredMenu.tsx | 32 +- apps/mobile/src/components/ui/AppBar.types.ts | 3 - apps/mobile/src/components/ui/AppSymbol.tsx | 11 - apps/mobile/src/components/ui/AppText.tsx | 4 - .../src/components/ui/FrostedCutout.ios.tsx | 1 - .../src/components/ui/FrostedCutout.tsx | 1 - .../src/components/ui/FrostedCutout.types.ts | 1 - .../src/components/ui/GlassBackdrop.tsx | 1 - .../mobile/src/components/ui/GlassSurface.tsx | 2 - apps/mobile/src/components/ui/ListRow.tsx | 1 - .../src/components/ui/ListRowLayout.tsx | 2 - .../src/components/ui/ListToggleRow.tsx | 1 - apps/mobile/src/components/ui/MenuPopup.tsx | 1 - .../src/components/ui/NativePresentation.tsx | 2 - .../src/components/ui/NavigationRow.tsx | 1 - .../src/components/ui/OverlayPortal.tsx | 7 +- .../mobile/src/components/ui/RowPressable.tsx | 1 - .../src/components/ui/ScreenContent.tsx | 2 - .../src/components/ui/ScreenScrollView.tsx | 1 - .../components/ui/SegmentedControl.types.ts | 1 - .../components/ui/useAndroidControlSizing.ts | 1 - .../connection/background-activity-scopes.ts | 4 +- .../environment-cache-store.test.ts | 3 - .../src/connection/environment-cache-store.ts | 2 - apps/mobile/src/connection/onboarding.ts | 1 - apps/mobile/src/dependency-graph.test.ts | 38 +- .../androidNotifications.test.ts | 1 - .../agent-awareness/androidNotifications.ts | 2 - .../foregroundNotificationBehavior.ts | 7 - .../agent-awareness/notificationNavigation.ts | 4 - .../agent-awareness/notificationPayload.ts | 5 - .../remoteRegistration.test.ts | 16 - .../agent-awareness/remoteRegistration.ts | 70 +- .../archive/ArchivedThreadsScreen.tsx | 1 - .../browser/BrowserPreviewRouteScreen.tsx | 7 - .../src/features/browser/BrowserTabMenu.tsx | 6 - .../features/browser/PreviewStreamWebView.tsx | 12 - .../features/browser/ThreadBrowserFloat.tsx | 2 - .../browser/browser-preview-button.tsx | 1 - .../src/features/browser/browserTabs.ts | 1 - .../browser/preview-stream-document.ts | 4 - .../browser/preview-stream.browser.ts | 24 - .../src/features/browser/previewPromptText.ts | 1 - .../src/features/cloud/CloudAuthProvider.tsx | 8 +- .../cloud/ConnectOnboardingRouteScreen.tsx | 7 - .../features/cloud/T3ConnectProfilePage.tsx | 10 - .../mobile/src/features/cloud/cloud-drafts.ts | 5 +- .../src/features/cloud/connectOnboarding.ts | 10 +- .../cloud/connectOnboardingNavigation.ts | 6 +- .../features/cloud/connectOnboardingOptOut.ts | 2 - .../features/cloud/managedRelayLayer.test.ts | 1 - .../src/features/cloud/managedRelayLayer.ts | 2 - .../connection/CloudEnvironmentRows.tsx | 28 - .../connection/ConnectionEnvironmentRow.tsx | 1 - .../connection/ConnectionFormField.tsx | 1 - .../connection/ConnectionStatusDot.tsx | 2 - .../features/connection/ConnectionTraceId.tsx | 1 - .../connection/ConnectionsNewRouteScreen.tsx | 14 - .../connection/LocalEnvironmentList.tsx | 1 - .../connection/PermissionUpdateNotice.tsx | 5 +- .../connection/environmentSections.ts | 4 - .../connection/environmentStatusText.ts | 5 - .../mobile/src/features/connection/pairing.ts | 4 +- .../devices/DevicePreviewRouteScreen.tsx | 4 - .../features/devices/DeviceStreamWebView.tsx | 1 - .../devices/device-stream-document.ts | 5 +- .../features/devices/device-stream.browser.ts | 1 - .../src/features/devices/shakeDetector.ts | 5 - .../SettingsDiagnosticsRouteScreen.tsx | 5 - .../diagnostics/crash-log-model.test.ts | 1 - .../features/diagnostics/crash-log-model.ts | 21 - .../diffs/nativeReviewDiffHighlighter.test.ts | 1 - .../features/diffs/nativeReviewDiffSurface.ts | 1 - .../features/files/AttachmentFileScreen.tsx | 10 - .../features/files/FileMarkdownPreview.tsx | 1 - .../mobile/src/features/files/FilesHeader.tsx | 2 +- .../src/features/files/SourceFileSurface.tsx | 20 +- .../features/files/ThreadFilesRouteScreen.tsx | 18 - .../files/WorkspaceFilePreviewError.tsx | 5 - .../files/WorkspaceFileVideoPreview.tsx | 1 - .../src/features/files/filePath.test.ts | 1 - apps/mobile/src/features/files/filePath.ts | 7 - .../features/files/workspaceFileAssetUrl.ts | 4 - .../features/home/AndroidHomeFab.shared.tsx | 1 - .../src/features/home/HomeHeader.android.tsx | 3 - apps/mobile/src/features/home/HomeHeader.tsx | 7 - .../src/features/home/HomeRouteScreen.tsx | 9 +- apps/mobile/src/features/home/HomeScreen.tsx | 67 -- .../src/features/home/ThreadListToolbar.tsx | 2 - .../home/WorkspaceConnectionTitle.tsx | 39 +- .../features/home/home-list-options.test.ts | 3 - .../src/features/home/home-list-options.ts | 4 - .../home/home-route-visibility.test.tsx | 1 - .../features/home/home-route-visibility.ts | 16 +- .../src/features/home/homeThreadList.test.ts | 2 - .../src/features/home/homeThreadList.ts | 2 - .../home/swipe-row-activation.test.ts | 1 - .../src/features/home/swipe-row-activation.ts | 9 +- .../src/features/home/thread-dismissal.ts | 2 - .../features/home/thread-swipe-actions.tsx | 42 +- .../mobile/src/features/home/threadArchive.ts | 4 - .../home/usePendingTaskListActions.ts | 2 - .../home/useThreadListActions.test.ts | 3 - .../src/features/home/useThreadListActions.ts | 7 - .../home/workspace-connection-status.ts | 2 - .../features/keyboard/commandPaletteItems.ts | 1 - .../keyboard/hardwareKeyboardCommands.ts | 8 - .../keyboard/threadKeyboardShortcuts.ts | 1 - .../layout/AdaptiveWorkspaceLayout.tsx | 52 +- .../createWorkspaceStackNavigator.ios.tsx | 5 - .../layout/native-glass-header-items.ts | 2 - .../layout/native-mail-search-toolbar.ts | 9 - .../layout/workspace-inspector-pane.tsx | 25 +- .../layout/workspace-pane-animation.ts | 10 +- .../layout/workspace-pane-divider.tsx | 2 - .../projects/AddProjectDestinationRoute.tsx | 1 - .../features/projects/AddProjectScreen.tsx | 34 +- .../features/projects/SourceControlIcon.tsx | 3 - .../prompt-links/prompt-link-actions.ts | 7 - .../prompt-links/prompt-link-model.ts | 17 - .../prompt-links/usePromptLinkResolver.ts | 17 - .../prompt-links/useThreadPromptLink.ts | 4 - .../pull-requests/PullRequestControls.tsx | 1 - .../pull-requests/PullRequestEntryRow.tsx | 1 - .../pull-requests/PullRequestListFilters.tsx | 1 - .../pull-requests/PullRequestMedia.tsx | 1 - .../PullRequestReviewersScreen.tsx | 1 - .../pull-requests/PullRequestRouteScreen.tsx | 5 - .../pull-requests/PullRequestsScreen.tsx | 5 - .../pull-requests/botBadgeImages.test.ts | 1 - .../features/pull-requests/botBadgeImages.ts | 3 - .../pull-requests/guide/GuideNotice.tsx | 1 - .../pull-requests/guide/GuideOutline.tsx | 1 - .../guide/PullRequestGuideScreen.tsx | 4 +- .../guide/ReviewGuideExcerpt.tsx | 1 - .../pull-requests/guide/guideDiffFocus.ts | 1 - .../pull-requests/guide/reviewGuideState.ts | 2 - .../pull-requests/pullRequestActions.ts | 1 - .../pullRequestActivityFilter.ts | 1 - .../pull-requests/pullRequestAgentTasks.ts | 3 - .../pull-requests/pullRequestAnimations.ts | 1 - .../pullRequestDescriptionContent.ts | 1 - .../pull-requests/pullRequestDiffComments.ts | 1 - .../pullRequestEnvironmentFallback.ts | 4 - .../pull-requests/pullRequestFileFilters.ts | 2 - .../features/pull-requests/pullRequestList.ts | 6 - .../pull-requests/pullRequestReviewDrafts.ts | 1 - .../pull-requests/pullRequestSelection.ts | 1 - .../pull-requests/pullRequestStack.ts | 1 - .../pull-requests/pullRequestStatus.ts | 1 - .../pull-requests/pullRequestTabScroll.ts | 1 - .../pull-requests/usePullRequestAction.ts | 1 - .../usePullRequestAgentActions.ts | 1 - .../usePullRequestEnvironmentFallback.ts | 1 - .../pull-requests/usePullRequestList.ts | 1 - .../usePullRequestListActions.ts | 2 +- .../usePullRequestListControls.ts | 1 - .../pull-requests/usePullRequestThreads.ts | 2 - .../src/features/review/ReviewCommentCard.tsx | 1 - .../src/features/review/ReviewSheet.tsx | 16 +- .../src/features/review/incrementalSnippet.ts | 3 - .../review/nativeReviewDiffAdapter.ts | 8 - .../features/review/reviewCommentSelection.ts | 3 - .../features/review/reviewDiffBridgeKeys.ts | 3 - .../review/reviewDiffHighlightScheduler.ts | 2 - .../mobile/src/features/review/reviewModel.ts | 2 - .../mobile/src/features/review/reviewState.ts | 2 - .../review/shikiReviewHighlighter.test.ts | 1 - .../features/review/shikiReviewHighlighter.ts | 4 - .../review/useReviewDiffPrewarming.ts | 2 - .../settings/EnvironmentRoutesSection.tsx | 12 - .../settings/SettingsAboutRouteScreen.tsx | 8 +- .../settings/SettingsAuthRouteScreen.tsx | 1 - .../SettingsMicrophoneRouteScreen.tsx | 8 - .../SettingsNotificationsRouteScreen.tsx | 11 - .../settings/SettingsRouteScreen.logic.ts | 1 - .../SettingsScheduledTasksRouteScreen.tsx | 4 - .../SettingsServerControlsRouteScreen.tsx | 2 - .../settings/SettingsThreadsRouteScreen.tsx | 12 - .../AppearancePreferencesProvider.tsx | 8 - .../components/AppearancePreviews.tsx | 14 - .../settings/appearance/useScaledTextRole.ts | 5 +- .../settings/autoSettleSettingsSync.ts | 1 - .../components/SettingsDragHandle.tsx | 1 - .../settings/components/SettingsScreen.tsx | 1 - .../settings/environment-maintenance.ts | 1 - .../settings/scheduledTaskDraft.test.ts | 1 - .../features/settings/scheduledTaskDraft.ts | 3 - .../settings-environment-filter.logic.ts | 1 - .../settings/settings-scoped-server.ts | 3 - .../features/sharing/incoming-share-inbox.ts | 8 +- .../sharing/incoming-share-model.test.ts | 4 - .../features/sharing/incoming-share-model.ts | 23 +- .../features/sharing/incoming-share-native.ts | 4 +- .../sharing/incoming-share-presentation.ts | 4 - .../src/features/shortcuts/appShortcuts.ts | 22 +- .../src/features/shortcuts/useAppShortcuts.ts | 8 - .../showcase/ShowcaseCaptureCoordinator.tsx | 23 +- .../features/showcase/nativeShowcaseScene.ts | 17 +- .../showcase/showcaseAgentActivity.ts | 7 - .../src/features/showcase/showcaseRetry.ts | 1 - .../showcase/stageShowcaseAgentActivity.ts | 11 - .../terminal/NativeTerminalSurface.tsx | 1 - .../terminal/TerminalContextSheet.tsx | 1 - .../terminal/ThreadTerminalRouteScreen.tsx | 12 - .../features/terminal/nativeTerminalModule.ts | 5 - .../features/terminal/terminalBufferReplay.ts | 2 - .../src/features/terminal/terminalDebugLog.ts | 7 - .../src/features/terminal/terminalInput.ts | 23 +- .../src/features/terminal/terminalMenu.ts | 9 +- .../src/features/terminal/terminalPaste.ts | 1 - .../src/features/terminal/terminalTheme.ts | 3 - .../features/terminal/useTerminalGridSync.ts | 1 - .../features/terminal/useTerminalLifecycle.ts | 3 - .../src/features/threads/BrowserAppIcon.tsx | 1 - .../threads/ComposerPopoverAnchor.android.tsx | 8 +- .../threads/ComposerPopoverAnchor.tsx | 1 - .../features/threads/ComposerPopoverHost.tsx | 10 +- .../features/threads/ComposerQueuedEdit.tsx | 9 - .../features/threads/ComposerUsageLimits.tsx | 8 - .../threads/CustomSnoozeSheet.ios.tsx | 1 - .../features/threads/HtmlRenderWebView.tsx | 19 - .../threads/McpAppFullscreenScreen.tsx | 15 +- .../src/features/threads/McpAppWebView.tsx | 61 +- .../threads/NewTaskContextPickerScreens.tsx | 1 - .../threads/NewTaskDraftRouteScreen.tsx | 5 - .../features/threads/NewTaskDraftScreen.tsx | 74 +- .../features/threads/NewTaskRouteScreen.tsx | 6 - .../features/threads/PendingApprovalCard.tsx | 2 - .../features/threads/PendingUserInputCard.tsx | 56 +- .../features/threads/ProjectCloneBanner.tsx | 5 - .../features/threads/ProviderSubagentBar.tsx | 10 +- .../features/threads/RequestActionButton.tsx | 1 - .../features/threads/SecretRequestCard.tsx | 9 - .../src/features/threads/SubagentRow.tsx | 6 - .../features/threads/ThreadAgentsSheet.tsx | 4 - .../threads/ThreadArrangementSheet.tsx | 5 - .../src/features/threads/ThreadComposer.tsx | 81 +- .../threads/ThreadCreationFailedCard.tsx | 5 - .../features/threads/ThreadDetailScreen.tsx | 126 +--- .../src/features/threads/ThreadFeed.tsx | 141 +--- .../features/threads/ThreadGitControls.tsx | 2 - .../features/threads/ThreadMarkdownImage.tsx | 11 +- .../threads/ThreadNavigationSidebar.tsx | 52 -- .../features/threads/ThreadQueueControl.tsx | 4 - .../features/threads/ThreadRouteScreen.tsx | 21 - .../threads/ThreadSettingsRows.shared.tsx | 1 - .../features/threads/ThreadSettingsSheet.tsx | 26 +- .../threads/checkout-new-task-branch.ts | 2 - .../threads/composerSendPresentation.ts | 11 - .../src/features/threads/fileChipMenu.ts | 4 - .../threads/floating-working-control.tsx | 23 +- .../threads/floating-working-status.ts | 14 - .../features/threads/git/GitOverviewSheet.tsx | 12 +- .../threads/git/gitSheetComponents.tsx | 4 - .../src/features/threads/markdownImageSize.ts | 1 - .../src/features/threads/mcpAppSizing.ts | 1 - .../threads/new-task-flow-provider.tsx | 78 +- .../new-task-project-selection.test.ts | 1 - .../threads/new-task-project-selection.ts | 9 - .../threads/pending-thread-feed.test.ts | 1 - .../features/threads/pending-thread-feed.ts | 1 - .../threads/pendingUserInputLayout.ts | 7 - .../projectThreadCreationValidation.ts | 9 - .../threads/provider-catalog-refresh.ts | 1 - .../features/threads/queued-message-icon.tsx | 1 - .../threads/sidebar-native-header-items.ts | 6 - .../threads/sidebar-navigation-shell.tsx | 18 - .../threads/subagent-card-presentation.ts | 2 - .../features/threads/thread-feed-item-size.ts | 5 +- .../features/threads/thread-list-v2-items.tsx | 111 +-- .../thread-list-v2-row-appearance.test.ts | 1 - .../threads/thread-list-v2-row-appearance.ts | 1 - .../threads/thread-provider-instance.test.ts | 2 - .../threads/thread-provider-instance.ts | 13 +- .../threads/thread-route-hydration.ts | 4 - .../features/threads/thread-row-emphasis.ts | 5 - .../threads/thread-settings-options.ts | 6 +- .../threads/thread-settings-sheet-state.ts | 8 - .../src/features/threads/thread-work-log.tsx | 24 +- .../threads/threadActivityFileNavigation.ts | 2 - .../threads/threadAgentsPresentation.ts | 2 - .../threads/threadContentPresentation.ts | 2 - .../features/threads/threadDragGap.test.ts | 1 - .../src/features/threads/threadDragGap.ts | 1 - .../src/features/threads/threadListV2.test.ts | 58 +- .../src/features/threads/threadListV2.ts | 176 +---- .../src/features/threads/threadOrder.ts | 44 +- .../threads/threadOrderAvailability.test.ts | 22 - .../threadQueueControlPresentation.test.ts | 1 - .../threads/threadQueueControlPresentation.ts | 3 - .../threads/use-composer-command-menu.test.ts | 1 - .../threads/use-composer-command-menu.ts | 12 - .../threads/use-legacy-plan-mode-enabled.ts | 5 - .../use-thread-list-v2-shelf-preferences.ts | 6 +- .../use-thread-settings-sheet-presentation.ts | 39 +- .../threads/use-visible-second-clock.test.tsx | 1 - .../threads/use-visible-second-clock.ts | 8 +- .../features/threads/use-worktree-setup.ts | 1 - .../src/features/threads/useFileChipShare.ts | 1 - .../threads/useThreadHeaderOptions.test.tsx | 6 - .../threads/useThreadHeaderOptions.tsx | 11 - .../src/features/threads/work-log-layout.tsx | 2 - .../features/threads/worktree-setup-card.tsx | 3 - .../threads/worktree-setup-state.test.ts | 1 - .../src/features/updates/app-updates.test.ts | 11 - .../src/features/updates/app-updates.ts | 85 +-- .../features/usage/UsageDailyChart.ios.tsx | 8 - .../src/features/usage/UsageDailyChart.tsx | 4 - .../src/features/usage/UsageLimitsPooled.tsx | 2 - .../src/features/usage/UsageLimitsSection.tsx | 24 - .../src/features/usage/UsageRouteScreen.tsx | 30 +- .../src/features/usage/usageChartData.ts | 8 - .../src/features/usage/usageProviders.ts | 13 - .../voice-input/ComposerDictationControl.tsx | 4 - .../voice-input/GlobalVoiceInputControl.tsx | 11 - .../voice-input/VoiceInputProvider.tsx | 9 +- .../features/voice-input/lazyVoiceRecorder.ts | 17 +- .../voice-input/useVoiceInputController.ts | 1 - .../voice-input/voiceInputMetering.ts | 1 - .../features/voice-input/voiceInputSession.ts | 1 - .../workspace/AndroidWorkspaceNavigation.tsx | 1 - .../AndroidWorkspaceTabsProvider.tsx | 1 - .../src/features/workspace/WorkspaceDock.tsx | 1 - .../workspace/WorkspaceNativeTabs.ios.tsx | 6 - .../workspace/WorkspaceRootScreen.android.tsx | 1 - .../workspace/WorkspaceRootScreen.tsx | 3 - .../workspace/WorkspaceSidebarTabs.ios.tsx | 1 - .../workspace/WorkspaceSidebarTabs.tsx | 1 - .../workspace/WorkspaceTabsContext.tsx | 1 - .../workspace/WorkspaceToolbar.ios.tsx | 2 - ...ndroid-workspace-navigation-colors.test.ts | 1 - .../android-workspace-navigation-colors.ts | 1 - .../useAndroidWorkspaceNavigation.ts | 1 - .../workspace/useSystemWorkspaceTabBar.ts | 1 - .../workspace/workspace-tabs-state.ts | 1 - apps/mobile/src/lib/adaptive-navigation.ts | 8 - .../src/lib/android-workspace-navigation.ts | 1 - apps/mobile/src/lib/androidControlSizing.ts | 2 - apps/mobile/src/lib/appLinking.ts | 4 - apps/mobile/src/lib/appearancePreferences.ts | 21 - apps/mobile/src/lib/assetImageSource.ts | 6 +- apps/mobile/src/lib/atomic-file.ts | 7 +- apps/mobile/src/lib/attachmentDocument.ts | 6 +- .../src/lib/attachmentDocumentPresentation.ts | 1 - apps/mobile/src/lib/attachmentDownload.ts | 9 - apps/mobile/src/lib/attachmentUpload.test.ts | 10 - apps/mobile/src/lib/attachmentUpload.ts | 57 +- apps/mobile/src/lib/composer-image-schema.ts | 1 - .../mobile/src/lib/composerAttachmentFiles.ts | 8 +- .../lib/composerAttachmentPreviewRetention.ts | 3 - .../lib/composerAttachmentUploadQueue.test.ts | 6 - .../src/lib/composerAttachmentUploadQueue.ts | 15 - .../src/lib/composerChipPresentation.test.ts | 5 - .../src/lib/composerChipPresentation.ts | 18 +- apps/mobile/src/lib/composerContext.test.ts | 1 - apps/mobile/src/lib/composerContext.ts | 11 - .../src/lib/composerContextClipboard.ts | 1 - apps/mobile/src/lib/composerEnterBehavior.ts | 6 - apps/mobile/src/lib/composerFiles.test.ts | 9 - apps/mobile/src/lib/composerImageFit.ts | 13 +- apps/mobile/src/lib/composerImages.test.ts | 6 - apps/mobile/src/lib/composerImages.ts | 29 +- apps/mobile/src/lib/debugLog.ts | 22 - .../src/lib/enrichedLinkPresentation.ts | 2 - apps/mobile/src/lib/filePreview.ts | 1 - apps/mobile/src/lib/filePreviewGallery.ts | 2 - apps/mobile/src/lib/followUpBehavior.ts | 8 - apps/mobile/src/lib/foreground-handoff.ts | 7 +- apps/mobile/src/lib/htmlRenderTheme.ts | 6 +- apps/mobile/src/lib/layout.ts | 12 - apps/mobile/src/lib/layoutMetrics.ts | 10 +- apps/mobile/src/lib/localAttachmentPreview.ts | 1 - apps/mobile/src/lib/markdownDocument.ts | 1 - apps/mobile/src/lib/markdownFileIcons.ts | 1 - apps/mobile/src/lib/markdownLinkIcons.ts | 1 - apps/mobile/src/lib/markdownLinks.ts | 10 +- apps/mobile/src/lib/markdownMedia.ts | 3 - apps/mobile/src/lib/markdownMediaSource.ts | 1 - apps/mobile/src/lib/mediaActionsSource.ts | 2 - .../mobile/src/lib/microphonePriority.test.ts | 1 - apps/mobile/src/lib/microphonePriority.ts | 22 +- apps/mobile/src/lib/mobileTheme.test.ts | 2 - apps/mobile/src/lib/mobileTheme.ts | 13 +- apps/mobile/src/lib/mobileThemeRuntime.ts | 5 - apps/mobile/src/lib/mobileThemeVariables.ts | 8 - apps/mobile/src/lib/modelOptions.test.ts | 13 - apps/mobile/src/lib/modelOptions.ts | 47 +- .../src/lib/projectFaviconDatabaseCache.ts | 17 +- apps/mobile/src/lib/projectFaviconRequests.ts | 8 - apps/mobile/src/lib/projectIcon.ts | 10 +- apps/mobile/src/lib/projectThreadStartTurn.ts | 7 - apps/mobile/src/lib/providerClients.ts | 2 - apps/mobile/src/lib/providerOptions.test.ts | 1 - apps/mobile/src/lib/providerOptions.ts | 5 - apps/mobile/src/lib/reserved-regions.ts | 6 - apps/mobile/src/lib/serialized-async-queue.ts | 4 - apps/mobile/src/lib/threadActivity.test.ts | 10 - apps/mobile/src/lib/threadActivity.ts | 39 - apps/mobile/src/lib/time.ts | 2 - apps/mobile/src/lib/typography.ts | 1 - .../lib/uniwind-platform-variants.fixture.cjs | 27 +- .../src/lib/uniwind-platform-variants.test.ts | 27 - apps/mobile/src/lib/useDelayedStatus.ts | 6 - apps/mobile/src/lib/useFontFamily.ts | 4 - apps/mobile/src/lib/useHoverGesture.ts | 2 - .../src/lib/useMobileNavigationTheme.ts | 6 - apps/mobile/src/lib/useNativePaste.ts | 8 - .../src/lib/useUiRuntimeMemoryWarningGc.ts | 1 - apps/mobile/src/lib/useUniwindTheme.ts | 8 - apps/mobile/src/lib/uuid.ts | 1 - apps/mobile/src/lib/videoAudioSession.test.ts | 1 - apps/mobile/src/lib/videoAudioSession.ts | 10 +- apps/mobile/src/lib/videoPreviewSource.ts | 2 - apps/mobile/src/lib/videoThumbnails.ts | 2 - apps/mobile/src/lib/wideMarkdownBlocks.ts | 29 +- .../mobile/src/native/NativeHeaderToolbar.tsx | 1 - .../src/native/NativeLayoutObserver.ios.tsx | 1 - .../src/native/NativeScrollViewMarker.tsx | 2 - apps/mobile/src/native/NativeSheetContent.tsx | 2 - .../src/native/NativeWorkspaceColumns.ios.tsx | 2 - .../src/native/NativeWorkspaceColumns.tsx | 1 - .../native/SelectableMarkdownText.types.ts | 7 +- apps/mobile/src/native/StackHeader.tsx | 3 - .../src/native/T3ComposerEditor.ios.tsx | 3 - .../src/native/T3ComposerEditor.native.tsx | 3 - .../src/native/T3ComposerEditor.types.ts | 8 +- apps/mobile/src/native/V5StackHeader.ios.tsx | 2 - .../src/native/composerEditorRevision.test.ts | 11 - .../src/native/composerEditorRevision.ts | 15 +- .../src/native/createV5StackNavigator.ios.tsx | 6 - apps/mobile/src/native/scrollEdgeEffects.ts | 10 +- apps/mobile/src/native/sheet-surface.ts | 4 - .../native/use-native-mail-search-toolbar.ts | 1 - .../src/native/useMarkdownImageSource.ts | 1 - apps/mobile/src/native/useOpenPullRequest.ts | 1 - .../src/native/v5-workspace-context.tsx | 1 - .../native/workspace-stack-projection.test.ts | 1 - .../src/native/workspace-stack-projection.ts | 2 - .../src/persistence/mobile-database.test.ts | 1 - .../src/persistence/mobile-preferences.ts | 13 - apps/mobile/src/persistence/mobile-storage.ts | 2 - apps/mobile/src/state/asset-url-state.test.ts | 2 - apps/mobile/src/state/asset-url-state.ts | 6 - apps/mobile/src/state/assets.ts | 3 - .../src/state/composer-attachment-uploads.ts | 1 - apps/mobile/src/state/entities.ts | 3 - apps/mobile/src/state/legacy-plan-mode.ts | 4 - apps/mobile/src/state/mediaActions.ts | 1 - apps/mobile/src/state/new-task-draft-key.ts | 12 - .../src/state/pending-new-tasks-model.ts | 13 - .../src/state/pending-task-editor-writes.ts | 4 - .../src/state/pending-thread-creation.test.ts | 9 - .../src/state/pending-thread-creation.ts | 33 +- apps/mobile/src/state/preferences.ts | 5 +- apps/mobile/src/state/preview.ts | 3 - apps/mobile/src/state/projectClones.ts | 9 - apps/mobile/src/state/queries.ts | 2 - .../mobile/src/state/queued-edit-draft-key.ts | 5 - apps/mobile/src/state/queued-run-edit.test.ts | 1 - apps/mobile/src/state/queued-run-edit.ts | 26 +- .../src/state/recover-failed-thread-draft.ts | 1 - apps/mobile/src/state/remote-runtime-types.ts | 1 - apps/mobile/src/state/session.test.ts | 2 - apps/mobile/src/state/session.ts | 2 - apps/mobile/src/state/shell.ts | 1 - .../mobile/src/state/thread-composer-error.ts | 9 +- .../src/state/thread-list-environments.ts | 1 - apps/mobile/src/state/thread-order.test.ts | 3 - apps/mobile/src/state/thread-order.ts | 2 - .../mobile/src/state/thread-outbox-manager.ts | 50 +- apps/mobile/src/state/thread-outbox-model.ts | 32 +- .../mobile/src/state/thread-outbox-removal.ts | 6 - .../mobile/src/state/thread-outbox-storage.ts | 2 - apps/mobile/src/state/thread-outbox.test.ts | 29 - apps/mobile/src/state/thread-outbox.ts | 9 +- .../src/state/thread-pr-presentation.ts | 5 - .../src/state/thread-provider-switching.ts | 6 - apps/mobile/src/state/usage.ts | 29 +- .../src/state/use-composer-drafts.test.ts | 49 +- apps/mobile/src/state/use-composer-drafts.ts | 118 +-- .../src/state/use-model-option-memory.ts | 7 - apps/mobile/src/state/use-pull-to-refresh.ts | 1 - .../state/use-remote-environment-registry.ts | 2 - .../use-selected-thread-git-actions.test.ts | 2 - .../use-selected-thread-git-actions.test.tsx | 7 - .../state/use-selected-thread-git-actions.ts | 6 +- .../src/state/use-selected-thread-requests.ts | 1 - .../src/state/use-thread-composer-state.ts | 38 - apps/mobile/src/state/use-thread-detail.ts | 4 - .../src/state/use-thread-outbox-drain.test.ts | 13 - .../src/state/use-thread-outbox-drain.ts | 124 +--- apps/mobile/src/state/use-thread-outbox.ts | 9 +- apps/mobile/src/state/use-thread-pr.ts | 4 - apps/mobile/src/state/use-thread-selection.ts | 11 - .../state/workspace-connection-atoms.test.ts | 1 - apps/mobile/src/state/workspaceModel.ts | 2 - apps/mobile/src/widgets/AgentActivity.test.ts | 23 +- apps/mobile/src/widgets/AgentActivity.tsx | 69 +- .../src/widgets/SubscriptionUsage.android.tsx | 3 - apps/mobile/src/widgets/SubscriptionUsage.tsx | 2 - .../widgets/SubscriptionUsageCoordinator.tsx | 1 - .../src/widgets/subscriptionUsageSnapshot.ts | 4 - apps/mobile/uniwind-types.d.ts | 1 - .../NetworkTransferMeasurement.integration.ts | 9 - .../SqlStatementCounter.integration.ts | 9 +- .../TransferBudgetReport.integration.ts | 17 +- .../integration/fixtures/transferBudget.ts | 1 - .../transferBudgetV2.integration.test.ts | 6 +- apps/server/scripts/acp-mock-agent.ts | 23 +- apps/server/scripts/acp-replay-agent.ts | 2 - apps/server/scripts/cli.ts | 19 - .../scripts/codexReplayRecordingRecords.ts | 10 - apps/server/scripts/evaluate-thread-titles.ts | 5 - apps/server/scripts/measure-pr-preview.ts | 2 - apps/server/scripts/migrate-dev-db.test.ts | 12 - apps/server/scripts/migrate-dev-db.ts | 80 +- apps/server/scripts/publishOrder.test.ts | 3 - apps/server/scripts/publishOrder.ts | 10 +- .../record-claude-agent-sdk-replay-fixture.ts | 7 - .../record-codex-app-server-replay-fixture.ts | 8 - .../record-cursor-agent-sdk-replay-fixture.ts | 2 - .../scripts/record-grok-acp-replay-fixture.ts | 34 - .../scripts/record-muse-msp-replay-fixture.ts | 23 - .../scripts/record-pi-rpc-replay-fixture.ts | 27 +- apps/server/scripts/t3-sqlite-state.test.ts | 1 - .../scripts/threadTitleEvaluationCases.ts | 1 - .../scripts/update-test-shard-weights.ts | 6 - apps/server/scripts/verify-background-live.ts | 11 - apps/server/src/assets/AssetAccess.test.ts | 10 - apps/server/src/assets/AssetAccess.ts | 31 +- apps/server/src/assets/AttachmentUpload.ts | 1 - apps/server/src/assets/GitHubMediaFetch.ts | 32 +- apps/server/src/assets/MediaFile.ts | 3 - .../src/assets/NativeAppIconResolver.ts | 2 - apps/server/src/assets/icns.ts | 5 +- apps/server/src/attachmentStore.test.ts | 3 - apps/server/src/attachmentStore.ts | 14 +- apps/server/src/auth/EnvironmentAuth.test.ts | 6 - apps/server/src/auth/EnvironmentAuth.ts | 20 +- .../src/auth/EnvironmentAuthPolicy.test.ts | 3 - apps/server/src/auth/McpOAuth.test.ts | 17 - apps/server/src/auth/McpOAuth.ts | 37 +- .../server/src/auth/PairingGrantStore.test.ts | 4 - apps/server/src/auth/PairingGrantStore.ts | 20 - apps/server/src/auth/RpcAuthorization.test.ts | 2 - apps/server/src/auth/RpcAuthorization.ts | 11 - apps/server/src/auth/ServerSecretStore.ts | 1 - apps/server/src/auth/SessionStore.test.ts | 3 - apps/server/src/auth/SessionStore.ts | 7 - apps/server/src/auth/dpop.ts | 1 - apps/server/src/auth/http.test.ts | 1 - apps/server/src/auth/http.ts | 8 +- apps/server/src/auth/mcpOAuthHtml.ts | 5 - apps/server/src/auth/mcpOAuthHttp.ts | 180 +++-- apps/server/src/auth/replayMarkers.test.ts | 2 - apps/server/src/auth/replayMarkers.ts | 12 +- apps/server/src/auth/utils.ts | 9 - apps/server/src/bin.ts | 11 +- .../src/checkpointing/CheckpointDiffQuery.ts | 25 - .../src/checkpointing/CheckpointStore.test.ts | 1 - .../src/checkpointing/CheckpointStore.ts | 44 -- apps/server/src/checkpointing/Diffs.ts | 6 - apps/server/src/checkpointing/Errors.ts | 5 - apps/server/src/claude-history-worker.ts | 3 - apps/server/src/claudeHistoryWorker.ts | 9 +- apps/server/src/claudeModelOptions.ts | 1 - apps/server/src/cli/acpMcpBridge.ts | 10 - apps/server/src/cli/browser.ts | 19 +- apps/server/src/cli/claudeHistory.ts | 7 +- apps/server/src/cli/config.test.ts | 12 - apps/server/src/cli/config.ts | 3 - apps/server/src/cli/connect.ts | 13 - apps/server/src/cli/invocation.test.ts | 10 +- apps/server/src/cli/invocation.ts | 41 +- apps/server/src/cli/pair.test.ts | 11 - apps/server/src/cli/pair.ts | 41 +- apps/server/src/cli/project.ts | 1 - apps/server/src/cli/server.ts | 1 - apps/server/src/cli/service.ts | 12 +- apps/server/src/cli/serviceLauncher.ts | 12 +- apps/server/src/cli/sshHelper.ts | 12 - apps/server/src/cli/theme.test.ts | 36 - apps/server/src/cli/theme.ts | 102 +-- apps/server/src/cli/trace.test.ts | 4 - apps/server/src/cli/trace.ts | 26 +- apps/server/src/cli/triage.test.ts | 3 - apps/server/src/cli/triage.ts | 36 +- apps/server/src/cli/triagePrompt.test.ts | 2 +- apps/server/src/cli/triagePrompt.ts | 12 +- apps/server/src/cli/uninstall.ts | 11 +- apps/server/src/cli/update.test.ts | 1 - apps/server/src/cli/update.ts | 61 +- apps/server/src/cli/updateProgress.test.ts | 1 - apps/server/src/cli/updateProgress.ts | 1 - apps/server/src/cloud/CliState.test.ts | 1 - apps/server/src/cloud/CliState.ts | 11 - apps/server/src/cloud/CliTokenManager.test.ts | 7 - apps/server/src/cloud/CliTokenManager.ts | 26 - .../src/cloud/CloudLink.lifecycle.test.ts | 44 +- apps/server/src/cloud/CloudLink.test.ts | 5 - apps/server/src/cloud/CloudLink.ts | 109 +-- .../src/cloud/ManagedEndpointRuntime.test.ts | 32 - .../src/cloud/ManagedEndpointRuntime.ts | 68 +- apps/server/src/cloud/bootService.test.ts | 13 +- apps/server/src/cloud/bootService.ts | 87 +-- apps/server/src/cloud/config.ts | 15 +- apps/server/src/cloud/http.test.ts | 3 - apps/server/src/cloud/http.ts | 3 - apps/server/src/cloud/linkChecks.ts | 9 - .../src/cloud/managedTunnelStartup.test.ts | 1 - apps/server/src/cloud/managedTunnelStartup.ts | 7 +- apps/server/src/cloud/pinnedRuntime.test.ts | 3 - apps/server/src/cloud/pinnedRuntime.ts | 28 +- apps/server/src/cloud/publicConfig.ts | 12 - apps/server/src/cloud/relayResponse.ts | 8 +- apps/server/src/cloud/selfUpdate.test.ts | 4 - apps/server/src/cloud/selfUpdate.ts | 5 - apps/server/src/cloud/servicePreflight.ts | 1 - apps/server/src/cloud/serviceProtocol.ts | 5 - .../src/cloud/traceRelayRequest.test.ts | 2 - apps/server/src/cloud/traceRelayRequest.ts | 15 - apps/server/src/cloud/updateHandoff.ts | 13 +- apps/server/src/compileCache.ts | 5 +- apps/server/src/config.ts | 28 - .../desktopUpdate/DesktopAppUpdate.test.ts | 8 - .../src/desktopUpdate/DesktopAppUpdate.ts | 10 +- apps/server/src/device/AgentDeviceShim.ts | 6 - apps/server/src/device/DeviceActions.ts | 32 +- apps/server/src/device/DeviceHost.ts | 26 - apps/server/src/device/DeviceHubProxy.ts | 24 +- apps/server/src/device/DeviceService.test.ts | 2 - apps/server/src/device/DeviceService.ts | 33 +- apps/server/src/device/DeviceToolchain.ts | 35 +- apps/server/src/device/LocalDeviceHost.ts | 30 +- .../src/device/deviceToolMaintenance.ts | 2 +- .../src/device/localSshDeviceHost.test.ts | 1 - apps/server/src/device/localSshDeviceHost.ts | 1 - .../server/src/device/sshDeviceScript.test.ts | 2 - apps/server/src/device/sshDeviceScript.ts | 1 - .../src/diagnostics/TraceDiagnostics.test.ts | 5 - .../src/diagnostics/TraceDiagnostics.ts | 14 +- apps/server/src/entrypoint.test.ts | 2 - apps/server/src/entrypoint.ts | 7 +- .../src/environment/DirectEndpoints.test.ts | 5 - .../server/src/environment/DirectEndpoints.ts | 25 +- .../src/environment/RemoteOpenTargets.test.ts | 2 - .../src/environment/RemoteOpenTargets.ts | 14 - .../src/environment/ServerEnvironment.test.ts | 10 - .../src/environment/ServerEnvironment.ts | 16 +- .../environment/ServerEnvironmentMachine.ts | 62 +- apps/server/src/environmentTheme.test.ts | 28 +- apps/server/src/environmentTheme.ts | 75 +- apps/server/src/git/GitManager.test.ts | 83 +-- apps/server/src/git/GitManager.ts | 128 +--- apps/server/src/git/GitWorkflowService.ts | 7 +- .../src/git/linkCreatedPullRequest.test.ts | 1 - apps/server/src/git/linkCreatedPullRequest.ts | 13 +- .../git/testing/StackRetention.fixture.mjs | 1 - apps/server/src/htmlRender/HtmlRender.test.ts | 12 - apps/server/src/htmlRender/HtmlRender.ts | 27 - apps/server/src/htmlRender/headlessChrome.ts | 40 +- .../server/src/htmlRender/publicProxy.test.ts | 10 - apps/server/src/htmlRender/publicProxy.ts | 36 +- apps/server/src/http.test.ts | 3 - apps/server/src/http.ts | 38 +- apps/server/src/httpCors.ts | 1 - .../server/src/httpResponseErrorGuard.test.ts | 1 - apps/server/src/httpResponseErrorGuard.ts | 21 +- apps/server/src/imageMime.test.ts | 6 - apps/server/src/imageMime.ts | 17 +- apps/server/src/keybindings.test.ts | 2 - apps/server/src/keybindings.ts | 52 -- apps/server/src/mcp/AcpMcpStdioBridge.test.ts | 2 - apps/server/src/mcp/AcpMcpStdioBridge.ts | 27 +- apps/server/src/mcp/McpDeviceToolkit.test.ts | 2 +- apps/server/src/mcp/McpHttpServer.test.ts | 10 - apps/server/src/mcp/McpHttpServer.ts | 58 +- apps/server/src/mcp/McpInvocationContext.ts | 25 +- .../server/src/mcp/McpSessionRegistry.test.ts | 2 - apps/server/src/mcp/McpSessionRegistry.ts | 22 - .../server/src/mcp/McpToolAccess.race.test.ts | 7 - apps/server/src/mcp/McpToolAccess.test.ts | 18 - apps/server/src/mcp/McpToolAccess.testkit.ts | 13 - apps/server/src/mcp/McpToolAccess.ts | 79 +- .../OrchestratorMcpService.activity.test.ts | 5 +- .../src/mcp/OrchestratorMcpService.test.ts | 39 - apps/server/src/mcp/OrchestratorMcpService.ts | 121 +-- ...OrchestratorMcpToolkit.integration.test.ts | 113 --- .../src/mcp/PreviewAutomationBroker.test.ts | 4 - .../server/src/mcp/PreviewAutomationBroker.ts | 34 +- ...skCancelNativeSubagent.integration.test.ts | 7 +- .../server/src/mcp/WorktreeMcpService.test.ts | 24 +- apps/server/src/mcp/WorktreeMcpService.ts | 43 +- apps/server/src/mcp/threadAccess.ts | 23 - .../src/mcp/toolkits/attachment/handlers.ts | 6 +- apps/server/src/mcp/toolkits/core.test.ts | 8 - .../src/mcp/toolkits/device/handlers.ts | 9 - apps/server/src/mcp/toolkits/device/tools.ts | 8 - .../mcp/toolkits/environment/handlers.test.ts | 3 - .../src/mcp/toolkits/environment/handlers.ts | 1 - apps/server/src/mcp/toolkits/html/handlers.ts | 2 - apps/server/src/mcp/toolkits/html/tools.ts | 1 - .../src/mcp/toolkits/orchestrator/handlers.ts | 4 - .../src/mcp/toolkits/orchestrator/tools.ts | 1 - .../src/mcp/toolkits/preview/handlers.ts | 8 +- apps/server/src/mcp/toolkits/preview/tools.ts | 4 - .../mcp/toolkits/previewControls/handlers.ts | 1 - .../src/mcp/toolkits/project/handlers.test.ts | 7 - .../src/mcp/toolkits/project/handlers.ts | 4 +- .../toolkits/pullRequests/handlers.test.ts | 5 - .../src/mcp/toolkits/pullRequests/handlers.ts | 31 - .../src/mcp/toolkits/pullRequests/tools.ts | 6 - .../src/mcp/toolkits/thread/handlers.ts | 3 - .../toolkits/worktree/registration.test.ts | 5 - .../server/src/mcp/toolkits/worktree/tools.ts | 1 - .../src/mcpApps/McpAppModelContext.test.ts | 5 +- apps/server/src/mcpApps/McpAppModelContext.ts | 9 - .../server/src/mcpApps/McpAppRequests.test.ts | 7 - apps/server/src/mcpApps/McpAppRequests.ts | 17 +- apps/server/src/mcpApps/McpAppSnapshot.ts | 11 - .../src/observability/DefectReporter.test.ts | 6 - .../src/observability/DefectReporter.ts | 7 +- .../observability/EventLoopMonitor.test.ts | 6 - .../src/observability/EventLoopMonitor.ts | 33 +- apps/server/src/observability/HeapSnapshot.ts | 14 - apps/server/src/observability/Metrics.test.ts | 1 - apps/server/src/observability/Metrics.ts | 9 - .../server/src/observability/Observability.ts | 1 - .../observability/OtlpEndpointCheck.test.ts | 1 - .../src/observability/OtlpEndpointCheck.ts | 7 - .../observability/RpcInstrumentation.test.ts | 10 +- .../src/observability/RpcInstrumentation.ts | 15 +- .../AcpRegistryOrchestratorV2.live.test.ts | 7 - .../Adapters/AcpAdapterV2.test.ts | 227 +----- .../Adapters/AcpAdapterV2.testkit.ts | 6 - .../Adapters/AcpRegistryAdapterV2.test.ts | 6 - .../Adapters/AntigravityAdapterV2.test.ts | 8 - .../Adapters/AntigravityAdapterV2.ts | 14 +- .../Adapters/ClaudeAdapterV2.test.ts | 237 ------ .../Adapters/ClaudeAdapterV2.testkit.ts | 28 - .../Adapters/ClaudeAdapterV2.ts | 699 ++---------------- .../ClaudeAgentSdkQueryRunner.test.ts | 7 +- .../Adapters/CodexAdapterV2.test.ts | 39 - .../Adapters/CodexAdapterV2.ts | 202 +---- .../Adapters/CursorAdapterV2.testkit.ts | 14 - .../Adapters/GrokAdapterV2.test.ts | 16 +- .../Adapters/GrokAdapterV2.testkit.ts | 4 - .../Adapters/MuseAdapterV2.testkit.ts | 37 +- .../Adapters/OpenCode2AdapterV2.test.ts | 276 ------- .../Adapters/OpenCode2AdapterV2.testkit.ts | 28 - .../Adapters/OpenCodeAdapterV2.testkit.ts | 15 - .../Adapters/PiAdapterV2.testkit.ts | 44 -- .../Adapters/claudeWorkflowProgress.ts | 23 +- .../orchestration-v2/AttachmentClaims.test.ts | 12 - .../src/orchestration-v2/AttachmentClaims.ts | 8 +- .../BackgroundWorkStop.integration.test.ts | 21 - .../CheckpointCaptureService.test.ts | 18 - .../CheckpointRestoreSafety.test.ts | 2 - .../CheckpointRestoreSafety.ts | 1 - .../CheckpointRollbackService.test.ts | 4 - .../CheckpointRollbackService.ts | 3 - .../src/orchestration-v2/CheckpointService.ts | 1 - ...laudeAutomaticDelivery.integration.test.ts | 3 - .../orchestration-v2/CommandPolicy.test.ts | 6 - .../src/orchestration-v2/CommandPolicy.ts | 1 - .../orchestration-v2/CommandReceiptStore.ts | 10 - .../ContextHandoffBudget.test.ts | 2 - .../ContextHandoffDelivery.ts | 6 - .../CursorOrchestratorV2.live.test.ts | 1 - .../DelegatedCompletionDelivery.test.ts | 30 - .../DelegatedTaskQuestionReactor.ts | 7 - .../DispatchModeLimit.test.ts | 5 - .../src/orchestration-v2/DispatchModeLimit.ts | 13 +- .../src/orchestration-v2/EffectOutbox.ts | 38 +- .../src/orchestration-v2/EffectWorker.test.ts | 4 - .../src/orchestration-v2/EffectWorker.ts | 18 - apps/server/src/orchestration-v2/EventSink.ts | 33 +- .../server/src/orchestration-v2/EventStore.ts | 1 - .../FoundationPersistence.test.ts | 27 - .../GrokOrchestratorV2.live.test.ts | 1 - .../orchestration-v2/LiveStreamBudget.test.ts | 5 - .../src/orchestration-v2/LiveStreamBudget.ts | 11 +- .../orchestration-v2/NotificationMailbox.ts | 2 - ...penCode2OrchestratorV2.integration.test.ts | 115 ++- .../OpenCode2OrchestratorV2.live.test.ts | 69 -- .../Orchestrator.control-reads.test.ts | 7 - .../Orchestrator.migration.test.ts | 4 - .../src/orchestration-v2/Orchestrator.ts | 222 +----- .../orchestration-v2/ProjectCommands.test.ts | 1 - .../src/orchestration-v2/ProjectCommands.ts | 15 +- ...ProjectSettingsUpgrade.integration.test.ts | 4 - .../src/orchestration-v2/ProjectStore.ts | 6 +- .../ProjectionControlReads.test.ts | 2 - .../orchestration-v2/ProjectionMaintenance.ts | 11 - .../ProjectionRecovery.test.ts | 8 - .../ProjectionSettlement.test.ts | 4 - .../orchestration-v2/ProjectionStore.test.ts | 67 -- .../src/orchestration-v2/ProjectionStore.ts | 121 +-- .../ProviderAdapterRegistry.ts | 5 - .../ProviderContinuationService.test.ts | 6 - .../ProviderContinuationService.ts | 36 +- .../ProviderEventIngestor.test.ts | 7 - .../orchestration-v2/ProviderEventIngestor.ts | 40 +- ...ProviderRuntimeRecoveryPerformance.test.ts | 6 - .../ProviderRuntimeRecoveryService.test.ts | 37 +- .../ProviderRuntimeRecoveryService.ts | 35 - .../ProviderSessionManager.test.ts | 129 ---- .../ProviderSessionManager.ts | 210 +----- .../ProviderSwitchService.test.ts | 4 - .../orchestration-v2/ProviderSwitchService.ts | 6 - .../ProviderTurnControlService.test.ts | 2 - .../ProviderTurnControlService.ts | 5 +- .../ProviderTurnStartService.test.ts | 10 - .../ProviderTurnStartService.ts | 41 +- .../PullRequestSyncReactor.test.ts | 14 - .../PullRequestSyncReactor.ts | 26 +- .../PullRequestWatchReactor.ts | 84 +-- .../ResourceCleanupService.ts | 1 - .../RestartBackgroundNote.test.ts | 13 - .../orchestration-v2/RestartBackgroundNote.ts | 53 +- .../RestartContinuation.test.ts | 11 - .../orchestration-v2/RestartContinuation.ts | 29 +- .../RunExecutionService.test.ts | 37 - .../orchestration-v2/RunExecutionService.ts | 90 +-- .../orchestration-v2/RuntimePolicy.test.ts | 3 - .../src/orchestration-v2/RuntimePolicy.ts | 14 - .../SelectionRestart.integration.test.ts | 9 - .../src/orchestration-v2/ShellStream.test.ts | 2 - .../src/orchestration-v2/ShellStream.ts | 42 +- .../SteeringCompletion.integration.test.ts | 9 - .../SubagentProjection.test.ts | 2 - .../orchestration-v2/ThreadCommandExecutor.ts | 1 - .../src/orchestration-v2/ThreadDeletion.ts | 1 - .../src/orchestration-v2/ThreadFind.test.ts | 6 - .../server/src/orchestration-v2/ThreadFind.ts | 7 - .../ThreadFork.execution.test.ts | 2 - .../src/orchestration-v2/ThreadForkService.ts | 4 - .../ThreadLaunchService.test.ts | 23 - .../orchestration-v2/ThreadLaunchService.ts | 33 +- .../ThreadLiveEventCoalescer.test.ts | 1 - .../ThreadLiveEventCoalescer.ts | 8 - .../ThreadManagementService.test.ts | 8 - .../ThreadManagementService.ts | 63 +- .../ThreadMessageIntake.test.ts | 19 - .../orchestration-v2/ThreadMessageIntake.ts | 7 - .../ThreadPullRequestService.test.ts | 5 - .../ThreadPullRequestService.ts | 11 - .../src/orchestration-v2/ThreadSearch.test.ts | 3 - .../src/orchestration-v2/ThreadSearch.ts | 9 - .../ThreadSettlementService.test.ts | 22 - .../ThreadSettlementService.ts | 51 +- .../src/orchestration-v2/ThreadStop.test.ts | 23 - .../src/orchestration-v2/ThreadStream.test.ts | 7 - .../src/orchestration-v2/ThreadStream.ts | 19 +- .../ThreadTitleRegenerationService.ts | 2 - .../ThreadTransportPerformance.test.ts | 7 - .../orchestration-v2/TurnStartReads.test.ts | 1 - .../UsageLimitRecoveryWorker.ts | 3 - .../src/orchestration-v2/UserFacingErrors.ts | 6 - .../orchestration-v2/V1ImportBoundary.test.ts | 10 - .../orchestration-v2/WireProjection.test.ts | 3 - .../src/orchestration-v2/WireProjection.ts | 24 +- .../orchestration-v2/assistantStreaming.ts | 26 +- .../boundedSnapshotTransport.test.ts | 7 - apps/server/src/orchestration-v2/http.ts | 9 +- .../LegacyV1Cutover.integration.test.ts | 51 +- .../legacy/LegacyV1ThreadImporter.ts | 1 - .../orchestration-v2/pullRequestWatch.test.ts | 8 - .../src/orchestration-v2/pullRequestWatch.ts | 15 - .../src/orchestration-v2/runtimeLayer.test.ts | 66 -- .../src/orchestration-v2/runtimeLayer.ts | 1 - .../ClaudeReplayFixtures.integration.test.ts | 2 - .../CodexReplayFixtures.integration.test.ts | 6 - ...rchestratorReplayFixtures.contract.test.ts | 2 - ...estratorReplayFixtures.integration.test.ts | 12 - ...estratorReplayRecovery.integration.test.ts | 5 - ...yRestartBackgroundNote.integration.test.ts | 19 +- .../testkit/OrchestratorScenario.ts | 15 +- .../testkit/ProviderReplayHarness.ts | 16 - .../ProviderSwitch.integration.test.ts | 18 - .../ProviderTurnStartMemory.fixture.mjs | 1 - .../testkit/ReplayRuntimeInstructions.ts | 1 - .../ThreadMergeBack.integration.test.ts | 3 - .../claude_background_monitor_wake/input.ts | 2 - .../input.ts | 4 - .../output.ts | 14 - .../output.ts | 19 - .../claude_background_task_interrupt/input.ts | 1 - .../output.ts | 3 +- .../claude_background_task_wake/input.ts | 3 - .../claude_background_task_wake/output.ts | 8 - .../input.ts | 2 - .../output.ts | 4 - .../input.ts | 4 - .../output.ts | 4 - .../claude_compact_after_peer_turn/input.ts | 4 - .../claude_compact_after_peer_turn/output.ts | 4 - .../claude_compact_after_resume_wake/input.ts | 4 - .../fixtures/claude_idle_resume/input.ts | 11 - .../fixtures/claude_idle_resume/output.ts | 4 - .../claude_mcp_tool_presentation/output.ts | 3 - .../input.ts | 4 - .../claude_nested_subagent_model/input.ts | 2 - .../claude_nested_subagent_model/output.ts | 6 - .../fixtures/claude_result_is_error/input.ts | 8 +- .../fixtures/claude_result_is_error/output.ts | 8 - .../grok_auto_blocked_command/input.ts | 1 - .../grok_auto_blocked_command/output.ts | 3 - .../fixtures/grok_background_bash/input.ts | 4 - .../fixtures/grok_background_bash/output.ts | 7 - .../grok_background_bash_fast_wake/input.ts | 6 - .../grok_background_subagent/input.ts | 3 - .../grok_background_subagent/output.ts | 10 +- .../testkit/fixtures/grok_monitor/input.ts | 5 - .../testkit/fixtures/grok_monitor/output.ts | 6 - .../fixtures/grok_prompt_error/input.ts | 6 - .../fixtures/grok_prompt_error/output.ts | 2 - .../testkit/fixtures/index.ts | 12 - .../malformed_session_update/input.ts | 5 - .../malformed_session_update/output.ts | 1 - .../fixtures/multi_turn/muse_output.ts | 5 - .../testkit/fixtures/multi_turn/pi_output.ts | 4 - .../testkit/fixtures/muse_permission/input.ts | 5 - .../fixtures/muse_permission/output.ts | 7 +- .../fixtures/muse_subagent_approval/input.ts | 4 - .../fixtures/muse_subagent_approval/output.ts | 2 +- .../fixtures/muse_subagent_interrupt/input.ts | 1 - .../muse_subagent_interrupt/output.ts | 4 - .../testkit/fixtures/muse_workflow/input.ts | 6 - .../testkit/fixtures/muse_workflow/output.ts | 7 - .../fixtures/opencode2_background/input.ts | 5 - .../fixtures/opencode2_background/output.ts | 12 - .../fixtures/opencode2_command/output.ts | 5 - .../fixtures/opencode2_compaction/output.ts | 2 - .../testkit/fixtures/opencode2_inbox/input.ts | 5 - .../fixtures/opencode2_inbox/output.ts | 9 - .../fixtures/opencode2_interrupt/input.ts | 1 - .../fixtures/opencode2_interrupt/output.ts | 4 - .../opencode2_nested_background/input.ts | 5 - .../opencode2_nested_background/output.ts | 10 - .../fixtures/opencode2_permission/input.ts | 1 - .../fixtures/opencode2_permission/output.ts | 5 - .../fixtures/opencode2_question/input.ts | 1 - .../fixtures/opencode2_question/output.ts | 5 - .../opencode2_resume_after_restart/output.ts | 7 - .../fixtures/opencode2_revert/input.ts | 1 - .../fixtures/opencode2_revert/output.ts | 5 - .../fixtures/opencode2_simple/output.ts | 2 - .../fixtures/opencode2_skill/output.ts | 5 - .../fixtures/opencode2_subagent/output.ts | 7 - .../fixtures/opencode2_tool_call/output.ts | 3 - .../opencode_child_approval/output.ts | 3 - .../opencode_running_child_approval/output.ts | 7 - .../testkit/fixtures/pi_compaction/input.ts | 9 +- .../testkit/fixtures/pi_compaction/output.ts | 6 - .../fixtures/plan_questions/codex_output.ts | 2 - .../plan_questions/opencode_output.ts | 3 - .../fixtures/proposed_plan/codex_output.ts | 3 - .../provider_thread_resume/pi_output.ts | 5 - .../testkit/fixtures/shared.ts | 35 - .../testkit/fixtures/simple/pi_output.ts | 9 +- .../skill_invocation/cursor_output.ts | 3 - .../input.ts | 10 - .../output.ts | 3 - .../fixtures/subagent/claude_output.ts | 1 - .../testkit/fixtures/subagent/codex_output.ts | 1 - .../subagent_v2_approval/codex_output.ts | 2 - .../fixtures/subagent_v2_approval/input.ts | 1 - .../subagent_v2_nested/codex_output.ts | 4 - .../codex_output.ts | 7 - .../fixtures/thread_rollback/codex_output.ts | 1 - .../fixtures/thread_rollback/pi_output.ts | 9 - .../thread_rollback_after_restart/input.ts | 3 +- .../thread_rollback_after_stop/input.ts | 5 +- .../thread_rollback_after_stop/pi_output.ts | 6 +- .../codex_output.ts | 5 - .../thread_rollback_to_stopped_turn/input.ts | 4 - .../fixtures/tool_call_denied_write/input.ts | 5 - .../tool_call_read_only_on_request/output.ts | 9 - .../fixtures/turn_interrupt/muse_output.ts | 4 - .../turn_interrupt_mid_tool/codex_output.ts | 2 - .../turn_interrupt_mid_tool/pi_output.ts | 7 - .../fixtures/web_search/codex_output.ts | 1 - .../testkit/pullRequestFixtures.ts | 1 - .../threadHistoryPaging.test.ts | 19 - .../orchestration-v2/threadHistoryPaging.ts | 55 +- .../toolOutputImageBytes.test.ts | 1 - .../orchestration-v2/toolOutputImageBytes.ts | 27 +- .../src/orchestration-v2/unknownField.ts | 1 - .../workflowAgentAnswers.test.ts | 4 - .../orchestration-v2/workflowAgentAnswers.ts | 6 - .../src/orchestration-v2/workflowFileRead.ts | 16 - .../workflowScriptQuery.test.ts | 5 - apps/server/src/outboundHttp.test.ts | 5 - apps/server/src/outboundHttp.ts | 17 +- apps/server/src/persistence/AuthSessions.ts | 2 - apps/server/src/persistence/Errors.ts | 15 +- apps/server/src/persistence/Migrations.ts | 44 +- .../016_CanonicalizeModelSelections.test.ts | 3 - .../016_CanonicalizeModelSelections.ts | 1 - ..._CanonicalizeModelSelectionOptions.test.ts | 7 - .../026_CanonicalizeModelSelectionOptions.ts | 24 - .../027_ProviderSessionRuntimeInstanceId.ts | 19 +- .../037_ProjectionTurnsKeysetIndex.ts | 9 +- .../041_AuthSessionClientConnection.ts | 3 - ...epairAutomaticSettlementTimestamps.test.ts | 5 - ...046_RepairAutomaticSettlementTimestamps.ts | 11 +- ...49_ProjectionThreadsActiveOrderKey.test.ts | 2 - .../050_ProjectionThreadPullRequests.test.ts | 1 - .../Migrations/053_PullRequestFilesViewed.ts | 5 +- ...jectionThreadsAutoSettleDisabledAt.test.ts | 1 - .../Migrations/055_OrchestrationV2.ts | 3 - .../Migrations/059_McpAppModelContext.ts | 3 - .../060_ThreadSnapshotWindowIndexes.ts | 7 +- .../Migrations/061_PullRequestReviewGuides.ts | 3 - .../062_ThreadFailureLookupIndex.ts | 1 - .../ApplicationEventSequenceIndexes.ts | 1 - .../OrchestrationV2/ApplicationEventSource.ts | 6 +- .../OrchestrationV2/EffectCancellation.ts | 1 - .../Migrations/OrchestrationV2/Foundation.ts | 10 +- .../OrchestrationV2/LegacyV1ImportState.ts | 6 - .../ProviderSessionBindings.ts | 1 - .../OrchestrationV2/RecoveryIndexes.ts | 1 - .../OrchestrationV2/ScheduledTasks.ts | 1 - .../OrchestrationV2/ShellIndexes.ts | 2 - .../Migrations/OrchestrationV2/Subagents.ts | 1 - .../OrchestrationV2/ThreadLaunchWorkflows.ts | 1 - .../OrchestrationCommandReceipts.ts | 19 - .../OrchestrationEventStore.test.ts | 7 - .../persistence/OrchestrationEventStore.ts | 41 +- .../src/persistence/ProviderSessionRuntime.ts | 40 - .../src/persistence/PullRequestFilesViewed.ts | 41 +- .../persistence/PullRequestReviewGuides.ts | 1 - apps/server/src/persistence/Sqlite.test.ts | 2 - apps/server/src/persistence/Sqlite.ts | 5 - .../src/persistence/initializeV2Database.ts | 2 +- .../reconcileV2PreviewMigration.test.ts | 1 - .../src/preview/DesktopBrowserChannel.test.ts | 1 - .../src/preview/DesktopBrowserChannel.ts | 17 +- apps/server/src/preview/Manager.test.ts | 7 - apps/server/src/preview/Manager.ts | 36 +- apps/server/src/preview/PortScanner.test.ts | 11 - apps/server/src/preview/PortScanner.ts | 56 +- .../server/src/preview/PreviewBrowser.test.ts | 4 - apps/server/src/preview/PreviewBrowser.ts | 26 +- apps/server/src/preview/PreviewBrowserHost.ts | 40 +- apps/server/src/preview/ServerBrowser.test.ts | 51 -- apps/server/src/preview/ServerBrowser.ts | 152 +--- .../src/preview/ServerBrowserContexts.test.ts | 1 - .../src/preview/ServerBrowserContexts.ts | 21 +- .../src/preview/ServerBrowserPage.test.ts | 4 - apps/server/src/preview/ServerBrowserPage.ts | 12 +- .../src/preview/ServerBrowserStream.test.ts | 2 - .../server/src/preview/ServerBrowserStream.ts | 11 - apps/server/src/preview/SessionControl.ts | 10 - apps/server/src/process/agentScope.test.ts | 10 - apps/server/src/process/agentScope.ts | 40 +- .../src/process/externalLauncher.test.ts | 50 +- apps/server/src/process/externalLauncher.ts | 100 +-- apps/server/src/processRunner.test.ts | 1 - apps/server/src/processRunner.ts | 2 - apps/server/src/project/AgentSessionJson.ts | 11 - .../src/project/AgentSessionScanner.test.ts | 26 - .../server/src/project/AgentSessionScanner.ts | 98 +-- .../src/project/ManagedProjectFolders.test.ts | 26 - .../src/project/ManagedProjectFolders.ts | 63 +- .../src/project/ProjectCloneTracker.test.ts | 6 - .../server/src/project/ProjectCloneTracker.ts | 54 +- .../project/ProjectEnrichmentService.test.ts | 7 - .../src/project/ProjectEnrichmentService.ts | 9 - .../project/ProjectFaviconResolver.test.ts | 8 - .../src/project/ProjectFaviconResolver.ts | 30 - .../project/ProjectService.deletion.test.ts | 4 - .../server/src/project/ProjectService.test.ts | 8 - apps/server/src/project/ProjectService.ts | 14 - .../project/ProjectSetupScriptRunner.test.ts | 3 - .../src/project/ProjectSetupScriptRunner.ts | 53 +- .../src/project/RepositoryIdentityResolver.ts | 5 - .../server/src/project/T3ProjectFileLoader.ts | 17 +- .../src/project/WorktreeSetupTracker.test.ts | 8 - .../src/project/WorktreeSetupTracker.ts | 32 +- apps/server/src/project/gitCloneProgress.ts | 8 +- .../src/provider/AntigravityAuth.test.ts | 1 - apps/server/src/provider/AntigravityAuth.ts | 7 - .../provider/AntigravityInstallation.test.ts | 3 - .../src/provider/AntigravityProvider.ts | 10 - .../provider/ClaudeCapabilitiesProbe.test.ts | 2 - .../src/provider/ClaudeModelCatalog.test.ts | 11 - .../ClaudeModelCatalog.testFixtures.ts | 2 - .../server/src/provider/ClaudeModelCatalog.ts | 16 - apps/server/src/provider/ClaudeProvider.ts | 56 +- .../src/provider/ClaudeTurnTokenUsage.ts | 11 +- apps/server/src/provider/CodexAuthCallback.ts | 1 - .../src/provider/CodexChatGptAuth.test.ts | 1 - apps/server/src/provider/CodexChatGptAuth.ts | 6 - .../src/provider/CodexChatGptHandoff.ts | 1 - .../server/src/provider/CodexChatGptModels.ts | 1 - .../src/provider/CodexChatGptSessionLock.ts | 1 - .../CodexDeveloperInstructions.test.ts | 3 - .../provider/CodexDeveloperInstructions.ts | 19 +- .../src/provider/CodexInstallation.test.ts | 2 - apps/server/src/provider/CodexInstallation.ts | 7 +- .../src/provider/CodexManagedRuntime.ts | 1 - .../src/provider/CodexMcpElicitation.ts | 2 - .../provider/CodexProvider.catalog.test.ts | 3 +- apps/server/src/provider/CodexProvider.ts | 53 +- .../src/provider/CodexThreadRevert.test.ts | 1 - apps/server/src/provider/CodexThreadRevert.ts | 1 - .../src/provider/CodexTurnTokenUsage.ts | 11 +- .../Drivers/AntigravityDriver.test.ts | 4 - .../src/provider/Drivers/AntigravityDriver.ts | 11 +- .../Drivers/AntigravitySkills.test.ts | 1 - .../src/provider/Drivers/AntigravitySkills.ts | 12 +- .../src/provider/Drivers/AntigravityUsage.ts | 14 - .../src/provider/Drivers/ClaudeDriver.ts | 24 +- .../src/provider/Drivers/ClaudeExecutable.ts | 28 +- .../server/src/provider/Drivers/ClaudeHome.ts | 17 +- .../Drivers/ClaudeSkillDispatch.test.ts | 1 - .../provider/Drivers/ClaudeSkillDispatch.ts | 35 +- .../src/provider/Drivers/ClaudeSkills.test.ts | 11 - .../src/provider/Drivers/ClaudeSkills.ts | 77 +- .../src/provider/Drivers/CodexDriver.test.ts | 6 +- .../src/provider/Drivers/CodexDriver.ts | 46 +- .../provider/Drivers/CodexManagedProvider.ts | 1 - .../Drivers/antigravityUsageReader.test.ts | 10 - .../Drivers/antigravityUsageReader.ts | 22 +- .../src/provider/Drivers/claudeUsage.test.ts | 1 - .../src/provider/Drivers/claudeUsage.ts | 20 +- .../src/provider/Drivers/codexUsage.test.ts | 7 - .../server/src/provider/Drivers/codexUsage.ts | 43 +- apps/server/src/provider/Errors.ts | 13 - .../src/provider/EventNdjsonLogger.test.ts | 2 +- apps/server/src/provider/EventNdjsonLogger.ts | 14 +- apps/server/src/provider/GrokProvider.test.ts | 4 - .../server/src/provider/ModelManifest.test.ts | 15 - apps/server/src/provider/ModelManifest.ts | 55 +- .../provider/OpenCode2Client.events.test.ts | 5 - .../provider/ProjectSettingsOverrideSweep.ts | 25 - .../src/provider/ProviderAuthService.test.ts | 5 - .../src/provider/ProviderCredentialStore.ts | 1 - apps/server/src/provider/ProviderHostLive.ts | 6 - .../provider/ProviderInstanceRegistry.test.ts | 65 -- .../src/provider/ProviderInstanceRegistry.ts | 178 +---- .../ProviderInstanceRegistryHydration.ts | 66 +- .../ProviderInstanceRegistryMutator.ts | 42 +- ...viderOrchestrationAdapterInfrastructure.ts | 7 +- .../src/provider/ProviderRegistry.test.ts | 51 -- apps/server/src/provider/ProviderRegistry.ts | 209 +----- .../provider/ProviderUsageLimitsIngestion.ts | 4 - .../provider/acp/AcpJsonRpcConnection.test.ts | 2 - .../acp/AcpSessionRuntime.processTree.test.ts | 36 +- .../src/provider/acp/AntigravityAcpSupport.ts | 26 - .../provider/acp/AntigravityClientFiles.ts | 8 +- .../src/provider/acp/AntigravityProtocol.ts | 8 +- .../provider/acp/AntigravitySessionFiles.ts | 10 +- .../provider/antigravityAuthSupport.test.ts | 4 - .../src/provider/antigravityAuthSupport.ts | 57 +- .../src/provider/antigravityCallback.ts | 2 - .../server/src/provider/antigravityRelease.ts | 2 - .../provider/antigravityUsageLimits.test.ts | 2 - .../src/provider/antigravityUsageLimits.ts | 2 - apps/server/src/provider/builtInDrivers.ts | 38 +- .../src/provider/builtInProviderCatalog.ts | 6 - .../src/provider/claudeResetCredits.test.ts | 2 - .../server/src/provider/claudeResetCredits.ts | 25 +- .../src/provider/claudeUsageLimits.test.ts | 1 - apps/server/src/provider/claudeUsageLimits.ts | 24 +- .../src/provider/codexModelCatalog.test.ts | 5 - apps/server/src/provider/codexModelCatalog.ts | 13 +- apps/server/src/provider/codexUsageLimits.ts | 23 - .../provider/providerCompatibility.test.ts | 5 - .../src/provider/providerCompatibility.ts | 3 - .../src/provider/providerInstallation.ts | 1 - .../providerMaintenanceRunner.test.ts | 8 - .../src/provider/providerMaintenanceRunner.ts | 23 +- .../src/provider/providerStatusCache.ts | 14 - .../provider/resetCreditCoordinator.test.ts | 2 - .../src/provider/resetCreditCoordinator.ts | 23 +- .../testFixtures/codexCollabMockPeer.mjs | 27 +- .../testFixtures/grok-text-mock-agent.mjs | 1 - .../ClaudeCapabilitiesProbe.fixture.mjs | 1 - .../ProviderInstanceRegistryLive.fixture.mjs | 1 - .../provider/unavailableProviderSnapshot.ts | 23 - .../PullRequestProviderRegistry.ts | 5 - .../pullRequest/PullRequestReadCache.test.ts | 6 - .../src/pullRequest/PullRequestReadCache.ts | 10 +- .../PullRequestReviewGuideService.test.ts | 29 - .../PullRequestReviewGuideService.ts | 52 -- .../pullRequest/PullRequestService.test.ts | 196 ----- .../src/pullRequest/PullRequestService.ts | 392 +--------- .../pullRequestReviewGuideInput.ts | 7 - .../pullRequestReviewGuideNormalize.test.ts | 7 - .../pullRequestReviewGuideNormalize.ts | 21 - .../src/pullRequest/pullRequestSyncKey.ts | 1 - .../src/pullRequest/pullRequestViewedFiles.ts | 81 +- .../src/relay/AgentAwarenessRelay.test.ts | 11 - apps/server/src/relay/AgentAwarenessRelay.ts | 41 +- apps/server/src/relay/HeldHooksWaker.ts | 7 - .../src/relay/relayEnvironmentClient.ts | 4 - .../DesktopTelemetryReceiver.ts | 7 - .../src/resourceTelemetry/HostResources.ts | 2 - apps/server/src/resourceTelemetry/Model.ts | 1 - .../NativeTelemetryClient.test.ts | 1 - apps/server/src/review/ReviewService.test.ts | 1 - apps/server/src/review/ReviewService.ts | 2 - apps/server/src/rpcInitialItems.ts | 1 - .../src/scheduledTasks/RelayDeliveryProof.ts | 4 - .../src/scheduledTasks/Schedule.test.ts | 5 - apps/server/src/scheduledTasks/Schedule.ts | 18 - .../ScheduledTaskService.schedule.test.ts | 1 - .../ScheduledTaskService.test.ts | 30 - .../scheduledTasks/ScheduledTaskService.ts | 108 +-- .../ScheduledTaskService.webhook.test.ts | 33 - .../src/scheduledTasks/webhookRoute.test.ts | 13 - .../server/src/scheduledTasks/webhookRoute.ts | 11 +- .../src/scheduledTasks/webhookTemplate.ts | 24 +- .../src/scheduledTasks/webhookVerification.ts | 5 - apps/server/src/scheduling/Scheduler.ts | 3 - .../server/src/secrets/SecretRequests.test.ts | 10 - apps/server/src/secrets/SecretRequests.ts | 31 - apps/server/src/server.ts | 97 +-- apps/server/src/serverActivation.ts | 2 - apps/server/src/serverLogger.test.ts | 10 +- apps/server/src/serverLogger.ts | 5 - apps/server/src/serverOwnership.ts | 2 - .../src/serverRuntimeStartup.autoPull.test.ts | 3 - apps/server/src/serverRuntimeStartup.test.ts | 2 - apps/server/src/serverRuntimeStartup.ts | 3 - apps/server/src/serverRuntimeState.test.ts | 2 - apps/server/src/serverRuntimeState.ts | 18 +- apps/server/src/serverSettings.test.ts | 41 +- apps/server/src/serverSettings.ts | 79 +- apps/server/src/serviceLauncher.test.ts | 28 - apps/server/src/serviceLauncher.ts | 63 +- apps/server/src/settingsSecrets.ts | 4 - .../src/sourceControl/BitbucketApi.test.ts | 8 - .../sourceControl/ServerSourceControlHost.ts | 6 - .../SourceControlDiscovery.test.ts | 1 - .../SourceControlProviderRegistry.test.ts | 7 - .../SourceControlProviderRegistry.ts | 11 +- .../SourceControlRepositoryService.test.ts | 5 - .../SourceControlRepositoryService.ts | 9 - .../src/sourceControl/builtInDrivers.ts | 12 - .../sourceControl/githubStackRebase.test.ts | 13 +- .../sourceControl/sourceControlHostSecrets.ts | 13 - apps/server/src/storageCleanup.test.ts | 10 - apps/server/src/storageCleanup.ts | 20 - .../src/telemetry/AnalyticsService.test.ts | 6 - apps/server/src/telemetry/AnalyticsService.ts | 26 +- apps/server/src/telemetry/Identify.ts | 9 - apps/server/src/terminal/Manager.test.ts | 32 - apps/server/src/terminal/Manager.ts | 92 +-- apps/server/src/terminal/NodePtyAdapter.ts | 13 +- apps/server/src/testUtils/gitConfig.setup.ts | 11 +- .../AntigravityTextGeneration.ts | 5 - .../textGeneration/ClaudeTextGeneration.ts | 21 - .../CodexTextGeneration.test.ts | 5 - .../src/textGeneration/CodexTextGeneration.ts | 13 +- .../OpenCode2TextGeneration.fixture.ts | 5 +- .../OpenCode2TextGeneration.test.ts | 2 - .../src/textGeneration/TextGeneration.test.ts | 2 - .../src/textGeneration/TextGeneration.ts | 3 - .../src/textGeneration/ThreadTitleLinks.ts | 1 - apps/server/src/usage/UsageLimitSources.ts | 17 - apps/server/src/usage/UsageService.test.ts | 63 +- apps/server/src/usage/UsageService.ts | 120 +-- apps/server/src/usage/cliproxyApi.test.ts | 1 - apps/server/src/usage/cliproxyApi.ts | 2 - .../server/src/usage/usageAggregation.test.ts | 5 - apps/server/src/usage/usageAggregation.ts | 50 +- apps/server/src/usage/usagePricing.test.ts | 5 - apps/server/src/usage/usagePricing.ts | 74 +- apps/server/src/usage/usageScanCache.test.ts | 10 - apps/server/src/usage/usageScanCache.ts | 77 +- apps/server/src/usage/usageTestFormats.ts | 5 - .../src/usage/usageTranscriptReader.test.ts | 12 - .../server/src/usage/usageTranscriptReader.ts | 81 +- .../usage/usageTranscriptStreaming.test.ts | 2 - apps/server/src/vcs/GitVcsDriver.ts | 40 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 44 -- apps/server/src/vcs/GitVcsDriverCore.ts | 88 +-- apps/server/src/vcs/VcsDriver.ts | 2 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 2 - apps/server/src/vcs/VcsDriverRegistry.ts | 8 +- apps/server/src/vcs/VcsProcess.ts | 2 - .../src/vcs/VcsStatusBroadcaster.test.ts | 9 - apps/server/src/vcs/VcsStatusBroadcaster.ts | 8 - .../src/workspace/WorkspaceEntries.test.ts | 7 - .../src/workspace/WorkspaceFileSystem.test.ts | 1 - .../src/workspace/WorkspaceFileSystem.ts | 29 +- apps/server/src/workspace/WorkspacePaths.ts | 16 - .../WorkspaceSearchIndex.native.test.ts | 1 - .../src/workspace/WorkspaceSearchIndex.ts | 23 +- .../src/workspace/fileHeaderMimeType.ts | 2 - apps/server/src/workspace/workspaceLease.ts | 1 - apps/server/src/worktreesDirectory.ts | 10 +- apps/server/src/ws.test.ts | 5 - apps/server/src/ws.ts | 116 +-- apps/server/src/zipArchive.ts | 4 +- apps/server/vite.config.ts | 34 +- apps/web/scripts/warm-dep-cache.ts | 7 - apps/web/src/AppRoot.tsx | 6 +- apps/web/src/appearanceFonts.ts | 73 +- apps/web/src/assets/projectFaviconCache.ts | 1 - .../src/browser/BrowserProfileReporter.tsx | 6 - apps/web/src/browser/ElectronBrowserHost.tsx | 9 +- apps/web/src/browser/HostedBrowserWebview.tsx | 12 +- apps/web/src/browser/ScreenRotationIcon.tsx | 7 - apps/web/src/browser/ServerBrowserSurface.tsx | 26 - apps/web/src/browser/browserDefaults.test.ts | 3 - apps/web/src/browser/browserDefaults.ts | 44 -- .../web/src/browser/browserLinkTarget.test.ts | 2 - apps/web/src/browser/browserLinkTarget.ts | 32 +- apps/web/src/browser/browserRecording.ts | 10 - apps/web/src/browser/browserTargetResolver.ts | 9 +- apps/web/src/browser/browserViewportLayout.ts | 5 - .../src/browser/desktopTabLifetime.test.ts | 4 - apps/web/src/browser/desktopTabLifetime.ts | 1 - apps/web/src/browser/openFileInPreview.ts | 9 - apps/web/src/browser/previewRuntime.ts | 17 - .../browser/previewWebviewConfigState.test.ts | 1 - .../src/browser/previewWebviewConfigState.ts | 3 +- apps/web/src/browser/recordingCompositor.ts | 2 - .../web/src/browser/serverPictureInPicture.ts | 1 - .../src/browser/useBrowserViewportResize.ts | 4 +- apps/web/src/browser/useOpenLink.ts | 12 +- apps/web/src/browserFaviconStore.ts | 2 - apps/web/src/browserHistoryStore.test.ts | 1 - apps/web/src/bundledDev.test.ts | 4 - apps/web/src/cloud/connectCliAuth.test.ts | 1 - apps/web/src/cloud/connectCliAuth.ts | 24 +- apps/web/src/cloud/connectOnboarding.ts | 4 +- apps/web/src/cloud/linkEnvironment.ts | 3 - apps/web/src/cloud/primaryCloudLinkState.ts | 1 - apps/web/src/cloud/useCloudLinkController.ts | 22 +- apps/web/src/commandPaletteBus.ts | 3 - apps/web/src/components/AnimatedHeight.tsx | 1 - apps/web/src/components/AppSidebarLayout.tsx | 13 - apps/web/src/components/BranchPicker.tsx | 1 - .../components/BranchToolbar.logic.test.ts | 12 - .../web/src/components/BranchToolbar.logic.ts | 19 - apps/web/src/components/BranchToolbar.tsx | 50 +- .../BranchToolbarBranchSelector.tsx | 24 - .../ChatMarkdown.permissions.test.tsx | 2 - apps/web/src/components/ChatMarkdown.test.tsx | 5 - apps/web/src/components/ChatMarkdown.tsx | 125 +--- .../web/src/components/ChatView.logic.test.ts | 7 - apps/web/src/components/ChatView.logic.ts | 70 +- apps/web/src/components/ChatView.tsx | 373 +--------- apps/web/src/components/CommandBlock.tsx | 1 - .../src/components/CommandPalette.logic.ts | 26 - apps/web/src/components/CommandPalette.tsx | 87 +-- .../src/components/CommandPaletteContent.tsx | 9 - .../src/components/CommandPaletteResults.tsx | 11 +- .../src/components/ComposerContextLabel.tsx | 1 - .../src/components/ComposerPromptEditor.tsx | 5 - ...rPromptEditorTiptap.citationDraft.test.tsx | 2 - ...oserPromptEditorTiptap.navigation.test.tsx | 1 - .../components/ComposerPromptEditorTiptap.tsx | 133 +--- .../src/components/ConnectionStatusDot.tsx | 2 - apps/web/src/components/ContextChip.tsx | 21 - apps/web/src/components/DiffPanel.tsx | 7 - .../src/components/DiffWorkerPoolProvider.tsx | 2 - .../src/components/EnvironmentMachineIcon.tsx | 5 - .../src/components/GitActionsControl.logic.ts | 5 - apps/web/src/components/GitActionsControl.tsx | 18 - apps/web/src/components/Icons.tsx | 11 +- apps/web/src/components/LegacySidebar.tsx | 41 +- apps/web/src/components/MorphIcon.tsx | 1 - .../src/components/PermissionUpdateNotice.tsx | 8 +- .../ProjectCloneToastCoordinator.tsx | 20 - .../components/ProjectEnvironmentBadge.tsx | 10 - apps/web/src/components/ProjectFavicon.tsx | 6 +- .../src/components/ProjectScriptsControl.tsx | 8 - .../ProviderUpdateEnvironmentRows.tsx | 75 +- ...erUpdateLaunchNotification.environments.ts | 22 - ...iderUpdateLaunchNotification.logic.test.ts | 10 - .../ProviderUpdateLaunchNotification.logic.ts | 78 +- .../ProviderUpdateLaunchNotification.tsx | 25 - .../ProviderUpdatePrimaryNotification.tsx | 8 - .../src/components/ProviderUpdatesAction.tsx | 13 - apps/web/src/components/QuitHoldOverlay.tsx | 6 - .../src/components/RenderErrorBoundary.tsx | 1 - .../components/ReopenClosedViewShortcut.tsx | 1 - .../src/components/RightPanelTabs.test.tsx | 13 - apps/web/src/components/RightPanelTabs.tsx | 85 +-- .../components/ServerUpdateAction.test.tsx | 4 - .../web/src/components/ServerUpdateAction.tsx | 27 +- apps/web/src/components/Sidebar.drag.test.ts | 7 - apps/web/src/components/Sidebar.drag.ts | 13 - apps/web/src/components/Sidebar.logic.test.ts | 18 - apps/web/src/components/Sidebar.logic.ts | 119 +-- .../web/src/components/Sidebar.motion.test.ts | 9 - apps/web/src/components/Sidebar.motion.ts | 25 +- .../src/components/Sidebar.pointer.test.ts | 2 - apps/web/src/components/Sidebar.pointer.ts | 12 +- .../web/src/components/Sidebar.snooze.test.ts | 5 - apps/web/src/components/Sidebar.snooze.ts | 4 - apps/web/src/components/Sidebar.tsx | 405 +--------- .../src/components/SidebarStageBackdrop.tsx | 3 - .../SlowRpcRequestToastCoordinator.tsx | 1 - .../src/components/ThreadCommandSubtitle.tsx | 6 - apps/web/src/components/ThreadContextChip.tsx | 4 - apps/web/src/components/ThreadHoverCard.tsx | 1 - .../ThreadNotificationCoordinator.test.tsx | 1 - .../ThreadNotificationCoordinator.tsx | 5 - apps/web/src/components/ThreadRouteView.tsx | 19 +- .../src/components/ThreadStatusIndicators.tsx | 51 -- .../ThreadTerminalDrawer.permissions.test.tsx | 2 - .../src/components/ThreadTerminalDrawer.tsx | 43 +- .../src/components/WorkspaceBreadcrumb.tsx | 4 - .../src/components/WorkspacePageContainer.tsx | 1 - .../src/components/WorkspacePageHeader.tsx | 4 - .../components/WorktreeBaseBranchPicker.tsx | 1 - .../src/components/auth/AuthSurfaceShell.tsx | 3 - .../components/auth/ConnectAgentSurface.tsx | 14 - apps/web/src/components/chat/AgentElapsed.tsx | 6 - .../chat/AssistantCitationChip.test.tsx | 2 - .../components/chat/AssistantCitationChip.tsx | 7 - .../chat/AssistantCitationSource.tsx | 9 - .../src/components/chat/ChangedFilesTree.tsx | 1 - .../src/components/chat/ChatCanvas.test.tsx | 1 - apps/web/src/components/chat/ChatCanvas.tsx | 4 - .../src/components/chat/ChatCanvasContext.ts | 1 - apps/web/src/components/chat/ChatComposer.tsx | 361 +-------- apps/web/src/components/chat/ChatHeader.tsx | 35 +- .../src/components/chat/ComposerBanner.tsx | 16 +- .../chat/ComposerBannerStack.test.tsx | 1 - .../components/chat/ComposerBannerStack.tsx | 5 - .../chat/ComposerCodeBlockLanguagePicker.tsx | 14 - .../chat/ComposerCodeBlockNodeView.tsx | 10 - .../src/components/chat/ComposerControl.tsx | 5 - .../chat/ComposerPendingUserInputPanel.tsx | 13 - .../chat/ComposerPrimaryActions.tsx | 6 - .../chat/ComposerServerUpdateStatus.tsx | 1 - .../components/chat/ComposerStashBadge.tsx | 8 - .../src/components/chat/ComposerStashMenu.tsx | 7 +- .../src/components/chat/ComposerSurface.tsx | 4 - .../components/chat/ComposerUsageLimits.tsx | 3 - .../chat/ContextWindowMeter.logic.ts | 15 - .../components/chat/ContextWindowMeter.tsx | 1 - .../src/components/chat/DraftHeroHeadline.tsx | 31 +- .../components/chat/ExpandedImageDialog.tsx | 2 - .../components/chat/ExpandedImagePreview.tsx | 5 - apps/web/src/components/chat/FileTagChip.tsx | 1 - .../components/chat/HighlightedTokens.test.ts | 5 - .../src/components/chat/HighlightedTokens.tsx | 24 +- .../components/chat/HtmlRenderDialog.test.tsx | 1 - .../src/components/chat/HtmlRenderFrame.tsx | 23 +- .../components/chat/MarkdownThreadLink.tsx | 7 - apps/web/src/components/chat/McpAppFrame.tsx | 62 +- .../src/components/chat/MermaidDiagram.tsx | 13 +- .../src/components/chat/MessageCopyButton.tsx | 2 - .../chat/MessagesTimeline.logic.test.ts | 37 - .../components/chat/MessagesTimeline.logic.ts | 162 +--- .../components/chat/MessagesTimeline.test.tsx | 48 -- .../src/components/chat/MessagesTimeline.tsx | 200 +---- apps/web/src/components/chat/ModelListRow.tsx | 7 - .../components/chat/ModelPickerContent.tsx | 53 -- .../components/chat/ModelPickerSidebar.tsx | 22 - .../src/components/chat/OpenInPicker.logic.ts | 3 - .../src/components/chat/ProposedPlanCard.tsx | 3 +- .../components/chat/ProviderInstanceIcon.tsx | 3 - .../components/chat/ProviderModelPicker.tsx | 10 - .../components/chat/ProviderPackageIcon.tsx | 7 - .../components/chat/ProviderStatusBanner.tsx | 2 - .../components/chat/ProviderSubagentBar.tsx | 10 - .../src/components/chat/QueuedRunsControl.tsx | 4 - .../src/components/chat/SecretRequestCard.tsx | 10 - .../src/components/chat/ShellCommandBlock.tsx | 8 - .../src/components/chat/SkillInlineText.tsx | 1 - .../chat/SubagentTooltipContent.tsx | 2 - .../chat/ThreadAutomationsPanel.tsx | 10 +- .../chat/ThreadContextDragGhost.tsx | 1 - .../src/components/chat/ThreadDetailsCard.tsx | 9 - .../components/chat/ThreadDetailsControl.tsx | 1 - .../components/chat/ThreadDetailsPanel.tsx | 1 - .../components/chat/ThreadDetailsPrRow.tsx | 29 - .../components/chat/ThreadDetailsPrRows.tsx | 1 - .../components/chat/ThreadDetailsSection.tsx | 1 - .../src/components/chat/ThreadErrorBanner.tsx | 5 - .../web/src/components/chat/ThreadFindBar.tsx | 5 - .../components/chat/ThreadFindProvider.tsx | 1 - .../chat/ThreadLineageWorkflowRow.tsx | 10 - .../chat/ThreadRelationshipIcon.tsx | 1 - ...ThreadRelationshipsControl.agents.test.tsx | 10 - .../chat/ThreadRelationshipsControl.tsx | 24 +- .../src/components/chat/ThreadStatusLine.tsx | 5 - .../src/components/chat/TraitsPicker.test.ts | 3 - apps/web/src/components/chat/TraitsPicker.tsx | 10 - .../src/components/chat/V2ItemInspector.tsx | 6 - .../src/components/chat/V2LifecycleRow.tsx | 14 - apps/web/src/components/chat/WorkLog.tsx | 7 +- .../components/chat/WorkspaceEntryIcon.tsx | 2 - .../src/components/chat/WorktreeSetupCard.tsx | 40 - .../web/src/components/chat/ZoomableImage.tsx | 1 - .../src/components/chat/agentSpawnSummary.ts | 2 - .../src/components/chat/chatCanvasLayout.ts | 17 - .../chat/composerAttachmentFiles.ts | 19 +- .../src/components/chat/composerEventScope.ts | 3 - .../components/chat/composerMentionDrag.ts | 14 +- .../chat/composerPromptHistory.test.ts | 2 - .../components/chat/composerPromptHistory.ts | 56 +- .../chat/composerProviderState.test.tsx | 4 - .../components/chat/composerProviderState.tsx | 3 - .../chat/composerSlashCommandSearch.ts | 3 +- .../components/chat/draftHeroTransition.ts | 4 +- .../chat/externalLinkContextMenu.ts | 7 - .../components/chat/markdownImageGallery.ts | 2 - .../components/chat/modelPickerSearch.test.ts | 5 - .../src/components/chat/modelPickerSearch.ts | 11 - .../components/chat/pendingDraftWork.test.ts | 5 - .../src/components/chat/pendingDraftWork.ts | 7 +- .../src/components/chat/providerIconUtils.ts | 6 - .../src/components/chat/queuedMessageEdit.ts | 2 - .../restingComposerControlsMeasurement.ts | 11 - .../src/components/chat/threadContextDrag.ts | 10 - .../chat/threadDetailsCardLayout.ts | 8 - .../chat/threadDetailsPanelStyles.ts | 8 - apps/web/src/components/chat/threadFind.ts | 3 - .../components/chat/threadFindHighlights.ts | 10 - .../src/components/chat/threadFindPosition.ts | 1 - .../components/chat/timelineMinimapItems.ts | 1 - .../chat/timelineScrollAnchoring.ts | 9 +- .../components/chat/timelineScrollTarget.ts | 2 - .../chat/useAssistantCitationTarget.ts | 1 - .../chat/useAutoBalanceUpdateBanner.tsx | 1 - .../chat/useComposerFocusState.test.tsx | 1 - .../components/chat/useComposerFocusState.ts | 2 - .../chat/useComposerMenuState.test.tsx | 1 - .../components/chat/useComposerMenuState.ts | 1 - .../chat/useComposerTriggerState.test.tsx | 1 - .../chat/useComposerTriggerState.ts | 1 - .../components/chat/useThreadFind.test.tsx | 2 +- apps/web/src/components/chat/useThreadFind.ts | 6 - .../chat/useThreadFindNavigation.test.tsx | 1 - .../chat/useThreadFindNavigation.ts | 3 - .../clerk/BrowserManagedAuthShell.tsx | 4 +- .../clerk/ElectronManagedAuthShell.tsx | 8 +- .../RemoveT3ConnectEnvironmentDialog.tsx | 7 - .../clerk/T3ConnectAccountPages.tsx | 6 +- .../src/components/clerk/clerkAppearance.ts | 7 +- .../cloud/CloudEnvironmentConnectList.tsx | 17 - .../cloud/ConnectCliAuthSurface.tsx | 6 - .../cloud/ConnectOnboardingDialog.tsx | 30 - .../cloudEnvironmentConnectionPresentation.ts | 6 - .../composerCodeBlockHighlight.test.ts | 12 - .../components/composerCodeBlockHighlight.ts | 26 +- .../composerContextPresentation.test.ts | 1 - .../composerContextPresentation.tsx | 8 - .../components/composerFooterLayout.test.ts | 34 +- .../src/components/composerFooterLayout.ts | 53 +- apps/web/src/components/composerInlineChip.ts | 1 - .../components/composerInlineTokenPaste.ts | 4 - apps/web/src/components/contextChipParts.tsx | 7 - .../components/contextPresentationRegistry.ts | 5 - .../desktop/DesktopWebLinkCoordinator.tsx | 17 +- .../desktop/RunningThreadKeepAlive.tsx | 6 - .../desktop/SnapShotCoordinator.tsx | 2 - .../web/src/components/desktopUpdate.logic.ts | 1 - .../components/desktopUpdate.toast.test.tsx | 1 - .../src/components/desktopUpdate.toast.tsx | 4 +- .../device/DeviceAndroidFoldControls.tsx | 1 - .../components/device/DeviceControlsRail.tsx | 1 - .../components/device/DeviceDuoControls.tsx | 9 - .../src/components/device/DeviceDuoGlyph.tsx | 7 - .../components/device/DeviceDuoViewport.tsx | 1 - .../components/device/DeviceHostUpdates.tsx | 1 - .../web/src/components/device/DevicePanel.tsx | 2 - .../components/device/DevicePhoneViewport.tsx | 1 - .../device/DeviceStreamView.test.tsx | 2 - .../components/device/DeviceStreamView.tsx | 22 +- .../components/device/DeviceToolsPanel.tsx | 6 - .../src/components/device/DeviceWorkspace.tsx | 1 - .../web/src/components/device/deviceHubApi.ts | 24 +- .../web/src/components/device/deviceModels.ts | 1 - .../src/components/device/phoneTrackpad.ts | 3 +- .../components/device/useDeviceControls.ts | 3 +- .../components/diffs/AnnotatableCodeView.tsx | 1 - .../diffs/DiffCommentAnnotation.test.tsx | 1 - .../diffs/DiffCommentAnnotation.tsx | 1 - .../diffs/DiffFileLoadingBoundary.tsx | 1 - .../components/diffs/DiffFileTree.test.tsx | 5 - .../web/src/components/diffs/DiffFileTree.tsx | 17 - .../diffs/StyledDiffCodeView.test.tsx | 1 - .../components/diffs/StyledDiffCodeView.tsx | 8 - .../components/diffs/commentSubmitShortcut.ts | 1 - .../components/diffs/diffFileTree.logic.ts | 19 - .../components/diffs/useCodeViewFileReveal.ts | 2 - .../files/AttachmentFilePreview.tsx | 21 - .../web/src/components/files/AudioPreview.tsx | 1 - .../components/files/BrowserDocumentFrame.tsx | 44 +- .../files/DelimitedTablePreview.tsx | 1 - .../src/components/files/FileBreadcrumbs.tsx | 2 - .../src/components/files/FileBrowserPanel.tsx | 19 - .../src/components/files/FilePreviewPanel.tsx | 39 +- .../files/ProjectFilePicker.logic.ts | 8 +- .../files/ReadOnlySourcePreview.tsx | 5 - .../files/fileEditorVirtualization.test.ts | 6 - apps/web/src/components/files/filePath.ts | 4 - .../src/components/files/filePreviewMode.ts | 7 - .../components/files/fileSurfaceChrome.tsx | 11 - .../files/fileTreeDragMention.test.ts | 3 - .../components/files/fileTreeDragMention.ts | 15 - .../files/projectFilesQueryState.ts | 16 +- .../components/files/useDirectoryEntries.ts | 3 - .../web/src/components/media/MediaActions.tsx | 4 - .../src/components/media/MediaVideoPlayer.tsx | 5 - .../src/components/media/OpenMediaLink.tsx | 1 - apps/web/src/components/media/mediaContent.ts | 3 - .../components/onboarding/FirstRunGate.tsx | 26 +- .../components/onboarding/WelcomeWizard.tsx | 72 +- .../components/preview/AgentBrowserCursor.tsx | 1 - .../src/components/preview/BrowserMockup.tsx | 1 - .../components/preview/PreviewChromeRow.tsx | 18 +- .../components/preview/PreviewMoreMenu.tsx | 30 +- .../preview/PreviewPanelShell.test.ts | 6 - .../components/preview/PreviewPanelShell.tsx | 13 +- .../components/preview/PreviewUnreachable.tsx | 9 - .../components/preview/PreviewView.test.tsx | 9 - .../src/components/preview/PreviewView.tsx | 81 +- .../preview/RightPanelResizeHandle.tsx | 8 - .../preview/ThreadPreviewMiniPlayer.tsx | 12 +- .../src/components/preview/ZoomIndicator.tsx | 7 - .../components/preview/addBrowserSurface.ts | 2 - .../components/preview/closePreviewSession.ts | 4 - .../components/preview/errorCodeMessages.ts | 4 - .../components/preview/fileExplorerLabel.ts | 3 - .../components/preview/openDiscoveredPort.ts | 1 - .../preview/openPreviewSession.test.ts | 3 - .../components/preview/openPreviewSession.ts | 3 - .../preview/openTerminalLinkInPreview.ts | 7 - .../components/preview/previewActionBus.ts | 5 - .../src/components/preview/previewBridge.ts | 7 - .../components/preview/previewConstants.ts | 5 - .../preview/previewMiniPlayerLayout.test.ts | 17 - .../preview/previewMiniPlayerLayout.ts | 63 +- .../components/preview/showPreviewPopup.ts | 5 - .../preview/useDiscoveredLocalServers.ts | 4 - .../components/preview/usePreviewBridge.ts | 18 +- .../projectScriptEditor.permissions.test.tsx | 1 - .../src/components/projectScriptEditor.tsx | 13 - .../promptLinks/HostedPromptLinkLanding.tsx | 8 - .../promptLinks/NewThreadLinkView.tsx | 20 - .../promptLinks/ThreadLinkPrompt.tsx | 4 - .../components/promptLinks/copyPromptLink.ts | 6 - .../components/promptLinks/linkedPrompt.ts | 7 - .../pullRequest/LinkPullRequestDialog.tsx | 6 - .../PullRequestCandidatePicker.tsx | 21 +- .../pullRequest/PullRequestChecksPopover.tsx | 19 +- .../pullRequest/PullRequestCodeTab.tsx | 149 +--- .../pullRequest/PullRequestCommentBody.tsx | 1 - .../pullRequest/PullRequestCommentForm.tsx | 4 - .../pullRequest/PullRequestComposer.tsx | 19 - .../PullRequestDetailPanel.test.tsx | 9 +- .../pullRequest/PullRequestDetailPanel.tsx | 237 +----- .../pullRequest/PullRequestEditButton.tsx | 1 - .../pullRequest/PullRequestGhosts.tsx | 29 +- .../pullRequest/PullRequestGuideTab.tsx | 16 - .../pullRequest/PullRequestLabelPicker.tsx | 11 - .../pullRequest/PullRequestLinkPreview.tsx | 1 - .../pullRequest/PullRequestListEmptyState.tsx | 35 - .../PullRequestListFilters.test.tsx | 1 - .../pullRequest/PullRequestListFilters.tsx | 37 - .../pullRequest/PullRequestListRow.tsx | 25 - .../pullRequest/PullRequestMarkdown.tsx | 11 - .../pullRequest/PullRequestMarkdownEditor.tsx | 17 +- .../pullRequest/PullRequestReactions.tsx | 2 - .../PullRequestReviewAnnotation.tsx | 17 - .../pullRequest/PullRequestReviewForm.tsx | 8 - .../pullRequest/PullRequestReviewerPicker.tsx | 12 - .../components/pullRequest/PullRequestRow.tsx | 30 - .../pullRequest/PullRequestSpeedActions.tsx | 2 - .../pullRequest/PullRequestStackMenu.tsx | 1 - .../PullRequestSummaryTab.test.tsx | 2 - .../pullRequest/PullRequestSummaryTab.tsx | 76 +- .../pullRequest/PullRequestThreadLinks.tsx | 1 - .../pullRequest/PullRequestTimelineTab.tsx | 20 - .../pullRequest/ThreadPullRequestsPanel.tsx | 14 - .../pullRequest/guide/GuideExcerpt.tsx | 24 - .../pullRequest/guide/GuideFileDiff.tsx | 5 - .../pullRequest/guide/GuideNotes.tsx | 2 - .../pullRequest/guide/GuideProse.tsx | 8 - .../pullRequest/guide/GuideRail.tsx | 7 - .../pullRequest/guide/GuideStop.tsx | 18 - .../pullRequest/guide/GuideTour.tsx | 21 - .../guide/PullRequestGuideRunCoordinator.tsx | 8 - .../pullRequest/guide/guideRunStore.ts | 17 - .../pullRequest/guide/useGuideDiffFiles.ts | 10 - .../pullRequest/guide/useReviewGuideWriter.ts | 8 - .../pullRequest/pullRequestChecks.test.tsx | 6 - .../pullRequestDetail.logic.test.ts | 33 - .../pullRequest/pullRequestDetail.logic.ts | 258 +------ .../pullRequest/pullRequestDiff.logic.test.ts | 8 - .../pullRequest/pullRequestDiff.logic.ts | 15 +- .../pullRequestEditing.logic.test.ts | 2 - .../pullRequest/pullRequestEditing.logic.ts | 18 +- .../pullRequestFileOrder.logic.test.ts | 3 - .../pullRequest/pullRequestFileOrder.logic.ts | 24 - .../pullRequestFilesViewed.logic.test.ts | 9 - .../pullRequestFilesViewed.logic.ts | 29 +- .../pullRequest/pullRequestLinkContextMenu.ts | 13 - .../pullRequest/pullRequestList.logic.test.ts | 35 - .../pullRequest/pullRequestList.logic.ts | 268 +------ .../pullRequest/pullRequestListLines.test.ts | 1 - .../pullRequest/pullRequestListLines.ts | 9 - .../pullRequest/pullRequestListPreferences.ts | 5 +- .../pullRequest/pullRequestMarkdown.logic.ts | 1 - .../pullRequest/pullRequestPresentation.tsx | 62 -- .../pullRequestProjectAssignment.logic.ts | 32 - .../pullRequestProjectFilter.logic.ts | 2 - .../pullRequestReactions.logic.test.ts | 10 - .../pullRequest/pullRequestReactions.logic.ts | 25 +- .../pullRequest/pullRequestReviewStore.ts | 10 - .../pullRequest/pullRequestStackSnapshot.ts | 5 - .../pullRequestSummaryScroll.logic.ts | 6 - .../pullRequestsRouteSearch.test.ts | 1 - .../pullRequest/useInlineActionConfirm.ts | 1 - .../pullRequest/usePullRequestActions.ts | 70 +- .../usePullRequestFilesViewed.test.tsx | 7 - .../pullRequest/usePullRequestFilesViewed.ts | 37 - .../search/ProjectContentSearchDialog.tsx | 10 +- .../settings/AcpRegistryIcon.test.ts | 2 - .../components/settings/AcpRegistryIcon.tsx | 7 +- .../settings/AcpRegistrySearchStep.tsx | 4 - .../settings/AddCodexAccountDialog.tsx | 1 - ...roviderInstanceDialog.environment.test.tsx | 1 - .../AddProviderInstanceDialog.logic.ts | 8 - .../settings/AddProviderInstanceDialog.tsx | 16 +- .../settings/AddUsageLimitSourceDialog.tsx | 15 +- .../settings/BrowserImportWizard.tsx | 27 - .../settings/CaptureShortcutConfig.tsx | 1 - .../settings/ChatGptWelcomeCoordinator.tsx | 5 +- .../settings/CliCommandSettingsRow.tsx | 4 - .../components/settings/CodexSetupSection.tsx | 6 - .../settings/ConnectionsSettings.logic.ts | 9 - .../settings/ConnectionsSettings.tsx | 137 +--- .../components/settings/CustomModelEditor.tsx | 9 - .../settings/DiagnosticsSettings.tsx | 2 - .../settings/EnvironmentIconPicker.tsx | 13 - .../settings/EnvironmentRoutesList.tsx | 12 - .../components/settings/EnvironmentRow.tsx | 12 - .../components/settings/ExpandableText.tsx | 4 - .../settings/FoldedSettingsSection.tsx | 5 - .../components/settings/FontFamilyPicker.tsx | 36 +- .../settings/GitHubAccountSettings.logic.ts | 9 - .../settings/GitHubAccountSettings.tsx | 9 - .../settings/GitHubRoutingSettings.tsx | 10 - .../settings/GitHubTokenSettings.tsx | 6 - .../IntegrationsSettings.logic.test.ts | 2 - .../settings/IntegrationsSettings.test.tsx | 3 - .../settings/IntegrationsSettings.tsx | 84 --- .../KeybindingsSettings.environment.test.tsx | 2 - .../KeybindingsSettings.logic.test.ts | 1 - .../settings/KeybindingsSettings.logic.ts | 8 - .../settings/KeybindingsSettings.tsx | 24 - .../settings/LoadBalancingSettings.tsx | 11 - .../settings/ProcessSignalActions.tsx | 1 - .../settings/ProjectActionsSettings.tsx | 11 - .../settings/ProjectDefaultsSettings.tsx | 10 - .../settings/ProjectSettingsPanel.tsx | 8 - .../components/settings/ProjectsSettings.tsx | 2 - .../settings/ProviderAccentColorPicker.tsx | 1 - .../settings/ProviderAuthTerminal.tsx | 1 - .../ProviderAuthenticationSection.tsx | 2 - .../settings/ProviderInstanceCard.test.ts | 1 - .../settings/ProviderInstanceCard.tsx | 81 +- .../settings/ProviderModelsSection.test.ts | 1 - .../settings/ProviderModelsSection.tsx | 72 -- .../settings/ProviderSettingsForm.tsx | 8 +- ...ProviderSettingsPanel.environment.test.tsx | 1 - .../ProviderSettingsPanel.logic.test.ts | 4 - .../settings/ProviderSettingsPanel.logic.ts | 21 +- .../settings/ProviderSettingsPanel.tsx | 24 - .../settings/ProviderSetupSection.tsx | 2 - .../ProviderWizardAuthenticationStep.tsx | 1 - .../settings/ScheduledTasksSettings.tsx | 27 +- .../src/components/settings/ScopedSwitch.tsx | 5 - .../src/components/settings/SecretInput.tsx | 3 +- .../settings/SettingInheritance.tsx | 13 +- .../settings/SettingsBreadcrumb.tsx | 4 - .../settings/SettingsFontPreviews.tsx | 39 +- .../src/components/settings/SettingsGroup.tsx | 1 - .../settings/SettingsPanels.logic.test.ts | 2 - .../settings/SettingsPanels.logic.ts | 17 +- .../components/settings/SettingsPanels.tsx | 71 +- .../settings/SettingsScopeContext.tsx | 8 - .../settings/SettingsScopeNotice.tsx | 1 - .../settings/SettingsScopeSentence.tsx | 9 - .../settings/SettingsSidebarNav.tsx | 2 - .../settings/SnapShotSettings.logic.ts | 4 - .../components/settings/SnapShotSettings.tsx | 2 - .../settings/SourceControlHostSettings.tsx | 6 - .../settings/SourceControlSettings.tsx | 12 +- .../SourceControlWritingSettings.test.tsx | 3 - .../settings/SourceControlWritingSettings.tsx | 7 - .../settings/TelemetryExportSettings.test.tsx | 1 - .../settings/TelemetryExportSettings.tsx | 8 - .../components/settings/ThemeColorPicker.tsx | 7 - .../components/settings/ThemeEditorHost.tsx | 14 - .../components/settings/ThemeEditorPanel.tsx | 94 +-- .../components/settings/ThemeImportDialog.tsx | 32 +- .../settings/ThemePreviewCircles.tsx | 15 - .../settings/ThemeSearchSection.tsx | 26 +- .../src/components/settings/ThemeSettings.tsx | 33 - .../components/settings/ThemeWireframe.tsx | 8 - .../settings/UsageProviderSettings.tsx | 2 - .../browserImportWizard.logic.test.ts | 3 - .../settings/browserImportWizard.logic.ts | 35 - .../components/settings/colorPickers.test.tsx | 5 - .../settings/customModelEditor.logic.ts | 12 - .../settings/deviceHostConnectionChecks.ts | 1 - .../settings/deviceHostsSettings.logic.ts | 3 - apps/web/src/components/settings/itemRows.ts | 1 - .../components/settings/providerDriverMeta.ts | 1 - .../src/components/settings/providerStatus.ts | 20 - .../scheduledTasksSettings.logic.test.ts | 2 - .../settings/scheduledTasksSettings.logic.ts | 11 +- .../settings/scopedSettings.test.ts | 1 - .../src/components/settings/scopedSettings.ts | 29 - .../components/settings/settingsLayout.tsx | 34 - .../src/components/settings/settingsScope.ts | 8 +- .../components/settings/settingsScopeAxis.ts | 8 - .../settings/settingsScopeNavigation.test.ts | 1 - .../settings/settingsScopeNavigation.ts | 5 - .../settings/settingsSearch.test.ts | 1 - .../src/components/settings/settingsSearch.ts | 48 -- .../components/settings/themeEditorStore.ts | 13 - .../src/components/settings/themeInspector.ts | 13 +- .../useProjectScriptSettings.test.tsx | 1 - .../settings/useProjectScriptSettings.ts | 4 - .../settings/useScopedModelAvailability.ts | 6 - .../components/settings/useScopedSettings.ts | 8 - .../settings/useSettingsProjectGroups.ts | 1 - .../settings/useSnapShotShortcutRecorder.tsx | 1 - .../sidebar/DesktopUpdateStatusIcon.tsx | 2 - .../src/components/sidebar/SidebarChrome.tsx | 9 - .../sidebar/SidebarThreadHeader.tsx | 27 - .../sidebar/SidebarUpdateCapsule.tsx | 11 - .../components/sidebar/SidebarUpdatePill.tsx | 22 - .../sidebar/UpdateEverythingDialog.tsx | 12 +- .../src/components/sidebar/mainAppLocation.ts | 7 - .../sidebar/updateEverything.logic.test.ts | 1 - .../sidebar/updateEverything.logic.ts | 87 +-- .../sidebar/updateEverythingRun.tsx | 44 +- .../components/sidebar/useUpdateEverything.ts | 23 - .../components/threadActionMenu.logic.test.ts | 1 - .../src/components/threadActionMenu.logic.ts | 30 - apps/web/src/components/threadSidebarWidth.ts | 1 - .../src/components/ui/alert-dialog.test.tsx | 3 - apps/web/src/components/ui/alert-dialog.tsx | 3 - apps/web/src/components/ui/alert.tsx | 1 - apps/web/src/components/ui/badge.tsx | 2 - apps/web/src/components/ui/button.tsx | 4 - .../ui/collapsible-section-header.tsx | 1 - apps/web/src/components/ui/collapsible.tsx | 2 - apps/web/src/components/ui/color-picker.tsx | 4 - apps/web/src/components/ui/combobox.tsx | 1 - apps/web/src/components/ui/command.tsx | 1 - apps/web/src/components/ui/draft-input.tsx | 7 +- apps/web/src/components/ui/empty.tsx | 2 - apps/web/src/components/ui/input-group.tsx | 2 - apps/web/src/components/ui/input.tsx | 2 - apps/web/src/components/ui/menu.tsx | 11 +- .../web/src/components/ui/middle-truncate.tsx | 18 - .../components/ui/panel-tab-close-button.tsx | 1 - apps/web/src/components/ui/popover.tsx | 11 +- apps/web/src/components/ui/refresh-icon.tsx | 1 - apps/web/src/components/ui/scroll-area.tsx | 4 - apps/web/src/components/ui/select.tsx | 4 - apps/web/src/components/ui/sheet.tsx | 2 - apps/web/src/components/ui/sidebar.tsx | 20 - apps/web/src/components/ui/skeleton.tsx | 2 - .../web/src/components/ui/standalone-page.tsx | 2 - apps/web/src/components/ui/switch.tsx | 5 - apps/web/src/components/ui/textarea.tsx | 1 - .../web/src/components/ui/toast.logic.test.ts | 8 - apps/web/src/components/ui/toast.logic.ts | 13 - apps/web/src/components/ui/toast.tsx | 19 - apps/web/src/components/ui/toastHelpers.ts | 6 - apps/web/src/components/ui/toggle.tsx | 1 - apps/web/src/components/ui/tooltip.tsx | 8 - apps/web/src/components/ui/wizard.tsx | 2 - apps/web/src/components/usage/UsageLimits.tsx | 31 +- .../components/usage/UsageLimitsPooled.tsx | 53 +- .../src/components/usage/UsageModelDialog.tsx | 6 - apps/web/src/components/usage/UsagePage.tsx | 26 - .../components/usage/UsagePriceOverrides.tsx | 7 - .../usage/UsageProviderChart.test.ts | 5 - .../components/usage/UsageProviderChart.tsx | 21 - .../src/components/usage/UsageShareBar.tsx | 4 - .../src/components/usage/usageBreakdown.ts | 5 - .../components/usage/usagePagePreferences.ts | 4 - .../src/components/usage/usagePriceTable.ts | 5 - .../src/components/usage/usagePriceTargets.ts | 2 - .../src/components/usage/usageProviders.ts | 8 - .../src/components/usage/usageShortcuts.ts | 1 - apps/web/src/composer-block-shortcuts.test.ts | 20 - apps/web/src/composer-code-block.test.ts | 17 - apps/web/src/composer-code-block.ts | 63 +- apps/web/src/composer-code-languages.test.ts | 4 - apps/web/src/composer-code-languages.ts | 20 +- apps/web/src/composer-list-continuation.ts | 28 - apps/web/src/composer-list-split.test.ts | 17 - apps/web/src/composer-logic.test.ts | 9 - apps/web/src/composer-logic.ts | 7 - apps/web/src/composer-rich-text-doc.test.ts | 25 - apps/web/src/composer-rich-text-doc.ts | 241 +----- apps/web/src/composer-rich-text.ts | 16 - apps/web/src/composer-undo-grouping.test.ts | 8 - apps/web/src/composer-undo-grouping.ts | 12 +- apps/web/src/composerDraftStore.test.ts | 32 - apps/web/src/composerDraftStore.ts | 321 +------- apps/web/src/confirmDialog.test.ts | 1 - apps/web/src/confirmDialog.ts | 8 +- apps/web/src/connection/desktopLocal.ts | 21 +- apps/web/src/connection/platform.test.ts | 3 - apps/web/src/connection/platform.ts | 29 +- apps/web/src/connection/storage.ts | 10 +- .../connection/useDesktopLocalBootstraps.ts | 8 +- apps/web/src/contextMenuFallback.ts | 17 - apps/web/src/diffPanelStore.ts | 1 - apps/web/src/env.ts | 1 - apps/web/src/environmentGrouping.test.ts | 2 - apps/web/src/environments/primary/target.ts | 3 - apps/web/src/fileContextMenu.ts | 28 - .../src/hooks/FileMetadataThreadProvider.tsx | 1 - apps/web/src/hooks/showThreadUndoNotice.ts | 5 - apps/web/src/hooks/threadUndo.ts | 2 - apps/web/src/hooks/useActiveProjectTarget.ts | 5 - apps/web/src/hooks/useActiveThreadRef.ts | 1 - apps/web/src/hooks/useCommitOnBlur.ts | 15 - apps/web/src/hooks/useCopyToClipboard.test.ts | 1 - apps/web/src/hooks/useCopyToClipboard.ts | 9 +- apps/web/src/hooks/useDefaultTheme.test.ts | 4 - apps/web/src/hooks/useDefaultTheme.ts | 39 +- apps/web/src/hooks/useDelayedStatus.ts | 6 - .../hooks/useEnvironmentDisconnectDelay.ts | 1 - .../web/src/hooks/useEnvironmentTheme.test.ts | 20 - apps/web/src/hooks/useEnvironmentTheme.ts | 21 +- .../src/hooks/useEnvironmentThemeSync.test.ts | 1 - apps/web/src/hooks/useHandleNewThread.test.ts | 4 - apps/web/src/hooks/useHandleNewThread.ts | 78 +- apps/web/src/hooks/useHtmlRenderTheme.test.ts | 2 - apps/web/src/hooks/useHtmlRenderTheme.ts | 2 - apps/web/src/hooks/useLiveRefresh.test.ts | 2 - apps/web/src/hooks/useLiveRefresh.ts | 56 +- .../src/hooks/useLocalStorage.hook.test.tsx | 1 - apps/web/src/hooks/useMediaQuery.ts | 1 - apps/web/src/hooks/useNavigateBack.ts | 2 - apps/web/src/hooks/useNewProject.ts | 9 - apps/web/src/hooks/useNowMinute.ts | 8 - .../src/hooks/usePreviewPanelInlineSize.ts | 11 - .../src/hooks/usePullRequestLinking.test.ts | 4 - apps/web/src/hooks/usePullRequestLinking.ts | 9 - apps/web/src/hooks/useRemoveClonedProject.ts | 6 - apps/web/src/hooks/useResizableWidth.test.tsx | 1 - apps/web/src/hooks/useResizableWidth.ts | 22 +- apps/web/src/hooks/useResizeDrag.ts | 7 +- apps/web/src/hooks/useScratchProject.ts | 11 - .../src/hooks/useSettings.favorites.test.tsx | 2 - apps/web/src/hooks/useSettings.ts | 74 +- apps/web/src/hooks/useSidebarProjectGroups.ts | 1 - apps/web/src/hooks/useStableCallback.ts | 10 +- apps/web/src/hooks/useT3ProjectFileScripts.ts | 17 - apps/web/src/hooks/useTheme.ts | 37 +- apps/web/src/hooks/useThreadActionMenu.ts | 15 - .../useThreadActions.permissions.test.ts | 2 - apps/web/src/hooks/useThreadActions.ts | 37 +- .../src/hooks/useThreadVisitedMigration.ts | 11 - .../src/hooks/useWorkspaceMutationRefresh.ts | 4 - apps/web/src/hostedPairing.ts | 3 - apps/web/src/keybindings.test.ts | 2 - apps/web/src/keybindings.ts | 12 +- .../lib/assistantCitationNavigation.test.ts | 1 - .../src/lib/assistantTextSelection.test.ts | 2 - apps/web/src/lib/assistantTextSelection.ts | 22 +- .../web/src/lib/attachmentUploadQueue.test.ts | 11 - apps/web/src/lib/attachmentUploadQueue.ts | 42 +- apps/web/src/lib/attachmentUploadState.ts | 3 +- .../web/src/lib/backgroundActivityReporter.ts | 4 +- apps/web/src/lib/bootError.ts | 1 - apps/web/src/lib/chatThreadActions.ts | 7 - apps/web/src/lib/chunkReloadGuard.ts | 18 +- apps/web/src/lib/color.ts | 1 - .../src/lib/composerContextRecords.test.ts | 7 - apps/web/src/lib/composerContextRecords.ts | 32 - apps/web/src/lib/composerContextReferences.ts | 14 - apps/web/src/lib/composerDraftUploads.ts | 6 - apps/web/src/lib/composerPathSearchState.ts | 1 - apps/web/src/lib/contextWindow.test.ts | 1 - apps/web/src/lib/contextWindow.ts | 2 - apps/web/src/lib/diffFileContents.ts | 3 - apps/web/src/lib/diffRendering.test.ts | 3 - apps/web/src/lib/diffRendering.ts | 19 +- apps/web/src/lib/discardComposerDraft.ts | 5 - apps/web/src/lib/editableFocus.ts | 2 - apps/web/src/lib/elementContext.test.ts | 1 - apps/web/src/lib/elementContext.ts | 20 +- apps/web/src/lib/embeddedScripts.test.ts | 4 - apps/web/src/lib/embeddedScripts.ts | 35 +- apps/web/src/lib/imageCompression.test.ts | 32 - apps/web/src/lib/imageCompression.ts | 91 --- apps/web/src/lib/incrementalHighlighting.ts | 4 - apps/web/src/lib/observeResize.ts | 14 +- apps/web/src/lib/openPullRequestLink.test.ts | 5 - apps/web/src/lib/openPullRequestLink.ts | 37 - apps/web/src/lib/previewAnnotation.ts | 3 - apps/web/src/lib/previewFocus.ts | 8 - apps/web/src/lib/revealThreadTerminal.ts | 1 - apps/web/src/lib/selectionActions.ts | 10 +- apps/web/src/lib/snapShotSetupResume.ts | 12 +- apps/web/src/lib/snapShotShortcut.ts | 1 - apps/web/src/lib/snapShotSource.ts | 1 - apps/web/src/lib/storage.ts | 1 - apps/web/src/lib/syntaxHighlighting.ts | 2 - apps/web/src/lib/t3ProjectFileDefaults.ts | 10 - apps/web/src/lib/terminalCloseConfirm.ts | 7 - apps/web/src/lib/terminalContext.ts | 3 - apps/web/src/lib/videoFirstFrame.ts | 1 - apps/web/src/lib/visibleAnimation.ts | 2 +- apps/web/src/lib/windowControlsOverlay.ts | 1 - apps/web/src/localApi.ts | 2 - apps/web/src/localEnvironment.ts | 6 - apps/web/src/main.tsx | 17 +- apps/web/src/markdown-clipboard.test.ts | 12 - apps/web/src/markdown-clipboard.ts | 31 +- apps/web/src/markdown-links.ts | 11 - apps/web/src/modelPickerVisibility.ts | 5 - apps/web/src/modelSelection.ts | 46 +- apps/web/src/observability/clientTracer.ts | 4 - apps/web/src/onboarding/firstRun.logic.ts | 8 - apps/web/src/onboarding/firstRun.ts | 5 - .../web/src/onboarding/projectImport.logic.ts | 26 +- .../src/onboarding/providerReadiness.logic.ts | 14 +- .../src/onboarding/targetEnvironment.logic.ts | 8 +- apps/web/src/onboarding/useProjectScans.ts | 1 - apps/web/src/openVsxThemes.test.ts | 4 - apps/web/src/openVsxThemes.ts | 2 - apps/web/src/panelAnimations.ts | 5 - apps/web/src/pendingUserInput.ts | 7 - apps/web/src/pierre-icons.ts | 2 - apps/web/src/pierre-tree-theme.ts | 2 - apps/web/src/portDiscoveryState.ts | 4 +- apps/web/src/previewMiniPlayerStore.test.ts | 1 - apps/web/src/previewMiniPlayerStore.ts | 3 - apps/web/src/previewStateStore.ts | 24 +- apps/web/src/projectIdentity.ts | 2 - apps/web/src/projectScripts.test.ts | 1 - apps/web/src/projectScripts.ts | 6 - apps/web/src/promptStashStore.test.ts | 8 - apps/web/src/promptStashStore.ts | 82 +- apps/web/src/proposedPlan.ts | 1 - apps/web/src/providerInstances.test.ts | 3 - apps/web/src/providerInstances.ts | 124 +--- apps/web/src/providerModels.ts | 8 - apps/web/src/pullRequestReference.ts | 5 - apps/web/src/questionAttachments.ts | 1 - apps/web/src/remoteOpen.ts | 40 +- apps/web/src/reopenClosedView.ts | 9 - apps/web/src/reviewCommentContext.ts | 1 - apps/web/src/rightPanelStore.test.ts | 11 - apps/web/src/rightPanelStore.ts | 67 +- apps/web/src/routeTree.gen.ts | 4 - apps/web/src/router.ts | 3 - apps/web/src/routes/__root.tsx | 12 +- apps/web/src/routes/_chat.index.tsx | 10 - apps/web/src/routes/_chat.pull-requests.tsx | 411 +--------- apps/web/src/routes/_chat.tsx | 6 - apps/web/src/routes/settings.providers.tsx | 5 - apps/web/src/routes/settings.tsx | 3 - apps/web/src/routes/welcome.tsx | 5 - apps/web/src/rpc/requestLatencyState.ts | 5 - apps/web/src/session-logic.test.ts | 11 - apps/web/src/session-logic.ts | 31 +- apps/web/src/settleAndAdvance.ts | 8 - apps/web/src/shortcutModifierState.test.ts | 3 +- apps/web/src/shortcutModifierState.ts | 12 +- .../src/sidebarPendingFileDropStore.test.ts | 6 - apps/web/src/sidebarPendingFileDropStore.ts | 21 +- apps/web/src/sidebarProjectGrouping.ts | 11 - apps/web/src/sourceControlPresentation.ts | 3 - apps/web/src/state/agentSessions.ts | 6 - apps/web/src/state/assets.ts | 1 - apps/web/src/state/desktopSshHosts.ts | 1 - apps/web/src/state/device.ts | 5 - apps/web/src/state/entities.ts | 20 - apps/web/src/state/npmRelease.ts | 7 - apps/web/src/state/previewStream.ts | 2 - apps/web/src/state/projectClones.ts | 9 - apps/web/src/state/projects.ts | 5 - apps/web/src/state/pullRequests.test.ts | 3 - apps/web/src/state/pullRequests.ts | 22 +- apps/web/src/state/queries.ts | 2 - apps/web/src/state/server.ts | 15 +- apps/web/src/state/session.ts | 7 +- apps/web/src/state/shell.test.ts | 1 - apps/web/src/state/shell.ts | 8 - apps/web/src/state/terminalSessions.ts | 1 - apps/web/src/state/threads.test.ts | 9 - apps/web/src/state/threads.ts | 18 +- apps/web/src/state/usage.test.tsx | 5 - apps/web/src/state/usage.ts | 42 -- .../src/state/use-orchestration-command.ts | 1 - apps/web/src/state/usePullRequestStack.ts | 1 - apps/web/src/terminal/ghostty/core.test.ts | 3 - apps/web/src/terminal/ghostty/core.ts | 12 +- .../web/src/terminal/ghostty/keyCodes.test.ts | 1 - .../web/src/terminal/ghostty/renderer.test.ts | 2 - apps/web/src/terminal/ghostty/renderer.ts | 2 - apps/web/src/terminal/ghostty/runtime.ts | 1 - .../src/terminal/ghostty/runtimeAbi.test.ts | 12 - apps/web/src/terminal/ghostty/surface.test.ts | 27 +- apps/web/src/terminal/ghostty/surface.ts | 155 +--- apps/web/src/terminalUiStateStore.ts | 8 - apps/web/src/test-fixtures.ts | 4 - apps/web/src/test/reactElementTree.ts | 6 - apps/web/src/test/reactHookHarness.ts | 29 +- apps/web/src/themeBoot.test.ts | 7 - apps/web/src/themePalette.test.ts | 12 - apps/web/src/themePalette.ts | 116 +-- apps/web/src/threadRoutes.ts | 4 - apps/web/src/threadSelectionStore.test.ts | 41 +- apps/web/src/threadSelectionStore.ts | 22 +- apps/web/src/timestampFormat.test.ts | 3 - apps/web/src/timestampFormat.ts | 41 +- apps/web/src/types.ts | 1 - apps/web/src/uiStateStore.ts | 8 +- apps/web/src/vendor/mdast-find-and-replace.ts | 1 - apps/web/src/versionSkew.test.ts | 2 - apps/web/src/versionSkew.ts | 21 - apps/web/src/vscodeThemeImport.test.ts | 16 - apps/web/src/vscodeThemeImport.ts | 70 +- apps/web/src/workspaceBasenameLookup.test.ts | 2 - apps/web/src/workspaceBasenameLookup.ts | 6 - apps/web/vite.config.ts | 40 +- apps/web/vite/tailwind.ts | 1 - infra/relay/scripts/android-push-watch.ts | 2 - infra/relay/scripts/tunnel-census.ts | 11 - infra/relay/src/Config.ts | 8 - .../AgentActivityPublisher.test.ts | 5 - .../src/agentActivity/ApnsClient.test.ts | 5 - infra/relay/src/agentActivity/ApnsClient.ts | 8 +- .../src/agentActivity/ApnsDeliveries.test.ts | 21 - .../relay/src/agentActivity/ApnsDeliveries.ts | 17 - .../agentActivity/ApnsProviderTokens.test.ts | 4 - .../src/agentActivity/ApnsProviderTokens.ts | 3 - infra/relay/src/agentActivity/Devices.ts | 2 - .../relay/src/agentActivity/FcmDeliveries.ts | 3 - .../src/agentActivity/LiveActivities.test.ts | 3 - .../relay/src/agentActivity/LiveActivities.ts | 7 +- .../agentActivity/MobileRegistrations.test.ts | 3 - .../agentActivity/agentActivityAggregate.ts | 13 +- .../src/agentActivity/agentActivityAlerts.ts | 2 - .../agentActivity/agentActivityPayloads.ts | 6 +- .../src/agentActivity/apnsDeliveryJobs.ts | 9 - infra/relay/src/agentActivity/apnsJwt.ts | 4 - infra/relay/src/agentActivity/fcmPayloads.ts | 3 - infra/relay/src/clientConfig.test.ts | 2 - infra/relay/src/clientConfig.ts | 20 +- .../src/environments/EnvironmentConnector.ts | 9 - .../environments/EnvironmentLinker.test.ts | 2 - .../src/environments/EnvironmentLinks.test.ts | 2 - .../src/environments/EnvironmentLinks.ts | 2 - .../ManagedEndpointAllocations.test.ts | 1 - .../ManagedEndpointAllocations.ts | 3 - .../ManagedEndpointProvider.test.ts | 22 - .../environments/ManagedEndpointProvider.ts | 27 +- .../ManagedEndpointReaper.test.ts | 20 - .../src/environments/ManagedEndpointReaper.ts | 18 +- .../src/environments/ManagedTunnelLimits.ts | 4 - infra/relay/src/hooks/HeldHooks.test.ts | 1 - infra/relay/src/hooks/HeldHooks.ts | 14 +- infra/relay/src/hooks/HookForwarder.test.ts | 13 - infra/relay/src/hooks/HookForwarder.ts | 27 +- infra/relay/src/hooks/HookInbox.ts | 6 - infra/relay/src/hooks/HookInboxObject.ts | 16 - infra/relay/src/hooks/HookInboxStore.test.ts | 8 - infra/relay/src/hooks/HookInboxStore.ts | 66 +- infra/relay/src/hooks/upstream.ts | 10 - infra/relay/src/http/Api.test.ts | 4 - infra/relay/src/http/Api.ts | 22 +- infra/relay/src/observability.ts | 5 - infra/relay/src/persistence/schema.ts | 2 - infra/relay/src/worker.ts | 18 +- oxlint-plugin-t3code/index.ts | 2 + .../rules/no-hermes-unsupported-apis.ts | 2 - .../rules/no-inline-schema-compile.ts | 2 - .../rules/no-low-value-comments.ts | 118 +++ .../no-manual-effect-runtime-in-tests.ts | 3 - .../rules/no-outset-state-indicators.ts | 6 - .../rules/no-raw-mcp-registration.ts | 17 - .../rules/no-rpc-permission-bypass.ts | 1 - oxlint-plugin-t3code/rules/no-test-in-loop.ts | 9 - oxlint-plugin-t3code/rules/no-unscoped-has.ts | 11 - .../rules/prefer-catch-tags.ts | 2 - .../rules/require-centered-scroll-gutter.ts | 17 +- .../rules/require-suppression-reason.ts | 8 - oxlint-plugin-t3code/test/utils.ts | 3 - .../src/authorization/layer.test.ts | 1 - .../src/authorization/remote.ts | 2 - .../src/authorization/service.ts | 13 +- .../client-runtime/src/composerThreadItems.ts | 5 +- .../client-runtime/src/connection/catalog.ts | 29 +- .../src/connection/compatibility.ts | 1 - .../client-runtime/src/connection/driver.ts | 25 - .../client-runtime/src/connection/errors.ts | 11 +- .../connection/githubRoutingPermissions.ts | 2 - .../client-runtime/src/connection/layer.ts | 4 +- .../client-runtime/src/connection/model.ts | 2 +- .../src/connection/onboarding.test.ts | 1 - .../src/connection/onboarding.ts | 19 - .../src/connection/outdatedHostUpdate.test.ts | 2 - .../src/connection/outdatedHostUpdate.ts | 13 +- .../src/connection/presentation.test.ts | 2 - .../src/connection/presentation.ts | 7 +- .../src/connection/registry.test.ts | 16 - .../client-runtime/src/connection/registry.ts | 61 +- .../client-runtime/src/connection/resolver.ts | 1 - .../src/connection/routes.test.ts | 7 - .../client-runtime/src/connection/routes.ts | 66 +- .../src/connection/supervisor.test.ts | 29 - .../src/connection/supervisor.ts | 79 +- .../client-runtime/src/connection/wakeups.ts | 3 - .../client-runtime/src/delayedStatus.test.ts | 1 - packages/client-runtime/src/delayedStatus.ts | 14 +- .../src/device/androidFoldScene.ts | 18 - .../src/device/deviceFraming.ts | 2 - .../client-runtime/src/device/deviceMotion.ts | 16 +- .../src/device/deviceViewSnap.ts | 1 - .../src/device/duoControl.test.ts | 5 - .../client-runtime/src/device/duoControl.ts | 20 +- .../client-runtime/src/device/duoScene.ts | 5 - packages/client-runtime/src/device/duoSnap.ts | 7 - .../src/device/duoViewer.test.ts | 15 +- .../client-runtime/src/device/duoViewer.ts | 17 - packages/client-runtime/src/device/frame.ts | 1 - .../client-runtime/src/device/hubAccess.ts | 5 - packages/client-runtime/src/device/model.ts | 2 - .../client-runtime/src/device/modelScene.ts | 1 - .../src/device/phoneInteraction.ts | 3 +- .../src/device/phoneScene.test.ts | 1 - .../client-runtime/src/device/phoneScene.ts | 5 - .../client-runtime/src/device/phoneViewer.ts | 7 - .../src/device/renderScheduler.ts | 1 - .../client-runtime/src/device/screenshot.ts | 1 - .../client-runtime/src/device/shapeProfile.ts | 2 - .../client-runtime/src/device/stream.test.ts | 1 - packages/client-runtime/src/device/stream.ts | 46 +- packages/client-runtime/src/diffCount.ts | 1 - packages/client-runtime/src/errors/network.ts | 2 - .../client-runtime/src/errors/transport.ts | 10 - packages/client-runtime/src/handoff.ts | 18 +- .../client-runtime/src/imageAttachmentSize.ts | 12 +- packages/client-runtime/src/markdownImages.ts | 1 - .../client-runtime/src/mcpApps/host.test.ts | 6 - packages/client-runtime/src/mcpApps/host.ts | 46 +- packages/client-runtime/src/mediaActions.ts | 1 - packages/client-runtime/src/mediaReference.ts | 2 - .../client-runtime/src/mediaSource.test.ts | 1 - packages/client-runtime/src/mediaSource.ts | 1 - .../operations/commands.performance.test.ts | 9 - .../src/operations/commands.test.ts | 1 - .../client-runtime/src/operations/commands.ts | 18 +- .../client-runtime/src/operations/projects.ts | 47 -- .../src/platform/capabilities.ts | 3 +- .../src/platform/orchestrationCache.test.ts | 1 - .../src/platform/orchestrationCache.ts | 5 - .../src/platform/persistence.ts | 22 +- .../src/platform/shellCacheEncoding.ts | 7 - .../client-runtime/src/platform/source.ts | 3 +- .../src/platform/storageDocument.test.ts | 4 - .../src/platform/storageDocument.ts | 21 +- .../src/preview/serverBrowserStream.ts | 24 +- .../client-runtime/src/projectFaviconCache.ts | 21 +- packages/client-runtime/src/promptLinks.ts | 16 +- .../client-runtime/src/providerSkills.test.ts | 1 - packages/client-runtime/src/providerSkills.ts | 11 +- .../src/providerUpdateRequiredModels.ts | 9 - .../client-runtime/src/pullRequestMarkdown.ts | 22 +- .../src/relay/discovery.test.ts | 2 - .../client-runtime/src/relay/discovery.ts | 4 - .../src/relay/errorPresentation.ts | 9 - .../client-runtime/src/relay/managedRelay.ts | 7 - .../src/relay/managedRelayState.ts | 1 - .../src/reviewGuideTour.test.ts | 5 - .../client-runtime/src/reviewGuideTour.ts | 33 +- .../client-runtime/src/rpc/client.test.ts | 2 - packages/client-runtime/src/rpc/client.ts | 11 - packages/client-runtime/src/rpc/http.ts | 1 - .../client-runtime/src/rpc/session.test.ts | 2 - packages/client-runtime/src/rpc/session.ts | 3 - .../src/scheduledTaskWebhook.ts | 1 - packages/client-runtime/src/secretRequest.ts | 12 +- .../src/sourceControlClients.test.ts | 1 - .../src/sourceControlClients.ts | 16 +- .../client-runtime/src/state/assets.test.ts | 2 - packages/client-runtime/src/state/assets.ts | 18 - .../src/state/attachments.test.ts | 7 - .../client-runtime/src/state/attachments.ts | 44 +- .../state/boundedThreadSnapshotHttp.test.ts | 1 - .../src/state/boundedThreadSnapshotHttp.ts | 16 +- .../src/state/cachePersistence.ts | 1 - .../src/state/commandPermissions.ts | 2 - .../src/state/composerDispatch.ts | 9 - .../client-runtime/src/state/connections.ts | 5 - packages/client-runtime/src/state/device.ts | 2 - .../src/state/deviceHubAccess.ts | 15 +- .../client-runtime/src/state/entities.test.ts | 6 - .../src/state/environmentHttpAuth.test.ts | 1 - .../src/state/environmentHttpAuth.ts | 15 - .../client-runtime/src/state/fileMetadata.ts | 3 - .../src/state/filesystem.test.ts | 1 - .../client-runtime/src/state/itemSupport.ts | 5 - packages/client-runtime/src/state/mcpApps.ts | 5 - packages/client-runtime/src/state/models.ts | 35 +- .../client-runtime/src/state/orchestration.ts | 2 - .../state/orchestrationV2Projection.test.ts | 6 - .../src/state/orchestrationV2Projection.ts | 2 - .../src/state/outdatedServerUpdate.ts | 4 - .../src/state/presentation.test.ts | 1 - .../client-runtime/src/state/presentation.ts | 3 - .../src/state/projectCommands.ts | 3 - .../src/state/projectGrouping.ts | 14 - packages/client-runtime/src/state/projects.ts | 1 - .../src/state/providerInstanceDisplay.ts | 34 - .../src/state/pullRequestDiffHttp.test.ts | 2 - .../src/state/pullRequestRouting.ts | 5 +- .../src/state/pullRequestStatsPersistence.ts | 1 - .../src/state/pullRequests.test.ts | 6 - .../client-runtime/src/state/pullRequests.ts | 31 +- .../src/state/queryPersistence.ts | 1 - .../client-runtime/src/state/runtime.test.ts | 9 +- packages/client-runtime/src/state/runtime.ts | 12 - .../client-runtime/src/state/server.test.ts | 14 - packages/client-runtime/src/state/server.ts | 44 +- .../src/state/serverConfigProjection.ts | 4 +- .../src/state/serverUsage.test.ts | 1 - packages/client-runtime/src/state/session.ts | 11 - .../src/state/sharedSettings.ts | 26 +- .../src/state/shell-sync.test.ts | 22 - packages/client-runtime/src/state/shell.ts | 21 +- .../src/state/shellPullRequests.ts | 13 - .../src/state/shellReducer.test.ts | 2 - .../client-runtime/src/state/shellReducer.ts | 20 +- .../src/state/shellSnapshotHttp.ts | 25 +- .../src/state/sourceControl.test.ts | 2 - .../client-runtime/src/state/sourceControl.ts | 2 - .../src/state/subagentDisplay.ts | 8 - .../src/state/subagentRuntime.test.ts | 4 - .../src/state/subagentRuntime.ts | 29 - .../src/state/terminalOutput.ts | 15 - .../src/state/threadCheckpoints.ts | 1 - .../src/state/threadExecution.test.ts | 4 - .../src/state/threadExecution.ts | 51 +- .../src/state/threadFeedback.ts | 1 - .../src/state/threadHistoryController.test.ts | 1 - .../src/state/threadHistoryController.ts | 8 - .../src/state/threadHistoryMerge.test.ts | 6 - .../src/state/threadHistoryMerge.ts | 21 - .../src/state/threadInbox.test.ts | 3 - .../client-runtime/src/state/threadInbox.ts | 26 +- .../src/state/threadRelationships.test.ts | 7 - .../src/state/threadRelationships.ts | 13 - .../src/state/threadRequests.ts | 5 - .../src/state/threadRetention.ts | 2 - .../client-runtime/src/state/threadSearch.ts | 5 - .../client-runtime/src/state/threadSettled.ts | 73 -- .../src/state/threadShell.test.ts | 4 - .../client-runtime/src/state/threadShell.ts | 8 +- .../src/state/threadSnapshotHttp.ts | 32 +- .../src/state/threadSnoozed.test.ts | 9 - .../src/state/threadSort.test.ts | 6 - .../client-runtime/src/state/threadSort.ts | 52 -- .../client-runtime/src/state/threadState.ts | 5 +- .../src/state/threadSubagents.ts | 19 - .../src/state/threadWorkflows.ts | 2 - .../src/state/threads-sync.test.ts | 35 - packages/client-runtime/src/state/threads.ts | 33 - .../src/state/turnItemPresentation.test.ts | 3 - .../src/state/turnItemPresentation.ts | 9 - packages/client-runtime/src/state/usage.ts | 13 +- .../src/state/usageProgress.test.ts | 2 - .../client-runtime/src/state/usageProgress.ts | 28 - packages/client-runtime/src/state/vcs.test.ts | 2 - packages/client-runtime/src/state/vcs.ts | 6 - .../src/state/vcsAction.test.ts | 3 - .../client-runtime/src/state/vcsAction.ts | 5 +- .../src/state/vcsRefInvalidation.ts | 8 +- packages/client-runtime/src/t3ToolSummary.ts | 4 - packages/client-runtime/src/textPaste.ts | 2 - .../src/threadPullRequestCompatibility.ts | 1 - packages/client-runtime/src/userMessage.ts | 6 - .../src/voice-input/controller.ts | 8 +- .../src/voice-input/transcription.ts | 1 - packages/client-runtime/src/webhookAddress.ts | 8 - .../src/work-log/commandLabel.test.ts | 6 - .../src/work-log/commandLabel.ts | 25 - .../src/work-log/itemDetail.test.ts | 1 - .../client-runtime/src/work-log/itemDetail.ts | 28 +- .../src/work-log/presentation.test.ts | 2 +- .../src/work-log/presentation.ts | 31 +- packages/client-runtime/src/worktreeSetup.ts | 20 - packages/contracts/src/acpRegistry.ts | 10 - packages/contracts/src/agentSessions.ts | 22 - packages/contracts/src/applicationEvent.ts | 11 - packages/contracts/src/assets.ts | 15 - packages/contracts/src/assistantCitations.ts | 1 - packages/contracts/src/auth.ts | 106 +-- packages/contracts/src/baseSchemas.test.ts | 5 - packages/contracts/src/baseSchemas.ts | 72 +- packages/contracts/src/browserImport.ts | 65 -- packages/contracts/src/browserProfile.test.ts | 12 - packages/contracts/src/browserProfile.ts | 32 +- packages/contracts/src/chatAttachment.test.ts | 6 - packages/contracts/src/chatAttachment.ts | 15 +- .../contracts/src/clientRpcPermissions.ts | 1 - .../contracts/src/composerContext.test.ts | 1 - packages/contracts/src/composerContext.ts | 31 +- .../contracts/src/composerContextClipboard.ts | 5 - packages/contracts/src/desktopBootstrap.ts | 2 - packages/contracts/src/desktopBrowser.ts | 17 - packages/contracts/src/device.ts | 54 -- packages/contracts/src/editor.ts | 28 - packages/contracts/src/environment.ts | 112 +-- .../contracts/src/environmentHttp.test.ts | 2 - packages/contracts/src/environmentHttp.ts | 22 - packages/contracts/src/filesystem.ts | 2 - packages/contracts/src/git.ts | 26 +- packages/contracts/src/ipc.ts | 198 +---- packages/contracts/src/keybindings.ts | 2 - packages/contracts/src/mcpApps.ts | 15 - packages/contracts/src/model.ts | 38 +- packages/contracts/src/modelSelection.test.ts | 18 +- packages/contracts/src/modelSelection.ts | 12 - .../contracts/src/orchestrationDispatch.ts | 4 - .../contracts/src/orchestrationProject.ts | 5 - .../contracts/src/orchestrationV2.test.ts | 17 - packages/contracts/src/orchestrationV2.ts | 327 +------- .../contracts/src/orchestratorMcp.test.ts | 1 - packages/contracts/src/orchestratorMcp.ts | 28 - packages/contracts/src/preview.ts | 73 +- packages/contracts/src/previewAutomation.ts | 23 +- packages/contracts/src/project.test.ts | 2 - packages/contracts/src/project.ts | 24 +- packages/contracts/src/projectClone.ts | 15 - packages/contracts/src/provider.ts | 5 - packages/contracts/src/providerInstance.ts | 91 +-- packages/contracts/src/providerPolicy.ts | 1 - packages/contracts/src/providerRuntime.ts | 44 +- packages/contracts/src/providerSetup.ts | 3 - packages/contracts/src/providerUsageLimits.ts | 36 +- packages/contracts/src/pullRequest.test.ts | 10 +- packages/contracts/src/pullRequest.ts | 539 +------------- .../contracts/src/pullRequestReviewGuide.ts | 44 -- packages/contracts/src/relay.ts | 18 +- packages/contracts/src/resourceTelemetry.ts | 11 - packages/contracts/src/review.ts | 1 - packages/contracts/src/rpc.test.ts | 6 - packages/contracts/src/rpc.ts | 52 +- packages/contracts/src/scheduledTask.ts | 10 - packages/contracts/src/secretRequest.ts | 5 +- packages/contracts/src/server.test.ts | 6 - packages/contracts/src/server.ts | 160 +--- packages/contracts/src/settings.test.ts | 11 - packages/contracts/src/settings.ts | 304 ++------ packages/contracts/src/sourceControl.ts | 7 +- packages/contracts/src/t3ProjectFile.ts | 18 +- packages/contracts/src/terminal.ts | 7 - packages/contracts/src/threadPullRequest.ts | 30 +- packages/contracts/src/threadTitle.ts | 1 - packages/contracts/src/usage.ts | 105 +-- packages/contracts/src/usageLimitSourceId.ts | 5 +- packages/contracts/src/vcs.ts | 1 - packages/contracts/src/worktreeMcp.ts | 14 +- packages/contracts/src/worktreeSetup.ts | 19 - .../effect-acp/src/_generated/meta.gen.ts | 3 - .../src/_generated/schema-v1.gen.ts | 3 - .../effect-acp/src/_generated/schema.gen.ts | 1 - packages/effect-acp/src/_internal/shared.ts | 4 - packages/effect-acp/src/agent.test.ts | 1 - packages/effect-acp/src/agent.ts | 49 -- packages/effect-acp/src/client.test.ts | 6 - packages/effect-acp/src/client.ts | 86 +-- packages/effect-acp/src/compat.ts | 3 - packages/effect-acp/src/errors.test.ts | 2 - packages/effect-acp/src/errors.ts | 11 +- packages/effect-acp/src/protocol.test.ts | 6 - packages/effect-acp/src/protocol.ts | 19 +- packages/effect-acp/src/rpc.ts | 4 - packages/effect-acp/src/schema.ts | 2 - .../scripts/generate.ts | 11 +- .../src/_generated/meta.gen.ts | 3 - .../src/_generated/namespaces.gen.ts | 3 - .../src/_generated/schema.gen.ts | 3 - .../effect-codex-app-server/src/replay.ts | 11 +- packages/provider-acp-registry/src/client.ts | 8 - packages/provider-acp-registry/src/server.ts | 10 - .../server/AcpRegistryRuntimeCoordinator.ts | 3 - .../src/server/AcpRegistrySupport.test.ts | 2 - .../src/server/AcpRegistrySupport.ts | 16 - .../src/server/adapter.ts | 19 +- .../provider-acp-registry/src/server/auth.ts | 5 +- .../src/server/authenticationState.test.ts | 1 - .../src/server/authenticationState.ts | 1 - .../src/server/copilotAcp.ts | 15 +- .../src/server/devinAcp.ts | 1 - .../src/server/driver.ts | 6 - .../provider-acp-registry/src/server/probe.ts | 7 - .../provider-acp-registry/src/settings.ts | 6 - packages/provider-acp-registry/src/testing.ts | 6 - .../src/server/AcpSessionRuntime.ts | 176 +---- packages/provider-acp/src/server/adapter.ts | 633 +--------------- .../src/server/clientPolicy.test.ts | 1 - .../provider-acp/src/server/clientPolicy.ts | 47 +- .../src/server/clientTerminals.test.ts | 2 - .../src/server/clientTerminals.ts | 22 - .../src/server/mcpOverAcpBridge.test.ts | 1 - .../src/server/mcpOverAcpBridge.ts | 2 - .../src/server/runtimeModel.test.ts | 35 - .../provider-acp/src/server/runtimeModel.ts | 78 +- .../provider-acp/src/server/sessionConfig.ts | 18 +- packages/provider-acp/src/server/stderr.ts | 2 - packages/provider-core/src/client.ts | 32 - .../provider-core/src/server/IdAllocator.ts | 1 - .../src/server/McpProviderSessions.ts | 7 - .../src/server/ModelCatalog.test.ts | 8 - .../provider-core/src/server/ModelCatalog.ts | 45 -- .../src/server/ProviderAdapter.ts | 61 +- .../server/ProviderContinuationRequests.ts | 32 +- .../src/server/ProviderEventLoggers.ts | 8 - .../provider-core/src/server/ProviderHost.ts | 19 - .../src/server/ProviderLatestVersions.ts | 15 - .../src/server/attachmentPrompt.test.ts | 1 - .../src/server/attachmentPrompt.ts | 1 - packages/provider-core/src/server/auth.ts | 2 - packages/provider-core/src/server/driver.ts | 103 +-- packages/provider-core/src/server/errors.ts | 6 - packages/provider-core/src/server/failure.ts | 3 - .../provider-core/src/server/handoffBudget.ts | 20 +- .../src/server/instanceIdentity.ts | 7 - .../provider-core/src/server/maintenance.ts | 3 - .../src/server/maintenanceResolver.test.ts | 15 - .../src/server/maintenanceResolver.ts | 67 +- .../src/server/managedProvider.test.ts | 7 - .../src/server/managedProvider.ts | 10 - .../provider-core/src/server/mcpSession.ts | 14 +- .../src/server/nativeProtocolLogging.ts | 1 - .../provider-core/src/server/notification.ts | 12 - .../src/server/orchestrationInstructions.ts | 4 +- .../provider-core/src/server/pathExpansion.ts | 10 +- .../provider-core/src/server/processGroup.ts | 6 +- .../src/server/providerAuthFlow.ts | 5 - .../src/server/runtimeInstructions.ts | 1 - .../src/server/selectionTransition.ts | 5 - packages/provider-core/src/server/snapshot.ts | 5 +- .../provider-core/src/server/snapshotProbe.ts | 14 - .../src/server/snapshotSettings.ts | 1 - .../src/server/subagentProjection.ts | 16 - .../src/server/textGeneration.ts | 34 - .../src/server/textGenerationOperations.ts | 25 +- .../src/server/textGenerationPrompts.test.ts | 1 - .../src/server/textGenerationPrompts.ts | 46 +- .../src/server/textGenerationUtils.ts | 17 - .../src/server/threadTitleContext.ts | 4 - packages/provider-core/src/server/usage.ts | 54 +- .../provider-core/src/server/usageLimits.ts | 24 - packages/provider-cursor/src/client.ts | 6 - packages/provider-cursor/src/server.ts | 6 - .../src/server/CursorAccountReader.ts | 9 - .../src/server/CursorAgentSdk.ts | 16 +- .../src/server/CursorKeychain.ts | 18 +- .../src/server/CursorSdk.test.ts | 5 +- .../provider-cursor/src/server/CursorSdk.ts | 14 +- .../src/server/CursorUsageAccounts.ts | 34 - .../src/server/accountCache.test.ts | 1 - .../src/server/accountCache.ts | 32 +- .../src/server/accountUsage.test.ts | 6 - .../src/server/accountUsage.ts | 17 - .../src/server/adapter.test.ts | 3 - .../provider-cursor/src/server/adapter.ts | 16 +- packages/provider-cursor/src/server/auth.ts | 2 - .../src/server/credentialStore.test.ts | 1 - .../src/server/credentialStore.ts | 6 - packages/provider-cursor/src/server/driver.ts | 9 - .../provider-cursor/src/server/skills.test.ts | 4 - packages/provider-cursor/src/server/skills.ts | 6 - .../src/server/textGeneration.ts | 9 +- .../src/server/transportFailure.ts | 1 - packages/provider-cursor/src/settings.ts | 7 - packages/provider-cursor/src/testing.ts | 6 - packages/provider-grok/src/client.ts | 6 - packages/provider-grok/src/server.ts | 5 - .../src/server/acpCliProbe.test.ts | 10 - .../provider-grok/src/server/acpSupport.ts | 22 +- packages/provider-grok/src/server/adapter.ts | 22 +- .../provider-grok/src/server/driver.test.ts | 1 - packages/provider-grok/src/server/driver.ts | 3 +- packages/provider-grok/src/server/skills.ts | 28 +- packages/provider-grok/src/server/status.ts | 19 +- .../provider-grok/src/server/usage.test.ts | 1 - packages/provider-grok/src/server/usage.ts | 27 +- .../provider-grok/src/server/usageLimits.ts | 15 +- .../src/server/xaiAcpExtension.test.ts | 17 - .../src/server/xaiAcpExtension.ts | 144 +--- .../src/server/xaiBackgroundTasks.ts | 1 - packages/provider-grok/src/settings.ts | 8 - packages/provider-grok/src/testing.ts | 6 - packages/provider-muse/src/client.ts | 7 - packages/provider-muse/src/server.ts | 6 - .../provider-muse/src/server/adapter.test.ts | 10 - packages/provider-muse/src/server/adapter.ts | 71 +- .../src/server/itemPresentation.ts | 4 - .../src/server/maintenance.test.ts | 1 - .../provider-muse/src/server/maintenance.ts | 5 - .../src/server/modelCatalog.test.ts | 1 - .../provider-muse/src/server/modelCatalog.ts | 4 - packages/provider-muse/src/server/protocol.ts | 9 +- packages/provider-muse/src/server/sdk.test.ts | 5 - packages/provider-muse/src/server/sdk.ts | 33 +- packages/provider-muse/src/server/status.ts | 1 - .../src/server/textGeneration.ts | 3 - packages/provider-muse/src/settings.ts | 6 +- packages/provider-muse/src/testing.ts | 7 - packages/provider-opencode/src/client.ts | 6 - packages/provider-opencode/src/server.ts | 6 - .../server/OpenCodeRuntime.cliParsers.test.ts | 7 +- .../OpenCodeRuntime.permissions.test.ts | 2 - .../src/server/OpenCodeRuntime.ts | 64 +- .../src/server/OpenCodeServerLedger.test.ts | 12 - .../src/server/OpenCodeServerLedger.ts | 34 +- .../src/server/OpenCodeServerOwner.ts | 1 - .../src/server/adapter.test.ts | 8 - .../provider-opencode/src/server/adapter.ts | 46 +- .../src/server/driver.test.ts | 8 - .../provider-opencode/src/server/driver.ts | 44 +- .../src/server/openCode2Catalog.test.ts | 14 - .../src/server/openCode2Catalog.ts | 23 +- .../src/server/probeResponses.fixture.ts | 10 - .../src/server/status.test.ts | 19 - .../provider-opencode/src/server/status.ts | 43 +- .../src/server/textGeneration.test.ts | 2 - .../provider-opencode/src/server/toolItems.ts | 12 - .../src/server/usage.test.ts | 8 - .../provider-opencode/src/server/usage.ts | 20 - .../src/server/usageLimits.ts | 5 +- .../server/v2/OpenCode2Client.live.test.ts | 15 +- .../src/server/v2/OpenCode2Client.test.ts | 1 - .../src/server/v2/OpenCode2Client.ts | 25 +- .../server/v2/OpenCode2Server.live.test.ts | 30 +- .../src/server/v2/OpenCode2Server.test.ts | 6 - .../src/server/v2/OpenCode2Server.ts | 45 +- .../src/server/v2/adapter.ts | 606 +-------------- .../src/server/v2/textGeneration.ts | 18 +- .../src/server/versionProbe.test.ts | 2 - .../src/server/versionProbe.ts | 15 +- packages/provider-opencode/src/settings.ts | 8 +- packages/provider-opencode/src/testing.ts | 7 - packages/provider-pi/src/client.ts | 6 - packages/provider-pi/src/server.ts | 6 - .../provider-pi/src/server/adapter.test.ts | 68 -- packages/provider-pi/src/server/adapter.ts | 222 +----- packages/provider-pi/src/server/commands.ts | 1 - .../provider-pi/src/server/driver.test.ts | 1 - packages/provider-pi/src/server/driver.ts | 5 - .../src/server/mcpExtensionSource.test.ts | 6 - .../src/server/mcpExtensionSource.ts | 7 - .../provider-pi/src/server/mcpInjection.ts | 8 - packages/provider-pi/src/server/rpc.ts | 67 +- packages/provider-pi/src/server/status.ts | 17 +- .../src/server/textGeneration.test.ts | 4 - .../provider-pi/src/server/textGeneration.ts | 10 - .../src/server/thinkingCapabilities.ts | 7 - packages/provider-pi/src/server/usage.ts | 3 +- packages/provider-pi/src/settings.ts | 7 +- .../provider-testing/src/TestProviderHost.ts | 13 - packages/provider-testing/src/fakeCli.ts | 26 +- packages/provider-testing/src/replayGate.ts | 6 +- .../provider-testing/src/replayTranscript.ts | 5 - .../provider-testing/src/replayWorkspace.ts | 1 - packages/shared/src/AgentScope.test.ts | 1 - packages/shared/src/AgentScope.ts | 39 +- packages/shared/src/DrainableWorker.test.ts | 3 - packages/shared/src/DrainableWorker.ts | 36 +- packages/shared/src/HostProcess.ts | 12 +- packages/shared/src/KeyedCoalescingWorker.ts | 12 - packages/shared/src/KeyedLock.test.ts | 6 - packages/shared/src/KeyedLock.ts | 9 +- packages/shared/src/Net.test.ts | 4 +- packages/shared/src/Net.ts | 28 +- packages/shared/src/PtyAdapter.ts | 17 - packages/shared/src/agentAwareness.ts | 2 - packages/shared/src/assistantCitations.ts | 6 - packages/shared/src/atomicWrite.test.ts | 1 - packages/shared/src/atomicWrite.ts | 3 +- packages/shared/src/changeRequestUrl.ts | 36 +- packages/shared/src/claudeCompaction.test.ts | 2 - packages/shared/src/cliArgs.ts | 24 - packages/shared/src/cliRelease.test.ts | 2 - packages/shared/src/cliRelease.ts | 37 +- packages/shared/src/codexArtifactTemplates.ts | 1 - packages/shared/src/codexAuthHandoff.ts | 2 - .../shared/src/codexMarkdownDirectives.ts | 5 - .../src/composerContextClipboard.test.ts | 3 - .../shared/src/composerContextClipboard.ts | 3 - .../shared/src/composerContextLegacy.test.ts | 5 - packages/shared/src/composerContextLegacy.ts | 12 +- .../src/composerContextLegacySend.test.ts | 2 - .../shared/src/composerContextLegacySend.ts | 7 - .../src/composerContextReferences.test.ts | 1 - .../shared/src/composerContextReferences.ts | 41 +- .../shared/src/composerInlineTokens.test.ts | 2 - packages/shared/src/composerInlineTokens.ts | 32 +- .../src/composerPullRequestMatches.test.ts | 1 - .../shared/src/composerPullRequestMatches.ts | 11 - packages/shared/src/composerTrigger.ts | 7 - packages/shared/src/connectAuth.ts | 26 +- packages/shared/src/dateTime.ts | 1 - packages/shared/src/delimitedPreview.ts | 2 - packages/shared/src/desktopAppControl.ts | 1 - .../shared/src/desktopBootstrapToken.test.ts | 1 - packages/shared/src/desktopBootstrapToken.ts | 17 +- packages/shared/src/devHome.test.ts | 2 - packages/shared/src/devHome.ts | 29 +- packages/shared/src/devProxy.ts | 5 +- packages/shared/src/editor.ts | 2 +- packages/shared/src/favicon.ts | 16 - packages/shared/src/fileLinks.ts | 1 - packages/shared/src/filePreview.ts | 10 +- packages/shared/src/git.test.ts | 5 - packages/shared/src/git.ts | 60 +- packages/shared/src/gitPatchPath.test.ts | 4 - packages/shared/src/gitPatchPath.ts | 34 +- packages/shared/src/githubMarkdownLinks.ts | 2 - packages/shared/src/githubMedia.ts | 19 - .../shared/src/hostClassification.test.ts | 1 - packages/shared/src/hostClassification.ts | 5 - packages/shared/src/htmlRender.test.ts | 3 - packages/shared/src/htmlRender.ts | 87 +-- packages/shared/src/httpReadiness.ts | 21 - packages/shared/src/image.test.ts | 6 - packages/shared/src/image.ts | 13 +- packages/shared/src/imageDimensions.test.ts | 6 - packages/shared/src/imageDimensions.ts | 13 - packages/shared/src/legacyCliLauncher.test.ts | 2 +- packages/shared/src/legacyCliLauncher.ts | 9 +- packages/shared/src/logging.ts | 1 - packages/shared/src/markdownGithubAlerts.ts | 17 - packages/shared/src/markdownLinks.test.ts | 2 - packages/shared/src/markdownLinks.ts | 26 - .../shared/src/markdownListIndentation.ts | 8 +- packages/shared/src/markdownPipeline.ts | 18 - packages/shared/src/mcpApp.test.ts | 1 - packages/shared/src/mcpApp.ts | 37 +- packages/shared/src/mcpResponsePayloads.ts | 12 - packages/shared/src/model.test.ts | 2 - packages/shared/src/model.ts | 27 - packages/shared/src/nodeRuntime.ts | 18 +- packages/shared/src/nodeSqliteClient.test.ts | 30 - packages/shared/src/nodeSqliteClient.ts | 104 +-- packages/shared/src/oauthScope.ts | 4 - packages/shared/src/observability.test.ts | 11 - packages/shared/src/observability.ts | 33 +- .../shared/src/orchestrationTiming.test.ts | 7 - packages/shared/src/orchestrationTiming.ts | 14 +- .../orchestrationV2BoundedSnapshot.test.ts | 5 - .../src/orchestrationV2BoundedSnapshot.ts | 9 - ...chestrationV2PendingBackgroundWork.test.ts | 1 - .../orchestrationV2PendingBackgroundWork.ts | 51 +- .../shared/src/orchestrationV2ThreadError.ts | 20 +- .../shared/src/orchestrationV2Timeline.ts | 1 - packages/shared/src/otelEnvironment.test.ts | 3 - packages/shared/src/otelEnvironment.ts | 55 -- packages/shared/src/path.test.ts | 4 - packages/shared/src/path.ts | 9 - packages/shared/src/preview.test.ts | 1 - packages/shared/src/preview.ts | 37 +- packages/shared/src/projectFavicon.ts | 4 +- packages/shared/src/projectScripts.ts | 7 - packages/shared/src/projectSettings.test.ts | 9 - packages/shared/src/projectSettings.ts | 51 +- packages/shared/src/promptLinks.ts | 38 +- packages/shared/src/providerAuthReturnUrl.ts | 1 - packages/shared/src/qrCode.ts | 342 ++------- packages/shared/src/relayClient.test.ts | 13 - packages/shared/src/relayClient.ts | 24 +- packages/shared/src/relayJwt.ts | 1 - packages/shared/src/relayTracing.ts | 13 +- packages/shared/src/schemaJson.test.ts | 3 - packages/shared/src/schemaJson.ts | 30 - packages/shared/src/schemaYaml.ts | 29 - packages/shared/src/searchRanking.ts | 6 - packages/shared/src/semver.test.ts | 2 - packages/shared/src/semver.ts | 11 - packages/shared/src/serverSettings.test.ts | 8 - packages/shared/src/serverSettings.ts | 44 +- packages/shared/src/shell.test.ts | 22 +- packages/shared/src/shell.ts | 49 +- packages/shared/src/sourceControl.test.ts | 2 - packages/shared/src/sourceControl.ts | 12 - packages/shared/src/symlink.ts | 8 +- packages/shared/src/t3McpToolPresentation.ts | 6 - packages/shared/src/t3ProjectFile.ts | 17 - packages/shared/src/terminalLabels.ts | 9 +- .../src/testing/TraceRetention.fixture.mjs | 1 - packages/shared/src/testing/longTempDir.ts | 7 +- packages/shared/src/testing/symlinks.ts | 11 +- packages/shared/src/themePalettes.ts | 19 - packages/shared/src/themePreview.ts | 2 - packages/shared/src/threadFindText.ts | 3 - packages/shared/src/threadLinks.test.ts | 1 - packages/shared/src/threadLinks.ts | 15 - packages/shared/src/threadPullRequests.ts | 32 +- packages/shared/src/toolActivity.ts | 10 - packages/shared/src/toolOutput.ts | 25 +- packages/shared/src/usageFormat.ts | 28 +- packages/shared/src/usageLimits.test.ts | 22 - packages/shared/src/usageLimits.ts | 110 +-- packages/shared/src/usageMerge.test.ts | 7 - packages/shared/src/usageMerge.ts | 54 +- packages/shared/src/video.ts | 3 - .../src/client/definition.ts | 9 - .../server/AzureDevOpsPullRequestCli.test.ts | 73 -- .../src/server/AzureDevOpsPullRequestCli.ts | 87 +-- .../AzureDevOpsPullRequestProvider.test.ts | 48 +- .../server/AzureDevOpsPullRequestProvider.ts | 113 +-- .../AzureDevOpsSourceControlProvider.ts | 6 +- .../src/server/azureDevOpsDiff.test.ts | 41 - .../src/server/azureDevOpsDiff.ts | 72 +- .../server/azureDevOpsPullRequestJson.test.ts | 28 +- .../src/server/azureDevOpsPullRequestJson.ts | 67 -- .../src/server/azureDevOpsPullRequests.ts | 13 +- .../src/server/driver.ts | 5 - .../src/client/definition.ts | 11 - .../src/server/BitbucketApi.ts | 53 +- .../server/BitbucketPullRequestApi.test.ts | 30 +- .../src/server/BitbucketPullRequestApi.ts | 111 +-- .../BitbucketPullRequestProvider.test.ts | 5 - .../server/BitbucketPullRequestProvider.ts | 32 +- .../server/bitbucketPullRequestJson.test.ts | 6 - .../src/server/bitbucketPullRequestJson.ts | 56 +- .../src/server/driver.ts | 6 - .../src/client/definition.ts | 112 +-- .../src/server/PullRequestProvider.ts | 259 +------ .../src/server/SourceControlHost.ts | 23 +- .../src/server/SourceControlProvider.ts | 27 +- .../src/server/SourceControlRateLimit.ts | 6 +- .../src/server/diffFileRevisions.ts | 21 +- .../src/server/discovery.ts | 10 - .../source-control-core/src/server/driver.ts | 14 - .../src/server/mergeMessage.ts | 1 - .../src/server/pullRequestChecks.ts | 16 - .../src/client/definition.ts | 10 +- .../src/server/ForgejoCli.ts | 1 - .../ForgejoSourceControlProvider.test.ts | 4 - .../server/ForgejoSourceControlProvider.ts | 4 - .../src/server/driver.ts | 6 - .../src/client/definition.ts | 9 - .../src/server/GitCafeApi.ts | 10 - .../src/server/GitCafeCredentials.test.ts | 19 - .../src/server/GitCafeCredentials.ts | 17 - .../server/GitCafePullRequestProvider.test.ts | 37 - .../src/server/GitCafePullRequestProvider.ts | 30 +- .../GitCafeSourceControlProvider.test.ts | 13 - .../server/GitCafeSourceControlProvider.ts | 14 - .../src/server/driver.ts | 6 - .../src/server/gitCafeHosts.test.ts | 8 - .../src/server/gitCafeHosts.ts | 10 - .../src/server/gitCafePullRequestJson.test.ts | 26 - .../src/server/gitCafePullRequestJson.ts | 20 - .../src/client/definition.ts | 12 - .../src/server/GitHubCredentials.test.ts | 2 - .../src/server/GitHubCredentials.ts | 24 - .../src/server/GitHubPullRequestApi.test.ts | 116 --- .../src/server/GitHubPullRequestApi.ts | 244 +----- .../server/GitHubPullRequestProvider.test.ts | 9 - .../src/server/GitHubPullRequestProvider.ts | 37 +- .../src/server/GitHubQuota.test.ts | 3 - .../src/server/GitHubQuota.ts | 28 +- .../src/server/GitHubRest.test.ts | 2 - .../GitHubSourceControlProvider.test.ts | 6 - .../src/server/GitHubSourceControlProvider.ts | 78 +- .../src/server/driver.ts | 6 - .../src/server/gitHubAuthStatus.ts | 6 - .../src/server/gitHubChangeRequestTemplate.ts | 6 - .../server/gitHubConditionalChecks.test.ts | 2 +- .../src/server/gitHubConditionalChecks.ts | 8 - .../src/server/gitHubPullRequestJson.test.ts | 30 - .../src/server/gitHubPullRequestJson.ts | 172 +---- .../src/server/gitHubPullRequests.ts | 1 - .../server/gitHubRepositoryResolution.test.ts | 1 - .../src/server/gitHubRepositoryResolution.ts | 37 +- .../src/server/githubStackActions.test.ts | 1 - .../src/server/githubStackActions.ts | 2 - .../src/server/githubStackRebase.ts | 15 - .../src/client/definition.ts | 5 - .../src/server/GitLabPullRequestCli.test.ts | 38 - .../src/server/GitLabPullRequestCli.ts | 121 +-- .../server/GitLabPullRequestProvider.test.ts | 14 - .../src/server/GitLabPullRequestProvider.ts | 42 +- .../GitLabSourceControlProvider.test.ts | 1 - .../src/server/GitLabSourceControlProvider.ts | 1 - .../src/server/driver.ts | 5 - .../src/server/gitLabMergeRequestJson.test.ts | 21 - .../src/server/gitLabMergeRequestJson.ts | 135 +--- .../src/TestSourceControlHost.ts | 10 - packages/ssh/src/command.ts | 1 - packages/ssh/src/runnerProcess.test.ts | 3 - packages/ssh/src/tunnel.test.ts | 52 +- packages/ssh/src/tunnel.ts | 14 - packages/tailscale/src/tailscale.test.ts | 16 - packages/tailscale/src/tailscale.ts | 16 +- scripts/build-cli-archive.ts | 41 +- scripts/build-desktop-artifact.test.ts | 48 +- scripts/build-desktop-artifact.ts | 171 +---- scripts/build-npm-platform-packages.test.ts | 14 - scripts/build-npm-platform-packages.ts | 62 +- scripts/dev-runner.test.ts | 33 - scripts/dev-runner.ts | 80 +- scripts/export-android-icons.ts | 17 - scripts/install.test.ts | 1 - scripts/knip-schemas.ts | 3 - scripts/legend-list-initial-reveal.test.ts | 2 - scripts/lib/build-target-arch.test.ts | 7 +- scripts/lib/cli-executable-imports.ts | 6 +- scripts/lib/cli-external-packages.test.ts | 44 +- scripts/lib/cli-external-packages.ts | 53 +- scripts/lib/cursor-sdk-packaging.test.ts | 4 - scripts/lib/desktop-external-packages.ts | 16 +- scripts/lib/dev-share.test.ts | 17 - scripts/lib/dev-share.ts | 46 +- scripts/lib/icon-export.ts | 1 - scripts/lib/resolve-catalog.ts | 8 - scripts/mobile-native-client.ts | 1 - scripts/mobile-showcase-environment.ts | 12 +- scripts/mobile-showcase.config.ts | 20 - scripts/mobile-showcase.test.ts | 6 - scripts/mobile-showcase.ts | 30 +- scripts/notify-discord-release.test.ts | 3 - scripts/notify-discord-release.ts | 6 - scripts/resolve-nightly-release.ts | 4 - scripts/resolve-previous-release-tag.ts | 5 - scripts/setup-worktree.ts | 7 - scripts/sign-macos.ts | 1 - scripts/smoke-cli-archive.ts | 11 - vite.config.ts | 41 +- 3139 files changed, 4376 insertions(+), 49646 deletions(-) create mode 100644 oxlint-plugin-t3code/rules/no-low-value-comments.ts diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index d7a034f91106..06983eaa71fd 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -1,6 +1,5 @@ import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config"; -// Org members and collaborators can approve their own pull requests. const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? ""); @@ -17,11 +16,7 @@ export default defineConfig((ctx) => ({ docstrings: { mode: "off" }, override_requested_reviewers_only: isGated(ctx), }, - path_filters: [ - // Vendored read-only reference checkouts of upstream Effect and Alchemy - // (see scripts/lib/reference-repos.ts). Nothing imports from them. - "!.repos/**", - ], + path_filters: ["!.repos/**"], path_instructions: [ { path: "{apps,packages,infra}/**/*.ts", diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs index 377ac36a22a8..943bf116909d 100644 --- a/.github/scripts/check-nightly-release.cjs +++ b/.github/scripts/check-nightly-release.cjs @@ -40,7 +40,6 @@ async function assertReleaseSource({ github, context, releaseChannel }) { const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v"); -// Newest published nightly by publication time, or undefined when none exists. async function findLatestNightly({ github, context }) { const releases = await github.paginate(github.rest.repos.listReleases, { ...context.repo, @@ -51,7 +50,6 @@ async function findLatestNightly({ github, context }) { .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; } -// Runs after the workflow acquires the nightly concurrency lock. async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { const lastNightly = await findLatestNightly({ github, context }); @@ -81,9 +79,6 @@ async function shouldReleaseNightly({ github, context, core, now = Date.now() }) return true; } -// Stable releases build the commit the latest nightly shipped, so the stable -// build is one nightly users already ran. Returns the nightly tag, its commit, -// and the stable version that nightly was a preview of. async function resolveLatestNightlyCommit({ github, context, core }) { const lastNightly = await findLatestNightly({ github, context }); if (!lastNightly) { @@ -91,7 +86,6 @@ async function resolveLatestNightlyCommit({ github, context, core }) { } const tag = lastNightly.tag_name; - // repos.getCommit dereferences annotated tags, so this is the commit either way. const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag }); const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1]; if (!version) { diff --git a/.github/scripts/mirror-play-apk.cjs b/.github/scripts/mirror-play-apk.cjs index 0ee6e4664e9e..51991c346431 100644 --- a/.github/scripts/mirror-play-apk.cjs +++ b/.github/scripts/mirror-play-apk.cjs @@ -18,7 +18,6 @@ function requireEnv(name) { return value; } -// Service account key -> OAuth access token via a self-signed JWT grant. async function playAccessToken(key) { const now = Math.floor(Date.now() / 1000); const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); diff --git a/AGENTS.md b/AGENTS.md index f0202081de83..810d26e31341 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ - Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md). - `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md). - Inferred types over annotations. `any` is the enemy. -- Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. +- Comments say why: a constraint, an external quirk, or an invariant the code can't show. Don't restate names or signatures, narrate steps, or record history. `t3code/no-low-value-comments` catches the mechanical cases. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. diff --git a/apps/desktop/gnome-extension/captureFeedback.js b/apps/desktop/gnome-extension/captureFeedback.js index 1278101b57ca..fff63b896021 100644 --- a/apps/desktop/gnome-extension/captureFeedback.js +++ b/apps/desktop/gnome-extension/captureFeedback.js @@ -6,7 +6,6 @@ import St from "gi://St"; import * as Main from "resource:///org/gnome/shell/ui/main.js"; import { captureDestinationFrame, findCaptureDestination } from "./feedbackGeometry.js"; -/** A single, bounded, sender-owned flight. No live window clone or per-frame JS work. */ export class CaptureFeedback { begin(sender, pid, snapshot, options) { this.dispose(); @@ -54,7 +53,6 @@ export class CaptureFeedback { }); } } catch (error) { - // Optional decoration must not prevent attaching a successfully captured image. this._clearActors(); console.warn(`T3 capture effects unavailable: ${error.message}`); } diff --git a/apps/desktop/gnome-extension/captureService.js b/apps/desktop/gnome-extension/captureService.js index 3b436afd5e22..769f797bb4b5 100644 --- a/apps/desktop/gnome-extension/captureService.js +++ b/apps/desktop/gnome-extension/captureService.js @@ -1,4 +1,3 @@ -// This policy is independent of Shell so lifecycle and authorization can be tested without a desktop. export const CLIENT_NAMES = [ "com.t3tools.T3Code.SnapShot", "com.t3tools.T3Code.Development.SnapShot", diff --git a/apps/desktop/scripts/build-browser-secret.mjs b/apps/desktop/scripts/build-browser-secret.mjs index c65d16a87e8b..fc069fbbc155 100644 --- a/apps/desktop/scripts/build-browser-secret.mjs +++ b/apps/desktop/scripts/build-browser-secret.mjs @@ -31,9 +31,7 @@ if (hostPlatform === "linux") { NodeFS.statSync(source).mtimeMs, NodeFS.statSync(NodeURL.fileURLToPath(import.meta.url)).mtimeMs, ) && matchesArchitecture(output); - } catch { - /* The first build has no output yet. */ - } + } catch {} if (!current) { let flags; try { diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 22475fb26c16..6cdcb43c9cb6 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -225,7 +225,6 @@ function killChildTree(signal) { return; } - // Kill direct children as a final fallback in case normal shutdown leaves stragglers. NodeChildProcess.spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore", }); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index ec6324576b6b..780cf639fd18 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -1,5 +1,3 @@ -// This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" - import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeModule from "node:module"; @@ -402,11 +400,10 @@ function buildMacLauncher(electronBinaryPath) { ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - // Keep Electron's native executable inside the branded bundle. Launching the - // node_modules copy makes macOS associate the process (and Dock label) with - // Electron.app even though this bundle's Info.plist has the T3 Code name. - // Its conventional executable name also keeps Electron's default-app runtime - // in development mode instead of making app.isPackaged report true. + // Launching the node_modules copy makes macOS associate the process (and Dock label) with + // Electron.app even though this bundle's Info.plist has the T3 Code name. Its conventional + // executable name also keeps Electron's default-app runtime in development mode instead of + // making app.isPackaged report true. writeDevelopmentEnvironmentScript(); writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 84d08cd9b6b8..c2440f364e7b 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -145,7 +145,6 @@ describe("electron development launcher", () => { const development = resolveMacLauncherIconPaths("/runtime", true); const production = resolveMacLauncherIconPaths("/runtime", false); - // The source icons are real repo paths, joined for the host. assert.match(development.sourceIconPath, /assets[\\/]dev[\\/]blueprint-macos-1024\.png$/); assert.equal(development.generatedIconPath, "/runtime/icon-dev.icns"); assert.match(production.sourceIconPath, /assets[\\/]prod[\\/]black-macos-1024\.png$/); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 24dbf2232f00..68febbc6386f 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -149,8 +149,6 @@ const fatalStartupCause = (stage: string, cause: Cause.Cause) => export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( function* (): Effect.fn.Return { - // Stop every backend in the pool with a timeout to guarantee the quit - // path makes progress even if a backend hangs during teardown. const pool = yield* DesktopBackendPool.DesktopBackendPool; const instances = yield* pool.list; yield* Effect.forEach( @@ -171,8 +169,6 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; - // The renderer is served from the bundled client (or Vite in development) - // rather than through the local backend, so the window can open without one. const electronProtocol = yield* ElectronProtocol.ElectronProtocol; yield* electronProtocol.registerDesktopProtocol({ scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), @@ -243,11 +239,6 @@ const bootstrap = Effect.gen(function* () { } if (!(yield* Ref.get(state.quitting))) { - // The main window waits for the primary backend. In wsl-only mode that is - // the WSL backend, which can be slow to cold-boot β€” show a "Connecting to - // WSL" splash immediately so the app feels responsive instead of presenting - // no window until WSL is ready. (Dual mode opens fast off the Windows - // primary, so no splash there.) if (settings.wslOnly === true && settings.wslBackendEnabled === true) { yield* desktopWindow.showConnectingSplash; } @@ -257,10 +248,6 @@ const bootstrap = Effect.gen(function* () { Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), ); - // Bring up the WSL backend if the user previously enabled it. The - // primary is already starting; reconcile fires off the WSL register - // in parallel rather than blocking primary readiness on a possibly - // slow first wsl.exe spawn. yield* Effect.forkScoped(wslBackend.reconcile); } }).pipe(Effect.withSpan("desktop.bootstrap")); diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index dfe415644760..6e0c76842f24 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -159,7 +159,6 @@ describe("desktop app control server", () => { }), ); - // Two desktop apps can share one state dir, such as nightly and a preview build. it.effect("keeps a newer app's socket when an older app on the same state dir quits", () => Effect.gen(function* () { const platform = yield* HostProcess.Platform; diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 19f24460106e..688bd789572f 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -47,7 +47,6 @@ export class DesktopAppActivationStartError extends Schema.TaggedError Promise; readonly close: () => Promise; } @@ -75,7 +74,6 @@ function requestIdFromUnknown(value: unknown): string { return "invalid-request"; } -/** Makes sure the socket directory is safe to use. Returns true when it had to create it. */ async function prepareUnixDirectory(input: { readonly directory: string; readonly userId: number | undefined; @@ -106,16 +104,6 @@ function closeServer(server: NodeNet.Server): Promise { return new Promise((resolve) => server.close(() => resolve())); } -/** - * Serves `t3 app` requests on the local control address until `close`. - * - * Two desktop apps can share one state dir, for example nightly and a preview - * build. They share one socket path, so on Unix: - * - The newest app takes the path over. - * - `close` removes the socket file only while it is still this app's socket. - * - An app binds the path again when it is gone, for example after the app that - * took it over quits. - */ export async function startDesktopAppControlServer(input: { readonly address: string; readonly directory: string | null; @@ -201,8 +189,8 @@ export async function startDesktopAppControlServer(input: { // Closing a Unix socket server unlinks the path it was bound to, even when // another app's socket lives there now. Bind a staging path and move it onto // the address instead, so a later close only unlinks the staging path, which - // is already gone. `rename` takes the address over in one step. `link` claims - // it only while it is free, and fails with EEXIST otherwise. + // is already gone. + const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => { const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`); const server = await listen(staging); @@ -242,7 +230,6 @@ export async function startDesktopAppControlServer(input: { } const next = await bindUnix(input.directory, "claim-free").catch( (error: NodeJS.ErrnoException) => { - // Another app bound the address first. if (error.code === "EEXIST") return null; throw error; }, @@ -268,7 +255,6 @@ export async function startDesktopAppControlServer(input: { }); watcher.on("error", input.onReclaimError); } catch (error) { - // The socket still works without a watcher. It only cannot recover after removal. input.onReclaimError(error); } }; @@ -309,7 +295,7 @@ export class DesktopAppActivation extends Context.Service< const { logWarning } = makeComponentLogger("desktop-app-activation"); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment; const desktopWindow = yield* DesktopWindow.DesktopWindow; diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.ts b/apps/desktop/src/app/DesktopAppActivationBroker.ts index 221df9ca86dd..04f3d61717df 100644 --- a/apps/desktop/src/app/DesktopAppActivationBroker.ts +++ b/apps/desktop/src/app/DesktopAppActivationBroker.ts @@ -29,7 +29,6 @@ function failure( }; } -/** Holds CLI requests until the real desktop renderer is ready to handle them. */ export class DesktopAppActivationBroker { readonly #pending = new Map(); readonly #requestTimeoutMs: number; diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 10ab907965f2..eecea6ad9261 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -221,8 +221,6 @@ describe("DesktopAppIdentity", () => { assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Beta)"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); - // Packaged: the bundle's own icon stands, so a custom one the user - // attached survives. assert.deepEqual(calls.setDockIcon, []); }), { @@ -262,7 +260,6 @@ describe("DesktopAppIdentity", () => { const runtimeName = calls.setName[0]; assert.isDefined(runtimeName); assert.equal(runtimeName, `T3 Code ${stage}`); - // RFC 9110's token grammar, after Electron removes ASCII spaces. assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/); assert.equal(calls.setAboutPanelOptions[0]?.applicationName, `T3 Code (${stage})`); }), @@ -282,8 +279,6 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - // Electron shows a generic icon for an unpackaged run, which is the - // reason this call exists at all. assert.deepEqual(calls.setDockIcon, ["/icon.png"]); }), { diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index 9c81b8ab1c04..479753375863 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -119,7 +119,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( return yield* resolveResourcePath(`icon.${ext}`); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const context = yield* Effect.context< FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index b3aa2d14a0f7..f457a0364fc9 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -39,7 +39,6 @@ import * as DesktopWebLinks from "./DesktopWebLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; -/** Clerk forwards web links; tests that are not about links ignore them. */ const ignoreWebLinks = DesktopWebLinks.DesktopWebLinks.of({ receive: () => Effect.void, setRendererReady: () => Effect.void, @@ -144,8 +143,6 @@ describe("DesktopClerk", () => { events.push("createClerkBridge"); return { cleanup: vi.fn(), isPrimaryInstance: true }; }); - // runSync throws if the layer ever suspends, which would let Electron emit - // ready before the bridge exists. main.ts provides the same FileSystem. // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- The assertion IS that the layer builds synchronously; it.effect would mask a regression to async. Effect.runSync( Effect.scoped( @@ -417,13 +414,10 @@ it.effect("hands web and app links to the renderer and leaves other links alone" listeners.get("open-url")!(event, url); return event.preventDefault.mock.calls.length; }; - // macOS hands the default browser every web link. assert.strictEqual(open("https://example.com/page"), 1); assert.strictEqual(open("http://localhost:3000/"), 1); - // Prompt and thread links ride the same queue; the renderer routes them in the app. assert.strictEqual(open("t3code://new?prompt=hi"), 1); assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1); - // Anything else is not a web page; Electron keeps its own handling. assert.strictEqual(open("mailto:hello@example.com"), 0); assert.strictEqual(open("t3code://somewhere-else"), 0); yield* Effect.yieldNow; diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 10eb76d7d9a5..94167c2b3a18 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -100,7 +100,6 @@ export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const shell = yield* ElectronShell.ElectronShell; - // The SDK bridge acquires Electron's profile-scoped single-instance lock. // Must not yield: the bridge registers a scheme Electron rejects once ready. const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment); yield* electronApp.setPath("userData", userDataPath); @@ -189,8 +188,6 @@ export const make = Effect.gen(function* () { ); return true; }; - // Prompt links and thread links (`t3code://new?…`, `t3code://threads/…`) reach the - // renderer through the same queue as web links; it routes them in the app. const openAppLink = (value: string | undefined) => { if (!value || promptLinkAppPath(value) === null) return false; void runPromise(webLinks.receive(value)); @@ -198,7 +195,6 @@ export const make = Effect.gen(function* () { }; const args = yield* HostProcess.Arguments; args.some((value) => startProviderAuthHandoff(value) || openAppLink(value)); - // As the default browser, macOS hands T3 Code every web link through the same event. const openWebLink = (url: string) => { if (!DesktopWebLinks.isWebLink(url)) return false; void runPromise(webLinks.receive(url)); @@ -213,7 +209,6 @@ export const make = Effect.gen(function* () { ) event.preventDefault(); }); - // A browser opens HTML files too, which macOS hands over by path. yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { if (!DesktopWebLinks.isWebPageFile(path)) return; event.preventDefault(); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index b2305fe9840a..7b44fab01855 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -27,7 +27,6 @@ const environmentFor = ( appImagePath: Option.none(), } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); -/** The service for a packaged app; the launcher is written on Install. */ const commandIn = ( input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean }, spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"], @@ -48,7 +47,6 @@ const commandIn = ( : make; }); -/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */ const fakePowerShell = (initial: string) => { const registry = { path: initial, failReads: false, writes: 0 }; const spawner = ChildProcessSpawner.make((command) => @@ -104,7 +102,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const link = path.join(home, ".local", "bin", "t3"); expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); - // Install writes the launcher itself, even when no local backend ever did. const installed = yield* command.install; expect(installed.installedPath).toBe(link); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); @@ -112,7 +109,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect((yield* command.uninstall).installedPath).toBeNull(); expect(yield* fs.exists(link)).toBe(false); - // The launcher itself stays for setup commands. expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); }).pipe(Effect.scoped), ); @@ -126,7 +122,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const theirs = path.join(home, ".local", "bin", "t3"); yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); yield* fs.writeFileString(theirs, "npm's t3\n"); - // Even a broken link in the next folder is someone else's. yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true }); yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3")); @@ -148,7 +143,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); expect((yield* after.state).installedPath).toBe(link); - // Installing again points the link at this home's launcher. expect((yield* after.install).installedPath).toBe(link); expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); yield* after.uninstall; @@ -162,7 +156,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped(); - // A big executable that happens to contain the marker text is still not ours. const binary = path.join(home, "native-t3"); yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); @@ -187,7 +180,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const command = yield* commandIn({ home }); const theirs = path.join(shadow, "t3"); expect((yield* command.state).shadowedBy).toBe(theirs); - // A link behind it would never run, so nothing is created. const error = yield* Effect.flip(command.install); expect(error.message).toContain(theirs); expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index d79cab92f7cc..aebe8adb4a05 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -12,19 +12,11 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopCliShim from "./DesktopCliShim.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -// Settings β†’ Install `t3` command, like VS Code's "Install 'code' command". -// The app's launcher (see DesktopCliShim) lives in the T3 home and is off PATH -// by default. Installing links it into a folder on the user's PATH, or on -// Windows adds the launcher's folder to the user's PATH. Removing undoes only -// what installing did: a link that points at one of the app's launchers, or a -// PATH entry the app recorded adding. - export class DesktopCliCommandError extends Schema.TaggedError()( "DesktopCliCommandError", { message: Schema.String }, ) {} -/** User-writable folders that login shells commonly put on PATH, in preference order. */ const unixCandidates = (home: string, platform: NodeJS.Platform) => platform === "darwin" ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] @@ -33,7 +25,6 @@ const unixCandidates = (home: string, platform: NodeJS.Platform) => const pathEntries = (value: string | undefined, separator: string) => (value ?? "").split(separator).filter((entry) => entry.length > 0); -/** Windows paths compare without case or a trailing separator. */ const sameWindowsPath = (left: string, right: string) => left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); @@ -82,7 +73,6 @@ export const make = Effect.gen(function* () { const windows = environment.platform === "win32"; const launcher = DesktopCliShim.launcherPath(environment); const binDirectory = path.dirname(launcher); - /** Records that Install added `binDirectory` to the Windows PATH, so Remove takes out only that. */ const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); const fail = (message: string) => new DesktopCliCommandError({ message }); @@ -93,11 +83,6 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); - /** - * A link to one of the app's launchers, by the marker the launcher carries. - * This also finds links made under a previous T3 home, so Remove can clean - * them up and Install does not add a second. - */ const isOurLink = (link: string) => Effect.gen(function* () { yield* fs.readLink(link); @@ -137,7 +122,6 @@ export const make = Effect.gen(function* () { Effect.mapError(() => fail("Could not update your PATH.")), ); - /** The `t3` a new shell runs, by PATH order, or none. */ const firstOnPath = Effect.gen(function* () { for (const directory of pathEntries(process.env.PATH, ":")) { const candidate = path.join(directory, "t3"); @@ -146,7 +130,6 @@ export const make = Effect.gen(function* () { return Option.none(); }); - /** The `t3` a new shell runs when it is not this app's, on Unix. */ const foreignFirstOnPath = Effect.gen(function* () { if (windows) return Option.none(); const first = yield* firstOnPath; @@ -154,7 +137,6 @@ export const make = Effect.gen(function* () { return first; }); - /** Where this app's command is installed now, if anywhere. */ const installedAt = Effect.gen(function* () { if (windows) { if (!(yield* exists(ownedPathMarker))) return Option.none(); @@ -180,14 +162,11 @@ export const make = Effect.gen(function* () { if (Option.isNone(installed)) { return { supported: true, installedPath: null, onPath: false, ...shadow }; } - // On Windows only terminals opened after the change see it. On Unix the - // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it. const first = yield* firstOnPath; const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); return { supported: true, installedPath: installed.value, onPath, ...shadow }; }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); - /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */ const ensureLauncher = DesktopCliShim.install.pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(FileSystem.FileSystem, fs), @@ -216,12 +195,10 @@ export const make = Effect.gen(function* () { if (Option.isSome(existing)) { const target = yield* fs.readLink(existing.value).pipe(Effect.option); if (Option.getOrUndefined(target) === launcher) return yield* state; - // A link to a previous T3 home's launcher: point it at this one instead. yield* fs .remove(existing.value) .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); } - // A link behind another `t3` never runs, so installing one would only hide the problem. const shadowedBy = yield* foreignFirstOnPath; if (Option.isSome(shadowedBy)) { return yield* fail( @@ -230,7 +207,6 @@ export const make = Effect.gen(function* () { } const onPath = pathEntries(process.env.PATH, ":"); const candidates = unixCandidates(environment.homeDirectory, environment.platform); - // Prefer a folder already on PATH that the user can write to without admin rights. for (const directory of [ ...candidates.filter((candidate) => onPath.includes(candidate)), ...candidates.filter((candidate) => !onPath.includes(candidate)), @@ -243,7 +219,6 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); if (!created) continue; - // symlink fails if anything, even a broken link, is already there. const linked = yield* fs.symlink(launcher, link).pipe( Effect.as(true), Effect.orElseSucceed(() => false), diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index c5a1bb109e4d..92f42dd2b247 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -26,7 +26,6 @@ const writeExecutable = (path: string, content: string) => { NodeFS.writeFileSync(path, content, { mode: 0o755 }); }; -/** Writes the launcher for `target` at `/bin/t3`, with `/home` as its T3 home. */ const writeShim = (root: string, target: CliShimTarget) => { const shim = NodePath.join(root, "bin", "t3"); writeExecutable(shim, renderCliShim({ target, shimPath: shim, t3Home: `${root}/home` })); @@ -39,16 +38,9 @@ const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = env: { PATH: process.env.PATH, ...env }, }); -/** An app executable that reports its environment and arguments. */ const REPORTER = '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; -/** - * A stand-in AppImage whose image holds the reporter. `--appimage-mount` - * prints the mount and stays up until killed, recording that it was; with - * `canMount` false it fails like a host without FUSE. `--appimage-extract` - * unpacks into ./squashfs-root. - */ const fakeAppImage = (canMount = true) => { const root = tempRoot(); const image = NodePath.join(root, "image"); @@ -84,7 +76,6 @@ describe("renderCliShim", () => { "a b", "", ]); - // The trap kills the mount helper as the launcher exits. NodeChildProcess.spawnSync("sh", [ "-c", `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`, @@ -94,7 +85,6 @@ describe("renderCliShim", () => { it("extracts the AppImage where it cannot be mounted", () => { const { shim } = fakeAppImage(false); - // Extract-and-run is ignored too, so the runtime never starts the app. const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); expect(result.status).toBe(3); expect(result.stdout).toMatch( @@ -113,7 +103,6 @@ describe("renderCliShim", () => { // sudo clears the environment, so the launcher supplies its own. expect(run(shim, []).stdout).toContain(`home=${root}/home`); expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); - // Run by a relative path, it still names itself absolutely. const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); expect(relative.stdout).toContain(`cli=${shim}`); }); @@ -160,7 +149,6 @@ describe("renderCliShim", () => { expect(lines).toContain( 'if exist "C:\\Apps\\R&whoami&X 100%%\\!CHANNEL!\\T3 Code.exe" goto run', ); - // Paths only ever appear quoted or inside `set "..."`, never bare where `&` would split them. const bare = lines.filter( (line) => line.includes("whoami") && !/"[^"]*whoami[^"]*"/.test(line), ); @@ -174,7 +162,6 @@ describe("renderCliShim", () => { shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd", t3Home: "C:\\Users\\me\\.t3", }); - // A Ctrl-C that ends the batch would leave the console switched. expect(ascii).not.toContain("chcp"); expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%"); }); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 5cd942f66ced..c90e55a88d9f 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -6,17 +6,12 @@ import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// A desktop install puts no `t3` on PATH, so commands the server asks a person -// to run (`sudo t3 browser setup`) had nothing to call. The app keeps a small -// launcher for its bundled CLI in the T3 home, which is never on PATH and so -// never shadows another `t3`, and the server names it by absolute path in those -// commands through T3CODE_CLI_PATH. An AppImage mounts somewhere new each run, -// so its launcher mounts the AppImage itself instead of pointing into it. +// An AppImage mounts somewhere new each run, so its launcher mounts the +// AppImage itself instead of pointing into it. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; -/** Server entry inside the app, relative to its server root (an asar archive when packaged). */ const SERVER_ENTRY = "apps/server/dist/bin.mjs"; const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; @@ -32,11 +27,10 @@ export type CliShimTarget = | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; /** - * The launcher script. Electron runs the server as plain Node with - * `ELECTRON_RUN_AS_NODE`, which reads the entry from inside the asar archive. - * The launcher's own path and the app's T3 home are written in, so the command - * the server shows is absolute and `sudo`, which clears the environment, still - * runs against this install's home. + * Electron runs the server as plain Node with `ELECTRON_RUN_AS_NODE`, which + * reads the entry from inside the asar archive. The launcher's own path and the + * app's T3 home are written in, so the command the server shows is absolute and + * `sudo`, which clears the environment, still runs against this install's home. */ export const renderCliShim = (input: { readonly target: CliShimTarget; @@ -54,9 +48,9 @@ export const renderCliShim = (input: { `rem ${MARKER}`, // Delayed expansion would rewrite `!name!` in paths and arguments. "setlocal EnableExtensions DisableDelayedExpansion", - // The file is UTF-8. Only a non-ASCII path needs the console switched to - // UTF-8 to read it; a Ctrl-C that ends the batch skips the restore, so the - // switch is left out for the common all-ASCII install. + // Only a non-ASCII path needs the console switched to UTF-8 to read it; a + // Ctrl-C that ends the batch skips the restore, so the switch is left out + // for the common all-ASCII install. ...(utf8 ? [ `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, @@ -125,7 +119,6 @@ export const renderCliShim = (input: { ].join("\n"); }; -/** Where the packaged app keeps its launcher: `/bin/t3`, `t3.cmd` on Windows. */ export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) => environment.path.join( environment.baseDir, @@ -133,11 +126,6 @@ export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment[ environment.platform === "win32" ? "t3.cmd" : "t3", ); -/** - * Writes the packaged app's launcher to `/bin` and returns its path - * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout - * and get none. - */ export const install = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; if (!environment.isPackaged) return Option.none(); @@ -154,7 +142,6 @@ export const install = Effect.gen(function* () { appImage, executableName: path.basename(process.execPath), }), - // macOS and .deb installs live at a fixed path, so the launcher runs the app directly. onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }), }); const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir }); @@ -162,13 +149,11 @@ export const install = Effect.gen(function* () { return yield* Effect.gen(function* () { const existing = yield* fs.readFileString(shimPath).pipe(Effect.option); if (Option.isSome(existing) && !existing.value.includes(MARKER)) { - // Someone else's file; leave it, and let commands fall back to plain `t3`. yield* logWarning("leaving a t3 launcher the app did not write", { shimPath }); return Option.none(); } if (Option.getOrUndefined(existing) !== content) { yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true }); - // Written beside the launcher and renamed over it, so a running `t3` never reads half a file. const staging = `${shimPath}.${process.pid}.tmp`; yield* fs.writeFileString(staging, content, { mode: 0o755 }); yield* fs.rename(staging, shimPath); @@ -176,7 +161,6 @@ export const install = Effect.gen(function* () { } return Option.some(shimPath); }).pipe( - // Best-effort: nothing here may block the backend's start; commands then fall back to plain `t3`. Effect.catchCause((cause) => logWarning("could not install t3 launcher", { shimPath, cause: Cause.pretty(cause) }).pipe( Effect.as(Option.none()), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index d20bdf95dd31..a9020c36d1c8 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -376,7 +376,7 @@ const migrateSavedEnvironmentRecords = Effect.fn( }; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index b311a9e25ed5..01d1aa1b115e 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -131,7 +131,6 @@ describe("DesktopEarlyElectronStartup", () => { "0.50.0": "c4a4c341568944bd4fb9cd503558b2de602c0213", "0.50.1": "4e242d086e20b32951fdc0ebcbfb4d41b5be8dcc", }; - // Shape of `hyprctl version -j` from a release tarball, trimmed to the fields we read. const hyprctlVersion = (version: string, overrides: Record = {}) => () => diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index c4adf8f3c75e..ad2947013771 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -83,7 +83,6 @@ export function resolveEarlyLinuxPasswordStorePreference( const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1"; -// Chromium feature entries look like `*Name => (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []); @@ -129,14 +128,6 @@ const hasBrokenColorManager = (readHyprctlVersionJson: () => string): boolean => } }; -/** - * Returns the merged `--disable-features` value to apply before Electron is ready, - * or null to leave the command line alone. `ozonePlatform` is the resolved - * `--ozone-platform` value; Electron fills it in before app code runs. - * - * Color management stays on unless `hyprctl version -j` identifies a stock Hyprland - * release known to kill the native Wayland connection right after the first window opens. - */ export function resolveLinuxDisableFeaturesSwitch(input: { readonly env: NodeJS.ProcessEnv; readonly ozonePlatform: string | null; diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index f697e0456db9..ecf08255feb3 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -54,19 +54,11 @@ export class DesktopEnvironment extends Context.Service< readonly browserArtifactsDir: string; readonly rootDir: string; readonly appRoot: string; - // Root of the tree containing apps/server/dist and node_modules for the - // backend. Equals appRoot everywhere except packaged Windows, where the - // server tree ships as the resources/server.asar sidecar (see - // scripts/build-desktop-artifact.ts) that the asar-aware - // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend - // extracts on demand (see DesktopWslServerTree). readonly serverRoot: string; readonly backendEntryPath: string; - // Built web client the packaged renderer is served from over t3code://app. readonly clientAssetsDir: string; readonly backendCwd: string; readonly preloadPath: string; - // Preload that turns on the V8 compile cache for the local backend. readonly compileCachePath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts index b29cfb3b7c63..9deba2389922 100644 --- a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -11,15 +11,8 @@ import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; /** - * Carries the renderer's localStorage (prompt stash, unsent drafts, layout, - * theme) over from the V1 desktop profile, which V2 replaced with its own - * `t3code-v2` profile. The V1 profile is only read, never opened by Chromium, - * so this works while V1 is still running. - * - * `load` runs before the window opens; the preload takes the items once, - * merges them into the new profile, and calls `complete`, which writes a - * marker so later launches skip the read. A failed read or merge, or a window - * that never completes, leaves no marker and retries next launch. + * The V1 profile is only read, never opened by Chromium, so this works while V1 + * is still running. */ export class DesktopLegacyLocalStorage extends Context.Service< DesktopLegacyLocalStorage, @@ -31,7 +24,6 @@ export class DesktopLegacyLocalStorage extends Context.Service< >()("@t3tools/desktop/app/DesktopLegacyLocalStorage") {} const MARKER_FILE_NAME = "v1-local-storage-imported"; -// V1 used "T3 Code (Alpha)" when that folder existed and "t3code" otherwise. const V1_PROFILE_NAMES = ["T3 Code (Alpha)", "t3code"]; const make = Effect.gen(function* () { @@ -57,7 +49,6 @@ const make = Effect.gen(function* () { return newest; }).pipe(Effect.option); - /** The V1 profile whose Local Storage was written most recently, if any. */ const findV1LocalStorage = Effect.gen(function* () { let newest: { readonly directory: string; readonly mtime: number } | null = null; for (const name of V1_PROFILE_NAMES) { @@ -82,7 +73,6 @@ const make = Effect.gen(function* () { }); const load = Effect.fn("desktop.legacyLocalStorage.load")(function* (userDataPath: string) { - // Development already shares its profile between versions. if (environment.isDevelopment) return; const marker = path.join(userDataPath, MARKER_FILE_NAME); if (yield* fs.exists(marker).pipe(Effect.orElseSucceed(() => true))) return; @@ -108,7 +98,6 @@ const make = Effect.gen(function* () { return DesktopLegacyLocalStorage.of({ load, - // Taking clears the items, so a reload before `complete` cannot merge twice. take: Ref.getAndSet(items, Option.none()), complete: writeMarker, }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index cbabe14dd6fc..492f093031f8 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -168,9 +168,6 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "[Desktop Entry]"); assert.include(entry, "Name=T3 Code (Nightly)"); - // Exec composes both escaping layers: a literal backslash becomes four - // backslashes in the file, a quote three characters, a dollar sign two - // backslashes plus the sign. assert.include( entry, 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index e67cdcefe9fa..a190441813d6 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -17,10 +17,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // appimagekit_-….desktop) and its filename is not under our control. // Electron's app.setAsDefaultProtocolClient resolves the desktop id from // setDesktopName, which cannot match those files β€” so the browser keeps -// prompting "Choose an application" for every OAuth callback. Instead, write -// our own handler entry pointing at the current AppImage, refresh the desktop -// MIME cache so desktop environments recognize that entry as a handler, and -// use xdg-mime to record it as the scheme default in mimeapps.list. +// prompting "Choose an application" for every OAuth callback. const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError()( @@ -67,10 +64,7 @@ const escapeDesktopEntryString = (value: string): string => // Exec values are unescaped twice by implementations: first the general // string-value rules, then the Exec quoting rules β€” so writing composes the -// layers in reverse. The argument is double-quoted with reserved characters -// backslash-escaped and literal percent signs doubled (field codes), and the -// general string escaping is applied on top: a literal backslash ends up as -// four backslashes in the file, a quote as \\", a dollar sign as \\$. +// layers in reverse. export function escapeDesktopEntryExecArgument(value: string): string { const quoted = value .replaceAll("\\", () => "\\\\") @@ -255,8 +249,6 @@ export const make = Effect.gen(function* () { yield* setDefaultHandler; yield* logInfo("registered URL scheme handler", { scheme }); }).pipe( - // Registration is best-effort: a missing xdg-mime or read-only home must - // never block startup β€” the OS chooser remains as fallback. Effect.catch((error) => logWarning("URL scheme handler registration failed", { scheme, diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 378bbb9e406b..ef462f19a29b 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -76,7 +76,6 @@ interface ExportedRequest { readonly body: string; } -/** Answers every export with a 200 and keeps what was posted for assertions. */ const layerCollector = (requests: Array) => Layer.succeed( HttpClient.HttpClient, @@ -415,8 +414,6 @@ describe("DesktopObservability", () => { assert.include(request?.body ?? "", "service.runtime"); assert.strictEqual(request?.headers["x-scope"], "desktop"); - // The log record is the export now, so the same message must not also - // ride along as an event on the span. const record = (yield* fileSystem.readFileString(tracePath)) .trim() .split("\n") @@ -601,8 +598,6 @@ describe("DesktopObservability", () => { yield* writeObservabilitySettings(layerEnvironment, { otlpTracesUrl: "https://settings.example.com/v1/traces", otlpLogsUrl: "https://settings.example.com/v1/logs", - // The main process records no metrics yet, so this endpoint must - // not produce a request. otlpMetricsUrl: "https://settings.example.com/v1/metrics", }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 89ed63002059..299db7019dd7 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -54,15 +54,6 @@ export interface DesktopBackendOutputLogShape { readonly discardSession: Effect.Effect; } -// Factory for per-instance backend output logs. `forInstance(id)` returns -// a writer that targets a distinct rotating log file β€” the primary -// instance keeps `server-child.log` so the historical path stays stable -// for ops; other instances get `server-child-.log`. -// -// Writers are cached per id within a single factory instance so repeated -// `forInstance` calls (e.g. during a backend restart that re-resolves -// services) reuse the same rotating writer rather than racing each other -// on the same file. export class DesktopBackendOutputLogFactory extends Context.Service< DesktopBackendOutputLogFactory, { @@ -351,12 +342,6 @@ const readPersistedObservabilitySettings: Effect.Effect< : parsePersistedServerObservabilitySettings(raw.value); }); -/** - * Resolved as the server resolves them, with persisted Settings as the - * fallback. Settings is read once for every signal, so the main process - * cannot resolve traces against one revision of the file and logs against - * another. - */ const resolveOtlpEndpoints = Effect.gen(function* () { const otel = yield* OtelEnvironment.load; if (otel.disabled) { @@ -450,11 +435,8 @@ const backendLogFilePathForInstance = ( return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); }; -// Just the IO sink. Cacheable by resolved file path so two ids that -// sanitize to the same filename share a single RotatingLogFileWriter -// (no race on currentSize tracking). Splitting the sink off from the -// per-call shape lets the shape annotate writes with the *caller's* -// id rather than whatever id created the cached writer first. +// Cacheable by resolved file path so two ids that sanitize to the same filename +// share a single RotatingLogFileWriter (no race on currentSize tracking). const makeBackendOutputSinkForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, @@ -567,14 +549,6 @@ const layerBackendOutputLogFactory = Layer.effect( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const factoryScope = yield* Scope.Scope; - // Per-file-path cache of the IO sink only. The per-call shape - // wraps the sink with the caller's instance id so a cache hit on - // a path collision (e.g. "wsl:default" and "wsl_default" both - // resolve to server-child-wsl_default.log) doesn't attribute the - // second caller's writes to the first caller's id. Each sink pins - // itself to the factory's scope so all log resources tear down - // together at app exit. Mutex serializes concurrent first-time - // lookups for the same file path. const cacheRef = yield* SynchronizedRef.make< ReadonlyMap> >(new Map()); @@ -617,17 +591,10 @@ const layerBackendOutputLogFactory = Layer.effect( }), ); -/** - * Logs and traces for the main process, assembled together because they share - * one read of the environment and Settings, and because a process gets exactly - * one logger set. - */ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; - // `host.name` keeps machines apart on a shared collector; one set in - // `OTEL_RESOURCE_ATTRIBUTES` wins, since explicit attributes override it. const resource = { serviceName: "t3code-desktop", serviceVersion: environment.appVersion, @@ -643,7 +610,6 @@ const layerTelemetry = Layer.unwrap( // every logger the main process wants has to be named in this one call. // Splitting the OTLP logger back out into a layer of its own silently // drops either it or the console output. - // // Swapping `Logger.tracerLogger` out for the OTLP logger matches the // server: both reach a collector, but the tracer logger covers only // messages logged inside a recorded span and files them under traces, @@ -707,23 +673,6 @@ const layerTelemetry = Layer.unwrap( }), ).pipe(Layer.provide(OtlpExporter.layerFlusher)); - // Metrics stay off until the main process records one. `OtlpMetrics` - // exports on every interval even when the registry is empty, so wiring - // it up today would post an empty payload every ten seconds to any - // collector configured for the backend. Restore this when a desktop - // metric exists, and add it to the `Layer.mergeAll` below. - // - // const metricsLayer = - // endpoints.metrics === undefined - // ? Layer.empty - // : OtlpMetrics.layer({ - // url: endpoints.metrics.url, - // exportInterval: `${endpoints.metrics.export.exportIntervalMs} millis`, - // headers: endpoints.metrics.export.headers, - // resource, - // }).pipe(Layer.provide(SharedObservability.layerOtlpSerialization(endpoints.metrics.export.protocol))); - - // Logged once the loggers above are installed, so the warnings use them. const layerOtelWarnings = Layer.effectDiscard( Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)), ); diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 8ce58f7ebbb3..5af13f94413f 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -33,14 +33,9 @@ const syncFs = (method: string, path: string, run: () => A) => }); /** - * A synchronous FileSystem for startup work that runs before Electron's ready. - * Electron emits ready as soon as startup yields to the event loop. Only the - * operations userData resolution needs are implemented. - * * @public Service construction is part of the canonical Effect module API. */ export const make = FileSystem.makeNoop({ - // Like Effect's exists: only a missing path is false, other access errors fail. exists: (path) => syncFs("exists", path, () => { NodeFS.accessSync(path); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index b021ae02e826..a90246c73b3b 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -89,9 +89,7 @@ export const make = Effect.gen(function* () { ), iconPath, ); - } catch { - // Icon installation is optional; registration retries after readiness. - } + } catch {} } NodeFS.writeFileSync( NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), @@ -106,9 +104,7 @@ export const make = Effect.gen(function* () { }), "utf8", ); - } catch { - // The URL handler retries with the full environment and logs failures. - } + } catch {} // Chromium caches its portal registration during startup. Set the identity // before any asynchronous work can initialize it with Electron's default. Electron.app.setDesktopName(linux.linuxDesktopEntryName); @@ -122,7 +118,6 @@ export const make = Effect.gen(function* () { ozonePlatform: readCommandLineSwitchValue(Electron.app.commandLine, "ozone-platform"), enableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "enable-features"), disableFeatures: readCommandLineSwitchValue(Electron.app.commandLine, "disable-features"), - // Runs only on Hyprland Wayland sessions without an explicit feature override. readHyprctlVersionJson: () => NodeChildProcess.execFileSync("hyprctl", ["version", "-j"], { encoding: "utf8", diff --git a/apps/desktop/src/app/DesktopUserData.ts b/apps/desktop/src/app/DesktopUserData.ts index 3306b244dc72..99a082b438b0 100644 --- a/apps/desktop/src/app/DesktopUserData.ts +++ b/apps/desktop/src/app/DesktopUserData.ts @@ -31,7 +31,6 @@ export class DesktopUserDataInitializationError extends Schema.TaggedError { }, }); const window = { webContents } as unknown as Electron.BrowserWindow; - /** The web app starts loading a new page, as a reload does. */ const reload = () => webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); const layer = DesktopWebLinks.layer.pipe( @@ -57,7 +56,6 @@ describe("DesktopWebLinks", () => { const { sent, revealed, layer } = makeWindow(); return Effect.gen(function* () { const webLinks = yield* DesktopWebLinks.DesktopWebLinks; - // The link that launched the app arrives before the web app has loaded. yield* webLinks.receive("https://example.com/first"); yield* webLinks.receive("https://example.com/second"); assert.deepStrictEqual(sent, []); @@ -69,7 +67,6 @@ describe("DesktopWebLinks", () => { yield* webLinks.receive("https://example.com/third"); assert.deepStrictEqual(sent.at(-1), "https://example.com/third"); - // A reloading web app stops listening; links wait for it again. yield* webLinks.setRendererReady(false); yield* webLinks.receive("https://example.com/fourth"); assert.strictEqual(sent.length, 3); @@ -81,7 +78,6 @@ describe("DesktopWebLinks", () => { return Effect.gen(function* () { const webLinks = yield* DesktopWebLinks.DesktopWebLinks; yield* webLinks.setRendererReady(true); - // The old page never says it stopped; the reload itself ends its listening. reload(); yield* webLinks.receive("https://example.com/during-reload"); assert.deepStrictEqual(sent, []); diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index 2031ada45a12..f448f184b893 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -6,29 +6,18 @@ import * as Option from "effect/Option"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; -/** An http(s) link the operating system handed to T3 Code as the default browser. */ export const isWebLink = (value: string) => { if (!URL.canParse(value)) return false; const { protocol } = new URL(value); return protocol === "http:" || protocol === "https:"; }; -/** An HTML file the operating system asked T3 Code to open, as browsers are asked to. */ export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path); -/** - * Web links and HTML files macOS opens with T3 Code once it is the default - * browser, plus `t3code://` prompt and thread links on every platform. Files - * arrive as file:// URLs. They can - * arrive before the app is ready (the link that launched it) or while the web - * app reloads, so they wait until the renderer says it is listening. - */ export class DesktopWebLinks extends Context.Service< DesktopWebLinks, { - /** Queues a link and delivers it as soon as the renderer is listening. */ readonly receive: (url: string) => Effect.Effect; - /** The renderer started or stopped listening; listening flushes queued links. */ readonly setRendererReady: (ready: boolean) => Effect.Effect; } >()("@t3tools/desktop/app/DesktopWebLinks") {} @@ -36,7 +25,6 @@ export class DesktopWebLinks extends Context.Service< const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const pending: Array = []; - /** The page that said it is listening; a reload or crash replaces it, and it must say so again. */ let listening: Electron.WebContents | null = null; let detachListening: (() => void) | null = null; @@ -65,7 +53,6 @@ const make = Effect.gen(function* () { if (listening === webContents) return; stopListening(); listening = webContents; - // Links that arrive while a new page loads wait for it to listen again. const onNavigation = ( event: Electron.Event, ) => { diff --git a/apps/desktop/src/app/chromiumLocalStorage.ts b/apps/desktop/src/app/chromiumLocalStorage.ts index d5847b814db6..98de2e91eb06 100644 --- a/apps/desktop/src/app/chromiumLocalStorage.ts +++ b/apps/desktop/src/app/chromiumLocalStorage.ts @@ -5,8 +5,6 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; /** - * Read-only decoder for a Chromium profile's `Local Storage/leveldb`. - * * It reads the database files directly and never takes LevelDB's lock, so it * works while another app version has the profile open. Live files come from * the MANIFEST that CURRENT names, and the newest sequence number wins, which @@ -97,7 +95,6 @@ function crc32c(bytes: Uint8Array): number { return (crc ^ 0xffffffff) >>> 0; } -/** LevelDB stores CRCs masked so a CRC of data containing CRCs stays well distributed. */ const unmaskCrc = (masked: number) => { const rotated = (masked - 0xa282ead8) >>> 0; return ((rotated >>> 17) | (rotated << 15)) >>> 0; @@ -149,10 +146,9 @@ function decompressSnappy(input: Uint8Array): Uint8Array { } /** - * Splits a LevelDB log (write-ahead log or MANIFEST) into its records. Like - * LevelDB's own reader, a corrupt record or zero padding skips the rest of its - * block, since a reused log keeps appending after a torn write, and a record - * cut off by the end of the file ends the log. + * Like LevelDB's own reader, a corrupt record or zero padding skips the rest of + * its block, since a reused log keeps appending after a torn write, and a + * record cut off by the end of the file ends the log. */ function readLogRecords(bytes: Uint8Array): Uint8Array[] { const records: Uint8Array[] = []; @@ -212,7 +208,6 @@ interface LiveFiles { readonly previousLogNumber: number; } -/** Replays MANIFEST version edits to find the live table files and log. */ function readManifest(bytes: Uint8Array): LiveFiles { const tables = new Set(); let logNumber = 0; @@ -225,21 +220,21 @@ function readManifest(bytes: Uint8Array): LiveFiles { const tag = reader.varint(); required.delete(tag); switch (tag) { - case 1: // comparator + case 1: reader.lengthPrefixed(); break; case 2: logNumber = reader.varint(); break; - case 3: // next file number - case 4: // last sequence + case 3: + case 4: reader.varint(); break; - case 5: // compaction pointer + case 5: reader.varint(); reader.lengthPrefixed(); break; - case 6: // deleted file + case 6: reader.varint(); tables.delete(reader.varint()); break; @@ -265,7 +260,6 @@ function readManifest(bytes: Uint8Array): LiveFiles { interface VersionedEntry { readonly sequence: number; - /** `null` marks a deletion. */ readonly value: Uint8Array | null; } @@ -279,10 +273,8 @@ function record(entries: EntryMap, key: Uint8Array, entry: VersionedEntry) { if (!existing || existing.entry.sequence < entry.sequence) entries.set(id, { key, entry }); } -/** Applies one write-ahead log record (a WriteBatch). */ function readWriteBatch(entries: EntryMap, batch: Uint8Array) { if (batch.length < 12) fail("write batch too short"); - // The sequence is a little-endian uint64; real values stay far below 2^53. const sequence = readUint32(batch, 0) + readUint32(batch, 4) * 0x100000000; const count = readUint32(batch, 8); const reader = new Reader(batch, 12); @@ -332,7 +324,6 @@ function* blockEntries(block: Uint8Array): Generator<[Uint8Array, Uint8Array]> { } } -/** Applies every entry in one sorted table (`.ldb`/`.sst`) file. */ function readTable(entries: EntryMap, table: Uint8Array) { if (table.length < TABLE_FOOTER_SIZE) fail("table too short"); const footer = new Reader(table, table.length - TABLE_FOOTER_SIZE); @@ -376,10 +367,6 @@ export class ChromiumLocalStorageReadError extends Schema.TaggedError readManifest(manifest)).pipe(wrap); const logs = (yield* fs.readDirectory(directory).pipe(wrap)) .map((name) => /^(\d+)\.log$/.exec(name)) - // The logs LevelDB's own recovery replays. .filter((match) => { const number = Number(match?.[1]); return number >= live.logNumber || number === live.previousLogNumber; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 717e4a53bddc..c0ef57ae993a 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -272,8 +272,6 @@ describe("DesktopBackendConfiguration", () => { second.bootstrap.desktopBootstrapSecret, first.bootstrap.desktopBootstrapSecret, ); - // The launch token is the secret's token for the current window, and - // the renderer is handed the same one. assert.equal( first.bootstrap.desktopBootstrapToken, currentDesktopBootstrapToken( @@ -344,7 +342,6 @@ describe("DesktopBackendConfiguration", () => { const wsl = yield* configuration.resolveWsl({ port: 5000, distro: null }); assert.equal(primary.bootstrap.shellEnvironmentPrepared, true); - // A WSL server runs on its own Linux user environment and must hydrate it. assert.isUndefined(wsl.bootstrap.shellEnvironmentPrepared); }), ), @@ -465,8 +462,6 @@ describe("DesktopBackendConfiguration", () => { observedProbeRoots.push(root); return { ok: true, resolvedPath }; }, - // The staged runtime carries its own Node and node-pty, so it must - // not require the mounted server tree's native dependency check. ensureNodePty: () => { throw new Error("the staged runtime must not probe for node-pty"); }, @@ -765,11 +760,6 @@ describe("DesktopBackendConfiguration", () => { Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - // Resolve both before any token is cached, concurrently, so the - // generate step (a yield point) can interleave. The atomic - // get-or-create must still hand both the same token; a non-atomic - // Ref would let each generate its own and break the shared-token - // invariant. const [primary, wsl] = yield* Effect.all( [configuration.resolvePrimary, configuration.resolveWsl({ port: 5000, distro: null })], { concurrency: "unbounded" }, @@ -833,9 +823,6 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); - // No settings.json exists here: the endpoints come from the desktop - // environment, and the bootstrap carries them for a WSL child that - // lacks the variables. assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); @@ -893,8 +880,6 @@ describe("DesktopBackendConfiguration", () => { const config = yield* configuration.resolvePrimary; assert.equal(config.bootstrap.otlpLogsUrl, "http://env:4318/v1/logs"); - // Only the logs endpoint is set in env, so the other two still come - // from the settings file rather than being dropped together. assert.equal(config.bootstrap.otlpTracesUrl, "http://persisted:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://persisted:4318/v1/metrics"); }).pipe( @@ -985,7 +970,6 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolvePrimary; assert.equal(config.captureOutput, true); - // Dev never shares the prod compile cache. assert.notInclude(config.args, "--require"); }).pipe( Effect.provide( @@ -1069,7 +1053,6 @@ describe("DesktopBackendConfiguration", () => { T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces", }; const previousWslEnv = process.env.WSLENV; - // A developer's own OTLP variables would be forwarded too. const ambientOtel = Object.entries(process.env).filter( ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"), ); @@ -1090,7 +1073,6 @@ describe("DesktopBackendConfiguration", () => { config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, "authorization=Bearer%20token", ); - // Without a flag, WSL passes the values through untranslated. const wslEnv = (config.env.WSLENV ?? "").split(":"); assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT"); assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS"); @@ -1133,7 +1115,6 @@ describe("DesktopBackendConfiguration", () => { const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; - // A developer's own OTEL_* variables would be forwarded too. const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_")); try { for (const [name] of ambientOtel) delete process.env[name]; @@ -1149,23 +1130,14 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.executablePath, "wsl.exe"); assert.equal(config.bootstrap.port, 5050); - // Binds to 0.0.0.0 inside WSL so the backend is reachable via - // both wslhost-forwarded localhost and the distro's eth0 IP. assert.equal(config.bootstrap.host, "0.0.0.0"); assert.equal(config.bootstrap.tailscaleServeEnabled, false); assert.notProperty(config.bootstrap, "desktopTelemetryFd"); assert.notProperty(config.bootstrap, "resourceMonitorPath"); - // httpBaseUrl uses the resolved distro IP from the test stub, - // not localhost β€” the renderer reaches the backend directly to - // avoid relying on wslhost forwarding. assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); - // The existing WSLENV is preserved byte-for-byte (note the empty - // "::" segment survives β€” WSL ignores it, so we don't normalize - // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is - // already declared, so it isn't forwarded twice. assert.equal( config.env.WSLENV, "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", @@ -1212,9 +1184,6 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolvePrimary; - // wsl-only is persisted but WSL is unavailable, so the primary must - // not spawn wsl.exe (which would loop on preflight failures while the - // Connections backend control is hidden). Resolve the Windows primary. assert.equal(config.executablePath, process.execPath); assert.equal(config.bootstrap.t3Home, environment.baseDir); assert.isTrue(Option.isNone(config.preflightFailure)); @@ -1570,9 +1539,6 @@ describe("DesktopBackendConfiguration", () => { yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - // Mirrors the resolvePrimary fall-back: the label must follow the - // backend that actually resolves, not the persisted preference, so the - // env switcher can't show "WSL" for a Windows backend. const label = yield* configuration.resolvePrimaryLabel; assert.equal(label, "Windows"); }).pipe( @@ -1597,13 +1563,6 @@ describe("DesktopBackendConfiguration", () => { ); it("resolvePrimaryLabel is runSync-safe against the real WSL availability probe", async () => { - // getLocalEnvironmentBootstraps is a sync IPC method: it resolves the - // primary instance's lazy label through Effect.runSync. The label chains - // to wslEnvironment.isAvailable, whose real layer probes the filesystem. - // That probe must run once at layer build and expose a resolved value, not - // a live async effect β€” otherwise runSync throws in the handler. Build the - // real WSL layer (not the sync test stub) and resolve the label with a - // top-level runSync, exactly as the handler does. // oxlint-disable-next-line t3code/no-manual-effect-runtime-in-tests -- This test intentionally replicates the sync IPC handler's runSync path to catch a regression to async-only resolution; it.effect would mask it. const runtime = ManagedRuntime.make( layerBackendConfiguration().pipe( @@ -1611,8 +1570,6 @@ describe("DesktopBackendConfiguration", () => { Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layer), - // isAvailable on win32 only touches the filesystem, never the spawner, - // so a die-stub is enough to satisfy the layer's deps. Layer.provideMerge( Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 9dcd72263d32..d9ff98279718 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -40,19 +40,10 @@ export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedE export class DesktopBackendConfiguration extends Context.Service< DesktopBackendConfiguration, { - // Build the Windows-native primary backend's start config. Reads the - // primary's port/host/exposure from DesktopServerExposure. Can fail - // with PlatformError because bootstrap token generation now uses - // crypto.randomBytes under the hood (post Effect 4 migration). readonly resolvePrimary: Effect.Effect< DesktopBackendManager.DesktopBackendStartConfig, PlatformError.PlatformError >; - // Build a WSL backend start config for the given distro on the given - // port. The WSL backend is always loopback-only (the primary owns LAN - // exposure when the user opts in), so this takes the port directly and - // hardcodes 127.0.0.1. Distro=null means "WSL default distro" and is - // forwarded to wsl.exe with no -d flag. readonly resolveWsl: (input: { readonly port: number; readonly distro: string | null; @@ -60,14 +51,7 @@ export class DesktopBackendConfiguration extends Context.Service< DesktopBackendManager.DesktopBackendStartConfig, PlatformError.PlatformError >; - // The renderer-facing label for the primary instance, derived from the - // same decision resolvePrimary makes (including the WSL-availability - // fall-back to Windows), so the env switcher can't show "WSL" for a - // backend that actually resolved to Windows. readonly resolvePrimaryLabel: Effect.Effect; - // The bootstrap token the renderer should present right now. It rotates - // every window (derived from the secret every backend was launched with), - // so the renderer never holds one long-lived admin credential. readonly currentBootstrapToken: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} @@ -98,8 +82,7 @@ const DESKTOP_BACKEND_ENV_NAMES = [ ] as const; // Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. The dev-server URL travels as -// the `--dev-url` CLI flag instead. +// across the wsl.exe boundary without WSLENV. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -153,9 +136,6 @@ const mergeWslEnv = ( ): string | undefined => { const existing = existingWslEnv?.trim() ?? ""; - // Names already declared, so we don't forward a duplicate. We parse the - // existing value only for this membership test β€” the string itself is - // preserved verbatim below rather than re-serialized. const seenNames = new Set( existing .split(":") @@ -250,11 +230,6 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { }; }); -// The bootstrap carries the OTLP endpoints to every backend, including a WSL -// child that lacks the variables. The T3 URLs also travel as variables in -// WSL_FORWARDED_ENV_NAMES so they outrank a forwarded OTEL endpoint. Env beats -// the persisted settings file, matching the precedence resolveServerConfig and -// DesktopObservability apply. const readBackendObservabilitySettings = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedBackendObservabilitySettings; @@ -271,9 +246,6 @@ interface SharedBootstrapInput { readonly observabilitySettings: BackendObservabilitySettings; } -// What the launch runs inside the distro. The staged runtime is the release's -// self-contained `t3` executable (Node inside); the mounted server tree is a -// script that needs the distro's own Node. type WslPreflightRuntime = | { readonly kind: "executable"; @@ -294,20 +266,13 @@ interface WslPreflightSuccess { readonly runningDistro: string; readonly windowsEntryPath: string; readonly runtime: WslPreflightRuntime; - // PATH captured from the user's login shell. The launch forwards this value - // directly without a shell so the server can spawn provider CLIs by name. readonly resolvedPath: string; - // Identifies the distro-local runtime cache selected from the packaged archive. readonly runtimeId?: string; } interface WslPreflightFailure { readonly _tag: "Failed"; readonly reason: string; - // Fatal: the WSL distro is misconfigured (no node, wrong version, missing - // build tools) and retrying won't help β€” surface it and (wsl-only) fall back - // to Windows. Non-fatal: transient (WSL not ready yet, wslpath while it - // boots), with a bounded window for self-healing before fallback. readonly fatal: boolean; readonly retryLimit?: number; } @@ -395,8 +360,6 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f ...(result.retryLimit === undefined ? {} : { retryLimit: result.retryLimit }), }) as const; - // The mounted server tree is the fallback runtime: the Windows-side copy the - // distro reads over /mnt. Slower to launch from, but always installed. const resolveMountedAppRoot = Effect.gen(function* () { const serverTree = yield* wslServerTree.ensure; if (!serverTree.ok) { @@ -423,12 +386,6 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f : ({ ok: true, windowsEntryPath, linuxAppRoot: mountedAppRoot.value } as const); }); - // Set once a staged runtime has been ruled out by the probe, and carried - // through the mounted attempt: if the mounted tree works the cache is the - // broken part and gets invalidated, and if the mounted tree returns its own - // fatal verdict the cached reason is the more actionable one to report. - // A transient mounted failure is neither β€” it rules nothing out, so it stays - // retryable and the staged verdict waits for an attempt that can answer. let stagedFailure: { readonly runtimeId: string; readonly reason: string } | undefined; const failedStaged = (failure: { readonly reason: string }) => ({ @@ -489,9 +446,6 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f : failedNodePty(nodePtyResult); } - // The mounted tree runs what the cache could not, so the cache is the broken - // copy: revoke its ready marker so the next launch reinstalls it instead of - // reusing a tree that has already been proven unloadable. if (stagedFailure) { yield* wslEnv.invalidateRuntime(runningDistro, stagedFailure.runtimeId); } @@ -509,10 +463,8 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; }); -// True when the given IPv4 belongs to a Windows-side network -// interface. In WSL2 mirrored mode the distro's eth0 IP equals the -// host's, which is the signature we use to detect that mode and -// switch the renderer URL to loopback. +// In WSL2 mirrored mode the distro's eth0 IP equals the host's, which is the +// signature we use to detect that mode and switch the renderer URL to loopback. const isLocalHostIpv4 = (ip: string): boolean => { const interfaces = NodeOS.networkInterfaces(); for (const list of Object.values(interfaces)) { @@ -520,9 +472,7 @@ const isLocalHostIpv4 = (ip: string): boolean => { for (const entry of list) { // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on // the Node build Electron ships (41 / Node 22, verified), but some Node - // builds report the numeric 4. Normalize to a string so a future runtime - // bump can't silently break mirrored-mode detection and leave the - // renderer pointed at the distro IP instead of loopback. + // builds report the numeric 4. const family = String(entry.family); if ((family === "IPv4" || family === "4") && entry.address === ip) return true; } @@ -601,10 +551,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", - // The server names this launcher in commands it asks a person to run. T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), }, - // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). extendEnv: true, bootstrap, bootstrapDelivery: "fd3", @@ -632,17 +580,11 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const fileSystem = yield* FileSystem.FileSystem; - // Bind to 0.0.0.0 inside WSL so the backend is reachable both via - // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 - // -> WSL 127.0.0.1) AND via the distro's eth0 IP directly from - // Windows. wslhost forwarding is unreliable on some Windows hosts: - // the desktop's readiness probe and the renderer's saved-env-style - // fetch both saw "Failed to fetch" when the backend only bound to - // 127.0.0.1 inside WSL. Binding to 0.0.0.0 plus advertising the - // WSL IP as the renderer-visible URL avoids that dependency. - // Security-wise this is acceptable for the local-only WSL backend: - // the network it exposes on is the WSL-vEthernet network, not the - // LAN; the primary owns LAN exposure when the user opts in. + // wslhost forwarding is unreliable on some Windows hosts: the desktop's + // readiness probe and the renderer's saved-env-style fetch both saw "Failed + // to fetch" when the backend only bound to 127.0.0.1 inside WSL. Binding to + // 0.0.0.0 plus advertising the WSL IP as the renderer-visible URL avoids that + // dependency. const wslBindHost = "0.0.0.0"; const bootstrap = { @@ -655,22 +597,13 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl host: wslBindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // PortSchema rejects 0, so when tailscale serve is disabled we still - // need a valid number in this slot. The backend reads tailscaleServePort - // only when tailscaleServeEnabled is true, so the actual value here is - // inert. + // PortSchema rejects 0, so when tailscale serve is disabled we still need a + // valid number in this slot. tailscaleServeEnabled: false, tailscaleServePort: 443, - // The packaged sidecar is a Windows executable and cannot run inside the - // Linux WSL backend. Keep the field absent instead of passing an unusable - // `/mnt/.../*.exe` path; WSL resource telemetry is reported unavailable. - // See docs/internals/resource-telemetry.md. ...buildObservabilityFragment(input.observabilitySettings), }; - // The archive is the primary packaged WSL path: it installs directly into - // the distro's ext4 filesystem. The server.asar extraction service is only - // consulted lazily if the archive is unavailable or cannot be staged. const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME); const archiveHashPath = environment.path.join( environment.resourcesPath, @@ -700,20 +633,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ? null : { windowsPath: archivePath, - // The verified archive bytes are the cache identity. Release builds - // embed the release version and pnpm install metadata, so the - // archive changes on every update even when application logic does - // not. Later launches of that update still reuse this directory. runtimeId: `sha256-${archiveHash}`, sha256: archiveHash, }, - // Packaged builds run the self-contained Linux runtime and, on fallback, - // whatever Linux node-pty the mounted tree carries, so the WSL backend never - // needs a compiler, node-gyp, or network on first launch. Compiling from - // source is a dev-only convenience: a checkout has no Linux binary, and - // developers have the toolchain. In packaged builds we instead surface a - // clear diagnostic if the binary can't load (unsupported arch/distro), - // rather than silently dropping into a fragile runtime build. allowBuild: !environment.isPackaged, }); @@ -723,9 +645,6 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; const distroForConfig = runningDistro ?? input.distro; - // Resolve the selected distro's IPv4 address. In mirrored mode the distro - // reports a host interface, so use loopback instead; a failed probe also - // falls back to loopback and preserves the previous behavior. const distroIp = yield* wslEnvironment.getDistroIp(distroForConfig); const usesSharedNetworkStack = Option.match(distroIp, { onNone: () => false, @@ -747,11 +666,6 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } } - // Build an explicit copy of process.env minus T3CODE_HOME (dev-runner - // exports the Windows-side base dir for the primary; if it leaks into - // the WSL backend the Linux side ends up sharing C:\Users\...\.t3 via - // /mnt/c, which means both backends read/write the same database and - // their env-ids collide). const parentEnvWithoutT3Home: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (key === "T3CODE_HOME") continue; @@ -781,8 +695,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl }; // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. The packaged build - // leaves devServerUrl as None. + // config resolution lands in dev/ instead of userdata/. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], @@ -805,13 +718,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // The WSL server spawns commands its providers reference by name β€” `npm`/`npx` // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on - // the process PATH, so `npm install -g ...` fails with NotFound. Pass the - // user PATH entries captured by the preflight. Every dynamic value is a - // separate argv entry under `wsl.exe --exec`; no shell command is involved, - // so Windows cannot mangle nested quotes and stdin remains reserved for the - // bootstrap envelope. A node-script runtime additionally leads with the - // probed Node's bin dir so the server cannot pick up a different node than - // the one node-pty was probed with. + // the process PATH, so `npm install -g ...` fails with NotFound. const runtime = preflight.runtime; const launchPath = runtime.kind === "executable" @@ -853,9 +760,8 @@ export const make = Effect.gen(function* () { // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can // resolve concurrently; with a plain Ref both could observe None, generate // distinct secrets, and one would overwrite the other β€” leaving the two - // backends deriving mismatched tokens and breaking the shared-token - // invariant the renderer relies on. modifyEffect serializes the whole - // get-or-create so the first caller wins and the rest reuse its secret. + // backends deriving mismatched tokens and breaking the shared-token invariant + // the renderer relies on. const secretRef = yield* SynchronizedRef.make(Option.none()); const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { @@ -870,11 +776,6 @@ export const make = Effect.gen(function* () { }), ); - // Both resolvers share the same bootstrap token: the renderer holds a - // single token and uses it against whichever backend it's currently - // talking to. Observability settings get re-read each resolve so a - // hot-swap of the server-settings file is picked up on the next - // restart cycle without having to bounce the desktop process. const sharedInputs = Effect.gen(function* () { const bootstrapSecret = yield* getOrCreateBootstrapSecret; const bootstrapToken = currentDesktopBootstrapToken( @@ -893,12 +794,6 @@ export const make = Effect.gen(function* () { }); const buildWslPrimaryConfig = Effect.gen(function* () { - // wsl-only mode pipes the WSL backend through the same port the - // Windows primary would normally take. That way the renderer - // still loads from the local-only endpoint advertised by - // DesktopServerExposure, and primary-aware code paths (cookie - // auth, the env switcher's "primary" id) keep working without - // a parallel "secondary" registration. const backendExposure = yield* serverExposure.backendConfig; const persistedSettings = yield* settings.get; const shared = yield* sharedInputs; @@ -937,10 +832,6 @@ export const make = Effect.gen(function* () { ); }); - // Single source of truth for what the primary actually runs as. Both - // the start-config dispatch and the renderer-facing label derive from - // this, so they can't disagree β€” e.g. the label reading "WSL" while the - // config silently fell back to Windows because WSL is unavailable. // Dispatch happens at resolve time so toggling wsl-only between restarts // is picked up on the next start cycle (the pool's primary instance is // created once at layer init, but configResolve fires on each restart). diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 760e5a24f897..8cc1a9fc6433 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -141,11 +141,6 @@ interface MakeInstanceInput { readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect; } -// Helper that constructs a primary backend instance using the factory -// directly. The factory's deps (FileSystem, ChildProcessSpawner, -// HttpClient, DesktopBackendOutputLogFactory) are provided per-test via -// a scoped layer; tests yield the returned Effect inside `Effect.scoped` -// to drive the instance's lifecycle. function makeTestInstance(input: MakeInstanceInput) { const stubLog: DesktopObservability.DesktopBackendOutputLogShape = { beginSession: () => Effect.void, @@ -759,9 +754,6 @@ describe("DesktopBackendManager", () => { ), ); - // The backend stays 503 through the first *two* readiness budgets - // and only becomes healthy (200) for the third round, i.e. it comes - // up well after the initial 50ms budget has expired. const layerHttp = layerHttpClient((request) => Effect.gen(function* () { requestCount += 1; @@ -789,18 +781,10 @@ describe("DesktopBackendManager", () => { assert.equal(readyCount, 0); assert.equal(readinessTimeoutCount, 0); - // The first 50ms readiness budget expires while the backend still - // answers 503. The child is alive and may yet become healthy, so the - // probe must start a fresh round instead of stopping permanently β€” - // the pre-fix behavior left the app stuck on "Connecting to WSL…" - // forever even though the backend kept running. yield* TestClock.adjust(Duration.millis(50)); assert.equal(readinessTimeoutCount, 1); assert.equal(readyCount, 0); - // The second budget also expires (backend still 503), then the third - // round connects. The point is the probe persisted across budgets - // while the process was alive instead of giving up after the first. yield* TestClock.adjust(Duration.millis(100)); assert.equal(readinessTimeoutCount, 2); assert.equal(readyCount, 1); @@ -1296,15 +1280,12 @@ describe("DesktopBackendManager", () => { yield* instance.start; assert.deepEqual(failures, []); - // Five fatal attempts with exponential backoff (500ms, 1s, 2s, 4s) reach - // the cap, at which point the failure is surfaced exactly once. yield* TestClock.adjust(Duration.millis(500)); yield* TestClock.adjust(Duration.seconds(1)); yield* TestClock.adjust(Duration.seconds(2)); yield* TestClock.adjust(Duration.seconds(4)); assert.deepEqual(failures, ["Node.js not found"]); - // Past the cap the loop stops and nothing else is surfaced. yield* TestClock.adjust(Duration.seconds(8)); yield* TestClock.adjust(Duration.seconds(30)); assert.deepEqual(failures, ["Node.js not found"]); @@ -1394,8 +1375,7 @@ describe("DesktopBackendManager", () => { }); yield* instance.start; - // Well beyond the fatal cap's worth of time: a transient failure must - // keep retrying (self-heal) and never surface. + yield* TestClock.adjust(Duration.minutes(2)); assert.deepEqual(failures, []); }).pipe(Effect.provide(TestClock.layer())), @@ -1545,10 +1525,6 @@ describe("DesktopBackendManager", () => { it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () => Effect.scoped( Effect.gen(function* () { - // Each backend's process-scope finalizer reports when it starts and - // when it finishes, keyed by instance name, so the test can prove - // both backends reached each milestone instead of inferring it from - // a shared flag or a clock advance. const teardownStarted = yield* Queue.unbounded(); const teardownFinished = yield* Queue.unbounded(); const allowTeardown = yield* Deferred.make(); @@ -1589,9 +1565,6 @@ describe("DesktopBackendManager", () => { unregister: () => Effect.die(new Error("unregister not implemented")), }); - // Mirror the quit path: register stopAllPoolInstances as a scope - // finalizer and let the scope close run it, rather than calling it - // as an ordinary interruptible effect. const quitFiber = yield* Effect.scoped( Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()), ).pipe(Effect.provide(layerMockPool), Effect.forkChild); @@ -1599,14 +1572,10 @@ describe("DesktopBackendManager", () => { const started = yield* Queue.takeN(teardownStarted, 2); assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); - // Both backends are now hung in teardown. Advancing past the 5s - // budget must let the quit finalizer return without them. yield* TestClock.adjust(Duration.seconds(5)); yield* Fiber.join(quitFiber); assert.equal(yield* Queue.size(teardownFinished), 0); - // The timed-out closes keep running in the background and finish - // once the backends unblock. yield* Deferred.succeed(allowTeardown, undefined); const finished = yield* Queue.takeN(teardownFinished, 2); assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index c758b27dd282..8d2a3f1c4912 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,28 +1,3 @@ -// Per-instance backend factory. Replaces the legacy singleton -// `DesktopBackendManager` Context.Service: each call to -// `makeBackendInstance(spec)` constructs an isolated backend lifecycle β€” -// its own state Ref, mutex, restart loop, and active child process. The -// returned `DesktopBackendInstance` exposes start/stop/snapshot/wait -// methods that operate on that single backend. -// -// The pool layer (`DesktopBackendPool.ts`) calls this factory once per -// backend it wants to run. Today that's the Windows primary; follow-up -// commits add a second call for the WSL instance. -// -// Singleton couplings that the legacy service held inline are now -// parameterized via the spec: -// - configResolve replaces the legacy `DesktopBackendConfiguration.resolve` -// so each instance can resolve its own start config β€” the primary wires -// `configuration.resolvePrimary`, the WSL orchestrator wires a -// `configuration.resolveWsl({ port, distro })` closure. -// - onReady / onShutdown drive UI side effects (window auto-open, -// readiness latch) only for instances that want them β€” the primary's -// spec passes the window's handleBackendReady/handleBackendNotReady, -// other pool instances pass nothing. -// - log writes go through a per-instance writer that the factory -// pulls from `DesktopBackendOutputLogFactory.forInstance(spec.id)`, -// so each instance lands in its own rotating file. - import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; @@ -58,10 +33,6 @@ import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); -// After this many consecutive fatal preflight failures, stop the silent -// restart loop and surface the reason via onPreflightFailed. Transient -// failures may instead provide their own larger retryLimit when they should -// self-heal for a while but must not leave the app connecting forever. const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); @@ -101,20 +72,10 @@ export interface DesktopBackendStartConfig extends BackendProcessContext { readonly httpBaseUrl: URL; readonly captureOutput: boolean; readonly preflightFailure: Option.Option; - // Present for a WSL run after the configured/default distro has been - // resolved to the concrete distro passed to wsl.exe. readonly runningDistro?: string; - // Present only when this run launched from a staged WSL-local runtime. - // Once HTTP readiness succeeds, the manager uses it to retain this cache - // plus the newest previous cache and prune older versions. readonly wslRuntimeId?: string; } -// A preflight failure records whether it is fatal. Transient failures (WSL -// cold-starting, wslpath while the VM boots) keep retrying so the backend can -// self-heal; fatal ones (no node, wrong version, missing build tools) are -// surfaced via onPreflightFailed and stop the restart loop after -// MAX_PREFLIGHT_FAILURE_ATTEMPTS. export interface PreflightFailure { readonly reason: string; readonly fatal: boolean; @@ -226,9 +187,7 @@ export type BackendProcessError = typeof BackendProcessError.Type; interface RunBackendProcessOptions extends DesktopBackendStartConfig { readonly desktopTelemetryStream: Stream.Stream; - /** Events for the desktop's browser tabs, written to `desktopBrowserFd`. */ readonly desktopBrowserStream?: Stream.Stream; - /** Each line the backend writes to `desktopBrowserControlFd`. */ readonly onDesktopBrowserCommand?: (line: string) => Effect.Effect; readonly onDesktopTelemetryControl?: ( message: DesktopTelemetryControlMessageValue, @@ -254,11 +213,6 @@ export interface DesktopBackendSnapshot { readonly restartScheduled: boolean; } -// Opaque identifier for one backend process inside the pool. Today only -// PRIMARY_INSTANCE_ID is registered. Follow-up commits add WSL distros -// under ids derived from the distro name (e.g. "wsl:ubuntu"). Eventually -// these map 1:1 with environment ids on the frontend; keeping them -// desktop-local for now avoids leaking the contracts dependency. export type BackendInstanceId = string & Brand.Brand<"BackendInstanceId">; export const BackendInstanceId = Brand.nominal(); @@ -266,9 +220,6 @@ export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( PRIMARY_LOCAL_ENVIRONMENT_ID, ); -// One pooled backend instance. Same lifecycle surface as the legacy -// `DesktopBackendManagerShape`; the id and label give the pool registry -// + UI something to route on. export interface DesktopBackendInstance { readonly id: BackendInstanceId; readonly label: Effect.Effect; @@ -276,25 +227,12 @@ export interface DesktopBackendInstance { readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; - // Polls desiredRunning + the instance's own ready flag until the - // backend reports ready, or the timeout elapses. Returns true on - // ready, false on timeout. Used by the WSL backend swap to drive its - // rollback path. readonly waitForReady: (timeout: Duration.Duration) => Effect.Effect; } -// Spec describing one backend instance to spawn. The configResolve -// effect is awaited each time the instance is (re)started so live -// settings changes are picked up on the next start cycle. onReady and -// onShutdown let the primary instance trigger UI side effects (window -// open, global readiness flag) without coupling the factory to those -// concerns; other instances pass them as undefined. export interface BackendInstanceSpec { readonly id: BackendInstanceId; readonly label: Effect.Effect; - // configResolve can now fail with PlatformError because the - // bootstrap-token closure inside DesktopBackendConfiguration uses - // crypto.randomBytes (Effect 4 beta.73 migration). readonly configResolve: Effect.Effect; // Receives the *resolved* httpBaseUrl of the run that just became // ready. The window service uses this to decide what URL to load @@ -303,9 +241,6 @@ export interface BackendInstanceSpec { // between "fired onReady" and "currentConfig already advanced". readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; - // Fired once when a fatal or bounded preflight failure has exhausted its - // retries. Returns true when the callback changed configuration and the - // manager should resolve once more; false stops the failed instance. readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; readonly onStateDirOwned?: () => Effect.Effect; } @@ -325,8 +260,6 @@ interface BackendManagerState { readonly config: Option.Option; readonly active: Option.Option; readonly restartAttempt: number; - // Consecutive bounded/fatal preflight failures, reset on a clean or - // unbounded-transient preflight. restartAttempt counts all restarts. readonly preflightFailureAttempt: number; readonly restartFiber: Option.Option>; readonly nextRunId: number; @@ -609,14 +542,12 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ).pipe(Effect.forkScoped), ); } - // Probe readiness in a loop while the backend process is still alive - // instead of giving up after the first budget. A slow cold boot (the - // WSL bundle loading across /mnt/c, or a first launch right after an - // update) can exceed the initial readiness budget while the backend is - // about to come up moments later; a one-shot probe left the app stuck - // on "Connecting to WSL…" forever even though the backend kept running - // and became healthy. Each round gets a fresh budget, and the forked - // loop is torn down with the run scope once the child exits. + // Probe readiness in a loop while the backend process is still alive instead + // of giving up after the first budget. A slow cold boot (the WSL bundle + // loading across /mnt/c, or a first launch right after an update) can exceed + // the initial readiness budget while the backend is about to come up moments + // later; a one-shot probe left the app stuck on "Connecting to WSL…" forever + // even though the backend kept running and became healthy. const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => waitForHttpReady({ executablePath: options.executablePath, @@ -668,11 +599,6 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( } satisfies BackendProcessExit; }); -// Factory for one pooled backend instance. The returned instance owns -// its own state Ref, mutex, restart loop, and active child process; -// nothing is shared between instances created from separate -// makeBackendInstance calls. The instance shuts down automatically when -// the calling scope closes (typically the application scope). export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( spec: BackendInstanceSpec, ): Effect.fn.Return< @@ -784,9 +710,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (Option.isSome(preflightFailure)) { const { reason, fatal, retryLimit } = preflightFailure.value; if (!fatal && retryLimit === undefined) { - // Transient (WSL cold-starting, wslpath while the VM boots). Keep - // retrying so the backend self-heals once WSL is ready. Reset a - // prior bounded/fatal streak because this is a different failure. yield* Ref.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest @@ -801,9 +724,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return [next, { ...latest, preflightFailureAttempt: next }] as const; }); if (attempt > attemptLimit) { - // We already surfaced and asked for the Windows fallback, yet we're - // still resolving the WSL primary β€” the fallback didn't take (e.g. - // the settings write failed). Stop rather than loop forever. yield* logInstanceError("backend preflight still failing after fallback; stopping", { reason, attempt, @@ -816,10 +736,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } if (attempt === attemptLimit) { - // Fatal/bounded and out of retries. Surface the reason (onPreflightFailed, - // on the primary, shows a dialog and persists Windows mode), then - // schedule one more restart so the next resolve picks up the Windows - // primary and a window can open. yield* logInstanceError( "backend preflight failed repeatedly; surfacing and falling back", { reason, attempt }, @@ -841,8 +757,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* scheduleRestart(reason); return; } - // Clean preflight β€” reset the fatal counter so a later failure gets a - // fresh allowance. yield* Ref.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, ); @@ -957,7 +871,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const program = runBackendProcess({ ...config.value, desktopTelemetryStream: desktopTelemetryPublisher.encoded, - // Only a bootstrap that names the browser fds (the local primary) gets them. desktopBrowserStream: desktopBrowserHost.events, onDesktopBrowserCommand: desktopBrowserHost.handleCommandLine, onDesktopTelemetryControl: (message) => @@ -1193,8 +1106,6 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const waitForReady = (timeout: Duration.Duration): Effect.Effect => Effect.gen(function* () { const current = yield* Ref.get(state); - // Return false early if an external `stop()` flipped desiredRunning off - // β€” no point polling for a backend that is being torn down. if (!current.desiredRunning) return { done: true, ready: false }; return current.ready ? { done: true, ready: true } : { done: false, ready: false }; }).pipe( diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index c5f31fc47a09..ed6fab28503c 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -124,8 +124,6 @@ describe("DesktopBackendPool", () => { assert.equal(yield* Option.getOrThrow(fetchedWsl).label, "WSL (Ubuntu)"); assert.isTrue(Option.isNone(fetchedMissing)); assert.lengthOf(all, 2); - // First instance becomes primary in layerTest so single-instance - // stubs don't have to wire an explicit primary. assert.equal(resolvedPrimary.id, DesktopBackendPool.PRIMARY_INSTANCE_ID); }).pipe( Effect.provide( diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 21845205110c..13c85ac58fdc 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -1,84 +1,3 @@ -// Pool registry for multiple backend processes. This file is the entry -// point for the concurrent-Windows+WSL-backend feature; see the design -// notes below before extending it. -// -// Current state: -// - `DesktopBackendManager.ts` exposes a per-instance factory -// (`makeBackendInstance(spec)`); the pool calls it once for the -// Windows primary at startup, and `DesktopWslBackend.reconcile` -// calls it through `pool.register` to bring up the WSL instance -// when the user enables it. -// - The primary spec wires `configResolve` to -// `DesktopBackendConfiguration.resolvePrimary` and the -// `onReady`/`onShutdown` callbacks to the window service. WSL -// instances wire `configResolve: configuration.resolveWsl(...)` -// and skip onReady/onShutdown β€” the window only follows the primary. -// - The pool exposes `register(spec)` and `unregister(id)`. Each -// registered instance gets its own child scope, so unregister can -// stop it cleanly without tearing down the pool. The primary's id -// refuses unregister. -// - Settings: `wslBackendEnabled: boolean` + `wslDistro: string | null`. -// The legacy `wslMode: "local" | "wsl"` swap setting is migrated on -// load. IPC surface is `setWslBackendEnabled(boolean)` + -// `setWslDistro(string | null)`; both persist and then call the -// orchestrator's reconcile. No swap, no rollback, primary stays up. -// - `getLocalEnvironmentBootstraps()` (plural) returns one entry per -// pool instance currently registered with bootstrap info. The -// primary keeps the "primary" id; WSL instances are "wsl:default" -// or "wsl:". -// - `pickFolder` accepts an optional `targetEnvironmentId`. Omitting -// it gives the Windows picker β€” what every existing caller gets, -// and what non-WSL users see. WSL targets route to the wsl helpers. -// - Web settings UX: a plain toggle for "WSL backend" plus a distro -// picker that shows up when the toggle is on. Default-off, so -// users who never opted in see the same surface as before. -// -// Renderer-side wiring (apps/web/src/environments/local/): -// - reconcileLocalSecondaryEnvironments() runs at app boot and after -// WSL settings changes. It reads getLocalEnvironmentBootstraps(), -// skips the primary (which the existing primary/ runtime owns), -// and for every other entry POSTs the shared bootstrap token to -// /api/auth/bootstrap/bearer on that backend's URL, fetches the -// descriptor, builds a SavedEnvironmentRecord marked desktopLocal, -// writes the bearer to the secret store, and opens a connection -// through the same saved-env path remote envs use. -// - The desktopLocal marker filters records out of saved-env -// persistence, so toggling WSL off or switching distros doesn't -// pollute the user's settings file. The sidebar, CommandPalette, -// env switcher, and project-id routing all read the saved-env -// registry, so the WSL backend shows up there without any -// per-surface changes. -// -// Browser validation (2026-05-17, dev:desktop with wslBackendEnabled=true, -// wslDistro="Ubuntu"): -// - Two backends listening on distinct loopback ports -// (server.log: 13773 primary, 13774 wsl). -// - Per-instance log files: server-child.log + server-child-wsl_Ubuntu.log. -// - Distinct environment ids reported by each backend's -// /.well-known/t3/environment (Windows vs Linux platform). -// - Renderer completes the bearer-token bootstrap against the WSL -// backend (POST /api/auth/bootstrap/bearer 200), obtains a -// ws-token (POST /api/auth/ws-token 200), and holds an -// ESTABLISHED WebSocket connection to both ports (netstat). -// -// Migration history (commits): -// 1. Reshape `DesktopBackendManager` into an instance factory and route -// consumers through the pool. Pool held a single instance. (a8fc7845) -// 2. Drop `DesktopState.backendReady`. The window owns its own -// readiness latch via onReady / onShutdown callbacks. (425c7d0b) -// 3. Per-instance log routing via DesktopBackendOutputLogFactory. (563820ed) -// 4. Add register/unregister to the pool. (a0eaf560) -// 5. Wire WSL through the pool: settings rename, BackendConfiguration -// split, DesktopWslBackend orchestrator, new IPC, web compat. -// (b1622191 + 31ce3add + 627c80cb) -// 6. Widen getLocalEnvironmentBootstrap to *Bootstraps (plural). (bad66041) -// 7. pickFolder takes optional targetEnvironmentId. (5d80468d) -// 8. Settings UX: toggle + distro picker, no swap dialog. (eb5a03ea) -// 9. Register WSL backend as desktop-local saved env via -// reconcileLocalSecondaryEnvironments. (1c7e7873 + c17897bd) -// 10. CommandPalette enables file-manager picker for desktop-local -// envs, routes pickFolder by env id. (38e8477a) - import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -113,9 +32,6 @@ export const PRIMARY_INSTANCE_ID = DesktopBackendManager.PRIMARY_INSTANCE_ID; export type DesktopBackendInstance = DesktopBackendManager.DesktopBackendInstance; export type BackendInstanceSpec = DesktopBackendManager.BackendInstanceSpec; -// Caller tried to register an id that's already in the pool. The pool -// refuses overwrites so two independent orchestrators racing on the -// same id surface as a typed failure instead of one silently winning. export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Schema.TaggedError()( "DesktopBackendPoolInstanceAlreadyRegisteredError", { @@ -127,9 +43,6 @@ export class DesktopBackendPoolInstanceAlreadyRegisteredError extends Schema.Tag } } -// Primary instance is registered for the pool's lifetime. Unregister is -// a no-op for it today (no real callers), but if someone wires it up -// later it's a clear bug rather than something to "handle". export class DesktopBackendPoolCannotUnregisterPrimaryError extends Schema.TaggedError()( "DesktopBackendPoolCannotUnregisterPrimaryError", {}, @@ -142,40 +55,18 @@ export class DesktopBackendPoolCannotUnregisterPrimaryError extends Schema.Tagge export class DesktopBackendPool extends Context.Service< DesktopBackendPool, { - // Look up a registered instance. None when no backend with that id is - // currently registered (e.g. WSL backend disabled). readonly get: (id: BackendInstanceId) => Effect.Effect>; - // Snapshot of all currently-registered instances. Order is unspecified; - // callers that need a canonical "primary first" view should sort by id. readonly list: Effect.Effect; - // Convenience accessor for the always-registered primary instance. - // Currently equivalent to `get(PRIMARY_INSTANCE_ID)` unwrapped, but - // exposed as a typed effect so consumers don't have to handle the - // Option for the case that's guaranteed to be present. readonly primary: Effect.Effect; - // Build a fresh DesktopBackendInstance from `spec` and add it to the - // registry. The pool owns the instance's scope: unregister(id) or pool - // teardown closes it and runs the instance's auto-stop finalizer. The - // returned instance has not been started β€” callers decide when to - // start it (and can call start more than once if a retry-after-failure - // story makes sense for them). readonly register: ( spec: BackendInstanceSpec, ) => Effect.Effect; - // Stop the named instance and remove it from the registry. Closing the - // instance's scope triggers its auto-stop finalizer; the registry is - // updated atomically with the scope close so subsequent get(id) calls - // observe the unregister before the underlying child process has fully - // exited. readonly unregister: ( id: BackendInstanceId, ) => Effect.Effect; } >()("@t3tools/desktop/backend/DesktopBackendPool") {} -// Services required by makeBackendInstance β€” exported so caller -// orchestrators that build their own specs can confirm the layer graph -// satisfies them at compile time. export type BackendInstanceFactoryRequirements = | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner @@ -188,9 +79,6 @@ export type BackendInstanceFactoryRequirements = interface ActiveRegisteredInstance { readonly _tag: "Active"; readonly instance: DesktopBackendInstance; - // None for the primary (which lives in the pool's own layer scope and - // is never unregistered); Some for instances added via register, whose - // scope unregister closes to stop them. readonly scope: Option.Option; } @@ -218,22 +106,13 @@ export const layer = Layer.effect( const electronDialog = yield* ElectronDialog.ElectronDialog; const electronApp = yield* ElectronApp.ElectronApp; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; - // Anchor the pool's lifetime to its layer scope so registered - // instance scopes can be forked off it. Without this, instance - // scopes are orphaned: they only close via explicit unregister() - // calls, so on app shutdown the WSL backend child process gets - // hard-killed by the OS instead of receiving the graceful - // SIGTERM + grace period the instance's stop finalizer would - // otherwise run. + // Without this, instance scopes are orphaned: they only close via explicit unregister() calls, + // so on app shutdown the WSL backend child process gets hard-killed by the OS instead of + // receiving the graceful SIGTERM + grace period the instance's stop finalizer would otherwise + // run. const layerScope = yield* Scope.Scope; - // Capture the services needed to build any future instance from the - // pool's layer scope. register() runs `makeBackendInstance` against - // a fresh child scope but reuses these services so the instance gets - // the same FileSystem, spawner, HTTP client and log factory the - // primary instance uses. const factoryContext = yield* Effect.context(); - // A WSL preflight failure on the primary only happens in wsl-only mode. // Fatal configuration failures persist the Windows fallback. Bounded // transport failures use an in-memory fallback for this launch so the app // opens without overwriting the user's WSL preference. @@ -260,13 +139,9 @@ export const layer = Layer.effect( "WSL backend couldn't start", `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, ); - // Fully disable the WSL backend β€” both flags, matching the "Switch to - // Windows" recovery path β€” so the manager's next restart re-resolves the - // primary as Windows and reconcile won't register a secondary WSL backend - // against the same broken setup. Clearing wslBackendEnabled alone would - // leave a stale wslOnly:true that silently re-traps the user in wsl-only - // mode the next time they enable WSL. If the persisted write fails, keep - // this process recoverable by applying the fallback to in-memory settings. + // Clearing wslBackendEnabled alone would leave a stale wslOnly:true that silently re-traps + // the user in wsl-only mode the next time they enable WSL. If the persisted write fails, + // keep this process recoverable by applying the fallback to in-memory settings. yield* appSettings.applyWslWindowsFallback.pipe( Effect.catch((error) => logBackendPoolWarning( @@ -289,12 +164,8 @@ export const layer = Layer.effect( // primaries as Windows. label: configuration.resolvePrimaryLabel, configResolve: configuration.resolvePrimary, - // Window creation errors propagating out of handleBackendReady must - // not block the readiness callback (that would prevent restartAttempt - // from being reset), so we absorb them here. The window service only - // logs on success, so log the failure here before swallowing it β€” - // otherwise a post-readiness window-open failure vanishes silently and - // is near-impossible to diagnose in production. + // Window creation errors propagating out of handleBackendReady must not block the readiness + // callback (that would prevent restartAttempt from being reset), so we absorb them here. onReady: (httpBaseUrl) => desktopWindow.handleBackendReady(httpBaseUrl).pipe( Effect.catch((error) => @@ -454,13 +325,6 @@ export const layer = Layer.effect( }), ); -// Test layer for unit tests that want to assert against a known pool -// composition without standing up the full manager. Each provided -// instance is registered under its own id; the first one is also -// surfaced as `primary` so callers can stub a single-instance pool. -// `register` and `unregister` are stubbed to die so tests that -// accidentally exercise pool registration fail loudly instead of -// silently noop'ing. export const layerTest = ( instances: readonly DesktopBackendInstance[], ): Layer.Layer => diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 3f0bd82d5888..900afce9b860 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -128,7 +128,6 @@ describe("DesktopLocalEnvironmentAuth", () => { ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); - // The first exchange happens a day after launch, past the launch token's windows. yield* TestClock.setTime(DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS * 2 + 1); yield* Effect.gen(function* () { const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; @@ -171,7 +170,6 @@ describe("DesktopLocalEnvironmentAuth", () => { }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); - // Answers the first `failures` exchanges with `failure`, then with a token. const makeExchange = (failures: number, failure: () => Response) => Effect.gen(function* () { const requestCount = yield* Ref.make(0); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index d137d502e6c5..4d95dd075cf3 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -16,10 +16,6 @@ import * as HttpClient from "effect/http/HttpClient"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; -// A loopback /oauth/token exchange can fail transiently while the backend -// settles (a 502-504, a refused or reset connection, a timeout). Retry those -// for as long as the renderer's own bootstrap does; a rejected credential or a -// server error is final. const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); @@ -113,7 +109,6 @@ export const make = Effect.gen(function* () { while: isTransientBearerBootstrapError, schedule: Schedule.spaced(BOOTSTRAP_TRANSIENT_RETRY_INTERVAL), }), - // Bounds the attempts and any request still in flight at the deadline. Effect.timeout(BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT), Effect.mapError( (cause) => diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts index f2c32e1d0731..b75a686ca46b 100644 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -39,7 +39,7 @@ export class DesktopNetworkInterfaces extends Context.Service< } >()("@t3tools/desktop/backend/DesktopNetworkInterfaces") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; return DesktopNetworkInterfaces.of({ diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index b4d0a0626554..d280a82c7481 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -317,7 +317,6 @@ describe("DesktopServerExposure", () => { const settings = yield* DesktopAppSettings.DesktopAppSettings; return DesktopAppSettings.DesktopAppSettings.of({ ...settings, - // Hold the mode write the way a slow disk would. setServerExposureMode: (mode) => Deferred.succeed(modeWriteStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseModeWrite)), @@ -405,11 +404,8 @@ describe("DesktopServerExposure", () => { Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); - // mode stays at default "local-only", tailscaleServeEnabled stays false. const endpoints = yield* serverExposure.getAdvertisedEndpoints; - // Only the loopback endpoint; no tailscale spawn means the dieOnSpawnLayer - // would have crashed the test if the gate was missing. assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), ["http://127.0.0.1:4173/"], diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index e5bd11035f5c..f8f872147129 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -198,9 +198,7 @@ const resolveDesktopCoreAdvertisedEndpoints = ( : "User-configured endpoint for this desktop backend.", }), ); - } catch { - // Ignore malformed user-configured endpoints without dropping valid endpoints. - } + } catch {} } return endpoints; diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts index c9eb35b24e5f..a8abdad9bbc6 100644 --- a/apps/desktop/src/boot.ts +++ b/apps/desktop/src/boot.ts @@ -1,4 +1,4 @@ -// Packaged app entry. Enables the compile cache before the main bundle loads, -// so the cache also covers main.cjs itself. +// Enables the compile cache before the main bundle loads, so the cache also +// covers main.cjs itself. require("./compileCache.cjs"); require("./main.cjs"); diff --git a/apps/desktop/src/compileCache.ts b/apps/desktop/src/compileCache.ts index 20f942db7db9..da0fd66f5e58 100644 --- a/apps/desktop/src/compileCache.ts +++ b/apps/desktop/src/compileCache.ts @@ -1,19 +1,12 @@ // @effect-diagnostics nodeBuiltinImport:off -// Runs before any Effect runtime exists, so it stays on Node built-ins. import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -// Turns on Node's on-disk V8 code cache for every module loaded after this one, -// so later launches skip recompiling the large main and server bundles. -// Packaged builds only: boot.ts loads it for the main process, and the local -// backend gets it with `--require`. Dev launches main.cjs directly and skips it. -// Linux uses the user's cache dir because /tmp is shared between users; the -// macOS and Windows temp dirs are already per user. -// -// Skipped under AppImage: it mounts the app at a new /tmp/.mount_* path each -// launch, and Node keys entries by path, so every launch would miss and leave -// another copy behind. The backend inherits APPIMAGE, so this covers it too. +// Linux uses the user's cache dir because /tmp is shared between users; the macOS and Windows +// temp dirs are already per user. Skipped under AppImage: it mounts the app at a new +// /tmp/.mount_* path each launch, and Node keys entries by path, so every launch would miss +// and leave another copy behind. try { if (!process.env.APPIMAGE) { const cacheRoot = @@ -23,6 +16,4 @@ try { : NodeOS.tmpdir(); NodeModule.enableCompileCache(NodePath.join(cacheRoot, "t3code", "compile-cache")); } -} catch { - // The cache is only a speedup. Never let it stop the app from starting. -} +} catch {} diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 3f0be7cc7b6d..315f6b5510a0 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -49,7 +49,6 @@ describe("ElectronProtocol", () => { const request = (pathname: string, init?: RequestInit) => Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init))); - // SPA routes fall back to index.html, including ones containing dots. const page = yield* request("/settings/connections"); assert.equal(yield* Effect.promise(() => page.text()), "app"); assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'"); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 7b2bf8a59fc9..21cf8994800b 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -52,8 +52,6 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError catch: (cause) => new ElectronProtocolFetchError({ cause }), }); -// The dev renderer target can briefly refuse connections while Vite restarts: -// retry idempotent requests after 50ms, then 150ms, and keep the last failure. const fetchWithTransientRetry = (url: string, init: RequestInit) => netFetch(url, init).pipe( Effect.retry({ schedule: Schedule.exponential("50 millis", 3), times: 2 }), @@ -213,9 +207,6 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* ( return withContentSecurityPolicy(response, contentSecurityPolicy); }); -// Serves the packaged web client without a backend: files resolve within the -// asset directory, and any other path falls back to index.html so the SPA -// router handles it, except for asset-shaped misses (`/missing.js`) which 404. const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( request: Request, assetDirectory: string, @@ -276,7 +267,6 @@ export const make = Effect.gen(function* () { contentSecurityPolicy, ); } - // Reject with net.fetch's own error, as an unproxied fetch would. return runPromise( proxyRequest(request, input.targetOrigin, contentSecurityPolicy).pipe( Effect.catchTags({ diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 5504125ec202..d6832c79eb80 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -58,7 +58,6 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; - /** Opens a known System Settings pane by identifier, not by URL. */ readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index a5e74494c12f..a7ab05303f72 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -139,7 +139,6 @@ export const make = Effect.gen(function* () { ); return windowsForegroundFocus; }; - // Tracks a capture reveal in flight. Ordinary reveals keep Electron's native path. const captureRevealWindows = new Set(); yield* Effect.addFinalizer(() => Effect.sync(() => windowsForegroundFocus?.close())); const mainWindowRef = yield* Ref.make>(Option.none()); @@ -269,8 +268,6 @@ export const make = Effect.gen(function* () { return; } - // Only a capture reveal fights another process for the foreground, which - // needs Win32 calls that load native modules. Everything else stays native. const captureReveal = platform === "win32" && captureRevealWindows.delete(window.id); const shellHostedForeground = captureReveal && (await isWindowsShellHostedForeground().catch(() => false)); diff --git a/apps/desktop/src/electron/WindowsForeground.ts b/apps/desktop/src/electron/WindowsForeground.ts index b90081d20cde..47ecd7fd71f8 100644 --- a/apps/desktop/src/electron/WindowsForeground.ts +++ b/apps/desktop/src/electron/WindowsForeground.ts @@ -14,7 +14,6 @@ export interface WindowsForegroundApi { | { readonly x: number; readonly y: number; readonly width: number; readonly height: number } | undefined; readonly getProcessImagePath: (processId: number) => string; - /** High bit of `GetAsyncKeyState`: whether the virtual key is currently down. */ readonly isKeyDown: (virtualKey: number) => boolean; readonly attachThreadInput: ( sourceThreadId: number, diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index dd645a3379f6..354acc5a8155 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -154,10 +154,6 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain)); -/** - * Convenience helpers for creating IPC methods - */ - export interface DesktopIpcMethodRegistration< Payload, EncodedPayload, diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts index c6376554f99a..94bbf085a992 100644 --- a/apps/desktop/src/ipc/methods/localEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -12,8 +12,6 @@ import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; -// `relaunch` declares the lifecycle runtime services as requirements even -// though the mocked relaunch never touches them. const layerUnusedLifecycleRuntime = Layer.mergeAll( DesktopShutdown.layer, DesktopState.layer, diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 94c56464f7c4..74c6f85043d7 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -256,11 +256,9 @@ export const clearCache = DesktopIpc.makeIpcMethod({ }); /** - * Partition scope for an (environment, profile) pair. - * * The default profile keeps the bare environment id it used before profiles - * existed, so upgrading does not strand anyone's existing logins in an - * orphaned partition. Incognito derives a non-persistent partition. + * existed, so upgrading does not strand anyone's existing logins in an orphaned + * partition. */ export function resolvePartitionScope( environmentId: string, @@ -322,11 +320,6 @@ export const getPreviewConfig = DesktopIpc.makeIpcMethod({ }), }); -/** - * Registered separately from `methods`: these carry `BrowserImport` in their - * context and their own failure type, so they do not unify with the - * manager-backed handlers the shared loop iterates. - */ export const listBrowserImportSources = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL, payload: Schema.Void, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 33895ffffbd1..6a2bfc7f1773 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -102,21 +102,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ const isPrimary = instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID; const config = yield* instance.currentConfig; const snapshot = yield* instance.snapshot; - // A secondary backend (e.g. a parallel WSL backend) that hasn't produced - // a config yet (mid-registration, before its first start cycle) or that - // is retrying a *transient* preflight failure (WSL VM still booting, a - // not-yet-built linux server entry) is not listening on a port. We - // surface it as a *pending* bootstrap (null endpoints, no token) so the - // renderer can show a "Connecting…" indicator while it retries β€” null - // endpoints keep the renderer from dialing the dead port, avoiding the - // needless /api/auth/bootstrap/bearer error cycles a real endpoint would - // trigger. if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) { - // Skip the primary (same-origin, no "connecting" affordance) and skip a - // secondary whose preflight failed *fatally* (no node, wrong version, - // missing build tools): it has stopped retrying, so an indefinite - // "Connecting…" would be misleading β€” its error is surfaced by the - // WSL-state UI instead. const fatalPreflight = Option.isSome(config) && Option.isSome(config.value.preflightFailure) && @@ -143,11 +129,9 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ runningDistro, httpBaseUrl: httpBaseUrl.href, wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - // A backend launched with the desktop secret accepts whichever token - // the secret derives for the current window, so hand out that one - // rather than the token frozen into its launch config. Every backend - // the desktop launches (primary, staged or mounted WSL runtime) is the - // server build bundled with this desktop, so it understands the secret. + // A backend launched with the desktop secret accepts whichever token the secret derives + // for the current window, so hand out that one rather than the token frozen into its + // launch config. ...(bootstrap.desktopBootstrapSecret ? { bootstrapToken: yield* configuration.currentBootstrapToken } : bootstrap.desktopBootstrapToken @@ -159,10 +143,6 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ }), }); -// Pull the distro selection out of a backend instance id like -// "wsl:ubuntu". Returns null for "wsl:default", which is the sentinel -// for "track the user's WSL default distro" and maps to the -// wslEnv-derived default at picker time. function extractWslDistroFromEnvironmentId(envId: string): string | null { if (!envId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX)) { return null; @@ -196,15 +176,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ if (!settings.localEnvironmentEnabled) { return null; } - // Three picker modes: - // - targetEnvironmentId omitted: default to the primary picker. Keeps - // the historical behavior unchanged for users who never enabled the - // WSL backend, and is what unfamiliar callers should get out of the - // box. - // - targetEnvironmentId starts with "wsl:": route to the WSL picker - // using the distro encoded in the id (or the user's selected - // wslDistro when the id is the "wsl:default" sentinel). - // - anything else (incl. PRIMARY_LOCAL_ENVIRONMENT_ID): primary picker. const targetId = options?.targetEnvironmentId; const wslDistroFromTarget = targetId !== undefined && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX) @@ -381,8 +352,6 @@ export const pasteAsText = DesktopIpc.makeIpcMethod({ }), }); -/** Theme files are a few KB; anything larger returns empty text and lets the - * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; export const pickThemeFiles = DesktopIpc.makeIpcMethod({ @@ -394,9 +363,6 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ const electronWindow = yield* ElectronWindow.ElectronWindow; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // The VS Code extensions directory is the same dotfolder on Windows, - // macOS, and Linux; when it is missing the picker opens wherever the - // platform would by default. const extensionsDir = path.join(yield* HostProcess.HomeDirectory, ".vscode", "extensions"); const defaultPath = yield* fileSystem .exists(extensionsDir) @@ -420,10 +386,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ } const text = yield* fileSystem.readFileString(filePath); return { name, size, text } satisfies PickedThemeFile; - }).pipe( - // An unreadable file degrades to an entry the renderer reports. - Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" })), - ); + }).pipe(Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" }))); }); }), }); diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 37cd992bb641..3c10f04512b4 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -32,8 +32,6 @@ const readWslState: Effect.Effect< available, wslOnly: settings.wslOnly, distros, - // Only the dual-mode secondary records this; a wsl-only failure surfaces via - // a dialog + Windows fallback, so it stays null there. preflightError: settings.wslOnly ? null : Option.getOrNull(preflightError), }; }); @@ -70,9 +68,7 @@ export const setWslBackendEnabled = makeIpcMethod({ return state; } // Reconcile is idempotent and never fails; no need for a swap-style - // rollback when the WSL side has trouble coming up. With both - // backends running side by side, "WSL didn't start" is a transient - // state on one instance β€” the primary stays up either way. + // rollback when the WSL side has trouble coming up. yield* wslBackend.reconcile; return yield* readWslState; }), @@ -90,7 +86,6 @@ export const setWslDistro = makeIpcMethod({ const settings = yield* appSettings.get; // In active wsl-only mode the pool's primary IS the WSL backend, and its // distro is captured when that backend starts, so relaunch to replace it. - // When WSL is disabled, this only stages a preference for the next enable. if (settings.wslBackendEnabled && settings.wslOnly && change.changed) { const state = yield* readWslState; yield* lifecycle.relaunch(`wslDistro=${distro ?? "default"}`); diff --git a/apps/desktop/src/legacyLocalStorageMerge.ts b/apps/desktop/src/legacyLocalStorageMerge.ts index 5a048e2edd18..c63bf7e4668c 100644 --- a/apps/desktop/src/legacyLocalStorageMerge.ts +++ b/apps/desktop/src/legacyLocalStorageMerge.ts @@ -1,10 +1,3 @@ -/** - * Merges V1 profile localStorage items into the V2 profile. Runs in the preload - * before the app reads storage, and never overwrites what V2 already holds: a - * key V2 has written keeps V2's value, except that stashed prompts and unsent - * drafts are merged entry by entry so neither side's are lost. - */ - interface MergeStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -15,7 +8,6 @@ const COMPOSER_DRAFTS_KEY = "t3code:composer-drafts:v1"; const NOT_IMPORTED_KEYS = new Set([ // Identifies one running install to the server; two apps must not share it. "t3.backgroundActivity.clientId", - // Resumes a permission setup V1 left mid-flow; V2 would redirect to it on boot. "t3code:snap-shot-setup-resume:v1", ]); /** Matches MAX_STASH_ENTRIES in apps/web/src/promptStashStore.ts. */ @@ -40,7 +32,6 @@ function parseObject(raw: string): JsonObject | null { } } -/** V2 entries first, then V1 entries V2 does not already have, up to the stash cap. */ function mergePromptStash(current: JsonObject, legacy: JsonObject): JsonObject | null { const currentEntries = isObject(current.state) ? current.state.entries : null; const legacyEntries = isObject(legacy.state) ? legacy.state.entries : null; @@ -55,7 +46,6 @@ function mergePromptStash(current: JsonObject, legacy: JsonObject): JsonObject | }; } -/** Adds V1 drafts for threads V2 has no draft for. Shapes must share a version. */ function mergeComposerDrafts(current: JsonObject, legacy: JsonObject): JsonObject | null { if (current.version !== legacy.version) return null; if (!isObject(current.state) || !isObject(legacy.state)) return null; @@ -86,7 +76,6 @@ function mergeValue(key: string, current: string, legacy: string): string | null return merged === null ? null : JSON.stringify(merged); } -/** Returns false when a write failed, so the caller can retry on a later launch. */ export function mergeLegacyLocalStorage( storage: MergeStorage, legacyItems: Readonly>, @@ -105,7 +94,6 @@ export function mergeLegacyLocalStorage( try { storage.setItem(key, next); } catch { - // Over quota: skip this key rather than abandon the rest. complete = false; } } diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index 5827e38e406f..157175e53332 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -26,7 +26,6 @@ describe("linuxSecretStorage", () => { expect( resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "niri" } }), ).toBe(preference); - // An explicit preference also wins where auto would have stayed out of the way. expect( resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "KDE" } }), ).toBe(preference); @@ -76,9 +75,6 @@ describe("linuxSecretStorage", () => { }); it("overrides KDE sessions identified only by legacy variables", () => { - // Chromium reaches KWallet4 for some of these, such as DESKTOP_SESSION=kde with a version, and - // basic text for the rest. Either way these are the variables a previous session leaves behind, - // so they are treated as unproven and forced to a real keyring rather than a guessed wallet. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "plasma" })).toBe("gnome-libsecret"); for (const session of [ "kde", @@ -107,7 +103,6 @@ describe("linuxSecretStorage", () => { expect( autoSwitch({ XDG_CURRENT_DESKTOP: "niri", DESKTOP_SESSION: "gnome", GDMSESSION: "gnome" }), ).toBe("gnome-libsecret"); - // A previous KDE session left these behind; the compositor running now is not KDE. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", KDE_SESSION_VERSION: "6" })).toBe( "gnome-libsecret", ); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index 3aa7a440d1e8..81f891985b9d 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -9,8 +9,7 @@ export type LinuxPasswordStoreSwitch = Exclude { const send = (action: "drag" | "finder" | "close") => ipcRenderer.send(MAC_PERMISSION_HELPER_CHANNEL, action); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index fd34e8d3469e..3056ce1ea263 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -103,9 +103,6 @@ const layerDesktopEnvironment = Layer.unwrap( }), ); -// The remote runs the exact release this app is on, from its self-contained -// archive, so it needs neither Node nor npm. Development points the remote at -// a source checkout instead so the two sides can be iterated together. const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], ): RemoteT3RunnerOptions => { @@ -187,11 +184,6 @@ const layerDesktopAppActivation = DesktopAppActivation.layer.pipe( Layer.provide(layerDesktopWindow), ); -// Pool layer instantiates the backend factory once for the Windows -// primary instance and exposes it via pool.primary. Consumers go through -// the pool now; the legacy DesktopBackendManager service is gone. The -// WSL second instance gets registered later in the migration. See -// DesktopBackendPool.ts header for the full rollout plan. const layerDesktopBackend = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), @@ -202,9 +194,6 @@ const layerDesktopBackend = DesktopBackendPool.layer.pipe( Layer.provideMerge(layerDesktopWindow), ); -// WSL orchestrator hangs off the backend layer because it needs the -// pool + configuration + serverExposure; it pulls NetService and the -// foundation services through the same provideMerge chain. const layerDesktopWslBackend = DesktopWslBackend.layer.pipe( Layer.provideMerge(layerDesktopBackend), ); diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index 3f395c81ffc4..dfc378cfd1c4 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -18,7 +18,6 @@ const permissionGranted = (permission: MacPermission) => { return false; }; -/** Resolve the outer app bundle, never the executable or the ASAR inside it. */ export function macAppBundlePath(executable: string): string | undefined { return /^(.+\.app)\/Contents\/MacOS\/[^/]+$/.exec(executable)?.[1]; } @@ -65,7 +64,6 @@ img { width: 32px; height: 32px; pointer-events: none; } `; } -/** Owns one temporary panel and its IPC listener. Closing it releases all resources. */ export class MacPermissionHelper { private generation = 0; private window: Electron.BrowserWindow | undefined; @@ -130,7 +128,6 @@ export class MacPermissionHelper { try { if ((await isGranted()) && !window.isDestroyed()) finish(); } catch { - // An unavailable probe is not evidence of a grant; the wizard can retry. } finally { checking = false; } diff --git a/apps/desktop/src/permissions/MacSettingsWindow.ts b/apps/desktop/src/permissions/MacSettingsWindow.ts index 02d8180e0785..f529d82b5941 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.ts @@ -56,7 +56,6 @@ function run() { } }`; -/** Place the panel inside Settings' content column, above its bottom edge. */ export function settingsHelperBounds(settings: NonNullable): Electron.Rectangle { const sidebarWidth = 216; const inset = 16; @@ -69,7 +68,6 @@ export function settingsHelperBounds(settings: NonNullable): Ele }; } -/** Track only metadata; this does not request Accessibility or Screen Recording. */ export function watchMacSettingsWindow( onChange: (window: SettingsWindow) => void, onUnavailable: () => void, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index efd49ffa3864..2e046b7f238c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -40,9 +40,7 @@ try { void ipcRenderer.invoke(IpcChannels.COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL); } } -} catch { - // Best effort: the app still starts on the V2 profile's own storage. -} +} catch {} // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. const clientPlatform = process.platform; diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index edd4e981dee5..22e384834789 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -26,10 +26,6 @@ const cookie = { sameSite: "lax" as const, }; -/** - * Dies if the import reaches session work: every case here covers a request - * that must be rejected before a cookie is read or written. - */ const layerRejectedBeforeSession = Layer.succeed( BrowserSession.BrowserSession, BrowserSession.BrowserSession.of({ @@ -41,10 +37,6 @@ const layerRejectedBeforeSession = Layer.succeed( }), ); -/** - * Builds the service against a scratch home containing an installed, closed - * copy of the source browser. - */ const withImporter = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); @@ -80,8 +72,6 @@ describe("BrowserImport.importCookies", () => { const fileSystem = yield* FileSystem.FileSystem; const { importer, home } = yield* withImporter(); - // A cookie database reachable on disk but outside the browser's - // user-data directory β€” the payoff a traversal would be after. yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true }); yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db"); @@ -151,7 +141,6 @@ describe("BrowserImport.writeCookies", () => { skipped: 1, skippedDomains: ["rejected.example"], }); - // Nothing landed, so there is nothing to persist. assert.equal(flushes, 0); }), ); @@ -175,8 +164,6 @@ describe("BrowserImport.writeCookies", () => { { cookies: [cookie, cookie], undecryptable: 0, undecryptableHosts: [] }, ); - // One flush after every write, not one per cookie; the cookies are in - // the session either way, so a failed flush is not a failed import. assert.deepEqual(events, ["set", "set", "flush"]); assert.deepEqual(result, { imported: 2, skipped: 0, skippedDomains: [] }); }), diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 69fccc5b761d..e7f97eca26c0 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -1,9 +1,3 @@ -/** - * Browser import service - lists importable sources and writes their cookies - * into a T3 Code browser profile's Electron partition. - * - * @module BrowserImport - */ import type { BrowserImportInput, BrowserImportResult, @@ -44,7 +38,6 @@ export class BrowserImportFailedError extends Schema.TaggedError>; readonly importCookies: (input: { readonly input: BrowserImportInput; - /** Partition scope of the target profile, derived by the caller in main. */ readonly scope: string; readonly persistent: boolean; readonly namespace?: BrowserSession.BrowserSessionPartitionNamespace; @@ -93,11 +85,8 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; if (yield* isSourceRunning(definition, context)) return "browserRunning"; - // Safari's jar is found by `stat`, which TCC permits without Full Disk - // Access β€” so a Safari that lists as ready may still refuse the read. Probe - // the grant here, so the wizard can open on the permission step and a - // post-grant recheck can tell granted from still-denied, rather than only - // discovering it by attempting the import. + // Safari's jar is found by `stat`, which TCC permits without Full Disk Access + // β€” so a Safari that lists as ready may still refuse the read. if (definition.engine === "safari") { const jar = yield* resolveCookieDatabase(definition, context, "."); if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess"; @@ -105,7 +94,6 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* return undefined; }); -/** The host a constructed cookie URL points at, for naming what was skipped. */ const cookieHost = (url: string): string => { try { return new URL(url).hostname; @@ -152,9 +140,7 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( } // `set` resolves once the cookie is in memory; Chromium writes the store to // disk on its own schedule. Flush before reporting "Done", so a crash right - // after does not lose what the user was just told was imported. A failed - // flush is logged rather than surfaced: the cookies are still in the - // session and land on disk at the next scheduled write. + // after does not lose what the user was just told was imported. if (imported > 0) { yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( Effect.tapError((error) => @@ -171,8 +157,6 @@ export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; const platform = yield* HostProcess.Platform; const executablePath = yield* HostProcess.ExecutablePath; - // Captured here so the service's methods stay free of a requirements - // channel: the layer is built where NodeServices is already in scope. const platformServices = yield* Effect.context< FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner >(); @@ -185,8 +169,6 @@ export const make = Effect.gen(function* BrowserImportMake() { return { id: definition.id, name: definition.name, - // Listing profiles touches the source's own files, so skip it when the - // source is unusable anyway. profiles: unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [], ...(unavailable === undefined ? {} : { unavailable }), @@ -252,16 +234,9 @@ export const make = Effect.gen(function* BrowserImportMake() { requestedProfile.directory, ).pipe(Effect.provide(platformServices)); if (databasePath === undefined) { - // A profile we listed moments ago can lose its database before the - // import runs (browser data cleanup, a profile reset). That is a read - // failure, not a platform problem. return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }); } - // Both branches fail with a tagged error, so the union stays structurally - // identifiable and each tag is handled on its own below. The success side - // is normalized to one shape too, so the skipped tally survives either - // engine β€” Firefox stores plaintext, so nothing there is ever unreadable. const userDataDirectory = definition.userDataDirectory(pathContext); const read: Effect.Effect< CookieReadResult, @@ -297,15 +272,10 @@ export const make = Effect.gen(function* BrowserImportMake() { Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), ), - // Firefox has one failure mode β€” its plaintext database would not open - // β€” so its error carries no reason of its own and the user-facing one - // is supplied here. FirefoxCookieReadError: (cause) => Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), ), - // Safari's reasons are already user-facing: a TCC refusal is the Full - // Disk Access prompt, anything else is a read failure. SafariCookieReadError: (cause) => Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 0cd921267ac3..eace39003e39 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -140,7 +140,6 @@ describe("readChromiumCookieDatabase", () => { expect(partial.cookies.map((cookie) => cookie.value)).toEqual(["kept"]); expect(partial.undecryptable).toBe(1); - // A partitioned-only jar does not need its key: it is skipped separately. yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`delete from cookies where name = 'readable'`; @@ -322,8 +321,6 @@ describe("readChromiumCookieDatabase", () => { ('ev11.example', 'empty-v11', '', ${encryptChromium("v11", "empty v11 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - // The records' own keys fail, and the empty key recovers both β€” the - // retry Chromium itself performs. const recovered = yield* readChromiumCookieDatabase( filename, { cbcV10, cbcV11, cbcEmpty }, @@ -335,8 +332,6 @@ describe("readChromiumCookieDatabase", () => { ]); expect(recovered.undecryptable).toBe(0); - // Matching Chromium: a record whose own key is missing entirely is not - // retried with the empty key. const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux"); expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]); expect(noV11.undecryptableHosts).toEqual(["ev11.example"]); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index d524bf0938fc..d5f7592b2606 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -1,18 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the // OSCrypt primitives Chromium uses; Effect has no equivalent. -/** - * Chromium cookie extraction. - * - * Reads a Chromium-family browser's cookie database and decrypts each record - * with the key its prefix calls for. Key acquisition β€” and the consent it - * needs β€” lives in `ChromiumKeys`. - * - * Records whose scheme we hold no key for are skipped rather than failing the - * whole import: a Linux database can mix `v10` and `v11`. A partial result - * reported honestly is more useful than an all-or-nothing error. - * - * @module ChromiumCookies - */ import * as NodeCrypto from "node:crypto"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -45,12 +32,7 @@ const AES_GCM_NONCE_LENGTH = 12; const AES_GCM_TAG_LENGTH = 16; const isChromiumKeyError = Schema.is(ChromiumKeyError); -/** - * Every way the read can fail: the key failures, plus the ones this module - * raises itself. - */ export const ChromiumCookieReadReason = Schema.Literals([ - // `readFailed` already comes from the key failures, so it is not repeated. ...ChromiumKeyFailure.literals, "browserRunning", ]); @@ -60,13 +42,7 @@ export class ChromiumCookieReadError extends Schema.TaggedError { if (value === 0) return "no_restriction"; @@ -113,10 +87,9 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { }; /** - * Chromium timestamps count microseconds from 1601-01-01; Electron wants - * seconds from the UNIX epoch. The microsecond value overflows JavaScript's - * safe integer range and `node:sqlite` refuses to narrow it, so the division - * happens in SQL and this only ever sees seconds. + * The microsecond value overflows JavaScript's safe integer range and + * `node:sqlite` refuses to narrow it, so the division happens in SQL and this + * only ever sees seconds. */ const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; const toUnixSeconds = (webkitSeconds: number): number | undefined => { @@ -126,7 +99,7 @@ const toUnixSeconds = (webkitSeconds: number): number | undefined => { /** * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding - * a cookie to its domain. Strip it when present. + * a cookie to its domain. */ const stripDomainBinding = ( plaintext: Buffer, @@ -176,11 +149,6 @@ const decryptGcm = ( } }; -/** - * Decrypts one stored value, choosing the scheme from its prefix. Returns null - * when no key covers that scheme β€” including Windows' app-bound `v20`, which - * this build has no key for at all. - */ export function decryptChromiumValue( encrypted: Uint8Array, keys: ChromiumKeyMaterial, @@ -203,8 +171,7 @@ export function decryptChromiumValue( // Chromium retries a failed record with a key derived from an empty // passphrase, because some Linux clients wrote data that way - // (crbug.com/1195256). A record whose own key is missing entirely stays - // skipped, matching Chromium. + // (crbug.com/1195256). if (prefix === "v10") { if (!keys.cbcV10) return null; return ( @@ -219,18 +186,12 @@ export function decryptChromiumValue( (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) ); } - // No recognised prefix: Chromium on macOS and Linux both treat this as - // legacy data stored in the clear and return it as-is, so it is a readable - // cookie rather than an undecryptable one. Windows is the exception β€” its - // app-bound `v20` blobs also lack these prefixes and must not be read as - // plaintext β€” but Windows Chromium is not importable here at all. if (platform === "darwin" || platform === "linux") { return stripDomainBinding(buffer, domain, schemaVersion)?.toString("utf8") ?? null; } return null; } -/** Reads and decodes one snapshotted Chromium cookie database. */ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")( function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) { const result = yield* Effect.gen(function* () { @@ -288,8 +249,6 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu sameSite: sameSiteFromColumn(row.samesite), }); } - // Keep partial imports, but do not call a missing key a successful import - // when it prevented every otherwise importable cookie from being read. if ( cookies.length === 0 && keys.cbcV11Error !== undefined && @@ -315,7 +274,6 @@ export interface ChromiumCookieSource { readonly keychainAccount: string | undefined; readonly linuxSecretApplication: string | undefined; readonly windowsLocalStatePath?: string; - /** Supplied by the caller from `HostProcess.Platform` rather than read here. */ readonly platform: NodeJS.Platform; } diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index ef7c8685b707..0d91ebe5a374 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -1,22 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the // OSCrypt key derivation Chromium uses; Effect has no equivalent. -/** - * Chromium cookie-encryption keys, per platform. - * - * Chromium calls this OSCrypt, and it works differently on each OS: - * - * - **macOS** keeps one key in the login keychain. Reading it prompts the - * user, which is the consent this feature is built around. - * - **Linux** may keep a key in libsecret/kwallet (`v11` records), or use a - * hardcoded `peanuts` passphrase when no keyring is available (`v10`). Both - * can appear in the same database, so both are derived up front and chosen - * per record. - * - * - **Windows** legacy Chromium stores protect a random AES key with DPAPI. - * App-Bound Encryption remains deliberately unsupported. - * - * @module ChromiumKeys - */ import * as NodeCrypto from "node:crypto"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -42,14 +25,12 @@ export const ChromiumKeyFailure = Schema.Literals([ "keychainItemMissing", "keychainUnavailable", "unsupportedPlatform", - /** The key store itself could not be read, as opposed to holding no key. */ "readFailed", ]); export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type; export class ChromiumKeyError extends Schema.TaggedError()("ChromiumKeyError", { reason: ChromiumKeyFailure, - /** Kept for the log; never surfaced to the user. */ cause: Schema.optional(Schema.Defect()), }) { override get message(): string { @@ -57,17 +38,9 @@ export class ChromiumKeyError extends Schema.TaggedError()("Ch } } -/** - * Keys to try, indexed by the record prefix they decrypt. A database can hold - * records written under more than one scheme, so a missing entry means those - * records are skipped rather than the whole import failing. - */ export interface ChromiumKeyMaterial { - /** AES-128-CBC on macOS, and the keyring-free Linux fallback. */ readonly cbcV10?: Buffer; - /** AES-128-CBC, Linux keyring-derived. */ readonly cbcV11?: Buffer; - /** Retained so an import that needs this key can report why it is missing. */ readonly cbcV11Error?: ChromiumKeyError; /** * AES-128-CBC from an empty passphrase. Some Linux clients wrote records @@ -75,7 +48,6 @@ export interface ChromiumKeyMaterial { * after a record's own key fails. */ readonly cbcEmpty?: Buffer; - /** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */ readonly gcmV10?: Buffer; } @@ -83,15 +55,9 @@ const derive = (passphrase: string, iterations: number) => NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); /** - * Reads the macOS OSCrypt secret from the login keychain. - * - * Uses the in-process Keychain API rather than shelling out to - * `/usr/bin/security`, because macOS attributes both the consent prompt and the - * resulting ACL entry to the binary that asks. Via the CLI the prompt says - * "security" and "Always Allow" grants trust to a tool every process on the - * machine can invoke; in-process it names this app and the grant belongs to it. - * (In an unsigned dev build the name is the dev binary, not the shipped app - * identity.) + * Via the CLI the prompt says "security" and "Always Allow" grants trust to a + * tool every process on the machine can invoke; in-process it names this app + * and the grant belongs to it. * * Deliberately untimed: macOS answers this with a modal, and a timeout racing * the user means the prompt can be approved while nothing is left listening, @@ -111,8 +77,6 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function try: () => new Keyring.Entry(service, account).getPassword(), catch: (cause) => { const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); - // Distinguish the causes rather than reporting "approve the prompt" for - // a failure approving cannot fix. const missing = /no (matching )?entry|not found/i.test(message); return new ChromiumKeyError({ reason: missing ? "keychainItemMissing" : "needsKeychainApproval", @@ -127,10 +91,8 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function }); /** - * The bundled helper searches Chromium's libsecret schema and application - * attribute, retaining the desktop's normal unlock prompt. Its exit codes - * distinguish a missing key, denied access, and an unavailable keyring without - * parsing localized error messages. Stdout is the unmodified secret. + * Its exit codes distinguish a missing key, denied access, and an unavailable + * keyring without parsing localized error messages. */ export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( application: string, @@ -215,7 +177,6 @@ export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrap return wrappedBuffer.subarray(DPAPI_PREFIX.length); }); -/** Unwraps a key with the current Windows user's DPAPI identity. */ export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* ( wrapped: Buffer, ) { @@ -273,7 +234,6 @@ export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiK ); }); -/** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */ export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* ( localStatePath: string, ) { @@ -308,8 +268,7 @@ export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys") if (request.platform === "linux") { // The fallback passphrase always applies to `v10` records; a keyring - // secret, when one is reachable, additionally unlocks `v11`. Preserve its - // failure until the reader knows whether any cookies needed that key. + // secret, when one is reachable, additionally unlocks `v11`. const keyringSecret = request.linuxSecretApplication ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( // v10 remains importable when Secret Service is absent or does not diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts index 9742cb68c364..2eb6cdbb5a17 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -1,9 +1,3 @@ -/** - * Shared pieces of cookie extraction: the shape both engines produce, and the - * snapshot every reader takes before touching a live database. - * - * @module CookieDatabase - */ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -11,49 +5,29 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -/** A cookie in the shape Electron's `session.cookies.set` accepts. */ export interface ImportedCookie { readonly url: string; readonly name: string; readonly value: string; /** - * Set only for domain cookies, which the sources mark with a leading dot. - * A host-only cookie leaves this undefined: Electron treats any `domain` it - * is given as marking a domain cookie and re-adds the dot, which would widen - * the cookie to every subdomain of the host it was scoped to, and rejects - * `__Host-` cookies, which require it to be absent. + * A host-only cookie leaves this undefined: Electron treats any `domain` it is given as marking a + * domain cookie and re-adds the dot, which would widen the cookie to every subdomain of the host + * it was scoped to, and rejects `__Host-` cookies, which require it to be absent. */ readonly domain: string | undefined; readonly path: string; readonly secure: boolean; readonly httpOnly: boolean; - /** Seconds since the UNIX epoch, or undefined for a session cookie. */ readonly expirationDate: number | undefined; readonly sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; } -/** - * Cookies recovered from one database and rows that could not be decrypted. - * The skipped count reaches the user instead of disappearing from a partial - * import result. - */ export interface CookieReadResult { readonly cookies: ReadonlyArray; readonly undecryptable: number; - /** Distinct hosts of the rows that could not be decrypted. */ readonly undecryptableHosts: ReadonlyArray; } -/** - * The URL and domain Electron should register a stored row under. - * - * Both engines mark a domain cookie with a leading dot on the host. Electron - * matches on a URL, so the dot comes off for that; `domain` is passed through - * only for domain cookies, because supplying it at all makes Electron treat - * the cookie as one and re-add the dot β€” widening a host-only cookie to every - * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, - * which require it to be absent. - */ export const cookieScope = ( host: string, path: string, @@ -71,18 +45,11 @@ export const cookieScope = ( }; }; -/** A host without the leading dot both engines put on a domain cookie, for display. */ export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); /** - * Creates a transactionally consistent snapshot of a cookie database in a - * temporary directory and returns the snapshot's path. - * - * Both engines keep the file open with WAL while the browser runs, so reading - * in place can observe a torn write. Copying also guarantees we never open the - * browser's own file for writing. - * - * Scoped: the temporary directory goes away when the caller's scope closes. + * Both engines keep the file open with WAL while the browser runs, so reading in place can observe + * a torn write. Copying also guarantees we never open the browser's own file for writing. */ export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( cookiePath: string, diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index 17f9ff9b9867..9f28387d4f14 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -24,7 +24,6 @@ const parseWindowsFirefoxProfiles = (ini: string, root = "C:\\Users\\user\\Firef return parseFirefoxProfiles(ini, path, root); }).pipe(Effect.provide(NodePath.layerWin32)); -/** Builds a `cookies.sqlite` with Firefox's real `moz_cookies` shape. */ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( rows: ReadonlyArray<{ host: string; @@ -130,7 +129,6 @@ describe("readFirefoxCookies", () => { name: "plain", value: "v", path: "/app", - // Firefox writes 0 for a session cookie. expiry: 0, isSecure: 0, isHttpOnly: 0, @@ -142,8 +140,6 @@ describe("readFirefoxCookies", () => { expect(cookies).toEqual([ { - // The leading dot stays on the domain but not in the URL, which is - // what Electron matches against. url: "https://github.com/", name: "session", value: "abc", @@ -158,13 +154,10 @@ describe("readFirefoxCookies", () => { url: "http://example.test/app", name: "plain", value: "v", - // Host-only in Firefox, so no `domain`: supplying one would make - // Electron widen it to every subdomain of example.test. domain: undefined, path: "/app", secure: false, httpOnly: false, - // Session cookies carry no expiry rather than one at the epoch. expirationDate: undefined, sameSite: "no_restriction", }, @@ -336,7 +329,6 @@ describe("readFirefoxCookies", () => { yield* readFirefoxCookies(file); - // The browser's own file is snapshotted, never opened for writing. const after = yield* fileSystem.stat(file); expect(after.mtime).toEqual(before.mtime); expect(after.size).toBe(before.size); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 1c159f682622..16ad488f5cc2 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -1,14 +1,3 @@ -/** - * Firefox cookie extraction. - * - * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key - * to fetch and no consent prompt β€” the file is readable by anything running as - * the user. That is Mozilla's design choice, not a control being circumvented, - * which is why this path works identically on macOS, Windows, and Linux while - * the Chromium one needs a per-platform credential store. - * - * @module FirefoxCookies - */ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -16,25 +5,10 @@ import * as SqlClient from "effect/sql/SqlClient"; import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts"; -/** - * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error - * the service can tell apart, rather than one of them widening the channel to - * an anonymous shape. - * - * No `reason` field: unlike Chromium there is only one way this fails β€” the - * plaintext database would not open β€” and the tag already says which engine it - * was. `BrowserImport` supplies the user-facing reason when it maps the union. - */ export class FirefoxCookieReadError extends Schema.TaggedError()( "FirefoxCookieReadError", { - /** - * Which database the read was for. Firefox keeps one per profile, so - * without it a failure cannot be traced back to the profile that caused - * it. - */ cookieDatabasePath: Schema.String, - /** Always present: every construction site wraps a real failure. */ cause: Schema.Defect(), }, ) { @@ -44,13 +18,11 @@ export class FirefoxCookieReadError extends Schema.TaggedError= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; - // Only the default container. Firefox isolates cookies per container and - // per private window via `originAttributes` (`^userContextId=2`, - // `^privateBrowsingId=1`); Electron has no equivalent, so importing them - // all would collapse several identities onto one host/name/path and hand - // the profile an arbitrary container's session. + // Firefox isolates cookies per container and per private window via + // `originAttributes` (`^userContextId=2`, `^privateBrowsingId=1`); + // Electron has no equivalent, so importing them all would collapse + // several identities onto one host/name/path and hand the profile an + // arbitrary container's session. const raw = hasRawSameSite ? yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite diff --git a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts index b2c2d3a47e6b..b365b15720ad 100644 --- a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts +++ b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.ts @@ -5,7 +5,6 @@ import * as Layer from "effect/Layer"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; -/** Absolute path to the helper shipped with this desktop instance. */ export const LinuxBrowserSecretPath = Context.Reference( "@t3tools/desktop/preview/BrowserImport/LinuxBrowserSecretPath", { defaultValue: () => undefined }, diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 587c045422bb..fa369c8edc62 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -23,11 +23,9 @@ interface FixtureCookie { readonly path: string; readonly value: string; readonly flags: number; - /** Seconds since 2001-01-01, as Safari stores them. */ readonly expiry: number; } -/** Encodes one cookie exactly as Safari lays it out. */ function encodeCookie(cookie: FixtureCookie): Buffer { const strings = [cookie.domain, cookie.name, cookie.path, cookie.value]; const headerSize = 56; @@ -58,7 +56,6 @@ function encodeCookie(cookie: FixtureCookie): Buffer { return buffer; } -/** Builds a single-page `Cookies.binarycookies` file. */ function encodeBinaryCookies(cookies: ReadonlyArray): Buffer { const encoded = cookies.map(encodeCookie); const headerSize = 12 + encoded.length * 4; @@ -90,7 +87,6 @@ describe("parseBinaryCookies", () => { name: "session", path: "/", value: "abc", - // secure | httpOnly flags: 0x1 | 0x4, expiry: 800_000_000, }, @@ -113,17 +109,13 @@ describe("parseBinaryCookies", () => { path: "/", secure: true, httpOnly: true, - // Safari counts from 2001-01-01, Electron from 1970. expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS, - // The format predates SameSite; Lax is the safe modern default. sameSite: "lax", }, { url: "http://example.test/app", name: "plain", value: "v", - // Host-only: no leading dot in the jar, so no `domain` for Electron, - // which would otherwise re-add the dot and widen it to subdomains. domain: undefined, path: "/app", secure: false, @@ -158,15 +150,12 @@ describe("parseBinaryCookies", () => { }); it("reads cookies spread across multiple pages", () => { - // Safari pages its cookie file, and a single-page reader would silently - // return only the first slice. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); const second = encodeBinaryCookies([ { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, ]); - // Splice the two single-page files into one two-page file. const firstPage = first.subarray(12); const secondPage = second.subarray(12); const header = Buffer.alloc(16); @@ -183,7 +172,6 @@ describe("parseBinaryCookies", () => { it("rejects a page that runs past the end of the file", () => { // `Buffer.subarray` clamps rather than throwing, so an overlong first page // swallows the second one's bytes and advances the cursor past the end. - // Every cookie after the boundary then vanishes from a "successful" import. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); @@ -195,7 +183,6 @@ describe("parseBinaryCookies", () => { const header = Buffer.alloc(16); header.write("cook", 0, "latin1"); header.writeUInt32BE(2, 4); - // Declares more bytes for page one than the file holds in total. header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8); header.writeUInt32BE(secondPage.length, 12); @@ -208,8 +195,6 @@ describe("parseBinaryCookies", () => { const valid = encodeBinaryCookies([ { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, ]); - // The record's own length is what bounds its string offsets; an inflated - // one lets them read the following record's bytes as this cookie's value. const pageStart = 8 + 4; const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); const corrupt = Buffer.from(valid); @@ -242,18 +227,14 @@ describe("parseBinaryCookies", () => { const pageStart = 8 + 4; const firstRecord = valid.readUInt32LE(pageStart + 8); - // Pointing the second offset at the page's offset table would let those - // table bytes parse as a fabricated record. const intoTable = Buffer.from(valid); intoTable.writeUInt32LE(4, pageStart + 12); expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError); - // Pointing it back at the first record makes the same bytes count twice. const overlapping = Buffer.from(valid); overlapping.writeUInt32LE(firstRecord, pageStart + 12); expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError); - // And a well-formed two-record page still parses. expect(parseBinaryCookies(valid)).toHaveLength(2); }); @@ -287,9 +268,6 @@ describe("parseBinaryCookies", () => { }); it("rejects a jar whose page table stops short of its contents", () => { - // A second, undeclared page after the first would be silently dropped β€” - // the cookies it holds vanish from the import with no error β€” so a file - // the header does not fully describe is refused instead. const first = encodeBinaryCookies([ { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, ]); @@ -300,7 +278,6 @@ describe("parseBinaryCookies", () => { expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow( SafariCookieReadError, ); - // A trailer that claims a property list it doesn't contain is refused too. const badLength = Buffer.alloc(4); badLength.writeUInt32BE(99, 0); expect(() => @@ -335,9 +312,7 @@ describe("readSafariCookies", () => { Effect.gen(function* () { // What Full Disk Access actually looks like: the file is there, the read // is refused with EPERM. Effect tags that `Unknown`, not - // `PermissionDenied`, so the reader has to look at the errno. Reporting - // it as a generic failure would send the user looking for a missing - // browser instead of a checkbox. + // `PermissionDenied`, so the reader has to look at the errno. const denied = PlatformError.systemError({ _tag: "Unknown", module: "FileSystem", @@ -361,8 +336,6 @@ describe("readSafariCookies", () => { const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); const jar = `${directory}/Cookies.binarycookies`; yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); - // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix - // it, so it must not be routed to that grant. yield* fileSystem.chmod(jar, 0o000); const error = yield* readSafariCookies(jar).pipe(Effect.flip); @@ -396,8 +369,6 @@ describe("safariAccessDenied", () => { it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () => Effect.gen(function* () { - // `stat` finds the jar without the grant, so only an open tells the - // listing whether the import would actually be allowed. assert.isTrue( yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe( Effect.provide(denied(eperm)), @@ -413,28 +384,20 @@ describe("safariAccessDenied", () => { const jar = `${directory}/Cookies.binarycookies`; yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); assert.isFalse(yield* safariAccessDenied(jar)); - // Missing entirely is "not installed", not "denied". assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); describe("isPermissionDenied", () => { - // Shapes taken from a real `FileSystem.readFile` failure on macOS β€” verified - // against Safari's TCC-protected jar, whose denial is EPERM, tagged - // `Unknown` rather than `PermissionDenied`. const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; it("treats a TCC EPERM denial as permission denied", () => { - // The regression: EPERM is tagged `Unknown`, so checking the tag alone - // reported Safari's Full Disk Access refusal as a generic read failure. expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); }); it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => { - // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk - // Access, so it stays a plain read failure; only TCC's EPERM routes there. expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false); }); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index 36d0417c79e8..15f8de8d3a36 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -1,24 +1,3 @@ -/** - * Safari cookie extraction. - * - * Safari does not encrypt its cookies; it stores them in a proprietary - * `Cookies.binarycookies` file inside its app container. The protection is - * TCC, not cryptography β€” the file lives under a path only apps with Full Disk - * Access may read, so the gate is a permission the user grants in System - * Settings rather than a key to obtain. - * - * The format, big-endian throughout except the page bodies: - * - * magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page: - * u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount], - * then each cookie: - * u32le size, u32le unknown, u32le flags, u32le unknown, - * u32le urlOffset, nameOffset, pathOffset, valueOffset, - * u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated - * strings at the offsets above (relative to the cookie start). - * - * @module SafariCookies - */ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; @@ -31,7 +10,6 @@ const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; /** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ const COOKIE_PAGE_HEADER_SIZE = 12; -/** Through the `f64 creation` field; string bytes follow. */ const COOKIE_RECORD_HEADER_SIZE = 56; const FLAG_SECURE = 0x1; @@ -44,12 +22,7 @@ export class SafariCookieReadError extends Schema.TaggedError buffer.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } @@ -112,8 +83,6 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray page.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } - // Bounded by the record's own length so a string offset cannot run past - // it into the following record's bytes. const recordSize = page.readUInt32LE(cookieStart); const cookieEnd = cookieStart + recordSize; if ( @@ -133,8 +102,6 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length, @@ -173,13 +140,8 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { // TCC denies with EPERM, which Effect tags `Unknown` rather than // `PermissionDenied` β€” so the errno is what identifies it. EACCES (and the @@ -210,12 +164,10 @@ export const isPermissionDenied = (error: PlatformError.PlatformError): boolean }; /** - * Whether reading the jar is refused by TCC. `stat` succeeds on the jar - * inside Safari's container even without Full Disk Access β€” that is what lets - * the listing find it β€” so presence alone cannot tell granted from denied. - * Opening it for read is what TCC gates: EPERM means the grant is missing. - * Anything else (including a missing jar) is not a permission answer. - */ + * `stat` succeeds on the jar inside Safari's container even without Full Disk + * Access β€” that is what lets the listing find it β€” so presence alone cannot + * tell granted from denied. + * */ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( @@ -225,7 +177,6 @@ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: strin ); }); -/** A missing or unreadable jar is never evidence that access was granted. */ export const safariAccessGranted = Effect.fnUntraced(function* (cookiePath: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( @@ -241,11 +192,6 @@ export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(fu const fileSystem = yield* FileSystem.FileSystem; const contents = yield* fileSystem.readFile(cookiePath).pipe( Effect.mapError((cause) => { - // TCC denies the read even though the file exists β€” a permission the user - // grants in System Settings rather than a missing browser. macOS never - // prompts for Full Disk Access, so there is no dialog to wait on; the - // read just fails, and it fails with EPERM, which Effect surfaces as an - // `Unknown` system error rather than `PermissionDenied` (that is EACCES). return new SafariCookieReadError({ reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed", cookieDatabasePath: cookiePath, @@ -253,8 +199,6 @@ export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(fu }); }), ); - // The parser throws on a malformed jar; catch it here so callers see a typed - // failure rather than a defect. return yield* Effect.try({ try: () => parseBinaryCookies(Buffer.from(contents)), catch: (cause) => diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts index 1aa72bcc0a0f..1bad2ec2b357 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts @@ -26,6 +26,5 @@ export const safariPermissionCheck = Effect.gen(function* () { } return false; }); - // Open and close the jar without reading cookies or attempting an import. return () => runPromise(check); }); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 51bab02ce939..30bf5a7fd923 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -71,7 +71,6 @@ describe("Windows browser lock errors", () => { }); }); -/** A scratch home with the source's user-data directory already created. */ const withSourceHome = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); @@ -83,7 +82,6 @@ const withSourceHome = Effect.fnUntraced(function* () { return context; }); -/** Every case here runs on darwin, where Helium always resolves a directory. */ const userDataDirectory = (context: BrowserImportPathContext) => { const root = helium.userDataDirectory(context); if (root === undefined) throw new Error("Helium has no macOS user-data directory"); @@ -98,7 +96,6 @@ const run = ( >, ) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); -/** Writes a Chromium-shaped cookie table with `count` rows. */ const writeCookieDatabase = (file: string, count: number) => Effect.sync(() => { const database = new NodeSqlite.DatabaseSync(file); @@ -360,8 +357,6 @@ describe("isSourceInstalled", () => { yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); assert.isTrue(yield* isSourceInstalled(helium, context)); - // A real install whose cookies live outside `Default` still counts: - // reporting it as absent hides the source from the menu entirely. yield* fileSystem.remove(`${root}/Default`, { recursive: true }); yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); @@ -418,8 +413,6 @@ describe("listSourceProfiles", () => { const fileSystem = yield* FileSystem.FileSystem; const paths = yield* withSourceHome(); const root = helium.userDataDirectory(paths); - // A directory named `Cookies` would list as importable and then fail - // the SQLite open, so only a regular file counts as a database. yield* fileSystem.makeDirectory(`${root}/Broken/Cookies`, { recursive: true }); yield* fileSystem.makeDirectory(`${root}/Real`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Real/Cookies`, "db"); @@ -438,8 +431,6 @@ describe("listSourceProfiles", () => { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Assuming `Default` would report a browser whose cookies live in - // `Profile 1` as having nothing to import, and it is then hidden. yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); @@ -463,8 +454,6 @@ describe("listSourceProfiles", () => { assert.deepEqual(yield* listSourceProfiles(helium, context), [ { directory: "Default", name: "You" }, - // Blank display name falls back to the directory rather than - // rendering an empty row. { directory: "Profile 2", name: "Profile 2" }, ]); }), @@ -591,8 +580,6 @@ Path=Profiles/wxyz.empty const fileSystem = yield* FileSystem.FileSystem; const paths = yield* withSourceHome(); const root = helium.userDataDirectory(paths); - // A folder squatting on the preferred candidate path must not shadow - // the real legacy database behind it. yield* fileSystem.makeDirectory(`${root}/Default/Network/Cookies`, { recursive: true }); yield* writeCookieDatabase(`${root}/Default/Cookies`, 2); @@ -640,7 +627,6 @@ describe("cookieDatabaseCandidatePaths", () => { yield* resolveCookieDatabase(helium, context, "Default"), context.path.join(root, "Default", "Network", "Cookies"), ); - // A fresh install with only the Network/ jar is installed, not hidden. yield* fileSystem.remove(`${root}/Default/Cookies`); assert.isTrue(yield* isSourceInstalled(helium, context)); }), @@ -812,8 +798,6 @@ describe("listSourceProfiles Firefox fallback", () => { Effect.provideService(HostProcess.Platform, "darwin"), ); const root = firefox.userDataDirectory(context)!; - // `profiles.ini` names a profile that was never launched (no cookie - // database), while the real cookies sit in an undeclared one. yield* fileSystem.makeDirectory(path.join(root, "Profiles", "stale.default"), { recursive: true, }); @@ -825,7 +809,6 @@ describe("listSourceProfiles Firefox fallback", () => { ["[Profile0]", "Name=Stale", "IsRelative=1", "Path=Profiles/stale.default"].join("\n"), ); - // Returning the empty declared list would hide the browser entirely. assert.deepEqual(yield* listSourceProfiles(firefox, context), [ { directory: path.join("Profiles", "real.default"), @@ -908,8 +891,6 @@ describe("isSourceRunning for Firefox", () => { assert.isFalse(yield* isSourceRunning(firefox, context)); - // Firefox keeps its locks per profile. A root-level lock is not one, - // and looking there was why a running Firefox read as importable. yield* fileSystem.writeFileString(`${root}/lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); @@ -934,14 +915,7 @@ describe("isSourceRunning for Firefox", () => { const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-lock-" }); const lock = `${directory}/.parentlock`; yield* fileSystem.writeFileString(lock, ""); - // A Mac without the developer tools has only Apple's shim, which - // refuses to run the script; a machine with no python at all has - // nothing. Either way the probe is unavailable, not the lock held β€” - // treating it as held would block Firefox import on that machine for - // good. assert.isFalse(yield* posixLockIsHeld(lock, ["/nonexistent/python3"])); - // And a fake "interpreter" that exits non-zero without a verdict, as - // the shim does, is the same case. assert.isFalse(yield* posixLockIsHeld(lock, ["/usr/bin/false"])); }), ), @@ -967,8 +941,6 @@ describe("isSourceRunning for Firefox", () => { const parentLock = `${profile}/.parentlock`; yield* fileSystem.writeFileString(parentLock, ""); - // Hold the lock from a child the way Firefox does (F_SETLK, write), - // and keep it until the scope closes. const holder = yield* spawner.spawn( ChildProcess.make( "python3", @@ -984,7 +956,6 @@ describe("isSourceRunning for Firefox", () => { { stdin: "ignore" }, ), ); - // Wait for the child to confirm it holds the lock before probing. yield* holder.stdout.pipe( Stream.decodeText(), Stream.splitLines, @@ -1010,13 +981,9 @@ describe("isSourceRunning for Firefox", () => { assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); - // A crash leaves the symlink behind with a dead pid, on any local address. assert.isFalse(yield* firefoxSymlinkLockIsHeld("127.0.0.1:+9999", local, alive)); assert.isFalse(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+9999", local, alive)); - // Anything unparseable stays conservative. assert.isTrue(yield* firefoxSymlinkLockIsHeld("garbage", local, alive)); - // A foreign owner (a shared profile locked from another machine) names - // a pid we cannot probe, so it is held regardless of local liveness. assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive)); }), ); @@ -1041,8 +1008,7 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); // On Windows, Firefox creates parent.lock as a regular file that - // persists after the process exits. The file is only locked while - // Firefox is running; the old stat-based check always found it. + // persists after the process exits. yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); }), @@ -1053,9 +1019,6 @@ describe("isSourceRunning for Firefox", () => { describe("Windows user-data directories", () => { it.effect("keeps app-bound Chromium forks unsupported on win32", () => Effect.sync(() => { - // Helium retains the older DPAPI-backed store. Other Chromium forks use - // App-Bound Encryption, so omitting win32 makes `unavailableReason` - // report `unsupportedPlatform` and keeps them out of the menu. for (const source of BROWSER_IMPORT_SOURCES) { if (source.engine === "chromium" && source.id !== "helium") { assert.notInclude(source.platforms, "win32"); @@ -1071,9 +1034,6 @@ describe("listSourceProfiles hardening", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); - // `Local State` is writable by anything running as the user, so a - // crafted key must not reach `cookieDatabasePath` and read a database - // outside the browser's user-data directory. yield* fileSystem.writeFileString( `${userDataDirectory(context)}/Local State`, `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`, diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 86baffbf1b2f..59a89eadfa73 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -1,20 +1,3 @@ -/** - * Importable browser sources. - * - * Chromium-family browsers keep cookies in an - * encrypted SQLite database whose key lives in an OS credential store; Firefox - * keeps them in plain SQLite with no key at all, so it needs no keychain and - * works the same on every platform. Safari uses binary cookie files, with - * separate WebKit data stores for named profiles. - * - * Each entry pins its own paths and credential-store coordinates rather than - * deriving them, because the forks do not agree. macOS uses service/account - * pairs, while Linux Chromium uses a custom libsecret schema keyed by an - * `application` attribute. The user-data directory also differs per fork and - * per platform. - * - * @module BrowserImportSources - */ import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -29,18 +12,11 @@ import * as SqlClient from "effect/sql/SqlClient"; export type BrowserImportEngine = "chromium" | "firefox" | "safari"; -/** - * Directory roots a definition builds its paths from. Passed in rather than - * read from `process`, so source resolution stays testable for platforms the - * host is not currently running. - */ export interface BrowserImportPathContext { readonly path: Path.Path; readonly platform: NodeJS.Platform; readonly home: string; - /** `%APPDATA%` on Windows; unused elsewhere. */ readonly appData: string | undefined; - /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */ readonly localAppData: string | undefined; } @@ -48,13 +24,10 @@ export interface BrowserImportSourceDefinition { readonly id: BrowserImportSourceId; readonly name: string; readonly engine: BrowserImportEngine; - /** Platforms the definition has paths for. */ readonly platforms: ReadonlyArray; readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; - /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ readonly keychainService?: string; readonly keychainAccount?: string; - /** Chromium's `application` attribute in the Linux libsecret schema. */ readonly linuxSecretApplication?: string; } @@ -63,11 +36,6 @@ const macApplicationSupport = ( ...segments: ReadonlyArray ) => context.path.join(context.home, "Library", "Application Support", ...segments); -/** - * One Chromium fork. The leaves differ per fork; omitting a platform's - * segments marks the fork as unavailable there. Most Windows Chromium builds - * use App-Bound Encryption, but forks can retain the older DPAPI-backed store. - */ const chromiumSource = (input: { readonly id: BrowserImportSourceId; readonly name: string; @@ -107,8 +75,7 @@ const chromiumSource = (input: { export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ // No Chromium fork is importable on Windows: since Chrome 127 their cookies // are encrypted to the browser's own identity (App-Bound Encryption), so no - // other process can read them. macOS and Linux keep working, so only the - // Windows segments are omitted. + // other process can read them. chromiumSource({ id: "chrome", name: "Chrome", @@ -154,7 +121,6 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray directory.length > 0 && directory !== "." && @@ -365,11 +309,8 @@ const CookieCountRow = Schema.Struct({ count: Schema.Number }); const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); /** - * How many importable cookies a profile holds, counted without decrypting - * anything. Firefox containers use identities Electron cannot represent, so - * its count uses the same default-container predicate as the reader. Best - * effort: a locked, missing or unexpected database yields `undefined` rather - * than failing the listing. + * Firefox containers use identities Electron cannot represent, so its count + * uses the same default-container predicate as the reader. */ const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, @@ -449,7 +390,6 @@ const listSafariProfiles = Effect.fnUntraced(function* ( name: profile.title?.trim() || profile.external_uuid, }); } - // If Safari's metadata is unavailable, recover stores that have cookies. // With readable metadata, avoid resurrecting deleted profiles left on disk. if (declared.length === 0) { const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => [])); @@ -463,15 +403,6 @@ const listSafariProfiles = Effect.fnUntraced(function* ( return profiles; }); -/** - * Profiles the source browser knows about. - * - * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When - * that metadata is missing, unreadable or malformed, the directories that - * actually hold a cookie database are scanned instead. Assuming a single - * `Default` would report a browser whose cookies live in `Profile 1` as having - * nothing to import β€” and it is then left out of the menu entirely. - */ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -490,12 +421,11 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( Effect.orElseSucceed(() => [] as ReadonlyArray), ); // `profiles.ini` also lists profiles the installer created but the user - // never launched, which hold no cookie database and nothing to import. - // Only keep the ones a database proves exist, like the directory scans - // below do. When none of the declared profiles has one, fall through to - // the scan rather than returning empty: `profiles.ini` can list stale or - // never-launched profiles while the cookies live in one it does not - // mention, and an empty answer here hides the browser entirely. + // never launched, which hold no cookie database and nothing to import. When + // none of the declared profiles has one, fall through to the scan rather + // than returning empty: `profiles.ini` can list stale or never-launched + // profiles while the cookies live in one it does not mention, and an empty + // answer here hides the browser entirely. if (declared.length > 0) { const found = yield* Effect.forEach(declared, (profile) => Effect.forEach( @@ -509,9 +439,6 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( } } - // No usable `profiles.ini`, so fall back to scanning the directory the - // profiles actually live in, keeping only the ones a cookie database - // proves were launched. const fallbackDirectory = context.platform === "linux" ? root : context.path.join(root, "Profiles"); const scanned = yield* fileSystem @@ -546,8 +473,6 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( ); if (declared.length > 0) return yield* withCookieCounts(definition, context, declared); - // `Local State` is missing, unreadable or malformed. Scanning for directories - // that hold a cookie database finds the profiles anyway. const entries = yield* fileSystem .readDirectory(root) .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); @@ -600,11 +525,6 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf return [...profiles.values()]; }); -/** - * Whether a cookie database candidate is a regular file. Presence alone is - * not enough: a directory at the path would list as an importable profile and - * then fail the SQLite open, so anything but a file is treated as absent. - */ const databaseFileExists = Effect.fnUntraced(function* (path: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.stat(path).pipe( @@ -621,7 +541,6 @@ export const chromiumProcessIsAlive = ( ) => Effect.sync(() => { try { - // Signal 0 performs a read-only existence/permission check. signalProcess(pid, 0); return true; } catch (cause) { @@ -639,7 +558,6 @@ export const chromiumProcessIsAlive = ( const processIsAlive: ProcessLivenessProbe = (pid) => chromiumProcessIsAlive(pid); -/** Whether a Chromium `-` lock target may still name its owner. */ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( target: string, currentHost: string, @@ -703,11 +621,6 @@ export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( return held.some(Boolean); }); -/** - * Whether a Firefox `lock` symlink's `:[+]` target still names a - * live owner. Firefox writes this symlink beside the profile while it runs and - * unlinks it on a clean exit, so a dangling one is either live or a crash. - */ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( target: string, localAddresses: ReadonlySet, @@ -718,9 +631,6 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( // The owner half is whatever Firefox's resolver returned for the machine's // hostname β€” 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN // address β€” so a pid is only meaningful when that address is one of ours. - // A shared (NFS) profile locked from another machine names a foreign - // address whose pid cannot be probed here, nor could a reused local pid - // vouch for it, so it stays conservatively held. const owner = target.slice(0, separator); if (!localAddresses.has(owner)) return true; // A `+` marks an fcntl-holding owner; the pid follows either way. @@ -732,18 +642,16 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( }); /** - * Interpreters that can run the fcntl probe, tried in order. `/usr/bin/python3` - * is named absolutely first so a Dock-launched app with launchd's bare `PATH` - * still finds it without depending on the login-shell PATH merge; Linux - * distributions carry python3 on the default path. + * `/usr/bin/python3` is named absolutely first so a Dock-launched app with + * launchd's bare `PATH` still finds it without depending on the login-shell + * PATH merge; Linux distributions carry python3 on the default path. */ const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; /** - * The probe prints exactly one of these. Anything else means the script never - * ran β€” most importantly Apple's `/usr/bin/python3` shim, which on a Mac - * without the Command Line Tools exits non-zero after printing an install - * prompt, without ever reaching our code. + * Anything else means the script never ran β€” most importantly Apple's + * `/usr/bin/python3` shim, which on a Mac without the Command Line Tools exits + * non-zero after printing an install prompt, without ever reaching our code. */ const FCNTL_PROBE_SCRIPT = "import fcntl,os,sys\n" + @@ -756,21 +664,12 @@ const FCNTL_PROBE_SCRIPT = " print('free')"; /** - * Whether another process holds an fcntl write lock on `path`. - * - * Firefox's `.parentlock` is an empty file whose only signal is the kernel - * lock, and Node exposes no fcntl, so a throwaway interpreter tries a - * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never - * acquired for real: on success the child exits and the kernel drops it. - * - * The answer is trusted only when the script itself spoke. A verdict of - * `held` or `free` on stdout is the probe's own, and stands. Anything else β€” - * no interpreter on any candidate path, or one that refused to run the script - * (Apple's shim without the developer tools) β€” is the probe being unavailable, - * not evidence about the lock. That case falls back to "not held" rather than - * "held": reporting every profile as locked forever would block Firefox import - * outright on such machines, and the SQLite snapshot already copes with a - * live database's WAL, as it does for every other engine. + * Anything else β€” no interpreter on any candidate path, or one that refused to + * run the script (Apple's shim without the developer tools) β€” is the probe + * being unavailable, not evidence about the lock. That case falls back to "not + * held" rather than "held": reporting every profile as locked forever would + * block Firefox import outright on such machines, and the SQLite snapshot + * already copes with a live database's WAL, as it does for every other engine. */ export const posixLockIsHeld = Effect.fnUntraced(function* ( path: string, @@ -801,32 +700,20 @@ export const posixLockIsHeld = Effect.fnUntraced(function* ( }); /** - * Whether Firefox holds a profile. - * - * Firefox leaves two kinds of lock behind, and they mean different things. - * On Linux the `lock` symlink (target `:+`) is removed on a clean - * exit, so its presence is evidence β€” provided the pid it names is alive. But - * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files - * held with fcntl or a Windows handle and are *deliberately left on disk* - * after exit, as a last-used marker; treating them as proof of a running - * browser blocks every import after Firefox has been used once. On POSIX the - * fcntl lock itself is the truth, and macOS in particular writes nothing else - * (no symlink, no pid), so `.parentlock` is probed for the kernel lock. On - * Windows the held handle denies our open, which `windowsLockIsHeld` reads as `Busy`. + * But `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files + * held with fcntl or a Windows handle and are *deliberately left on disk* after + * exit, as a last-used marker; treating them as proof of a running browser + * blocks every import after Firefox has been used once. */ const firefoxProfileIsHeld = Effect.fnUntraced(function* ( directory: string, context: BrowserImportPathContext, - // Resolved once by the caller: it involves a DNS lookup of the hostname and - // is the same for every profile. localAddresses: ReadonlySet, ) { const fileSystem = yield* FileSystem.FileSystem; if (context.platform === "win32") { return yield* windowsLockIsHeld(context.path.join(directory, "parent.lock")); } - // Linux additionally writes the `lock` symlink; a live pid there settles it - // without spawning anything. const symlinkHeld = yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe( Effect.flatMap((target) => firefoxSymlinkLockIsHeld(target, localAddresses, processIsAlive)), Effect.orElseSucceed(() => false), @@ -841,7 +728,6 @@ const firefoxProfileIsHeld = Effect.fnUntraced(function* ( return yield* posixLockIsHeld(parentLock); }); -/** Whether the browser is running, which leaves its cookie DB mid-write. */ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -853,15 +739,8 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const fileSystem = yield* FileSystem.FileSystem; const root = definition.userDataDirectory(context); if (root === undefined) return false; - // Probe the source's own lock state rather than scanning the process table. // Safari keeps no lock and writes its jar atomically, so a running instance // is not a hazard there. - // - // Chromium exposes its lock through the cookie jar on Windows and through a - // user-data SingletonLock on POSIX. Firefox keeps its locks inside each - // profile under three names across platforms (`lock` on macOS and Linux, - // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's - // at the root finds nothing and reports a running browser as importable. if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { @@ -876,7 +755,6 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") } const profiles = yield* listSourceProfiles(definition, context); - // Only the Linux `lock` symlink names an address, so Windows skips the lookup. const localAddresses: ReadonlySet = context.platform === "win32" ? new Set() : yield* yield* HostProcess.Addresses; const found = yield* Effect.forEach(profiles, (profile) => { @@ -889,14 +767,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") }); /** - * Whether the source has cookies to import. - * * Keyed off the cookie database rather than the user-data directory, because * that directory is not evidence the browser exists: installers for native * messaging hosts create an empty one for every Chromium fork they know about, * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as - * present. The database is the thing an import actually needs, so its absence - * is the honest answer either way. + * present. * * Existence is checked without opening the file, which matters for Safari: TCC * permits `stat` on the jar inside its container but refuses a read, so this diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 9799d88fdc9c..fc4c2dfc843a 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -93,11 +93,9 @@ describe("BrowserSession", () => { const replacementChar = yield* browserSessions.getPartition("p\ufffd"); assert.notStrictEqual(loneSurrogate, replacementChar); - // The escape can't be forged with a literal backslash either. const literal = yield* browserSessions.getPartition("p\\ud800"); assert.notStrictEqual(literal, loneSurrogate); - // And a well-formed scope still lands on its historical partition. assert.strictEqual( yield* browserSessions.getPartition("scope-a"), "persist:t3code-preview-f051bb2c68cb7b2fe969", @@ -109,8 +107,6 @@ describe("BrowserSession", () => { Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; - // These share the same scope string: default environment `a::b`, and - // environment `a` with nondefault profile `b`. const legacyDefault = yield* browserSessions.getPartition("a::b"); const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile"); @@ -123,10 +119,8 @@ describe("BrowserSession", () => { ); // A rewritten session UA β€” any variant, even ones that keep the Electron - // token β€” makes Cloudflare Turnstile loop with error 600010 (#5002), so - // the guest must end up with Electron's native User-Agent. The mock applies - // setUserAgent calls, so this fails on any reintroduced rewrite while still - // permitting a harmless re-set of the unchanged native string. + // token β€” makes Cloudflare Turnstile loop with error 600010 (#5002), so the + // guest must end up with Electron's native User-Agent. it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => Effect.gen(function* () { // Electron's real UA shape: app token, then Chrome, then Electron, then @@ -242,8 +236,6 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - // A prompt without an owner can open behind the app window, leaving the one - // prompt slot taken and every later link silently denied. it.effect("attaches the open-externally prompt to the window hosting the preview", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; @@ -362,10 +354,6 @@ describe("BrowserSession", () => { const browserSessions = yield* BrowserSession.BrowserSession; const partition = yield* browserSessions.getPartition("scope-untouched"); - // Deriving the partition string does not create the session, and the - // clear only walks sessions it already holds. Without loading it first - // this reports success and deletes nothing β€” which is what a user - // clearing a profile after a restart would get. assert.isUndefined(sessions.get(partition)); yield* browserSessions.clearCookies([partition]); assert.isUndefined(sessions.get(partition)); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 10e2a2575f38..9f0b5edb1097 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -23,33 +23,27 @@ const PROFILE_PARTITION_MARKER = "profile-"; export type BrowserSessionPartitionNamespace = "profile"; -// Permissions granted to preview web content. `clipboard-sanitized-write` is the -// Electron permission behind `navigator.clipboard.writeText()` β€” note it is NOT -// `clipboard-write`, which is not a valid Electron permission name. Async -// clipboard writes are gated by the permission *check* handler (not only the -// request handler), so both handlers must allow it; otherwise built-in "Copy" -// buttons β€” e.g. the Next.js / Vercel error overlay β€” fail with -// `Failed to execute 'writeText' on 'Clipboard': Write permission denied`. +// `clipboard-sanitized-write` is the Electron permission behind +// `navigator.clipboard.writeText()` β€” note it is NOT `clipboard-write`, which +// is not a valid Electron permission name. Async clipboard writes are gated by +// the permission *check* handler (not only the request handler), so both +// handlers must allow it; otherwise built-in "Copy" buttons β€” e.g. the Next.js +// / Vercel error overlay β€” fail with `Failed to execute 'writeText' on +// 'Clipboard': Write permission denied`. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-read", "clipboard-sanitized-write", "notifications", "geolocation", - // The Fullscreen API. `DesktopWindow` disables HTML fullscreen window resizing - // for the app window and every preview guest, so a page that goes fullscreen - // fills its own webview instead of taking over the whole T3 window. + // `DesktopWindow` disables HTML fullscreen window resizing for the app window + // and every preview guest, so a page that goes fullscreen fills its own + // webview instead of taking over the whole T3 window. "fullscreen", // Deliberately NOT local-fonts: preview sessions run untrusted web content, // and silently granting it would hand every page the user's installed-font - // fingerprint (and font file bytes via FontData.blob()). The app's own font - // picker runs in the main window session, which is unaffected by this list. + // fingerprint (and font file bytes via FontData.blob()). ]); -/** - * Schemes a preview page may never hand to the OS, even with the user's - * consent: they read local files, run script, or are web pages that belong in - * the preview itself. - */ const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ "about:", "blob:", @@ -64,10 +58,6 @@ const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ "view-source:", ]); -/** - * The URL to offer the user when a preview page navigates to a custom scheme - * such as `slack://` or `zoom://`, or `null` when it must stay denied. - */ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => { if (!rawUrl) return null; try { @@ -167,11 +157,6 @@ export class BrowserSession extends Context.Service< } >()("@t3tools/desktop/preview/BrowserSession") {} -/** - * Restricts a clear to the given partitions. Omitting them keeps the historical - * "every partition" behaviour, which callers now only use for an explicit - * "all profiles" action β€” a per-profile clear must never reach across profiles. - */ const selectSessions = ( sessions: ReadonlyMap, partitions: ReadonlyArray | undefined, @@ -181,14 +166,10 @@ const selectSessions = ( ); /** - * Scope bytes for the partition digest. - * * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` * and `"p\ufffd"` would hash to the same partition and share cookies. Those * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` * first β€” and a literal backslash is doubled so the escape cannot be forged. - * Every well-formed scope passes through byte-for-byte unchanged, which keeps - * existing partitions (and the logins in them) where they are. */ const encodeScopeForDigest = (scope: string): Uint8Array => new TextEncoder().encode( @@ -201,8 +182,8 @@ const encodeScopeForDigest = (scope: string): Uint8Array => ); /** - * The window showing a preview guest. A `` guest has no window of its - * own, so look it up through the page that embeds it. + * A `` guest has no window of its own, so look it up through the page + * that embeds it. */ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => { if (guest === null || guest.isDestroyed()) return undefined; @@ -279,9 +260,6 @@ export const make = Effect.gen(function* BrowserSessionMake() { ), ); const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX; - // Legacy/default partitions are prefix + hex digest. The non-hex profile - // marker creates a disjoint namespace while leaving every legacy default - // partition byte-for-byte unchanged. return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Hex.encode(digest).slice(0, 20)}`; }); @@ -301,8 +279,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { // form β€” even variants that keep the Electron token β€” makes Cloudflare // Turnstile fail its integrity check with error 600010 and recreate // the challenge every few seconds, so logins behind it never complete - // (#5002). Re-setting the unchanged native string is harmless, so it - // is the rewritten string itself that trips the check. + // (#5002). browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { if (permission === "openExternal") { diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index a87d8af85ea0..5932db57a863 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -27,7 +27,6 @@ describe("CDP relay", () => { relay.receive(JSON.stringify({ id: 2, method: "Target.createTarget", params: {} })); await settle(); const attach = written.findIndex((message) => message["id"] === 1); - // The page is announced right after the auto-attach reply, never before it. expect(written.slice(attach, attach + 2)).toEqual([ { id: 1, result: {} }, { @@ -58,7 +57,6 @@ describe("CDP relay", () => { JSON.stringify({ id: 5, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }), ); relay.receive(JSON.stringify({ id: 6, method: "DOM.enable", sessionId: "iframe-session" })); - // A slow screenshot must not hold up the reply behind it. pending[1]!(); await settle(); expect(written).toEqual([ @@ -78,7 +76,6 @@ describe("CDP relay", () => { it("resumes paused child frames, so cross-site iframes run", async () => { const send = vi.fn(async () => ({})); const relay = createCdpRelayConnection(makeTarget(send), () => {}); - // The page's own session is already running; only its children wait. relay.receive( JSON.stringify({ id: 1, @@ -181,7 +178,6 @@ describe("CDP relay downloads", () => { ); await settle(); expect(directories).toEqual(["/srv/artifacts"]); - // The tab's own debugger has no such context, so the id stays here. expect(send).toHaveBeenCalledWith( "Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: "/srv/artifacts", eventsEnabled: true }, diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 62c2410c19b8..581b692882b3 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -1,16 +1,9 @@ /** - * Presents one preview tab's debugger as a minimal CDP browser endpoint, so - * the environment server can drive a desktop `` with the same - * Playwright engine it uses for headless tabs. - * - * Only the tab's own page is reachable. Browser-level commands Playwright sends - * while connecting are answered here, and every page command goes to the tab's - * `webContents.debugger`. Electron allows one debugger client per webContents, - * so the relay shares the attach the preview manager already holds. + * Electron allows one debugger client per webContents, so the relay shares the + * attach the preview manager already holds. */ export interface CdpRelayTarget { - /** Sends a command to the tab's page, or to a child session such as an iframe. */ readonly send: ( method: string, params: Record, @@ -22,17 +15,14 @@ export interface CdpRelayTarget { readonly title: () => string; readonly userAgent: () => string; /** - * Where the server wants this tab's downloads, named by their CDP guid. The - * desktop app and the server it launched share a disk, so the server's + * The desktop app and the server it launched share a disk, so the server's * Playwright reads the file where it asked for it. */ readonly setDownloadDirectory: (directory: string | null) => void; } export interface CdpRelayConnection { - /** Feed one message from the server. */ readonly receive: (raw: string) => void; - /** Feed one debugger event from the tab. */ readonly event: (method: string, params: unknown, sessionId: string | undefined) => void; } @@ -50,12 +40,9 @@ const isCommand = (value: unknown): value is CdpCommand => typeof (value as { method?: unknown }).method === "string"; const BROWSER_CONTEXT_ID = "t3-preview"; -/** Marks an event to send right after a command's reply. */ const ANNOUNCE = Symbol("announce"); -/** The page's root session id, as Playwright sees it. */ const PAGE_SESSION_ID = "t3-preview-page"; -/** Browser-level commands that only need acknowledging for a page the desktop owns. */ const ACKNOWLEDGED = new Set([ "Target.setDiscoverTargets", "Browser.grantPermissions", @@ -95,8 +82,6 @@ export function createCdpRelayConnection( if (ACKNOWLEDGED.has(command.method)) return {}; switch (command.method) { case "Browser.setDownloadBehavior": { - // The tab's debugger reports downloads as Chromium does, so the server - // sees them as its own. Only the directory needs the desktop's help. const directory = command.params?.["downloadPath"]; const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string"; target.setDownloadDirectory(allowed ? directory : null); @@ -143,7 +128,6 @@ export function createCdpRelayConnection( case "Target.setAutoAttach": { if (attached) return {}; attached = true; - // The page is announced once, right after this reply, as Chromium does. const info = await targetInfo(); return { [ANNOUNCE]: { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 81ea040701f0..0d281c46f860 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -16,7 +16,6 @@ const decodeCdpReply = Schema.decodeUnknownSync( ); const key = { threadId: "thread-1", tabId: "tab-1" }; -/** A tab's webContents and debugger, with the debugger's commands left pending until released. */ const makeDebuggee = () => { const emitter = new NodeEvents.EventEmitter(); const pending: Array<() => void> = []; @@ -45,7 +44,6 @@ const makeDebuggee = () => { }; }; -/** Reads `count` events from one backend's subscription. */ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], count: number) => host.events.pipe( Stream.take(count), @@ -58,7 +56,7 @@ describe("DesktopBrowserHost", () => { Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; host.attach(key, makeDebuggee().tab); - // A restarted backend subscribes after the attach and still hears it. + expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]); expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]); }), @@ -70,7 +68,7 @@ describe("DesktopBrowserHost", () => { const debuggee = makeDebuggee(); host.attach(key, debuggee.tab); const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped); - // Wait until the reader has received the announcement. + yield* Effect.yieldNow; const command = (id: number, method: string) => host.handleCommandLine( @@ -86,7 +84,7 @@ describe("DesktopBrowserHost", () => { debuggee.release(); yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); const [, reply] = yield* Fiber.join(reader); - // Only the new connection's reply arrives; the old one's id could collide. + expect(reply).toMatchObject({ type: "cdp" }); expect(decodeCdpReply((reply as { message: string }).message).id).toBe(2); }).pipe(Effect.scoped), @@ -101,7 +99,7 @@ describe("DesktopBrowserHost", () => { const item = { setSavePath: (path: string) => void paths.push(path), } as unknown as Electron.DownloadItem; - // Before the server sets a directory, Electron keeps its own handling. + expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false); yield* host.handleCommandLine( encodeJson({ @@ -125,7 +123,7 @@ describe("DesktopBrowserHost", () => { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); host.attach(key, debuggee.tab); - // Reading the page is not acting on it. + yield* host.handleCommandLine( encodeJson({ type: "cdp", diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 1e5d28e1c540..cef42d4f35fc 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -1,13 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk. /** - * The desktop end of the desktop browser channel (see `DesktopBrowserEvent` in - * contracts). The primary backend gets two file descriptors at spawn: this - * service writes events for the desktop's tabs to one and reads commands from - * the other. Each attached tab is reachable only through its `CdpRelay`. - * - * A tab is attached once its `` registers with a key the web app - * gave it. The preview manager owns the tab's single debugger session and hands - * it here; the relay shares it. + * The preview manager owns the tab's single debugger session and hands it here; + * the relay shares it. */ import { DesktopBrowserCommand, @@ -29,10 +23,8 @@ import { sendPageCommand } from "./GuestInput.ts"; const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent)); const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand)); const lineEncoder = new TextEncoder(); -/** CDP commands through which an agent clicks, types, or navigates the page. */ const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; -/** How long after an agent's input a download still counts as the agent's. */ const AGENT_DOWNLOAD_WINDOW_MS = 5_000; export interface DesktopBrowserTabKey { @@ -40,7 +32,6 @@ export interface DesktopBrowserTabKey { readonly tabId: string; } -/** A tab's debugger, as the preview manager lends it to the relay. */ export interface DesktopBrowserTabDebugger { readonly webContents: Electron.WebContents; readonly debugger: Electron.Debugger; @@ -52,11 +43,8 @@ interface AttachedTab { readonly key: DesktopBrowserTabKey; readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; - /** Where the server wants this tab's downloads; null keeps Electron's own handling. */ downloadDirectory: string | null; - /** The guid CDP gave the download that is about to start. */ pendingDownloadGuid: string | null; - /** When the server last sent this page input, which only an agent does. */ agentInputAt: number; readonly onMessage: ( event: Electron.Event, @@ -69,25 +57,12 @@ interface AttachedTab { export class DesktopBrowserHost extends Context.Service< DesktopBrowserHost, { - /** - * Newline-delimited events for a backend's browser fd. Each run starts by - * announcing the tabs already attached, so a restarted backend hears them. - */ readonly events: Stream.Stream; - /** One line from the backend's browser control fd. */ readonly handleCommandLine: (line: string) => Effect.Effect; - /** Offers a server tab's `` to the server. */ readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; - /** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */ readonly detach: (key: DesktopBrowserTabKey) => void; - /** - * Whether a server tab's download came from the person at this desktop: - * the server sent the page no input just before it started. - */ readonly humanStartedDownload: (source: Electron.WebContents) => boolean; - /** Points a server tab's download at the server; false for any other download. */ readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; - /** The agent's cursor positions for attached tabs, keyed by their server tab. */ readonly pointers: Stream.Stream<{ readonly key: DesktopBrowserTabKey; readonly phase: "move" | "click"; @@ -129,7 +104,6 @@ export const make = Effect.gen(function* () { tab.downloadDirectory = directory; }, }, - // A released relay's late replies belong to a connection that is gone. (message) => { if (tab.relay === relay && tabs.get(keyOf(tab.key)) === tab) { emit({ type: "cdp", ...tab.key, message }); @@ -141,9 +115,8 @@ export const make = Effect.gen(function* () { }; /** - * Saves a download from a server tab where the server's Playwright expects - * it. Without a path Electron would open its Save dialog over the app for a - * file the agent asked for. CDP names the download just before this runs. + * Without a path Electron would open its Save dialog over the app for a file + * the agent asked for. CDP names the download just before this runs. */ const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => { const tab = [...tabs.values()].find( @@ -200,7 +173,6 @@ export const make = Effect.gen(function* () { return; } if (command.value.type === "release") { - // A new server connection starts with a fresh relay and fresh sessions. tab.relay = null; return; } @@ -213,7 +185,6 @@ export const make = Effect.gen(function* () { return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; }; - // Read when a backend starts, not when the host is built. const announceAll = Effect.suspend(() => Effect.forEach( [...tabs.values()], diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts index 7e8ae64ff889..a2b36d182a8f 100644 --- a/apps/desktop/src/preview/FaviconCapture.ts +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -642,8 +642,6 @@ async function rasterizeFavicon( ]); const result = new Promise((resolve, reject) => { - // Electron cannot cancel isolated-world execution. This timeout ends only - // the logical attempt; renderer work may finish after a newer attempt starts. const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS); let settled = false; const finish = (complete: () => void) => { diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 4a3eea297c12..29011a3db7e0 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -13,5 +13,4 @@ export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; -/** Renderer argument that turns on the guest passkey bridge; see Passkeys.ts. */ export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index e6e4329d29c6..c58ce6440a8d 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -409,7 +409,6 @@ const makeTestPreviewWebContents = ( } as unknown as TestPreviewWebContents; }; -/** Two ready tabs (41, 42) sharing one window, so they contend for the single display-media slot. */ const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => Effect.gen(function* () { const capturePage = vi.fn(async () => ({ @@ -826,7 +825,6 @@ describe("PreviewManager", () => { }; beforeInput({ preventDefault } as never, input as never); expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false); - // Releasing Command must not disable native fallback for the pending paste. beforeInput( { preventDefault } as never, { ...input, type: "keyUp", key: "Meta", meta: false } as never, @@ -836,7 +834,6 @@ describe("PreviewManager", () => { beforeInput({ preventDefault } as never, { ...input, key: "w" } as never); expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true); - // An injected paste in an unfocused guest cannot edit the active renderer. getFocusedWebContents.mockReturnValue(null); beforeInput({ preventDefault } as never, input as never); expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true); @@ -1583,8 +1580,6 @@ describe("PreviewManager", () => { expect(states.at(-1)?.zoomFactor).toBe(1); expect(setZoomFactor).toHaveBeenCalledWith(1); - // An app zoom leaves the guest reporting the inherited level. Navigating - // must not adopt it as the preview's zoom. effectiveZoom = 0.8; url = "https://example.com/after-app-zoom"; listeners.get("did-navigate")?.(); @@ -1597,7 +1592,6 @@ describe("PreviewManager", () => { }); expect(states.at(-1)?.zoomFactor).toBe(1); - // Only the preview's own zoom controls move it. yield* manager.zoomIn("tab_zoom"); expect(setZoomFactor).toHaveBeenCalledWith(1.1); expect(states.at(-1)?.zoomFactor).toBe(1.1); @@ -1690,9 +1684,6 @@ describe("PreviewManager", () => { ), ); - // did-attach and dom-ready both re-register the guest that is already - // attached, and a guest that just inherited the app window's zoom needs its - // own back β€” without that round trip republishing tab state. effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => withManager((manager) => Effect.gen(function* () { @@ -1893,8 +1884,6 @@ describe("PreviewManager", () => { effectIt.effect("sets the opaque base as soon as a guest attaches", () => withManager((manager) => Effect.gen(function* () { - // The tab's first document can paint before the renderer registers the - // guest, so the opaque base has to be the first command on attach. const claimed = makeAttachingGuest(44); fromId.mockReturnValue(claimed.wc); yield* manager.prepareWebview(claimed.wc); @@ -1912,7 +1901,6 @@ describe("PreviewManager", () => { features: [{ name: "prefers-color-scheme", value: "dark" }], }); - // A guest destroyed before any tab claims it releases its session. const unclaimed = makeAttachingGuest(45); yield* manager.prepareWebview(unclaimed.wc); expect(unclaimed.attach).toHaveBeenCalledTimes(1); @@ -2071,7 +2059,6 @@ describe("PreviewManager", () => { }); const exit = yield* manager.setAudioMuted("tab_audio_fail", true).pipe(Effect.exit); - // Reporting success would draw the tab as muted while it keeps playing. expect(Exit.isFailure(exit)).toBe(true); expect(states.at(-1)?.audioMuted).toBe(false); }), @@ -2089,15 +2076,12 @@ describe("PreviewManager", () => { yield* manager.setAudioMuted("tab_audio_attach_fail", true); const replacement = makeAudioWebContents(43); - // Fails the post-attach settle, not the pre-publish apply. replacement.setAudioMuted.mockImplementationOnce(() => undefined); replacement.setAudioMuted.mockImplementationOnce(() => { throw new Error("guest went away"); }); fromId.mockReturnValue(replacement.wc); - // Reconciliation is best-effort: a guest dying mid-attach must not fail - // the registration it was attaching for. const exit = yield* manager.registerWebview("tab_audio_attach_fail", 43).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); }), @@ -2220,13 +2204,9 @@ describe("PreviewManager", () => { yield* manager.navigate("tab_audio_nav", "https://example.com/next"); yield* Effect.yieldNow; - // navigate runs before loadURL swaps the document, so the old page can - // still be playing. Dropping audibility here would lose the speaker - // with no transition left to bring it back. expect(states.at(-1)?.audioMuted).toBe(true); expect(states.at(-1)?.audible).toBe(true); - // Chromium reports the real stop once the new document takes over. guest.emitAudioState(false); yield* Effect.yieldNow; expect(states.at(-1)?.audible).toBe(false); @@ -2407,7 +2387,6 @@ describe("PreviewManager", () => { withManager((manager) => Effect.gen(function* () { const preview = makeFaviconWebContents(); - // The frame tree is still loading the iframe; the main frame is done. Object.assign(preview.webContents, { isLoading: () => true }); fromId.mockReturnValue(preview.webContents); const states: PreviewManager.PreviewTabState[] = []; @@ -2499,7 +2478,6 @@ describe("PreviewManager", () => { expect(Exit.isSuccess(retriedExit)).toBe(true); expect(capturePage).toHaveBeenCalledTimes(3); - // A persistent failure still surfaces once the retries are spent. capturePage.mockClear(); const captureCause = new Error("capture failed"); capturePage.mockRejectedValue(captureCause); @@ -2587,7 +2565,6 @@ describe("PreviewManager", () => { } as never); yield* manager.startRecording("tab_capture_throttling_1"); - // The first renderer takes its grant, freeing the arm slot for the second tab. host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {}); yield* manager.startRecording("tab_capture_throttling_2"); expect(setBackgroundThrottling.mock.calls).toEqual([[false]]); @@ -2903,7 +2880,6 @@ describe("PreviewManager", () => { const manager = yield* PreviewManager.PreviewManager; yield* manager.createTab("tab_1", { serverTab }); yield* manager.registerWebview("tab_1", 42); - // The debugger attaches in the background; the server hears it here. expect(yield* nextType).toBe("attached"); yield* manager.closeTab("tab_1"); expect(yield* nextType).toBe("detached"); @@ -2926,7 +2902,6 @@ describe("PreviewManager", () => { const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe( Effect.forkChild({ startImmediately: true }), ); - // Let the rejection schedule its retry before replacing the guest. yield* TestClock.adjust(60); expect(capturePage).toHaveBeenCalledTimes(1); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43)); @@ -2987,7 +2962,6 @@ describe("PreviewManager", () => { })); const firstSendCommand = vi.fn(async () => undefined); const secondSendCommand = vi.fn(async () => undefined); - // Both webviews live in the same window, so they share one display-media handler. const host = makeTestHostWebContents(); const makeWebContents = ( id: number, @@ -3091,8 +3065,6 @@ describe("PreviewManager", () => { ), ); - // Runs on the real clock: an earlier queueing design only settled under TestClock and stalled the - // losing start forever in the desktop app. effectIt.live("settles both starts when two tabs race for the capture stream", () => withManager((manager) => Effect.gen(function* () { @@ -3107,7 +3079,6 @@ describe("PreviewManager", () => { ); const [exitA, exitB] = exits; - // Exactly one start owns the stream; the other fails fast instead of hanging. expect(exits.filter(Exit.isSuccess)).toHaveLength(1); const loserExit = Exit.isSuccess(exitA) ? exitB : exitA; if (Exit.isSuccess(loserExit)) return; @@ -3115,7 +3086,6 @@ describe("PreviewManager", () => { _tag: "PreviewRecordingArmConflictError", }); - // The single grant goes to the tab that actually won the slot, never the other one. takeGrant(); expect(grants).toEqual([{ video: { routingId: Exit.isSuccess(exitA) ? 41 : 42 } }]); expect(host.session.setDisplayMediaRequestHandler).toHaveBeenCalledOnce(); @@ -3137,7 +3107,6 @@ describe("PreviewManager", () => { armedTabId: "tab_race_a", }); - // Nothing ever captured the armed tab, so the slot goes stale and stops blocking starts. yield* TestClock.adjust(10_000); yield* manager.startRecording("tab_race_b"); takeGrant(); @@ -3156,7 +3125,6 @@ describe("PreviewManager", () => { yield* manager.startRecording("tab_race_a"); yield* TestClock.adjust(10_000); - // The handler cannot read a clock, so the expiry fiber must have dropped the frame. takeGrant(); expect(grants).toEqual([{}]); @@ -3269,7 +3237,6 @@ describe("PreviewManager", () => { yield* manager.startRecording("tab_cursor_reload", options); for (const recording of [true, false]) { if (!recording) yield* manager.stopRecording("tab_cursor_reload"); - // A new document has lost the previous preload's cursor overlay. cursorActive = false; const restored = new Promise((resolve) => { cursorUpdated = resolve; @@ -3373,7 +3340,6 @@ describe("PreviewManager", () => { yield* manager.startRecording("tab_recording_warmup_failure"); expect(capturePage).toHaveBeenCalledTimes(2); - // The armed tab answers exactly one display-media request, then further requests are denied. const handler = host.displayMediaHandler(); const streams: Array<{ video?: unknown }> = []; handler?.({ frame: host.mainFrame }, (value) => streams.push(value)); @@ -3876,10 +3842,7 @@ describe("PreviewManager", () => { })); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); const { pictureInPictureWindow: initializingWindow } = makeTestPictureInPictureWindow( - () => - new Promise(() => { - // Simulate a renderer load that never settles. - }), + () => new Promise(() => {}), ); const { pictureInPictureWindow: reopenedWindow } = makeTestPictureInPictureWindow(); browserWindowConstructor @@ -4176,7 +4139,6 @@ describe("PreviewManager", () => { on: vi.fn(), once: vi.fn(), off: vi.fn(), - // A wedged compositor leaves `capturePage` pending forever. capturePage: vi.fn(() => new Promise(() => {})), ipc: { on: vi.fn((channel: string, listener: typeof onPicked) => { @@ -4225,8 +4187,6 @@ describe("PreviewManager", () => { expect(pick.pollUnsafe()).toBeUndefined(); yield* TestClock.adjust("6 seconds"); - // The pick has to give up on the crop rather than strand the renderer, - // which would leave the composer stuck on "Capturing…". const result = yield* Fiber.join(pick); expect(result?.annotation.screenshot).toBeNull(); expect(result?.screenshotFailed).toBe(true); @@ -4355,18 +4315,14 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_1", 42); const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); yield* Effect.yieldNow; - // The first pick submits and its crop hangs. onPicked?.({}, annotation, null, "send"); yield* Effect.yieldNow; - // A second pick on the same tab replaces the first, which resumes null. const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); yield* Effect.yieldNow; expect(yield* Fiber.join(firstPick)).toBeNull(); webviewSend.mockClear(); - // The first pick's crop times out while the second pick is live. It - // must not signal the overlay, which would tear down the second pick. yield* TestClock.adjust("6 seconds"); yield* Effect.yieldNow; expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured"); @@ -4437,7 +4393,6 @@ describe("PreviewManager", () => { yield* Effect.yieldNow; expect(goForward).toHaveBeenCalledOnce(); - // Ignores unknown payloads and never navigates when history is exhausted. mouseNavigate?.({}, { direction: "sideways" }); canGoBack = false; mouseNavigate?.({}, { direction: "back" }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index a12ae3390e8a..b4eca14575e7 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -106,30 +106,20 @@ export interface PreviewTabState { zoomFactor: number; pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; - /** User intent to silence this tab. Re-applied to each guest that attaches. */ audioMuted: boolean; - /** Observed from Chromium. Stays true while a muted tab keeps playing. */ audible: boolean; controller: "human" | "agent" | "none"; favicon?: DesktopPreviewFavicon; - /** Set for a tab of the desktop's own server, which drives it over the browser channel. */ serverTab?: { readonly threadId: string; readonly tabId: string }; updatedAt: string; } -/** Discrete zoom levels mirroring Chrome's preset list. */ const ZOOM_LEVELS: ReadonlyArray = [ 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, ]; const DEFAULT_ZOOM_FACTOR = 1.0; const ZOOM_EPSILON = 0.001; -/** - * A `[role]` container's innerText is its whole subtree, which turned one - * snapshot's element list into 60 KB of repeated page text. Names are labels, - * not content, so cap them where they are read. - */ -/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; @@ -354,12 +344,6 @@ const findZoomStep = (current: number): number => { return Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON ? index : index - 1; }; -/** - * Clamp a client-supplied zoom factor onto the discrete ladder. The setting is - * chosen from the same ladder, but it arrives over IPC from a schema that only - * guarantees a positive number, so an out-of-band value snaps to the nearest - * step rather than leaving the guest at a zoom the zoom controls can't reach. - */ const normalizeZoomFactor = (value: number | undefined): number => { if (value === undefined || !Number.isFinite(value)) return DEFAULT_ZOOM_FACTOR; let closest = ZOOM_LEVELS[0]!; @@ -408,7 +392,6 @@ interface PictureInPictureSession { readonly initializationScope: Scope.Closeable; } -/** The tab whose frame the next `getDisplayMedia()` request is allowed to capture. */ interface PendingRecording { readonly tabId: string; readonly webContents: Electron.WebContents; @@ -422,7 +405,6 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; - // Pins the WebContents' Debugger wrapper for the session's lifetime. // Electron's Debugger is GC-managed but registered with Chromium as a raw // DevToolsAgentHostClient pointer; collecting it while attached crashes the // browser process (electron/electron#53376). Detach must also go through @@ -446,15 +428,11 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; /** - * Protocols a preview page may open in a real popup window. - * - * `about:blank` stays out: Chromium skips browser-side navigation for it, so the - * child copies the guest's `contextIsolation: false` preferences and Electron - * gives no way to override them. Those popups keep loading in the preview tab. - * + * `about:blank` stays out: Chromium skips browser-side navigation for it, so the child copies + * the guest's `contextIsolation: false` preferences and Electron gives no way to override them. * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits - * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` - * and not in a window spawned by a third-party page in the preview. + * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` and not in a + * window spawned by a third-party page in the preview. */ const POPUP_PROTOCOLS = new Set(["http:", "https:"]); @@ -467,13 +445,10 @@ const isPopupUrl = (rawUrl: string): boolean => { }; /** - * Preferences for a popup a preview page opens. - * - * A popup is not a webview attach, so the `will-attach-webview` hardening in - * `DesktopWindow` never sees it, and an unoverridden child would inherit the - * guest's relaxed posture: the picker preload needs `contextIsolation: false` - * to share `globalThis` with the previewed page, and no OAuth provider should - * get that. The window keeps the opener and the guest session either way. + * A popup is not a webview attach, so the `will-attach-webview` hardening in `DesktopWindow` + * never sees it, and an unoverridden child would inherit the guest's relaxed posture: the + * picker preload needs `contextIsolation: false` to share `globalThis` with the previewed page, + * and no OAuth provider should get that. */ const POPUP_WINDOW_OPTIONS = { webPreferences: { @@ -484,22 +459,11 @@ const POPUP_WINDOW_OPTIONS = { } satisfies Electron.BrowserWindowConstructorOptions; /** - * Decides what a preview page's `window.open` should do. - * - * `"popup"` opens a real window, which scripted popups need: denying them makes - * `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and - * navigating the preview tab instead destroys the opener the popup has to - * `postMessage` its result back to. - * - * `"new-tab"` hands a `target="_blank"` link (a tab disposition) to the web - * app, which opens it as another preview tab so the page that held the link - * stays put. Schemes a popup cannot be hardened for keep loading in place, and - * so does a form POST with a body, which a new tab could only reopen as a GET. - * - * `"deny"` is for a blank window (`""` or `about:blank`). It cannot get a real - * window (see `POPUP_PROTOCOLS`), and loading it into the preview tab would - * replace the opener with an empty page. Denying makes `window.open()` return - * `null`, which SDKs such as MSAL treat as a blocked popup and fall back from. + * `"popup"` opens a real window, which scripted popups need: denying them makes `window.open()` + * return `null` (OAuth SDKs report that as a blocked popup), and navigating the preview tab + * instead destroys the opener the popup has to `postMessage` its result back to. Schemes a + * popup cannot be hardened for keep loading in place, and so does a form POST with a body, + * which a new tab could only reopen as a GET. */ export const previewWindowOpenAction = (details: { readonly url: string; @@ -649,10 +613,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); const capture = Effect.gen(function* () { - // Check after the retry delay, and again before accepting its result. yield* requireCurrentGuest; const image = yield* Effect.tryPromise({ - // An abort-signal parameter makes a stalled promise interruptible. try: (_signal) => wc.capturePage(), catch: (cause) => new PreviewOperationError({ ...errorContext, cause }), }).pipe( @@ -940,10 +902,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); /** - * Pushes a tab's zoom factor onto whichever guest it currently owns, reading - * both at call time. Anything that applies zoom after an await goes through - * here: a snapshot taken before the await can be older than a zoom action that - * landed in between, and re-applying it would roll that action back. + * Anything that applies zoom after an await goes through here: a snapshot taken before the + * await can be older than a zoom action that landed in between, and re-applying it would roll + * that action back. */ const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); @@ -959,10 +920,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function * Mute counterpart to {@link assertTabZoom}: pushes the tab's committed mute * onto whichever guest it currently owns, reading both at call time so an * older snapshot can never roll back a mute action that landed after it. - * - * Failures propagate so the user-facing setter can roll its commit back. - * Reconciliation callers, where a guest going away mid-attach is expected, - * discard the error at their own call site. */ const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* ( tabId: string, @@ -976,13 +933,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - /** - * Publishes an observed audibility value for the guest that reported it. - * Shared by the `audio-state-changed` handler and the post-attach reconcile - * so both drop values from a guest the tab no longer owns, and both skip - * unchanged values: Chromium re-emits per media element, and republishing - * would cost an IPC push per element rather than per real transition. - */ const syncTabAudible = Effect.fn("PreviewManager.syncTabAudible")(function* ( tabId: string, wc: Electron.WebContents, @@ -1065,7 +1015,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const detachControlSession = Effect.fn("PreviewManager.detachControlSession")(function* ( webContentsId: number, - /** The server tab it rendered, when the caller already took the tab out of `tabsRef`. */ closedServerTab?: PreviewTabState["serverTab"], ) { const control = yield* SynchronizedRef.modify(controlSessionsRef, (sessions) => [ @@ -1075,7 +1024,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ]); if (control) { - // The server can only drive a tab while the desktop holds its debugger. if (closedServerTab) browserHost.detach(closedServerTab); for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { if (tab.webContentsId === webContentsId && tab.serverTab) browserHost.detach(tab.serverTab); @@ -1097,8 +1045,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function > => { const existing = sessions.get(wc.id); if (existing) return Effect.succeed([existing, sessions] as const); - // A guest can be destroyed while it waits for this lock, and its native - // methods throw once it is. if (wc.isDestroyed()) { return Effect.fail( new PreviewOperationError({ @@ -1127,10 +1073,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const scope = yield* Scope.fork(parentScope, "sequential"); const wcDebugger = wc.debugger; const consoleReleases = yield* Queue.sliding(1); - // Console message eviction does not release the debugger's strong - // object handles. We keep text only, so release the whole group, - // including its object-id bookkeeping. Coalesce bursts behind one - // command rather than queueing a command for every logged object. + // Console message eviction does not release the debugger's strong object handles. We + // keep text only, so release the whole group, including its object-id bookkeeping. yield* Effect.forkIn( Effect.forever( Queue.take(consoleReleases).pipe( @@ -1216,14 +1160,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("message", onMessage); wcDebugger.attach("1.3"); }); - // Electron gives `` guests a transparent base background, and - // Chromium only paints a dark canvas for dark color-scheme pages over an - // opaque base. Without this, dark-scheme pages with no background of - // their own (text/plain, e.g. .md files) render white text on white. - // White matches the webview's existing white backing, so light pages look - // the same; Chromium still swaps in its dark canvas for dark-scheme pages. - // Sent first because a document that paints before it arrives keeps the - // transparent base until its next load. + // Without this, dark-scheme pages with no background of their own (text/plain, e.g. + // .md files) render white text on white. Sent first because a document that paints + // before it arrives keeps the transparent base until its next load. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => @@ -1316,7 +1255,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } // Electron emits did-stop-loading after did-fail-load. At that point the // failed guest is no longer "loading", but it has not successfully - // navigated anywhere. Keep the failure until a new load actually starts. + // navigated anywhere. const navStatus = preserveLoadFailure && current.navStatus.kind === "LoadFailed" && @@ -1542,7 +1481,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; const syncMenuShortcuts = (contents: Electron.WebContents, input: Electron.Input): void => { if (input.type !== "keyDown") return; - // Native editing roles must remain available after the page handles the key. // Background automation must not edit whichever other renderer has focus. contents.setIgnoreMenuShortcuts( !isPreviewEditingShortcut(input, hostPlatform) || @@ -1627,8 +1565,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { - // Only focused native editing shortcuts may reach the application menu. - // Other preview input, including CDP keys, belongs to the page. wc.setIgnoreMenuShortcuts(true); wc.on("did-start-navigation", navigationStarted); wc.on("did-navigate", syncNavigation); @@ -1863,9 +1799,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const currentAttachment = attached.get(webContentsId); yield* keepFrameCaptureWebContentsUnthrottled(tabId, wc); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { - // The guest we already own re-announced itself, so nothing about the tab - // changed. Only push its zoom back down β€” Chromium may have just handed - // this guest the app window's zoom level. yield* assertTabZoom(tabId); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => { wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme); @@ -1879,7 +1812,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ? tab.webContentsId : null; if (replacedWebContentsId !== null) { - // The replaced guest can no longer redeem a display-media grant. clearPendingRecording(tabId); yield* Effect.all( [ @@ -1905,9 +1837,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => wc.setZoomFactor(currentTab.zoomFactor), ); - // A replacement guest attaches unmuted, so reassert the tab's mute before it - // is published rather than letting it emit audio the user already silenced. - // Settled again after attach, below, the same way zoom is. + // A replacement guest attaches unmuted, so reassert the tab's mute before it is published + // rather than letting it emit audio the user already silenced. yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () => wc.setAudioMuted(currentTab.audioMuted), ); @@ -1965,13 +1896,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // addressed the guest this one replaced, so settle the new guest on the // committed values. yield* assertTabZoom(tabId); - // Best-effort here, unlike in setAudioMuted: a guest that dies mid-attach - // must not fail the registration it was attaching for. yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); runFork(restoreControlSession(tabId, wc)); - // emitIfCurrent, not emit: audio-state-changed can land between the commit - // above and here, and republishing this snapshot would roll the UI back to - // a superseded audibility that syncTabAudible will not re-send. yield* emitIfCurrent(tabId, registered); // Transitions that fired before the tab owned this guest were dropped by // syncTabAudible's ownership check, so re-read and reconcile through the @@ -2007,21 +1933,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Called when a guest attaches to the window, before its first document - // paints. A tab opened straight to a URL often paints before the renderer - // gets to registerWebview, which would leave that page on the transparent - // base. registerWebview reuses the session opened here. + // Called when a guest attaches to the window, before its first document paints. A tab opened + // straight to a URL often paints before the renderer gets to registerWebview, which would + // leave that page on the transparent base. const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( wc: Electron.WebContents, ) { yield* rendererHistory.register(wc, { surface: "preview" }); const webContentsId = wc.id; - // A guest destroyed before any tab claims it has no other cleanup path. wc.once("destroyed", () => { runFork(detachControlSession(webContentsId)); }); - // Runs detached from the attach event, so nothing may escape. registerWebview - // opens the session again if this one did not. yield* ensureControlSession(wc).pipe( Effect.catchCause((cause) => Effect.logDebug("Preview webview control session was not opened on attach.", { @@ -2052,10 +1974,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", - // Both carry across navigation. Mute is user intent, and the old - // document keeps playing until loadURL actually replaces it, so - // clearing audibility here would drop the speaker with no transition - // left to restore it. Chromium reports the change when it happens. + // Mute is user intent, and the old document keeps playing until loadURL + // actually replaces it, so clearing audibility here would drop the + // speaker with no transition left to restore it. Chromium reports the + // change when it happens. audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", @@ -2069,9 +1991,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - // emitIfCurrent for the same reason as update: this snapshot carries - // audibility forward, and an audio-state-changed landing in between would - // otherwise be rolled back with no follow-up transition to correct it. yield* emitIfCurrent(tabId, pending); if (pending.webContentsId == null) return; const webContentsId = pending.webContentsId; @@ -2118,8 +2037,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } - // URL-bar navigation hands the page back to the human. The agent's own - // navigation shares this path; its next action marks the page again. if (wc.getURL() === url) { yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => wc.reload(), @@ -2211,8 +2128,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); return yield* Effect.callback( (resume) => { - // Declared first so cleanup can check slot ownership by identity - // without a type cycle through the cancel effect it builds. const session: PickSession = { cancel: Effect.suspend(() => cancelPickSession()) }; const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { @@ -2230,10 +2145,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function : sessions, ); }); - // Every exit from this session runs through `claimSettle`, so the - // renderer's `pickElement` promise resolves exactly once. The previous - // identity check let a cancelled or replaced session return without - // resuming, which left the composer waiting forever. let settled = false; const claimSettle = (): boolean => { if (settled) return false; @@ -2286,9 +2197,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach"; const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1; runFork( - // The full-page capture with retries is what the screenshot button - // uses. A single cropped `capturePage(rect)` with a fixed timeout - // dropped the crop on pages where that capture was merely slow. capturePageWithRetry( { operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id }, tabId, @@ -2301,9 +2209,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), ), Effect.withSpan("PreviewManager.captureAnnotationScreenshot"), - // The renderer cannot tell a dropped crop from a comment-only - // pick by the null alone, so a failed or timed-out capture is - // flagged on the result. Effect.match({ onFailure: (): PreviewAnnotationSubmissionResult => ({ annotation: payload, @@ -2374,12 +2279,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - /** - * Chromium hands every guest `` the embedder's zoom level, so zooming - * the app UI drags the previewed page along with it. The preview browser owns - * its own zoom factor, so re-assert it on each attached guest whenever the main - * window's zoom changes (see DesktopWindow.zoomMain). - */ const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); yield* Effect.forEach(tabIds, assertTabZoom, { discard: true }); @@ -2418,7 +2317,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function features: [ { name: "prefers-color-scheme", - // An empty value clears the override so the page follows the OS. value: colorScheme === "system" ? "" : colorScheme, }, ], @@ -2426,10 +2324,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Re-establish the control session after a detach, restoring any - // color-scheme override the tab carries. The scheme is read after the - // session attaches so a concurrent setColorScheme is not overwritten with - // a stale snapshot. + // The scheme is read after the session attaches so a concurrent setColorScheme is not + // overwritten with a stale snapshot. const restoreControlSession = (tabId: string, wc: Electron.WebContents) => Effect.gen(function* () { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); @@ -2467,9 +2363,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } if (tab.colorScheme !== colorScheme) { - // Record the choice even when the CDP call below can't run yet (no - // webview, DevTools holding the debugger) β€” it is re-applied on the - // next control-session (re)attach. yield* update(tabId, { colorScheme }); } // Re-read after the update: registerWebview may have swapped the guest @@ -2496,8 +2389,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* withTabLifecycleLock( tabId, Effect.gen(function* () { - // Record the intent even when no guest is attached yet β€” it is - // re-applied by registerWebview when one arrives. const previous = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.audioMuted; const committed = previous !== undefined && previous !== audioMuted; if (committed) { @@ -2718,8 +2609,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId: string, consumer: FrameCaptureConsumer, ) { - // Recording keeps only the activity lease. Picture-in-picture owns the - // capturePage loop and tolerates transient compositor warmup failures. const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe( Effect.andThen(capturePreviewFrame(tabId)), Effect.catch((error) => @@ -3107,17 +2996,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* Effect.failCause(initializationExit.cause); }); - /** Only drops the armed target when it still belongs to `tabId`, so tabs cannot clobber each other. */ const clearPendingRecording = (tabId: string) => { if (pendingRecording?.tabId === tabId) pendingRecording = null; }; /** - * Claims the single arm slot for `tabId`. A display-media request carries no tab identity, so the - * slot is exclusive: a second tab arming before the first request lands would redirect the first - * renderer's stream. Rather than queue (which can only ever stall a start), a colliding start - * fails fast and the renderer can retry. An arm the renderer never redeemed goes stale after - * `RECORDING_ARM_GRACE_MS` so it cannot hold the slot forever. + * A display-media request carries no tab identity, so the slot is exclusive: a second tab + * arming before the first request lands would redirect the first renderer's stream. */ const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( tabId: string, @@ -3338,10 +3223,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); let pointerSequence = 0; - /** - * The server reports where an agent action is about to land on a tab it - * drives here. The live cursor and desktop recordings draw it like any agent. - */ const emitAgentPointer = Effect.fn("PreviewManager.emitAgentPointer")(function* (pointer: { readonly key: { readonly threadId: string; readonly tabId: string }; readonly phase: "move" | "click"; @@ -3377,7 +3258,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); - // Listening starts with the first tab of the desktop's own server. let pointersStarted = false; const listenForAgentPointers = Effect.suspend(() => { if (pointersStarted) return Effect.void; @@ -3648,8 +3528,6 @@ export class PreviewManager extends Context.Service< readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; - // Re-applies every attached guest's own zoom factor, undoing the zoom level - // Chromium inherits from the embedder when the app UI zooms. readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; readonly setColorScheme: ( @@ -3730,7 +3608,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runForkDownload = Effect.runForkWith(yield* Effect.context()); - /** Puts a copy in Downloads under a name that never replaces a file already there. */ const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* ( source: string, fileName: string, @@ -3749,8 +3626,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { return; } }); - // Server tabs save downloads where the server's engine reads them. Downloads - // the person starts in a tab the server is not driving keep Electron's dialog. const placeServerDownloads = (session: Electron.Session) => { if (downloadSessions.has(session)) return; downloadSessions.add(session); @@ -3758,7 +3633,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { // Decided before the server's path is set: placing it consumes the guid. const human = browserHost.humanStartedDownload(source); if (!browserHost.placeDownload(source, item) || !human) return; - // The server keeps its copy for agents; the person who clicked gets theirs. item.once("done", (_doneEvent, state) => { if (state !== "completed") return; runForkDownload( diff --git a/apps/desktop/src/preview/PasskeyAttestation.ts b/apps/desktop/src/preview/PasskeyAttestation.ts index 17927a2957da..043ccaa77206 100644 --- a/apps/desktop/src/preview/PasskeyAttestation.ts +++ b/apps/desktop/src/preview/PasskeyAttestation.ts @@ -1,8 +1,3 @@ -/** - * Reads the raw `authData` bytes out of a CBOR attestation object - * (`{ fmt, attStmt, authData }`). Returns undefined for anything malformed. - * Only the definite-length encodings authenticators emit are understood. - */ export const authenticatorDataFromAttestation = (bytes: Uint8Array) => { let offset = 0; const readHead = () => { diff --git a/apps/desktop/src/preview/PasskeyBackend.ts b/apps/desktop/src/preview/PasskeyBackend.ts index ac174d2759a4..ec4a70936031 100644 --- a/apps/desktop/src/preview/PasskeyBackend.ts +++ b/apps/desktop/src/preview/PasskeyBackend.ts @@ -1,19 +1,11 @@ import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; -/** What the shared bridge has already checked before a backend runs a ceremony. */ export interface PasskeyCeremonyContext { /** The committed origin of the frame that asked; never the page's own claim. */ readonly origin: string; - /** The window the system sheet attaches to. */ readonly nativeWindowHandle: Buffer; } -/** - * One platform's way to create and use passkeys for a preview page. The - * bridge in `Passkeys.ts` owns everything platform-neutral: which frame may - * ask, focus, one ceremony at a time, the deadline, and dropping results for - * a page that navigated away. A backend only talks to the platform. - */ export interface PasskeyBackend { readonly create: ( publicKey: PublicKeyCredentialCreationOptions, diff --git a/apps/desktop/src/preview/PasskeyBackendMac.ts b/apps/desktop/src/preview/PasskeyBackendMac.ts index a21d94df1cbe..ccdffa2a70c0 100644 --- a/apps/desktop/src/preview/PasskeyBackendMac.ts +++ b/apps/desktop/src/preview/PasskeyBackendMac.ts @@ -33,9 +33,7 @@ const toBase64Url = (value: BufferSource) => ).toString("base64url"); /** - * macOS passkeys through AuthenticationServices (`electron-webauthn`): the - * system sheet offers iCloud Keychain, password managers, phones, and security - * keys. Needs Apple's managed browser entitlement in the signed build. + * Needs Apple's managed browser entitlement in the signed build. */ export const macPasskeyBackend: PasskeyBackend = { create: async (publicKey, context): Promise => { @@ -63,8 +61,6 @@ export const macPasskeyBackend: PasskeyBackend = { const authData = authenticatorDataFromAttestation( Buffer.from(result.data.attestationObject, "base64url"), ); - // Sites verify the new credential from its authenticator data; without it - // the passkey is unusable, so say so now rather than let sign-up fail later. if (!authData) return { success: false, error: "NotAllowedError" }; return { success: true, diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts index 3ca93190ae51..8cbaad7f38dc 100644 --- a/apps/desktop/src/preview/PasskeyBridge.test.ts +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -106,7 +106,6 @@ describe("installPasskeyBridge", () => { challenge, rpId: "example.com", allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }], - // Unknown members the page adds must not break IPC cloning. extensions: { onHint: () => {} } as AuthenticationExtensionsClientInputs, }, })) as PublicKeyCredential; @@ -191,7 +190,6 @@ describe("installPasskeyBridge", () => { publicKey: { challenge: new Uint8Array([1]) }, }); await credentials.get({ password: true } as CredentialRequestOptions); - // Conditional create upgrades a password sign-in silently; no modal sheet. const upgrade = { mediation: "conditional", publicKey: creationOptions } as const; await credentials.create(upgrade); await credentials.create({ password: {} } as CredentialCreationOptions); diff --git a/apps/desktop/src/preview/PasskeyBridge.ts b/apps/desktop/src/preview/PasskeyBridge.ts index cea175c29633..fbb9fefa4aa5 100644 --- a/apps/desktop/src/preview/PasskeyBridge.ts +++ b/apps/desktop/src/preview/PasskeyBridge.ts @@ -2,7 +2,6 @@ import type { CreateCredentialResult, GetCredentialResult } from "electron-webau import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; -/** What the main process answers a guest passkey ceremony with. */ export type PasskeyCeremonyResult = | Extract | Extract @@ -46,9 +45,8 @@ const ceremonyError = (name: string) => ); /** - * Copies request options into plain IPC-cloneable data. Chromium ignores - * members it does not know, so functions and other uncloneable values drop out - * here instead of failing the whole ceremony. + * Chromium ignores members it does not know, so functions and other uncloneable + * values drop out here instead of failing the whole ceremony. */ const toCloneable = (value: unknown): unknown => { if (value instanceof ArrayBuffer) return value.slice(0); @@ -205,14 +203,10 @@ const abortReason = (signal: AbortSignal) => signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); /** - * Routes this page's WebAuthn ceremonies to the system passkey sheet through - * the main process, which supplies the frame's real origin. Runs in the page's - * own world (the preview preload has contextIsolation off), before any page - * script. Conditional (autofill and automatic upgrade) requests stay native: - * the system sheet is modal and must not open on its own. + * Conditional (autofill and automatic upgrade) requests stay native: the system + * sheet is modal and must not open on its own. */ export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { - // Insecure contexts have no WebAuthn to bridge. if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { return; } @@ -230,7 +224,6 @@ export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { ? credentialFromCreateResult(result.data) : credentialFromGetResult(result.data); }, - // No handler yet (or any more) for this guest: answer like a refusal. (error: unknown) => { throw error instanceof DOMException || error instanceof TypeError ? error diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts index 0fe4ed8be682..c6b8720314a3 100644 --- a/apps/desktop/src/preview/Passkeys.test.ts +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -66,7 +66,6 @@ const makeGuest = (origin = "https://accounts.example.com") => { const bridgeLayer = layerFor({ t3codeWebAuthn: { browserPasskeys: true } }); -/** CBOR `{ fmt: "packed", attStmt: { alg: -7, sig }, authData }`, as authenticators encode it. */ const attestationObject = (authData: ReadonlyArray) => { const text = (value: string) => [0x60 + value.length, ...Buffer.from(value)]; const bytes = (value: ReadonlyArray) => [0x58, value.length, ...value]; @@ -93,7 +92,6 @@ describe("PreviewPasskeys", () => { getNativeWindowHandle: () => Buffer.from([1]), }); }); - // Restored here so a failing fake-timer test cannot leak fake timers into the next one. afterEach(() => { vi.useRealTimers(); }); @@ -126,7 +124,6 @@ describe("PreviewPasskeys", () => { yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) }); assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com"); - // Only the main frame runs the preload; anything else is not the page. const forged = yield* call(PASSKEY_CREATE_CHANNEL, publicKey, { origin: "https://evil.example", }); @@ -182,7 +179,6 @@ describe("PreviewPasskeys", () => { assert.deepStrictEqual(unsupported, { success: false, error: "NotSupportedError" }); assert.strictEqual(webauthn.createCredential.mock.calls.length, 1); - // A malformed algorithm list is the page's error, not a cue to pick ES256. const malformed = yield* call(PASSKEY_CREATE_CHANNEL, { ...publicKey, pubKeyCredParams: { type: "public-key", alg: -7 }, @@ -190,7 +186,6 @@ describe("PreviewPasskeys", () => { assert.deepStrictEqual(malformed, { success: false, error: "TypeError" }); assert.strictEqual(webauthn.createCredential.mock.calls.length, 1); - // Without authenticator data the site cannot verify the passkey. webauthn.createCredential.mockResolvedValueOnce({ success: true, data: { credentialId: "AQ", attestationObject: Buffer.from([0x00]).toString("base64url") }, @@ -231,7 +226,6 @@ describe("PreviewPasskeys", () => { webauthn.getCredential.mockResolvedValueOnce(assertion("AQ")); assert.deepInclude(yield* call(PASSKEY_GET_CHANNEL, publicKey), { success: true }); - // With no allow list, any of the site's credentials will do. webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); const discoverable = yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]), @@ -268,7 +262,6 @@ describe("PreviewPasskeys", () => { yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); - // The page navigated while the sheet was up; its credential goes nowhere. page.mainFrame.origin = "https://other.example"; finish({ success: true, data: { credentialId: "AQ" } }); assert.deepStrictEqual(yield* Fiber.join(first), notAllowed); diff --git a/apps/desktop/src/preview/Passkeys.ts b/apps/desktop/src/preview/Passkeys.ts index c06b4dc522b5..5456aaab6a8a 100644 --- a/apps/desktop/src/preview/Passkeys.ts +++ b/apps/desktop/src/preview/Passkeys.ts @@ -37,31 +37,13 @@ export class PreviewPasskeysConfigureError extends Schema.TaggedError; - /** Lets the user choose between several passkeys for one site. Idempotent. */ readonly installSessionHandlers: (session: Session) => void; - /** Serves a preview guest's passkey ceremonies. Returns the detach function. */ readonly attachGuest: (guest: WebContents) => () => void; } >()("@t3tools/desktop/preview/Passkeys/PreviewPasskeys") {} @@ -131,8 +113,7 @@ const isTrustworthyOrigin = (origin: string) => { }; /** - * The platform that serves preview pages' passkey ceremonies, or none. Windows - * needs none: Chromium already hands WebAuthn to Windows Hello there. + * Windows needs none: Chromium already hands WebAuthn to Windows Hello there. */ const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined => platform === "darwin" ? macPasskeyBackend : undefined; @@ -191,7 +172,6 @@ const make = Effect.gen(function* () { // be trusted for the origin. Read the committed origin before any await: // the frame object outlives a navigation. const origin = frame.origin; - // Like Chromium, only a focused page may open the sheet, one at a time. if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) { return notAllowed; } @@ -224,8 +204,6 @@ const make = Effect.gen(function* () { guest.ipc.removeHandler(PASSKEY_GET_CHANNEL); }; detach(); - // Option shapes are the page's to get wrong: the native layer validates - // them and answers with a TypeError. guest.ipc.handle( PASSKEY_CREATE_CHANNEL, (event, publicKey: PublicKeyCredentialCreationOptions) => diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 7b0f900f8fec..5114bc161971 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -38,7 +38,6 @@ const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; const MAX_MARQUEE_ELEMENTS = 20; -/** Upper bound on one element's React context lookup during submit. */ const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; const CONTENT_LAYER_Z_INDEX = 1; const CHROME_LAYER_Z_INDEX = 10; @@ -188,7 +187,6 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); -// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. const MOUSE_BUTTON_BACK = 3; const MOUSE_BUTTON_FORWARD = 4; @@ -326,7 +324,6 @@ function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { node.style.height = `${rect.height}px`; } -/** Clamps a box to the viewport and clipping ancestors, so its border is never painted off-screen. */ function visibleElementRect(element: Element): PreviewAnnotationRect { const rect = element.getBoundingClientRect(); let left = Math.max(0, rect.left); @@ -400,10 +397,9 @@ function toStackFrame(frame: { } /** - * Resolves to `null` instead of hanging when `promise` outlives `millis`. - * `getElementContext` walks the inspected page's React internals, and some - * pages leave it pending forever. Without a bound, the whole submit chain - * stalls and the overlay sits on "Capturing…". + * `getElementContext` walks the inspected page's React internals, and some pages leave it + * pending forever. Without a bound, the whole submit chain stalls and the overlay sits on + * "Capturing…". */ function withCaptureTimeout(promise: Promise, millis: number): Promise { let timer: ReturnType | undefined; @@ -415,14 +411,12 @@ function withCaptureTimeout(promise: Promise, millis: number): Promise clearTimeout(timer)); } -/** Truncation for the DOM-only preview used when React context is unavailable. */ const HTML_PREVIEW_MAX_CHARS = 500; /** - * Describes a picked element. The React context lookup can stall or throw on - * some pages, so the element is never dropped: without context it still - * carries its tag, a short HTML preview, and its rect so the crop stays on the - * pick instead of falling back to the whole viewport. + * The React context lookup can stall or throw on some pages, so the element is never dropped: + * without context it still carries its tag, a short HTML preview, and its rect so the crop + * stays on the pick instead of falling back to the whole viewport. */ async function captureElement(element: Element): Promise { const base = { @@ -448,9 +442,7 @@ async function captureElement(element: Element): Promise { styles: context.styles ?? "", }; } - } catch { - // Fall through to the DOM-only payload. - } + } catch {} return { ...base, selector: null, @@ -1033,7 +1025,6 @@ function startAnnotation(sendEnabled: boolean): void { const getAnnotationBounds = (): PreviewAnnotationRect | null => unionRects( [ - // Follow the visible outline, so a clipped selection does not anchor the editor off-screen. ...Array.from(selected.values(), (target) => visibleElementRect(target.element)).filter( (rect) => rect.width > 0 && rect.height > 0, ), @@ -1401,7 +1392,6 @@ function startAnnotation(sendEnabled: boolean): void { pendingCapture = true; submit.disabled = true; submit.textContent = "Capturing…"; - // Snapshot everything the annotation will carry before the capture runs. // The element context lookup can take up to its timeout, and the user can // keep editing meanwhile; the annotation must describe what they submitted. const submittedComment = comment.value.trim(); @@ -1457,9 +1447,6 @@ function startAnnotation(sendEnabled: boolean): void { ); }) .catch(() => { - // Last resort. Main is waiting on this message, so hand it an empty - // pick rather than leaving the button stuck on "Capturing…" and the - // renderer's pick promise pending. teardown is a no-op once finished. teardown(true); }); }; diff --git a/apps/desktop/src/preview/PickedElementPayload.ts b/apps/desktop/src/preview/PickedElementPayload.ts index e2d596120dba..1dab0e41a7b4 100644 --- a/apps/desktop/src/preview/PickedElementPayload.ts +++ b/apps/desktop/src/preview/PickedElementPayload.ts @@ -1,15 +1,3 @@ -/** - * Strict structural validator for `PickedElementPayload` messages received - * from the in-page picker preload (`apps/desktop/src/preview/PickPreload.ts`) - * via `wc.ipc`. Lives in its own electron-free module so the validator is - * trivially unit-testable. - * - * Validation must be tight: downstream `normalizeElementContextSelection` - * calls `.trim()` on incoming strings, so a malformed payload (preload bug, - * future schema mismatch, malicious page that intercepts the preload's IPC - * channel via prototype pollution) would otherwise throw deep in the - * renderer and the chip silently never appears. - */ import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; function isStringOrNull(value: unknown): value is string | null { diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 70b0a43be43a..4524b0d017b6 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -37,7 +37,6 @@ export function installRecordingCursor( agentCursor.setAttribute("data-t3code-recording-agent-cursor", ""); agentCursor.style.cssText = "position:fixed;left:0;top:0;width:20px;height:20px;pointer-events:none;z-index:2147483647;display:none;filter:drop-shadow(0 1px 2px #0003);transition:transform 150ms ease-out,opacity 150ms ease-out;"; - // Match the MousePointer2 icon used by the live AgentBrowserCursor. agentCursor.innerHTML = ''; document.documentElement.append(style, cursor, agentCursor); diff --git a/apps/desktop/src/preview/RecordingInput.ts b/apps/desktop/src/preview/RecordingInput.ts index a69102121d4e..388090dfff10 100644 --- a/apps/desktop/src/preview/RecordingInput.ts +++ b/apps/desktop/src/preview/RecordingInput.ts @@ -16,7 +16,6 @@ export interface RecordingKeyPress { readonly shiftKey: boolean; } -/** Formats a single chord without duplicating a modifier pressed on its own. */ export function recordingKeyLabel(input: RecordingKeyPress, isMac: boolean): string | null { if (["Dead", "Process", "Unidentified", ""].includes(input.key)) return null; const modifiers = [ diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts index 498c1df4665b..94681b3e046f 100644 --- a/apps/desktop/src/preview/WebviewPreferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -2,29 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts"; -/** - * Mirrors Electron's webview attribute parser closely enough to catch the - * regressions we've already hit: - * - * - whitespace inside the comma-separated list silently drops keys (so - * `" sandbox=true"` becomes an unknown key and Electron falls back to - * defaults β€” re-opening the Node-leak window we closed), - * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy - * strings and assigned to a boolean preference, which silently flips - * `contextIsolation=no` to ENABLED (then react-grab can't see the React - * DevTools hook and componentName resolution always returns null). - * - * The actual Electron parser does roughly: - * - * for (const pair of webpreferences.split(',')) { - * const [key, value] = pair.split('='); - * prefs[key] = value; // value left as a string - * } - * - * then later coerces booleans via `Boolean(value)`. Replicating that here - * keeps the test independent of Electron internals while still failing if - * we accidentally ship `"contextIsolation=no"` again. - */ function parseWebPreferences(input: string): Record { const out: Record = {}; for (const pair of input.split(",")) { @@ -71,8 +48,6 @@ describe("PREVIEW_WEBVIEW_PREFERENCES", () => { }); it("contains no whitespace (Electron's parser does not trim)", () => { - // Electron splits on `,` without trimming, so any whitespace would turn - // a key into an unknown one and silently drop the security flag. expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/); }); }); diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts index 085c75232b38..4236b78293ab 100644 --- a/apps/desktop/src/preview/WebviewPreferences.ts +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -1,42 +1,9 @@ /** - * webPreferences override applied to every preview `` element via - * its `webpreferences="..."` attribute. Single source of truth so all guest - * surfaces inherit the same security posture. - * - * Lives in its own electron-free module so the value is unit-testable - * without importing `Manager.ts` (which transitively imports - * `electron` and blows up under vitest). - * - * - `contextIsolation=false`: the picker preload needs to share `globalThis` - * with the page so react-grab/bippy can read the React DevTools hook - * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without - * this every pick comes back with `componentName: null` even on dev React - * apps. * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical * when paired with `contextIsolation=false` β€” without sandbox, the preload * has full Node access (`require`, `fs`, `child_process`, ...) and that * `require` would land on the page's shared `globalThis`, giving any * third-party page in the preview full Node + IPC access to the host. - * In sandboxed mode Electron still synthesizes the `electron` module for - * the preload's `import { ipcRenderer }` line, but no Node globals leak. - * - `nodeIntegration=false`: pinned for clarity (the page itself never gets - * Node access). - * - * Format notes (locked down by `WebviewPreferences.test.ts`): - * - Whitespace-free. Electron's webpreferences parser splits on `,` and - * does not trim, so a leading space would turn a key into an unknown one - * and silently drop it. - * - Values are JS-boolean strings (`true`/`false`) β€” `yes`/`no` are not - * special-cased by the parser; `value="no"` becomes the truthy STRING - * `"no"` when assigned to a boolean webPreferences key. Most critically, - * `contextIsolation="no"` is truthy β†’ contextIsolation stays ENABLED β†’ - * react-grab can't see the React DevTools hook. - * - * Defense in depth: `apps/desktop/src/main.ts` also runs a - * `will-attach-webview` handler that force-sets `sandbox: true` and - * `nodeIntegration*: false` on the actual webPreferences object, gated on - * the preview partition, so even if this string is ever wrong, the - * security-critical flags can't regress on preview tabs. */ export const PREVIEW_WEBVIEW_PREFERENCES = "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a8dbc8e63f05..1c131681295f 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -35,19 +35,9 @@ export interface DesktopSettings { readonly tailscaleServePort: number; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; - // Was a "local" | "wsl" swap mode in an earlier iteration of the WSL - // integration. We now run Windows and WSL backends side by side, so the - // setting is just whether the WSL backend should be running alongside the - // primary. Persisted documents that still carry the legacy `wslMode: "wsl"` - // value are migrated to `wslBackendEnabled: true` on load. readonly wslBackendEnabled: boolean; readonly wslDistro: string | null; - // When true (and wslBackendEnabled is also true) the desktop runs only - // the WSL backend as the primary, and the Windows-side Node backend is - // not started. Designed for users who develop entirely inside WSL and - // don't want a second backend process running. Defaults to false so - // existing setups stay on the parallel-backends behavior. Changing - // this requires a desktop restart because the pool's primary spec is + // Changing this requires a desktop restart because the pool's primary spec is // chosen once at layer init. readonly wslOnly: boolean; } @@ -106,9 +96,6 @@ const DesktopSettingsDocument = Schema.Struct({ tailscaleServePort: Schema.optionalKey(Schema.Number), updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), - // Newer form of the WSL toggle. `wslMode` is still accepted on load so - // existing on-disk settings keep working; on the next persist we write the - // new boolean and the legacy key drops out. wslBackendEnabled: Schema.optionalKey(Schema.Boolean), wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), @@ -224,9 +211,6 @@ function normalizeDesktopSettingsDocument( parsed.updateChannelConfiguredByUser === true || (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); - // Newer form wins when both are present; otherwise fall back to the legacy - // `wslMode === "wsl"` signal so users coming off the swap-mode build keep - // their WSL backend enabled. const wslBackendEnabled = parsed.wslBackendEnabled === true || (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); @@ -481,6 +465,7 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp ); }); +/** @public */ /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index d6e1a210cc0a..248fb9b6f848 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -130,7 +130,6 @@ export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentSecretDecodeError | DesktopSavedEnvironmentSecretProtectionError; -/** Reads the previous registry for connection-catalog migration. */ export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index fe58aa87f0c1..8fa8a60b2e62 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -465,7 +465,6 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", - // The telemetry opt-out is documented as a shell variable; GUI launches never see it. "T3CODE_TELEMETRY_ENABLED", ] as const) { if (!config.env[name] && shellEnvironment[name]) { diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index 0d564f10f855..f62669a39bf3 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -7,9 +7,8 @@ import * as Schema from "effect/Schema"; import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts"; /** - * The foreground window as the snapshot service needs it. `id` is the - * CGWindowNumber on macOS and the HWND on Windows, which is what the capture - * backends key on. + * `id` is the CGWindowNumber on macOS and the HWND on Windows, which is what + * the capture backends key on. */ export type ActiveWindow = { readonly platform: "macos" | "windows"; @@ -49,9 +48,6 @@ const MacActiveWindow = Schema.Struct({ }); const decodeMacActiveWindow = Schema.decodeUnknownSync(Schema.fromJsonString(MacActiveWindow)); -// The frontmost app's first on-screen, layer-0 window in front-to-back order is -// the active window. Window titles need Screen Recording, which the snapshot -// service has already requested by the time this runs. const MAC_LOOKUP_SCRIPT = ` ObjC.import("CoreGraphics"); ObjC.import("AppKit"); @@ -135,7 +131,6 @@ async function windowsActiveWindow(): Promise { }; } -/** Resolve the OS foreground window, or `undefined` when there is none. */ export function activeWindow(platform: NodeJS.Platform): Promise { if (platform === "darwin") return macActiveWindow(); if (platform === "win32") return windowsActiveWindow(); diff --git a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts index 248a6874e48f..aa7d6cce5112 100644 --- a/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts +++ b/apps/desktop/src/snapShot/CaptureShortcutConfig.test.ts @@ -245,7 +245,6 @@ it("the installed Niri parser accepts the generated edit and its relative includ } throw error; } - // No compositor is launched: validate only parses the temporary fixture. await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); await NodeFSP.writeFile( NodePath.join(directory, "keys.kdl"), diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 485ed2aaa0f7..aa56fede5e99 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -429,8 +429,6 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopSnapShot from "./DesktopSnapShot.ts"; import * as SnapShotAccessibility from "./SnapShotAccessibility.ts"; -// The accessibility reader normally runs in a worker with the real xa11y `App`. -// Tests hand it this stand-in so the mocks above drive window lookups. const accessibilityApp = { byPid: accessibilityByPidMock, list: accessibilityListMock, diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 7585c1c18879..68026f6b8191 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -187,7 +187,6 @@ export class DesktopSnapShot extends Context.Service< shortcut: SnapShotShortcut, ) => Effect.Effect; readonly setShortcutSuppressed: (suppressed: boolean) => Effect.Effect; - /** Capture the foreground window in place, including T3 Code itself. */ readonly capture: Effect.Effect; readonly listPending: Effect.Effect< ReadonlyArray, @@ -770,7 +769,6 @@ export const make = Effect.gen(function* () { path.join(__dirname, "snapShot", "RegionSnapShotWorker.cjs"), ); let registeredAccelerator: string | undefined; - // False until the first applySettings; the first pass must always register. let initialized = false; let portalShortcut: PortalCaptureShortcut | undefined; let shortcutGeneration = 0; @@ -925,7 +923,6 @@ export const make = Effect.gen(function* () { const appIconDataUrl = yield* Effect.promise(() => iconDataUrl(source, active, environment.platform), ); - // Native labels are unbounded; keep a valid screenshot when its metadata is too long. const appIdentifier = boundedSnapShotString( active?.platform === "macos" ? active.owner.bundleId : linuxWindow?.appIdentifier, 255, @@ -971,8 +968,6 @@ export const make = Effect.gen(function* () { if (!settings.snapShotEnabled) { return yield* new DesktopSnapShotError({ operation: "disabled" }); } - // Only source acquisition and the initial handoff require exclusive access. - // Each captured image can finish its own accessibility read and persistence. const prepared = yield* prepareCapture(settings).pipe( Effect.tapError((error) => (error.captureId ? discardCapture(error.captureId) : Effect.void).pipe( @@ -1328,8 +1323,6 @@ export const make = Effect.gen(function* () { if (action === "test-mac-capture") { if (environment.platform !== "darwin") return yield* new DesktopSnapShotSetupError({ action, reason: "unsupported-session" }); - // Exercise the real capture path during setup without attaching a snapshot - // or running capture feedback. The temporary image is discarded on failure too. yield* Effect.scoped( Effect.gen(function* () { const directory = yield* fileSystem.makeTempDirectoryScoped({ @@ -1540,9 +1533,6 @@ export const make = Effect.gen(function* () { ) : environment.platform === "darwin" ? Effect.gen(function* () { - // Permissions can change in System Settings at any time. Surface a - // revocation on every read, and re-register the shortcut once a - // previously missing permission is granted again. const settings = yield* Ref.get(settingsRef); const macPermissions = currentMacPermissions(); const message = settings.snapShotEnabled @@ -1564,7 +1554,6 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((state) => Effect.gen(function* () { - // Keep the session identity available even when its capability probe fails. const linuxDesktop = state.mode === "portal" ? process.env.XDG_CURRENT_DESKTOP?.toLowerCase() diff --git a/apps/desktop/src/snapShot/GlobalShiftShortcutWorker.ts b/apps/desktop/src/snapShot/GlobalShiftShortcutWorker.ts index a8c0e0f79ac4..78f41968ba63 100644 --- a/apps/desktop/src/snapShot/GlobalShiftShortcutWorker.ts +++ b/apps/desktop/src/snapShot/GlobalShiftShortcutWorker.ts @@ -1,7 +1,4 @@ // @effect-diagnostics globalTimers:off -- A plain Node child; the poll runs outside any Effect fiber. -// Windows modifier-pair listener. Runs in a forked Node-mode child so a stuck or -// crashed FFI call cannot take the main process with it. Mirrors the macOS -// poller: sample both physical keys at 20 Hz, fire on the rising edge. import { SNAP_SHOT_MODIFIERS, type SnapShotModifier } from "@t3tools/contracts"; import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts"; diff --git a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts index 429a9986d94c..cc4ada0d57ab 100644 --- a/apps/desktop/src/snapShot/GnomeCaptureSetup.ts +++ b/apps/desktop/src/snapShot/GnomeCaptureSetup.ts @@ -35,7 +35,6 @@ const decodeMetadata = Schema.decodeUnknownSync(Schema.fromJsonString(Metadata)) type SetupPaths = { readonly bundle: string; readonly dataHome: string }; -/** Copies only the shipped extension, offline. Replaced versions are kept for recovery. */ export async function installGnomeCaptureBundle({ bundle, dataHome }: SetupPaths) { const metadata = decodeMetadata( await NodeFSP.readFile(NodePath.join(bundle, "metadata.json"), "utf8"), @@ -86,7 +85,6 @@ export async function installGnomeCaptureBundle({ bundle, dataHome }: SetupPaths } } -/** A short-lived, read-only probe unless the user explicitly invokes an action. */ export class GnomeCaptureSetup { private readonly disconnected: Promise; private readonly paths: SetupPaths; diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index 9b8c8bb90dc0..5a90aa818fac 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -95,7 +95,6 @@ async function regularFile(path: string): Promise { return NodeFSP.readFile(path); } -/** No install on launch: writes happen only after the user chooses Install helper. */ export class KdeCaptureSetup { private readonly paths: KdeCapturePaths; constructor(paths: KdeCapturePaths) { @@ -144,7 +143,6 @@ export class KdeCaptureSetup { throw new Error( "Another desktop entry uses the capture helper's name. Rename it before continuing.", ); - // Never overwrite/follow a symlink, including the installation directory itself. const directory = NodePath.dirname(executable); const existing = await NodeFSP.lstat(directory).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; @@ -186,7 +184,6 @@ export class KdeCaptureSetup { await run("kbuildsycoca6", ["--noincremental"]); // AppImages change XDG search paths, and KService caches are also locale-specific. // Refresh Plasma's cache in its session environment, not just the app's cache. - // Non-systemd sessions use the direct refresh and atomic directory notification above. await run("systemd-run", [ "--user", "--quiet", diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts b/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts index dca715a4a8af..2759f761e8f3 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.dbus.test.ts @@ -224,7 +224,6 @@ it.runIf(hasDbus)("captures through real D-Bus marshalling on a private bus", as "--method", "com.t3tools.SnapShot.Capture", ]; - // Exercise the actual command copied to Niri's configuration, including gdbus introspection. await new Promise((resolve, reject) => { NodeChildProcess.execFile( "gdbus", diff --git a/apps/desktop/src/snapShot/LinuxSnapShot.ts b/apps/desktop/src/snapShot/LinuxSnapShot.ts index dffd2a71f36c..8603232e7e9f 100644 --- a/apps/desktop/src/snapShot/LinuxSnapShot.ts +++ b/apps/desktop/src/snapShot/LinuxSnapShot.ts @@ -172,7 +172,6 @@ export class LinuxCaptureConnection { }); if (kde !== undefined && decodeKde(kde).Version.value >= 2) return "kde"; } - // Sandboxed apps already have an identity. Host apps register before any portal calls. if (!process.env.FLATPAK_ID && !process.env.SNAP) { await this.call({ destination: PORTAL, @@ -297,7 +296,6 @@ export class LinuxCaptureConnection { }), ); } - // Closing this dedicated connection also removes the signal match. } } diff --git a/apps/desktop/src/snapShot/NativeCaptureFeedback.ts b/apps/desktop/src/snapShot/NativeCaptureFeedback.ts index a9211b89a3f7..6b5775633f0b 100644 --- a/apps/desktop/src/snapShot/NativeCaptureFeedback.ts +++ b/apps/desktop/src/snapShot/NativeCaptureFeedback.ts @@ -40,7 +40,6 @@ export async function startNativeCaptureFeedback( ready.resolve(undefined); landed.resolve(); child.stdin.end('{"command":"close"}\n'); - // Kill only our own child if the compositor fails to acknowledge cancellation. killTimer = setTimeout(() => child.kill(), 1000); killTimer.unref(); }; diff --git a/apps/desktop/src/snapShot/NiriCaptureShortcut.ts b/apps/desktop/src/snapShot/NiriCaptureShortcut.ts index bae9c2b962af..7746413cabea 100644 --- a/apps/desktop/src/snapShot/NiriCaptureShortcut.ts +++ b/apps/desktop/src/snapShot/NiriCaptureShortcut.ts @@ -6,7 +6,6 @@ import { NIRI_CAPTURE_PATH as PATH, } from "./linuxCaptureSession.ts"; -/** Niri owns the keybinding; this endpoint triggers capture without first focusing T3. */ export async function startNiriCaptureShortcut( appId: string, onCapture: () => void, diff --git a/apps/desktop/src/snapShot/NiriSnapShot.test.ts b/apps/desktop/src/snapShot/NiriSnapShot.test.ts index c25030650afd..313d335d3886 100644 --- a/apps/desktop/src/snapShot/NiriSnapShot.test.ts +++ b/apps/desktop/src/snapShot/NiriSnapShot.test.ts @@ -61,7 +61,6 @@ beforeEach(async () => { else if (typeof request !== "string" && request.Action.ScreenshotWindow) { const screenshot = request.Action.ScreenshotWindow; capturePath = screenshot.path; - // Focus may change between discovery and capture. The request must keep the original ID. focused = { ...window, id: 99, title: "Another app" }; await NodeFSP.writeFile(screenshot.path, png); for (const event of events) { diff --git a/apps/desktop/src/snapShot/NiriSnapShot.ts b/apps/desktop/src/snapShot/NiriSnapShot.ts index 87133aded548..c1384d581bf6 100644 --- a/apps/desktop/src/snapShot/NiriSnapShot.ts +++ b/apps/desktop/src/snapShot/NiriSnapShot.ts @@ -48,7 +48,6 @@ const isWindowChanged = Schema.is(WindowChanged); const isWindowClosed = Schema.is(WindowClosed); const isScreenshotCaptured = Schema.is(ScreenshotCaptured); -/** A session hint, never a probe of another compositor or a sandbox escape. */ export function niriSocketPath(env = process.env): string | undefined { if (env.FLATPAK_ID || env.SNAP) return undefined; if (!env.XDG_CURRENT_DESKTOP?.split(":").some((name) => name.toLowerCase() === "niri")) @@ -56,7 +55,6 @@ export function niriSocketPath(env = process.env): string | undefined { return env.NIRI_SOCKET && NodePath.isAbsolute(env.NIRI_SOCKET) ? env.NIRI_SOCKET : undefined; } -/** One bounded exchange or event subscription. All waiters end when the socket closes. */ class NiriConnection { private readonly socket: NodeNet.Socket; private readonly listeners = new Set<(value: unknown) => void>(); diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts index 67794846f3dc..acd9a29b3220 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts @@ -90,7 +90,6 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)( ]; results = { shortcuts: new Variant("a(sa{sv})", bound) }; } - // Exercise the fast-portal race: Response is delivered before the method's handle reply. const response = Message.newSignal( handle, "org.freedesktop.portal.Request", diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts index 7487e2f582ba..ec42af46a388 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts @@ -54,13 +54,11 @@ export interface PortalShortcutState { readonly shortcutActionRegistered?: boolean; readonly shortcutRegistered: boolean; readonly shortcutPending: boolean; - /** Whether retry-shortcut can reopen permissions or start a new session. */ readonly shortcutCanRetry?: boolean; readonly shortcutLabel?: string; readonly shortcutMessage: string | null; } -/** Own the portal session, including consent and release; Electron only reports submission. */ export class PortalCaptureShortcut { state: PortalShortcutState = { shortcutRegistered: false, @@ -117,15 +115,12 @@ export class PortalCaptureShortcut { try { if (this.pending) this.closeObject(this.pending.path, REQUEST); if (this.session) this.closeObject(this.session, SESSION); - } catch { - // A broken bus is already closing its sessions; local cleanup must still run. - } + } catch {} this.bus.removeListener("message", this.message); this.responses.clear(); this.bus.disconnect(); }; - /** A previously denied binding is changed by the desktop, not by bypassing its decision. */ get hasSession() { return !this.closed && Boolean(this.session); } @@ -161,7 +156,6 @@ export class PortalCaptureShortcut { this.update({ shortcutRegistered: false, shortcutPending: false, - // This failed session is closing, so retry can register a fresh one. shortcutCanRetry: !this.managedByHyprland, shortcutMessage: this.managedByHyprland ? "Couldn't connect to Hyprland shortcuts. Make sure xdg-desktop-portal-hyprland is running, then restart T3 Code." diff --git a/apps/desktop/src/snapShot/RegionSnapShot.ts b/apps/desktop/src/snapShot/RegionSnapShot.ts index 46da3f78f985..c640198524af 100644 --- a/apps/desktop/src/snapShot/RegionSnapShot.ts +++ b/apps/desktop/src/snapShot/RegionSnapShot.ts @@ -55,10 +55,6 @@ export type RegionSnapShotProcess = { readonly close: () => void; }; -/** - * One capture child. `warm` it ahead of time so the native module is loaded - * before the shortcut fires; each child serves a single capture and exits. - */ export function startRegionSnapShotProcess( workerPath: string, fork: (workerPath: string) => RegionSnapShotChild = forkRegionSnapShotChild, diff --git a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts index fcc7b56259a7..374745c96e60 100644 --- a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts +++ b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts @@ -1,5 +1,5 @@ -// Windows region capture. Runs in a forked Node-mode child so the native -// screenshot call cannot crash or stall the main process. One request per child. +// Runs in a forked Node-mode child so the native screenshot call cannot crash +// or stall the main process. import type { RegionSnapShotRequest, RegionSnapShotResult } from "./RegionSnapShot.ts"; process.once("disconnect", () => process.exit(0)); diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index 01532d0e26c4..d1cd732ec4ac 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -17,7 +17,6 @@ type SnapShotAnimationDetails = { export type SnapShotAnimationDestination = { readonly frame: Electron.Rectangle; - /** Unit coordinates in T3's content area; GNOME supplies the real compositor origin. */ readonly relativeFrame?: Electron.Rectangle | undefined; readonly backgroundColor: string; readonly borderColor: string; @@ -410,7 +409,6 @@ export class SnapShotTransition { }), ); if (this.active !== active) return; - // One display failing must not cut the flight short on the others. await Promise.allSettled( prepared.map((result) => { if (result.status !== "fulfilled" || !result.value || result.value.window.isDestroyed()) { diff --git a/apps/desktop/src/snapShot/captureConfigEdit.test.ts b/apps/desktop/src/snapShot/captureConfigEdit.test.ts index ea2953ceddc3..6d57d35ee8b6 100644 --- a/apps/desktop/src/snapShot/captureConfigEdit.test.ts +++ b/apps/desktop/src/snapShot/captureConfigEdit.test.ts @@ -20,7 +20,6 @@ describe("Niri capture config edits", () => { }); it("ignores commented, nested, and string-valued binds and braces", () => { const before = `// binds { fake\n/* binds { /* nested */ } */\n/- binds { Ctrl+Shift+2 { quit; } }\nrecent-windows { binds { Alt+Tab { next-window; } }; }\nspawn-at-startup "echo" r#"binds { }"#\n"binds" {\n Mod+Q { spawn "a } \\" b"; }\n}\n`; - // A quoted brace in a valid shell argument must not close the binds section. const valid = before.replace('"a } \\" b"', '"a } b"'); const result = editCaptureConfig(valid, "niri", app, "install"); expect(result.after).toContain(` ${binding}\n}\n`); diff --git a/apps/desktop/src/snapShot/captureConfigEdit.ts b/apps/desktop/src/snapShot/captureConfigEdit.ts index c46211faa8ac..7de20719b3aa 100644 --- a/apps/desktop/src/snapShot/captureConfigEdit.ts +++ b/apps/desktop/src/snapShot/captureConfigEdit.ts @@ -188,7 +188,6 @@ export function editCaptureConfig( readKdlNodes(after); } else { // Do not append executable Lua inside a block/string, or after an early return. - // The normal Omarchy binding file is a sequence of top-level hl.bind calls. after += `${after && !after.endsWith("\n") ? newline : ""}${newline}${binding}${newline}`; } return { after, shortcut: keys }; diff --git a/apps/desktop/src/snapShot/captureConfigKdl.ts b/apps/desktop/src/snapShot/captureConfigKdl.ts index f3b3beab8c26..ca8527549174 100644 --- a/apps/desktop/src/snapShot/captureConfigKdl.ts +++ b/apps/desktop/src/snapShot/captureConfigKdl.ts @@ -2,7 +2,6 @@ import * as Schema from "effect/Schema"; const decodeString = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.String)); -/** A lossless structural reader: edits only binding nodes, never reformats a user's KDL. */ type Token = { value: string; start: number; end: number; quoted: boolean }; export type KdlNode = { name: string; diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 2cfa406d1781..0c85a0148425 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -25,7 +25,6 @@ interface AccessibilityTreeNode { const MAX_ACCESSIBILITY_TREE_NODES = 10_000; const WINDOW_BLUR_TIMEOUT_MS = 1_000; -/** Win32 virtual-key codes for the left and right key of each modifier pair. */ export const WINDOWS_MODIFIER_PAIR_VIRTUAL_KEYS: Record< SnapShotModifier, readonly [number, number] @@ -536,7 +535,6 @@ const ELECTRON_KEY_NAMES: Readonly> = { Escape: "Esc", }; -/** Two shortcuts are the same when they would register the same listener. */ export function sameSnapShotShortcut(left: SnapShotShortcut, right: SnapShotShortcut): boolean { if (isModifierPairShortcut(left) || isModifierPairShortcut(right)) { return ( diff --git a/apps/desktop/src/telemetry/DesktopRendererHistory.ts b/apps/desktop/src/telemetry/DesktopRendererHistory.ts index 1d65d5c56f8d..ed1590f63b19 100644 --- a/apps/desktop/src/telemetry/DesktopRendererHistory.ts +++ b/apps/desktop/src/telemetry/DesktopRendererHistory.ts @@ -72,7 +72,6 @@ interface TrackedRenderer { const nonnegative = (value: number): number | null => Number.isFinite(value) && value >= 0 ? Math.round(value) : null; -/** Small restart-surviving PID history; never reads URLs or page content. */ const make = Effect.fn("DesktopRendererHistory.make")(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const path = yield* Path.Path; diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 7651eb92da01..30c9e7517129 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -467,8 +467,6 @@ describe("DesktopTelemetryPublisher", () => { } as const; yield* publisher.publishUpdateReport(report); - // A subscriber that attaches after the publish (the backend spawned - // by a relaunch) still sees the latest report replayed. const decoder = new TextDecoder(); const decodeMessage = Schema.decodeUnknownEffect( Schema.fromJsonString(DesktopHostTelemetryMessage), @@ -658,7 +656,6 @@ describe("DesktopRendererHistory", () => { assert.equal(crash.rendererPidSource, "last-known"); assert.equal(crash.reason, "oom"); assert.equal(crash.exitCode, -7); - // A restarted renderer can reuse the same numeric PID. const restarted = yield* Deferred.make(); milestone = { matches: (row) => row.event === "dom-ready", written: restarted }; previewPid = 7_003; @@ -684,7 +681,6 @@ describe("DesktopRendererHistory", () => { yield* history.recordMetrics(metrics); assert.equal((yield* Deferred.await(afterDestroy)).webContentsId, 1); assert.isFalse(persisted.slice(previousCount).some((row) => row.webContentsId === 2)); - // The app quit handshake drains before the history layer closes. for (let index = 0; index < 12; index++) main.emit("dom-ready"); yield* history.shutdown; assert.equal( diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index c0369e702fa1..1155a2b15b0c 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -69,11 +69,7 @@ export class DesktopTelemetryPublisher extends Context.Service< message: DesktopTelemetryControlMessage, ) => Effect.Effect; readonly removeControlSource: (sourceId: string) => Effect.Effect; - /** Sends the report to the attached backend and replays the latest one - to backends that attach later (including the one spawned after a - relaunch). */ readonly publishUpdateReport: (report: DesktopUpdateStatusReport) => Effect.Effect; - /** Update requests received over the control channel. Single consumer. */ readonly updateRequests: Stream.Stream; readonly updateCommits: Stream.Stream; readonly updateCancellations: Stream.Stream; @@ -219,7 +215,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { { concurrency: "unbounded" }, ); // One Electron sampler preserves the shared CPU measurement interval. - // History follows the existing power-aware cadence, even without demand. yield* rendererHistory.recordMetrics(metrics); const polledLocked = systemIdleState === "unknown" diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 1198429a1700..1fc0a9e39be5 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -112,7 +112,6 @@ describe("DesktopRemoteUpdates", () => { yield* settle; assert.equal(harness.quitAndInstalls(), 1); - // The mirror stamped the in-run state changes with the request id. const statuses = reports .filter((report) => report.requestId === "req-1") .map((report) => report.state.status); @@ -150,8 +149,6 @@ describe("DesktopRemoteUpdates", () => { }); yield* settle; - // Install failures reduce to status "downloaded" + errorContext - // "install"; the prepared result is followed by the commit failure. const terminals = terminalReports(reports); assert.deepEqual( terminals.map((report) => report.outcome), @@ -185,7 +182,6 @@ describe("DesktopRemoteUpdates", () => { ["ready-to-install"], ); - // A local install takes the updater reservation and starts shutdown. const localInstall = yield* updates.install.pipe(Effect.forkChild); yield* Deferred.await(installStarted); yield* Queue.offer(commits, { @@ -195,8 +191,6 @@ describe("DesktopRemoteUpdates", () => { }); yield* settle; - // No "failed" marker: that install relaunches the app and the - // client proves the handoff by reconnecting on the target version. assert.deepEqual( terminalReports(reports).map((report) => report.outcome), ["ready-to-install"], @@ -271,9 +265,6 @@ describe("DesktopRemoteUpdates", () => { requestId: "req-5", }); yield* settle; - // First run failed; state is "downloaded" with a lingering - // errorContext "install". The retry succeeds and must not report - // that leftover as a fresh failure. yield* Queue.offer(requests, request("req-6")); yield* settle; yield* Queue.offer(commits, { @@ -336,9 +327,7 @@ describe("DesktopRemoteUpdates", () => { it.effect("retries a download refused while the check still holds the reservation", () => { // electron-updater emits update-available from inside checkForUpdates, - // before the check action releases its reservation. The download the - // remote flow forks in response is refused and must be retried once the - // reservation frees up, without burning a download attempt. + // before the check action releases its reservation. const releaseCheck = Deferred.makeUnsafe(); const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); @@ -348,7 +337,6 @@ describe("DesktopRemoteUpdates", () => { yield* settle; assert.equal(harness.checkCount(), 1); - // Fire "available" while the check reservation is still held. harness.emit("update-available", { version: "1.2.4" }); yield* settle; assert.equal(harness.downloadCount(), 0); @@ -409,14 +397,11 @@ describe("DesktopRemoteUpdates", () => { const desktopState = yield* DesktopState.DesktopState; harness.emit("update-downloaded", { version: "1.2.4" }); yield* settle; - // Another install already owns the shutdown. yield* Ref.set(desktopState.quitting, true); yield* Queue.offer(requests, request("req-9")); yield* settle; - // Report "installing" once, no "failed" after the refusal: that - // install will relaunch the app and this request rides along. assert.deepEqual( terminalReports(reports).map((report) => report.outcome), ["ready-to-install"], diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index 31e3c5af5260..716a73c199aa 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -24,7 +24,6 @@ import { const { logInfo, logError } = DesktopObservability.makeComponentLogger("desktop-remote-updates"); -/** Pause before retrying an action the updater refused for a held reservation. */ const ACTION_RETRY_DELAY = Duration.millis(250); const PREPARED_UPDATE_TTL = Duration.minutes(5); @@ -42,11 +41,6 @@ type CommitClaim = | { readonly _tag: "committing" } | { readonly _tag: "claimed"; readonly prepared: PreparedUpdate }; -/** - * Server-triggered desktop updates. Mirrors updater state, prepares downloads, - * cancels abandoned preparations, and commits installs after the remote client - * confirms that it received the preparation token. - */ export const listen: Effect.Effect< void, never, @@ -181,12 +175,11 @@ export const listen: Effect.Effect< const { latest, changes } = yield* updates.subscribe; const disabledReason = Option.getOrNull(yield* updates.disabledReason); let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; - // The updater admits one action at a time. A state event can land - // while the action that produced it still holds the reservation - // (e.g. "available" before the check releases), so a forked action - // can be refused with no later state event to retry on. Rejected - // actions re-enqueue their state here after a short pause so the - // step runs again once the reservation is free. + // A state event can land while the action that produced it still holds + // the reservation (e.g. "available" before the check releases), so a + // forked action can be refused with no later state event to retry on. + // Rejected actions re-enqueue their state here after a short pause so + // the step runs again once the reservation is free. const retries = yield* Queue.unbounded(); const retryLater = (state: DesktopUpdateState) => Effect.sleep(ACTION_RETRY_DELAY).pipe( @@ -195,16 +188,12 @@ export const listen: Effect.Effect< Effect.forkScoped, ); - // Returns true when the run reached a terminal outcome. const step = (state: DesktopUpdateState): Effect.Effect => Effect.gen(function* () { const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason); switch (next.action) { case "wait": return false; - // Counters increment before the action so the state event the - // action produces already sees it; a refusal for a held - // reservation rolls the count back, since it was not a try. case "check": attempts = { ...attempts, checks: attempts.checks + 1 }; yield* updates.check("remote-update").pipe( @@ -315,8 +304,6 @@ export const listen: Effect.Effect< ), ); - // Sequential by construction: a second remote request queued mid-run is - // handled after the current one, when the state machine resolves it fast. yield* Stream.runForEach(publisher.updateRequests, handleRequest).pipe(Effect.forkScoped); yield* Stream.runForEach(publisher.updateCancellations, (cancellation) => diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 26ad0f1c4e70..8fc67dc11759 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -686,7 +686,6 @@ describe("DesktopUpdates", () => { const updates = yield* DesktopUpdates.DesktopUpdates; yield* updates.configure; assert.equal((yield* updates.getState).notAppliedVersion, "1.2.4"); - // Kept, so the next start on 1.2.3 still knows 1.2.4 did not apply. assert.isDefined(harness.previousInstallRecord()); harness.emit("update-available", { version: "1.2.4" }); @@ -762,9 +761,7 @@ describe("DesktopUpdates", () => { assert.isTrue(result.accepted); assert.isFalse(yield* Ref.get(desktopState.quitting)); assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); - // The restarted old backend must release its tunnel on a later quit. assert.equal(harness.updateRestartMarkers.size, 0); - // The app never quit, so the next start must not report an unapplied install. assert.equal(harness.installRecords.size, 0); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 77069d3ba3fe..58a44f29d6fa 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -73,7 +73,6 @@ const UpdateInfo = Schema.Struct({ const DownloadProgressInfo = Schema.Struct({ percent: Schema.Number, }); -/** Saved in the runtime folder before an install quits the app, read on the next start. */ export const DESKTOP_UPDATE_INSTALL_RECORD_FILE = "desktop-update-install.json"; const DesktopUpdateInstallRecord = Schema.fromJsonString( Schema.Struct({ fromVersion: Schema.String, targetVersion: Schema.String }), @@ -171,12 +170,8 @@ export class DesktopUpdates extends Context.Service< DesktopUpdates, { readonly getState: Effect.Effect; - /** True while a check, download, install, or channel change holds the - updater's single action reservation. */ readonly isActionActive: Effect.Effect; - /** True only while an install owns the updater action reservation. */ readonly isInstallActive: Effect.Effect; - /** Current state plus a stream of every later state change. */ readonly subscribe: Effect.Effect< { readonly latest: DesktopUpdateState; @@ -207,7 +202,6 @@ const { } = DesktopObservability.makeComponentLogger("desktop-updater"); function redactMessage(message: string): string { - // Updater HTTP errors append response bodies and headers after the first line. return (message.split(/[\r\n]/, 1)[0] ?? "") .replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, "[redacted URL]") .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, "[redacted credential]") @@ -254,7 +248,6 @@ function summarizeCause(cause: unknown, depth: number): string { return `${errorTag(cause)}${code}${message}${nested}`; } -/** Log a bounded cause summary without stacks, response payloads, or request credentials. */ export function describeUpdateErrorCause(cause: unknown): string { try { return summarizeCause(cause, 0).slice(0, 1024); @@ -587,9 +580,6 @@ export const make = Effect.gen(function* () { ); }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); - // Tells the primary backend that the coming stop is an update restart, so it - // keeps its managed tunnel for the backend the updated app starts. Best - // effort: without the marker the backend only re-provisions its tunnel. const updateRestartMarkerDir = environment.path.join(environment.baseDir, "runtime"); const updateRestartMarkerPath = environment.path.join( updateRestartMarkerDir, @@ -610,8 +600,6 @@ export const make = Effect.gen(function* () { .remove(updateRestartMarkerPath, { force: true }) .pipe(Effect.ignore); - // Records the version an install hands to the OS installer, so the next - // start can tell whether it applied. Best effort, like the restart marker. const installRecordPath = environment.path.join( updateRestartMarkerDir, DESKTOP_UPDATE_INSTALL_RECORD_FILE, @@ -627,10 +615,6 @@ export const make = Effect.gen(function* () { .remove(installRecordPath, { force: true }) .pipe(Effect.ignore); - // Reads the record of the last install. Returns the version it did not - // apply, when the app came back on the version it installed from. That - // record stays through later starts, until the version changes or a new - // install replaces it, so no restart forgets the failed version. const readNotAppliedInstallVersion = fileSystem.readFileString(installRecordPath).pipe( Effect.flatMap(decodeInstallRecord), Effect.option, @@ -750,13 +734,11 @@ export const make = Effect.gen(function* () { yield* writeUpdateRestartMarker; const { downloadedVersion } = yield* Ref.get(updateStateRef); if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion); - // Stop every backend in the pool, not just the primary. With - // parallel WSL + Windows backends, leaving the WSL instance up - // means quitAndInstall's app.quit() exits before the pool's - // scope cascade has a chance to run its stop finalizer, so the - // WSL child gets hard-killed by the OS instead of receiving - // SIGTERM + grace. Stops run concurrently with the same 5s - // budget the primary had on its own. + // Stop every backend in the pool, not just the primary. With parallel + // WSL + Windows backends, leaving the WSL instance up means + // quitAndInstall's app.quit() exits before the pool's scope cascade + // has a chance to run its stop finalizer, so the WSL child gets + // hard-killed by the OS instead of receiving SIGTERM + grace. const instances = yield* pool.list; yield* Effect.forEach( instances, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 3cab5f15e451..ef6d217299d0 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -125,13 +125,10 @@ interface NormalizedDesktopUpdateReleaseNotes { } /** - * Turns electron-updater's release notes into the groups the popover shows. * With `fullChangelog` on (nightly), electron-updater collects every GitHub - * release whose version is semver-greater than the running one, whatever - * train it belongs to; a maintainers' `-preview.` cut sorts above every - * `-nightly.` of the same base version and would lead the list. Only - * releases on the channel being followed are kept, the same test the - * updater applies to the offered version itself. + * release whose version is semver-greater than the running one, whatever train + * it belongs to; a maintainers' `-preview.` cut sorts above every `-nightly.` + * of the same base version and would lead the list. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, diff --git a/apps/desktop/src/updates/remoteUpdateFlow.test.ts b/apps/desktop/src/updates/remoteUpdateFlow.test.ts index acc8479013fc..d7afeafab879 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.test.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.test.ts @@ -75,8 +75,6 @@ describe("nextRemoteDesktopUpdateStep", () => { ), { action: "install" }, ); - // A previous install failure keeps status "downloaded", so a remote run - // retries the install. assert.deepEqual( nextRemoteDesktopUpdateStep( makeState({ @@ -90,8 +88,6 @@ describe("nextRemoteDesktopUpdateStep", () => { ), { action: "install" }, ); - // A download survives an unrelated background updater error, so a run - // installs it instead of replaying that error. assert.deepEqual( nextRemoteDesktopUpdateStep( makeState({ @@ -105,8 +101,6 @@ describe("nextRemoteDesktopUpdateStep", () => { ), { action: "install" }, ); - // A leftover download behind a check error is not installable: fresh - // runs re-check, and post-check the error is terminal. const staleError = makeState({ status: "error", downloadedVersion: "1.2.4", @@ -172,8 +166,6 @@ describe("nextRemoteDesktopUpdateStep", () => { }); it("re-checks stale up-to-date and error states before trusting them", () => { - // These states are retained from earlier/background checks; a remote - // request must look again instead of replaying them. assert.deepEqual( nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), NO_ATTEMPTS, null), { action: "check" }, diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts index a1523baee58c..91fb45fee3a4 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -1,10 +1,5 @@ import type { DesktopUpdateRemoteOutcome, DesktopUpdateState } from "@t3tools/contracts"; -/** - * What a server-triggered update run should do next, given the updater's - * current state. "wait" means an action (possibly started locally) is in - * flight and the run should ride along until the next state change. - */ export type RemoteDesktopUpdateStep = | { readonly action: "check" } | { readonly action: "download" } @@ -17,10 +12,10 @@ export type RemoteDesktopUpdateStep = }; /** - * How many times this run already issued each action. The caps are what stop - * a check -> up-to-date -> check loop and endless download retries; they are - * counts rather than booleans because a state event raced by the local - * 4-minute poller can re-show an already-handled status once. + * The caps are what stop a check -> up-to-date -> check loop and endless + * download retries; they are counts rather than booleans because a state event + * raced by the local 4-minute poller can re-show an already-handled status + * once. */ export interface RemoteDesktopUpdateAttempts { readonly checks: number; @@ -30,7 +25,6 @@ export interface RemoteDesktopUpdateAttempts { export const MAX_REMOTE_UPDATE_CHECKS = 2; export const MAX_REMOTE_UPDATE_DOWNLOADS = 3; -/** Same predicate DesktopUpdates.installDownloadedUpdate uses for admission. */ function isInstallableDesktopUpdateState(state: DesktopUpdateState): boolean { return ( state.downloadedVersion !== null && @@ -105,7 +99,6 @@ export function nextRemoteDesktopUpdateStep( reason: state.message ?? "The desktop app update failed.", }; } - // status === "idle" if (attempts.checks >= MAX_REMOTE_UPDATE_CHECKS) { return { action: "done", @@ -117,10 +110,9 @@ export function nextRemoteDesktopUpdateStep( } /** - * Normalizes an updater message for the `reason` wire field, which is a - * TrimmedNonEmptyString. Updater messages are plain strings and may be blank; - * a blank reason would fail encoding and silently drop the terminal report, - * leaving the server to wait for its timeout. + * Updater messages are plain strings and may be blank; a blank reason would + * fail encoding and silently drop the terminal report, leaving the server to + * wait for its timeout. */ export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { const trimmed = reason?.trim(); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index e7f9a2f8547d..1b1a1ec1efa9 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,12 +1,10 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; -// Preview builds are the maintainers' test train, cut by hand from unreleased -// branches to exercise the release flow. They share nightly's branding but -// are packaged without an update feed (see -// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the -// channel a preview install reports is cosmetic: it never checks for updates -// and no updater feed ever lists a preview release. +// They share nightly's branding but are packaged without an update feed (see +// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the channel +// a preview install reports is cosmetic: it never checks for updates and no +// updater feed ever lists a preview release. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index 48fcc07acda9..4d3852446b18 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -212,17 +212,11 @@ export function reduceDesktopUpdateStateOnInstallFailure( }; } -/** The install this app handed to the OS installer, saved right before it quits. */ export interface DesktopUpdateInstallRecord { readonly fromVersion: string; readonly targetVersion: string; } -/** - * Read on the next start: the version an install did not apply, because the - * app came back on the version it installed from. Null when the install - * applied, or when the app now runs some other version. - */ export function resolveNotAppliedInstallVersion( record: DesktopUpdateInstallRecord, appVersion: string, diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index c3701280ad55..d356506e99ca 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -15,10 +15,6 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; -/** Shared DesktopUpdates test harness: a fully stubbed updater layer whose - electron-updater events are driven by hand via `emit`. Used by - DesktopUpdates.test.ts and DesktopRemoteUpdates.test.ts. */ - export const flushCallbacks = Effect.yieldNow; export interface UpdatesHarnessOptions { @@ -35,9 +31,7 @@ export interface UpdatesHarnessOptions { readonly startBackend?: Effect.Effect; readonly env?: Record; readonly platform?: NodeJS.Platform; - /** Contents of the resources/package-type marker a Linux package ships. */ readonly packageType?: string | undefined; - /** Contents of the install record a previous run left behind. */ readonly installRecord?: string | undefined; } @@ -210,10 +204,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) : DesktopAppSettings.layer; - // Tracks the restart markers and install records installs leave, so - // installs stay free of real disk I/O that would outrun the tests' settle loops. const updateRestartMarkers = new Set(); - // Contents of the install record, by path once an install writes one. const installRecords = new Map(); let previousInstallRecord = options.installRecord; const isInstallRecord = (path: string) => @@ -275,7 +266,6 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { installSteps, updateRestartMarkers, installRecords, - /** The record the test started with, until the app removes it. */ previousInstallRecord: () => previousInstallRecord, downloadCount: () => downloadCount, feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f111dac25db8..cb0e5fa7c410 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -217,8 +217,6 @@ describe("DesktopApplicationMenu", () => { }), ); - // Chromium pastes as plain text for the accelerator on its own. Dispatching - // the action as well injects a second paste, which doubles the pasted text. it.effect("leaves the accelerator to Chromium instead of injecting a paste", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); @@ -248,9 +246,6 @@ describe("DesktopApplicationMenu", () => { }), ); - // Zoom must route through DesktopWindow.zoomMain instead of the Electron - // zoom roles: the roles zoom whichever webContents has focus, which breaks - // app zoom while an embedded preview WebContentsView holds focus. it.effect("routes View menu zoom to the main window instead of zoom roles", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index d0363fb01a27..07967834ff4c 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -144,8 +144,7 @@ export const make = Effect.gen(function* () { // Chromium already pastes as plain text for this chord, so the accelerator // needs nothing from the menu: the composer and the terminal each arm // themselves from the same keydown. Routing it through the renderer anyway - // lands a second, injected paste and doubles the text. Only a menu click, - // which produces no keystroke for them to see, needs that round trip. + // lands a second, injected paste and doubles the text. const pasteAsTextClick = ( _item: Electron.MenuItem, _window: Electron.BaseWindow | undefined, @@ -240,8 +239,7 @@ export const make = Effect.gen(function* () { /* Not the reload, DevTools or zoom roles: those act on the focused webContents, so with a browser page focused they reload or zoom - the guest page and the app UI appears stuck. These always target - the main window (see DesktopWindow.zoomMain). + the guest page and the app UI appears stuck. */ { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c9a49b1d735b..d17b530a86dd 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -347,11 +347,6 @@ function layerTest(input: { ); } -// Builds a DesktopWindow over a fake ElectronWindow whose `create` returns the -// given outcomes in order (null => simulated open failure), and whose -// currentMainOrFirst mirrors the real fallback to the first live window (the -// splash, before any main is registered). Reveal targets are recorded so tests -// can assert what activation actually surfaced. const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | null)[]) => Effect.gen(function* () { const createdWindows = yield* Ref.make([]); @@ -798,8 +793,6 @@ describe("DesktopWindow", () => { fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), [-0.5, -1, -0.5, 0], ); - // Recorded after the window level moved, so the preview is put back at - // its own zoom on every step rather than left on the inherited one. assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); }).pipe(Effect.provide(layer)); }), @@ -851,7 +844,6 @@ describe("DesktopWindow", () => { yield* desktopWindow.zoomMain(direction); const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0]; assert.isDefined(position); - // The 14-point native buttons should share the zoomed 52px header's center. const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor(); assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5); assert.equal(position.x, 16); @@ -929,9 +921,6 @@ describe("DesktopWindow", () => { }), ); - // The window boots hidden with throttling disabled so first paint runs at - // full speed; the first reveal must hand it back to normal hidden-window - // throttling or a minimized window stays expensive forever. it.effect("re-enables background throttling on first reveal", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1461,20 +1450,14 @@ describe("DesktopWindow", () => { Effect.gen(function* () { const splash = makeFakeBrowserWindow(); const main = makeFakeBrowserWindow(); - // create #1 -> splash, #2 -> fails (the pool swallows this post-readiness - // window-open error), #3 -> the real main on activate's retry. const scenario = yield* makeSplashScenario([splash.window, null, main.window]); yield* Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; - // 1. WSL-only boot shows the connecting splash. yield* desktopWindow.showConnectingSplash; assert.equal(yield* Ref.get(scenario.createCalls), 1); - // 2. Backend reports ready, but opening the real main fails. The pool - // swallows that error in production, so handleBackendReady fails - // here without a registered main window -- only the splash is open. const readyExit = yield* Effect.exit( desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), ); @@ -1482,9 +1465,6 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(scenario.createCalls), 2); assert.isTrue(Option.isNone(yield* Ref.get(scenario.mainWindow))); - // 3. Activating must not mistake the splash for the main window: it - // retries the open and brings up the real main instead of leaving - // the user stranded on "Connecting to WSL". yield* desktopWindow.activate; assert.equal(yield* Ref.get(scenario.createCalls), 3); const registeredMain = yield* Ref.get(scenario.mainWindow); @@ -1499,7 +1479,6 @@ describe("DesktopWindow", () => { () => Effect.gen(function* () { const splash = makeFakeBrowserWindow(); - // Only the splash is ever created; the backend never reports ready. const scenario = yield* makeSplashScenario([splash.window]); yield* Effect.gen(function* () { @@ -1508,8 +1487,6 @@ describe("DesktopWindow", () => { yield* desktopWindow.showConnectingSplash; assert.equal(yield* Ref.get(scenario.createCalls), 1); - // Taskbar/dock activation during cold boot must bring the splash back - // rather than no-op and leave it hidden until the backend finishes. yield* desktopWindow.activate; assert.equal(yield* Ref.get(scenario.createCalls), 1); assert.deepEqual(yield* Ref.get(scenario.revealedWindows), [splash.window]); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 75393d54a1b2..d9f9805edab1 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -57,21 +57,10 @@ const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; -// Renderer crash (usually V8 OOM on long sessions) recovery: reload after a -// short delay, at most MAX_ATTEMPTS times per rolling WINDOW so a renderer -// that dies on boot cannot reload-loop forever. const RENDERER_RECOVERY_RELOAD_DELAY_MS = 500; const RENDERER_RECOVERY_MAX_ATTEMPTS = 3; const RENDERER_RECOVERY_WINDOW_MS = 60_000; -const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ - -2, // ERR_FAILED - -7, // ERR_TIMED_OUT - -9, // ERR_UNEXPECTED (custom protocol handler rejected) - -102, // ERR_CONNECTION_REFUSED - -105, // ERR_NAME_NOT_RESOLVED - -106, // ERR_INTERNET_DISCONNECTED - -118, // ERR_CONNECTION_TIMED_OUT -]); +const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([-2, -7, -9, -102, -105, -106, -118]); type WindowTitleBarOptions = Pick< Electron.BrowserWindowConstructorOptions, @@ -106,21 +95,8 @@ export class DesktopWindow extends Context.Service< readonly revealOrCreateMain: Effect.Effect; readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; - // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only - // mode), before the WSL backend that acts as the primary is ready. It is - // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; - // Marks the primary backend as ready so `createMainIfBackendReady` and the - // macOS "activate without windows" path may open the real main window. The - // renderer now always loads the local client URL (getDesktopUrl) and connects - // to the backend through the connection layer, so the reported httpBaseUrl is - // no longer used to point the window at the backend β€” it is kept only for the - // readiness log and to preserve the callback contract the backend pool drives. readonly handleBackendReady: (httpBaseUrl: URL) => Effect.Effect; - // Called when the backend transitions back to "not ready" (clean stop, - // restart, crash). Clears the latch that lets `activate` auto-create a - // window so a "macOS dock click" while the backend is down doesn't - // produce a stranded window pointing at nothing. readonly handleBackendNotReady: Effect.Effect; readonly flushMainWindowBounds: Effect.Effect; readonly prepareCaptureReveal: Effect.Effect; @@ -128,22 +104,13 @@ export class DesktopWindow extends Context.Service< action: string, options?: { readonly reveal?: boolean }, ) => Effect.Effect; - /** - * Push a capture lifecycle event to the renderer. Only `started` reveals the - * window; the rest must not interrupt the app the user has switched to. - */ readonly dispatchSnapShotEvent: ( event: DesktopSnapShotEvent, ) => Effect.Effect; - // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut` - // menu roles act on whichever webContents has keyboard focus, so with an - // embedded preview WebContentsView (or DevTools) focused they zoom the - // guest page instead of the app UI. The menu routes here to always target - // the main window. + // The Electron `zoomIn`/`zoomOut` menu roles act on whichever webContents + // has keyboard focus, so with an embedded preview WebContentsView (or + // DevTools) focused they zoom the guest page instead of the app UI. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; - // Reload and DevTools for the main window's own webContents, for the same - // reason as zoomMain: the Electron roles act on the focused webContents, - // which is a preview guest whenever a browser page has focus. readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect; readonly syncAppearance: Effect.Effect; } @@ -156,7 +123,7 @@ function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, platform: NodeJS.Platform, ): { icon: string } | Record { - if (platform === "darwin") return {}; // macOS uses .icns from app bundle + if (platform === "darwin") return {}; const ext = platform === "win32" ? "ico" : "png"; return Option.match(iconPaths[ext], { onNone: () => ({}), @@ -207,9 +174,6 @@ export function resolveInitialMainWindowBounds( return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } -// A self-contained "Connecting to WSL" splash, shown immediately in wsl-only -// mode while the WSL backend (which serves the renderer) cold-boots. Inlined as -// a data URL so it needs no bundled asset and no backend β€” pure CSS, no JS. function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { const background = getInitialWindowBackgroundColor(shouldUseDarkColors); const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; @@ -334,14 +298,7 @@ export const make = Effect.gen(function* () { const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; - // Window-side latch for the primary backend's readiness. Set by - // handleBackendReady (driven by the pool's onReady callback), cleared - // by handleBackendNotReady (driven by onShutdown). Only consumed by - // createMainIfBackendReady, which gates the post-readiness window - // open in development and the macOS "activate without windows" path. const backendReadyRef = yield* Ref.make(false); - // The transient "Connecting to WSL" splash window, tracked separately so it - // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); @@ -359,9 +316,7 @@ export const make = Effect.gen(function* () { // which during WSL-only boot is the connecting splash. The splash is never // registered via setMain, so it must be treated as "no real main window" -- // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never - // open (or retry) the real main. That is the failure the pool's swallowed - // post-readiness window-open error would otherwise strand the user in: - // splash up, backend ready, no main, and activation only re-reveals splash. + // open (or retry) the real main. const withoutSplash = (window: Option.Option) => Ref.get(splashWindowRef).pipe( Effect.map((splash) => @@ -419,7 +374,6 @@ export const make = Effect.gen(function* () { ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, - // The window boots hidden (show: false until ready-to-show), and // Chromium throttles hidden renderers: timers coalesce and rAF stops, // which stalls first paint. Boot unthrottled; the first-reveal trigger // re-enables throttling so a hidden or minimized window goes back to @@ -656,9 +610,6 @@ export const make = Effect.gen(function* () { // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. - // Deliberate presses still flow through the renderer or native menu. - // Intercept the quit accelerator before the native menu sees it and apply - // the configured direct, hold, or double-press behavior. const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, getMode: () => @@ -806,12 +757,8 @@ export const make = Effect.gen(function* () { details.reason === "crashed" || details.reason === "oom" || details.reason === "abnormal-exit"; - // Long sessions can OOM the renderer (V8 heap exhaustion from - // accumulated thread state). Without a reload the user is left staring - // at a dead white window while agents keep running invisibly, so - // recover by reloading β€” the renderer rehydrates from the backend, - // which is unaffected. Recovery attempts are bounded so a renderer - // that dies immediately on boot cannot reload-loop forever. + // Recovery attempts are bounded so a renderer that dies immediately on + // boot cannot reload-loop forever. runFork( Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; @@ -844,13 +791,9 @@ export const make = Effect.gen(function* () { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } bindFirstRevealTrigger(revealSubscribers, () => { - // Boot is done; hand the window back to normal hidden-window throttling - // (see the backgroundThrottling comment on the create options above). if (!window.isDestroyed()) { window.webContents.setBackgroundThrottling(true); } - // Reveal the real window, then close the connecting splash (if any) so the - // two don't overlap and there's no blank gap between them. if (persistedSettings.mainWindowMaximized) { window.maximize(); } @@ -892,8 +835,6 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); - // With the local environment disabled there is no backend to wait for: the - // renderer is served from bundled assets and only talks to remote environments. const waitingForBackend = Effect.gen(function* () { if (yield* Ref.get(backendReadyRef)) return false; return (yield* desktopSettings.get).localEnvironmentEnabled; @@ -907,7 +848,6 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { - // Only when nothing is shown yet: no real window, no existing splash. const existingSplash = yield* Ref.get(splashWindowRef); if (Option.isSome(existingSplash)) return; const existingWindow = yield* electronWindow.currentMainOrFirst; @@ -946,7 +886,6 @@ export const make = Effect.gen(function* () { void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); yield* logWindowInfo("connecting splash shown"); }).pipe( - // The splash is best-effort UX β€” never let it fail startup. Effect.catch((error) => logWindowWarning("failed to show connecting splash", { message: error.message }), ), @@ -993,11 +932,6 @@ export const make = Effect.gen(function* () { yield* electronWindow.reveal(existingWindow.value); return; } - // No real main window yet. While the backend is still cold-booting, - // re-reveal the connecting splash so taskbar/dock activation brings it - // back instead of doing nothing. Once the backend is ready we fall - // through to (re)create the real main -- including retrying a previously - // failed open the pool swallowed -- rather than latching onto the splash. const backendReady = yield* Ref.get(backendReadyRef); if (!backendReady) { const splash = yield* Ref.get(splashWindowRef); @@ -1041,7 +975,6 @@ export const make = Effect.gen(function* () { return; } const webContents = window.value.webContents; - // Same step size as the Electron zoomIn/zoomOut menu roles. webContents.setZoomLevel( direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index 47f3ec5298ec..591fad185a5d 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -44,11 +44,9 @@ function makeHarness(options?: { const preventDefault = vi.fn(); const send = async (input: QuitHoldKeyInput) => { handler({ preventDefault }, input); - // Let the getMode promise settle. await Promise.resolve(); await Promise.resolve(); }; - // Simulates the OS auto-repeating the held shortcut every `intervalMs`. const holdFor = async ( durationMs: number, repeatOverrides: Partial = {}, @@ -80,7 +78,6 @@ describe("makeQuitShortcutHandler", () => { vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).not.toHaveBeenCalled(); - // The watchdog dismisses the hint once the press is clearly over. expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); @@ -128,8 +125,6 @@ describe("makeQuitShortcutHandler", () => { await harness.send(makeInput({})); await harness.holdFor(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS * 2); - // If neither keyUp reaches the handler, continued repeats must keep the - // app alive. Once they stop, the quiet period is the release signal. expect(harness.quit).not.toHaveBeenCalled(); vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); @@ -178,8 +173,6 @@ describe("makeQuitShortcutHandler", () => { await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); harness.preventDefault.mockClear(); - // Repeats without the modifier prove Q is still down, so they hold the - // quit back for as long as they keep arriving. await harness.holdFor(QUIT_HOLD_RELEASE_GRACE_MS * 2, { meta: false }); expect(harness.preventDefault).toHaveBeenCalled(); expect(harness.quit).not.toHaveBeenCalled(); @@ -199,7 +192,6 @@ describe("makeQuitShortcutHandler", () => { vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS); expect(harness.quit).toHaveBeenCalledTimes(1); - // A lone Cmd tap afterwards must not quit a second time. harness.quit.mockClear(); await harness.send(makeInput({ key: "Meta" })); await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); @@ -300,26 +292,21 @@ describe("makeQuitShortcutHandler", () => { ); it("discards a stale mode resolution from a superseded press", async () => { - // Press #1's mode is still pending when the user releases and - // presses again; its late resolution must not act for press #2. const resolvers: Array<(mode: QuitConfirmationMode) => void> = []; const harness = makeHarness({ getMode: () => new Promise((resolve) => resolvers.push(resolve)), }); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); - // Outside the double-press window, so the second press starts a new hold. vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS + 100); await harness.send(makeInput({})); expect(resolvers).toHaveLength(2); - // Press #1 resolves late with "direct". It must not quit press #2. resolvers[0]?.("direct"); await Promise.resolve(); await Promise.resolve(); expect(harness.quit).not.toHaveBeenCalled(); - // Press #2 resolves to hold and completes the gesture. resolvers[1]?.("hold"); await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); await harness.send(makeInput({ type: "keyUp" })); @@ -442,10 +429,8 @@ describe("makeQuitShortcutHandler", () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.holdFor(500); - // Shift pressed mid-hold breaks the gesture... await harness.send(makeInput({ shift: true })); expect(harness.notifications).toEqual([HOLD_DOWN, UP]); - // ...so later repeats past the threshold must not quit. await harness.holdFor(QUIT_HOLD_DURATION_MS); expect(harness.quit).not.toHaveBeenCalled(); }); @@ -454,7 +439,6 @@ describe("makeQuitShortcutHandler", () => { const harness = makeHarness({ mode: "double-click" }); await harness.send(makeInput({})); await harness.send(makeInput({ shift: true })); - // A fresh press right after the interruption starts a new attempt. await harness.send(makeInput({})); expect(harness.quit).not.toHaveBeenCalled(); expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index f656183f1375..eb0b3141c47a 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -2,18 +2,13 @@ import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contracts"; -// The quit accelerator is intercepted in before-input-event, which runs -// before the native menu accelerator. Quitting from the application menu is -// untouched and always quits immediately. export const QUIT_HOLD_DURATION_MS = 1200; export const QUIT_DOUBLE_PRESS_MS = 500; // "Still held" is proven by auto-repeat keydowns, not by the absence of a // release: macOS suppresses a letter keyUp while the command key is down, so a // tap release can go completely unseen and a release-based timer would quit // anyway. Once held, quitting waits for Q keyUp or a quiet grace period after -// repeats stop so they cannot reach the next app. Keyboards with -// auto-repeat disabled must use a double press or the application menu Quit action. -// Supporting holds without repeats requires a native physical key-state check. +// repeats stop so they cannot reach the next app. export const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. @@ -45,7 +40,6 @@ export function makeQuitShortcutHandler( let holding = false; let mode: QuitConfirmationMode | undefined; let notified = false; - // Set once getMode resolves to hold; auto-repeats may only complete the hold when armed. let armed = false; let quitOnRelease = false; let heldSince = 0; @@ -83,7 +77,6 @@ export function makeQuitShortcutHandler( } }; - // Dismisses any overlay first so a cancelled quit cannot leave a stale hint. const quitNow = () => { release(); lastPressAt = 0; @@ -125,18 +118,13 @@ export function makeQuitShortcutHandler( } if (quitOnRelease) { event.preventDefault(); - // A Q keydown proves the key is still down whether or not the modifier - // is still held, so it only pushes the quiet period back. if (key === "q") quitAfterQuietPeriod(); return; } if (!modifierDown || input.alt || input.shift || key !== "q") { - // Re-pressing the platform modifier is the first half of a second full - // quit shortcut, so it must not cancel an active double-press window. if (key === modifierKey && !input.alt && !input.shift) return; - // Other keys cancel the hold and the first tap, even after release. // Keep this separate from release(), which also runs when a fresh Q // keydown follows a keyUp that macOS did not deliver. if (!input.isAutoRepeat) { @@ -161,13 +149,9 @@ export function makeQuitShortcutHandler( const now = Date.now(); const previousPressAt = lastPressAt; lastPressAt = now; - // A fresh keydown supersedes the current physical hold or the hint kept - // alive after a detected release. if (holding || notified) release(); generation += 1; - // Every mode accepts two presses. Quit before reading settings so a slow - // read cannot delay the second press. Repeats never reach this branch. if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { quitNow(); return; @@ -196,7 +180,6 @@ export function makeQuitShortcutHandler( return; } - // A hold cannot be armed after its physical press has ended. if (!holding) return; mode = resolvedMode; @@ -204,14 +187,11 @@ export function makeQuitShortcutHandler( options.notify({ state: "down", mode: resolvedMode }); armed = true; - // No auto-repeat by then means the key was released (possibly with a - // suppressed keyUp) or repeat is disabled; either way, don't quit. watchdog = setTimeout(() => { watchdog = undefined; release(); }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); }, - // A failed settings read must never strand the quit request. () => { if (generation !== pressGeneration) return; quitNow(); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index fe131ca4f9c0..95e5e84d9134 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -1,25 +1,3 @@ -// Orchestrator that keeps the WSL pool instance in sync with the user's -// settings. `reconcile` is the single entry point β€” bootstrap calls it -// once after the primary backend starts, and the wsl.ts IPC calls it -// after persisting a `wslBackendEnabled` or `wslDistro` change. The -// effect is idempotent and never fails: errors (WSL not available, port -// allocation failed, register failed) get logged and reconcile returns -// having left the pool in a consistent state (either the previous WSL -// instance is still running, or none is). -// -// The instance id encodes the desired distro selection β€” `wsl:default` -// when the user picked "track the WSL default" (settings.wslDistro is -// null) and `wsl:` otherwise. Changing the distro setting -// changes the id, so reconcile unregisters the old instance before -// registering the new one. The label that the frontend env switcher -// renders is derived from the same field. -// -// Port allocation: each WSL instance gets a freshly scanned port to -// avoid colliding with the primary or with a previously-registered WSL -// instance that's still tearing down. The scan only checks loopback -// (127.0.0.1) since the WSL backend is loopback-only β€” the primary -// owns LAN exposure when the user opts in. - import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -37,10 +15,8 @@ import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; -// Exported so callers that parse pool ids (e.g. the pickFolder IPC -// handler in ipc/methods/window.ts) reference the same prefix this -// module produces. Keeping it inline in two places risks silent -// divergence if one ever gets renamed. +// Exported so callers that parse pool ids (e.g. the pickFolder IPC handler in +// ipc/methods/window.ts) reference the same prefix this module produces. export const WSL_INSTANCE_ID_PREFIX = "wsl:"; const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; const MAX_TCP_PORT = 65_535; @@ -48,14 +24,7 @@ const MAX_TCP_PORT = 65_535; export class DesktopWslBackend extends Context.Service< DesktopWslBackend, { - // Bring the pool in line with the current persisted WSL settings. - // Idempotent. Never fails (errors are logged); callers can chain it - // after persisting settings without an error-handling dance. readonly reconcile: Effect.Effect; - // Reason the dual-mode WSL secondary last failed preflight (no node, wrong - // version, missing build tools), or None. Read by the getWslState IPC so - // Connections settings can show it inline. None in wsl-only mode (that path - // surfaces via a dialog + Windows fallback). readonly lastPreflightError: Effect.Effect>; } >()("@t3tools/desktop/wsl/DesktopWslBackend") {} @@ -107,11 +76,6 @@ export const layer = Layer.effect( // with different distros, leaving the loser stranded. const reconcileMutex = yield* Semaphore.make(1); - // Last fatal preflight failure from the dual-mode WSL *secondary*, surfaced - // inline in Connections settings. The primary's failure is handled by the - // pool (dialog + Windows fallback) instead; here the app stays usable on - // Windows, so we record the reason rather than interrupting. Cleared on any - // reconcile state change so it reflects the current attempt. const preflightErrorRef = yield* Ref.make(Option.none()); const ownershipRefusedRef = yield* Ref.make( Option.none(), @@ -126,8 +90,6 @@ export const layer = Layer.effect( pool.unregister(id).pipe( Effect.catchTags({ DesktopBackendPoolCannotUnregisterPrimaryError: (cause) => - // Should never happen β€” wsl: ids are not the primary id β€” but - // log loudly if the logic ever drifts. logWslBackendWarning("refusing to unregister primary as wsl instance", { id, error: cause.message, @@ -166,9 +128,6 @@ export const layer = Layer.effect( id: targetId, label: Effect.succeed(buildLabel(input.distro)), configResolve: configuration.resolveWsl({ port: allocatedPort, distro: input.distro }), - // Dual-mode secondary: record a fatal preflight failure so Connections - // settings can show why the WSL backend never appeared. No dialog or - // fallback β€” Windows is the primary and keeps working. onPreflightFailed: (failure) => Ref.set(preflightErrorRef, Option.some(failure.reason)).pipe(Effect.as(false)), onStateDirOwned: () => @@ -220,7 +179,6 @@ export const layer = Layer.effect( ? Option.some(resolveTargetInstanceId(settings.wslDistro)) : Option.none(); - // No-op if the desired state already matches what's registered. if (Option.isNone(targetId) && Option.isNone(existingId)) { return; } @@ -243,9 +201,6 @@ export const layer = Layer.effect( return; } - // A real state change is happening (start, stop, or distro swap). Clear - // any stale secondary preflight error so it reflects this fresh attempt; - // onPreflightFailed re-sets it only if the new secondary exhausts retries. yield* Ref.set(preflightErrorRef, Option.none()); yield* Ref.set(ownershipRefusedRef, Option.none()); @@ -255,23 +210,13 @@ export const layer = Layer.effect( } if (Option.isSome(targetId)) { - // Pre-warm the WSL VM before registering so the readiness probe - // doesn't race wsl.exe's first-spawn cold start. preWarm tolerates - // distro=null (uses the WSL default) and is bounded by its own - // timeout, so it's safe to await unconditionally here. + // Pre-warm the WSL VM before registering so the readiness probe doesn't + // race wsl.exe's first-spawn cold start. yield* wslEnvironment.preWarm(settings.wslDistro); yield* startNew({ distro: settings.wslDistro }); } }); - // Top-level safety net. Every internal step today already catches - // its own failures (port allocation, register, preWarm), so the - // inferred error type is `never` and this catch is a no-op in - // steady state. It's here to enforce the file-header contract - // ("reconcile never fails; errors are logged") if a future change - // introduces an unhandled failure path β€” otherwise IPC callers - // like setWslBackendEnabled would surface it to the renderer as - // an opaque error. const reconcile = reconcileMutex .withPermits(1)(reconcileBody) .pipe( diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index a3cb739be0f6..0224eca440ea 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -35,8 +35,6 @@ const encoder = new TextEncoder(); const REQUIRED_SHELL_TOOLS = ["flock", "sha256sum", "tar", "mktemp"] as const; const posixShellRunner = (() => { - // Candidates rather than a platform switch: wsl.exe simply fails to spawn - // where it does not exist, which is the same answer as a shell missing flock. const candidates = [ { file: "bash", args: [] as ReadonlyArray }, { file: "wsl.exe", args: ["-e", "bash"] as ReadonlyArray }, @@ -69,7 +67,6 @@ const runShell = (script: string) => { const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; -// Polls for up to five seconds until a background holder has taken effect. const awaitHolder = (condition: string) => `i=0; until ${condition}; do i=$((i + 1)); [ "$i" -lt 500 ] || exit 1; sleep 0.01; done`; @@ -83,8 +80,6 @@ const holdRuntimeBusy = (entry: string) => awaitHolder(`grep -qF -- ${entry} /proc/[0-9]*/cmdline 2>/dev/null`), ].join("\n"); -// Holds a cache's install lock the way a concurrent install does, and likewise -// releases it when the script exits. const holdInstallLock = (lock: string) => [ `exec 6> >(exec 9> ${lock}; flock -x 9; cat >/dev/null 2>&1)`, @@ -97,8 +92,6 @@ const readField = (stdout: string, field: string) => { return line.slice(field.length + 1).trim(); }; -// Stands in for the release's self-contained `t3` executable: the install -// script only asks it for `--version`. const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => @@ -229,8 +222,6 @@ describe("WSL runtime cache", () => { ); expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`); - // A warm cache exits before the hash, so reuse never pays for it, and the - // mismatch check runs before anything mutates the cache. const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); const digestChecked = script.indexOf("archive_sha=$(sha256sum"); const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); @@ -240,11 +231,6 @@ describe("WSL runtime cache", () => { expect(extracted).toBeGreaterThan(digestChecked); }); - // Invalidation revokes the ready marker without stopping the backend that - // failed the probe, so the next install can find an unready tree that a live - // process is still running out of. Deleting it there unlinks node_modules - // under that process; the pruner already refuses to touch in-use caches, and - // the install path has to refuse too. it("moves an in-use runtime aside instead of deleting it under a live backend", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", @@ -253,8 +239,6 @@ describe("WSL runtime cache", () => { ); expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); - // No /proc means no way to tell, and guessing wrong costs a backend its - // runtime, so an unknowable answer has to count as in use. expect(script).toContain(" [ -d /proc/1 ] || return 0"); expect(script).toContain(' if runtime_in_use "$runtime_root"; then'); @@ -265,9 +249,6 @@ describe("WSL runtime cache", () => { expect(inUseChecked).toBeGreaterThan(-1); expect(inUseChecked).toBeLessThan(moved); - // In use: keep the tree and restart the sweep's clock, because renaming - // preserves the directory's mtime and a long-installed tree would otherwise - // already be past the age gate. Idle: delete it now, as before. const kept = script.indexOf('touch "$runtime_stale"'); const deleted = script.indexOf('rm -rf "$runtime_stale"'); expect(kept).toBeGreaterThan(moved); @@ -281,13 +262,8 @@ describe("WSL runtime cache", () => { "b".repeat(64), ); - // The same proof the SSH runner and CLI installers use: executable, and - // `--version` exits 0. That is what decides arch and loadability, so no - // separate native probe is needed. expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); - // Readiness gates the short-circuit, so a cache whose executable broke - // reinstalls from the archive instead of being reused forever. const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); const readinessDefined = script.indexOf("runtime_is_ready() {"); const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); @@ -296,9 +272,6 @@ describe("WSL runtime cache", () => { expect(readinessDefined).toBeLessThan(readyShortCircuit); }); - // A swapped or half-written `t3` can still exist and even still answer - // `--version`, and launch then runs something this install never verified. - // The digest the install records is what turns that into a miss. it("re-hashes the executable against the digest the install recorded", () => { const script = buildWslRuntimeInstallScript( "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", @@ -310,14 +283,11 @@ describe("WSL runtime cache", () => { expect(script).toContain( ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', ); - // A runtime installed before the marker carried a digest reads as empty, - // which has to be a miss rather than a pass. expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&'); expect(script).toContain( `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`, ); - // The digest is recorded after extraction and before promotion. const extracted = script.indexOf("tar -xzf"); const digestRecorded = script.indexOf( 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', @@ -338,8 +308,6 @@ describe("WSL runtime cache", () => { expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); - // The extracted tree is rejected before the ready marker is written, so a - // defective archive falls back to the mounted tree instead of caching. const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); @@ -370,18 +338,11 @@ describe("WSL runtime cache", () => { it("never deletes a runtime another backend is running from", () => { const script = buildWslRuntimePruneScript("1.2.3/x64"); - // The running backend's argv holds `/t3`, so - // the process itself is the lease and exiting releases it. Nothing has to be - // registered up front, which is what makes this cover backends already - // running from an older version that knows nothing about pruning. expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); expect(script).toContain(' ! runtime_in_use "$candidate" || continue'); - // Without visible processes the retention rules cannot tell a live cache - // from an abandoned one, so the sweep is skipped rather than guessed at. expect(script).toContain("[ -d /proc/1 ] || exit 0"); - // The guard has to gate the delete, not just exist. const inUseChecked = script.indexOf('! runtime_in_use "$candidate"'); const removed = script.indexOf('rm -rf -- "$candidate"'); expect(inUseChecked).toBeGreaterThan(-1); @@ -391,31 +352,20 @@ describe("WSL runtime cache", () => { it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => { const script = buildWslRuntimePruneScript("1.2.3/x64"); - // Dot-prefixed, so `"$runtime_parent"/*` never matches them, and they carry - // no ready marker either; without this pass a killed install leaks forever. expect(script).toContain( 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', ); - // Age guard: a scratch directory younger than this belongs to a live install. expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120'); }); it("invalidates a cache by dropping its ready marker, not the tree", () => { const script = buildWslRuntimeInvalidateScript("1.2.3/x64"); - // Readiness is a presence check, so a tree whose pty.node is present but - // unloadable stays ready forever unless the probe can revoke the marker. expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"'); - // Deleting the tree here would pull it out from under any backend still - // running from it; the next install moves an unready root aside instead. expect(script).not.toContain("rm -rf"); }); }); -// Reading the generated script proves what it says, not what it does. A cache -// whose entry was truncated satisfied every assertion above and still got -// reused, so these run the real script against a real archive in a throwaway -// HOME and check the outcome. describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { const fixtures: Array = []; @@ -429,8 +379,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed [ "set -eu", "work=$(mktemp -d)", - // Mirrors the release archive: one top-level versioned directory that - // holds the executable and its native addons. 'stage="$work/stage/t3-0.0.0-linux-x64"', 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, @@ -474,8 +422,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed [ `export HOME=${sh(`${fixture.work}/home`)}`, 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"', - // Isolate login profiles and hide the host's Node/version managers. - // The resolver must discover the fixture's installation itself. "bash() { (", " command() {", ' case "$*" in', @@ -541,8 +487,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed it("reuses a warm cache without touching the archive", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); - // Deleting the archive is how the test tells reuse apart from a silent - // reinstall: only the warm path can succeed without it. expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0); const warm = fixture.install(); @@ -572,20 +516,12 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed const broken = fixture.install(); - // Non-zero with no runtimeRoot is what sends the backend to the mounted - // server tree. Exiting 0 here is the bug: launch would pick the zero-byte - // server, fail to become ready, and do it again on every restart. expect(broken.status).not.toBe(0); expect(parseWslRuntimeRoot(broken.stdout)).toBeNull(); }); it("extracts once when two installs race for the same cache", () => { const fixture = createFixture(); - // A tar shim counts extractions and holds the critical section open long - // enough that the second install is certain to arrive while the first is - // still inside it. One extraction is the answer either way the runs - // interleave: whoever waits for the lock re-checks readiness before - // spending an extract, so a broken lock shows up as two. const raced = runShell( [ "set -eu", @@ -626,9 +562,6 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed it("leaves no half-built cache when extraction fails", () => { const fixture = createFixture(); - // Truncating the archive and re-recording its digest gets the install past - // the digest gate and into a tar that dies mid-stream, which is what a full - // disk or an interrupted write looks like from inside the distro. const truncated = runShell( [ "set -eu", @@ -647,21 +580,12 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(failed.status).not.toBe(0); expect(parseWslRuntimeRoot(failed.stdout)).toBeNull(); - // A partial extract that survived under the cache name would be promoted by - // the next launch's readiness check; scratch that survived would sit there - // until the pruner's age sweep. Neither is left behind. Only directories - // are counted: the empty flock file stays on purpose, which is what keeps - // the lock from carrying stale state across a killed install. const leftovers = runShell( `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`, ); expect(leftovers.stdout.trim()).toBe(""); }); - // The archive and the identity recorded beside it can diverge β€” a partial - // download, or a rebuilt archive dropped next to an older sidecar. Either - // gate firing means the bytes never reach the cache under a name that claims - // to describe something else. it("refuses an archive whose bytes do not match the digest recorded for it", () => { const fixture = createFixture(); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index dedf4a4a3427..7e00f1b40ad1 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -34,9 +34,6 @@ export interface EnsureWslNodePtyOptions { readonly nodeEngineRange?: string | null; } -// The packaged WSL runtime archive plus the SHA-256 identity the build recorded -// for it. The cache key derives from the same digest, and installation verifies -// the bytes before promoting the extracted tree. export interface WslRuntimeArchive { readonly windowsPath: string; readonly runtimeId: string; @@ -66,9 +63,6 @@ export type EnsureWslNodePtyResult = readonly retryLimit?: number; }; -// Outcome of asking the staged self-contained runtime to prove itself. Any -// failure sends the launch to the mounted server tree; the caller decides what -// to do with the cache. export type ProbeWslRuntimeResult = | { readonly ok: true; @@ -104,26 +98,19 @@ export class DesktopWslEnvironment extends Context.Service< distro: string | null, windowsPath: string, ) => Effect.Effect>; - // Resolves the user's Linux home dir inside the chosen distro (e.g. - // "/home/josh"). Used by the folder picker to expand `~` correctly. readonly getUserHome: (distro: string | null) => Effect.Effect>; - // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter - // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's - // httpBaseUrl so the renderer can reach it without relying on wslhost's - // localhostβ†’WSL automatic forwarding, which is flaky in practice - // (the backend can be listening for 30+ seconds before wslhost starts - // forwarding 127.0.0.1:port to WSL-side localhost). + // The orchestrator uses this for the WSL backend's httpBaseUrl so the + // renderer can reach it without relying on wslhost's localhostβ†’WSL + // automatic forwarding, which is flaky in practice (the backend can be + // listening for 30+ seconds before wslhost starts forwarding 127.0.0.1:port + // to WSL-side localhost). readonly getDistroIp: (distro: string | null) => Effect.Effect>; readonly prepareRuntime: ( distro: string | null, archive: WslRuntimeArchive, ) => Effect.Effect; readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect; - // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; - // Proves a staged self-contained runtime can run (`/t3 --version`) - // and resolves the user's PATH, including version-managed Node for provider - // CLIs. Node is optional; the mounted tree still requires ensureNodePty. readonly probeRuntime: ( distro: string | null, linuxAppRoot: string, @@ -183,9 +170,6 @@ const formatWslShellTransportFailureReason = ( } }; -// Reuse the SSH remote resolver so WSL and SSH discover version-managed Node -// the same way. Passing the engine range lets the resolver fall through to -// version managers like nvm when a system node exists but is too old. const buildWslNodeEnvPreamble = ( nodeEngineRange?: string | null, ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} @@ -193,8 +177,7 @@ ensure_remote_node_path || true `; // wsl.exe re-escapes args before forwarding them to the Linux side, which -// mangles quotes inside `bash -lc "', @@ -399,7 +388,6 @@ describe("HtmlRender", () => { expect(reference.heights).toEqual( HTML_RENDER_MEASURE_WIDTHS.map((width) => [width, Math.ceil(width / 2)]), ); - // The chart as the column-width load laid it out, in 4px units. expect(reference.outline).toBe("m0,0,182,91,1"); }).pipe(Effect.provide(layerHtmlRender(executable))); }), diff --git a/apps/server/src/htmlRender/HtmlRender.ts b/apps/server/src/htmlRender/HtmlRender.ts index bf982a3e9ce6..e927bd56a197 100644 --- a/apps/server/src/htmlRender/HtmlRender.ts +++ b/apps/server/src/htmlRender/HtmlRender.ts @@ -82,34 +82,24 @@ export type HtmlRenderPrepareError = | HtmlRenderPageTooLargeError; export interface HtmlPreview { - /** Base64 PNG of the top `capturedHeight` pixels. */ readonly png: string; readonly width: number; - /** Height the page needs to show without scrolling. */ readonly contentHeight: number; readonly capturedHeight: number; readonly consoleMessages: ReadonlyArray; - /** Local image paths that could not be read; they show as broken images. */ readonly missingImages?: ReadonlyArray; } export class HtmlRender extends Context.Service< HtmlRender, { - /** The self-contained page that gets stored: local images inlined, theme bootstrap injected. */ readonly prepare: (html: string) => Effect.Effect; - /** - * Stores a prepared page as a thread attachment for clients to show inline, - * measuring its height at each client width when the preview browser is - * already installed. - */ readonly publish: (input: { readonly threadId: ThreadId; readonly html: string; readonly title: string; readonly height: number; }) => Effect.Effect; - /** Screenshots a page in headless Chrome, tolerating unreadable local images. */ readonly preview: (input: { readonly html: string; readonly width?: number | undefined; @@ -138,10 +128,7 @@ const IMAGE_MIME_TYPES: Record = { ico: "image/x-icon", }; const IMAGE_EXTENSIONS = Object.keys(IMAGE_MIME_TYPES).join("|"); -// POSIX `/…` (not protocol-relative `//…`) or Windows `C:\…` / `C:/…`. const ABSOLUTE_PATH = String.raw`(?:/(?!/)|[a-z]:[\\/])`; -// An absolute image path that is a whole quoted string ("…", '…', `…`) or an -// unquoted CSS url(…). URLs, data:, blob:, and relative paths never match. const LOCAL_IMAGE_PATTERN = new RegExp( String.raw`(["'\x60])(${ABSOLUTE_PATH}(?:(?!\1)[^\r\n]){0,2048}?\.(?:${IMAGE_EXTENSIONS}))\1` + String.raw`|url\(\s*(${ABSOLUTE_PATH}[^\s"'\x60()]{0,2048}?\.(?:${IMAGE_EXTENSIONS}))\s*\)`, @@ -184,17 +171,11 @@ const isImageBytes = (bytes: Uint8Array) => { return hasSvgRoot(new TextDecoder().decode(bytes.subarray(0, 4096))); }; -/** The index just past `token` at or after `from`, or -1 when it never appears. */ const after = (text: string, token: string, from: number) => { const at = text.indexOf(token, from); return at === -1 ? -1 : at + token.length; }; -/** - * Whether an XML document's root element is , after any processing - * instructions, comments, and a doctype. One forward pass, so no input can - * make it slow, and quoted text never counts as markup. - */ const hasSvgRoot = (text: string) => { let at = 0; while (at !== -1) { @@ -208,7 +189,6 @@ const hasSvgRoot = (text: string) => { return false; }; -/** The index just past a doctype whose body starts at `from`, honoring quotes and its internal subset. */ const afterDoctype = (text: string, from: number) => { let inSubset = false; let at = from; @@ -227,7 +207,6 @@ const afterDoctype = (text: string, from: number) => { return -1; }; -/** Replaces every local image reference with a data URI; unreadable paths stay as written. */ const inlineLocalImages = Effect.fn("HtmlRender.inlineLocalImages")(function* (html: string) { const fileSystem = yield* FileSystem.FileSystem; const references = findLocalImages(html); @@ -292,7 +271,6 @@ const inlineLocalImages = Effect.fn("HtmlRender.inlineLocalImages")(function* (h const dataUris = new Map( images.map( (image) => - // Node's encoder: images run to 10 MiB. [ image.path, dataUriPrefix(image.path) + Buffer.from(image.bytes).toString("base64"), @@ -321,12 +299,9 @@ const inlineLocalImages = Effect.fn("HtmlRender.inlineLocalImages")(function* (h const MIN_PREVIEW_WIDTH = 240; const MAX_PREVIEW_WIDTH = 1_600; -// Each preview or measurement runs its own browser; more at once mostly costs memory. const MAX_CONCURRENT_BROWSERS = 2; -// Publishing waits this long at most for heights before returning without them. const MEASURE_TIMEOUT = "6 seconds"; -// Measured in the dark theme with Arial-metric fonts, so text wraps close to how clients show it. const MEASURE_FRAGMENT = htmlRenderThemeFragment( htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_MEASURE_FONTS), ); @@ -344,7 +319,6 @@ const make = Effect.gen(function* () { const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); - /** Runs one browser launch; a host that cannot start it gets setup steps instead. */ const launching = ( executable: string, run: (noSandbox: boolean) => Effect.Effect, @@ -367,7 +341,6 @@ const make = Effect.gen(function* () { ), ); - // Never installs the browser: html_render must not depend on it. const measure = (html: string) => Effect.gen(function* () { const executable = yield* previewBrowser.installed; diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index 60a1ef756cf9..12eeeee066c5 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -22,7 +22,6 @@ export class HtmlRenderBrowserError extends Schema.TaggedError Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1"); @@ -132,7 +127,6 @@ const CONSOLE_LEVELS: Readonly> = { assert: "error", }; -/** Everything the page logs, uncaught exceptions, and the browser's own load errors. */ const consoleMessageFromEvent = (method: string, params: unknown): ConsoleMessage | undefined => { if (method === "Runtime.consoleAPICalled") { const event = Option.getOrUndefined(decodeConsoleApiCalled(params)); @@ -162,12 +156,6 @@ const SETTLE_EXPRESSION = const MEASURE_EXPRESSION = "(() => { const root = document.documentElement; return root.scrollHeight > root.clientHeight ? root.scrollHeight : root.getBoundingClientRect().height; })()"; -// The page's main blocks in document order, for a client's loading skeleton: -// media and solid shapes, text with its line count, and tables with their row -// count. Boxes that only wrap other blocks are walked through, not drawn. -// Past the detail budget, remaining blocks are kept whole, and once the outline -// is full, one block of text lines stands in for the rest of the page, so the -// skeleton still reaches the bottom. const OUTLINE_MAX_VISITS = 2_000; const OUTLINE_EXPRESSION = `(() => { const boxes = [], detail = ${HTML_RENDER_OUTLINE_MAX_BOXES - 12}, max = ${HTML_RENDER_OUTLINE_MAX_BOXES}; @@ -251,9 +239,7 @@ const Navigation = Schema.Struct({ errorText: Schema.optional(Schema.String) }); const Measured = Schema.Struct({ result: Schema.Struct({ value: Schema.Finite }) }); interface PageEvents { - /** The page's main frame, which only ever shows `PAGE_URL`. */ readonly mainFrameId: string; - /** The page served for `PAGE_URL`, from `pageBody`. */ body: Uint8Array | undefined; loaded: Deferred.Deferred | undefined; readonly consoleMessages: Array; @@ -262,9 +248,7 @@ interface PageEvents { /** * Starts the headless shell for the life of the scope, speaking CDP over - * `--remote-debugging-pipe` (fd 3 in, fd 4 out, NUL-delimited JSON). Scope - * close kills the process group. Each page is its own target, so pages load - * in parallel. + * `--remote-debugging-pipe` (fd 3 in, fd 4 out, NUL-delimited JSON). */ const launchBrowser = Effect.fnUntraced(function* (input: { readonly executable: string; @@ -339,7 +323,6 @@ const launchBrowser = Effect.fnUntraced(function* (input: { let disconnected: BrowserFailure | undefined; let nextId = 0; - // A command whose reply nobody awaits; `receive` drops replies without a waiter. const post = (method: string, params: Record, sessionId: string) => Queue.offer( outgoing, @@ -455,7 +438,6 @@ const launchBrowser = Effect.fnUntraced(function* (input: { return Effect.forEach(complete, receive, { discard: true }); }), Effect.ignore, - // Interruption means the scope is closing on purpose; only an exit disconnects. Effect.andThen(disconnect), Effect.forkScoped, ); @@ -486,7 +468,6 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); }); - /** A fresh page at `width`; each `load` navigates it and measures the settled layout. */ const openPage = Effect.fnUntraced(function* (width: number) { const { targetId } = yield* send( "Target.createTarget", @@ -541,7 +522,6 @@ const launchBrowser = Effect.fnUntraced(function* (input: { sessionId, ); - /** Loads a page from `pageBody`, so callers loading it often encode it once. */ const load = Effect.fnUntraced(function* (body: Uint8Array, urlFragment: string) { const loaded = yield* Deferred.make(); events.body = body; @@ -594,13 +574,11 @@ const launchBrowser = Effect.fnUntraced(function* (input: { }, ]; - // Frees the page in the browser once it is no longer needed. const close = send("Target.closeTarget", { targetId }, Ignored).pipe( Effect.ensuring(Effect.sync(() => pages.delete(sessionId))), Effect.ignore, ); - /** The loaded page's outline, encoded for an `HtmlRenderReference`. */ const outline = send( "Runtime.evaluate", { expression: OUTLINE_EXPRESSION, returnByValue: true }, @@ -627,7 +605,6 @@ const launchBrowser = Effect.fnUntraced(function* (input: { return { openPage }; }); -/** A temporary directory for the browser profile, removed with the scope. */ const scratchDirectory = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; return yield* Effect.acquireRelease( @@ -641,7 +618,6 @@ const scratchDirectory = Effect.gen(function* () { ); }); -/** Loads `html` at `width` and returns a PNG of the top of the page. */ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreenshot")( function* (input: { readonly executable: string; @@ -677,9 +653,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen /** * Content heights of the page at each width, each from a fresh load, since - * pages often lay themselves out from the width once at load. One browser, - * a few widths at a time. The load at the column width also reads the page's - * outline; an outline that cannot be read is left out. + * pages often lay themselves out from the width once at load. */ export const measureHtmlHeights = Effect.fn("headlessChrome.measureHtmlHeights")(function* (input: { readonly executable: string; diff --git a/apps/server/src/htmlRender/publicProxy.test.ts b/apps/server/src/htmlRender/publicProxy.test.ts index 2d526c81246a..15c1e7f369ad 100644 --- a/apps/server/src/htmlRender/publicProxy.test.ts +++ b/apps/server/src/htmlRender/publicProxy.test.ts @@ -7,7 +7,6 @@ import * as NodeOS from "node:os"; import { publicProxy } from "./publicProxy.ts"; -// A public IPv4 address this machine holds, which no private range covers. const OWN_PUBLIC_IPV4 = "198.51.100.7"; vi.mock("node:os", async (importOriginal) => { const actual = await importOriginal(); @@ -29,7 +28,6 @@ vi.mock("node:os", async (importOriginal) => { }; }); -/** Sends a SOCKS5 greeting and CONNECT, and resolves with the reply code. */ const connectThrough = (proxyPort: number, target: Buffer, version = 5) => Effect.callback<{ readonly code: number; readonly socket: NodeNet.Socket }>((resume) => { const socket = NodeNet.connect(proxyPort, "127.0.0.1", () => { @@ -41,7 +39,6 @@ const connectThrough = (proxyPort: number, target: Buffer, version = 5) => let received = Buffer.alloc(0); socket.on("data", (chunk) => { received = Buffer.concat([received, chunk]); - // Method selection (2 bytes) then the 10-byte reply. if (received.length >= 12) resume(Effect.succeed({ code: received[3]!, socket })); }); socket.on("close", () => resume(Effect.succeed({ code: received[3] ?? -1, socket }))); @@ -67,7 +64,6 @@ const ipv6Target = (address: string, port: number) => { return target; }; -/** `a.b.c.d` as the two hex groups of an IPv4-mapped IPv6 address. */ const toHexPair = (address: string) => { const [a = 0, b = 0, c = 0, d = 0] = address.split(".").map(Number); return `${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`; @@ -93,8 +89,6 @@ describe("publicProxy", () => { ipv4Target("169.254.169.254", 80), ipv4Target("198.18.0.1", 80), domainTarget("localhost", 80), - // IPv6 forms that carry a local IPv4 address: NAT64, 6to4, Teredo, - // and IPv4-compatible. ipv6Target("64:ff9b::7f00:1", 80), ipv6Target("64:ff9b::a00:1", 80), ipv6Target("2002:7f00:1::1", 80), @@ -105,7 +99,6 @@ describe("publicProxy", () => { socket.destroy(); expect(code).toBe(2); } - // Port 0 is not a connection target. const { code, socket } = yield* connectThrough(port, ipv4Target("1.1.1.1", 0)); socket.destroy(); expect(code).toBe(7); @@ -118,7 +111,6 @@ describe("publicProxy", () => { const own = Object.values(NodeOS.networkInterfaces()) .flatMap((entries) => entries ?? []) .filter((entry) => !entry.internal && !entry.address.startsWith("fe80")); - // IPv4 also as IPv4-mapped IPv6, which names the same host. const targets = own.flatMap((entry) => entry.family === "IPv6" ? [ipv6Target(entry.address, 80)] @@ -166,8 +158,6 @@ describe("publicProxy", () => { Effect.gen(function* () { const port = yield* publicProxy; return yield* Effect.callback((resume) => { - // A client mid-handshake, which the proxy must not wait on; closing - // resets it. const client = NodeNet.connect(port, "127.0.0.1", () => { client.write(Buffer.from([5, 1, 0])); resume(Effect.succeed(client)); diff --git a/apps/server/src/htmlRender/publicProxy.ts b/apps/server/src/htmlRender/publicProxy.ts index b9d8f0fdb36a..ce6b7796fe13 100644 --- a/apps/server/src/htmlRender/publicProxy.ts +++ b/apps/server/src/htmlRender/publicProxy.ts @@ -5,7 +5,6 @@ import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; -/** This machine and its local networks, which HTML previews must never reach. */ const LOCAL_ADDRESSES = new NodeNet.BlockList(); for (const [network, prefix] of [ ["0.0.0.0", 8], @@ -38,7 +37,6 @@ NAT64.addSubnet("64:ff9b::", 96, "ipv6"); const SIX_TO_FOUR = new NodeNet.BlockList(); SIX_TO_FOUR.addSubnet("2002::", 16, "ipv6"); -/** The sixteen-bit groups of an IPv6 address, which may end in dotted IPv4. */ const ipv6Groups = (address: string) => { const bare = address.split("%", 1)[0]!; const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(bare); @@ -68,7 +66,6 @@ const embeddedIPv4 = (address: string) => { return [high >> 8, high & 0xff, low >> 8, low & 0xff].join("."); }; -/** The addresses this machine's interfaces hold right now. */ const ownAddresses = () => { const own = new NodeNet.BlockList(); for (const entry of Object.values(NodeOS.networkInterfaces()).flat()) { @@ -77,11 +74,6 @@ const ownAddresses = () => { return own; }; -/** - * Whether `address` belongs to a local network or to this machine itself, - * including a public address one of its interfaces holds. Both lists match - * IPv4-mapped IPv6 against their IPv4 entries. - */ const isLocal = (address: string, family: number): boolean => { const type = family === 6 ? "ipv6" : "ipv4"; if (LOCAL_ADDRESSES.check(address, type) || ownAddresses().check(address, type)) return true; @@ -90,9 +82,8 @@ const isLocal = (address: string, family: number): boolean => { }; /** - * The addresses to connect to for `host`, or undefined when any address it - * resolves to is local. The caller connects only to addresses checked here, - * so a name that later resolves elsewhere (DNS rebinding) changes nothing. + * The caller connects only to addresses checked here, so a name that later resolves elsewhere + * (DNS rebinding) changes nothing. */ const publicAddresses = async (host: string) => { const addresses = NodeNet.isIP(host) @@ -106,21 +97,14 @@ const publicAddresses = async (host: string) => { // SOCKS5 (RFC 1928) replies: success, refused by rule, host unreachable, command unsupported. const reply = (code: number) => Buffer.from([5, code, 0, 1, 0, 0, 0, 0, 0, 0]); -// What a client may send before its tunnel opens; a TLS hello fits easily. const MAX_EARLY_BYTES = 64 * 1024; -/** - * Answers and closes. The client is never read from again, so anything it - * sent after its request is dropped rather than left buffered. - */ const refuse = (client: NodeNet.Socket, code: number) => { client.end(reply(code), () => client.destroy()); }; -/** The CONNECT target in a complete SOCKS5 request, or "short" when more bytes are needed. */ const readRequest = (data: Buffer) => { if (data.length < 5) return "short" as const; - // Version 5, reserved byte 0. if (data[0] !== 5 || data[2] !== 0) return undefined; const type = data[3]; const end = type === 1 ? 10 : type === 3 ? 7 + data[4]! : type === 4 ? 22 : -1; @@ -138,11 +122,8 @@ const readRequest = (data: Buffer) => { }; /** - * Starts a SOCKS5 proxy on loopback for the life of the scope and returns its - * port. The preview browser sends every connection through it, and it only - * connects to public addresses. Chrome's Local Network Access misses some - * requests, such as speculation-rules prefetches; nothing misses this. It - * carries bytes only, so HTTP, TLS, and WebSockets pass through unchanged. + * Chrome's Local Network Access misses some requests, such as speculation-rules prefetches; + * nothing misses this. */ export const publicProxy = Effect.acquireRelease( Effect.callback< @@ -164,7 +145,6 @@ export const publicProxy = Effect.acquireRelease( data = Buffer.concat([data, chunk]); if (!greeted) { if (data.length < 2 || data.length < 2 + data[1]!) return; - // Version 5, offering "no authentication". if (data[0] !== 5 || !data.subarray(2, 2 + data[1]!).includes(0)) { return void client.end(Buffer.from([5, 0xff])); } @@ -178,8 +158,6 @@ export const publicProxy = Effect.acquireRelease( if (request === undefined || request.command !== 1 || request.port === 0) { return refuse(client, 7); } - // Keeps reading while the target resolves, so a client that leaves is - // noticed, and holds what it sends early up to a small cap. const early: Array = [request.rest]; let earlyBytes = request.rest.length; const holdEarly = (chunk: Buffer) => { @@ -194,9 +172,7 @@ export const publicProxy = Effect.acquireRelease( if (addresses === undefined) return refuse(client, 2); client.pause(); // Tries every checked address, IPv6 and IPv4 alike, so a host is - // still reached on a network whose IPv6 route is broken. A name goes - // through `lookup`, which hands back only the checked addresses; an - // address literal connects as itself. + // still reached on a network whose IPv6 route is broken. const upstream = track( NodeNet.connect({ host: request.host, @@ -220,8 +196,6 @@ export const publicProxy = Effect.acquireRelease( }; client.on("data", onData); }); - // A failure to listen fails this preview; once listening, a server error - // must not reach Node as an unhandled event either. let listening = false; server.on("error", (error) => { if (!listening) resume(Effect.fail(error)); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9d3686a46692..058d423d8e33 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -444,7 +444,6 @@ describe("video asset byte ranges", () => { ] as const) { const file = yield* openMediaFile(canonicalPath); if (!file) throw new Error("Expected an opened media file"); - // Model a sparse file beyond the native stream's numeric addressing limit. const info = yield* Effect.promise(() => file.handle.stat({ bigint: true })); info.size = size; const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info); @@ -516,8 +515,6 @@ describe("video asset byte ranges", () => { const filePath = path.join(directory, "audio.wav"); yield* fs.writeFileString(filePath, "RIFF"); const canonicalPath = yield* fs.realPath(filePath); - // An attachment is read straight from disk, so it carries no opened host file. Its URL is - // signed and short-lived; a cached copy would outlive the grant that served it. const response = HttpServerResponse.toWeb( yield* assetFileResponse({ path: canonicalPath, mimeType: "audio/wav" }), ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 60a5ae22972e..9f3f1e87ab4d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -57,12 +57,10 @@ import { const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; -// HTML previews are agent output, not the app. The sandbox gives the document an -// opaque origin: scripts run, but same-origin cookies, storage, and API calls are -// out of reach. Relative sibling assets still load through their signed URLs. -// No modals: agent HTML can open without a click (inline renders, and mobile -// loads it as the top document), and must not raise blocking dialogs. Downloads -// stay allowed so download links and buttons in the page work. +// The sandbox gives the document an opaque origin: scripts run, but same-origin +// cookies, storage, and API calls are out of reach. No modals: agent HTML can +// open without a click (inline renders, and mobile loads it as the top document), +// and must not raise blocking dialogs. const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-downloads"; @@ -77,7 +75,6 @@ const isSafeInlineMediaMimeType = (mimeType: string): boolean => const isSafeInlineDocumentMimeType = (mimeType: string): boolean => mimeType.toLowerCase() === "application/pdf" || mimeType.toLowerCase() === "text/html"; -/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */ export function downloadContentDisposition(fileName?: string): string { if (fileName === undefined) { return "attachment"; @@ -141,7 +138,6 @@ export function assetResponseHeaders( }; } -/** A single byte range for native media readers; unsupported range syntax uses the full file. */ function assetByteRange(header: string, size: bigint) { const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim()); if (!match || (!match[1] && !match[2])) return null; @@ -199,9 +195,9 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? ""); if (isMedia) { - // Host media can change in place. Do not invite conditional range requests - // with validators that cannot establish byte-for-byte identity. Attachment media - // carries no `file`, and must not outlive the signed URL that granted it either. + // Do not invite conditional range requests with validators that cannot establish + // byte-for-byte identity. Attachment media carries no `file`, and must not + // outlive the signed URL that granted it either. headers["Cache-Control"] = "private, no-store"; } let status = 200; @@ -264,11 +260,6 @@ export const layerBrowserApiCors = Layer.unwrap( const devOrigin = config.devUrl?.origin; // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. - // - // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second - // origin β€” a tailnet name, a LAN IP, a phone. Browser dev normally proxies - // through Vite and is same-origin (no preflight at all), so this is a - // safety net for the desktop renderer and any direct-to-backend caller. return HttpRouter.cors({ ...(devOrigin ? { @@ -342,8 +333,6 @@ class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecor // Renderers export up to once a second while they have spans buffered, so // tracing this proxy would add more server spans than it forwards. -// withTracerEnabled(false) drops the handler's spans, including the forward. -// untracedRequestsLayer drops the HTTP server span. export const layerOtlpTracesProxyRoute = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, @@ -400,12 +389,9 @@ export const layerOtlpTracesProxyRoute = HttpRouter.add( const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_PATH]); -// Skips the HTTP server span for UNTRACED_REQUEST_PATHS. That span starts -// before routing, so a route handler cannot skip it. TracerDisabledWhen is one -// predicate for the whole server, and HttpRouter.serve builds its routes -// privately, so it is provided to the served layer, never merged into the -// routes. Add paths here instead of providing it again. The query string is -// ignored, as in routing. +// TracerDisabledWhen is one predicate for the whole server, and +// HttpRouter.serve builds its routes privately, so it is provided to the served +// layer, never merged into the routes. const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { const queryIndex = request.url.indexOf("?"); const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex); @@ -545,7 +531,6 @@ const loadImmutableBuildAssets = Effect.gen(function* () { const openStaticFile = Effect.fn("openStaticFile")(function* (filePath: string) { const fileSystem = yield* FileSystem.FileSystem; - // Reject directories and special files before opening. Response metadata comes from the handle. const pathInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); if (pathInfo?.type !== "File") return null; const file = yield* fileSystem.open(filePath, { flag: "r" }); @@ -681,8 +666,6 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( } const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; - // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. - // HEAD still passes through compression, which selects headers without reading the stream. return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { headers, contentType, @@ -695,7 +678,6 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( }), ); -// Read the installed build's manifest once. Unknown files use revalidation. export const layerStaticAndDevRoute = Layer.unwrap( loadImmutableBuildAssets.pipe( Effect.map((assets) => HttpRouter.add("GET", "*", handleStaticAndDevRequest(assets))), diff --git a/apps/server/src/httpCors.ts b/apps/server/src/httpCors.ts index a6c702979efa..18b537f17aba 100644 --- a/apps/server/src/httpCors.ts +++ b/apps/server/src/httpCors.ts @@ -10,5 +10,4 @@ export const browserApiCorsAllowedHeaders = [ ORCHESTRATION_PROTOCOL_HEADER, ] as const; -/** Origins of the Electron renderer, packaged and dev. */ export const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"] as const; diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index 983547b5bcc0..de5e759a8425 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -74,7 +74,6 @@ describe("guardHttpResponseWriteErrors", () => { expect(writeErrors[0]).toBeInstanceOf(Error); expect((writeErrors[0] as NodeJS.ErrnoException).code).toBe("EPIPE"); - // The process survived the failed write and the server keeps serving. server.on("request", (_request, response) => { response.writeHead(200, { "content-type": "text/plain" }); response.end("ok"); diff --git a/apps/server/src/httpResponseErrorGuard.ts b/apps/server/src/httpResponseErrorGuard.ts index d673eaa42bec..1461e6a5ead9 100644 --- a/apps/server/src/httpResponseErrorGuard.ts +++ b/apps/server/src/httpResponseErrorGuard.ts @@ -2,24 +2,9 @@ import type * as NodeHttp from "node:http"; /** - * Node surfaces late socket write failures (EPIPE, ECONNRESET, - * ERR_STREAM_DESTROYED) as "error" events. An "error" event without a - * listener escalates into an uncaught exception and terminates the whole - * server process, taking every other client and all in-flight provider - * work with it. - * - * Two emitters need coverage: - * - * - Upgrade sockets. Once a connection upgrades (the websocket RPC path, - * including its auth rejection responses), Node's http server detaches - * its own socket error handling, so the raw socket has no listener at - * all until the websocket server adopts it. - * - Server responses. Response streams have no default error listener - * either. - * - * A disconnected client only affects its own request: the request fiber is - * already interrupted through the response "close" event, so the write - * failure needs no handling beyond being observed. + * An "error" event without a listener escalates into an uncaught exception and + * terminates the whole server process, taking every other client and all + * in-flight provider work with it. */ export function guardHttpResponseWriteErrors( server: T, diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts index e87e4dcbd0dc..f9c83400edbc 100644 --- a/apps/server/src/imageMime.test.ts +++ b/apps/server/src/imageMime.test.ts @@ -38,12 +38,9 @@ describe("imageMime", () => { }); it("rejects structurally malformed base64", () => { - // '=' before the trailing padding position expect(parseBase64DataUrl("data:image/png;base64,AB=CD===")).toBeNull(); expect(parseBase64DataUrl("data:image/png;base64,SGV=bG8=")).toBeNull(); - // more than two padding characters expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=====AAA")).toBeNull(); - // length not a multiple of 4 expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8")).toBeNull(); }); @@ -71,9 +68,6 @@ describe("imageMime", () => { }); it("parses a multi-megabyte payload from a deep call stack", () => { - // Regression: matching the payload with a regex borrowed the JS call - // stack, so a ~10 MB image parsed inside fiber execution threw - // "RangeError: Maximum call stack size exceeded". const dataUrl = `data:image/png;base64,${"A".repeat(14_000_000)}`; const atDepth = (depth: number): ReturnType => depth === 0 ? parseBase64DataUrl(dataUrl) : atDepth(depth - 1); diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 7ad8c37211f7..b180dda51a09 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -30,21 +30,19 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); -// Whether `code` is a character the base64 payload may contain, aside from -// the whitespace handled separately below. function isBase64Char(code: number): boolean { return ( - (code >= 0x61 && code <= 0x7a) || // a-z - (code >= 0x41 && code <= 0x5a) || // A-Z - (code >= 0x30 && code <= 0x39) || // 0-9 - code === 0x2b || // + - code === 0x2f || // / - code === 0x3d // = + (code >= 0x61 && code <= 0x7a) || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x30 && code <= 0x39) || + code === 0x2b || + code === 0x2f || + code === 0x3d ); } function isBase64Whitespace(code: number): boolean { - return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space + return code === 0x0d || code === 0x0a || code === 0x20; } // Data URLs carry the full image payload, so this parser must never run a @@ -122,7 +120,6 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string return fromMime; } - // The registry returns bare extensions ("png"); the safe list is dotted. const fromMimeExtension = Option.map(Mime.getExtension(input.mimeType), (ext) => `.${ext}`); if (Option.isSome(fromMimeExtension) && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension.value)) { return fromMimeExtension.value; diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 0ea44fb3ca07..bef260bf9254 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -311,7 +311,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { existing, ]); - // Removing the added rule later must survive the next startup. yield* writeKeybindingsConfig(keybindingsConfigPath, [existing]); yield* keybindings.syncDefaultKeybindingsOnStartup; assert.deepStrictEqual(yield* backgroundRules, [existing]); @@ -616,7 +615,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; - // A directory where the file should be makes the read itself fail. yield* fileSystem.makeDirectory(keybindingsConfigPath, { recursive: true }); const keybindings = yield* Keybindings.Keybindings; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 496a5852979a..177da6652234 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -1,11 +1,3 @@ -/** - * Keybindings - Keybinding configuration service definitions. - * - * Owns parsing, validation, merge, and persistence of user keybinding - * configuration consumed by the server runtime. - * - * @module Keybindings - */ import { KeybindingRule, KeybindingsConfig, @@ -104,11 +96,6 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool ); } -// Default rules added to a command after startup sync had already persisted -// that command. Backfill skips commands a config already has, so startup adds -// each rule once per config, only next to the untouched earlier default -// (`alongside`), and records its id. Customized commands and later removals -// stay as the user set them. const LATE_DEFAULT_KEYBINDINGS: ReadonlyArray<{ readonly id: string; readonly alongside: KeybindingRule; @@ -226,52 +213,22 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { }; } -/** - * Keybindings - Service tag for keybinding configuration operations. - */ export class Keybindings extends Context.Service< Keybindings, { - /** - * Start the keybindings runtime and attach file watching. - * - * Safe to call multiple times. The first successful call establishes the - * runtime; later calls await the same startup. - */ readonly start: Effect.Effect; - /** - * Await keybindings runtime readiness. - * - * Readiness means the config directory exists, the watcher is attached, the - * startup sync has completed, and the current snapshot has been loaded. - */ readonly ready: Effect.Effect; - /** - * Ensure the on-disk keybindings file exists and includes all default - * commands so newly-added defaults are backfilled on startup. - */ readonly syncDefaultKeybindingsOnStartup: Effect.Effect; - /** - * Load runtime keybindings state along with non-fatal configuration issues. - */ readonly loadConfigState: Effect.Effect; - /** - * Read the latest keybindings snapshot from cache/disk. - */ readonly getSnapshot: Effect.Effect; - /** - * Stream of keybindings config change events. - */ readonly streamChanges: Stream.Stream; /** - * Upsert a keybinding rule and persist the resulting configuration. - * * Writes config atomically and enforces the max rule count by truncating * oldest entries when needed. */ @@ -279,9 +236,6 @@ export class Keybindings extends Context.Service< input: ServerUpsertKeybindingInput, ) => Effect.Effect; - /** - * Remove a single persisted keybinding rule by exact key/command/when match. - */ readonly removeKeybindingRule: ( input: ServerRemoveKeybindingInput, ) => Effect.Effect; @@ -424,7 +378,6 @@ const make = Effect.gen(function* () { return { keybindings, issues }; }); - // One applied migration id per line, next to the config file. const appliedMigrationsPath = path.join( path.dirname(keybindingsConfigPath), "keybindings-migrations", @@ -480,7 +433,6 @@ const make = Effect.gen(function* () { })), ); - // A failed read is not kept: the next read retries instead of replaying the failure. const resolvedConfigCache = yield* Cache.makeWith< typeof resolvedConfigCacheKey, KeybindingsConfigState, @@ -558,8 +510,6 @@ const make = Effect.gen(function* () { } missingDefaults.push(defaultRule); } - // The loop above backfills whole missing commands. Late defaults join - // an untouched earlier default, when their shortcut is still free. for (const { alongside, rule } of pendingLateDefaults) { if ( customConfig.some((entry) => isSameKeybindingRule(entry, alongside)) && @@ -607,7 +557,6 @@ const make = Effect.gen(function* () { if (defaultsToAppend.length > 0) { yield* writeConfigAtomically([...defaultsToAppend, ...customConfig]); } - // A late default skipped at max entries stays pending for a later start. const settledLateDefaults = pendingLateDefaults.filter( (late) => !skippedDefaults.includes(late.rule), ); @@ -639,7 +588,6 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - // Debounce watch events so the file is fully written before we read it. // Editors emit multiple events per save (truncate, write, rename) and // `fs.watch` can fire before the content has been flushed to disk. const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe( diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts index 9cde2b880a7a..5734d613bf52 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.test.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.test.ts @@ -1,5 +1,3 @@ -// The harness asserts raw JSON-RPC wire strings, mirroring the bridge's -// schema-free passthrough. // @effect-diagnostics nodeBuiltinImport:off globalTimers:off import * as NodeStream from "node:stream"; diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index 02e6cdeac607..82a3aaa16eb3 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -10,19 +10,8 @@ import * as Stream from "effect/Stream"; import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; /** - * Stdio-to-HTTP bridge for T3's MCP endpoint. - * - * ACP agents must support stdio MCP servers, while optional http/sse support - * is unevenly implemented. `t3 acp-mcp-bridge` runs as the stdio MCP server an - * ACP agent spawns and forwards each JSON-RPC line to T3's authenticated - * streamable-HTTP endpoint: single JSON responses and SSE streams are written - * back as newline-delimited JSON-RPC, notification acknowledgements (202/204) - * produce no output, and the `mcp-session-id` / negotiated protocol version - * are replayed on subsequent requests. - * - * This entry sits on the ACP first-message critical path (bin.ts fast-paths - * it before the CLI graph), so it imports only effect core modules; keep the - * import list lean when changing it. + * ACP agents must support stdio MCP servers, while optional http/sse support is + * unevenly implemented. */ export class AcpMcpBridgeError extends Error { @@ -73,8 +62,6 @@ export interface AcpMcpToolCallOptions { } /** - * Call one MCP tool through a fresh authenticated HTTP session. - * * This is the terminal fallback for ACP agents that accept `mcpServers` in * `session/new` but fail to expose those tools to their model. Compliant ACP * agents continue to use the stdio bridge above. @@ -84,8 +71,6 @@ export function callAcpMcpTool( ): Effect.Effect { const fetchImplementation = options.fetchImplementation ?? fetch; return Effect.gen(function* () { - // The bridge is single-fibered at creation time; concurrent sends only - // read these after the sequential handshake, so plain locals suffice. let sessionId: string | null = null; let protocolVersion: string | null = null; @@ -162,9 +147,6 @@ export function callAcpMcpTool( export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect.Effect { const fetchImplementation = options.fetchImplementation ?? fetch; return Effect.gen(function* () { - // Dispatch happens synchronously on the single stream fiber, so plain - // locals carry the shared session state; forwards mutate them in - // completion order exactly like the wire does. let sessionId: string | null = null; let protocolVersion: string | null = null; const running = new Set>(); @@ -308,9 +290,8 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. } /** - * Argv runner for `t3 acp-mcp-bridge` and `t3 acp-mcp-call`, shared by the - * fast-path dispatch in bin.ts and the full CLI's command handlers. Kept free - * of heavy imports: these commands run on the ACP first-message critical path. + * Kept free of heavy imports: these commands run on the ACP first-message + * critical path. */ export async function runAcpMcpCliFastPath( command: "acp-mcp-bridge" | "acp-mcp-call", diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index e1a688ff64f4..8e80b30a7f10 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -94,7 +94,7 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ platform: input.platform, openedAt: "2026-09-08T00:00:00.000Z", }), - // UDID-1 is open in the test thread; UDID-2 exists but belongs to another thread. + sessionsForThread: (id) => Effect.succeed( id === threadId diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index d1a48708c699..9d28aeb4dfc1 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -104,7 +104,6 @@ const snapshotResult = { }, }; -/** Answers every snapshot request on a fresh broker host with the given result. */ const serveSnapshots = (clientId: string, result: unknown) => Effect.gen(function* () { const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; @@ -349,7 +348,6 @@ it.effect.each([ ]); } - // Output selection belongs to this call, not the MCP session's history. const nextDefault = yield* server .callTool({ name: "preview_snapshot", @@ -422,7 +420,6 @@ it.effect("saves the snapshot PNG on request and reports its path", () => const snapshot = yield* callSnapshot({ save: true, includeImage: true }); expect(snapshot.isError).toBe(false); - // The browser never receives the server-only `save` flag. expect(inputs).toEqual([{}]); expect(snapshot.content.map((content) => content.type)).toContain("image"); const structured = snapshot.structuredContent as { readonly screenshotPath?: string }; @@ -439,7 +436,6 @@ it.effect("saves the snapshot PNG on request and reports its path", () => const unsaved = yield* callSnapshot({}); expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath"); - // A save without the image skips the page dump. const pathOnly = yield* callSnapshot({ save: true }); const saved = pathOnly.structuredContent as { readonly screenshotPath: string }; expect(saved).toEqual({ url: snapshotResult.url, screenshotPath: expect.any(String) }); @@ -456,7 +452,6 @@ it.effect("reports a tagged error when the screenshot cannot be saved", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; - // A regular file where the artifacts directory should be makes every write fail. yield* fileSystem.writeFileString(config.browserArtifactsDir, ""); yield* serveSnapshots("mcp-save-failure-client", snapshotResult); @@ -494,7 +489,6 @@ it.effect( const denied = yield* server .callTool({ name: "list_thread_pull_requests", arguments: {} }) .pipe( - // A preview-only credential: the token predates the toolkit or was minted elsewhere. Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); @@ -547,8 +541,6 @@ it.effect("bounds large ARIA trees without dropping every server locator", () => it.effect("keeps the snapshot text under the agent's output ceiling", () => Effect.scoped( Effect.gen(function* () { - // Mirrors the real failure: a [role] container whose innerText is the whole - // project list, repeated for several elements, plus a big AX tree. const pageText = "/Users/theo/Code/project\nClaude, Codex Β· 79 threads\n".repeat(600); const element = (name: string, index: number) => ({ tag: "div", @@ -649,7 +641,6 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are it.effect("bounds page text made of wide characters before dropping locators", () => Effect.scoped( Effect.gen(function* () { - // The character caps alone leave 8,000 three-byte characters, about 24 KB. yield* serveSnapshots("mcp-wide-text-client", { ...snapshotResult, visibleText: "η•Œ".repeat(9_000), @@ -739,7 +730,6 @@ it.effect("sheds log entries before locators when every list is full", () => readonly networkEntries: ReadonlyArray; readonly actionTimeline: ReadonlyArray; }; - // Locators survive; the log lists take the cut. expect(parsed.interactiveElements).toHaveLength(20); expect( parsed.consoleEntries.length + parsed.networkEntries.length + parsed.actionTimeline.length, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 64e8a344cde3..cd2ccf141638 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -59,17 +59,15 @@ import { import * as HtmlHandlers from "./toolkits/html/handlers.ts"; import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts"; -/** Where an MCP client discovers how to sign in (RFC 9728), at this request's own origin. */ const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) => HttpServerRequest.toURL(request).pipe( Option.map((url) => `${url.origin}/.well-known/oauth-protected-resource/mcp`), ); /** - * Agents T3 Code launched carry a registry token and must never be sent into - * an OAuth flow when it dies: they cannot open a browser, and a sign-in - * would mint a credential that outlives their session. Only a request that - * does not look like a provider token is pointed at the OAuth metadata. + * Agents T3 Code launched carry a registry token and must never be sent into an + * OAuth flow when it dies: they cannot open a browser, and a sign-in would mint + * a credential that outlives their session. */ const unauthorized = (input: { readonly request: HttpServerRequest.HttpServerRequest; @@ -103,10 +101,6 @@ const unauthorized = (input: { ); }; -/** - * Resolves a bearer token that is not a provider-session token: an OAuth - * client signed in from outside T3. Undefined when the token is not one. - */ export class McpClientAuthenticator extends Context.Service< McpClientAuthenticator, { @@ -161,9 +155,6 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { ? yield* clients.value.authenticate(request) : undefined); if (!invocation) { - // Without this the only symptom of a dead credential is the agent - // quietly losing the whole `t3-code` toolkit for the rest of its - // session, with nothing on the server to explain why. yield* Effect.logWarning("rejected MCP request with an unusable credential", { reason: token.length === 0 ? "missing_bearer_token" : "unknown_or_expired_token", }); @@ -186,12 +177,10 @@ const layerMcpAuthMiddleware = HttpRouter.middleware<{ /** * Claude Code moves an MCP result above its output limit to a file and hands - * the agent a notice instead, so a snapshot that carries the full - * accessibility tree and page text loses its locators too. Claude Code also - * shows the model `structuredContent` in place of the text blocks when a - * result has both, so both carry the same bounded snapshot. Keep it near - * 20 KB and tell the agent what was cut. The short `omitted` notes may go a - * little over; the provider limit is far above this. + * the agent a notice instead, so a snapshot that carries the full accessibility + * tree and page text loses its locators too. Claude Code also shows the model + * `structuredContent` in place of the text blocks when a result has both, so + * both carry the same bounded snapshot. */ export const MAX_SNAPSHOT_TEXT_BYTES = 20_000; const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; @@ -205,7 +194,6 @@ const utf8Length = (text: string) => Buffer.byteLength(text, "utf8"); const cutText = (text: string, max: number) => text.length > max ? `${text.slice(0, max)}…` : text; -/** Shortens every string field of a log entry; other fields pass through. */ const cutEntryStrings = (entry: A): A => typeof entry === "object" && entry !== null ? (Object.fromEntries( @@ -235,13 +223,6 @@ type SnapshotMetadata = { readonly [key: string]: unknown; }; -/** - * Keeps server ARIA refs, drops legacy object trees, shortens page text, names, identifiers, - * and log strings, keeps only the newest log entries, and finally sheds - * interactive elements until the JSON fits. Returns the bounded value, its - * text, and notes on what is missing so the agent can reach for - * preview_evaluate. - */ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { const omitted: Array = []; const { accessibilityTree, ...withoutTree } = metadata; @@ -287,9 +268,6 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { // Per-field caps do not sum below the ceiling: three log arrays of 40 capped // entries alone can pass 60 KB, and the caps count characters, not bytes. - // Halve one thing per round until the JSON fits: logs first, then page - // text, then the locators. The identifier caps bound the rest, so this - // terminates. const shedOrder = [ "actionTimeline", "networkEntries", @@ -375,7 +353,6 @@ export class PreviewScreenshotSaveError extends Schema.TaggedError { try { const slug = new URL(rawUrl).hostname @@ -390,7 +367,6 @@ const screenshotSiteSlug = (rawUrl: string): string => { } }; -/** Writes the snapshot PNG under the browser artifacts directory and returns its path. */ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( pageUrl: string, data: Uint8Array, @@ -400,7 +376,6 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( const path = yield* Path.Path; const millis = yield* Clock.currentTimeMillis; const crypto = yield* Crypto.Crypto; - // Two saves in the same millisecond must not overwrite each other. const unique = (yield* crypto.randomUUIDv4().pipe(Effect.orDie)).slice(0, 8); const fileName = `browser-screenshot-${screenshotSiteSlug(pageUrl)}-${millis.toString(36)}-${unique}.png`; const screenshotPath = path.join(config.browserArtifactsDir, fileName); @@ -453,7 +428,6 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const threads = yield* ThreadManagementService.ThreadManagementService; - // The MCP tool runner only supplies the client, so hand the save path its services here. const saveServices = yield* Effect.context< ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path | Crypto.Crypto >(); @@ -508,7 +482,6 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot // later request, and some reject inline images outright. const includeImage = payload?.includeImage === true; if (screenshotPath !== undefined && !includeImage) { - // The agent only wants a file to show the user. The url keeps the site icon on the tool row. const saved = { url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), screenshotPath, @@ -536,7 +509,6 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot ? bounded.value : { ...bounded.value, omitted: bounded.omitted }, content: [ - // Keep the page identity readable even if a provider truncates the snapshot. { type: "text", text: encodeJsonText({ @@ -581,8 +553,8 @@ interface ImageToolResult { /** * Failures surface only their tag unless the tool describes them: the remote - * message may carry renderer or device output the agent should not see, and - * the tag is what it can act on. A describer returns a server-built message. + * message may carry renderer or device output the agent should not see, and the + * tag is what it can act on. */ const imageToolFailure = (toolName: string, operation: string, failureText: string | ((error: E) => string)) => @@ -629,10 +601,8 @@ const imageToolFailure = }; /** - * `McpServer.toolkit` serializes every result as JSON text, which is the - * wrong shape for a screenshot: the model needs image content. Tools whose - * result carries a `screenshot` field are registered by hand so the PNG goes - * out as an image block and the rest of the payload as JSON metadata. + * `McpServer.toolkit` serializes every result as JSON text, which is the wrong + * shape for a screenshot: the model needs image content. */ const registerImageTool = ( tool: T, @@ -752,7 +722,7 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct Effect.provideService(ThreadManagementService.ThreadManagementService, threads), ), "preview", - // Parameter errors and HTML render errors are both written by the server for the agent. + (error) => isOrchestratorMcpFailure(error) || AiError.isAiError(error) ? error.message @@ -761,9 +731,6 @@ const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(funct }); /** - * `McpServer.toolkit` for handlers that declared their access (see - * `McpToolAccess`). Every toolkit on `/mcp` registers through this. - * * `McpServer.toolkit` asks for every service its tools declare when it * registers them, but the auth middleware provides `McpInvocationContext` to * each request instead. Registration must not get one: the services it @@ -783,7 +750,6 @@ export const toolkitRegistration = , EX, return registered.pipe(Layer.provide(McpToolAccess.HandlersLayer.layer(handlers))); }; -/** A hand-registered tool, also only with handlers that declared their access. */ const imageToolRegistration = , A, E, R, EX, RX>( register: Effect.Effect, handlers: McpToolAccess.HandlersLayer, diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index c53dac6695c0..f830da3d4a03 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -20,41 +20,34 @@ const ALL_MCP_CAPABILITIES = [ ] as const; export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number]; -/** A provider session T3 Code launched for one thread. */ export interface McpThreadCaller { readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; } -/** An agent T3 Code did not launch, signed in through MCP OAuth. */ export interface McpClientCaller { readonly sessionId: string; readonly label: string; - /** Read only, or the most the threads it starts or changes may run with. */ readonly access: AuthMcpClientAccess; } /** - * The runtime mode a client caller's writes are capped at. A read-only client - * never reaches a write (`McpToolAccess` refuses it first), so it maps to the - * lowest mode rather than to nothing. + * A read-only client never reaches a write (`McpToolAccess` refuses it first), + * so it maps to the lowest mode rather than to nothing. */ export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => client === undefined || client.access === "read-only" ? "approval-required" : client.access; /** - * Who is calling and what they may do. Tool parameters choose the target - * (thread, project); the caller sets the limits. A thread caller's omitted - * target falls back to its own thread; a client caller has no own thread, so - * tools that act as the caller (delegate_task, preview, worktree handoff) - * need `thread`. + * A thread caller's omitted target falls back to its own thread; a client + * caller has no own thread, so tools that act as the caller (delegate_task, + * preview, worktree handoff) need `thread`. */ export interface McpInvocationScope { readonly environmentId: EnvironmentId; readonly capabilities: ReadonlySet; readonly issuedAt: number; - /** Namespaces idempotency keys so two callers reusing a clientRequestId cannot collide. */ readonly requestNamespace: string; readonly thread: McpThreadCaller | undefined; readonly client: McpClientCaller | undefined; @@ -65,7 +58,6 @@ export class McpInvocationContext extends Context.Service< McpInvocationScope >()("t3/mcp/McpInvocationContext") {} -/** The error a missing capability surfaces as; preview keeps its own so the broker can route it. */ export type McpCapabilityError = C extends "preview" ? PreviewAutomationUnavailableError : McpCapabilityUnavailableError; @@ -95,17 +87,11 @@ export const requireMcpCapability = ( McpInvocationContext.pipe( Effect.filterOrFail( (invocation) => invocation.capabilities.has(capability), - // The conditional type narrows what the literal argument decided at runtime. (invocation) => missingCapability(invocation, capability) as McpCapabilityError, ), Effect.withSpan("mcp.requireCapability"), ); -/** - * Preview tabs and device sessions belong to the calling thread, so their - * capabilities are only ever granted to thread callers. A scope that carries - * one without a thread is refused the same way as a missing capability. - */ export const requireThreadMcpCapability = ( capability: C, ): Effect.Effect, McpInvocationContext> => @@ -124,7 +110,6 @@ const threadCallerRequired = (operation: string) => message: `${operation} acts as the calling T3 thread, so it needs an agent running inside T3 Code. This MCP client signed in from outside a thread.`, }); -/** A scope with a thread caller, for tools whose whole surface acts as the caller. */ export type McpThreadInvocationScope = McpInvocationScope & { readonly thread: McpThreadCaller }; export const requireThreadScope = (scope: McpInvocationScope, operation: string) => diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 6b6207dc2660..a3f74c3e28c2 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -151,8 +151,6 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool", }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); - // Well past the liveness window in total, but each turn reports in before - // it lapses β€” this is the long-session case that used to lose the toolkit. for (let turn = 0; turn < 10; turn += 1) { timestamp += 99; yield* registry.touch(threadId); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 8bc478cd310c..b16529924a43 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -33,11 +33,6 @@ export interface McpSessionRegistryShape { readonly resolve: ( rawToken: string, ) => Effect.Effect; - /** - * Records a sign of life for every credential bound to `threadId`. Provider - * turns call this so that a session which is plainly alive keeps its - * credential even when it goes a long time without touching an MCP tool. - */ readonly touch: (threadId: ThreadId) => Effect.Effect; readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect; readonly revokeThread: (threadId: ThreadId) => Effect.Effect; @@ -49,7 +44,6 @@ export class McpSessionRegistry extends Context.Service< McpSessionRegistryShape >()("t3/mcp/McpSessionRegistry") {} -/** Registry credentials always belong to a provider session, so their scope has a thread. */ interface CredentialRecord { readonly tokenHash: string; readonly scope: McpInvocationContext.McpThreadInvocationScope; @@ -66,15 +60,6 @@ export interface McpSessionRegistryOptions { } /** - * How long a credential outlives the last sign of life from its provider - * session. - * - * Liveness is refreshed both by MCP traffic and by `touch` on every provider - * turn, so a session that is still doing work never expires no matter how long - * it goes between browser tool calls. This window therefore only bounds - * credentials whose session died without a clean stop β€” the normal paths - * (`stopSession`, `stopAll`) revoke eagerly and do not wait for it. - * * The bound matters because `/mcp` is mounted outside the environment auth * stack and is reachable on whatever host the server binds to, so this token is * the only thing guarding the `t3-code` toolkits on a remote-reachable server. @@ -86,8 +71,6 @@ const bytesToHex = (bytes: Uint8Array): string => const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); -// A wildcard bind is reachable on loopback, which is where the provider -// subprocesses run; anything else is announced as the address it bound. const getHttpMcpEndpointHost = (address: NetAddress.IpAddress): string => NetAddress.isUnspecified(address) ? "127.0.0.1" @@ -247,10 +230,6 @@ export const issueActiveMcpCredential = ( .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) : Effect.undefined; -/** - * Refreshes the liveness of a thread's MCP credential. Called on every provider - * turn so an active session is never mistaken for an abandoned one. - */ export const touchActiveMcpThread = (threadId: ThreadId): Effect.Effect => activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void; @@ -260,7 +239,6 @@ const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect => const revokeAllActiveMcpCredentials = (): Effect.Effect => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; -/** Exposed for tests. */ export const __testing = { make: makeWithOptions, }; diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts index 362527324ae9..52b8523ad56d 100644 --- a/apps/server/src/mcp/McpToolAccess.race.test.ts +++ b/apps/server/src/mcp/McpToolAccess.race.test.ts @@ -29,10 +29,6 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpToolAccess from "./McpToolAccess.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -// A Supervised outside agent renames a thread through a `writesThreads` tool. -// The thread's user raises it to full access after the tool's check but -// before its write: the race the orchestrator closes under the thread's lock. - const instanceId = ProviderInstanceId.make("codex"); const adapter = { instanceId, @@ -95,7 +91,6 @@ const decodeOutcome = Schema.decodeUnknownEffect( ), ); -/** Renames `threadId` as the Supervised client; the user may raise it in between. */ const renameRacingTheUser = (threadId: ThreadId, userRaises: boolean) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -106,9 +101,7 @@ const renameRacingTheUser = (threadId: ThreadId, userRaises: boolean) => (input) => [input.threadId], (input) => Effect.gen(function* () { - // The tool's own check has passed; the user acts before the write. if (userRaises) { - // The user's own command carries no limit, so it lands. yield* orchestrator .dispatch({ type: "thread.runtime-mode.set", diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 6a96fd580767..1613cc1112b1 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -71,8 +71,6 @@ const mcpClient = McpSchema.McpServerClient.of({ getClient: Effect.die("unused"), }); -// One tool per declaration, each answering with the modes it was handed, so -// the table below reads what the gate let through. const probe = { success: Schema.Struct({ ran: Schema.String }), failure: OrchestratorMcpFailure, @@ -122,7 +120,6 @@ const layerProbeHandlers = McpToolAccess.toLayer(ProbeToolkit, probeHandlers); const unchecked = () => ran; -// What the compiler refuses: each line below must fail to typecheck, and its // `@ts-expect-error` fails the build if one ever compiles. Never called. export const refusedAtCompileTime = () => { McpToolAccess.toLayer(ProbeToolkit, { @@ -144,7 +141,6 @@ export const refusedAtCompileTime = () => { new McpToolAccess.Declaration(unchecked); // @ts-expect-error a handlers layer built outside McpToolAccess McpToolAccess.HandlersLayer.make(Layer.empty); - // The refused layer below types its error and services as unknown, which is fine here. // @effect-diagnostics-next-line anyUnknownInErrorContext:off McpHttpServer.toolkitRegistration( ProbeToolkit, @@ -168,7 +164,6 @@ const probeServer = (threads: Layer.Layer { - // Private constructors only bind the type checker; Reflect.construct reaches them anyway. expect(() => Reflect.construct(McpToolAccess.Declaration, [unchecked])).toThrow(TypeError); expect(() => Reflect.construct(McpToolAccess.HandlersLayer, [Layer.empty])).toThrow(TypeError); }); it("refuses a declaration or handlers layer that only wears another's fields", () => { - // The types already refuse these; at runtime the private fields are missing too. const copiedDeclaration = Object.assign( unchecked, McpToolAccess.reads(() => ran), diff --git a/apps/server/src/mcp/McpToolAccess.testkit.ts b/apps/server/src/mcp/McpToolAccess.testkit.ts index 687b34cac2ff..a985c1477a23 100644 --- a/apps/server/src/mcp/McpToolAccess.testkit.ts +++ b/apps/server/src/mcp/McpToolAccess.testkit.ts @@ -16,10 +16,6 @@ import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.t const EPOCH = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); -/** - * A thread in the middle of a turn on the `codex` instance: the shape the - * access gate reads for a thread caller, and for the threads a caller targets. - */ export const liveThreadShell = ( id: ThreadId, modes: { @@ -63,19 +59,10 @@ export const liveThreadShell = ( }; }; -/** - * `ThreadManagementService` that answers only the access gate's lookups, - * every thread a live full-access one. For tests of a tool rather than of who - * may call it. - */ export const liveThreadsLayer = Layer.mock(ThreadManagement.ThreadManagementService)({ getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), }); -/** - * The thread of `shell` with no runs, messages or other records: what a - * caller looks like once its turn has ended. - */ export const idleThreadProjection = ( shell: OrchestrationV2ThreadShell, ): OrchestrationV2ThreadProjection => { diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 33323501965a..5cc8dc0c38b8 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -27,12 +27,9 @@ import { unavailable, } from "./threadAccess.ts"; -// Only the classes below assign these, from their static blocks, so nothing -// outside this module can build a declaration or a handlers layer. - /** - * Both constructors demand this. Their private constructors only stop the type - * checker; this also stops `Reflect.construct` and friends at runtime. + * Their private constructors only stop the type checker; this also stops + * `Reflect.construct` and friends at runtime. */ const builtHere: unique symbol = Symbol("t3/mcp/McpToolAccess/builtHere"); @@ -42,50 +39,28 @@ const refuseOutsideConstruction = (token: symbol) => { } }; -/** Builds a declaration; only the declaration functions below call it. */ let declare: ( handle: (params: P) => Effect.Effect, ) => Declaration<(params: P) => Effect.Effect>; type CheckedHandlerOf = D extends Declaration ? Handler : never; -/** Turns each declaration back into the handler it checks, keeping its type. */ interface CheckedHandler extends Struct.Lambda { (declaration: Declaration): Handler; readonly "~lambda.out": CheckedHandlerOf; } let checkedHandler: CheckedHandler; -/** Only `toLayer` below builds one. */ let handlersLayer: , EX, RX>( layer: Layer.Layer, EX, RX>, ) => HandlersLayer; /** - * Who may call a T3 MCP tool. Every handler is built by one of the - * declarations below, which say what the tool does. `toLayer` accepts only - * declarations, and `/mcp` registers only layers `toLayer` built, so a tool - * without a decision here does not compile. Both are nominal classes, so a - * handler or layer cannot pass for one by copying its fields. Effect's own - * registration functions accept any handler, so the + * Effect's own registration functions accept any handler, so the * `t3code/no-raw-mcp-registration` lint rule keeps Effect's `McpServer` inside * McpHttpServer. - * - * Parameters choose the target; the caller sets the limits: a thread caller - * its own runtime and interaction modes, an outside client the ceiling it was - * approved with. Nothing a caller starts or changes may run with broader - * modes, and a thread caller changes things only while its own run is live. - * A refusal is an `OrchestratorMcpFailure`, so the compiler requires it in the - * tool's failure schema, and `ThreadManagementService` in its dependencies. - * - * The declaration checks the caller before the handler runs. Handlers still - * check what only they can see, such as a queued run belonging to its thread. */ export class Declaration { - // A private field makes the class nominal, and only this module can build - // one: its constructor is private and the static block hands the only way - // in to module-scoped functions. Copying a declaration's fields onto - // anything else fails to typecheck and, at runtime, to run. readonly #handle: Handler; private constructor(token: typeof builtHere, handle: Handler) { refuseOutsideConstruction(token); @@ -97,7 +72,6 @@ export class Declaration { } } -/** A client approved for read-only access changes nothing. */ const refuseReadOnlyClient = McpInvocationContext.McpInvocationContext.pipe( Effect.flatMap((scope) => scope.client?.access === "read-only" @@ -112,17 +86,11 @@ const refuseReadOnlyClient = McpInvocationContext.McpInvocationContext.pipe( ), ); -/** - * The caller of a tool that changes something. Tools that act for a - * capability of their own (preview, device, worktree, pull requests) check it - * themselves. - */ const writingCaller = refuseReadOnlyClient.pipe( Effect.andThen(loadCaller()), Effect.tap(assertLiveCaller), ); -/** The same, for tools whose only capability is controlling threads. */ const orchestratingCaller = refuseReadOnlyClient.pipe( Effect.andThen(readCaller()), Effect.tap(assertLiveCaller), @@ -132,19 +100,12 @@ const requireThreadCaller = McpInvocationContext.McpInvocationContext.pipe( Effect.flatMap((scope) => McpInvocationContext.requireThreadScope(scope, "This tool")), ); -/** Changes nothing, so every caller may call it. */ export const reads = (handle: (params: P) => Effect.Effect) => declare((params: P) => handle(params)); -/** Reads what belongs to the calling T3 thread, such as its preview tabs or devices. */ export const readsAsCaller = (handle: (params: P) => Effect.Effect) => declare((params: P) => requireThreadCaller.pipe(Effect.flatMap(() => handle(params)))); -/** - * Acts as the calling T3 thread (its subagents, preview tabs, devices, - * worktree) while that thread's run is live. Only an agent running inside a - * T3 thread has one. - */ export const actsAsCaller = (handle: (params: P) => Effect.Effect) => declare((params: P) => requireThreadCaller.pipe( @@ -153,24 +114,16 @@ export const actsAsCaller = (handle: (params: P) => Effect.Effect(handle: (params: P) => Effect.Effect) => declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params)))); /** - * Changes something that belongs to no thread under a capability of the tool's own, such as - * writing a pull request's review guide. Unlike `writes` it does not need thread control; the - * handler checks its capability, as the capability toolkits do. + * Unlike `writes` it does not need thread control; the handler checks its + * capability, as the capability toolkits do. */ export const writesWithCapability = (handle: (params: P) => Effect.Effect) => declare((params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params)))); -/** - * Changes the threads `threads` names. An omitted id is the caller's own - * thread; any other thread must run within the caller's modes, checked here - * and again by the orchestrator when it applies each command. A thread that - * does not exist is the handler's to report. - */ export const writesThreads = ( threads: (params: P) => ReadonlyArray, handle: (params: P) => Effect.Effect, @@ -204,7 +157,6 @@ export const writesThreads = ( }), ); -/** How an agent hears that a thread it targets was raised above its modes mid-call. */ const escalationDenied = (refusal: DispatchModeRefusal) => new OrchestratorMcpFailure({ code: @@ -214,13 +166,11 @@ const escalationDenied = (refusal: DispatchModeRefusal) => message: `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this caller's. Its user changed it while this call ran.`, }); -/** The modes a started thread runs with: those requested, else the caller's own. */ export interface StartedModes { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; } -/** Starts threads with the modes `modes` requests, which may not be broader than the caller's. */ export const startsThreads = ( modes: (params: P) => { readonly runtimeMode?: RuntimeMode | undefined; @@ -247,9 +197,8 @@ const fullAccessRequired = "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; /** - * Changes projects or environment settings, which needs a full-access/default - * caller. `check` re-checks the caller wherever the handler waits before - * writing, such as for a lock, since the caller's modes can change meanwhile. + * `check` re-checks the caller wherever the handler waits before writing, such + * as for a lock, since the caller's modes can change meanwhile. */ export const writesEnvironment = ( handle: ( @@ -263,30 +212,18 @@ export const writesEnvironment = ( return declare((params: P) => check.pipe(Effect.flatMap(() => handle(params, check)))); }; -/** What a declaration's own check needs. */ type CheckServices = Effect.Services; -/** - * Each handler of `Handlers`, built by one of the declarations above. A - * declaration is never callable, which rules out a handler function wearing a - * declaration's fields. - */ type Declarations = { readonly [Name in keyof Handlers]: Declaration & NotCallable; }; -/** Anything but a function. */ type NotCallable = { readonly call?: never } & { readonly apply?: never }; -/** A toolkit's handlers, each built by one of the declarations above. */ export type Handlers> = Declarations< Toolkit.HandlersFrom >; -/** - * A toolkit's handler layer built by `toLayer`, the only kind `/mcp` - * registers. Like a declaration it is nominal, so nothing else passes for one. - */ export class HandlersLayer, EX = never, RX = never> { readonly #layer: Layer.Layer, EX, RX>; private constructor( @@ -299,7 +236,6 @@ export class HandlersLayer, EX = never, R static { handlersLayer = (layer) => new HandlersLayer(builtHere, layer); } - /** The handlers, for registering this toolkit on the MCP server. */ static layer, EX, RX>( handlers: HandlersLayer, ) { @@ -310,7 +246,6 @@ export class HandlersLayer, EX = never, R const checkedHandlers = (declarations: Declarations): Handlers => Struct.map(declarations, checkedHandler); -/** `Toolkit.toLayer` for handlers that all declare their access. */ export const toLayer = , EX = never, RX = never>( toolkit: Toolkit.Toolkit, build: Handlers | Effect.Effect, EX, RX>, diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 1c81d0ddb264..72497c5825f7 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -43,7 +43,7 @@ const childRunId = RunId.make("run-mcp-child"); const taskId = NodeId.make("node-mcp-task-1"); const now = DateTime.makeUnsafe("2026-08-04T12:00:00.000Z"); const codexDriver = ProviderDriverKind.make("codex"); -// Distinct from driver kind so a regression that re-derives from driver fails. + const customCodexInstanceId = ProviderInstanceId.make("codex-custom-workspace"); const parentInstanceId = ProviderInstanceId.make("codex"); @@ -498,7 +498,6 @@ it("readThread and sendToThread reach threads in other projects", async () => { }); expect(sent.threadId).toBe(foreignThreadId); - // Once the caller's run ends, it can still read other threads but no longer write to them. parentRuns = []; yield* service.readThread(makeScope(), { threadId: foreignThreadId }); const stale = yield* service @@ -509,7 +508,7 @@ it("readThread and sendToThread reach threads in other projects", async () => { .interruptThread(makeScope(), { threadId: foreignThreadId }) .pipe(Effect.flip); expect(staleInterrupt.code).toBe("parent_not_active"); - // Nor create, change or remove scheduled work in another project. + const staleSchedule = yield* service .scheduleTask(makeScope(), { projectId: foreignProjectId, diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 64f2d86eda94..f0ada42011ea 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -217,7 +217,6 @@ describe("OrchestratorMcpService", () => { status: "cancelled", delegatedTaskId: taskId, }, - // An unrelated later run cannot supply this follow-up's result. { id: RunId.make("run:mcp-restart-later"), ordinal: 2, status: "running" }, ], contextTransfers: [], @@ -270,7 +269,6 @@ describe("OrchestratorMcpService", () => { assert.equal(held.status, "running"); assert.equal(held.workState, "working"); assert.isNull(held.summary); - // Acknowledging the cut run would suppress the real result's wake. assert.equal(yield* Ref.get(dispatched), 0); awaitsRestart = false; const settled = yield* service.taskStatus(scope, taskId); @@ -317,7 +315,6 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), - // The child still runs within the parent's modes. getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( @@ -395,7 +392,6 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), - // The child still runs within the parent's modes. getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( @@ -476,7 +472,6 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), - // The child still runs within the parent's modes. getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( @@ -532,7 +527,6 @@ describe("OrchestratorMcpService", () => { const childRunId = RunId.make("run:mcp-cancel-dispose-failed-child"); const taskId = NodeId.make("node:mcp-cancel-dispose-failed-task"); const dispatched = yield* Ref.make>([]); - // The parent runs Supervised in plan mode. const parentProjection = { thread: { id: parentThreadId, runtimeMode: "approval-required", interactionMode: "plan" }, runs: [], @@ -564,7 +558,6 @@ describe("OrchestratorMcpService", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), - // Its user has since raised the child to full access. getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), dispatch: (command) => Ref.update(dispatched, (commands) => [...commands, command]).pipe( @@ -609,13 +602,8 @@ describe("OrchestratorMcpService", () => { }), ); - /** - * A Supervised parent cancels its Supervised child, under which a task - * now runs at full access. Reports what the cancel did. - */ const cancelOverRaisedGrandchild = (child: { readonly deleted: boolean; - /** The grandchild passes the check, and its user raises it before the stop reaches it. */ readonly raisedDuringStop?: boolean; }) => Effect.gen(function* () { @@ -635,7 +623,6 @@ describe("OrchestratorMcpService", () => { result: null, completionDelivery: { state: "pending" }, }); - // A Supervised parent delegated a Supervised child, which delegated a task of its own. const projections = new Map([ [ parentThreadId, @@ -681,8 +668,6 @@ describe("OrchestratorMcpService", () => { NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => Effect.succeed(projections.get(threadId)!), - // The child still runs Supervised; its user has since raised the task under it - // to full access. getThreadShell: (threadId) => Effect.succeed( threadId === grandchildThreadId && child.raisedDuringStop !== true @@ -695,8 +680,6 @@ describe("OrchestratorMcpService", () => { Ref.update(dispatched, (commands) => [...commands, command]).pipe( Effect.as({} as never), ), - // Stands in for the orchestrator, which finds the grandchild raised - // under its lock, when the stop runs under the parent's limit. stopDelegatedTasks: () => Effect.gen(function* () { const limit = yield* DispatchModeLimit; @@ -828,7 +811,6 @@ describe("OrchestratorMcpService provider resolution", () => { skills: [], }); - /** A registry whose probes report the same snapshots it already holds. */ const providerRegistryLayer = (providers: ReadonlyArray) => Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed(providers), @@ -928,16 +910,11 @@ describe("OrchestratorMcpService provider resolution", () => { driver: ProviderDriverKind.make("acpRegistry"), model: "acp-model", }), - // Antigravity has a live orchestration adapter through the provider - // instance registry even though no adapter driver entry exists in - // the static built-in list. providerSnapshot({ instanceId: antigravityInstanceId, driver: ProviderDriverKind.make("antigravity"), model: "ant-model", }), - // A second Antigravity instance whose adapter resolves but whose - // provider state still blocks delegation. providerSnapshot({ instanceId: disabledAntigravityInstanceId, driver: ProviderDriverKind.make("antigravity"), @@ -1062,7 +1039,6 @@ describe("OrchestratorMcpService provider resolution", () => { models: [ ...codex.models, { slug: "gpt-5.4-mini", name: "Mini", isCustom: false, capabilities: null }, - // Custom models cannot be hidden, matching the client pickers. { slug: "my-model", name: "Mine", isCustom: true, capabilities: null }, ], }, @@ -1346,8 +1322,6 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ hasReceipt: () => Effect.succeed(false), getThreadRecords: () => Effect.succeed(parentProjection([])), - // ThreadManagementService.dispatch is the chokepoint now: it is the - // one that refuses, not a separate pre-dispatch assert. dispatch: () => Effect.fail( new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({ @@ -1420,8 +1394,6 @@ describe("OrchestratorMcpService provider resolution", () => { ? parentProjection(delegated ? [task] : []) : childProjection, ), - // The parent's own instance is off for this project; the other - // instance of the same driver is on. isProviderInstanceEnabledForProject: ({ instanceId }) => Effect.succeed(instanceId !== codexInstanceId), assertProviderInstanceEnabled: ({ projectId, instanceId }) => @@ -1529,11 +1501,7 @@ describe("OrchestratorMcpService provider resolution", () => { : childProjection, ), ), - // A command id already accepted once, even if the project later - // disables every candidate: its retry must replay, not re-resolve. hasReceipt: () => Ref.get(accepted), - // Effective settings once the project turns every instance off, - // ignoring the already-accepted command's replay exemption. isProviderInstanceEnabledForProject: () => Ref.get(everyCandidateDisabled).pipe(Effect.map((disabled) => !disabled)), assertProviderInstanceEnabled: ({ projectId, instanceId }) => @@ -1593,8 +1561,6 @@ describe("OrchestratorMcpService provider resolution", () => { }); assert.equal(first.status, "running"); - // The project turns off every instance of this driver only after - // the delegation above was already accepted. yield* Ref.set(everyCandidateDisabled, true); const retry = yield* service.delegateTask(scope, { @@ -1655,7 +1621,6 @@ describe("OrchestratorMcpService provider resolution", () => { driver: ProviderDriverKind.make("codex"), model: "gpt-5.4", }); - // The cache still says the CLI is missing; a probe reports `cliInstalled`. let cliInstalled = false; const probes = yield* Ref.make(0); const dispatched = yield* Ref.make(0); @@ -2054,7 +2019,6 @@ describe("OrchestratorMcpService provider resolution", () => { NodeServices.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadShell: () => Effect.succeed(null), - // Its turn ended: no run is active. getThreadRecords: () => Effect.succeed(idleThreadProjection(shell)), }), ServerSettings.layerTest(), @@ -2100,7 +2064,6 @@ describe("OrchestratorMcpService provider resolution", () => { it.effect("checks a bound task against its thread's current modes", () => Effect.gen(function* () { const upserted = yield* Ref.make(0); - // Scheduled while the thread was Supervised; the thread now runs in full access. const bound = task({ threadId: boundThreadId }); const layer = service( [bound], @@ -2128,7 +2091,6 @@ describe("OrchestratorMcpService provider resolution", () => { const upserted = yield* Ref.make(0); const bound = task({ threadId: boundThreadId }); const lookups = yield* Ref.make(0); - // The edit's own check reads the thread; the read after the save fails. const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( Effect.provide( layerOrchestratorService.pipe( @@ -2178,7 +2140,6 @@ describe("OrchestratorMcpService provider resolution", () => { }); describe("resolveRuntimeMode", () => { - // Muse offers only these two, and runs any other mode as Supervised. const muse = { modelSelection: { instanceId: ProviderInstanceId.make("muse"), model: "muse-model" }, supportedRuntimeModes: ["approval-required", "full-access"] as const, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5c0a93cb1c0f..9378251eb9e8 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -101,8 +101,6 @@ import * as SecretRequests from "../secrets/SecretRequests.ts"; const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; -// Events that can end a delegated task wait: the parent's task record, -// and the child's runs, nested tasks, provider background work, and questions. const TASK_WAKE_EVENTS = [ { thread: "parent", eventType: "subagent.updated" }, { thread: "child", eventType: "run.updated" }, @@ -110,7 +108,6 @@ const TASK_WAKE_EVENTS = [ { thread: "child", eventType: "provider-thread.updated" }, { thread: "child", eventType: "runtime-request.updated" }, ] as const; -/** A person answers the card, so a slower poll is plenty. */ const SECRET_REQUEST_POLL_INTERVAL_MS = 500; const DEFAULT_THREAD_LIST_LIMIT = 50; const DEFAULT_THREAD_READ_LIMIT = 50; @@ -164,8 +161,7 @@ export interface OrchestratorMcpServiceShape { input: OrchestratorMcpDeleteScheduledTaskInput, ) => Effect.Effect; /** - * Asks the user for a secret through a card in the calling thread and waits - * for the answer. The value never reaches the agent: the result is a status. + * The value never reaches the agent: the result is a status. */ readonly requestSecret: ( scope: McpInvocationScope, @@ -229,11 +225,6 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -/** - * Workspace strategy for a scheduled task created/updated over MCP: bound runs - * post into the existing thread (the strategy is unused, keep root); unbound - * runs launch a fresh worktree per run. - */ function scheduledTaskWorkspaceStrategy( boundToThread: boolean, ): ScheduledTask["workspaceStrategy"] { @@ -242,11 +233,6 @@ function scheduledTaskWorkspaceStrategy( : { type: "worktree", baseRef: "main", startFromOrigin: true }; } -/** - * A scheduled task as an agent sees it. `mayRun` says whether the caller may - * run it: a webhook's URL carries the secret that starts the task's runs, so - * only such a caller sees it. - */ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): OrchestratorMcpScheduledTask { return { scheduledTaskId: task.id, @@ -258,7 +244,6 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato schedule: task.schedule, nextRunAt: task.nextRunAt, lastRunStatus: task.lastRunStatus, - // A bare path is not a URL anyone can call, so agents never get one to share. ...(task.webhook?.url == null || !mayRun ? {} : { webhookUrl: task.webhook.url }), ...(task.webhook === undefined ? {} @@ -290,12 +275,9 @@ function providerConstraints( } /** - * Checks requested option selections for duplicates and, when the model - * advertises option descriptors, against those descriptors. Models without - * descriptors skip the descriptor checks (mirroring how model slugs are only - * validated when the provider advertises models), but duplicate ids always - * fail: downstream consumers disagree on whether the first or last value of - * a duplicated id wins. + * Models without descriptors skip the descriptor checks (mirroring how model slugs are only + * validated when the provider advertises models), but duplicate ids always fail: downstream + * consumers disagree on whether the first or last value of a duplicated id wins. */ function invalidOptionSelections( selections: ReadonlyArray, @@ -369,7 +351,6 @@ export function delegatedTaskRun( transfer.targetThreadId === task.childThreadId, ); if (spawnTransfer === undefined) { - // Legacy delegated-task projections predate the durable spawn transfer. return childProjection.runs[0]; } return spawnTransfer.targetRunId === null @@ -381,7 +362,6 @@ export function hasPendingChildRuns( childProjection: Pick, delegatedRun: OrchestrationV2Run | undefined, ): boolean { - // Held queued runs wait for the user to resume the child; task_cancel holds them. return childProjection.runs.some( (run) => !ThreadManagementService.isTerminalRunStatus(run.status) && @@ -540,13 +520,6 @@ function interactionModeRank(mode: ProviderInteractionMode): number { return mode === "plan" ? 0 : 1; } -/** - * The mode a new thread runs in: the requested mode, else the parent's, never - * broader than the parent's. Pass `target` when the thread runs on a known - * provider. A provider runs a mode it does not offer as Supervised, so an - * explicit request for one is refused, and an inherited one steps down to the - * broadest mode the provider offers. - */ export function resolveRuntimeMode( parentMode: RuntimeMode, requested: OrchestratorMcpRuntimeMode | undefined, @@ -773,7 +746,6 @@ function threadDetail( ).length, archived: projection.thread.archivedAt !== null, ...threadSettlement(projection.thread), - // From the shell, like the list, so read and list agree on snooze state. ...threadSnooze(shell, nowMs), createdAt: DateTime.formatIso(projection.thread.createdAt), updatedAt: DateTime.formatIso(projection.thread.updatedAt), @@ -913,7 +885,6 @@ const make = Effect.gen(function* () { const gitDriver = yield* GitVcsDriver.GitVcsDriver; const serverSettings = yield* ServerSettings.ServerSettingsService; - /** A caller-named project, which must exist before anything is recorded against it. */ const requireProject = (projectId: ProjectId) => projects.getById(projectId).pipe( Effect.mapError((error) => @@ -928,7 +899,6 @@ const make = Effect.gen(function* () { ), ); - /** A client has no thread to inherit a model from, so it falls back to the project default. */ const projectDefaultModelSelection = ( project: Effect.Success>, ) => @@ -998,11 +968,6 @@ const make = Effect.gen(function* () { ) .pipe(Effect.mapError(threadManagementFailure)); - /** - * The caller's own thread and the most it may hand to threads it targets. A - * thread caller is capped by its own modes; an OAuth client by the ceiling - * chosen when it was approved. - */ const loadCaller = (scope: McpInvocationScope) => Effect.gen(function* () { yield* requireCapability(scope); @@ -1025,7 +990,6 @@ const make = Effect.gen(function* () { } as const; }); - /** The caller's own thread, for operations that act as the caller. */ const loadThreadCaller = (scope: McpInvocationScope, operation: string) => Effect.gen(function* () { yield* requireCapability(scope); @@ -1034,7 +998,6 @@ const make = Effect.gen(function* () { return { scope: threadScope, parent } as const; }); - /** A target project: the one passed, else the calling thread's. */ const resolveProjectTarget = ( parent: Pick | undefined, projectId: ProjectId | undefined, @@ -1050,7 +1013,6 @@ const make = Effect.gen(function* () { ), ); - /** Any live thread in the environment. */ const loadTargetThread = (threadId: ThreadId) => Effect.gen(function* () { const shell = yield* threadManagement @@ -1085,7 +1047,6 @@ const make = Effect.gen(function* () { ? Effect.void : assertLiveCaller(scope, parent); - /** Scheduled work outside the caller's own project needs the same live run. */ const assertLiveCallerForOtherProject = ( scope: McpInvocationScope, parent: Pick | undefined, @@ -1130,12 +1091,6 @@ const make = Effect.gen(function* () { const loadProviders = providerRegistry.getProviders; - /** - * Instance ids the adapter registry resolves β€” the same lookup a - * `delegated_task.request` performs when it runs. Capability reporting and - * target resolution must not advertise a set narrower (or wider) than what - * dispatch can actually serve. - */ const loadOrchestrationCapableInstanceIds = () => providerAdapters.list().pipe(Effect.map((instanceIds) => new Set(instanceIds))); @@ -1181,13 +1136,11 @@ const make = Effect.gen(function* () { readonly target: OrchestratorMcpTarget | undefined; readonly providers: ReadonlyArray; /** - * Skips the per-candidate project-enablement check in driver-only - * resolution. Set only when the command that will dispatch this target - * already has a receipt: that dispatch can only replay the recorded - * result, never start anything new, so filtering candidates by the - * project's current settings would just refuse a retry that does no new - * work. Never set this for a resolution whose dispatch uses a different - * command id than the one checked for a receipt. + * Set only when the command that will dispatch this target already has a receipt: that + * dispatch can only replay the recorded result, never start anything new, so filtering + * candidates by the project's current settings would just refuse a retry that does no new + * work. Never set this for a resolution whose dispatch uses a different command id than the + * one checked for a receipt. */ readonly skipProjectEnablementFilter?: boolean; }): Effect.Effect => @@ -1209,11 +1162,6 @@ const make = Effect.gen(function* () { `No V2 provider adapter is registered for driver ${requestedDriver}.`, ); } - // Inherit the parent's instance only when it can actually serve the - // child; an unavailable parent yields to a healthy instance of the - // requested driver rather than failing the delegation. An instance - // the project turned off cannot serve either, so selection skips it - // for the next candidate instead of failing the enablement check. const healthyCandidates = candidates.filter( (candidate) => providerConstraints(candidate, true).length === 0, ); @@ -1396,8 +1344,6 @@ const make = Effect.gen(function* () { messages: [...childControls.messages, ...resultRecords.messages], turnItems: resultRecords.turnItems, }; - // A restart cut the child's run and its continuation has not settled, or - // the child started working again after this read. const heldForRestart = task.result === null && taskWorkState === "result_available" && @@ -1464,7 +1410,6 @@ const make = Effect.gen(function* () { : subagentResultForRun(childProjection, terminalRun).text : null, latestTerminalResultContextTransferId: resultTransferForRun(terminalRun)?.id ?? null, - // A finished task has no question of its own; later child runs belong to other tasks. hasPendingQuestion: !isTerminalTaskStatus(status) && childControls.runtimeRequests.some( @@ -1510,8 +1455,6 @@ const make = Effect.gen(function* () { return response; }); - // Re-read the task only when an event on the parent or child thread can - // change its status, instead of polling the projections every 50 ms. const waitForTask = (scope: McpThreadInvocationScope, taskId: NodeId, timeoutMs: number) => Effect.gen(function* () { const streamError = (error: unknown) => @@ -1546,11 +1489,6 @@ const make = Effect.gen(function* () { ); }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten)); - /** - * The modes a task's runs execute at: its own, or for a task bound to a - * thread, also that thread's modes as they are now, since its runs are - * messages to that thread. - */ const scheduledTaskRunModes = (task: ScheduledTask) => Effect.gen(function* () { const modes = [{ runtimeMode: task.runtimeMode, interactionMode: task.interactionMode }]; @@ -1572,10 +1510,9 @@ const make = Effect.gen(function* () { interactionModeRank(modes.interactionMode) <= interactionModeRank(limits.interactionMode); /** - * A task as the caller may see it. Its webhook URL starts runs, so only a - * caller that may start one sees it: never a client approved for read-only - * access, and a thread caller only with a live turn, at modes covering every - * mode the task runs at. + * Its webhook URL starts runs, so only a caller that may start one sees it: never a client + * approved for read-only access, and a thread caller only with a live turn, at modes covering + * every mode the task runs at. */ const summarizeScheduledTask = ( scope: McpInvocationScope, @@ -1593,8 +1530,6 @@ const make = Effect.gen(function* () { caller.parent === undefined ? scope.client?.access !== "read-only" : Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent))); - // This runs after a save, so a failed lookup of the bound thread hides - // the webhook URL instead of reporting a saved task as an error. const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option); return scheduledTaskSummary( task, @@ -1642,7 +1577,6 @@ const make = Effect.gen(function* () { const projectId = yield* resolveProjectTarget(parent, input.projectId); yield* assertLiveCallerForOtherProject(scope, parent, projectId); const project = yield* requireProject(projectId); - // Binding means "wake this thread", which only a thread caller in that project has. const bindToCurrentThread = input.bindToCurrentThread ?? (parent !== undefined && parent.thread.projectId === projectId); @@ -1783,7 +1717,6 @@ const make = Effect.gen(function* () { }), requestSecret: (scope, input) => Effect.gen(function* () { - // The card is shown in, and answered from, the caller's own thread. const { scope: threadScope, parent } = yield* loadThreadCaller(scope, "request_secret"); const threadId = threadScope.thread.threadId; const run = ThreadManagementService.latestActiveRun(parent); @@ -1800,8 +1733,6 @@ const make = Effect.gen(function* () { const runId = run.id; const nodeId = run.rootNodeId; const key = yield* requestKey(input.clientRequestId); - // Turn item ids are global; scope the key to this thread. A retry with - // the same clientRequestId finds this card, answered or not. const turnItemId = TurnItemId.make( `turn-item:secret-request:${stablePart(threadId)}:${stablePart(key)}`, ); @@ -1834,16 +1765,13 @@ const make = Effect.gen(function* () { yield* record("pending"); // Only this call can hand the agent its ref, so the card must not // outlive it: a timeout, a failed wait or an aborted call closes it as - // cancelled. If even that fails, the server still refuses an answer - // once the run ends, and an unused value expires. + // cancelled. const closeCard = record("cancelled").pipe( Effect.catch((error) => Effect.logWarning("Could not close a secret request card", { error: error.message }), ), ); - // The user answers the card (secrets.answerRequest), or it ends with - // the run; poll it like a delegated task. const answered = yield* Effect.gen(function* () { while (true) { const projection = yield* threadManagement @@ -1882,7 +1810,6 @@ const make = Effect.gen(function* () { Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : closeCard)), ); if (Option.isNone(answered)) yield* closeCard; - // An answer that raced the timeout still wins: the card is answered once. const status = Option.isSome(answered) ? answered.value : yield* threadManagement @@ -1903,8 +1830,6 @@ const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "secret_request.status": status }); yield* Metrics.increment(Metrics.secretRequestsTotal, { status }); if (status !== "saved") return { status }; - // Saved means the value was stored before the card said so; a missing - // value is a storage fault, not an answer the agent can act on. const secretRef = yield* secretRequests.savedRef({ threadId: threadId, turnItemId }); if (Option.isNone(secretRef)) { return yield* failure( @@ -1920,7 +1845,6 @@ const make = Effect.gen(function* () { const { parent, limits } = yield* loadCaller(scope); const providers = yield* loadProviders; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); - // Models the user hid from pickers stay out of the catalog agents choose from. const modelPreferences = yield* serverSettings.getSettings.pipe( Effect.map((settings) => settings.providerModelPreferences ?? {}), Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")), @@ -1992,10 +1916,6 @@ const make = Effect.gen(function* () { ); } const providers = yield* loadProviders; - // Computed before resolving the target so a retry of an already - // accepted delegation can be recognized: dispatching under the same - // command id only replays the recorded result, so candidate - // selection must not refuse it over the project's current settings. const key = yield* requestKey(input.clientRequestId); const commandId = stableCommandId({ scope, @@ -2106,8 +2026,7 @@ const make = Effect.gen(function* () { return waited.value; } // An incomplete wait gives up delivery: upgrade so a later terminal - // wakes the parent even mid-turn. Best effort; settled_only still - // wakes a settled parent if the upgrade fails. + // wakes the parent even mid-turn. yield* threadManagement .dispatch({ type: "delegated_task.wake-policy", @@ -2121,10 +2040,6 @@ const make = Effect.gen(function* () { completionWake: "always", }) .pipe( - // The tool result still returns after a timeout or question, so failures - // stay warnings. Keep the two shapes apart: a rejected receipt - // means this exact command id already failed (a replay of a - // no-op upgrade), while anything else is a fresh dispatch fault. Effect.catch((error) => Effect.logWarning("orchestrator-mcp.delegate-task.wake-policy-failed", { taskId, @@ -2164,7 +2079,6 @@ const make = Effect.gen(function* () { const shell = yield* threadManagement .getThreadShell(threadId) .pipe(Effect.mapError(threadManagementFailure)); - // A deleted thread takes no stop, but the tasks under it still do. if (shell !== null && shell.deletedAt === null) { yield* resolveRuntimeMode(parentProjection.thread.runtimeMode, shell.runtimeMode); yield* resolveInteractionMode( @@ -2208,9 +2122,6 @@ const make = Effect.gen(function* () { ), ), ); - // Cancelling stops the child thread the way Stop does, including work that came - // after a published result: follow-up runs, pull request watch wakes, and the - // tasks it delegated. The published result stays terminal. const stopChild = Effect.gen(function* () { const commandId = stableCommandId({ scope, requestKey: key, operation: "cancel-task" }); const reason = input.reason; @@ -2229,7 +2140,6 @@ const make = Effect.gen(function* () { ), ), ); - // A retry with the same clientRequestId repeats only the stops that failed. yield* threadManagement .stopDelegatedTasks({ threadId: current.childThreadId, commandId, reason }) .pipe( @@ -2273,8 +2183,6 @@ const make = Effect.gen(function* () { status: current.status, } satisfies OrchestratorMcpTaskCancelResult; } - // A task waiting on its own delegates has work to stop below it. One with neither - // a running turn nor delegates (such as one awaiting a restart) keeps its delivery. const child = yield* loadProjection(current.childThreadId); if ( current.workState !== "waiting_for_children" && @@ -2669,7 +2577,6 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId); yield* assertLiveCallerForOtherThread(scope, parent, target); - // Stopping another thread's work is a write: it must run within the caller's modes. yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); const key = yield* requestKey(input.clientRequestId); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index c518fd459481..1e7ce5a4ff52 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -448,7 +448,6 @@ const client = McpSchema.McpServerClient.of({ getClient: Effect.die("unused"), }); -/** Build a persisted-looking ScheduledTask from an upsert input for the in-memory stub. */ function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask { const timestamp = IsoDateTime.make("2026-07-01T09:00:00.000Z"); return { @@ -488,7 +487,6 @@ function scheduledTaskFromUpsert(input: ScheduledTaskUpsertInput): ScheduledTask }; } -/** In-memory server secret store for tests that exercise secret requests. */ const layerMemorySecretStore = Layer.sync(ServerSecretStore.ServerSecretStore, () => { const stored = new Map(); return ServerSecretStore.ServerSecretStore.of({ @@ -537,8 +535,6 @@ describe("orchestrator MCP toolkit", () => { makeDeterministicAdapter({ instanceId: codexInstanceId, driver: ProviderDriverKind.make("codex"), - // Exercise queued completion ownership on a session without native steering. - // Native mailbox delivery and its completion races have dedicated integration tests. capabilities: { ...CodexProviderCapabilitiesV2, turns: { ...CodexProviderCapabilitiesV2.turns, supportsActiveSteering: false }, @@ -570,8 +566,6 @@ describe("orchestrator MCP toolkit", () => { : `Claude completed: ${turn.message.text}`, }), ]); - // Captures parent-wake offers made when a delegated child - // terminalizes after the parent run settled. const continuationOffers = yield* Ref.make< ReadonlyArray >([]); @@ -583,8 +577,6 @@ describe("orchestrator MCP toolkit", () => { take: Effect.never, }, ); - // Offers land after the finalize projection writes, so poll briefly - // instead of asserting counts immediately. const waitForContinuationOffers = (count: number) => Effect.gen(function* () { for (let attempt = 0; attempt < 1_000; attempt += 1) { @@ -596,8 +588,6 @@ describe("orchestrator MCP toolkit", () => { } return yield* Ref.get(continuationOffers); }); - // Absence has no event to await, so sample repeatedly instead of - // trusting a single sleep to outlast a late offer. const expectOffersToStay = (count: number) => Effect.gen(function* () { for (let sample = 0; sample < 4; sample += 1) { @@ -659,8 +649,6 @@ describe("orchestrator MCP toolkit", () => { model: "opencode/test", }), ]); - // In-memory ScheduledTaskService stub so the schedule/list/update/ - // delete tools can be exercised without SQL/launch wiring. const scheduledStore = yield* Ref.make>([]); const layerScheduledTaskStub = Layer.succeed( ScheduledTaskService.ScheduledTaskService, @@ -785,7 +773,6 @@ describe("orchestrator MCP toolkit", () => { const invoke = (name: string, args: Record) => invokeAs(invocation, name, args); - // Settling would stop the session, so the agent's own turn keeps running. const deferredSettle = yield* invoke("t3_thread_organize", { action: "settle" }); expect(deferredSettle.isError).toBe(false); expect(deferredSettle.structuredContent).toEqual({ settlesWhenTurnEnds: true }); @@ -905,10 +892,6 @@ describe("orchestrator MCP toolkit", () => { return { task, delivery, queuedRun }; }); - // Several async terminals belonging to one parent run reserve one - // durable delivery. The continuation worker owns the later - // message.dispatch, so use the same metadata here while the test - // probe records the offers instead of starting a worker. const coalescedTask = (suffix: string) => orchestrator.dispatch({ type: "delegated_task.request", @@ -1004,8 +987,6 @@ describe("orchestrator MCP toolkit", () => { return yield* Effect.die(new Error("Queued coalesced delivery missing.")); } - // Server-owned delivery ownership is authoritative even if a - // stale client tries to mutate the queue directly. const completionEditError = yield* orchestrator .dispatch({ type: "queued-run.edit", @@ -1059,10 +1040,6 @@ describe("orchestrator MCP toolkit", () => { ).toBeNull(); yield* Ref.set(continuationOffers, []); - // Waiting only observes the child run's status. A direct parent - // read acknowledges delivery only after the terminal result is - // returned untruncated, never from the child prompt or a partial - // result page. const directRead = yield* queueAutomaticCompletion( "direct-child-read", "Complete before a parent reads this child result directly.", @@ -1142,7 +1119,6 @@ describe("orchestrator MCP toolkit", () => { expect(firstChunk.text + (remainder.items[0]?.text ?? "")).toBe( "Claude completed: Complete before a parent reads this child result directly.", ); - // Reading a suffix alone cannot acknowledge a whole child result. expect( (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( (task) => task.id === directRead.task.id, @@ -1221,8 +1197,6 @@ describe("orchestrator MCP toolkit", () => { ).toMatchObject({ state: "acknowledged", observedByRunId: parentRun.id }); yield* Ref.set(continuationOffers, []); - // New user work retains its own queue entry while an explicit - // observation disposes the stale automatic one. const queueRace = yield* queueAutomaticCompletion( "queue-race", "Complete before a user queues follow-up work.", @@ -1318,8 +1292,6 @@ describe("orchestrator MCP toolkit", () => { runId: queuedUserRun.id, }); - // A user Steer keeps the parent run active. Once the - // parent observes the child result, its queued delivery is stale. const steerRace = yield* queueAutomaticCompletion( "steer-race", "Complete before a user steers the parent.", @@ -1355,9 +1327,6 @@ describe("orchestrator MCP toolkit", () => { ?.delivery, ).toBeNull(); - // Restart creates a new attempt for the same parent cohort. The - // old terminal must not turn that continuation into a Stop - // barrier, and an acknowledged result cancels the stale delivery. const restartRace = yield* queueAutomaticCompletion( "restart-race", "Complete before a user restarts the parent.", @@ -1489,14 +1458,10 @@ describe("orchestrator MCP toolkit", () => { providerInstanceId: claudeInstanceId, canRunCrossProviderChildTask: true, }), - // No opencode adapter is registered in this harness, so the - // capability view must not claim delegation can target it. expect.objectContaining({ providerInstanceId: "opencode", canRunChildTask: false, }), - // Models advertise their option descriptors so agents can - // discover valid target.options ids and values. expect.objectContaining({ providerInstanceId: codexInstanceId, models: [ @@ -1518,12 +1483,10 @@ describe("orchestrator MCP toolkit", () => { }); expect(scheduleCall.isError).toBe(false); expect(scheduleCall.structuredContent).toMatchObject({ - // Defaults to binding the calling thread. boundThreadId: parentThreadId, projectId, enabled: true, schedule: { type: "interval", everyMs: 60_000 }, - // Title is derived from the prompt when omitted. title: "wake up in this thread and say hello", }); const scheduledTaskId = (scheduleCall.structuredContent as { scheduledTaskId: string }) @@ -1535,18 +1498,15 @@ describe("orchestrator MCP toolkit", () => { projectId, createdBy: "agent", creationSource: "mcp", - // Inherits the parent thread's model selection. modelSelection: codexSelection, }); - // list_scheduled_tasks returns the task scoped to this project. const scheduledListCall = yield* invoke("list_scheduled_tasks", {}); expect(scheduledListCall.isError).toBe(false); expect(scheduledListCall.structuredContent).toMatchObject({ tasks: [{ scheduledTaskId, boundThreadId: parentThreadId }], }); - // update_scheduled_task pauses without deleting. const scheduledUpdateCall = yield* invoke("update_scheduled_task", { scheduledTaskId, enabled: false, @@ -1557,7 +1517,6 @@ describe("orchestrator MCP toolkit", () => { enabled: false, }); - // delete_scheduled_task removes it entirely. const scheduledDeleteCall = yield* invoke("delete_scheduled_task", { scheduledTaskId }); expect(scheduledDeleteCall.isError).toBe(false); expect(scheduledDeleteCall.structuredContent).toMatchObject({ @@ -1587,9 +1546,6 @@ describe("orchestrator MCP toolkit", () => { }); expect(yield* Ref.get(scheduledStore)).toHaveLength(0); - // The agent asks for a secret; the tool waits for the user and - // returns a one-use ref, never the value, which a signed webhook - // task then consumes. const secretRequests = yield* SecretRequests.SecretRequests; const secretFiber = yield* invoke("request_secret", { label: "GitHub webhook secret", @@ -1597,8 +1553,6 @@ describe("orchestrator MCP toolkit", () => { placeholder: "Paste the webhook secret", clientRequestId: "release-webhook-secret", }).pipe(Effect.forkChild); - // Polled without the helper's short budget: under load the tool's - // own reads come first. const asked = yield* Effect.gen(function* () { while (true) { const projection = yield* orchestrator.getThreadProjection(parentThreadId); @@ -1620,7 +1574,6 @@ describe("orchestrator MCP toolkit", () => { label: "GitHub webhook secret", placeholder: "Paste the webhook secret", }); - // Asking again with the same id leaves the open card exactly as it was. yield* orchestrator.dispatch({ type: "secret_request.record", commandId: CommandId.make("command:test:secret-request-replay"), @@ -1637,15 +1590,12 @@ describe("orchestrator MCP toolkit", () => { (item) => item.id === card.id, ), ).toMatchObject({ label: "GitHub webhook secret", runId: card.runId }); - // The agent is blocked on the user, so the thread asks for input - // like a question does, in both shell paths. expect( (yield* orchestrator.getThreadShell(parentThreadId))?.pendingRuntimeRequest, ).toMatchObject({ kind: "user_input" }); expect(threadShellFromProjection(asked).pendingRuntimeRequest).toMatchObject({ kind: "user_input", }); - // What the card's Save sends (secrets.answerRequest). yield* secretRequests.answer({ threadId: parentThreadId, turnItemId: card.id, @@ -1662,15 +1612,12 @@ describe("orchestrator MCP toolkit", () => { (yield* orchestrator.getThreadShell(parentThreadId))?.pendingRuntimeRequest ?? null, ).toBeNull(); expect(secretResult.secretRef).toMatch(/^secret-ref:[0-9a-f]{32}$/); - // The value appears nowhere in what the agent received. const received = [ ...secretCall.content.map((part) => ("text" in part ? part.text : "")), ...Object.values(secretResult), ]; expect(received.some((value) => value.includes("github-webhook-secret"))).toBe(false); - // A retry that lost the first result gets the same answer, with no - // second card for the user. const retried = yield* invoke("request_secret", { label: "GitHub webhook secret", reason: "Signs release webhooks. Enter the same value in GitHub's webhook settings.", @@ -1683,7 +1630,6 @@ describe("orchestrator MCP toolkit", () => { ), ).toHaveLength(1); - // The ref is the secret for exactly one consumer. expect( yield* secretRequests.consume({ ref: secretResult.secretRef as never, @@ -1806,7 +1752,6 @@ describe("orchestrator MCP toolkit", () => { expect(delegatedStatus.resultContextTransferId).not.toBeNull(); expect(delegatedStatus.latestTerminalResultContextTransferId).not.toBeNull(); - // Wait-mode completion already returned its result while the parent was live. yield* expectOffersToStay(0); const childFollowupCall = yield* invoke("t3_thread_send", { @@ -1896,7 +1841,6 @@ describe("orchestrator MCP toolkit", () => { legacyDelegatedRun, ), ).toBe(true); - // A queue Stop held waits for the user; it is not pending child work. expect( hasPendingChildRuns( { @@ -1930,7 +1874,6 @@ describe("orchestrator MCP toolkit", () => { result: delegatedResult, completionDelivery: { state: "disposed" }, }); - // Cancelling a finished task still stops the child thread's later work. yield* waitForProjection(orchestrator, delegated.childThreadId, (projection) => projection.runs.some( (run) => run.id === activeChildFollowup.runId && run.status === "interrupted", @@ -1951,7 +1894,6 @@ describe("orchestrator MCP toolkit", () => { latestTerminalStatus: "interrupted", }); - // Later requested work has its own delivery, including an interrupted follow-up. expect(childFollowup.taskId).toBeDefined(); expect(activeChildFollowup.taskId).toBeDefined(); yield* waitForContinuationOffers(2); @@ -1979,8 +1921,6 @@ describe("orchestrator MCP toolkit", () => { ), ).toHaveLength(1); - // Options outside the model's advertised descriptors are rejected - // before any child thread is created. const rejectedOptionsCall = yield* invoke("delegate_task", { task: cancellationPrompt, target: { @@ -1997,8 +1937,6 @@ describe("orchestrator MCP toolkit", () => { message: expect.stringContaining("rejected options"), }); - // Duplicate option ids fail fast: downstream consumers disagree on - // whether the first or last value of a duplicated id wins. const duplicateOptionsCall = yield* invoke("delegate_task", { task: cancellationPrompt, target: { @@ -2023,7 +1961,6 @@ describe("orchestrator MCP toolkit", () => { target: { providerInstanceId: codexInstanceId, model: codexModel, - // Shorthand record form; decodes to the canonical array. options: { reasoning: "low" }, }, mode: "async", @@ -2033,7 +1970,6 @@ describe("orchestrator MCP toolkit", () => { cancellableCall.structuredContent, ).pipe(Effect.orDie); expect(cancellable.status).toBe("running"); - // Original delegated run alone is not "later" work. expect(cancellable.hasPendingChildRuns).toBe(false); yield* waitForProjection(orchestrator, cancellable.childThreadId, (projection) => projection.providerTurns.some((turn) => turn.status === "running"), @@ -2051,7 +1987,6 @@ describe("orchestrator MCP toolkit", () => { latestTerminalRunId: null, latestTerminalStatus: null, }); - // The requested model options reach the child thread's selection. const optionedChild = yield* orchestrator.getThreadProjection( cancellable.childThreadId, ); @@ -2080,9 +2015,6 @@ describe("orchestrator MCP toolkit", () => { ).pipe(Effect.orDie); expect(cancelledStatus.status).toBe("interrupted"); - // Explicit task_cancel disposes automatic delivery after the child - // interrupt succeeds. The interrupted result remains readable, - // but it cannot create a parent continuation. expect( (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find( (task) => task.id === cancellable.taskId, @@ -2619,7 +2551,6 @@ describe("orchestrator MCP toolkit", () => { branch: null, worktreePath: cwd, }); - // Targets reach the whole environment; the caller's modes still cap writes. const foreignOrganizeCall = yield* invoke("t3_thread_organize", { threadId: foreignThreadId, action: "pin", @@ -2680,9 +2611,6 @@ describe("orchestrator MCP toolkit", () => { listed.threads.some((thread) => thread.relationshipToParent === "subagent"), ).toBe(false); - // A wait-mode delegation whose blocking wait times out no longer - // owns delivery, so delegate_task upgrades the task to "always". - // Its terminal then wakes the parent even mid-turn. const upgradedCall = yield* invoke("delegate_task", { task: cancellationPrompt, target: { @@ -2723,7 +2651,6 @@ describe("orchestrator MCP toolkit", () => { ).toMatchObject({ state: "disposed" }); yield* expectOffersToStay(0); - // A live question ends the wait and leaves completion delivery armed. const questionWait = yield* invoke("delegate_task", { task: cancellationPrompt, title: "Wait question", @@ -2783,12 +2710,6 @@ describe("orchestrator MCP toolkit", () => { p.subagents.some((task) => task.id === question.id && task.status === "interrupted"), ); - // The MCP tool cannot force the reverse interleaving (child - // terminal before the upgrade lands), so dispatch the command - // directly. The first wait-mode delegation completed while the - // parent run was live, so finalize skipped its offer under - // settled_only; the upgrade must accept, persist the policy, and - // deliver the wake finalize declined. const terminalUpgrade = yield* orchestrator.dispatch({ type: "delegated_task.wake-policy", commandId: CommandId.make("command:mcp-parent:wake-policy-terminal"), @@ -2809,13 +2730,8 @@ describe("orchestrator MCP toolkit", () => { (task) => task.id === delegated.taskId, )?.completionWake, ).toBe("always"); - // task_status above acknowledged this terminal result, so making - // its policy eager later cannot re-arm a stale parent wake. yield* expectOffersToStay(0); - // Legacy records omit completionWake and stay settled_only. The - // MCP service always sets the field now, so dispatch the request - // directly to cover the legacy shape. const legacyDispatch = yield* orchestrator.dispatch({ type: "delegated_task.request", createdBy: "agent", @@ -2846,12 +2762,8 @@ describe("orchestrator MCP toolkit", () => { yield* waitForProjection(orchestrator, legacyChildThreadId, (projection) => projection.providerTurns.some((turn) => turn.status === "running"), ); - // Nothing terminalized here, so the offer count must hold. yield* expectOffersToStay(0); - // Stop cancels a queued server-owned delivery for this cohort and - // records a barrier before any still-running child reaches a - // terminal state. const stopQueuedDispatch = yield* orchestrator.dispatch({ type: "delegated_task.request", createdBy: "agent", @@ -2987,9 +2899,6 @@ describe("orchestrator MCP toolkit", () => { ).toMatchObject({ state: "disposed" }); yield* expectOffersToStay(0); - // Same command against a terminal task whose finalize already - // offered (the parent was settled then, and still is): the policy - // must persist without a second offer, or the parent wakes twice. const settledUpgrade = yield* orchestrator.dispatch({ type: "delegated_task.wake-policy", commandId: CommandId.make("command:mcp-parent:wake-policy-settled"), @@ -3012,9 +2921,6 @@ describe("orchestrator MCP toolkit", () => { ).toBe("always"); yield* expectOffersToStay(0); - // A child that terminalizes after the coalesced delivery started - // is held for one successor. It must not fan out into a second - // concurrent delivery for the same parent run. const lateParentThreadId = ThreadId.make("thread:mcp-late-completion-parent"); const lateParentGate = yield* Deferred.make(); const lateDeliveryGate = yield* Deferred.make(); @@ -3247,9 +3153,6 @@ describe("orchestrator MCP toolkit", () => { return yield* Effect.die(new Error("Late completion successor delivery missing.")); } - // Results that land while the successor runs wait for it to settle, - // then go out together in one more delivery. Nothing is left - // pending once the parent has been told about every child. const successorGate = yield* Deferred.make(); deliveryTerminalGates.set(lateParentThreadId, successorGate); yield* orchestrator.dispatch({ @@ -3338,7 +3241,6 @@ describe("orchestrator MCP toolkit", () => { ?.state === "pending", ), ); - // The running successor still owns the cohort's only reservation. expect( pendingDuringSuccessor.runs.find((run) => run.id === lateParentRun.id) ?.delegatedCompletion?.delivery, @@ -3378,7 +3280,6 @@ describe("orchestrator MCP toolkit", () => { ?.state, ), ).toEqual(["delivered", "claimed", "claimed"]); - // One offer per delivery: first, successor, and this batch. yield* waitForContinuationOffers(3); yield* expectOffersToStay(3); expect( @@ -3429,9 +3330,6 @@ describe("orchestrator MCP toolkit", () => { ).toEqual(["delivered", "delivered", "delivered", "delivered"]); yield* expectOffersToStay(3); - // Queue Remove is a durable disposal action, not a local queue - // edit. Start a fresh parent-run cohort so removing this delivery - // cannot interfere with the bounded late-delivery assertions. const removeParentGate = yield* Deferred.make(); parentTerminalGates.set(lateParentThreadId, removeParentGate); const removeParentMessageId = MessageId.make("message:mcp-late-parent:remove-start"); @@ -3561,11 +3459,6 @@ describe("orchestrator MCP toolkit", () => { ).toMatchObject({ result: expect.any(String), status: "completed" }); yield* expectOffersToStay(0); - // A wide fan-out keeps waking its parent until every result is - // delivered. Two children finish before the first delivery starts, - // five while it runs, three before the successor starts, and two - // while the successor runs. Three batched deliveries cover all - // twelve, and the cohort never has two deliveries outstanding. const fanoutParentThreadId = ThreadId.make("thread:mcp-fanout-parent"); const fanoutParentGate = yield* Deferred.make(); const firstFanoutDeliveryGate = yield* Deferred.make(); @@ -3671,8 +3564,6 @@ describe("orchestrator MCP toolkit", () => { projection.messages.find((message) => message.id === run.userMessageId) ?.delegatedCompletion?.parentRunId === fanoutRun.id, ); - // The cohort holds one reservation, and at most one of its - // delivery runs is queued or running at a time. const expectAtMostOneOutstandingDelivery = ( projection: OrchestrationV2ThreadProjection, ) => @@ -3777,7 +3668,6 @@ describe("orchestrator MCP toolkit", () => { "delivered", ]); - // Results that land before the successor starts join it. yield* finishFanoutChildren(beforeSecondStarts); const secondJoined = yield* waitForProjection( orchestrator, @@ -3811,7 +3701,6 @@ describe("orchestrator MCP toolkit", () => { ); expectAtMostOneOutstandingDelivery(pendingDuringSecond); - // Later deliveries complete as soon as they start. deliveryTerminalGates.delete(fanoutParentThreadId); yield* Deferred.succeed(secondFanoutDeliveryGate, undefined); const thirdReserved = yield* waitForProjection( @@ -4052,8 +3941,6 @@ describe("orchestrator MCP toolkit", () => { delegated.resultContextTransferId, ); - // Delegated children are subagent threads too, but T3 owns them, so - // they keep taking follow-ups (provider-native children do not). const delegatedChild = yield* orchestrator.getThreadProjection(delegated.childThreadId); expect(delegatedChild.thread.lineage.relationshipToParent).toBe("subagent"); expect(isProviderNativeSubagentThread(delegatedChild.thread)).toBe(false); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 72d5d4b044d3..aab23b3c49ea 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -537,7 +537,6 @@ it.effect.each([ .invoke({ scope, operation: "open", input: {} }) .pipe(Effect.flip); expect(error._tag).toBe("PreviewAutomationExecutionError"); - // A desktop or other remote host's text stays out of the agent's context. expect(error.message.includes("ERR_CONNECTION_REFUSED")).toBe(shown); }), ), @@ -1382,7 +1381,6 @@ it.effect("rejects a routed action when its generation is evicted before deliver .pipe(Effect.flip, Effect.forkScoped); yield* Deferred.await(received); - // Suspend only this invocation in the gap between route selection and delivery. const tasks: Array<() => void> = []; let paused = false; const dispatcher: Scheduler.SchedulerDispatcher = { @@ -1437,7 +1435,6 @@ it.effect("keeps a host whose operation timeout arrives just after the budget", Effect.gen(function* () { if (request.operation === "waitFor") { yield* Deferred.succeed(received, undefined); - // The browser starts its own timer after delivery, so its timeout lands late. yield* Effect.sleep(request.timeoutMs + 50); } yield* broker.respond({ @@ -1472,7 +1469,6 @@ it.effect("keeps the host connected when a background status read times out", () Effect.gen(function* () { const broker = yield* makeBroker; const requests = requestsFrom(yield* broker.connect(makeHost())); - // A busy host answers its actions but not the metadata read behind them. yield* Stream.runForEach(requests, (request) => request.operation === "status" ? Effect.void diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index c3d17ffad2fc..1a68cf6f0544 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -44,7 +44,6 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; const HOST_RESPONSE_GRACE_MS = 1_000; export interface PreviewAutomationInvokeInput { - /** Preview tabs belong to a thread, so only thread callers reach the broker. */ readonly scope: McpInvocationContext.McpThreadInvocationScope; readonly operation: PreviewAutomationOperation; readonly input: unknown; @@ -52,16 +51,10 @@ export interface PreviewAutomationInvokeInput { readonly timeoutMs?: number; /** Background metadata reads must not change the agent's current tab. */ readonly updateCurrentTab?: boolean; - /** Capture the routed tab before another request changes the current assignment. */ readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void; } export interface PreviewAutomationConnectOptions { - /** - * New agent work goes to a preferred host before any desktop. The server's - * own headless browser registers this way so a standalone environment keeps - * browsing when every desktop disconnects. - */ readonly preferred?: boolean; } @@ -101,12 +94,9 @@ interface PendingRequest { } /** - * A lease pinning one provider session to one desktop runtime. It lives exactly - * as long as the connection it names: `connectionId`/`queue` identity is what - * makes a lease valid, so a disconnected or replaced host is dropped on the next - * lookup. The lease deliberately has no clock of its own β€” it used to inherit - * the MCP credential's expiry, which coupled host stickiness to an unrelated - * auth deadline and could migrate a live session to another runtime mid-flow. + * It lives exactly as long as the connection it names: `connectionId`/`queue` + * identity is what makes a lease valid, so a disconnected or replaced host is + * dropped on the next lookup. */ interface HostAssignment { readonly clientId: ClientConnection["clientId"]; @@ -210,7 +200,6 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind { } } -/** Enough for a browser's own error line, such as a refused connection and its URL. */ const MAX_REASON_CHARS = 500; const classifyResponseError = ( @@ -340,7 +329,6 @@ const classifyResponseError = ( return new PreviewAutomationExecutionError({ ...context, ...remoteDiagnostics, - // The server's own browser writes these; other hosts' text stays out of the agent's context. ...(context.clientId === SERVER_BROWSER_AUTOMATION_CLIENT_ID ? { reason: error.message.slice(0, MAX_REASON_CHARS) } : {}), @@ -386,7 +374,6 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { completeStream = false, ) { yield* SynchronizedRef.modifyEffect(state, (current) => { - // Retired generations were already closed by their replacement or eviction. if (current.clients.get(clientId)?.queue !== queue) { return Effect.succeed([undefined, current] as const); } @@ -524,11 +511,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined; const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId; // Keep one provider session on one physical desktop runtime so a - // multi-step browser interaction cannot jump between independent - // Electron cookie/DOM state. A live assignment that predates an - // operation is not silently moved to a newer client: the caller gets a - // capability failure and can deliberately start a fresh provider - // session. A dead lease is pruned above and may fail over. + // multi-step browser interaction cannot jump between independent Electron + // cookie/DOM state. const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => host.liveTabs.some( (tab) => @@ -661,10 +645,10 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { return yield* Option.match(result, { onNone: () => Effect.gen(function* () { - // An unanswered request invalidates this connection. Do not replay - // actions: the client may have applied them before becoming unreachable. - // A background metadata read has a short budget and changes nothing, - // so a slow one must not cut the host off from the agent's next call. + // Do not replay actions: the client may have applied them before + // becoming unreachable. A background metadata read has a short budget + // and changes nothing, so a slow one must not cut the host off from the + // agent's next call. if (input.updateCurrentTab !== false) { yield* disconnect(connection.clientId, connection.queue, true); } diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 9f58cb799150..9c36a26045dc 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -51,8 +51,8 @@ const instanceId = ProviderInstanceId.make("claudeAgent"); const modelSelection = { instanceId, model: "claude-test" }; // A delegated Claude child ends its turn while a background native subagent it -// started keeps running (#17154). Cancelling the task stops the child, which -// ends that subagent's process, so the task must end instead of waiting on it. +// started keeps running (#17154). + it.effect("task_cancel ends a delegated child's background native subagent", () => Effect.scoped( Effect.gen(function* () { @@ -233,7 +233,6 @@ it.effect("task_cancel ends a delegated child's background native subagent", () const childTurn = (yield* orchestrator.getThreadProjection(task.childThreadId!)) .providerTurns[0]!; - // The child starts a background native subagent, then ends its turn. const now = yield* DateTime.now; const subagentId = NodeId.make("node:native-subagent"); const native = { @@ -364,7 +363,7 @@ it.effect("task_cancel ends a delegated child's background native subagent", () childAfter.turnItems.find((item) => item.type === "subagent")?.status, "interrupted", ); - // The child's own turn had completed, so the task keeps that result. + const after = yield* service.taskStatus(scope, task.id); assert.equal(after.status, "completed"); assert.notEqual(after.workState, "waiting_for_children"); diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index a2c259a5e801..513471134e4d 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -289,9 +289,7 @@ const makeHarness = (options: HarnessOptions = {}) => { // Optional deterministic Path semantics: providing this BEFORE the general // mocks means the service resolves Path here rather than from NodeServices, - // so absolute-path validation is testable independently of the host OS. The - // service only calls isAbsolute; the minimal per-platform semantics are - // inlined so the test does not depend on the host's path module. + // so absolute-path validation is testable independently of the host OS. const win32IsAbsolute = (value: string) => /^(?:[a-zA-Z]:[\\/]|[\\/])/.test(value); const posixIsAbsolute = (value: string) => value.startsWith("/"); const layerService = @@ -458,7 +456,6 @@ describe("t3_worktree_handoff", () => { creationSource: "mcp", }), ); - // The continuation must be durably queued before anything slower runs. expect(harness.sendToThread.mock.invocationCallOrder[0]).toBeLessThan( harness.runForThread.mock.invocationCallOrder[0]!, ); @@ -531,8 +528,6 @@ describe("t3_worktree_handoff", () => { baseRefName: "dev", path: "/custom/worktree/location", }); - // localStatus is always consulted now (repo pre-check), but its branch - // must not override the explicit baseRef. expect(harness.localStatus).toHaveBeenCalled(); expect(harness.runForThread).not.toHaveBeenCalled(); expect(result.worktreePath).toBe("/custom/worktree/location"); @@ -649,8 +644,6 @@ describe("t3_worktree_handoff", () => { it.effect("does not trip the pre-flight on similarly named branches", () => { const harness = makeHarness({ existingBranchWorktreePath: null }); return Effect.gen(function* () { - // "feature/taken" exists in the mock branch list; "feature/take" does - // not, and a substring-based check would wrongly match it. const result = yield* runHandoff(harness, { branch: "feature/take" }); expect(result.branch).toBe("feature/take"); expect(harness.createWorktree).toHaveBeenCalledTimes(1); @@ -675,8 +668,6 @@ describe("t3_worktree_handoff", () => { if (!Exit.isFailure(exit)) { return expect.fail("expected a failure exit"); } - // Whether the binding committed is unknown on interruption, so the - // worktree must not be force-deleted and no typed failure is invented. expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true); expect(harness.removeWorktree).not.toHaveBeenCalled(); }); @@ -733,7 +724,6 @@ describe("t3_worktree_handoff", () => { const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/raced" })); expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" }); expect(harness.createWorktree).toHaveBeenCalledTimes(1); - // The freshly created worktree must not be left orphaned. expect(harness.removeWorktree).toHaveBeenCalledWith({ cwd: workspaceRoot, path: "/worktrees/project/feature/raced", @@ -768,7 +758,6 @@ describe("t3_worktree_handoff", () => { const exit = yield* Effect.exit(runHandoff(harness, { branch: "feature/archived-race" })); expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "invalid_request" }); expect(harness.dispatch).not.toHaveBeenCalled(); - // The created worktree must not be left orphaned. expect(harness.removeWorktree).toHaveBeenCalledWith({ cwd: workspaceRoot, path: "/worktrees/project/feature/archived-race", @@ -791,9 +780,6 @@ describe("t3_worktree_handoff", () => { const gate = yield* Deferred.make(); const harness = makeHarness({ dispatchGate: Deferred.await(gate) }); - // Interrupt arrives while the metadata dispatch is in flight; the - // binding-plus-continuation section must run to completion anyway so the - // continuation is never lost between the commit and the queue. const fiber = yield* Effect.forkChild( runHandoff(harness, { branch: "feature/interrupted", @@ -809,8 +795,6 @@ describe("t3_worktree_handoff", () => { expect(harness.dispatch).toHaveBeenCalledTimes(1); expect(harness.sendToThread).toHaveBeenCalledTimes(1); - // The setup script must also survive the pending interrupt; otherwise - // the continuation run starts in a worktree that was never set up. expect(harness.runForThread).toHaveBeenCalledTimes(1); }), ); @@ -858,8 +842,6 @@ describe("t3_worktree_handoff", () => { it.effect("rejects a Windows drive path under posix path semantics", () => { const harness = makeHarness({ pathSemantics: "posix" }); return Effect.gen(function* () { - // The schema-level pattern admits drive paths cross-platform; the - // runtime check reflects the host the worktree would be created on. const exit = yield* Effect.exit( runHandoff(harness, { branch: "feature/windows-path", path: "C:\\worktrees\\custom" }), ); @@ -879,7 +861,6 @@ describe("t3_worktree_handoff", () => { service.handoff(harness.scope, { branch: "feature/guard-1" }), ); expectTypedFailure(first, { _tag: "WorktreeMcpFailure", code: "already_in_worktree" }); - // A leaked guard would surface as handoff_in_progress here. const second = yield* Effect.exit( service.handoff(harness.scope, { branch: "feature/guard-2" }), ); @@ -902,14 +883,11 @@ describe("t3_worktree_handoff", () => { const service = yield* resolveService(harness); - // First handoff acquires the per-thread guard and blocks on the gate. const first = yield* Effect.forkChild( Effect.exit(service.handoff(harness.scope, { branch: "feature/race-1" })), ); yield* Effect.yieldNow; - // Second handoff for the same thread must be refused while the first - // is still in flight. const second = yield* Effect.exit( service.handoff(harness.scope, { branch: "feature/race-2" }), ); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 6c471249c323..15cf334d6fad 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -70,7 +70,6 @@ const make = Effect.gen(function* () { // one untracked on disk. const handoffThreadsInFlight = new Set(); - // Worktree tools act on the calling thread's own checkout binding. const requireThreadScope = (scope: McpInvocationScope) => scope.thread === undefined ? Effect.fail( @@ -156,8 +155,6 @@ const make = Effect.gen(function* () { if (projection.thread.worktreePath !== null) { return yield* alreadyInWorktree(projection.thread.worktreePath); } - // An archived thread would accept the binding but refuse the continuation - // message (and any other follow-up), so reject the handoff outright. if (projection.thread.archivedAt !== null) { return yield* failure( "invalid_request", @@ -175,9 +172,6 @@ const make = Effect.gen(function* () { ); } - // The repo check runs regardless of whether baseRef was supplied, so a - // non-repository workspace fails with an actionable error instead of an - // opaque git failure further down. const localStatus = yield* gitWorkflow .localStatus({ cwd: projectCwd }) .pipe(asOperationFailed("Unable to read git status")); @@ -188,12 +182,6 @@ const make = Effect.gen(function* () { ); } - // Fail fast with an actionable message when the branch already exists: - // the git driver deliberately keeps stderr out of its errors, so letting - // `git worktree add` fail would surface only an opaque failure. The - // existence check uses the complete local branch list (exact match); the - // paginated substring search only enriches the message with the checkout - // location when available. const localBranchNames = yield* gitWorkflow .listLocalBranchNames(projectCwd) .pipe(asOperationFailed("Unable to list branches")); @@ -270,8 +258,6 @@ const make = Effect.gen(function* () { ); const worktreePath = worktree.worktree.path; - // Shared shape for "the handoff already succeeded, so report the failure - // in the result instead of failing the call" (continuation, setup script). const reportFailed = (logMessage: string) => Effect.catchCause((cause: Cause.Cause) => { const detail = errorMessage(Cause.squash(cause)); @@ -282,9 +268,6 @@ const make = Effect.gen(function* () { }).pipe(Effect.as({ status: "failed", detail } as const)); }); - // suspend: build the rollback only if cleanup actually runs. Removing - // the worktree must succeed before deleting its freshly created branch; - // otherwise the branch may still be checked out there. const removeCreatedWorktree = Effect.suspend(() => gitWorkflow.removeWorktree({ cwd: projectCwd, path: worktreePath, force: true }).pipe( Effect.andThen( @@ -308,9 +291,6 @@ const make = Effect.gen(function* () { if (recheck.thread.worktreePath !== null) { return yield* alreadyInWorktree(recheck.thread.worktreePath); } - // Mirror the up-front archived check: the thread may have been - // archived during the slow git work, and an archived thread must - // not be bound to a fresh worktree it can never use. if (recheck.thread.archivedAt !== null) { return yield* failure( "invalid_request", @@ -328,10 +308,6 @@ const make = Effect.gen(function* () { }) .pipe( Effect.catchCause((cause) => - // Interrupt-only causes propagate unchanged: whether the - // dispatch committed is unknown, so neither a typed failure - // nor a rollback would be correct. Failures and defects - // (including mixed causes) map to a typed operation_failed. Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause as Cause.Cause) : Effect.fail( @@ -343,12 +319,9 @@ const make = Effect.gen(function* () { ), ); }).pipe( - // onError: the worktree was already created, so any failure between - // here and the committed binding (recheck read, recheck race, - // dispatch typed failure or defect) must remove it again so a failed - // handoff leaves nothing behind on disk. Interrupt-only causes skip - // the removal: the binding may have committed, and force-deleting a - // worktree the thread now points at would be worse than leaking one. + // Interrupt-only causes skip the removal: the binding may have + // committed, and force-deleting a worktree the thread now points at + // would be worse than leaking one. Effect.onError((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void : removeCreatedWorktree, ), @@ -357,11 +330,7 @@ const make = Effect.gen(function* () { // Queue the continuation right after the binding commits: the detach // that the metadata update schedules will terminate the calling // session, and a durably queued message is what guarantees the thread - // re-launches inside the worktree. When the dying run reaches a - // terminal state the orchestrator promotes the queued run, which - // derives its cwd from the updated projection. - // suspend: build the send effect only when the binding has succeeded, - // so a failed dispatch never even constructs the continuation call. + // re-launches inside the worktree. const queueContinuation: Effect.Effect = Effect.suspend(() => input.continuationPrompt === undefined @@ -383,8 +352,6 @@ const make = Effect.gen(function* () { status: "scheduled", delivery: sendResult.delivery, })), - // catchCause via reportFailed: the binding is already recorded, - // so a failed continuation must be reported, not fail the handoff. reportFailed("worktree handoff continuation failed to queue"), ), ); @@ -419,8 +386,6 @@ const make = Effect.gen(function* () { } : { status: "no-script" }, ), - // catchCause via reportFailed: the thread is already re-pointed at the - // worktree, so even a defect in the setup runner must not fail the handoff. reportFailed("worktree handoff setup script failed"), ); } diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index ca9d8d8a3209..5debcc331c55 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -22,7 +22,6 @@ export const unavailable = () => message: "The operation could not be completed.", }); -/** Decider string rejections are public; wrapped storage and hydration causes are not. */ export const dispatchFailure = ( error: OrchestratorV2Error | ThreadManagement.ThreadManagementProviderInstanceDisabledError, ) => @@ -38,11 +37,6 @@ export const dispatchFailure = ( }) : unavailable(); -/** - * The most a caller may hand to the threads it targets. A thread caller is - * capped by its own thread's modes; an OAuth client by the ceiling chosen - * when it was approved. - */ export interface CallerLimits { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; @@ -51,7 +45,6 @@ export interface CallerLimits { export interface Caller { readonly scope: McpInvocationContext.McpInvocationScope; readonly threads: ThreadManagement.ThreadManagementService["Service"]; - /** The calling thread, absent for a client signed in from outside T3. */ readonly caller: OrchestrationV2ThreadShell | undefined; readonly limits: CallerLimits; } @@ -67,10 +60,6 @@ export const readCaller = Effect.fn("mcp.readCaller")(function* () { return yield* loadCaller(); }); -/** - * The caller and its limits, whichever tools its credential grants. Tools - * check their own capability; `McpToolAccess` uses this for every tool. - */ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; const threads = yield* ThreadManagement.ThreadManagementService; @@ -102,10 +91,6 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { } satisfies Caller; }); -/** - * A caller may change another thread only if that thread runs within the - * caller's own modes. Its own thread is always within them. - */ export const assertTargetWithinLimits = ( limits: CallerLimits, target: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode }, @@ -136,17 +121,11 @@ export function assertLiveCaller({ caller, scope }: Caller) { : Effect.void; } -/** - * Actions that change the environment itself (projects, preferences) need full - * access: a thread caller in full-access/default mode, or a client approved - * with a full-access ceiling. - */ export const assertFullAccess = (context: Caller, message: string) => context.limits.runtimeMode === "full-access" && context.limits.interactionMode === "default" ? Effect.void : Effect.fail(new OrchestratorMcpFailure({ code: "capability_denied", message })); -/** A target project: the one passed, else the calling thread's. */ export const resolveProjectId = (context: Caller, projectId: ProjectId | undefined) => projectId !== undefined ? Effect.succeed(projectId) @@ -159,7 +138,6 @@ export const resolveProjectId = (context: Caller, projectId: ProjectId | undefin }), ); -/** A target thread: the one passed, else the calling thread. */ const resolveThreadId = (context: Caller, threadId: ThreadId | undefined) => threadId !== undefined ? Effect.succeed(threadId) @@ -172,7 +150,6 @@ const resolveThreadId = (context: Caller, threadId: ThreadId | undefined) => }), ); -/** Load a target thread anywhere in the environment; an omitted id means the calling thread. */ export const readThread = Effect.fn("mcp.readThread")(function* < K extends ProjectionRecordField = never, >(threadId?: ThreadId, fields: ReadonlyArray = []) { diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index 2236d8f305de..e1a6428eba8d 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -35,12 +35,11 @@ export function resolveAttachmentReferences( } /** - * As long as the pending file can live. The sweep counts its 24 hours from when - * the upload finishes, which can be up to the upload URL's lifetime after issue. + * The sweep counts its 24 hours from when the upload finishes, which can be up + * to the upload URL's lifetime after issue. */ const UPLOAD_OWNER_TTL_MS = 24 * 60 * 60 * 1000 + ATTACHMENT_UPLOAD_URL_TTL_MS; -/** A thread owns its uploads across provider sessions; an outside client per MCP session. */ const uploadOwner = McpInvocationContext.McpInvocationContext.pipe( Effect.map((scope) => scope.thread?.threadId ?? scope.requestNamespace), ); @@ -48,7 +47,6 @@ const uploadOwner = McpInvocationContext.McpInvocationContext.pipe( export const layer = McpToolAccess.toLayer( AttachmentToolkit, Effect.sync(() => { - // Which caller prepared each pending upload, so only that caller can discard it. const uploadOwners = new Map(); return { t3_attachment_prepare_upload: McpToolAccess.writes((input) => diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 92a179c1138d..45ea89082f42 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -84,7 +84,6 @@ import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult); -// Registration asks for every service the thread tools declare; these cases call none that use them. const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), @@ -110,9 +109,7 @@ it("publishes unique tool names with reference-free object-root inputs", () => { names.add(tool.name); const schema = Tool.getJsonSchema(tool); expect(schema).toMatchObject({ type: "object" }); - // The published tool catalog must also work with providers without $ref support. expect(JSON.stringify(schema), tool.name).not.toContain('"$ref"'); - // Every published tool must have labels for its lifecycle, branding, and a summary. const definition = resolveT3McpToolDefinition(tool.name); expect(definition, tool.name).not.toBeNull(); expect( @@ -296,11 +293,9 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both Layer.provide(PreviewBrowser.layer), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })), Layer.provide(NodeServices.layer), - // The preview browser is not installed in a fresh home, so nothing downloads. Layer.provide(FetchHttpClient.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ - // A live run: publishing stores a page, so it needs the caller's active turn. getThreadShell: () => Effect.succeed({ id: threadId, @@ -858,8 +853,6 @@ it.effect("only the caller that prepared a pending upload can discard it", () => const refused = yield* call("t3_attachment_discard", { attachmentId }, otherThread); expect(declaredFailure(refused)).toMatchObject({ code: "invalid_request" }); - // Another prepare a day later keeps it: the file outlives its URL's 24 hours - // by as long as the upload took, up to the URL's own lifetime. yield* TestClock.adjust(24 * 60 * 60 * 1000 + 5 * 60_000); yield* call( "t3_attachment_prepare_upload", @@ -867,7 +860,6 @@ it.effect("only the caller that prepared a pending upload can discard it", () => scope, ); - // A new provider session of the same thread still owns the upload. const discarded = yield* call( "t3_attachment_discard", { attachmentId }, diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 669d90f23ac5..d42efd9168c4 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -19,19 +19,12 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts"; -/** The flags that pin every agent-device command to one device. */ export function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray { return device.platform === "ios" ? ["--platform", "ios", "--udid", device.id] : ["--platform", "android", "--serial", device.id]; } -/** - * Just-in-time guidance returned from `device_open`. This is the one place - * the agent learns how to drive the device, so it lives with the tool result - * rather than in the always-on prompt block; threads that never open a device - * never pay for it. - */ export function agentDeviceQuickStart( device: DeviceSummary, targetArgs = agentDeviceTargetArgs(device), @@ -211,7 +204,6 @@ const handlers = { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; const sessions = yield* devices.sessionsForThread(scope.thread.threadId); - // Only devices this thread opened: another thread's device is not this agent's to watch. const hostId = input.deviceId === undefined ? input.hostId : (input.hostId ?? LOCAL_DEVICE_HOST_ID); const target = sessions.findLast( @@ -253,7 +245,6 @@ const handlers = { ), } satisfies McpToolAccess.Handlers; -/** Width and height from the IHDR chunk; a PNG that lacks one reports 0Γ—0. */ export function pngDimensions(png: Uint8Array): { width: number; height: number } { if (png.length < 24) return { width: 0, height: 0 }; const view = new DataView(png.buffer, png.byteOffset, png.byteLength); diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index da6828e5c712..5771e8233cd7 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -24,16 +24,8 @@ const dependencies = [ DeviceService.DeviceService, ]; -/** What a device tool fails with, including the access gate's refusal. */ const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure]); -/** - * Deliberately a small surface: lifecycle, visibility for the user, and one - * image-returning verb. Driving the device (taps, typing, install, logs) - * happens through the preconfigured `agent-device` CLI, which has the - * semantic snapshot model agents need and stays current with its own - * releases. Wrapping its commands here would only lag behind it. - */ const DeviceListTool = Tool.make("device_list", { description: "List iOS Simulators and Android Emulators on this environment's device hosts, which platforms each host can run, and which devices are already open in this thread's Device panel. Call this before device_open when you do not know a device id.", diff --git a/apps/server/src/mcp/toolkits/environment/handlers.test.ts b/apps/server/src/mcp/toolkits/environment/handlers.test.ts index bf32658886a0..c870a7354bcd 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.test.ts @@ -97,7 +97,6 @@ it.effect("refuses a preferences update when the caller's turn ends while it wai Effect.gen(function* () { const caller = yield* Ref.make(liveThreadShell(threadId)); const updates = yield* Ref.make(0); - // Completes once the declaration's own check has read the caller. const checked = yield* Deferred.make(); const layerDependencies = Layer.mergeAll( ThreadCommandExecutor.layer, @@ -137,7 +136,6 @@ it.effect("refuses a preferences update when the caller's turn ends while it wai const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; const held = yield* Deferred.make(); const release = yield* Deferred.make(); - // A turn-completion command holds the thread's lock. const holder = yield* executor .withLock( threadId, @@ -148,7 +146,6 @@ it.effect("refuses a preferences update when the caller's turn ends while it wai const update = yield* toolkit .handle("t3_environment_preferences_update", { newWorktreesStartFromOrigin: true }) .pipe(Stream.unwrap, Stream.runCollect, Effect.forkChild); - // The update passed its first check and waits for the lock; the turn then ends. yield* Deferred.await(checked); yield* Ref.update(caller, (shell) => ({ ...shell, activeRunId: null })); yield* Deferred.succeed(release, undefined); diff --git a/apps/server/src/mcp/toolkits/environment/handlers.ts b/apps/server/src/mcp/toolkits/environment/handlers.ts index d7b84ca0599c..fb1716885d6e 100644 --- a/apps/server/src/mcp/toolkits/environment/handlers.ts +++ b/apps/server/src/mcp/toolkits/environment/handlers.ts @@ -66,7 +66,6 @@ export const layer = McpToolAccess.toLayer(EnvironmentToolkit, { yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)), ); }); - // A thread caller serializes with its own turn; a client has no thread to lock. return yield* scope.thread === undefined ? update : executor.withLock(scope.thread.threadId, update); diff --git a/apps/server/src/mcp/toolkits/html/handlers.ts b/apps/server/src/mcp/toolkits/html/handlers.ts index 8daa9bd8d229..9ef61c1fcfab 100644 --- a/apps/server/src/mcp/toolkits/html/handlers.ts +++ b/apps/server/src/mcp/toolkits/html/handlers.ts @@ -12,7 +12,6 @@ const INVALID_PAGE_ERRORS = new Set([ "HtmlRenderPageTooLargeError", ]); -// Every HTML render error message is built on the server and tells the agent what to do next. const toFailure = (error: { readonly _tag: string; readonly message: string }) => new OrchestratorMcpFailure({ code: INVALID_PAGE_ERRORS.has(error._tag) ? "invalid_request" : "orchestration_error", @@ -37,7 +36,6 @@ const handlers = { }; }), ), - // The page is stored in the calling thread, so it needs that thread's live run. html_render: McpToolAccess.actsAsCaller((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; diff --git a/apps/server/src/mcp/toolkits/html/tools.ts b/apps/server/src/mcp/toolkits/html/tools.ts index bdd4513ec2ec..993acd9453ee 100644 --- a/apps/server/src/mcp/toolkits/html/tools.ts +++ b/apps/server/src/mcp/toolkits/html/tools.ts @@ -70,7 +70,6 @@ export const HtmlPreviewTool = Tool.make("html_preview", { // Read-only in the MCP sense: it shows a page in the caller's own thread and // touches no workspace, so plan mode and read-only sandboxes can use it. -// Open-world, since the page may load remote resources, as in a preview. const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, { description: `Show a finished HTML page (chart, table, diagram, collage, mockup) inline in this thread, above your final text reply; call it before writing that reply. The reader already sees the page, so the reply should not announce it, say where it is, or restate it: add only what the page doesn't say. Preview with html_preview first. T3 fits the frame to the page's height at each reader's width. A height below the page's contentHeight caps the frame there, and the rest scrolls inside it. ${PAGE_RULES} ${HTML_RENDER_LAYOUT_GUIDE} ${HTML_RENDER_THEME_GUIDE}`, parameters: Schema.Struct({ diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index fa84ce59eebc..7c05b330047c 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -58,7 +58,6 @@ const handlers = { }), ), schedule_task: McpToolAccess.startsThreads( - // A scheduled task runs with the caller's own modes. () => ({}), (input) => Effect.gen(function* () { @@ -109,9 +108,6 @@ const handlers = { return yield* service.listThreads(scope, input); }), ), - // Reading a child's finished result also acknowledges its delivery to the - // reader's own thread. That is bookkeeping on the caller's own subagent, not - // a change to anything it reads, so this stays a read. t3_thread_read: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 36c4d78723c0..e4da1f51ed3e 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -214,7 +214,6 @@ const ThreadReadTool = Tool.make("t3_thread_read", { dependencies, }) .annotate(Tool.Title, "Read a T3 thread") - // Strict so a misspelled paging param fails instead of silently reading from position 0. .annotate(Tool.Strict, true) .annotate(Tool.Readonly, false) .annotate(Tool.Destructive, false) diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 70b20677a502..cf9423ed5a78 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -33,10 +33,7 @@ import * as McpToolAccess from "../../McpToolAccess.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; -/** - * Collapses the `show` alias onto `open` and defaults tab reuse. - * - * Deliberately leaves an unstated `open` unstated. Whether a preview the agent +/** Whether a preview the agent * said nothing about surfaces is the user's `browserAutoShowFloatingPreview` * preference, which is desktop-local and unreadable from here β€” filling in * `true` would silently override it for every `preview_open`. @@ -147,7 +144,6 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( threadId, }); } - // The same completed upload can be returned to overlapping stop requests. const finalId = `${threadSegment}-${uuid}-${extension}`; const currentPath = resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, @@ -176,7 +172,6 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( yield* validateFile(currentPath); yield* fileSystem.rename(currentPath, finalPath); }).pipe( - // Another stop may already have claimed this exact upload for this thread. Effect.catchIf( (cause) => cause._tag !== "PreviewAutomationRecordingTransferError" && @@ -209,7 +204,6 @@ const handlers = { invokeTargeted("setColorScheme", input), ), preview_snapshot: McpToolAccess.readsAsCaller((input) => { - // Output selection and saving are MCP-only; the browser still produces a complete snapshot. const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; return invokeTargeted("snapshot", operationInput); }), diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 6e34a330025e..88a260ed6d3c 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -41,7 +41,6 @@ const dependencies = [ PreviewAutomationBroker.PreviewAutomationBroker, ]; -/** What a browser action fails with, including the access gate's refusal. */ const PreviewToolFailure = Schema.Union([PreviewAutomationError, OrchestratorMcpFailure]); const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) }; @@ -50,15 +49,12 @@ const PreviewActionResult = Schema.Struct(presentationFields).annotate({ description: "The preview action completed successfully.", }); -/** Drives the real browser and can destroy page state. */ const browserTool = (tool: T): T => tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; -/** Same open-world browser access, but the action does not destroy page state. */ const safeBrowserTool = (tool: T): T => tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, false) as T; -/** A safe browser action that only observes, so it is also repeatable. */ const readonlyBrowserTool = (tool: T): T => safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.ts index bdb2bad2ad6e..17c92eddb9eb 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.ts @@ -7,7 +7,6 @@ import { PreviewControlsToolkit } from "./tools.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; const access = Effect.gen(function* () { - // The preview capability already reflects the calling project's access setting. const scope = yield* requireThreadMcpCapability("preview"); return { scope, manager: yield* Preview.PreviewManager }; }); diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 7b045ae6b189..29a830535801 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -285,11 +285,9 @@ it.effect("starts a project from just a title when workspaceRoot is omitted", () }); expect(named).toEqual(["Pinball Stats"]); - // A path still registers that folder, and never makes a named project. yield* handle({ title: "Existing", workspaceRoot: "/work/existing" }); expect(registered).toEqual(["/work/existing"]); - // Fields this mode cannot apply are rejected, not dropped. for (const extra of [ { scripts: [] }, { defaultModelSelection: { instanceId: providerInstanceId, model: "gpt-5" } }, @@ -308,9 +306,6 @@ const clientLaunchHarness = (input: { readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; }) => { const projectId = ProjectId.make("project:client-target"); - // "claudeAgent" is the real default instance id for the Claude driver - // (see `defaultInstanceIdForDriver`), so it resolves as enabled by default - // under the project-scoped provider instance gate the launch path now runs. const modelSelection = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus", @@ -458,8 +453,6 @@ it.effect("a launch binds only an existing checkout that is one of the project's { type: "existing_worktree", worktreePath: repo }, ]); - // A removed worktree stays listed as prunable until `git worktree prune`; - // whatever directory is later made at its path is not one of the project's. yield* fileSystem.remove(worktree, { recursive: true }); yield* fileSystem.makeDirectory(worktree); expect((yield* launchInto(worktree)).at(-1)?.result).toMatchObject({ diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index c346ad62001f..ca47192c98e4 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -25,9 +25,7 @@ function projectFailure(error: Project.ProjectServiceError) { } /** - * An existing checkout a launch may bind: one of the project's own git - * worktrees. Without this check a launch could point an agent at any directory - * on the machine. + * Without this check a launch could point an agent at any directory on the machine. */ const assertProjectWorktree = Effect.fn("mcp.assertProjectWorktree")(function* ( workspaceRoot: string, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index a292ebf05a7e..a50f6594f70b 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -137,11 +137,8 @@ function makeLink( interface HarnessOptions { readonly thread?: PullRequestTestThread | null; readonly project?: OrchestrationProjectShell | null; - /** A rejection the orchestrator reports as the dispatch error's cause. */ readonly reject?: (command: OrchestrationCommand) => string | null; - /** Every project in the environment; defaults to the thread's own. */ readonly projects?: ReadonlyArray; - /** What the guide service answers for a pull request it is asked to write. */ readonly generate?: ( input: PullRequestRef, ) => Effect.Effect< @@ -217,7 +214,6 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( toolkit.handle(name, params).pipe( Stream.unwrap, Stream.runCollect, - // Failure mode is "error", so a delivered result is always the success shape. Effect.map( (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof PullRequestsToolkit.tools)[Name]>, ), @@ -275,7 +271,6 @@ describe("pull request toolkit handlers", () => { const result = yield* harness.call("watch_pull_request", { url: "https://github.com/t3tools/t3code/pull/9", }); - // The harness thread never changes, so the result reports what it still holds. expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false }); expect(yield* Ref.get(harness.commands)).toMatchObject([ { diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 3f790d8b4fa5..97d23c86b060 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -57,7 +57,6 @@ interface ResolvedTarget { readonly url: string; } -/** The project's host and provider supply defaults for a repository-and-number input. */ function projectHostAndProvider(project: OrchestrationProjectShell | undefined): { readonly host: string | null; readonly kind: SourceControlProviderKind | null; @@ -71,12 +70,6 @@ function projectHostAndProvider(project: OrchestrationProjectShell | undefined): }; } -/** - * Turns whichever shape the agent passed into one host-level identity. A URL - * wins outright; otherwise the repository and number are completed with the - * thread's project host, which is where an agent working in that checkout - * almost always opened the pull request. - */ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( input: PullRequestTargetInput, project: OrchestrationProjectShell | undefined, @@ -99,7 +92,6 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( const repository = input.repository.toLowerCase(); const url = changeRequestUrlFor( - // The project's kind only describes its own host; another host gets no URL guess. host === projectHost.host ? projectHost.kind : null, host, repository, @@ -142,7 +134,6 @@ function entryOf( }; } -/** What the tools report from a thread shell; exported so the shape is testable without a layer. */ export function listThreadPullRequests( thread: Pick, ): ListThreadPullRequestsResult { @@ -158,11 +149,6 @@ export function listThreadPullRequests( }; } -/** - * The project a pull request is read through: the caller's own when its checkout is that - * repository, else the first project here whose checkout is. Host reads are scoped to a project, - * so a repository with no checkout in this environment cannot be guided from it. - */ function projectForPullRequest( projects: ReadonlyArray, target: { readonly host: string; readonly repository: string }, @@ -262,10 +248,6 @@ const make = Effect.gen(function* () { ? Effect.failCause(cause as Cause.Cause) : Effect.fail(new Failure({ cause })); - /** - * Starts or stops a watch. One command links an unlinked pull request and watches it, and the - * result reports the state the thread holds afterwards. - */ const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* ( input: PullRequestTargetInput, watching: boolean, @@ -281,8 +263,6 @@ const make = Effect.gen(function* () { return yield* new PullRequestWatchFromSubagentError(); } const before = watchedLink(thread); - // A merged pull request cannot reopen. A closed one can, and its saved state may be stale, - // so the watch starts and its first read ends it if the host still says closed. if (watching && before?.snapshot?.state === "merged") { return yield* new PullRequestNotOpenError({ state: "merged" }); } @@ -309,10 +289,6 @@ const make = Effect.gen(function* () { }; }); - /** - * Resolves each requested pull request and the project it is read through. The caller's own - * project wins where it is a checkout of that repository; with no thread, any project here may. - */ const locateAll = Effect.fn("PullRequestsToolkit.locateAll")(function* ( targets: ReadonlyArray>, ) { @@ -328,7 +304,6 @@ const make = Effect.gen(function* () { .listShells() .pipe(Effect.mapError((cause) => new PullRequestListFailedError({ cause }))); const callerProject = known.find((project) => project.id === callerThread?.projectId); - // Each target resolves on its own, so one bad URL in a batch is that entry's failure only. return yield* Effect.forEach(targets, (input) => resolveTarget(input, callerProject).pipe( Effect.map((target) => ({ @@ -344,7 +319,6 @@ const make = Effect.gen(function* () { ); }); - /** The service reads a pull request through one project's checkout and credentials. */ const guideReference = ( project: OrchestrationProjectShell, target: { readonly host: string; readonly repository: string; readonly number: number }, @@ -355,11 +329,9 @@ const make = Effect.gen(function* () { number: target.number, }); - /** A host or service error, said as its own detail rather than its operation's wrapper. */ const guideFailureDetail = (error: { readonly message: string }) => "detail" in error && typeof error.detail === "string" ? error.detail : error.message; - /** A tool that changes `threadId`, or the caller's own thread when it is omitted. */ const writesThread =

( handle: (params: P) => Effect.Effect, ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); @@ -388,8 +360,6 @@ const make = Effect.gen(function* () { }) .pipe( Effect.as(false), - // The decider rejects a second link of the same PR; for the agent that is - // the outcome it asked for, not an error. Effect.catchCause(dispatchFailure(PullRequestLinkFailedError)), ); @@ -440,7 +410,6 @@ const make = Effect.gen(function* () { write_pull_request_guides: McpToolAccess.writesWithCapability((input) => Effect.gen(function* () { const located = yield* locateAll(input.pullRequests); - // One pull request that cannot be guided does not stop the rest; it reports why. const guides = yield* Effect.forEach( located, (entry) => diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 35b13f3d2dcf..faab682d1b80 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -34,11 +34,6 @@ const guideDependencies = [ const REGISTER_EVERY_PR = "Register every pull request you open for this thread, including each layer of a stack, right after creating it."; -/** - * Either the pull request's URL or its repository and number. Both forms - * resolve to the same host-level identity, so the agent can pass whichever - * the host CLI handed back. - */ export const PullRequestTargetInput = Schema.Struct({ threadId: Schema.optional( ThreadId.annotate({ @@ -347,7 +342,6 @@ const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); -/** A pull request to write or read a guide for, by URL or by repository and number. */ const GuideTarget = Schema.Struct({ url: PullRequestTargetInput.fields.url, repository: PullRequestTargetInput.fields.repository, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index f54065c0824e..e1bf858ca8fb 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -70,7 +70,6 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: { }); return { ...context, request, item }; }); -/** A tool that changes `threadId`, or the caller's own thread when it is omitted. */ const writesThread =

( handle: (params: P) => Effect.Effect, ) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle); @@ -101,8 +100,6 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { Effect.gen(function* () { const { caller } = yield* readCaller(); const { projectId: requested, ...query } = input; - // Like the other project tools, an omitted project means the caller's own; a client - // outside a thread searches every project. const projectId = requested ?? caller?.projectId; const threadSearch = yield* ThreadSearch.ThreadSearch; const result = yield* threadSearch.search(query).pipe(Effect.mapError(unavailable)); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index c0006f7fe171..1598031d50c7 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -139,14 +139,9 @@ it.effect("production mcp layer lists worktree tools over http", () => const toolNames = tools.map((tool) => tool.name); expect(toolNames).toContain("t3_worktree_handoff"); expect(toolNames).toContain("t3_worktree_status"); - // The worktree registration merges alongside the other toolkits rather - // than replacing them. expect(toolNames).toContain("preview_status"); expect(toolNames).toContain("delegate_task"); - // The handoff tool mutates thread state, reaches the network (origin - // fetch), and runs project setup scripts, so its MCP hints must not - // promise a read-only, closed-world, non-destructive tool. const handoff = tools.find((tool) => tool.name === "t3_worktree_handoff"); expect(handoff?.annotations?.readOnlyHint).toBe(false); expect(handoff?.annotations?.destructiveHint).toBe(true); diff --git a/apps/server/src/mcp/toolkits/worktree/tools.ts b/apps/server/src/mcp/toolkits/worktree/tools.ts index fda1fc9fa3c8..85bbfde03ce6 100644 --- a/apps/server/src/mcp/toolkits/worktree/tools.ts +++ b/apps/server/src/mcp/toolkits/worktree/tools.ts @@ -23,7 +23,6 @@ const dependencies = [ WorktreeMcpService.WorktreeMcpService, ]; -/** What handoff and status fail with, including the access gate's refusal. */ const WorktreeToolFailure = Schema.Union([WorktreeMcpFailure, OrchestratorMcpFailure]); const WorktreeHandoffTool = Tool.make("t3_worktree_handoff", { diff --git a/apps/server/src/mcpApps/McpAppModelContext.test.ts b/apps/server/src/mcpApps/McpAppModelContext.test.ts index f5fd7f331456..bcd01e03dfa8 100644 --- a/apps/server/src/mcpApps/McpAppModelContext.test.ts +++ b/apps/server/src/mcpApps/McpAppModelContext.test.ts @@ -16,7 +16,6 @@ const layer = it.layer( const threadId = ThreadId.make("thread-context"); const forkId = ThreadId.make("thread-context-fork"); -/** An app's tool call in the projection, in a run with the given status. */ const insertAppItem = (itemId: string, runId: string, runStatus: string) => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -50,8 +49,7 @@ layer("McpAppModelContext", (it) => { yield* set(threadId, "item-kept", "kept"); yield* set(threadId, "item-rolled-back", "from a rolled-back run"); yield* set(threadId, "item-deleted", "from an item that is gone"); - // A fork showing the source thread's app keeps its own context, even - // after the source rolls that app's run back. + yield* set(forkId, "item-kept", "from the fork"); yield* set(forkId, "item-rolled-back", "inherited before the rollback"); @@ -60,7 +58,6 @@ layer("McpAppModelContext", (it) => { assert.deepEqual(yield* texts(threadId), ["kept"]); assert.deepEqual(yield* texts(forkId), ["from the fork", "inherited before the rollback"]); - // Clearing removes the app's context. yield* set(threadId, "item-kept", ""); assert.deepEqual(yield* texts(threadId), []); }), diff --git a/apps/server/src/mcpApps/McpAppModelContext.ts b/apps/server/src/mcpApps/McpAppModelContext.ts index dcdd5cd786bb..d6894b38f8a3 100644 --- a/apps/server/src/mcpApps/McpAppModelContext.ts +++ b/apps/server/src/mcpApps/McpAppModelContext.ts @@ -6,7 +6,6 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; -/** Largest context one app may keep for the agent, in UTF-8 bytes. */ export const MCP_APP_MODEL_CONTEXT_MAX_BYTES = 16 * 1024; export class McpAppModelContextError extends Schema.TaggedError()( @@ -21,11 +20,6 @@ export interface McpAppModelContextEntry { readonly text: string; } -/** - * What each MCP App told the agent through `ui/update-model-context`. The spec - * replaces an app's context on every update, so there is one entry per app (its - * tool call); the thread's next turn carries the entries to the provider. - */ export class McpAppModelContext extends Context.Service< McpAppModelContext, { @@ -72,8 +66,6 @@ export const layer = Layer.effect( }); const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) { - // Only apps still in the thread's history count: an app whose item is - // gone, or whose run this thread rolled back, stops informing the agent. // The item can live in a thread this one was forked from, so it is // matched by id alone; that thread rolling the run back later leaves the // fork's copy in place. @@ -98,7 +90,6 @@ export const layer = Layer.effect( }), ); -/** No app context: for code paths and tests with no MCP Apps. */ export const layerEmpty = Layer.succeed( McpAppModelContext, McpAppModelContext.of({ set: () => Effect.void, forThread: () => Effect.succeed([]) }), diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts index dfd01ad27daa..e98f824d6088 100644 --- a/apps/server/src/mcpApps/McpAppRequests.test.ts +++ b/apps/server/src/mcpApps/McpAppRequests.test.ts @@ -26,7 +26,6 @@ const providerThreadId = ProviderThreadId.make("provider-thread-app"); const providerSessionId = ProviderSessionId.make("provider-session-app"); const forkId = ThreadId.make("thread-fork"); const unrelatedId = ThreadId.make("thread-unrelated"); -// A chain of forks deeper than any fixed walk limit: deep-70 β†’ … β†’ deep-1 β†’ app. const deepForkId = (depth: number) => ThreadId.make(`thread-deep-${depth}`); const deepParent = (id: ThreadId): ThreadId | null => { const match = /^thread-deep-(\d+)$/.exec(id); @@ -63,7 +62,6 @@ const app = { resourceUri: "ui://weather/dashboard", }; -/** Each app's stored context, keyed by item, as the real store keeps it. */ const storedContext = new Map(); function makeLayer(input: { @@ -88,7 +86,6 @@ function makeLayer(input: { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (id: ThreadId) => Effect.succeed({ - // A fork of the app's thread, and an unrelated thread. thread: { lineage: deepParent(id) !== null @@ -188,13 +185,11 @@ describe("McpAppRequests", () => { Effect.gen(function* () { const requests = yield* McpAppRequests.McpAppRequests; const own = { threadId, itemId, conversationThreadId: threadId }; - // The app's text is stored as sent, whitespace included. yield* requests.updateModelContext({ ...own, content: [{ type: "text", text: " indented value " }], }); assert.equal(storedContext.get(`${threadId}/${itemId}`), " indented value "); - // An update replaces the app's context rather than adding to it. yield* requests.updateModelContext({ ...own, content: [{ type: "text", text: "Filtered to overdue" }], @@ -210,7 +205,6 @@ describe("McpAppRequests", () => { ), "unsupported-content", ); - // The cap is in UTF-8 bytes: 6,000 three-byte characters exceed 16 KiB. assert.equal( yield* reason( requests.updateModelContext({ @@ -248,7 +242,6 @@ describe("McpAppRequests", () => { ), "not-an-app", ); - // However many forks deep, a descendant still counts. yield* requests.updateModelContext({ threadId, itemId, diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts index ee8f6d521324..6e97a726a021 100644 --- a/apps/server/src/mcpApps/McpAppRequests.ts +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -27,11 +27,8 @@ import * as ThreadManagementService from "../orchestration-v2/ThreadManagementSe import * as McpAppModelContext from "./McpAppModelContext.ts"; /** - * Requests an MCP App makes of its own MCP server. The app is resolved from the - * tool call that produced it, so it reaches only that server, and each request - * runs through the thread's live provider session, whose MCP client owns the - * connection and its credentials. A stopped session is reported, never - * started: an app view must not spend a provider turn. + * A stopped session is reported, never started: an app view must not spend a + * provider turn. */ export class McpAppRequests extends Context.Service< McpAppRequests, @@ -71,12 +68,10 @@ const make = Effect.gen(function* () { const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) => new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) }); - /** The app a tool call produced, read from the stored item. */ const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: { readonly threadId: ThreadId; readonly itemId: TurnItemId; }) { - // The stored item, not the wire projection, so the reference is intact. const item = yield* orchestrator .getTurnItem({ threadId: input.threadId, itemId: input.itemId }) .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); @@ -88,7 +83,6 @@ const make = Effect.gen(function* () { return { app, providerThreadId: item.providerThreadId }; }); - /** The app, its provider thread, and the live session's MCP Apps operations. */ const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: { readonly threadId: ThreadId; readonly itemId: TurnItemId; @@ -168,16 +162,10 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); }); - // Context is state for the agent's next turn, so it needs the app but not a - // live session. Text blocks are kept as written and structured content as - // JSON; other content kinds are not declared by the host. - /** Whether `threadId` is `ancestorId` or forked from it, however many forks deep. */ const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* ( threadId: ThreadId, ancestorId: ThreadId, ) { - // Walks fork links until the chain ends; a thread seen twice means a - // corrupt cycle, which proves nothing. const seen = new Set(); let current: ThreadId | null = threadId; while (current !== null && !seen.has(current)) { @@ -211,7 +199,6 @@ const make = Effect.gen(function* () { if (input.structuredContent !== undefined) { texts.push(encodeJson(input.structuredContent)); } - // Kept as sent; blank text clears the app's context in the store. const text = texts.join("\n"); if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) { return yield* fail( diff --git a/apps/server/src/mcpApps/McpAppSnapshot.ts b/apps/server/src/mcpApps/McpAppSnapshot.ts index 12a2a6378401..f47237f5c82c 100644 --- a/apps/server/src/mcpApps/McpAppSnapshot.ts +++ b/apps/server/src/mcpApps/McpAppSnapshot.ts @@ -17,11 +17,6 @@ import { createAttachmentId } from "../attachmentStore.ts"; const encoder = new TextEncoder(); -/** - * The HTML document in a `resources/read` result, with the `_meta.ui` the - * resource declared. Only `text/html;profile=mcp-app` content counts; a blob is - * base64 per MCP. A null document is one too large to store. - */ function readMcpAppDocument( contents: ReadonlyArray, uri: string, @@ -52,12 +47,6 @@ function readMcpAppDocument( return undefined; } -/** - * Stores an app's document as a thread attachment and returns the reference - * clients host it from. Saving a snapshot keeps the app viewable after the - * provider exits; interaction still goes through a live provider session. - * Undefined when the resource is not an MCP App document or is too large. - */ export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (input: { readonly attachmentsDir: string; readonly threadId: ThreadId; diff --git a/apps/server/src/observability/DefectReporter.test.ts b/apps/server/src/observability/DefectReporter.test.ts index 28c81180ba67..d70f5af1d096 100644 --- a/apps/server/src/observability/DefectReporter.test.ts +++ b/apps/server/src/observability/DefectReporter.test.ts @@ -20,7 +20,6 @@ class TestRpcs extends RpcGroup.make( ) {} type TestRpc = RpcGroup.Rpcs; -/** Runs `body` with every error log captured. */ const withErrorLogs = ( body: (logs: Queue.Queue>) => Effect.Effect, ) => @@ -41,7 +40,6 @@ describe("DefectReporter", () => { withErrorLogs((logs) => Effect.gen(function* () { const subscription = yield* Queue.unbounded(); - // The same client/server pairing as RpcTest.makeClient, with ws.ts's options. let client!: Effect.Success< ReturnType> >; @@ -49,7 +47,6 @@ describe("DefectReporter", () => { ...WS_RPC_SERVER_OPTIONS, onFromServer: (response) => client.write(response), }).pipe( - // Provided to the handlers, as ws.ts does. Effect.provide( TestRpcs.toLayer({ subscribe: () => Stream.fromQueue(subscription), @@ -72,11 +69,9 @@ describe("DefectReporter", () => { const boom = yield* Effect.exit(client.client.boom()); assert.isTrue(Exit.hasDies(boom)); - // The server reports before it answers, so the log is already written. const logged = yield* Queue.clear(logs); assert.deepEqual(logged.map(errorMessage), ["handler bug"]); - // The sibling subscription on the same client keeps delivering. yield* Queue.offer(subscription, 2); const next = yield* Queue.take(received).pipe( Effect.raceFirst(Fiber.await(sibling).pipe(Effect.as("subscription ended"))), @@ -94,7 +89,6 @@ describe("DefectReporter", () => { yield* ErrorReporter.report(Cause.interrupt()); yield* ErrorReporter.report(Cause.die(new Error("bug"))); - // Reporters log synchronously, so the logs are already written. assert.deepEqual((yield* Queue.clear(logs)).map(errorMessage), ["bug"]); }).pipe(Effect.provide(DefectReporter.layer)), ), diff --git a/apps/server/src/observability/DefectReporter.ts b/apps/server/src/observability/DefectReporter.ts index 220b2696d831..4e8911c1493d 100644 --- a/apps/server/src/observability/DefectReporter.ts +++ b/apps/server/src/observability/DefectReporter.ts @@ -3,17 +3,14 @@ import * as Effect from "effect/Effect"; import * as ErrorReporter from "effect/ErrorReporter"; /** - * Logs the defects of WebSocket RPC handlers. RpcServer reports every failed - * handler exit; typed failures are expected responses, so only dies are logged. + * RpcServer reports every failed handler exit; typed failures are expected responses, so only + * dies are logged. */ const reporter: ErrorReporter.ErrorReporter = { [ErrorReporter.TypeId]: ErrorReporter.TypeId, report: ({ cause, fiber }) => { for (const reason of cause.reasons) { if (reason._tag !== "Die" || ErrorReporter.isIgnored(reason.defect)) continue; - // Reporters are called synchronously from the failing fiber. Logging with - // its context keeps its loggers and annotations, and a fork cannot throw - // back into the server that reported. Effect.runForkWith(fiber.context)( Effect.logError("Unhandled defect", Cause.fromReasons([reason])), ); diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts index da990f2b62e1..963279d13824 100644 --- a/apps/server/src/observability/EventLoopMonitor.test.ts +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -25,10 +25,8 @@ const stalled: EventLoopMonitor.EventLoopReadings = { }, rssBytes: 1536 * 1024 * 1024, }; -// Over the threshold as read, but not once the resolution is subtracted. const quiet: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(2_950) }; -// The first sample covers startup, so the monitor discards it. const monitor = ( samples: Array, stallLog: EventLoopMonitor.StallLogOptions = { filePath: "\0", maxBytes: 1 }, @@ -52,7 +50,6 @@ describe("EventLoopMonitor", () => { }, }); yield* Effect.gen(function* () { - // The default stall log path cannot open, and the span must still be recorded. yield* monitor([stalled, quiet, stalled]); yield* TestClock.adjust("60 seconds"); assert.lengthOf(spans, 0); @@ -86,7 +83,6 @@ describe("EventLoopMonitor", () => { const fs = yield* FileSystem.FileSystem; const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-event-loop-" }); const filePath = `${directory}/event-loop-stalls.ndjson`; - // Room for one line, so each new stall rotates the last one into the backup. yield* monitor([stalled, stalled, stalled, { ...stalled, delayMaxNs: ms(7_000) }], { filePath, maxBytes: 300, @@ -122,8 +118,6 @@ describe("EventLoopMonitor", () => { }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - // A directory at the log path opens, but every append to it fails. The cap - // is large so the sink never rotates the directory out of the way. const filePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-event-loop-" }); yield* monitor([stalled, stalled], { filePath, maxBytes: 1024 * 1024 }); yield* TestClock.adjust("60 seconds"); diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts index 544b5ab042d1..d713e71940de 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -14,16 +14,13 @@ import * as ServerConfig from "../config.ts"; // Node's delay histogram wakes a native timer every RESOLUTION_MS and records the // gap between wakeups, so an idle loop reads about RESOLUTION_MS and a stall of S // reads between S and S + RESOLUTION_MS. We subtract the resolution, so a delay can -// undercount a stall by up to RESOLUTION_MS. With these values every stall over 3 s -// is caught, at 1 wakeup per second that never enters JS. +// undercount a stall by up to RESOLUTION_MS. const RESOLUTION_MS = 1000; const STALL_THRESHOLD_MS = 2000; const SAMPLE_INTERVAL = "30 seconds"; -// A stall line is about 250 bytes, so the file and its one backup keep the last -// 1000 to 2000 stalls. const STALL_LOG_MAX_BYTES = 256 * 1024; -/** One sample interval as Node reports it. Delay in ns, active time in ms, CPU in Β΅s. */ +/** Delay in ns, active time in ms, CPU in Β΅s. */ export interface EventLoopReadings { readonly delayMaxNs: number; readonly activeMs: number; @@ -39,9 +36,7 @@ export interface EventLoopReadings { readonly rssBytes: number; } -// Enables the delay histogram for the layer's lifetime. Each read returns the -// readings since the previous read and resets the histogram. Node skips the first -// gap after a reset, so a stall right at a sample boundary can be missed. +// Node skips the first gap after a reset, so a stall right at a sample boundary can be missed. const makeNodeSampler = Effect.gen(function* () { const histogram = yield* Effect.acquireRelease( Effect.sync(() => { @@ -80,14 +75,10 @@ const makeNodeSampler = Effect.gen(function* () { }); }); -/** - * Returns the stall to report for one sample in ms, or undefined when there was none. - */ export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; - // A stall is time the loop spent running code, so it counts as active time. libuv's - // clock keeps running while the system sleeps on macOS and Windows, so a sleep also - // reads as delay, but the loop spent it idle in poll. + // libuv's clock keeps running while the system sleeps on macOS and Windows, so a + // sleep also reads as delay, but the loop spent it idle in poll. if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; return delayMs; }; @@ -99,14 +90,6 @@ export interface StallLogOptions { readonly maxBytes: number; } -/** - * Samples event loop health every 30 s and records a `server.eventLoop.stall` span - * with a warning when the loop stalled for more than 2 s, so stalls land in - * the local trace file and Settings > Diagnostics without OTLP. Each stall is - * also appended as one NDJSON line to a small stall log, which keeps stall - * history long after the trace file has rotated. Takes the sampler and log - * options so tests can inject readings and a small cap. - */ export const layerWith = ( makeSampler: Effect.Effect, never, Scope.Scope>, stallLog: StallLogOptions, @@ -114,7 +97,6 @@ export const layerWith = ( Layer.effectDiscard( Effect.gen(function* () { const sample = yield* makeSampler; - // A log that cannot open must not stop the monitor; the span still records stalls. const stallSink = yield* Effect.try( () => new RotatingFileSink({ @@ -145,8 +127,6 @@ export const layerWith = ( involuntaryContextSwitches: usage.involuntaryContextSwitches, rssMb: Math.round(rssBytes / 1024 / 1024), }; - // Root, as the stall has no caller to attach to. Warn level keeps it when - // T3CODE_TRACE_MIN_LEVEL is raised to cut trace noise. yield* Effect.logWarning(`event loop stalled for ${delayMaxMs} ms`).pipe( Effect.withSpan("server.eventLoop.stall", { root: true, level: "Warn", attributes }), ); @@ -166,7 +146,7 @@ export const layerWith = ( // The layer builds before the rest of the server, so the first sample covers // startup work such as migrations and projection bootstrap. That can block the // loop for seconds on a large database, so skip it rather than warn at every - // launch. Layers build outside any span, so this fiber retains no parent span. + // launch. yield* wait.pipe( Effect.andThen(sample), Effect.andThen(wait.pipe(Effect.andThen(tick), Effect.forever)), @@ -175,7 +155,6 @@ export const layerWith = ( }), ); -/** Writes the stall log next to the server trace file, as `event-loop-stalls.ndjson`. */ export const layer = Layer.unwrap( Effect.gen(function* () { const { serverTracePath } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/observability/HeapSnapshot.ts b/apps/server/src/observability/HeapSnapshot.ts index 6359c1628c58..d015dce3c2a5 100644 --- a/apps/server/src/observability/HeapSnapshot.ts +++ b/apps/server/src/observability/HeapSnapshot.ts @@ -10,11 +10,6 @@ import * as Layer from "effect/Layer"; import * as ServerConfig from "../config.ts"; -/** - * Writes one V8 heap snapshot into `logsDir` and logs its path. A failed write - * logs a warning and removes any partial file, because that file can hold - * secrets and the failure is often a full disk. - */ export const writeHeapSnapshot = Effect.fn("server.heapSnapshot", { root: true })( function* (logsDir: string) { const fs = yield* FileSystem.FileSystem; @@ -29,15 +24,6 @@ export const writeHeapSnapshot = Effect.fn("server.heapSnapshot", { root: true } Effect.catch((cause) => Effect.logWarning("Failed to write heap snapshot.", { cause })), ); -/** - * Writes a heap snapshot when the process gets SIGUSR2 (`kill -USR2 `), - * so a maintainer can see what a long-running server holds. See "Heap - * Snapshots" in docs/operations/observability.md. - * - * The write blocks the event loop, so two snapshots never overlap: a signal - * sent during a write waits until it finishes. Windows has no SIGUSR2, so the - * layer does nothing there. - */ export const layer = Layer.effectDiscard( Effect.gen(function* () { if ((yield* HostProcess.Platform) === "win32") return; diff --git a/apps/server/src/observability/Metrics.test.ts b/apps/server/src/observability/Metrics.test.ts index fa2c82a1a6cd..ea1e60012d41 100644 --- a/apps/server/src/observability/Metrics.test.ts +++ b/apps/server/src/observability/Metrics.test.ts @@ -172,7 +172,6 @@ describe("withMetrics", () => { ); yield* Deferred.await(started); yield* TestClock.adjust(Duration.millis(10)); - // A backward wall-clock correction must not shorten the measured duration. yield* TestClock.setTime(0); yield* Deferred.succeed(finish, undefined); yield* Fiber.join(fiber); diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index b530b83184ee..091ff5234d7e 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -55,11 +55,6 @@ export const terminalRestartsTotal = Metric.counter("t3_terminal_restarts_total" description: "Total terminal restart requests handled.", }); -/** - * One per webhook request that reached a task, by `outcome` (accepted, - * not_found, rejected_signature, disabled, rate_limited, queue_full, expired, - * prompt_too_long, error) and `source` (relay or direct). - */ export const webhookDeliveriesTotal = Metric.counter("t3_webhook_deliveries_total", { description: "Webhook requests handled, by outcome and source.", }); @@ -68,23 +63,19 @@ export const webhookDeliveryDuration = Metric.timer("t3_webhook_delivery_duratio description: "Time to verify, log, and enqueue one webhook request.", }); -/** How long a relay-held request waited before this environment got it. */ export const webhookHeldDelay = Metric.timer("t3_webhook_held_delay", { description: "Time between the relay receiving a webhook request and the environment handling it.", }); -/** Runs started by webhook deliveries, by `outcome` (started, skipped, failed). */ export const webhookRunsTotal = Metric.counter("t3_webhook_runs_total", { description: "Runs started from webhook deliveries, by outcome.", }); -/** Secrets agents asked users for, by how each ended: saved, declined, cancelled, timed_out. */ export const secretRequestsTotal = Metric.counter("t3_secret_requests_total", { description: "Secrets agents asked users for, by how each request ended.", }); -/** One-use secret refs a tool tried to use, by result: used, rejected. */ export const secretRefsConsumedTotal = Metric.counter("t3_secret_refs_consumed_total", { description: "Secret refs tools tried to use, by result.", }); diff --git a/apps/server/src/observability/Observability.ts b/apps/server/src/observability/Observability.ts index 80ef1f84b70c..05c382ea3828 100644 --- a/apps/server/src/observability/Observability.ts +++ b/apps/server/src/observability/Observability.ts @@ -90,7 +90,6 @@ export const layer = Layer.unwrap( resource, }).pipe(Layer.provide(SharedObservability.layerOtlpSerialization(metrics.protocol))); - // Logged once the server's loggers are installed, so the warnings use them. const layerOtelWarnings = Layer.effectDiscard( Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)), ); diff --git a/apps/server/src/observability/OtlpEndpointCheck.test.ts b/apps/server/src/observability/OtlpEndpointCheck.test.ts index e7551d722ad4..47d4ad76a6fe 100644 --- a/apps/server/src/observability/OtlpEndpointCheck.test.ts +++ b/apps/server/src/observability/OtlpEndpointCheck.test.ts @@ -28,7 +28,6 @@ interface PostedRequest { readonly signal: AbortSignal; } -/** Answers every post with `status`, or fails like a refused connection when it is null. */ const checkWith = ( status: number | null, overrides: Partial = {}, diff --git a/apps/server/src/observability/OtlpEndpointCheck.ts b/apps/server/src/observability/OtlpEndpointCheck.ts index 919f83ec2e56..6a136a2ff7ab 100644 --- a/apps/server/src/observability/OtlpEndpointCheck.ts +++ b/apps/server/src/observability/OtlpEndpointCheck.ts @@ -19,12 +19,6 @@ const CHECK_TIMEOUT = "5 seconds"; export class OtlpEndpointCheck extends Context.Service< OtlpEndpointCheck, { - /** - * Posts an empty export for the signal to `url` from this server, with the - * running signal's protocol. Credentials are used only for its exact - * running endpoint, and redirects are not followed. Receivers accept an - * empty export without storing anything. - */ readonly check: (input: OtlpEndpointCheckInput) => Effect.Effect; } >()("t3/observability/OtlpEndpointCheck") {} @@ -58,7 +52,6 @@ const make = Effect.gen(function* () { url, }: OtlpEndpointCheckInput) { const { url: runningUrl, export: signalExport } = running[signal]; - // Keep credentials paired with the running endpoint, regardless of source. const headers = url === runningUrl ? signalExport.headers : undefined; const body = yield* Effect.gen(function* () { const serialization = yield* OtlpSerialization.OtlpSerialization; diff --git a/apps/server/src/observability/RpcInstrumentation.test.ts b/apps/server/src/observability/RpcInstrumentation.test.ts index 75dc6138bbdf..84827fa18045 100644 --- a/apps/server/src/observability/RpcInstrumentation.test.ts +++ b/apps/server/src/observability/RpcInstrumentation.test.ts @@ -27,7 +27,6 @@ import { RpcInstrumentation, rpcInstrumentationLayer } from "./RpcInstrumentatio type WsRpcMethod = keyof typeof RPC_REQUIRED_SCOPES; -/** The server group narrowed to `tags`, so a test only implements the handlers it calls. */ const groupOf = >(...tags: Tags) => WsRpcGroup.omit( ...[...WsRpcGroup.requests.keys()].filter( @@ -36,14 +35,12 @@ const groupOf = >(...tags: Tags) = ), ).middleware(RpcInstrumentation); -/** The middleware ws.ts installs for a connection with `scopes`. */ const connectionMiddleware = (scopes: ReadonlyArray) => Layer.merge(RpcAuthorization.layer(scopes), rpcInstrumentationLayer); const readOnlyConnection = connectionMiddleware([AuthOrchestrationReadScope]); const taskId = ScheduledTaskId.make("scheduled-task:instrumented"); const rpcSpanDefaults = { "rpc.transport": "websocket", "rpc.system": "effect-rpc" }; -/** Runs a test with a fresh metric registry and a tracer that keeps every span it ends. */ const withTelemetry = ( test: (ended: ReadonlyArray) => Effect.Effect, ) => { @@ -70,7 +67,7 @@ const rpcSpans = (ended: ReadonlyArray) => ended.filter((span) => span.name.startsWith("ws.rpc.")); // RpcTest keeps Effect's own RpcServer/RpcClient spans, which ws.ts turns off with -// `disableTracing: true`. Everything else comes from the middleware or the handlers. +// `disableTracing: true`. const appSpans = (ended: ReadonlyArray) => ended.filter((span) => span.attributes.get("rpc.system.name") !== "effect_rpc"); @@ -145,7 +142,6 @@ describe("WS RPC instrumentation middleware", () => { assert.deepStrictEqual(yield* client[WS_METHODS.serverProbe]({}), {}); const listError = yield* client[WS_METHODS.scheduledTasksList]({}).pipe(Effect.flip); assert.equal(listError._tag, "ScheduledTaskError"); - // Retrying telemetry needs operate scope, so the handler must not run. const rejection = yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe( Effect.flip, ); @@ -270,8 +266,6 @@ describe("WS RPC instrumentation middleware", () => { ), ); - // Wall and monotonic time disagree before the call starts, so a duration that mixes - // the two clocks is caught. yield* TestClock.adjust(Duration.seconds(1)); yield* TestClock.setTime(0); const consumer = yield* Stream.runDrain( @@ -279,11 +273,9 @@ describe("WS RPC instrumentation middleware", () => { ).pipe(Effect.forkChild); yield* Deferred.await(waiting); yield* TestClock.adjust(Duration.millis(250)); - // A backward wall-clock correction must not shorten the measured duration. yield* TestClock.setTime(0); assert.deepStrictEqual(appSpans(ended), []); - // The client waits for the server to stop the call, which ends the RPC span. yield* Fiber.interrupt(consumer); const [rpcSpan] = rpcSpans(ended); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 6b05224313e8..161694d55cb8 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -10,9 +10,8 @@ import { rpcRequestDuration, rpcRequestsTotal, withMetrics } from "./Metrics.ts" type WsRpcMethod = RpcGroup.Rpcs["_tag"]; /** - * The `rpc.aggregate` span attribute of every WebSocket RPC. Trace queries and dashboards group by - * these labels, so they keep their historical values even where they differ from the method - * prefix. Adding an RPC to `WsRpcGroup` without a label is a type error. + * Trace queries and dashboards group by these labels, so they keep their historical values + * even where they differ from the method prefix. */ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", @@ -225,19 +224,13 @@ const RPC_METHODS_WITH_TRACING_DISABLED: ReadonlySet = new Set([ ]); /** - * Records each WebSocket RPC's span and request metrics. `ws.ts` adds it to the server's group - * after `RpcScopeAuthorization`, so it wraps authorization and also records rejected calls. + * `ws.ts` adds it to the server's group after `RpcScopeAuthorization`, so it wraps + * authorization and also records rejected calls. */ export class RpcInstrumentation extends RpcMiddleware.Service()( "t3/server/RpcInstrumentation", ) {} -/** - * Wraps each WebSocket RPC call in its `ws.rpc.` span and records its request counter and - * duration. For a stream RPC, the middleware receives the whole stream run, so the span and the - * metrics cover the subscription until it ends, fails, or is interrupted. Methods in - * `RPC_METHODS_WITH_TRACING_DISABLED` record metrics but no spans, for the call or anything it runs. - */ export const rpcInstrumentationLayer = Layer.succeed(RpcInstrumentation)((effect, { rpc }) => { const method = rpc._tag; const measured = effect.pipe( diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts index da89b3bc6227..170c3802e5b3 100644 --- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts @@ -42,12 +42,6 @@ import * as ProviderTurnStartServiceTestkit from "./ProviderTurnStartService.tes import * as RuntimeLayer from "./runtimeLayer.ts"; import * as McpSessionRegistryTestkit from "../mcp/McpSessionRegistry.testkit.ts"; -// The Antigravity switch is a durable, named conformance fixture for Google's -// official Registry distribution. It uses credentials already owned by the -// Antigravity agent and never stores them in the test database. -// -// T3_ACP_ANTIGRAVITY_LIVE=1 ../../node_modules/.bin/vp test run \ -// src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts const layerPlatformTest = Layer.merge( NodeServices.layer, Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ @@ -121,7 +115,6 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi Layer.provide(FetchHttpClient.layer), Layer.provide(layerPlatformTest), ), - // The Codex driver now resolves managed ChatGPT installs; these runs never launch Codex. Layer.mock(CodexInstallation.CodexInstallation)({ managedDirectory: "unused-managed-installation", }), diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 3d5540933164..c57d647b5008 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -342,17 +342,13 @@ function cleanupPublishedDetachedFixture(path: string): void { .filter(Boolean) .map(Number), ); - } catch { - // The process already exited. - } + } catch {} } for (const [pid, start] of [...owned.entries()].toReversed()) { if (linuxProcessStart(pid) !== start) continue; try { process.kill(pid, "SIGKILL"); - } catch { - // The exact fixture process already exited. - } + } catch {} } } @@ -552,7 +548,6 @@ function makeTurnInput(input: { readonly now: DateTime.Utc; readonly ordinal?: number; readonly modelSelection?: ModelSelection; - /** agent+provider marks a post-settle continuation attach (drains wakeBuffer). */ readonly messageCreatedBy?: "user" | "agent"; readonly messageCreationSource?: "web" | "mobile" | "mcp" | "provider" | "server"; readonly messageText?: string; @@ -905,7 +900,6 @@ describe("AcpAdapterV2", () => { childProcessSpawner, mockAgentPath, protocolEvents, - // The model switch lands, then the agent rejects the mode step. environment: { T3_ACP_FAIL_SET_MODE_CONFIG_OPTION: "1" }, }), }, @@ -1140,7 +1134,6 @@ describe("AcpAdapterV2", () => { }, ...streamedWrite("root-write"), ...streamedWrite("child-write", "child-a"), - // Same-length output replacements are still live output. ...Array.from({ length: 5 }, (_, index) => ({ sessionUpdate: "tool_call_update" as const, toolCallId: "ticker", @@ -1246,7 +1239,6 @@ describe("AcpAdapterV2", () => { const instanceId = ProviderInstanceId.make("devin-replay"); const adapter = yield* makeAcpAdapterV2({ instanceId, - // Production Devin runs commands through client terminals. clientTerminals: { shellCommands: true }, selfInvocation: yield* resolveSelfInvocation(), flavor: { @@ -1276,8 +1268,6 @@ describe("AcpAdapterV2", () => { { sessionId: "mock-session-1", command: "true acp-mcp-call task_status {}" }, { requestId: "child-mcp-terminal", method: "terminal/create" }, ); - // Production thread 54aeb6d7 split after "(command". Metadata shapes - // below were captured from live Devin sessions showy-mile/fragrant-chamomile. const updates = [ { sessionUpdate: "tool_call_update", @@ -1501,7 +1491,6 @@ describe("AcpAdapterV2", () => { ); assert.deepEqual([...childMessages.values()], ["Checking the code.", "ONE"]); assert.equal(task?.prompt, "Run pwd, then reply ONE."); - // Terminal-fallback MCP calls in a child session keep their T3 identity. assert.isTrue( items.some( (item) => @@ -1635,8 +1624,6 @@ describe("AcpAdapterV2", () => { mode, }, }); - // Shapes captured from Copilot CLI 1.0.95 with two sync and one - // background subagent launched in one batch. yield* chunk("Launching "); yield* launch("task-a", "Describe a.txt", "sync"); yield* launch("task-b", "Describe b.txt", "sync"); @@ -2575,8 +2562,6 @@ describe("AcpAdapterV2", () => { }, ], }); - // Re-reading another binding must not reassign the forked native - // session's credential scope. yield* runtime.ensureThread({ threadId: sourceThreadId, modelSelection, @@ -3209,9 +3194,7 @@ describe("AcpAdapterV2", () => { const failedBackgroundMutationHandler = backgroundMutationHandlers[1]; assert.isDefined(failedReplacementHandler); assert.isDefined(failedBackgroundMutationHandler); - while (Option.isSome(yield* Queue.poll(events))) { - // Discard setup events before exercising the stale callback. - } + while (Option.isSome(yield* Queue.poll(events))) {} yield* failedReplacementHandler!({ sessionId: "failed-replacement-session", update: { @@ -3399,7 +3382,6 @@ describe("AcpAdapterV2", () => { environment: {}, hostPlatform: yield* HostProcess.Platform, selfInvocation: yield* resolveSelfInvocation(), - // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe( Effect.flatMap(makeXAiPromptCompletionRuntime), @@ -3446,7 +3428,6 @@ describe("AcpAdapterV2", () => { environment: {}, hostPlatform: yield* HostProcess.Platform, selfInvocation: yield* resolveSelfInvocation(), - // Production Grok runtimes are wrapped by the x.ai prompt runtime. makeRuntime: (input) => makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe( Effect.flatMap(makeXAiPromptCompletionRuntime), @@ -3469,9 +3450,6 @@ describe("AcpAdapterV2", () => { modelSelection: { instanceId, model: "composer-2" }, runtimePolicy, }); - // The mock session starts on default. Switching away and explicitly - // back must send the model configuration change; stale metadata can make - // the return trip a silent no-op that left the session on the alt model. for (const model of ["default", "composer-2"]) { yield* runtime.startTurn( makeTurnInput({ @@ -3527,9 +3505,6 @@ describe("AcpAdapterV2", () => { interactionMode: "default", cwd: process.cwd(), }); - // A stale composer selection (probe-time union descriptor) must not wedge - // the session open in a retry loop; the option is skipped and the agent - // default applies. const runtime = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"), @@ -5324,7 +5299,6 @@ describe("AcpAdapterV2", () => { providerTurnId, requestRuntimeRestart: true, }); - // Second durable interrupt after activeTurn is cleared must not fail. const second = yield* Effect.exit( runtime.interruptTurn({ providerThread, @@ -5412,7 +5386,6 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - // The still-running subagent defers finalize after session/prompt returns. yield* Deferred.await(promptSettled); const providerTurnId = idAllocator.derive.providerTurn({ @@ -5469,10 +5442,7 @@ describe("AcpAdapterV2", () => { childSessionId: null, result: null, } - : // Hydration-only shape (empty prompt, null title): without a - // carried-over lineage this update is dropped and the item - // stays running forever. - { + : { nativeTaskId: "task-generic-1", prompt: "", title: null, @@ -5679,7 +5649,6 @@ describe("AcpAdapterV2", () => { } } assert.equal(firstTerminalStatus, "interrupted"); - // Root settled with a live projected subagent in carryover: pin idle release. assert.isTrue( yield* hasPendingBackgroundWork, "carryover live subagent must pin hasPendingBackgroundWork after root settle", @@ -5709,7 +5678,6 @@ describe("AcpAdapterV2", () => { } } assert.equal(secondTerminalStatus, "completed"); - // Carryover is consumed into the next turn and terminalized; pin clears. assert.isFalse( yield* hasPendingBackgroundWork, "hasPendingBackgroundWork must clear after carryover subagent terminals", @@ -5866,10 +5834,7 @@ describe("AcpAdapterV2", () => { yield* hasPendingBackgroundWork, "rehydrated live subagent must pin from activeTurn", ); - while (Option.isSome(yield* Queue.poll(events))) { - // Discard setup events so the assertions below cover only child-session - // tool traffic after rehydration. - } + while (Option.isSome(yield* Queue.poll(events))) {} subagentPhase = "complete"; yield* sessionUpdateHandler!({ @@ -5883,8 +5848,6 @@ describe("AcpAdapterV2", () => { rawOutput: { content: "SUB_DONE" }, }, }); - // Duplicate terminal replay is harmless, and an older running frame cannot - // resurrect the completed lineage. yield* sessionUpdateHandler!({ sessionId: childSessionId, update: { @@ -5977,8 +5940,6 @@ describe("AcpAdapterV2", () => { const firstChildSessionId = "mock-child-session-finalize-window-first"; const secondChildSessionId = "mock-child-session-finalize-window-second"; let firstSubagentStatus: "running" | "completed" = "running"; - // Keep the pending-work pin without blocking the deferred-finalize - // timer, so the test can stop inside finalizeTurn deterministically. let secondSubagentStatus: "waiting" | "completed" = "waiting"; type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; let sessionUpdateHandler: @@ -6107,9 +6068,7 @@ describe("AcpAdapterV2", () => { }); assert.isTrue(yield* hasPendingBackgroundWork); - while (Option.isSome(yield* Queue.poll(events))) { - // Discard setup and first-subagent events. - } + while (Option.isSome(yield* Queue.poll(events))) {} blockNextClockRead = true; const adjustFiber = yield* TestClock.adjust("3 seconds").pipe(Effect.forkScoped); @@ -6295,9 +6254,6 @@ describe("AcpAdapterV2", () => { ); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - // The interrupted root's subscription is already closed in execution - // service, so the adapter must retain this terminal without claiming it - // reached the projection. subagentPhase = "complete"; yield* sessionUpdateHandler!({ sessionId: childSessionId, @@ -6398,7 +6354,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, enablePostSettleContinuation: true, - // The spawn tool reports a background subagent that keeps running. extractSubagentUpdate: (toolCall) => toolCall.toolCallId !== "tool-call-generic-1" ? undefined @@ -6472,12 +6427,10 @@ describe("AcpAdapterV2", () => { rootStatus = event.status; } } - // The root completed with the subagent still running: it is carryover. assert.equal(rootStatus, "completed"); assert.isTrue(yield* hasPendingBackgroundWork); assert.isDefined(finishSubagent); const subagentStatuses = Effect.gen(function* () { - // Adapter events reach this queue through the events stream fiber. yield* Effect.yieldNow; yield* Effect.yieldNow; const statuses: Array<[string, string | null]> = []; @@ -6493,7 +6446,6 @@ describe("AcpAdapterV2", () => { }); yield* subagentStatuses; - // A nested subagent reports to its own parent session, not the root. yield* finishSubagent!({ sessionId: "some-other-session", childSessionId, @@ -6655,8 +6607,7 @@ describe("AcpAdapterV2", () => { assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); // A production Grok spawn ACK has raw tool status completed but extracts - // as a running subagent with its original non-empty prompt. It buffers and - // offers a continuation after root settlement. + // as a running subagent with its original non-empty prompt. yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { @@ -6684,8 +6635,6 @@ describe("AcpAdapterV2", () => { }, }); - // The child-session terminal bypasses the root wake buffer and projects - // eagerly through the still-observable completed-root subscriber. subagentPhase = "complete"; yield* sessionUpdateHandler!({ sessionId: childSessionId, @@ -6896,8 +6845,6 @@ describe("AcpAdapterV2", () => { ); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - // Root-session terminal tool is wake evidence and will buffer: in-memory - // only so the continuation drain projects exactly once. subagentPhase = "complete"; yield* sessionUpdateHandler!({ sessionId: "mock-session-1", @@ -7170,8 +7117,6 @@ describe("AcpAdapterV2", () => { yield* Deferred.await(promptWireReturned); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - // The root turn consumes a terminal re-report while the native prompt is - // still open. The subagent flavor keeps its carried lineage running. yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { @@ -7205,8 +7150,6 @@ describe("AcpAdapterV2", () => { "live carryover must remain pending after the superseded root settles", ); - // The same tool is now a terminal carryover update. Since it was handled - // in-turn, bufferPostSettleWake drops it instead of offering a drain. subagentPhase = "complete"; yield* sessionUpdateHandler!({ sessionId: "mock-session-1", @@ -7317,8 +7260,6 @@ describe("AcpAdapterV2", () => { deferFinalizeForBackgroundWork: true, enablePostSettleContinuation: true, extractSubagentEndNotice: (text) => { - // Prefer the production parser; fall back only if the harness text - // is too short for its UUID + outcome rules. const parsed = extractXAiAcpSubagentEndNotice(text); if (parsed !== undefined) return parsed; if (!text.includes(childSessionId)) return undefined; @@ -7426,9 +7367,6 @@ describe("AcpAdapterV2", () => { ); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - // End notices are root user_message_chunk text and never buffer. The - // interrupted root still cannot project them until the next subscription - // attaches. yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { @@ -7640,8 +7578,6 @@ describe("AcpAdapterV2", () => { ); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - // Root-session terminal + distinctive assistant text: both enter wakeBuffer - // and the terminal offers a continuation that will drain them. subagentPhase = "complete"; yield* sessionUpdateHandler!({ sessionId: "mock-session-1", @@ -7671,7 +7607,6 @@ describe("AcpAdapterV2", () => { "pending continuation must keep hasPendingBackgroundWork pinned", ); - // Child path must not wipe wakeBuffer or clear the sticky continuation pin. yield* sessionUpdateHandler!({ sessionId: childSessionId, update: { @@ -7709,7 +7644,6 @@ describe("AcpAdapterV2", () => { "child-session must not clear the pin while wakeBuffer still has delivery", ); - // Continuation attach drains the preserved buffer and projects it. const continuationNow = yield* DateTime.now; yield* runtime.startTurn( makeTurnInput({ @@ -7905,9 +7839,6 @@ describe("AcpAdapterV2", () => { ); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - // The interrupted root-session completion advances in-memory carryover - // without projecting and offers a continuation. Child-session replay - // must not project a second copy before that observable attach. subagentPhase = "complete"; yield* sessionUpdateHandler!({ sessionId: "mock-session-1", @@ -8038,10 +7969,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, deferFinalizeForBackgroundWork: true, - // Hard interrupt flags (stricter than production Grok, which no - // longer sets restartRuntimeOnEveryInterrupt): every interrupt - // would hard-kill the process group without the settled-soft gate - // under test. restartRuntimeAfterInterrupt: true, restartRuntimeOnEveryInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, @@ -8068,9 +7995,6 @@ describe("AcpAdapterV2", () => { childSessionId: null, result: "SUB_DONE", }, - // No ownDetachedProcessGroup: if the interrupt wrongly takes the - // hard path, terminateProcessGroup is missing and the interrupt - // fails loudly with a poisoned session. makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, @@ -8113,8 +8037,6 @@ describe("AcpAdapterV2", () => { yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), ); - // The still-running subagent defers finalize after session/prompt returns, - // so the interrupt below hits a settled turn held open for background work. yield* Deferred.await(promptSettled); const firstProviderTurnId = idAllocator.derive.providerTurn({ @@ -8153,8 +8075,6 @@ describe("AcpAdapterV2", () => { ordinal: 2, }), ); - // Same runtime process (mock-session-1): a respawn would start - // mock-session-2 and drop the carryover on the session mismatch. const secondProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), @@ -8201,15 +8121,9 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, restartRuntimeAfterInterrupt: true, - // Local hard-flavor gate: production Grok no longer sets - // restartRuntimeOnEveryInterrupt, but when a flavor does, the - // settled-soft gate must not leak onto an unsettled prompt. restartRuntimeOnEveryInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, preserveRuntimeOnSettledInterrupt: true, - // No ownDetachedProcessGroup: the expected hard path fails loudly - // on the missing terminateProcessGroup, proving the settled-soft - // gate did not apply to an unsettled prompt. makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, @@ -8289,18 +8203,11 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, enablePostSettleContinuation: true, - // Production Grok interrupt flags: hard teardown only with - // requestRuntimeRestart (user Stop). Without - // restartRuntimeOnEveryInterrupt a mid-prompt steering interrupt - // stays soft: session/cancel, same process, session reuse. restartRuntimeAfterInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, preserveRuntimeOnSettledInterrupt: true, registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) => registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation), - // No ownDetachedProcessGroup: if the interrupt wrongly takes the - // hard path, terminateProcessGroup is missing and the interrupt - // fails loudly with a poisoned session. makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, @@ -8361,7 +8268,6 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), ) .pipe(Effect.forkScoped); - // Wait for the running command tool so the interrupt lands mid-prompt. yield* Stream.fromQueue(protocolEvents).pipe( Stream.filter( (event) => @@ -8377,7 +8283,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - // No requestRuntimeRestart: a steering interrupt, not a user Stop. yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); assert.isTrue( cancelCalled, @@ -8392,8 +8297,6 @@ describe("AcpAdapterV2", () => { } } assert.equal(firstTerminalStatus, "interrupted"); - // The cancel handler emitted _x.ai/task_backgrounded for the detached - // command; the tracked task must report as pending background work. let backgroundTracked = false; for (let attempt = 0; attempt < 80 && !backgroundTracked; attempt += 1) { backgroundTracked = yield* hasPendingBackgroundWork; @@ -8406,7 +8309,6 @@ describe("AcpAdapterV2", () => { "cancel-backgrounded task must be tracked as running background work", ); - // The second prompt reuses the same process and session. const secondNow = yield* DateTime.now; yield* runtime.startTurn( makeTurnInput({ @@ -8436,8 +8338,6 @@ describe("AcpAdapterV2", () => { "soft mid-prompt interrupt must not respawn the ACP runtime process", ); - // _x.ai/task_completed lands ~1.2s after the cancel and clears the - // tracked task without opening a synthetic continuation run. let backgroundPending = true; for (let attempt = 0; attempt < 50 && backgroundPending; attempt += 1) { backgroundPending = yield* hasPendingBackgroundWork; @@ -8547,8 +8447,6 @@ describe("AcpAdapterV2", () => { if (applyMutation === null) { return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); } - // Plumbing sanity: pre-Stop mutations on the root session track and - // clear pending background work through the extension callback. yield* applyMutation({ sessionId: "mock-session-1", taskId: "task-pre-stop", @@ -8582,10 +8480,6 @@ describe("AcpAdapterV2", () => { } assert.equal(terminalStatus, "interrupted"); - // Residual lifecycle from the stopped run: activeSessionId still points - // at the stopped session until the next turn respawns the runtime, so - // only the direct Stop quarantine stands between this mutation and the - // wake machinery. yield* applyMutation({ sessionId: "mock-session-1", taskId: "task-late-after-stop", @@ -8616,10 +8510,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, enablePostSettleContinuation: true, - // The full production Grok interrupt flag set after the non-Stop - // softening: no restartRuntimeOnEveryInterrupt. User Stop - // (requestRuntimeRestart) must still take the hard teardown and - // respawn path, not the soft cancel path. restartRuntimeAfterInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, preserveRuntimeOnSettledInterrupt: true, @@ -8628,8 +8518,6 @@ describe("AcpAdapterV2", () => { makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, - // Only hang the interrupted first turn. The replacement process must - // complete normally so startTurn / session-load assertions can finish. environment: (runtimeOrdinal) => { runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; @@ -8693,8 +8581,7 @@ describe("AcpAdapterV2", () => { // Effect 4 Queue.takeAll waits for at least one element when empty. After // the session/prompt stream drain the protocol queue is often empty, so - // takeAll would hang forever. Use clear (non-blocking drain) instead so - // the session/resume wait cannot match residual pre-restart traffic. + // takeAll would hang forever. yield* Queue.clear(protocolEvents); yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), @@ -8752,8 +8639,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, deferFinalizeForBackgroundWork: true, - // Production Grok interrupt flags: soft settle keeps the process; - // only requestRuntimeRestart (user Stop) hard-kills. restartRuntimeAfterInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, preserveRuntimeOnSettledInterrupt: true, @@ -8834,8 +8719,6 @@ describe("AcpAdapterV2", () => { ); yield* Deferred.await(promptSettled); - // Project an authoritative v2 assistant-message upsert onto the carryover - // subagent while the deferred turn is still active. assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); yield* sessionUpdateHandler!({ sessionId: childSessionId, @@ -8871,7 +8754,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - // Soft steer first: clears the turn, leaves the process alive. yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); assert.isFalse( cancelCalled, @@ -8896,7 +8778,6 @@ describe("AcpAdapterV2", () => { "soft steer must leave the original ACP runtime process alive", ); - // User Stop against the already-cleared turn: contain the orphan runtime. const stopExit = yield* runtime .interruptTurn({ providerThread, @@ -8910,8 +8791,6 @@ describe("AcpAdapterV2", () => { ); } - // Orphan Stop must terminalize carried-over subagents (soft steer left them - // running on purpose; quarantine alone would leave them stuck "running"). let subagentStopStatus: string | null = null; let subagentStopResult: string | null | undefined; let subagentStopProviderThreadId: string | null | undefined; @@ -8990,9 +8869,6 @@ describe("AcpAdapterV2", () => { "Stop after soft steer must replace the orphan ACP runtime process", ); - // nativeTurnId is `${sessionId}:turn:${ordinal}`. The mock always uses - // mock-session-1; ordinal 2 yields turn:2 on the replacement process. - // Carryover was quarantined by Stop, so turn-1's subagent must not re-attach. subagentPhase = "complete"; const secondProviderTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, @@ -9226,10 +9102,6 @@ describe("AcpAdapterV2", () => { let subagentPhase: "spawn" | "complete" = "spawn"; let cancelCalled = false; const promptPhases: Array = []; - // Gate adapter-visible prompt return so we can open the race window after - // the native stopReason is on the wire. Release lets Effect.tap mark - // promptWireSettled before the completion callback requests the permit; - // interrupt then ORs wire-done with promptSettled under the permit. const promptWireReturned = yield* Deferred.make(); const releasePromptCompletion = yield* Deferred.make(); const adapter = yield* makeAcpAdapterV2({ @@ -9238,7 +9110,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, deferFinalizeForBackgroundWork: true, - // Production Grok flags: without settled-soft, a steer soft-cancels. restartRuntimeAfterInterrupt: true, terminateRuntimeProcessGroupOnInterrupt: true, preserveRuntimeOnSettledInterrupt: true, @@ -9336,11 +9207,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - // Fork interrupt while the adapter-side return is still gated. Release so - // promptWireSettled completes (Effect.tap) before/while the completion - // callback contends for runtimeCallbackPermit. Holding the gate forever - // would also block the wire signal (same Effect resolution), so release - // is required; the assertion is cancel skipped after that race window. const interruptFiber = yield* runtime .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) .pipe(Effect.forkScoped); @@ -9473,7 +9339,6 @@ describe("AcpAdapterV2", () => { } assert.equal(terminalStatus, "completed"); - // Wait for the late post-finalize duplicate TaskOutput frame. yield* Stream.fromQueue(protocolEvents).pipe( Stream.filter( (event) => @@ -9901,9 +9766,6 @@ describe("AcpAdapterV2", () => { nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - // Wait (real time) until the post-settle end notice and TaskOutput - // hydration are ingested: the hydrated monitor card carries the - // fetched listing. let reportSeen = false; const trackReport = (event: ProviderAdapter.ProviderAdapterV2Event): void => { if ( @@ -9930,8 +9792,6 @@ describe("AcpAdapterV2", () => { yield* Effect.yieldNow; yield* Effect.yieldNow; - // Pre-fix the 2s deferred-finalize debounce fires here and the report - // streamed by the injected turn afterwards is dropped on the floor. yield* TestClock.adjust("3 seconds"); yield* Effect.yieldNow; yield* Effect.yieldNow; @@ -9947,7 +9807,6 @@ describe("AcpAdapterV2", () => { drained = yield* Queue.poll(events); } - // Release the report, then the normal debounce finalizes the turn. yield* fileSystem.writeFileString(triggerPath, "go"); yield* Stream.fromQueue(protocolEvents).pipe( Stream.filter( @@ -10108,9 +9967,6 @@ describe("AcpAdapterV2", () => { hydrated = true; } } - // Settled-held window: prompt returned, deferred finalize is holding for - // the injected report. An x.ai/task_completed ext mutation must not arm - // midTurnUnreported and open a wake after the report streams. const applyMutation = capturedMutation.current; if (applyMutation === null) { return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); @@ -10284,7 +10140,6 @@ describe("AcpAdapterV2", () => { return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); } - // Start a monitor mid-turn; leave it unhandled (no get_command). yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { @@ -10309,7 +10164,6 @@ describe("AcpAdapterV2", () => { taskId: "task-monitor-1", status: "running", }); - // Completion ext PRE-settle: arms midTurnUnreportedCompletedTaskIds. yield* applyMutation({ sessionId: "mock-session-1", taskId: "task-monitor-1", @@ -10321,8 +10175,6 @@ describe("AcpAdapterV2", () => { "pre-settle unhandled completion must not offer while the turn is active", ); - // Settle without reporting. Tool stays in_progress so deferred finalize - // holds the turn open for the injected report. yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); yield* Effect.yieldNow; yield* Effect.yieldNow; @@ -10333,8 +10185,6 @@ describe("AcpAdapterV2", () => { "settle must hold for deferred background work; midTurn alone must not offer mid-hold", ); - // The injected report can race ahead of the end notice. It must be - // remembered so the later notice consumes the pre-settle arm. yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { @@ -10384,9 +10234,6 @@ describe("AcpAdapterV2", () => { } assert.isTrue(reportSeen, "injected report must project into the held turn"); - // Hydration safety (60s) force-completes the monitor tool, then the 3s - // quiet window finalizes. No TaskOutput path: that would also clear - // midTurn and hide the agent_message_chunk consumption site under test. yield* TestClock.adjust("60 seconds"); yield* Effect.yieldNow; yield* Effect.yieldNow; @@ -10526,7 +10373,6 @@ describe("AcpAdapterV2", () => { return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); } - // Register monitors A and B in-turn; leave both unhandled. for (const [toolCallId, title] of [ ["tool-call-monitor-a", "Monitor: staggered A"], ["tool-call-monitor-b", "Monitor: staggered B"], @@ -10561,7 +10407,6 @@ describe("AcpAdapterV2", () => { taskId: "task-monitor-b", status: "running", }); - // A completes pre-settle while unhandled: arms midTurnUnreportedCompletedTaskIds. yield* applyMutation({ sessionId: "mock-session-1", taskId: "task-monitor-a", @@ -10573,8 +10418,6 @@ describe("AcpAdapterV2", () => { "pre-settle unhandled completion must not offer while the turn is active", ); - // Turn settles and finalizes with B still running: no offer at finalize - // (running set non-empty), but marks must be kept for the later end. yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); const providerTurnId = idAllocator.derive.providerTurn({ driver: ACP_TEST_DRIVER, @@ -10594,9 +10437,6 @@ describe("AcpAdapterV2", () => { "finalize must not offer while B is still running", ); - // B ends post-finalize via mutation-only end-notice (fails - // acpPostSettleWakeEvidence: extractBackgroundToolMutation matches). - // Without kept midTurn marks this path would neither buffer nor offer. const bEndNotice = { sessionId: "mock-session-1", update: { @@ -10659,9 +10499,6 @@ describe("AcpAdapterV2", () => { flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, - // Hold finalize after prompt settle while monitor tools stay open so - // interrupt lands on the settled-soft path (cancel need not race the - // hang prompt). deferFinalizeForBackgroundWork: true, enablePostSettleContinuation: true, extractBackgroundTaskId: (toolCall) => { @@ -10785,8 +10622,6 @@ describe("AcpAdapterV2", () => { }); assert.lengthOf(continuationRequests, 0); - // Settle while B's tool row still open: deferred finalize holds the - // turn. Interrupt then takes the settled-soft path. yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); yield* Effect.yieldNow; yield* Effect.yieldNow; @@ -10983,7 +10818,6 @@ describe("AcpAdapterV2", () => { return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); } - // Turn 1: in-turn monitor + get_command hydrate + report. yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { @@ -11056,7 +10890,6 @@ describe("AcpAdapterV2", () => { } assert.equal(firstTerminal, "completed"); - // Post-finalize injected monitor-event + ack chatter (live Grok CLI path). yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { @@ -11099,7 +10932,6 @@ describe("AcpAdapterV2", () => { "turn-1 injected-turn ack chatter must not remain as wake buffer evidence", ); - // Turn 2: second in-turn monitor; mid-turn lifecycle must not wake. yield* runtime .startTurn( makeTurnInput({ @@ -11324,7 +11156,6 @@ describe("AcpAdapterV2", () => { yield* agentText("A_DONE"); assert.lengthOf(continuationRequests, 1); assert.equal(continuationRequests[0]?.notification?.summary, 'Command "sleep a" finished'); - // B ends while A's wake waits for its turn; that wake is already named. yield* endCommand("task-b", "sleep b"); assert.lengthOf(continuationRequests, 1); @@ -11344,7 +11175,6 @@ describe("AcpAdapterV2", () => { yield* TestClock.adjust("3 seconds"); assert.equal(yield* awaitTerminal(2), "completed"); - // B's own wake names it. yield* agentText("B_DONE"); assert.lengthOf(continuationRequests, 2); assert.deepEqual(continuationRequests[1]?.notification, { @@ -11466,8 +11296,6 @@ describe("AcpAdapterV2", () => { runtimePolicy, }); const now = yield* DateTime.now; - // Seed turn: complete promptly, then dirty the wake buffer with a - // thought-only frame (buffers, no offer). yield* runtime .startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), @@ -11500,7 +11328,6 @@ describe("AcpAdapterV2", () => { assert.isTrue(yield* hasPendingBackgroundWork, "thought residue must dirty the wake buffer"); assert.lengthOf(continuationRequests, 0); - // Active un-finalized turn 2: user-turn start clears prior residue. yield* runtime .startTurn( makeTurnInput({ @@ -11525,7 +11352,6 @@ describe("AcpAdapterV2", () => { return yield* Effect.die("extensions and session handler must be wired for turn 2"); } - // Unhandled monitor: mid-turn complete must not offer while active. yield* sessionUpdateHandler({ sessionId: "mock-session-1", update: { @@ -11550,10 +11376,6 @@ describe("AcpAdapterV2", () => { taskId: "task-monitor-unhandled", status: "running", }); - // Re-seed dirty wakeBuffer while the turn is still active by routing a - // post-settle-style frame is impossible (active context). Defect B is - // covered by the mid-turn unreported set: completion lands mid-turn - // with empty buffer and must still not offer until finalize. yield* applyMutation({ sessionId: "mock-session-1", taskId: "task-monitor-unhandled", @@ -11584,7 +11406,6 @@ describe("AcpAdapterV2", () => { "post-finalize must offer when mid-turn completion was never handled in-turn", ); - // Third turn: hydrate in-turn before lifecycle complete; no offer. const firstOffer = continuationRequests[0]!; if (firstOffer.clearIfCurrent !== undefined) { yield* firstOffer.clearIfCurrent(); @@ -11772,7 +11593,6 @@ describe("AcpAdapterV2", () => { makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), ) .pipe(Effect.forkScoped); - // Wait until the wrapped prompt is awaiting the gate. yield* Effect.yieldNow; yield* Effect.yieldNow; assert.isDefined(sessionUpdateHandler); @@ -11781,7 +11601,6 @@ describe("AcpAdapterV2", () => { return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); } - // Start a monitor mid-turn; do not hydrate via get_command (unhandled). yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { @@ -11893,8 +11712,6 @@ describe("AcpAdapterV2", () => { flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, - // Quiet window on empty-drain continuation; persistent so the root - // monitor does not pin deferred finalize after mid-turn complete. deferFinalizeForBackgroundWork: true, enablePostSettleContinuation: true, extractBackgroundTaskId: (toolCall) => @@ -11966,8 +11783,6 @@ describe("AcpAdapterV2", () => { return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); } - // Mid-turn unhandled completion arms midTurn and offers after finalize - // with an empty wakeBuffer (midTurn-only offer). yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { @@ -12012,8 +11827,6 @@ describe("AcpAdapterV2", () => { assert.equal(rootTerminal, "completed"); assert.lengthOf(continuationRequests, 1, "midTurn-only evidence must offer one continuation"); - // Continuation attach with empty wakeBuffer: must schedule the quiet - // window rather than finalizing immediately blank. const rootInput = makeTurnInput({ threadId, providerThread, @@ -12041,7 +11854,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), }); - // Still open: no terminal yet (quiet window). let polled = yield* Queue.poll(events); while (Option.isSome(polled)) { assert.notEqual( @@ -12055,7 +11867,6 @@ describe("AcpAdapterV2", () => { polled = yield* Queue.poll(events); } - // Late wake frame during the quiet window attaches to this run. assert.isDefined(sessionUpdateHandler); yield* sessionUpdateHandler!({ sessionId: "mock-session-1", @@ -12067,7 +11878,6 @@ describe("AcpAdapterV2", () => { yield* Effect.yieldNow; yield* Effect.yieldNow; - // Partial advance keeps the window open; full 3s after rearm finalizes. yield* TestClock.adjust("1 second"); yield* Effect.yieldNow; polled = yield* Queue.poll(events); @@ -12274,7 +12084,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:2"), }); - // No frames: quiet window must still finalize (no wedge). yield* TestClock.adjust("3 seconds"); let continuationTerminal: string | null = null; while (continuationTerminal === null) { @@ -12674,9 +12483,7 @@ describe("AcpAdapterV2", () => { "failed teardown must leave both declared Bash and sleep PIDs live", ); - while (Option.isSome(yield* Queue.poll(adapterEvents))) { - // Discard the interrupted turn's expected projection before residual traffic. - } + while (Option.isSome(yield* Queue.poll(adapterEvents))) {} yield* fileSystem.writeFileString(residualCallbackTriggerPath, "go"); yield* Stream.fromQueue(protocolEvents).pipe( Stream.filter( @@ -13147,9 +12954,7 @@ describe("AcpAdapterV2", () => { assert.isDefined(replacementHandlers.elicitation); assert.isDefined(replacementHandlers.requestUserInput); assert.lengthOf(runtimeInputs, 2); - while (Option.isSome(yield* Queue.poll(adapterEvents))) { - // Discard generation 1 terminal and generation 2 startup projection. - } + while (Option.isSome(yield* Queue.poll(adapterEvents))) {} yield* oldHandlers.sessionUpdate!({ sessionId: "mock-session-1", @@ -13383,9 +13188,7 @@ describe("AcpAdapterV2", () => { if (Exit.isSuccess(staleResponse)) { assert.fail("teardown must synchronously remove the old pending request"); } - while (Option.isSome(yield* Queue.poll(events))) { - // Discard generation 1 cancellation and terminal projection. - } + while (Option.isSome(yield* Queue.poll(events))) {} yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), @@ -13743,7 +13546,6 @@ describe("AcpAdapterV2", () => { "late post-Stop assistant text must not attach to the stopped run", ); - // Give residual cancel-path updates a chance to mis-project if quarantine fails. yield* Effect.sleep("200 millis"); yield* Effect.yieldNow; yield* Effect.yieldNow; @@ -14071,7 +13873,6 @@ describe("AcpAdapterV2", () => { driver: ACP_TEST_DRIVER, nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), }); - // restart_active path: interrupt without requestRuntimeRestart. const interruptFiber = yield* runtime .interruptTurn({ providerThread, @@ -14269,7 +14070,6 @@ describe("acpPostSettleContinuationOfferEvidence", () => { }, }), ); - // Thoughts may still be wake evidence for buffering once a real offer opens. assert.isTrue( acpPostSettleWakeEvidence({ sessionId, @@ -14375,7 +14175,6 @@ describe("acpPostSettleContinuationOfferEvidence", () => { }, }, } as const; - // Raw frame looks terminal; the Grok flavor knows it is a running monitor. assert.isTrue(acpPostSettleContinuationOfferEvidence(monitorStartAck)); assert.isFalse( acpPostSettleContinuationOfferEvidence(monitorStartAck, { @@ -14434,14 +14233,10 @@ describe("acpPostSettleWakeShouldBuffer", () => { describe("acpIsAppOwnedWakeTurn", () => { it("recognizes only the orchestrator-injected app-owned wake", () => { - // Delegated-child wake: must preserve this session's own wake frames. assert.isTrue(acpIsAppOwnedWakeTurn({ createdBy: "agent", creationSource: "server" })); - // Provider-native continuation: handled by the continuation branch instead. assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "agent", creationSource: "provider" })); - // Real user turns still clear stale wake residue. assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "user", creationSource: "web" })); assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "user", creationSource: "mcp" })); - // A user-authored message never counts, whatever its surface. assert.isFalse(acpIsAppOwnedWakeTurn({ createdBy: "user", creationSource: "server" })); }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts index d95506e2a005..421cc9592455 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts @@ -153,12 +153,6 @@ function acpReplayAgentArgs(scriptPath: string): ReadonlyArray { return ["--experimental-strip-types", scriptPath]; } -/** - * Holds the replay agent's inbound lines at a gate the scenario releases. - * The agent writes one line per `emit_inbound` entry in transcript order, so - * the Nth line it writes belongs to the Nth inbound entry and carries its - * label. Lines after a held one wait with it, which keeps wire order. - */ function holdGatedReplayLines( transcript: AcpReplayTranscript, replayGate: ProviderReplayGate, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 171f7ae7eedc..565a3cac2f1f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -84,7 +84,6 @@ const layerRegistry = Layer.succeed( ), ); -// Adapters given an explicit runtime never resolve through the catalog. const layerInjectedRuntimeCatalog = Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({ resolve: () => Effect.die("the runtime is injected"), }); @@ -160,9 +159,6 @@ describe("AcpRegistryAdapterV2", () => { options: ["ask", "auto"].map((value) => ({ value, name: value })), }); - // A scripted ACP v1 agent: initialize, session/new answered with `setup`, - // then the frames T3 must send (and the agent's answers) to apply the - // user's stored pick from the agent's mode picker. const openWithStoredModePick = Effect.fn("openWithStoredModePick")(function* (input: { readonly setup: unknown; readonly modeFrames: ReadonlyArray; @@ -230,8 +226,6 @@ describe("AcpRegistryAdapterV2", () => { }), }) .pipe(Effect.scoped); - // Closing the session stops the agent, which writes its replay status in - // the same tick as its last answer. The script must be consumed exactly. yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript); }); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 825d0d81508c..2ded59be7fc3 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -234,8 +234,6 @@ describe("AntigravityAdapterV2 client file system", () => { assert.isTrue(Exit.isFailure(outsideWrite)); assert.isFalse(yield* fileSystem.exists(path.join(outside, "planted.txt"))); - // An in-workspace symlink to an outside file must not carry a read or - // write out of the workspace. const linkPath = path.join(workspace, "linked-secret.txt"); yield* fileSystem.symlink(outsideFile, linkPath); const linkedRead = yield* readTextFile( @@ -249,7 +247,6 @@ describe("AntigravityAdapterV2 client file system", () => { ).pipe(Effect.exit); assert.isTrue(Exit.isFailure(linkedWrite), "a write through an escaping symlink is denied"); assert.equal(yield* fileSystem.readFileString(outsideFile), "secret"); - // A dangling in-workspace symlink to an outside path must not create it. const plantedTarget = path.join(outside, "created-through-link.txt"); const danglingLink = path.join(workspace, "dangling.txt"); yield* fileSystem.symlink(plantedTarget, danglingLink); @@ -259,7 +256,6 @@ describe("AntigravityAdapterV2 client file system", () => { ).pipe(Effect.exit); assert.isTrue(Exit.isFailure(danglingWrite), "a write through a dangling symlink is denied"); assert.isFalse(yield* fileSystem.exists(plantedTarget)); - // A new file under an in-workspace directory link to outside is denied. yield* fileSystem.symlink(outside, path.join(workspace, "linked-dir")); const linkedDirWrite = yield* writeTextFile( { @@ -271,7 +267,6 @@ describe("AntigravityAdapterV2 client file system", () => { ).pipe(Effect.exit); assert.isTrue(Exit.isFailure(linkedDirWrite), "a write under an escaping directory link"); assert.isFalse(yield* fileSystem.exists(path.join(outside, "new.txt"))); - // A symlink that stays inside the workspace keeps working. const insideLink = path.join(workspace, "inside-link.ts"); yield* fileSystem.symlink(insidePath, insideLink); const viaInsideLink = yield* readTextFile( @@ -345,7 +340,6 @@ describe("AntigravityAdapterV2 workspace changes", () => { modelSelection, runtimePolicy: policyFor(workspaceA), }); - // The session opened for A now runs a turn for B. const now = yield* DateTime.now; yield* session .startTurn({ @@ -413,8 +407,6 @@ describe("AntigravityAdapterV2 workspace changes", () => { }); describe("AntigravityAdapterV2 client file system under restrictive policies", () => { - // Antigravity asks before each of its own edits, so T3 serves an opted-in - // write whatever the thread's policy says, confined to the workspace. it.effect("serves in-workspace reads and writes and still refuses outside paths", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 431cdf0ee3e9..62b2ec6eac11 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -63,7 +63,6 @@ const AntigravityProviderCapabilitiesV2 = { export interface AntigravityAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly selfInvocation: SelfInvocation; - /** Spawns the official agent with the instance's Google profile. */ readonly makeRuntime: ( input: Omit, ) => Effect.Effect< @@ -71,9 +70,7 @@ export interface AntigravityAdapterV2Options { EffectAcpErrors.AcpError | ProviderSetupError, Scope.Scope >; - /** Serialises process ownership against sign-in and sign-out. */ readonly withProcess: AntigravityAuth["withProcess"]; - /** Model to select for the provider default alias, from the manifest. */ readonly defaultModel: Effect.Effect; readonly onSessionStarted?: ( started: AcpSessionRuntime.AcpSessionRuntimeStartResult, @@ -85,9 +82,8 @@ export interface AntigravityAdapterV2Options { } /** - * ACP 1.1.1 exposes subagent invocations as ordinary `start_subagent` tools - * without child session ids. Project them as subagent items keyed by the tool - * call so the timeline shows them as delegated work. + * ACP 1.1.1 exposes subagent invocations as ordinary `start_subagent` tools without child + * session ids. */ const extractAntigravitySubagentUpdate: NonNullable = ( toolCall, @@ -112,7 +108,6 @@ const extractAntigravitySubagentUpdate: NonNullable cwd === null ? [options.host.paths.attachmentsDir] : [cwd, options.host.paths.attachmentsDir]; const makeRuntime = (input: AcpAdapterV2RuntimeInput) => Effect.gen(function* () { - // AcpAdapterV2 owns the runtime scope; sign-in and sign-out stop the - // process by closing a child of it, and the adapter respawns on the next turn. const scope = yield* Scope.fork(yield* Effect.scope); const runtime = yield* options .withProcess( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 868e7fa0bd62..b4991241e166 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -106,7 +106,6 @@ const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ }); const CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ customModels: [ - // A bare custom slug that shadows the built-in "opus" alias. "opus", { slug: "claude-custom-tuned", @@ -132,7 +131,6 @@ const CLAUDE_TEST_MODEL_SELECTION = { model: "claude-sonnet-4-6", options: [{ id: "effort", value: "ultrathink" }], } satisfies ModelSelection; -// Thinking is a launch flag, so switching to this selection needs a new process. const CLAUDE_TEST_RESTART_MODEL_SELECTION = { ...CLAUDE_TEST_MODEL_SELECTION, model: "claude-haiku-4-5-20251001", @@ -1092,8 +1090,6 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { requestId: "request-bash-accept-edits", }), ).pipe(Effect.forkScoped); - // Without a callback that asks, the command is allowed before any - // request is raised. const first = yield* Effect.raceFirst( Fiber.join(requestEvent).pipe( Effect.map((event) => ({ type: "request", event }) as const), @@ -1161,9 +1157,6 @@ describe("ClaudeAdapterV2 approval cancellation", () => { ); }); -// Opens a session with the given configured binary path and environment, runs -// one turn, and returns the executable paths the SDK was asked to spawn and the -// CLAUDE_CODE_ENABLE_TODO_TOOLS value it was given. const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( binaryPath: string, environment: ProviderInstanceEnvironment = [], @@ -1941,8 +1934,6 @@ describe("ClaudeAdapterV2 native fork", () => { ); }); -// Stands in for the Claude CLI behind the SDK's spawn hook: records how it was -// started and answers stdin control requests, except mcp_set_servers if asked. function makeFakeClaudeCli(answerSetServers = true) { const spawns: Array = []; const controlRequests: Array = []; @@ -2163,8 +2154,6 @@ const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown) describe("ClaudeAdapterV2 background wake turns", () => { const WAKE_NATIVE_SESSION = "native-thread-claude-wake"; - // Background Bash ids and texts follow the claude_background_task_wake - // recording, so the frames below have the shapes the CLI really sends. const WAKE_TASK_ID = "bdqirlcyw"; const WAKE_TOOL_USE_ID = "toolu_01Rs6JNNf5SqHRxpq5DeJHrW"; const WAKE_TASK_DESCRIPTION = "Background sleep test"; @@ -2307,7 +2296,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000107", text: WAKE_ASSISTANT_TEXT, }); - // The CLI opens the wake turn with `init`, seconds before its first output. const wakeTurnInit = claudeSdkFrame({ type: "system", subtype: "init", @@ -2354,8 +2342,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly subagentLaunchToolUseId?: string; readonly nativeSessionId?: string; readonly settings?: ClaudeSettings; - // A CLI process opened after the first streams from its own queue, so the - // first one can exit (Queue.shutdown) and a later turn can start another. readonly freshQueueOnReopen?: boolean; }) => Effect.gen(function* () { @@ -2379,7 +2365,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const offeredMessages: Array = []; const permissionModeChanges: Array = []; - // setModel and applyFlagSettings calls on the first process, in order. const liveSelectionChanges: Array = []; const continuationRequests: Array = []; @@ -2441,7 +2426,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages).pipe( Stream.flatMap((message) => Stream.make(message).pipe( - // The next pull happens after runForEach finishes handling this frame. Stream.concat( Stream.fromEffect( Effect.suspend(() => { @@ -2531,7 +2515,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { processQueues, offerAndWait, offeredMessages, - // The uuid Claude echoes on the turn answering the nth offered prompt. promptUuid: (index: number) => { const uuid = offeredMessages[index]?.uuid; if (uuid === undefined) throw new Error(`No prompt offered at index ${index}.`); @@ -2694,7 +2677,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* settle; assert.isTrue(yield* harness.hasPendingBackgroundWork); - // Omitted and explicit Normal are the same selection. yield* harness.runtime.startTurn( turn(2, { ...normal, options: [{ id: "fastMode", value: false }] }), ); @@ -2717,14 +2699,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { { model: "claude-opus-5-5" }, { effortLevel: "xhigh", fastMode: true, ultracode: true }, { model: "claude-sonnet-4-6" }, - // Sonnet's default effort; it has no Fast mode. { effortLevel: "high", fastMode: null, ultracode: null }, ]); assert.strictEqual(harness.getOpenedOptions(), originalOptions); assert.lengthOf(harness.offeredMessages, 4); - // Thinking is a launch flag. Haiku 4.5 has no effort, and only a new - // process reads the user's configured effort again. for (const [ordinal, selection] of [ [5, CLAUDE_TEST_RESTART_MODEL_SELECTION], [6, { ...normal, model: "claude-haiku-4-5-20251001" }], @@ -2875,8 +2854,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Summarized display can't mark a progress update, so only the second of two - // thinking blocks before a tool call becomes text; a lone block is reasoning. const plan = "Planning the check"; const update = "Found it: the memory file is the cause."; it.effect.each([ @@ -2972,8 +2949,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - // Same error text can describe a cancellation or a real refusal. - // Each result must use its own metadata, even in a multi-result frame. yield* harness.offerAndWait( claudeSdkFrame({ type: "user", @@ -3110,8 +3085,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { event.turnItem.text === WAKE_ASSISTANT_TEXT, ), ); - // Each result's usage covers only its own segment, so the steered - // turn bills both segments. const completed = harness.events.findLast( (event) => event.type === "provider_turn.updated", ); @@ -3974,7 +3947,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { harness.sdkMessages, toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), ); - // Claude entered plan mode on its own (EnterPlanMode). yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -4096,8 +4068,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); assert.equal(proposedPlan?.status, "active"); - // The second prompt reuses the live process, which is still in the - // plan mode Claude entered, so it is put back in the thread's mode. assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); }).pipe( Effect.provide( @@ -4743,8 +4713,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Frame shapes follow the claude_background_monitor_wake recording: Claude - // runs a Monitor as a local_bash task, linked to its call by tool_use_id. it.effect("keeps a running Claude monitor typed after many newer monitors end", () => Effect.scoped( Effect.gen(function* () { @@ -4839,7 +4807,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { for (const frame of longRunning.start) { yield* Queue.offer(harness.sdkMessages, frame); } - // More newer monitors start and end than any fixed id cap would hold. for (let index = 1; index <= 65; index++) { const monitor = monitorFrames(index); for (const frame of monitor.start) { @@ -4911,8 +4878,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID); assert.isTrue(yield* harness.hasPendingBackgroundWork); - // The Waiting strip's Stop reaches the adapter as an interrupt of the - // settled turn with requestRuntimeRestart. yield* harness.runtime.interruptTurn({ providerThread: settledThread ?? harness.providerThread, providerTurnId: harness.terminalEvents()[0]!.providerTurnId, @@ -4936,9 +4901,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // The CLI process exits on its own after the turn settled (idle, crash), - // leaving its background task on the roster. Stop must succeed so the - // orchestrator goes on to settle what the thread still shows. it.effect("a settled Stop with no CLI process left succeeds and clears the roster", () => Effect.scoped( Effect.gen(function* () { @@ -5029,8 +4991,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, - // The first CLI process keeps streaming until the test ends - // it, so Stop stays parked waiting for it to exit. close: isFirstProcess ? Deferred.succeed(firstCloseRequested, undefined).pipe(Effect.asVoid) : Queue.shutdown(sdkMessages), @@ -5094,8 +5054,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { .pipe(Effect.forkScoped); yield* Deferred.await(firstCloseRequested); - // While Stop waits for the old CLI to exit, a new turn on another - // model replaces the process and starts its own background task. yield* runtime.startTurn( makeClaudeTestTurnInput({ threadId, @@ -5140,8 +5098,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { [replacementTaskId], ); - // The replacement's background task still wakes Claude once its - // turn settles. yield* Queue.offer( processQueues[1]!, makeResultFrame({ @@ -5398,8 +5354,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA)); assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); - // Starting B closes A's only live query. A can never emit a roster - // clear from a dead process, so openQuery must idle-clear A. yield* runtime.startTurn( makeClaudeTestTurnInput({ threadId: appThreadB, @@ -5449,8 +5403,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadB)); assert.isTrue(yield* hasPendingBackgroundWork); - // Starting B's process clears only B's process-scoped level; A stays - // empty from the sibling replacement clear above. assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); yield* Queue.offer( @@ -5567,7 +5519,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }); - // Turn 1 launches background work and echoes its prompt early. yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -5589,9 +5540,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - // The task finishes while the user's next prompt is queued, and the - // CLI runs the wake turn first. That wake turn calls ExitPlanMode, - // whose permission callback fires between its tool_use and result. yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -5638,7 +5586,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { "continuation request", ); - // The prompt's own turn follows and echoes its uuid. yield* Queue.offer( harness.sdkMessages, stamp( @@ -5696,8 +5643,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? [event.plan] : [], ); - // Every update of the tool, from start to its (denied) result, is in - // the continuation run; the user's turn never starts or fails it. const planToolRuns = harness.events.flatMap((event) => event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === planToolUseId && @@ -5708,12 +5653,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isNotEmpty(planToolRuns); assert.isTrue(planToolRuns.every((runId) => runId === runOf(continuationAttempt))); assert.equal(planTool?.runId, runOf(continuationAttempt)); - // Claude was told the plan was captured, so it must be projected. assert.lengthOf(proposedPlans, 1); assert.equal(proposedPlans[0]?.runId, runOf(continuationAttempt)); assert.isTrue(plans.length > 0); assert.isTrue(plans.every((plan) => plan.runId === runOf(continuationAttempt))); - // Nothing from the wake turn reached the user's run. const userRunItems = [...latestItems.values()].filter( (item) => item.runId === runOf(userAttempt), ); @@ -5874,7 +5817,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { runtimePolicy: approvalPolicy, }), ); - // A queued wake turn asks to run Bash while its output is held. yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -5916,9 +5858,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ).pipe(Effect.forkScoped); - // The request is raised at once, releasing the held output to the - // pending prompt turn (where it went before output was held), so the - // user can answer it and the SDK is not left waiting on the echo. yield* awaitUntil( () => harness.events.some((event) => event.type === "runtime_request.updated"), "the approval request", @@ -5972,7 +5911,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - // The first turn echoes on its first frame: this process echoes early. yield* Queue.offer( harness.sdkMessages, stamp( @@ -6000,8 +5938,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: 2, }), ); - // Lifecycle debris ahead of the prompt's own turn: a stale wake's - // unstamped output, then its zero-turn result. yield* Queue.offer( harness.sdkMessages, makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000764", text: "Stale." }), @@ -6024,8 +5960,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); assert.equal(harness.terminalEvents()[1]?.status, "completed"); assert.lengthOf(harness.continuationRequests, 0); - // The debris' held output is released to the prompt's turn with its - // echo, as it streamed before the gate existed. assert.deepEqual( harness.events.flatMap((event) => event.type === "message.updated" && event.message.role === "assistant" @@ -6070,8 +6004,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { : [], ); - // The first turn echoes its prompt uuid on its first frame, so this - // CLI process is known to echo early. yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -6098,8 +6030,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - // The second turn's first frame carries no echo, so it is held; the - // stream then dies before any result. yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -6134,9 +6064,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("gives the same run attempt a fresh prompt uuid on every offer", () => Effect.scoped( Effect.gen(function* () { - // Two servers sharing a database copy allocate identical run attempt - // ids and resume the same Claude session. Claude acks a prompt whose - // uuid its transcript already holds without ever running a turn. const original = yield* makeWakeHarness; const copy = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -6331,8 +6258,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.lengthOf(harness.continuationRequests, 1); assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); - // The run attaches while Claude still thinks: only the notification - // and `init` are buffered, so the run waits for the turn's output. yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -6426,8 +6351,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; - // 2026-09-25T11:10 AEST: the reset a real wake reported while the CLI - // blocked its notification turn ("resets 11:10am (Australia/Sydney)"). const resetsAt = 1790298600; yield* harness.runtime.startTurn( @@ -6444,8 +6367,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, turnOneResult); yield* Queue.take(harness.terminalReceipts); - // The CLI wakes with a rejected window before the continuation turn - // exists, then blocks the wake turn itself. yield* Queue.offer(harness.sdkMessages, wakeNotification); yield* harness.offerAndWait( claudeSdkFrame({ @@ -6460,8 +6381,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), ); - // The parked rate-limit frame rides along with the wake output; it - // must not request a continuation on its own. assert.lengthOf(harness.continuationRequests, 0); yield* harness.offerAndWait( makeAssistantErrorFrame({ @@ -6500,7 +6419,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (terminal === undefined || terminal.status !== "failed") return; assert.equal(terminal.failure.class, "usage_limit"); assert.equal(terminal.failure.resetAt, "2026-09-25T01:10:00.000Z"); - // The rejected window is announced once the replay has a turn to own it. const pause = yield* Queue.take(harness.systemNoticeReceipts); assert.equal(pause.turnItem.type, "system_notice"); if (pause.turnItem.type !== "system_notice") return; @@ -6548,8 +6466,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); - // The user prompt reaches the CLI and the buffer stays untouched: the - // wake result must not settle the user turn or surface under it. yield* awaitUntil(() => harness.offeredMessages.length === 2, "user prompt offered"); assert.lengthOf(harness.terminalEvents(), 1); assert.isTrue(yield* harness.hasPendingBackgroundWork); @@ -6569,8 +6485,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // The continuation run queued behind the user turn drains the wake - // output afterwards. yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -6759,8 +6673,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult); - // Queue-ordered probe: once this assistant text is emitted, the stale - // origin result ahead of it has been consumed. yield* Queue.offer( harness.sdkMessages, makeAssistantTextFrame({ @@ -7053,8 +6965,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // The CLI sends one frame per content block, each carrying the id of the - // native message it belongs to. const makeSubagentAssistantFrames = (input: { readonly parentToolUseId: string; readonly uuid: string; @@ -7202,7 +7112,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer(harness.sdkMessages, result("00000000-0000-4000-8000-000000000902")); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - // The backgrounded subagent ends idle; its wake names it. yield* Queue.offer(harness.sdkMessages, ended("00000000-0000-4000-8000-000000000903")); yield* awaitUntil(() => harness.continuationRequests.length === 1, "subagent wake"); yield* Queue.offer(harness.sdkMessages, result("00000000-0000-4000-8000-000000000904")); @@ -7221,7 +7130,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "wake terminal"); - // A later turn re-runs it in the foreground and starts a background command. yield* userTurn("attempt-rerun-3", 3); yield* Queue.offer( harness.sdkMessages, @@ -7280,13 +7188,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); yield* Queue.offer(harness.sdkMessages, turnOneResult); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - // The command ends idle; Claude has not started its wake yet. yield* Queue.offer(harness.sdkMessages, wakeNotification); let notificationYields = 0; yield* awaitUntil(() => notificationYields++ >= 50, "notification to be recorded"); assert.isFalse(yield* harness.hasPendingBackgroundWork); - // The next prompt names an attachment that is gone, so it never reaches Claude. const missing = ChatImageAttachment.make({ type: "image", id: ChatAttachmentId.make("thread-claude-wake-12345678-1234-1234-1234-123456789abc"), @@ -7297,7 +7203,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { const failed = yield* Effect.exit(userTurn("attempt-failed-start-2", 2, [missing])); assert.isTrue(Exit.isFailure(failed)); - // The user's next prompt runs before Claude's wake does. yield* userTurn("attempt-failed-start-3", 2); yield* Queue.offer( harness.sdkMessages, @@ -7356,8 +7261,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - // While the user's prompt is queued, the wake turn launches a - // subagent; its lifecycle and child frames follow the wake turn. yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -7506,7 +7409,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - // A native wake turn launches a new subagent while T3 has no turn. const idleFrames = [ makeSubagentTaskStartedFrame({ taskId: TASK_ID, @@ -7614,7 +7516,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - // The root settles while the subagent's first Bash call still runs. for (const frame of [ makeSubagentTaskStartedFrame({ taskId: TASK_ID, @@ -7658,7 +7559,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } - // Before anything wakes the root, the idle work is already projected. yield* awaitUntil(() => { const event = latestToolItem(IDLE_BASH); return event?.type === "turn_item.updated" && event.turnItem.status === "running"; @@ -7674,8 +7574,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const routing = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); assert.deepEqual(routing.assistantTexts(routing.childThreadId), ["Still auditing."]); - // The call that spans the turn end keeps the type and input its - // tool_use gave it during the open turn. const straddling = latestToolItem(STRADDLING_BASH); const straddlingItem = straddling?.type === "turn_item.updated" ? straddling.turnItem : undefined; @@ -7788,7 +7686,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { } yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); - // While the root is idle, the subagent runs a nested one to completion. for (const frame of [ claudeSdkFrame({ type: "assistant", @@ -7824,8 +7721,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000974", session_id: WAKE_NATIVE_SESSION, }), - // The outer subagent's progress and own work don't wait for the - // nested start. claudeSdkFrame({ type: "system", subtype: "task_progress", @@ -7914,7 +7809,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); - // The nested Agent call is the nested subagent, never a plain tool row. assert.isFalse( harness.events.some( (event) => @@ -7967,7 +7861,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal, }), ); - // Turn 1 launches the outer subagent, which starts a nested one. yield* userTurn("attempt-claude-earlier-nested-1", 1); for (const frame of [ makeSubagentTaskStartedFrame({ @@ -8017,8 +7910,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, frame); } yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); - // A later turn settles, so its context, which never saw the nested - // launch, is the one the root's idle frames go through. yield* userTurn("attempt-claude-earlier-nested-2", 2); yield* Queue.offer( harness.sdkMessages, @@ -8026,8 +7917,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); - // The nested subagent ends and its Agent call returns while the root - // is idle. The outer subagent's next message marks when both are handled. for (const frame of [ makeSubagentNotificationFrame({ taskId: NESTED_TASK_ID, @@ -8057,7 +7946,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { "outer work after the nested call returns", ); - // The nested Agent call is the nested subagent, never a plain tool row. assert.isFalse( harness.events.some( (event) => @@ -8124,7 +8012,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); assert.deepEqual(bashStatuses(), ["command_execution:running"]); - // The CLI exits before the call returns; its result can never arrive. yield* Queue.shutdown(harness.sdkMessages); yield* awaitUntil(() => bashStatuses().length === 2, "call ended"); assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]); @@ -8141,7 +8028,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.gen(function* () { const TOOL_USE_ID = "toolu-idle-close-timeout"; const BASH = "toolu-idle-close-timeout-bash"; - // close() leaves the stream open, so Stop gives up waiting for it. const harness = yield* makeWakeHarnessWithOptions(); const now = yield* DateTime.now; const bashStatuses = () => @@ -8247,7 +8133,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { } yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); - // While the root is idle, the subagent starts Bash; it is projected at once. for (const frame of makeSubagentAssistantFrames({ parentToolUseId: OUTER_TOOL_USE_ID, uuid: "00000000-0000-4000-8000-000000001003", @@ -8257,8 +8142,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { } yield* awaitUntil(() => bashItems().length === 1, "idle Bash call"); - // A nested start buffers, and its launch result waits behind it. Bash's - // successful result arrives before or after that launch result. const bashResultFrame = makeSubagentToolResultFrame({ parentToolUseId: OUTER_TOOL_USE_ID, uuid: "00000000-0000-4000-8000-000000001006", @@ -8331,7 +8214,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { "continuation request", ); - // The CLI exits before the continuation run drains the wake buffer. yield* Queue.shutdown(harness.sdkMessages); let quietYields = 0; yield* awaitUntil(() => quietYields++ >= 50, "query exit"); @@ -8348,7 +8230,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - // The continuation opened a new CLI process: the first one's exit was handled. assert.equal(harness.processQueues.length, 2); yield* Queue.offer( harness.processQueues[1]!, @@ -8403,7 +8284,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - // The target subagent finishes inside the turn; the sender keeps running. for (const frame of [ makeSubagentTaskStartedFrame({ taskId: SENDER_TASK_ID, @@ -8430,8 +8310,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { } yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); - // While the root is idle, the sender resumes the finished subagent. - // Its SendMessage call is projected at once. yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -8450,8 +8328,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* awaitUntil(() => sendItems().length >= 1, "idle SendMessage call"); - // The resume's task_started buffers. The SendMessage result names that - // start's tool use, but its call already shows as a row, so it ends it. for (const frame of [ claudeSdkFrame({ type: "system", @@ -8490,7 +8366,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const beforeExit = sendItems(); - // The CLI exits before the continuation run drains the wake buffer. yield* Queue.shutdown(harness.sdkMessages); let quietYields = 0; yield* awaitUntil(() => quietYields++ >= 50, "query exit"); @@ -8507,7 +8382,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - // The continuation opened a new CLI process: the first one's exit was handled. assert.equal(harness.processQueues.length, 2); yield* Queue.offer( harness.processQueues[1]!, @@ -8560,16 +8434,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000341", session_id: WAKE_NATIVE_SESSION, }), - // task_started carried no tool_use_id, so these frames are held - // until the notification pairs the task with its tool use. The SDK - // types tool_use_id as optional; no recorded or logged task_started - // has omitted it, so this guards the typed contract only. ...makeSubagentAssistantFrames({ parentToolUseId: TOOL_USE_ID, uuid: "00000000-0000-4000-8000-000000000342", text: "Working.", }), - // The final answer arrives as one snapshot per text block. ...makeSubagentAssistantFrames({ parentToolUseId: TOOL_USE_ID, uuid: "00000000-0000-4000-8000-000000000344", @@ -8643,8 +8512,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - // task_started without tool_use_id (optional in the SDK type), then - // the root settles. for (const frame of [ claudeSdkFrame({ type: "system", @@ -8667,8 +8534,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { } yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); - // Another subagent starts while the root is idle, also without a - // tool_use_id, so its start waits in the wake buffer. const otherStartFrame = claudeSdkFrame({ type: "system", subtype: "task_started", @@ -8696,8 +8561,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000a05", toolUseId: BASH, }); - // Root idle: the child's Bash call arrives before anything names the - // owner's tool use; progress names it before or after the call returns. for (const frame of [ ...(otherStart === "before" ? [otherStartFrame] : []), ...makeSubagentAssistantFrames({ @@ -8752,7 +8615,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { const routing = subagentRouting(harness.events, [BASH]); const childTexts = routing.assistantTexts(routing.childThreadId); const statuses = bashStatuses(); - // Observed order of child texts and Bash item updates, for diagnosis. const trace = harness.events.flatMap((event) => event.type === "message.updated" && event.message.role === "assistant" && @@ -8774,8 +8636,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ["command_execution:running", "command_execution:completed"], `trace: ${JSON.stringify(trace)}`, ); - // Progress that waited behind the subagent's frames still reaches its - // card, and asks for no continuation of its own. assert.isTrue( harness.events.some( (event) => @@ -8899,8 +8759,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000324", text: "Still working.", }), - // Progress without a tool_use_id replaces the subagent entry while - // the per-turn tool-use alias still points at the previous one. claudeSdkFrame({ type: "system", subtype: "task_progress", @@ -8997,7 +8855,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.take(harness.terminalReceipts); - // The background task resumes in the idle gap, before a user turn. yield* harness.offerAndWait(started("stale-agent", "000000004004")); yield* startTurn(2); yield* harness.offerAndWait(finished("stale-agent", "000000004005")); @@ -9169,7 +9026,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { const scheduler = yield* Scheduler.Scheduler; const dispatcher = scheduler.makeDispatcher(); let checks = 0; - // Pause Stop before its SDK request and deliver the outcome while it is suspended. const stopping = yield* fixture.stop().pipe( Effect.provideService(Scheduler.Scheduler, { executionMode: scheduler.executionMode, @@ -9503,7 +9359,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.take(harness.terminalReceipts); const subagents = harness.events.filter((event) => event.type === "subagent.updated"); - // Aliases and dated ids are recorded as the catalog slug. const observedSlug = "claude-haiku-4-5"; const initialModel = source === "observed-before" @@ -9531,7 +9386,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect.each([ { agentModel: "opus", slug: "claude-opus-5-5", requested: undefined, expected: "max" }, { agentModel: "opus", slug: "claude-opus-5-5", requested: "low", expected: "low" }, - // claude-haiku-4-5 offers no effort, so none is sent or shown for it. { agentModel: "haiku-4.5", slug: "claude-haiku-4-5", requested: undefined, expected: null }, ])( "records the effort a $agentModel subagent runs at (Agent effort $requested)", @@ -9597,7 +9451,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000212", session_id: WAKE_NATIVE_SESSION, }), - // The reply names the model the alias resolved to. claudeSdkFrame({ type: "assistant", parent_tool_use_id: toolUseId, @@ -9620,8 +9473,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { } yield* Queue.take(harness.terminalReceipts); - // The alias is stored as its slug, so the reply is no model change, - // and the parent's fast mode stays with the parent. const subagentSelection = { instanceId: parentSelection.instanceId, model: slug, @@ -9729,13 +9580,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const instanceId = CLAUDE_TEST_MODEL_SELECTION.instanceId; assert.deepEqual(selections, { - // The custom model's own effort levels apply. [`task-${TUNED_TOOL_USE_ID}`]: { instanceId, model: "claude-custom-tuned", options: [{ id: "effort", value: "brutal" }], }, - // The custom slug is not resolved to the built-in model it shadows. [`task-${SHADOW_TOOL_USE_ID}`]: { instanceId, model: "opus" }, }); }).pipe( @@ -9807,7 +9656,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), taskStarted(OUTER_TOOL_USE_ID, "00000000-0000-4000-8000-000000000222"), - // The outer subagent's own Agent calls arrive in its snapshot. claudeSdkFrame({ type: "assistant", parent_tool_use_id: OUTER_TOOL_USE_ID, @@ -10094,8 +9942,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000505", session_id: WAKE_NATIVE_SESSION, }); - // As recorded in claude_background_subagent_lifecycle: the resumed - // run's notification carries the SendMessage call's tool_use_id. const secondNotification = claudeSdkFrame({ type: "system", subtype: "task_notification", @@ -10171,13 +10017,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY); assert.isFalse(yield* harness.hasPendingBackgroundWork); - // The resume task_started races past settle: no turn is active, so it - // must re-open the session registry entry (pinning idle again) and - // buffer for replay. Its notification then counts as wake evidence - // and carries the new summary as the continuation detail. The resume - // rides on a SendMessage tool call whose frames race past settle too; - // on drain replay the SendMessage tool_result is a delivery ACK and - // must not terminalize the re-opened subagent. yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -10240,8 +10079,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { tool_use_result: resumeAck, }), ); - // The resumed run's own work follows its task_started into the wake - // buffer: nothing ingests the child thread until the drain. for (const frame of [ ...makeSubagentAssistantFrames({ parentToolUseId: SUBAGENT_TOOL_USE_ID, @@ -10290,20 +10127,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { "resume continuation terminal", ); - // The drained replay re-opens the row (running, stale result cleared) - // before the second notification terminalizes it again. const statuses = subagentEvents().map((event) => event.subagent.status); const firstCompleted = statuses.indexOf("completed"); const reopenedIndex = statuses.lastIndexOf("running"); assert.isAbove(reopenedIndex, firstCompleted); assert.isNull(subagentEvents()[reopenedIndex]?.subagent.result); - // The drain-replayed reopen re-attributes the subagent to the - // continuation run performing the replay, so that run's ingestion - // fiber routes the resumed lifecycle and lingers past settle until - // the resumed task completes. assert.equal(subagentEvents()[reopenedIndex]?.subagent.runId, "run-attempt-claude-wake-9c"); - // The execution node re-opens too, even though the registry entry was - // already pre-opened by the wake buffer before the drain replay. const nodeStatuses = harness.events .filter( ( @@ -10318,9 +10147,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const finalSubagent = subagentEvents().at(-1)?.subagent; assert.equal(finalSubagent?.status, "completed"); assert.equal(finalSubagent?.result, SECOND_SUMMARY); - // The completion keeps the resuming run's attribution. assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-9c"); - // Its work is projected once, by the drain that re-opens it. const reopenIndex = harness.events.findIndex( (event) => event.type === "subagent.updated" && @@ -10384,7 +10211,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, - // End this process stream so openQuery can replace it. close: Queue.shutdown(sdkMessages), }; }), @@ -10432,9 +10258,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(processQueues.length, 1); const firstProcess = processQueues[0]!; yield* Queue.offer(firstProcess, wakeTaskStarted); - // The shell leaves the roster before its notification arrives, so - // nothing runs in this process any more and a model change may - // replace it. Wake eligibility outlives the empty level. yield* Queue.offer( firstProcess, claudeSdkFrame({ @@ -10453,8 +10276,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isFalse(yield* hasPendingBackgroundWork); const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; - // ProviderTurnStartService marks the thread active before startTurn; - // the process-reset clear must preserve that status. const activeProviderThread = { ...providerThread, status: "active" as const, @@ -10473,21 +10294,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.equal(processQueues.length, 2); - // Process-scoped level resets to empty on CLI (re)start while the - // starting turn's provider thread remains active (not idle). yield* awaitUntil( () => providerThreadRosterEvents(events).some( (event) => event.providerThread.status === "active" && (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && - // Prefer the post-replace clear over the initial empty thread. event.providerThread.updatedAt !== undefined, ), "roster cleared on process replace while remaining active", ); - // After replace, the in-memory Waiting probe must be false even if a - // late empty-level event was already present before background work. assert.isFalse(yield* hasPendingBackgroundWork); const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( (event) => @@ -10501,9 +10317,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); - // A late notification from the previous process must not wake after - // eligibility was reset with the process. Offer on the new process - // stream (the old queue is shut down). const secondProcess = processQueues[1]!; yield* Queue.offer(secondProcess, wakeNotification); let settleYields = 0; @@ -10629,14 +10442,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.isTrue(yield* hasPendingBackgroundWork); - // Idle completion notification buffers before any continuation runs. yield* Queue.offer(firstProcess, wakeNotification); let quietYields = 0; yield* awaitUntil(() => quietYields++ >= 50, "notification-only quiet window"); assert.lengthOf(continuationRequests, 0); - // User turn changes model, replacing the query while the wake buffer - // stays queued for the later provider continuation. const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; yield* runtime.startTurn( makeClaudeTestTurnInput({ @@ -10663,13 +10473,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { () => events.filter((event) => event.type === "turn.terminal").length === 2, "user turn terminal after replace", ); - // The terminal notification remains buffered for classification, but - // notification-only traffic no longer pins pending work. assert.isFalse(yield* hasPendingBackgroundWork); assert.lengthOf(continuationRequests, 0); - // Continuation drains the buffered local_bash notification with no - // fabricated subagent/node and attributes the wake result text. yield* Queue.offer(secondProcess, wakeResult); yield* awaitUntil(() => continuationRequests.length === 1, "continuation after result"); assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY); @@ -10704,7 +10510,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event.type === "node.updated" && event.node.kind === "subagent"), ), ); - // Must not re-project the opaque task id as anything but roster history. assert.isFalse( events.some( (event) => @@ -10890,14 +10695,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.isTrue(yield* hasPendingBackgroundWork); - // Session-registered subagent completion buffers; no opaque tombstone. yield* Queue.offer(firstProcess, subagentNotification); yield* awaitUntil(() => continuationRequests.length === 1, "continuation after notify"); assert.equal(continuationRequests[0]?.detail, SUBAGENT_SUMMARY); - // Model-changing user turn replaces the query while continuation stays - // queued. Process reset must not invent opaque classification for the - // buffered subagent notification. const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; yield* runtime.startTurn( makeClaudeTestTurnInput({ @@ -10924,7 +10725,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { () => events.filter((event) => event.type === "turn.terminal").length === 2, "user turn terminal after replace", ); - // The actual completion was applied while idle, before wake replay. assert.isFalse(yield* hasPendingBackgroundWork); assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); assert.lengthOf(continuationRequests, 1); @@ -11083,8 +10883,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => terminals().length === 1, "first turn terminal"); const settledTurn = terminals()[0]!; - // The subagent runs inside the first CLI process. A thinking change - // needs another process, so the turn must not start and close this one. const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; const switchTurn = (attempt: string) => runtime.startTurn( @@ -11108,7 +10906,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.lengthOf(processQueues, 1); - // Stop ends the background work, so the switch may replace the process. yield* runtime.interruptTurn({ providerThread, providerTurnId: settledTurn.providerTurnId, @@ -11117,8 +10914,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* switchTurn("attempt-claude-model-change-running-subagent-c"); assert.lengthOf(processQueues, 2); - // The stopped subagent never reports its end, so it must not block - // later changes on the replacement process either. yield* Queue.offer( processQueues[1]!, makeResultFrame({ @@ -11268,8 +11063,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ) .pipe(Effect.exit); assert.isTrue(Exit.isFailure(failedStart)); - // The shell runs in the first process, so it is never closed and - // no replacement is opened. assert.equal(openCount, 1); assert.isTrue(yield* hasPendingBackgroundWork); }).pipe( @@ -11554,7 +11347,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ) .pipe(Effect.exit); assert.isTrue(Exit.isFailure(failedStart)); - // No live process ever existed: do not emit a fabricated empty roster. assert.lengthOf(providerThreadRosterEvents(events), 0); }).pipe( Effect.provide( @@ -12070,7 +11862,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(first?.title, "alpha:one"); assert.equal(first?.prompt, "Reply with exactly: A1"); assert.equal(first?.model, "claude-opus-5[1m]"); - // Members hang off the coordinator's own thread, not the launching run's. assert.equal(first?.threadId, coordinator?.childThreadId); assert.equal(second?.threadId, coordinator?.childThreadId); assert.notEqual(first?.childThreadId, second?.childThreadId); @@ -12149,8 +11940,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { secondState: "queued", }), ); - // The coordinator's turn item is emitted after its members are - // projected, so a re-emitted member would already be in `events`. yield* awaitUntil( () => coordinatorTurnItems().length > turnItemsBeforeRepeat, "repeated workflow snapshot", @@ -13058,8 +12847,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(latest?.result, "Initial result."); assert.isFalse(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.continuationRequests, 1); - // The native wake turn ended once; no extra provider result will - // arrive to settle a mistakenly opened continuation. yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -13293,19 +13080,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { const terminal = yield* Queue.take(harness.terminalReceipts); assert.equal(terminal.status, terminalReason === "api_error" ? "failed" : "interrupted"); assert.equal(terminal.threadDisposition, "reusable"); - // The run following the workflow (its launcher, or the run a resumed - // workflow was re-attributed to) stops following it, so it is stopped - // instead of left running out of sight. const settled = terminalReason === "api_error" ? "failed" : "cancelled"; assert.deepEqual( stoppedTasks, pendingStop ? [WORKFLOW_TASK_ID, WORKFLOW_TASK_ID] : [WORKFLOW_TASK_ID], ); if (stopping !== undefined) { - // Parent failure assigns a local outcome without a task_notification. yield* TestClock.adjust("2 seconds"); assert.isTrue(Exit.isFailure(yield* Fiber.join(stopping))); - // Local parent cleanup still cannot provide a confirmed outcome on retry. const retry = yield* harness.runtime.stopSubagent!({ providerThread: harness.providerThread, nativeTaskId: WORKFLOW_TASK_ID, @@ -13581,8 +13363,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { betweenTurns && (status === "query_failed" || interrupted || status === "stop_buffered") ) { - // The launching turn already ended. Await the last member's - // terminal event, which follows its root node update. const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id; while (true) { const update = yield* Queue.take(harness.subagentReceipts); @@ -13759,8 +13539,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); - // The run outlives the turn that launched it, so this snapshot arrives - // with no turn context at all. yield* Queue.offer( harness.sdkMessages, workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001009", state: "done" }), @@ -13946,7 +13724,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { const NESTED_TOOL_USE_ID = "toolu-steer-nested"; const FINISHED_TOOL_USE_ID = "toolu-steer-finished"; const calls: Array = []; - // What happens while the move is still unanswered. let beforeAnswer: Effect.Effect = Effect.void; const harness = yield* makeWakeHarnessWithOptions({ backgroundTasks: (toolUseId) => @@ -14051,7 +13828,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { toolUseId: NESTED_TOOL_USE_ID, uuid: "00000000-0000-4000-8000-000000000a05", }), - // Nested too, though its launch never reached the adapter. foregroundTaskStarted({ taskId: "task-steer-deep", toolUseId: "toolu-steer-deep", @@ -14098,7 +13874,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const RERUN_TOOL_USE_ID = "toolu-steer-rerun"; if (backgrounding === "answers after the subagent ran again") { - // It ends and a SendMessage runs it again, in the foreground. beforeAnswer = harness .offerAndWait( makeSubagentNotificationFrame({ @@ -14131,26 +13906,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { } const steered = yield* Fiber.await(steering); - // Only the root's running subagent moves: the nested ones follow - // their owner and the finished one has nothing left to move. assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); if (backgrounding === "answers after the turn ended") { - // The steer is refused, so the orchestrator starts its follow-up turn. assert.isTrue(Exit.isFailure(steered)); assert.equal(harness.offeredMessages.length, offered); return; } - // A refused or unanswered move still lets the steer through. assert.isTrue(Exit.isSuccess(steered)); assert.equal(harness.offeredMessages.length, offered + 1); assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); if (backgrounding === "never answers") { - // Later steers wait on the move still in flight instead of - // piling up another request for the same run. for (let repeat = 1; repeat <= 2; repeat++) { const again = yield* Effect.forkScoped(steer); - // The steer's 5 second wait starts once it reaches it; move the - // clock until the steer has gone out. for (let tick = 0; tick < 100 && again.pollUnsafe() === undefined; tick++) { yield* TestClock.adjust("1 second"); yield* Effect.yieldNow; @@ -14168,12 +13935,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { ) { return; } - // The move answered for the first run; the second runs in the - // foreground, so its end is the parent's tool result, not a wake. const reran = backgrounding === "answers after the subagent ran again"; - // The moved subagent outlives the steered turn. When it ends during - // the user's next turn, Claude's wake after that turn names it. for (const frame of [ makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a06", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index ef03f2700add..ec3ec5b05246 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -45,10 +45,6 @@ import { import type { ProviderReplayGate } from "@t3tools/provider-testing/replayGate"; export const CLAUDE_AGENT_SDK_REPLAY_PROTOCOL = "claude-agent-sdk.query" as const; -/** - * Replay label of the result that ends the Nth (1-based) background wake turn - * of a recording, so a replay gate can hold it. - */ export const claudeBackgroundWakeResultLabel = (wakeNumber: number) => `result:background-wake:${wakeNumber}`; @@ -737,7 +733,6 @@ function makeReplayQueryRunner( const expected = entry.frame; const expectedUuid = promptOfferUuid(expected); const actualUuid = promptOfferUuid(actual); - // Recordings made before prompts carried a uuid simply lack one. if ( !sameFrame(withoutPromptOfferUuid(expected), withoutPromptOfferUuid(actual)) || (expectedUuid !== undefined && actualUuid === undefined) @@ -759,7 +754,6 @@ function makeReplayQueryRunner( advance(); }; - // The recorded reply to a one-shot session call (fork, subagent lookup). const assertNextReplyFrame = ( type: Frame["type"], isValid: (frame: object) => boolean, @@ -977,7 +971,6 @@ function layerClaudeAgentSdkReplay( transcript: ClaudeAgentSdkReplayTranscript, options: { readonly replayGate?: ProviderReplayGate; - // Shared across runtimes; its owner asserts completion. readonly queryRunner?: ClaudeQueryRunner; } = {}, ): Layer.Layer { @@ -1235,8 +1228,6 @@ function sanitizeSdkMessageForReplay(input: { return message; } -// Lets a recording wait a bounded time for the next frame without losing it -// when the wait times out: the pending read is handed to the next reader. class RecordingMessageReader implements AsyncIterator { private pending: Promise> | undefined; private readonly iterator: AsyncIterator; @@ -1251,12 +1242,10 @@ class RecordingMessageReader implements AsyncIterator { return pending; } - // The next frame if it arrives within `ms`, left unread for next(). async peekWithin(ms: number): Promise { const pending = this.pending ?? this.iterator.next(); this.pending = pending; return Promise.race([ - // A failed read stays pending for next() to surface. pending.then( (result) => (result.done === true ? undefined : result.value), () => undefined, @@ -1451,7 +1440,6 @@ async function recordMessagesUntilIteratorDone(input: { } } -// A queued wake turn starts right after the turn before it settles. const CLAUDE_RECORDING_WAKE_QUIET_MS = 5_000; function isSystemInitFrame(message: SDKMessage | undefined): boolean { @@ -1482,7 +1470,6 @@ async function recordMessagesUntilToolUse(input: { readonly iterator: AsyncIterator; readonly entries: Array; readonly scenario: string; - // Returns after the assistant frame carrying this many root tool uses. readonly toolUseCount: number; }): Promise { let toolUses = 0; @@ -1560,13 +1547,7 @@ async function recordClaudeStreamingQuery(input: { readonly allowDangerouslySkipPermissions?: boolean; readonly enablePermissionCallback?: boolean; readonly permissionDecision?: ProviderApprovalDecision; - // Per prompt, how many turns Claude starts on its own for background work - // (task-notification-origin results) to wait for before the next prompt. - // Setting it also records any further wake turn that starts within a short - // quiet window, so the next prompt is not offered while one is queued. readonly backgroundWakeCounts?: ReadonlyArray; - // Skip that quiet window: offer the next prompt while a wake may still be - // queued in the CLI, which then runs the wake turn first. readonly offerNextPromptImmediately?: boolean; }): Promise { const promptQueue = new RecordingPromptQueue(); @@ -1630,7 +1611,6 @@ async function recordClaudeStreamingQuery(input: { }); const iterator = new RecordingMessageReader(queryRuntime[Symbol.asyncIterator]()); let wakeNumber = 0; - // Records one turn and labels it when Claude started it for background work. const recordTurn = async (promptNumber: number): Promise<"prompt" | "wake"> => { const completed = await recordMessagesUntilTurnResult({ iterator, @@ -1648,8 +1628,6 @@ async function recordClaudeStreamingQuery(input: { return "prompt"; } wakeNumber += 1; - // A distinct label lets a replay gate hold the wake result until the - // continuation run that ingests it has started. input.entries[input.entries.length - 1] = { type: "emit_inbound", label: claudeBackgroundWakeResultLabel(wakeNumber), @@ -1659,7 +1637,6 @@ async function recordClaudeStreamingQuery(input: { }; try { for (const [index, prompt] of input.prompts.entries()) { - // Like the adapter, give each prompt a fresh uuid Claude echoes on its turn. const message = ClaudeAdapterV2.makeClaudeUserMessage({ text: prompt, uuid: await Effect.runPromise( @@ -2389,7 +2366,6 @@ async function recordInterruptedClaudeQuery(input: { readonly promptOfferLabel: string; readonly interruptLabel: string; readonly interruptAfter?: "prompt_offer" | "tool_use"; - // With interruptAfter "tool_use": interrupt after this many root tool uses. readonly interruptAfterToolUses?: number; readonly enableTools?: boolean; readonly tools?: ClaudeAdapterV2.ClaudeAgentSdkQueryTools; @@ -2915,10 +2891,6 @@ export const ClaudeOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarnes layerClaudeProviderAdapterRegistryReplay(transcript, options), }; -/** - * Replays one transcript across several orchestrator runtimes, the way a - * server restart reopens the same native session with a fresh adapter. - */ export function makeClaudeRestartReplayHarness(transcript: ClaudeAgentSdkReplayTranscript) { const queryRunner = makeReplayQueryRunner(transcript); return { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0e2dcb309318..897ac19221df 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -188,9 +188,6 @@ interface ClaudeCacheCreation { readonly ephemeral_5m_input_tokens: number; } -// Claude Code writes 1h cache entries on a subscription within its limits and -// 5m entries otherwise (API key, Bedrock, Vertex, extra usage). A response -// that wrote nothing leaves the TTL unknown. function claudePromptCacheTtlMs( cacheCreation: ClaudeCacheCreation | null | undefined, ): number | undefined { @@ -237,17 +234,10 @@ const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds"; /** - * Starts the Claude CLI as the leader of its own process group. When the CLI - * exits it stops the MCP servers it started, but not their children: a - * plugin's `bun run start` wrapper dies and its `bun server.ts` keeps running, - * writing to a pipe nobody reads (#3089). Those stay in the CLI's group, so - * the rest of the group gets SIGKILL once the CLI exits for any reason. - * Commands the agent runs through its Bash tool start their own groups, so - * background jobs it launched are not part of this. - * - * The SDK reads stderr only from its own spawn, where it redacts credentials - * before adding the tail to exit errors. Here stderr is dropped instead, so - * exit errors carry only the code or signal. + * When the CLI exits it stops the MCP servers it started, but not their children: a plugin's + * `bun run start` wrapper dies and its `bun server.ts` keeps running, writing to a pipe nobody + * reads (#3089). Those stay in the CLI's group, so the rest of the group gets SIGKILL once the + * CLI exits for any reason. */ function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProcess { const child = NodeChildProcess.spawn(options.command, options.args, { @@ -260,9 +250,7 @@ function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProc child.once("exit", () => { try { signalProcessGroup(child.pid ?? 0, "SIGKILL"); - } catch { - // The group is already empty. - } + } catch {} }); return child; } @@ -427,7 +415,6 @@ export interface ClaudeAgentSdkQuerySession { mode: PermissionMode, ) => Effect.Effect; readonly stopTask: (taskId: string) => Effect.Effect; - /** Moves the foreground task one tool call started to the background; false when none matched. */ readonly backgroundTasks: ( toolUseId: string, ) => Effect.Effect; @@ -466,7 +453,6 @@ export interface ClaudeAgentSdkQueryRunnerShape { readonly forkSession: ( input: ClaudeAgentSdkSessionForkInput, ) => Effect.Effect; - /** The tool call that launched a subagent, read from the CLI's session storage. */ readonly subagentLaunchToolUseId: ( input: ClaudeAgentSdkSubagentLookupInput, ) => Effect.Effect; @@ -518,8 +504,6 @@ function closeClaudeQuery(queryRuntime: ClaudeQuery) { // sdkMessages generator). The raw generator's return() queues behind the // in-flight read of the next CLI message and never settles while the CLI is // idle, deadlocking stream interruption (and with it, session scope close). -// Query.return() runs cleanup() first, which closes the transport and -// unblocks that read. export function claudeQueryMessages(queryRuntime: ClaudeQuery): AsyncIterable { return { [Symbol.asyncIterator]: () => queryRuntime }; } @@ -777,13 +761,10 @@ export const layerQueryRunner: Layer.Layer< pathToClaudeCodeExecutable: launch.command, executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])], }; - // A spawner the caller passed keeps its process. Windows has no process - // groups, so there the SDK spawns the CLI itself. + // Windows has no process groups, so there the SDK spawns the CLI itself. const spawnClaudeCodeProcess = options.spawnClaudeCodeProcess ?? (platform === "win32" ? undefined : spawnClaudeProcessGroup); - // The CLI is running once query() returns, so it is closed if anything - // before the session is handed out fails or is interrupted. const queryRuntime = yield* Effect.uninterruptibleMask((restore) => Effect.try({ try: () => @@ -800,10 +781,8 @@ export const layerQueryRunner: Layer.Layer< restore( Effect.gen(function* () { if (mcpServers !== undefined) { - // A rejected request or a server that failed to connect - // leaves the turn without those tools, as `--mcp-config` - // did. Error texts and the CLI's mcp_status response can - // echo the headers, so only server names are logged. + // Error texts and the CLI's mcp_status response can echo the headers, so + // only server names are logged. const failed = yield* Effect.tryPromise(() => queryRuntime.setMcpServers(mcpServers), ).pipe( @@ -1027,8 +1006,6 @@ export const layerQueryRunner: Layer.Layer< agentId: input.agentId, }, }); - // The CLI stamps every message of a subagent's transcript with the - // tool call that launched it; one message is enough. const messages = yield* Effect.tryPromise({ try: () => getSubagentMessages(input.sessionId, input.agentId, { @@ -1068,10 +1045,6 @@ function canApplyClaudeSelectionLive( ); } -/** - * Flag settings that move a live process to `selection`. `null` clears a key - * a new process for `selection` would not set. - */ function claudeLiveSelectionFlagSettings( selection: CompiledClaudeModelSelection, ): ClaudeFlagSettings { @@ -1088,12 +1061,6 @@ export function makeClaudeQueryOptions(input: { readonly resume: boolean; readonly resumeSessionAt?: string; readonly cwd: string | null; - /** - * The attachments dir grant lets the agent Read/copy pasted images at the - * paths appended to the turn text, without an approval prompt. It is a leaf - * directory holding only attachment files; siblings like secrets/ and - * state.sqlite stay ungranted. - */ readonly attachmentsDir?: string; readonly settings?: ClaudeSettings; readonly sdkSettings?: string | ClaudeSdkSettings; @@ -1237,15 +1204,11 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ // above that and the server's own wait timeout is what ends a long call. export const CLAUDE_T3_MCP_TOOL_TIMEOUT_MS = 65 * 60 * 1_000; -// The SDK's `allowedTools` only pre-approves tool calls; availability is the -// separate `tools` option. Attaching the t3-code MCP server therefore always -// pre-approves its tools (headless modes like `dontAsk` deny anything that is -// not pre-approved), but read-only sandboxes pre-approve only the annotated -// read-only orchestrator tools so a read-only session cannot silently spawn -// threads or scheduled tasks. -// -// The header carries the credential itself: the query runner sends -// `mcpServers` over the CLI's stdin, never its argv or environment. +// The SDK's `allowedTools` only pre-approves tool calls; availability is the separate `tools` +// option. Attaching the t3-code MCP server therefore always pre-approves its tools (headless +// modes like `dontAsk` deny anything that is not pre-approved), but read-only sandboxes +// pre-approve only the annotated read-only orchestrator tools so a read-only session cannot +// silently spawn threads or scheduled tasks. export function claudeMcpQueryOverrides(input: { readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; @@ -1319,10 +1282,6 @@ function claudeWritesUpdatesAsThinking(model: string): boolean { const CLAUDE_GOAL_SET_PREFIX = "Goal set: "; const CLAUDE_GOAL_ACTIVE = /^Goal active: ([\s\S]+?) \((?:not yet evaluated|(\d+) turns?)\)/u; -/** - * The goal after one root SDK frame, or undefined when the frame says nothing - * about it. Completion has no frame of its own; see finalizeActiveTurn. - */ function nextClaudeGoal( current: OrchestrationV2ProviderGoal | null, message: SDKMessage, @@ -1371,7 +1330,6 @@ function nextClaudeGoal( const providerGoalsEqual = Schema.toEquivalence(Schema.NullOr(OrchestrationV2ProviderGoal)); -/** Compares a subagent result with its routed text regardless of block joins. */ function normalizeClaudeResultText(text: string): string { return text.replace(/\s+/g, " ").trim(); } @@ -1585,13 +1543,12 @@ export function makeClaudeUserMessage(input: { readonly text: string; readonly priority?: SDKUserMessage["priority"]; readonly skillNames?: ReadonlySet; - // Claude echoes it as user_message_uuid on the turn that answers it. readonly uuid?: SDKUserMessage["uuid"]; }): SDKUserMessage { // Claude Code expands a skill only from the LAST text block, and only when // `/name` is its first character. A `$skill` chip anywhere in the prompt is - // therefore split into [leading text, "/name trailing text"] so the CLI - // runs it natively and the prose around it survives. See ClaudeSkillDispatch. + // therefore split into [leading text, "/name trailing text"] so the CLI runs + // it natively and the prose around it survives. const dispatch = input.skillNames === undefined ? undefined @@ -1633,9 +1590,6 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: }); } - // The model's tools cannot dereference inlined pixels. Appending the - // on-disk path is what lets a turn like "include this screenshot in the - // PR" copy the actual file (the query grants attachmentsDir for reads). const textWithAttachmentPaths = providerMessageTextWithAttachmentPaths({ text: input.text, attachments: input.attachments, @@ -1896,7 +1850,6 @@ function shouldInstallClaudePermissionCallback(policy: ClaudeRuntimeQueryPolicy) return policy.installPermissionCallback; } -// Whether a tool's permission callback must ask the user before answering. function requiresClaudeApproval(context: ActiveClaudeTurnContext): boolean { return shouldInstallClaudePermissionCallback( claudeRuntimeQueryPolicyForRuntimePolicy(context.input.runtimePolicy), @@ -2072,10 +2025,8 @@ function commandInputFromClaudeTool(toolName: string, input: ClaudeNativeToolInp ); } -// Opaque non-subagent background work admitted onto the Waiting roster, by -// Claude SDK `task_type`, and the kind the roster names it by. Subagents -// project through the normal subagent lifecycle and must not be -// double-counted when background_tasks_changed includes them. +// Subagents project through the normal subagent lifecycle and must not be double-counted when +// background_tasks_changed includes them. const CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS: ReadonlyMap< string, Exclude @@ -2119,16 +2070,9 @@ function isClaudeNonSubagentTask(message: SDKMessage): boolean { } function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean { - return ( - message.type === "system" && - // Undeclared SDK subtype: full roster snapshot of live background tasks. - (message.subtype as string) === "background_tasks_changed" - ); + return message.type === "system" && (message.subtype as string) === "background_tasks_changed"; } -// Claude opens every turn it runs with a root `init` frame. Outside a T3 turn -// that turn is a wake, and `init` comes 20-110 ms after the notification that -// caused it but seconds before its first output (model thinking time). function isClaudeTurnStartMessage(message: SDKMessage): boolean { return message.type === "system" && message.subtype === "init"; } @@ -2152,8 +2096,6 @@ function parseClaudeBackgroundTaskEntry( } const rawTaskType = Reflect.get(entry, "task_type"); const taskType = typeof rawTaskType === "string" ? rawTaskType : null; - // Mirror the incremental path: only opaque non-subagent types currently - // supported for Waiting. Subagent/agent entries stay on the subagent path. if (!isClaudeOpaqueBackgroundTaskType(taskType)) { return null; } @@ -2224,10 +2166,6 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string { return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value); } -/** - * Bash results arrive as `{ stdout, stderr, interrupted, ... }`; keep only the - * text. A background run has empty streams, so keep its acknowledgement instead. - */ function claudeCommandOutputText(output: ClaudeNativeToolOutput): string { const value = claudeNativeToolOutputValue(output); if (typeof value === "object" && value !== null) { @@ -2525,7 +2463,6 @@ function claudeToolResultEntriesFromMessage(message: SDKMessage): ReadonlyArray< ]; } -// These wire fields are not yet declared by the SDK's SDKUserMessage type. const ClaudeToolResultMetadata = Schema.Struct({ tool_result_meta: Schema.Array( Schema.Struct({ @@ -2633,9 +2570,8 @@ const awaitClaudeUserInputAnswers = Effect.fn("awaitClaudeUserInputAnswers")(fun }); /** - * First user-facing error from a non-success result. "[ede_diagnostic] ..." - * entries are CLI-internal telemetry (the CLI hides them from its own UI too), - * so they must never become the error banner (#5557). + * "[ede_diagnostic] ..." entries are CLI-internal telemetry (the CLI hides them from its own UI + * too), so they must never become the error banner (#5557). */ function resultUserFacingError(result: SDKResultMessage): string | undefined { const errors = "errors" in result && Array.isArray(result.errors) ? result.errors : []; @@ -2730,9 +2666,6 @@ function isClaudeProviderContinuationTurn( return input.message.createdBy === "agent" && input.message.creationSource === "provider"; } -// Root turn output whose owner (the offered prompt or a queued wake turn) -// the prompt echo decides. Subagent frames, lifecycle frames, and anything -// else not produced by the root model turn pass straight through. function isClaudePromptEchoGatedFrame(message: SDKMessage): boolean { switch (message.type) { case "assistant": @@ -2746,8 +2679,6 @@ function isClaudePromptEchoGatedFrame(message: SDKMessage): boolean { } } -// The tool_use a non-root frame belongs to: a subagent frame's parent, or a -// task lifecycle frame's tool_use_id. function claudeFrameToolUseIds(message: SDKMessage): ReadonlyArray { const ids: Array = []; const parent = Reflect.get(message, "parent_tool_use_id"); @@ -2772,9 +2703,6 @@ function claudeEchoedPromptUuids(message: SDKMessage): ReadonlyArray { return typeof uuid === "string" ? [uuid] : []; } -// The prompt a command_lifecycle frame (queued, started, completed) -// acknowledges. A CLI that sends them also echoes that uuid on the result of -// the turn answering the prompt; one that does not keeps the old path. function claudePromptLifecycle( message: SDKMessage, ): { readonly uuid: string; readonly state: string } | null { @@ -2785,10 +2713,9 @@ function claudePromptLifecycle( return typeof uuid === "string" && typeof state === "string" ? { uuid, state } : null; } -// A result that answers a turn other than the pending prompt's. An echo -// names the prompts its turn consumed. Without one, only a process known to -// echo proves the turn foreign by its non-human origin; on a CLI that never -// echoes, that result can be the prompt turn's only result. +// An echo names the prompts its turn consumed. Without one, only a process +// known to echo proves the turn foreign by its non-human origin; on a CLI that +// never echoes, that result can be the prompt turn's only result. function isClaudeResultForOtherTurn(input: { readonly message: SDKResultMessage; readonly promptUuid: string; @@ -2818,7 +2745,6 @@ function providerFailureFromResult( failureHint?: string, usageLimited = false, ): OrchestrationV2ProviderFailure | null { - // shortcut: accept 9router's wrapped 429 cooldown; prefer native reset metadata when available. const gatewayLimit = failureHint === undefined && message.subtype === "success" && @@ -2993,15 +2919,8 @@ const CLAUDE_USAGE_LIMIT_WINDOWS = { overage: "overage", } satisfies Record, string>; -/** Beyond this the reset time is not credible, so the row ships without a wait. */ const CLAUDE_USAGE_LIMIT_MAX_WAIT_MS = 30 * 24 * 60 * 60 * 1000; -/** - * `resetsAt` is epoch seconds. The row states the remaining wait rather than a - * wall-clock time: this renders on the server, while the row is read on clients - * that may sit in another timezone and locale, and that carry their own - * timestamp preference. A wait reads the same everywhere. - */ function describeClaudeUsageLimit( info: SDKRateLimitInfo, nowMs: number, @@ -3041,9 +2960,6 @@ interface ActiveClaudeTurnContext { readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; readonly providerTurnOrdinal: number; readonly startedAt: DateTime.Utc; - // Item ordinals allocated in this turn. Later turns never look items up - // here: a subagent resumed from another turn keeps its ordinal on the - // session subagent registry. readonly itemOrdinals: Map; readonly assistant: { fallbackText: string; @@ -3056,7 +2972,6 @@ interface ActiveClaudeTurnContext { readonly nextBlockIndex: Map; readonly snapshotBlockIndex: Map; readonly snapshots: Set; - // The latest snapshot block, when it was non-empty thinking. lastThinking: { readonly messageId: string; readonly itemId: string; @@ -3081,14 +2996,9 @@ interface ActiveClaudeTurnContext { readonly subagentsByTaskId: Map; readonly subagentsByToolUseId: Map; readonly subagentNodesByTaskId: Map; - // Set on turns that offered a prompt. Claude runs a wake turn it queued - // for background work before the next prompt's turn, and only the - // prompt's turn echoes this uuid (see handleSdkMessage). readonly promptUuid: string | null; promptEcho: "pending" | "confirmed"; - // Claude acknowledged starting the prompt (command_lifecycle "started"). promptStarted: boolean; - // Root frames seen before the echo; held only when the CLI echoes early. gatedFramesBeforeEcho: number; readonly heldRootFrames: Array; } @@ -3102,32 +3012,17 @@ interface ActiveClaudeProviderRetry { interface ActiveClaudeSubagent { task: OrchestrationV2Subagent; - // Local parent-turn cleanup can end the card without a native task outcome. readonly confirmedOutcome: ProviderAdapter.ProviderAdapterV2SubagentOutcome | undefined; - // The launch run's root node. task.parentNodeId is that same node, or the - // owning subagent's node for a subagent another subagent started. readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; readonly childThreadId: ThreadId; - /** - * The child thread object itself, not just its id: workflow members are - * parented to it long after the launching turn has settled, and building a - * child thread needs the whole parent. - */ childThread: OrchestrationV2AppThread | null; readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; readonly turnItemId: OrchestrationV2TurnItem["id"]; readonly turnItemOrdinal: number; - // The tool call that started the current run: the Agent launch, then each - // SendMessage that resumes the subagent. A new one means a new prompt. readonly runToolUseId: string | null; - // The API effort the subagent runs at: its Agent call's, else its owner's, - // else the session's. Undefined when none is sent and the model default applies. readonly effort: string | undefined; nextChildItemOrdinal: number; resultItemOrdinal: number | null; - // The subagent's latest assistant message routed into its child thread, - // accumulated across the per-content-block snapshots that share one native - // message id. A completion result equal to it is already on screen. lastAssistantText: string | null; lastAssistantMessageId: string | null; } @@ -3136,27 +3031,16 @@ interface ClaudeLiveQueryContext { readonly nativeThreadId: string; readonly query: ClaudeAgentSdkQuerySession; readonly queryPolicyKey: string; - // The selection this process runs now. Live changes update it. selection: CompiledClaudeModelSelection; readonly closed: Deferred.Deferred; - // Retained for process-exit cleanup even when its last turn failed. lastTurn: ActiveClaudeTurnContext | null; - // Whether this CLI process echoes a prompt's uuid on the first frame of - // the turn answering it ("early") or only on its result. Learned from the - // first prompt turn. "acknowledged": the CLI confirmed it took a prompt's - // uuid before any echo, so it echoes, but a resume's own turns can still - // run ahead of that prompt. + // Whether this CLI process echoes a prompt's uuid on the first frame of the turn answering it + // ("early") or only on its result. "acknowledged": the CLI confirmed it took a prompt's uuid + // before any echo, so it echoes, but a resume's own turns can still run ahead of that prompt. promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; - // The mode this process was opened in, and the mode the CLI last reported - // (init and status frames). Claude changes the latter itself through - // EnterPlanMode. readonly openedPermissionMode: PermissionMode; permissionMode: PermissionMode; - // Stop, rollback or fork is closing this process; its work is ending. stopping: boolean; - // Registry entries still running when this process opened. Their process - // is gone and never reports their end; any later task_started replaces the - // entry, so an entry still in this set runs nowhere. readonly subagentsFromEarlierProcesses: ReadonlySet; } @@ -3174,30 +3058,16 @@ interface ActiveClaudeToolCall { readonly presentation?: ClaudeToolPresentation; } -// What is known about a subagent before its task_started registers it, -// keyed by the tool_use_id that launches it. interface PendingClaudeSubagentLaunch { readonly model?: string; readonly effort?: string; - // parent_tool_use_id of the subagent whose own Agent call launches this one. readonly ownerToolUseId?: string; } -/** - * Agent calls name aliases ("opus") and replies name dated ids; both resolve - * to the catalog slug. A subagent's thread then keeps one model name across - * its launch, its replies and a resume after a restart, so a later report of - * the same model never reads as a model change that drops its effort. The - * catalog is the instance's, so a custom model keeps its own name. - */ function canonicalClaudeSubagentModel(catalog: ClaudeModelCatalog, model: string): string { return resolveClaudeModelSlug(catalog, model); } -/** - * The selection a subagent's thread shows. An effort its model does not offer - * is not sent for it, so it is left out (a nested subagent still inherits it). - */ function claudeSubagentModelSelection( catalog: ClaudeModelCatalog, instanceId: ModelSelection["instanceId"], @@ -3215,7 +3085,6 @@ function claudeSubagentModelSelection( } const PENDING_CLAUDE_SUBAGENT_CAP = 64; -// Per-subagent bound on frames held while waiting for task_started. const PENDING_CLAUDE_SUBAGENT_FRAME_CAP = 256; function rememberPendingClaudeSubagentLaunch( @@ -3233,12 +3102,6 @@ function rememberPendingClaudeSubagentLaunch( } } -/** - * Agent calls carry model and effort overrides even when the SDK omits child - * assistant snapshots. A subagent's own Agent call arrives only in its - * snapshot, so the owner recorded here is all that links the subagent it - * starts back to it. - */ function rememberClaudeSubagentLaunch( catalog: ClaudeModelCatalog, context: ActiveClaudeTurnContext, @@ -3249,8 +3112,6 @@ function rememberClaudeSubagentLaunch( ): void { if (context.subagentsByToolUseId.has(toolUseId)) return; const requested = firstStringInputField(input, ["model"]); - // "inherit" is the caller's model: the session's here, an owner's once - // task_started resolves it. const model = requested !== "inherit" ? requested && canonicalClaudeSubagentModel(catalog, requested) @@ -3258,8 +3119,6 @@ function rememberClaudeSubagentLaunch( ? context.input.modelSelection.model : undefined; const effort = firstStringInputField(input, ["effort"]); - // A model already known (a snapshot's, or an earlier sighting of this - // call) wins over the requested one. const launch: PendingClaudeSubagentLaunch = { ...(model === undefined || pending.get(toolUseId)?.model !== undefined ? {} : { model }), ...(effort === undefined ? {} : { effort }), @@ -3379,7 +3238,6 @@ export function claudeTodoSteps(input: unknown): ReadonlyArray>; function claudeTaskField(value: unknown, key: string): unknown { @@ -3404,13 +3262,6 @@ function claudeTaskStatus(value: unknown): OrchestrationV2PlanStep["status"] | u } } -/** - * Applies a finished TaskCreate, TaskUpdate or TaskList call to a task list. - * Claude 5 tracks todos with these tools instead of TodoWrite. Ids come from - * Claude (TaskCreate reports them in its result), a `deleted` status removes - * the task, and TaskList replaces the whole list. Returns undefined when the - * call is not a task tool or leaves the list unchanged. - */ function applyClaudeTaskTool( tasks: ClaudeTaskList, toolName: string, @@ -3481,7 +3332,6 @@ export interface ClaudeAdapterV2Options { readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; - /** Sink for wake-turn continuation requests; defaults to dropping them. */ readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -3494,7 +3344,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; - // Subagent model names and effort levels, including the instance's custom models. const subagentModelCatalog = scopeClaudeModelCatalog( BUNDLED_CLAUDE_MODEL_CATALOG, adapterOptions.settings.customModels, @@ -3503,10 +3352,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( offer: () => Effect.void, }; - // Re-scan on every send: skills are added and switched off mid-session, and - // the scan is a few directory reads. A skill switched off via skillOverrides, - // or reserved for the agent with `user-invocable: false`, is left as prose: - // the CLI would answer `/name` with a notice instead of running it. const userInvocableSkillNames = (cwd: string | null) => discoverClaudeSkills( adapterOptions.settings, @@ -3569,7 +3414,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( { readonly status: OrchestrationV2Subagent["status"]; readonly attempt: number; - /** Digest of the last emitted transcript answer, not the text: it can run to the transcript cap. */ readonly transcriptAnswerDigest: string | undefined; readonly transcriptReadAttempts: number; readonly finalTranscriptRead: boolean; @@ -3599,72 +3443,45 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingRuntimeRequests = yield* Ref.make( new Map(), ); - // Background-task wake support. Claude can settle a turn while a - // local_bash background task keeps running; the CLI later re-invokes - // the model (a "wake turn") on the same query stream with no active - // provider turn. These refs track pending background tasks, buffer - // wake messages until a continuation run attaches, and remember where - // to dispatch that run. + // Claude can settle a turn while a local_bash background task keeps running; the CLI + // later re-invokes the model (a "wake turn") on the same query stream with no active + // provider turn. const lastTurnRouteByNativeThread = yield* Ref.make( new Map< string, { readonly threadId: ThreadId; readonly providerThreadId: ProviderThreadId } >(), ); - // Authoritative + incremental background-task roster for post-settle - // Waiting UI. Outer key is native Claude session id so concurrent - // provider threads on one runtime cannot share or clear each other. const pendingBackgroundTasksByNativeThread = yield* Ref.make( new Map>(), ); - // Wake eligibility is separate from the Waiting roster. It survives - // empty background_tasks_changed levels (SDK: empty level can precede - // task_notification) and is consumed when the first idle notification - // is buffered/offered so duplicates cannot re-buffer. A short-lived - // replay tombstone then covers continuation drain classification so - // local_bash is never projected as a subagent; the tombstone is - // cleared after that drained notification is processed. Both sets - // clear on CLI process open/replacement and failed/interrupted turns. + // Wake eligibility is separate from the Waiting roster. It survives empty + // background_tasks_changed levels (SDK: empty level can precede task_notification) and + // is consumed when the first idle notification is buffered/offered so duplicates cannot + // re-buffer. const wakeEligibleBackgroundTasksByNativeThread = yield* Ref.make( new Map>(), ); const opaqueBackgroundTaskReplayTombstonesByNativeThread = yield* Ref.make( new Map>(), ); - // Last known provider-thread payload per native session, used to emit - // roster-only provider_thread.updated events between turns without - // resurrecting an active status after root settlement. const lastProviderThreadByNativeThread = yield* Ref.make( new Map(), ); - // Native `/goal` per session, seeded from the persisted provider thread. const goalsByNativeThread = new Map(); - // Turns whose model output met an active goal's Stop hook check at its end. const goalCheckedTurns = new Set(); // Subagent registry that survives turn settle: a background subagent // (Agent with run_in_background) can complete after the root turn // ended, and its task_notification must both count as wake evidence // and hydrate the original subagent node instead of being dropped. const sessionSubagentsByTaskId = yield* Ref.make(new Map()); - // Subagent frames carry only parent_tool_use_id. Frames that outlive - // the turn that launched the subagent (wake drain, later turns) resolve - // their subagent through this index into sessionSubagentsByTaskId. const sessionSubagentTaskIdsByToolUseId = yield* Ref.make(new Map()); // Requests that move a running subagent to the background before a // steer, by the tool call that started its run. Claude may never // answer one, so a later steer waits on it rather than asking again. const backgroundMovesByToolUseId = new Map>(); - // A subagent's tool calls can outlive the turn that started them, so - // they live here rather than on a turn context: a turn that ends while - // one runs neither fails it nor hides it from the turn that follows. const subagentToolCalls = new Map(); - // Launches seen before their task_started. A running subagent's Agent - // call can be projected while the root is idle and its task_started - // only in the continuation turn, so these outlive a turn too. const pendingSubagentLaunchesByToolUseId = new Map(); - // The turn that settled last on each native thread. While the root is - // idle, a running subagent's frames are projected through it at once - // instead of waiting in the wake buffer for a continuation run. const settledTurnByNativeThread = new Map(); const subagentStopOutcomes = new Map< string, @@ -3685,13 +3502,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( { readonly messages: ReadonlyArray; readonly detail: string | null } >(), ); - // Background work that ended and has not been named by a wake offer yet, - // by native thread and task id, with the user turn it ended in. Claude's - // wake result carries no task id, and its wake can run after a prompt the - // user queued meanwhile, so reports survive one user turn and then expire: - // a notification Claude folded into its own turn cannot name a later wake. - // One Ref holds both, so a report cannot be stamped with a turn that - // has already been superseded. + // Claude's wake result carries no task id, and its wake can run after a prompt the user + // queued meanwhile, so reports survive one user turn and then expire: a notification + // Claude folded into its own turn cannot name a later wake. One Ref holds both, so a + // report cannot be stamped with a turn that has already been superseded. const wakeReportsByNativeThread = yield* Ref.make<{ readonly userTurns: ReadonlyMap; readonly reports: ReadonlyMap< @@ -3699,7 +3513,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ReadonlyMap >; }>({ userTurns: new Map(), reports: new Map() }); - // Subagents Claude started in the background. Only their ends wake the root. const backgroundedSubagentTaskIds = yield* Ref.make>(new Set()); // Subagents another subagent started (spawn_depth above 1). Claude // reports their end to the owning subagent, so it never wakes the root. @@ -3733,7 +3546,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( else updatedReports.set(nativeThreadId, kept); return { userTurns: updatedTurns, reports: updatedReports }; }); - /** Removes and returns a thread's reports. */ const takeWakeReports = (nativeThreadId: string) => Ref.modify(wakeReportsByNativeThread, (current) => { const taken = current.reports.get(nativeThreadId); @@ -3744,16 +3556,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const clearWakeReports = (nativeThreadId: string) => takeWakeReports(nativeThreadId).pipe(Effect.asVoid); - // Last roster entry per opaque task. An empty roster level can land before - // the task's notification, and the wake still needs to name the task. + // An empty roster level can land before the task's notification, and + // the wake still needs to name the task. const lastKnownOpaqueTasks = yield* Ref.make( new Map(), ); - // Live Claude monitors. A Monitor call waits in `calls` until its - // task_started links it to a task, or its tool_result ends the call - // without one. A task stays in `tasks` until it leaves its native - // thread's roster or its task_notification arrives, so a monitor is - // never forgotten while it runs. const claudeMonitors = yield* Ref.make<{ readonly calls: ReadonlySet; readonly tasks: ReadonlyMap< @@ -3771,7 +3578,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ].map((toolResult) => toolResult.tool_use_id); if (started.length === 0 && returned.length === 0) return Effect.void; return Ref.update(claudeMonitors, (current) => { - // A replayed tool_use frame must not reopen a call whose task already started. const linked = new Set([...current.tasks.values()].map((task) => task.toolUseId)); const opened = started.filter((id) => !linked.has(id) && !current.calls.has(id)); const closed = returned.filter((id) => current.calls.has(id)); @@ -3781,7 +3587,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return { ...current, calls }; }); }; - /** True when a Monitor call started this task; links the task to that call. */ const isClaudeMonitorTask = (input: { readonly nativeThreadId: string; readonly taskId: string; @@ -3800,7 +3605,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); return [true, { calls, tasks }] as const; }); - /** Drops monitor tasks that ended: gone from the thread's roster, or notified. */ const endClaudeMonitorTasks = ( ended: (taskId: string, task: { readonly nativeThreadId: string }) => boolean, ) => @@ -3839,15 +3643,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return { kind: task.kind, label, outcome }; case "command": case undefined: - // Only local_bash is opaque background work today. return { kind: "command", label, outcome }; } }); const requestedContinuations = yield* Ref.make(new Set()); - // ExitPlanMode plans whose permission callback fired while the tool's - // root frames were held for a prompt echo. Each projects when its - // tool_use frame is handled, in whichever run that frame is routed - // to (the prompt's turn, or the continuation that drains a wake). const heldProposedPlansByToolUseId = new Map(); const runtimeContext = yield* Effect.context(); const runPromise = Effect.runPromiseWith(runtimeContext); @@ -3974,8 +3773,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return updated; }); - // First-notification wake offering only: not the Waiting roster and - // not the post-buffer replay tombstone. const isWakeEligibleOpaqueBackgroundTaskOnNativeThread = ( nativeThreadId: string, taskId: string, @@ -3992,9 +3789,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.map((all) => taskIdSetForNativeThread(all, nativeThreadId).has(taskId)), ); - // Classify a task_notification as opaque local_bash (not a subagent): - // live roster, still-eligible first notification, or short-lived - // replay tombstone left after the idle notification was buffered. const isKnownOpaqueBackgroundTaskOnNativeThread = ( nativeThreadId: string, taskId: string, @@ -4012,8 +3806,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); - // Admit onto wake eligibility only. Replay tombstones are created when - // the first idle notification is buffered, not at task start. const markWakeEligibleOpaqueBackgroundTasks = ( nativeThreadId: string, taskIds: ReadonlyArray, @@ -4024,9 +3816,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( taskIds, ); - // After the first idle opaque notification is buffered/offered: stop - // further wake buffering for this task id, but keep a replay tombstone - // until the continuation drain classifies the buffered notification. const consumeWakeEligibilityForBufferedNotification = ( nativeThreadId: string, taskId: string, @@ -4079,7 +3868,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - /** Applies one root frame's goal signal and writes any change onto the provider thread. */ const trackClaudeGoal = Effect.fnUntraced(function* (input: { readonly nativeThreadId: string; readonly context: ActiveClaudeTurnContext; @@ -4116,7 +3904,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : Ref.update(lastKnownOpaqueTasks, (current) => { const updated = new Map(current); for (const task of tasks) updated.set(task.taskId, task); - // Bounded: an entry only outlives its task when no notification came. for (const oldest of updated.keys()) { if (updated.size <= 64) break; updated.delete(oldest); @@ -4142,9 +3929,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return updated; }); yield* rememberOpaqueTasks(tasks); - // Empty level must not drop wake eligibility: notification may - // still be in flight. Non-empty level admits new task ids to - // wake eligibility only (replay tombstones are edge-created). if (tasks.length > 0) { yield* markWakeEligibleOpaqueBackgroundTasks( nativeThreadId, @@ -4194,12 +3978,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.delete(nativeThreadId); return updated; }); - // The thread's process died or its turn failed; those monitors never notify. yield* endClaudeMonitorTasks((_taskId, task) => task.nativeThreadId === nativeThreadId); }); - // Drop idle wake traffic for a dead native process so it cannot pin - // session-wide pending work after sibling query replacement. const clearWakeStateForNativeThread = (nativeThreadId: string) => Effect.gen(function* () { yield* clearWakeReports(nativeThreadId); @@ -4221,19 +4002,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // Process-scoped level: SDK emits nothing at CLI start, so both the - // Waiting roster and wake eligibility reset when a live query opens - // or is replaced for this native thread. Opaque replay tombstones that - // already covered buffered task_notification frames are restored so a - // model/policy query replacement still classifies those local_bash - // completions on continuation drain. Buffer membership alone must not - // invent opaque classification: session-registered subagent - // notifications share the same buffer. + // Process-scoped level: SDK emits nothing at CLI start, so both the Waiting roster and + // wake eligibility reset when a live query opens or is replaced for this native thread. const resetBackgroundTaskStateForNativeThreadProcess = Effect.fnUntraced(function* ( nativeThreadId: string, options?: { - // openQuery during startTurn: activeTurn is not installed yet, but - // ProviderTurnStartService already marked the provider thread active. readonly status?: OrchestrationV2ProviderThread["status"]; }, ) { @@ -4282,10 +4055,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (remembered === undefined) { return; } - // Prefer an explicit starting-turn status so a successful openQuery - // replacement clear cannot emit idle over an already-active thread. - // Otherwise: between turns never resurrect active from process reset; - // with a live activeTurn context, upgrade idle β†’ active. const activeContext = yield* Ref.get(activeTurn); const status = options?.status ?? @@ -4301,8 +4070,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // A subagent's projection ids derive from its task id, so they are - // the same whether this process created the subagent or not. const claudeSubagentIds = (context: ActiveClaudeTurnContext, taskId: string) => ({ nodeId: idAllocator.derive.nodeFromProviderItem({ driver: CLAUDE_PROVIDER, @@ -4506,8 +4273,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : input.classification.normalizedName === "webfetch" && outputText.trim().length > 0 ? { - // WebFetch returns page text, not search hits. Keep a - // bounded preview so the row has something to show. results: [ { ...(webFetchUrl === undefined ? {} : { url: webFetchUrl }), @@ -4550,13 +4315,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // A subagent launched before a server restart is missing from the - // registry when SendMessage resumes it. The resume's task_started - // names only the SendMessage call, while the resumed run's frames - // carry the launch call as parent_tool_use_id, which only the CLI's - // session storage still records. Registering the subagent as a - // finished run lets the resume reopen it like an in-session resume: - // same child thread, launch prompt kept, the new prompt appended. + // The resume's task_started names only the SendMessage call, while the resumed run's + // frames carry the launch call as parent_tool_use_id, which only the CLI's session + // storage still records. const recoverResumedClaudeSubagent = Effect.fnUntraced(function* (resume: { readonly context: ActiveClaudeTurnContext; readonly nativeThreadId: string; @@ -4564,8 +4325,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly toolUseId: string | undefined; readonly title: string; }) { - // An Agent launch never enters toolCalls, so a task_started under a - // tracked tool call is a resume. if ( resume.toolUseId === undefined || findToolCall(resume.context, resume.toolUseId) === undefined || @@ -4631,8 +4390,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( `task:${resume.taskId}:subagent`, ), runToolUseId: launchToolUseId, - // The launch's effort went with the old process. Without a task - // modelSelection the child thread keeps the one it was given. effort: undefined, nextChildItemOrdinal: 100, resultItemOrdinal: null, @@ -4647,12 +4404,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); - /** - * Stamps each member with the thread it owns. Derived from the - * coordinator's own child thread so the id inherits its provider-thread - * scoping, and done here so the snapshot the clients read carries the - * same id the projection emits. - */ const withWorkflowMemberThreadIds = ( workflow: OrchestrationV2SubagentWorkflow, coordinatorChildThreadId: ThreadId, @@ -4667,7 +4418,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( })), }); - /** Emits one conversation message into a subagent's own thread. */ const emitSubagentConversationMessage = Effect.fnUntraced(function* (input: { readonly nativeItemId: string; readonly threadId: ThreadId; @@ -4714,17 +4464,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - /** - * Projects a workflow's members as ordinary subagents. - * - * A member is not a provider task β€” it exists only inside the - * coordinator's progress snapshot β€” but everything the user expects - * from a subagent (a thread to open, a prompt, an answer) is expressed - * by the same events, so this emits exactly the sequence - * updateClaudeSubagentNode emits rather than inventing a parallel - * shape. Members hang off the coordinator's thread, not the run's, so - * a fan-out of fifty does not bury the parent's lineage. - */ const projectClaudeWorkflowMembers = Effect.fnUntraced(function* (input: { readonly coordinator: ActiveClaudeSubagent; readonly workflow: OrchestrationV2SubagentWorkflow; @@ -4907,9 +4646,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( let transcriptAnswerDigest = restarted ? undefined : previous?.transcriptAnswerDigest; let transcript: ReadonlyArray = []; if (settled) { - // The member's own transcript, when the run left one: a missing or - // unreadable file is expected (a run predating run-handle capture, - // a member that never started) and falls back to the excerpt. transcript = readTranscript ? yield* readWorkflowAgentAnswers({ transcriptDir, @@ -4929,7 +4665,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( transcriptAnswer === undefined ? undefined : yield* sha256Hex(crypto, transcriptAnswer); - // A continuation may settle or resume this workflow during the read. if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator) return; const previousDigest = transcriptAnswerDigest; @@ -5022,12 +4757,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly prompt?: string; readonly title?: string; readonly model?: string; - // The Agent call's effort and the subagent whose own Agent call - // started this one; read only when this call registers the subagent. readonly effort?: string; readonly owner?: ActiveClaudeSubagent; readonly progress?: string; - /** Raw SDK frame, folded into the coordinator's workflow snapshot. */ readonly workflowFrame?: unknown; readonly workflowName?: string; readonly workflowRunHandles?: OrchestrationV2WorkflowRunHandles; @@ -5043,10 +4775,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( >; readonly onlyIfRunningInRun?: RunId | null; }) { - // The session registry lets a wake-replay turn (fresh context maps) - // hydrate a subagent that was created by an earlier, settled turn. const registeredSubagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); - // Terminal cleanup must include snapshots received after the turn settled. const existingSubagent = input.onlyIfRunningInRun !== undefined ? registeredSubagent @@ -5096,10 +4825,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const lifecycleChanged = existingSubagent === undefined || existingSubagent.task.status !== input.status || - // A drain-replayed resume task_started finds the registry entry - // already pre-opened to running by bufferWakeMessage while the - // projection node still holds the old terminal status; re-emit - // the node lifecycle for authoritative task_started updates. (input.reopen === true && input.status === "running"); const now = yield* DateTime.now; @@ -5114,9 +4839,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const turnItemOrdinal = existingSubagent?.turnItemOrdinal ?? (yield* resolveItemOrdinal(input.context, `${nativeItemId}:subagent`)); - // A resumed subagent's previous final answer and progress no longer - // represent its outcome; the next task_progress/task_notification - // carry the new ones. const priorTask = existingSubagent === undefined ? undefined @@ -5125,9 +4847,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent.task, ) : existingSubagent.task; - // Workflow telemetry is cumulative: fold this frame into whatever the - // coordinator already carried, so a usage-only frame cannot blank the - // member roster the user is watching. const workflow = mergeClaudeWorkflowProgress({ previous: priorTask?.workflow, ...(input.workflowName === undefined ? {} : { name: input.workflowName }), @@ -5171,13 +4890,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( startedAt: now, }), status: input.status, - // A reopen replayed under a continuation run re-attributes the - // subagent to that run. RunExecutionService routes parent-thread - // events by runId, and only the resuming run's ingestion fiber is - // guaranteed alive (the launch run's fiber stops once its child - // subagents terminalize); attribution also enrolls the subagent - // in the resuming run's active-child tracking so its fiber - // outlives settle until the resumed task completes. ...(input.reopen === true && input.status === "running" && existingSubagent !== undefined @@ -5186,8 +4898,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(input.prompt === undefined ? {} : { prompt: input.prompt }), ...(input.title === undefined ? {} : { title: input.title }), model, - // What the subagent runs with, unless it was recovered after a - // restart. The parent's other options belong to the parent's session. ...(existingSubagent === undefined || existingSubagent.task.modelSelection !== undefined ? { modelSelection: claudeSubagentModelSelection( @@ -5226,9 +4936,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( effort, nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, - // Every task_started begins a new run of the subagent (including a - // resume that bufferWakeMessage already pre-opened), so text from - // an earlier run can no longer stand in for the next result. lastAssistantText: input.reopen === true ? null : (existingSubagent?.lastAssistantText ?? null), lastAssistantMessageId: @@ -5258,9 +4965,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); if (!accepted) return; if (input.nativeOutcome !== undefined) appliedSubagentOutcomes.add(input.nativeOutcome); - // A call the subagent left open never returns once it ends. It ends - // first, while the run that owns the child thread still takes its - // events. if (existingSubagent !== undefined && input.status !== "running") { const openCalls = [...subagentToolCalls.values()].filter( (toolCall) => toolCall.threadId === existingSubagent.childThreadId, @@ -5323,10 +5027,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( driver: CLAUDE_PROVIDER, node: { id: nodeId, - // Parenting stays with the launch run's root node (or the - // owning subagent) even on wake-replay; runId follows - // task.runId, which a reopen re-attributes to the resuming run - // (see task construction). threadId: task.threadId, runId: task.runId, parentNodeId: task.parentNodeId, @@ -5378,8 +5078,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, }); } - // Each run opens with its own prompt in the child thread: the launch - // task, then every message that resumes the subagent. const promptNativeItemId = existingSubagent === undefined ? `${nativeItemId}:prompt` @@ -5440,9 +5138,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (outcome !== undefined) yield* Deferred.succeed(outcome, subagent.confirmedOutcome); } - // A completed subagent's result is normally its final assistant - // message, which is already in the child thread when its text was - // routed there. Failures and cancellations always get the message. const resultAlreadyShown = input.status === "completed" && input.result !== undefined && @@ -5602,11 +5297,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // Resolves the subagent that owns a parent_tool_use_id. The session - // registry is authoritative: every update replaces the subagent - // object, and per-turn tool-use aliases can lag behind (updates - // without a toolUseId) or be empty (a turn other than the one that - // launched the subagent). Mutations must land on the current object. const resolveSubagentByToolUseId = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, toolUseId: string, @@ -5621,7 +5311,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const findToolCall = (context: ActiveClaudeTurnContext, nativeItemId: string) => context.toolCalls.get(nativeItemId) ?? subagentToolCalls.get(nativeItemId); - // Only a subagent's calls have no run: they belong to its child thread. const toolCallsFor = (context: ActiveClaudeTurnContext, toolCall: ActiveClaudeToolCall) => toolCall.runId === null ? subagentToolCalls : context.toolCalls; @@ -5869,7 +5558,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), }); - // Ends open calls whose results will never arrive. const endToolCalls = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly toolCalls: ReadonlyArray; @@ -5902,8 +5590,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); } }); - // Ends the calls a subagent kept open past the root's last turn, once - // the CLI process that ran them is gone. const endSubagentCallsOfClosedQuery = Effect.fnUntraced(function* ( nativeThreadId: string, status: "interrupted" | "failed", @@ -5934,9 +5620,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }) { yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); if (input.status !== "completed") { - // The run stops following its subagents once its root turn fails or is - // interrupted, so a workflow it follows is stopped rather than left - // running where Lineage can no longer see or stop it. const liveQuery = yield* Ref.get(queryContext); for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { if ( @@ -5960,9 +5643,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); } } - // A subagent still running in the background keeps its open calls: - // their results arrive after this turn. One left by an earlier CLI - // process is gone and never reports them. const liveQuery = yield* Ref.get(queryContext); const runningChildThreadIds = new Set( [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) => @@ -5980,8 +5660,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* endToolCalls({ context: input.context, toolCalls: [...input.context.toolCalls.values(), ...endingSubagentCalls], - // A stopped turn cuts its open tools short; only a turn that - // ended on its own leaves them failed. status: input.status === "completed" ? "failed" : input.status, completedAt: input.completedAt, }); @@ -6113,10 +5791,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), }, }), - // Surface this native thread's roster before the root turn - // terminals so writeFinalRunEvents preserves it. Failed or - // interrupted turns drop only this thread's roster so sibling - // native threads keep their Waiting state. + // Surface this native thread's roster before the root turn terminals so + // writeFinalRunEvents preserves it. Effect.gen(function* () { const nativeThreadId = input.context.input.providerThread.nativeThreadRef?.nativeId ?? null; @@ -6143,11 +5819,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const clearConversationHead = input.status === "completed" && input.context.input.providerThread.nativeConversationHeadRef !== null; - // While a goal is set, Claude ends a turn on its own only after the - // goal's Stop hook passes. It defers that check while background - // work runs, and a hook that stops the turn reports another reason. - // SDK mode does not report an evaluator timeout or an impossible - // verdict, so those still read as complete. const goal = nativeThreadId === null ? undefined : goalsByNativeThread.get(nativeThreadId); const goalChecked = goalCheckedTurns.delete(input.context.providerTurnId); @@ -6279,9 +5950,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } else { yield* resetBackgroundTaskStateForNativeThreadProcess(closedQuery.nativeThreadId); } - // A workflow can outlive its launching turn, but not its CLI process. - // Apply buffered outcomes before Stop can discard their wake buffer. - // Capture ownership before finalization, which may yield to a new process. for (const [taskId, subagent] of subagents) { if ( subagent.task.workflow === undefined || @@ -6300,7 +5968,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (message.subtype === "task_started" || message.subtype === "task_notification") && message.task_id === taskId, ); - // A later start resumes the same task and invalidates its older outcome. const completion = latestLifecycle?.subtype === "task_notification" ? latestLifecycle : undefined; yield* updateClaudeSubagentNode({ @@ -6332,16 +5999,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); - /** - * Refreshes a background workflow coordinator's entity between turns. - * Deliberately narrow: only a registered coordinator that this frame - * carries a snapshot for is touched, and only `subagent.updated` is - * emitted, because node and turn-item ids need a turn context this path - * does not have. Status is left alone β€” `task_notification` owns the - * lifecycle, and it has its own turn-aware path. - * These updates only reach the projection while the launching run is still - * ingesting, which holds because a running coordinator keeps that run open. - */ const applyWorkflowProgressWithoutTurn = Effect.fnUntraced(function* (message: SDKMessage) { if (message.type !== "system" || message.subtype !== "task_progress") return; if (!hasClaudeWorkflowSnapshot(message)) return; @@ -6397,12 +6054,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const message = wakeInput.message; const isNotification = message.type === "system" && message.subtype === "task_notification"; - // Only notifications for tracked tasks count as wake evidence: a - // wake-eligible local_bash task (eligibility set, not the Waiting - // roster), or a session-registered subagent that is still running - // (Agent with run_in_background settling after the root turn). A - // stray notification for an unknown task is dropped as before - // instead of triggering a spurious continuation. const isPendingTaskNotification = isNotification && (yield* isWakeEligibleOpaqueBackgroundTaskOnNativeThread( @@ -6411,9 +6062,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( )); const bufferedMessages = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId)?.messages ?? []; - // A nested subagent's end goes to the subagent that started it, not - // the root. It is buffered so the next drain completes its card, but - // it is not a wake. const isNestedSubagentNotification = isNotification && !isPendingTaskNotification && @@ -6437,13 +6085,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message.type === "system" && message.subtype === "task_started" && !isClaudeNonSubagentTask(message); - // A task_started for a session-registered subagent that races past - // settle is a resume (SendMessage to a completed subagent re-emits - // task_started with the same task id). Re-open the registry entry - // so the resumed run pins idle and its eventual notification counts - // as wake evidence again, and buffer the frame so the continuation - // drain re-opens the projection row; it does not itself offer a - // continuation. const isKnownSubagentTaskStarted = message.type === "system" && message.subtype === "task_started" && @@ -6474,10 +6115,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); } - // Rate-limit frames park with the wake output so the drain can - // replay them to the turn that was still starting when they - // arrived; the offer gate below keeps them from requesting a - // continuation on their own. const isWakeTurnStart = isClaudeTurnStartMessage(message); const isWakeEvidence = isPendingTaskNotification || @@ -6531,25 +6168,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); return updated; }); - // First idle opaque notification: consume wake eligibility so a - // duplicate cannot re-buffer, and leave a short-lived replay - // tombstone for continuation-drain classification. if (isPendingTaskNotification) { yield* consumeWakeEligibilityForBufferedNotification( wakeInput.nativeThreadId, message.task_id, ); } - // A terminal task notification can clear the Waiting roster without - // Claude dequeuing it into a native model turn. Buffer it for replay, - // but do not open an opaque-task continuation until the turn's `init`, - // or native user, assistant, or result output, proves that Claude - // actually began the wake turn. `init` comes first, so the thread - // shows working while Claude thinks instead of looking finished. - // Subagent notifications retain their existing immediate offer - // because their projected lifecycle owns the continuation. Only - // root frames prove it: a background subagent keeps streaming its - // own frames while the root is idle. + // Buffer it for replay, but do not open an opaque-task continuation until the turn's + // `init`, or native user, assistant, or result output, proves that Claude actually + // began the wake turn. Only root frames prove it: a background subagent keeps + // streaming its own frames while the root is idle. const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId); const hasBufferedNotification = buffered?.messages.some( @@ -6642,12 +6270,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* replacePendingBackgroundTasks(input.nativeThreadId, nextTasks); rosterChanged = true; } else if (message.type === "system" && message.subtype === "task_started") { - // Incremental fallback when background_tasks_changed is absent. - // Subagent tasks project as subagent turn items; only non-subagent - // background work (e.g. local_bash) lives on the provider-thread roster. - // A foreground task blocks its tool call (a subagent's own Bash - // steps included), so it is not background work; one moved to - // the background later arrives in background_tasks_changed. if (!isClaudeNonSubagentTask(message) || message.is_backgrounded === false) { return false; } @@ -6672,10 +6294,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.nativeThreadId, message.task_id, ); - // Waiting roster clears on the notification edge. Wake eligibility - // is consumed when the first idle notification is buffered; clear - // here too for same-turn active notifications that never entered - // the idle buffer path. Replay tombstones are not cleared here. yield* clearTaskIdFromNativeThreadSet( wakeEligibleBackgroundTasksByNativeThread, input.nativeThreadId, @@ -6695,8 +6313,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return true; } - // Between turns, never resurrect active status from a late empty - // roster update. During an active turn, preserve the thread status. const status = input.activeContext === null ? ("idle" as const) @@ -6711,10 +6327,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return true; }); - // A background subagent keeps working after the root turn settles. Its - // own frames and progress reach its thread and card at once, through - // the turn that settled last; root output and wake evidence still wait - // in the wake buffer for a continuation run. const settledTurnForSubagentFrame = Effect.fnUntraced(function* ( nativeThreadId: string, message: SDKMessage, @@ -6723,8 +6335,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (settled === undefined) { return null; } - // The result of a call already shown as its row only ends that row, - // so it never waits. const userResults = claudeToolResultBlocksFromUserMessage(message); if ( message.type === "user" && @@ -6736,10 +6346,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return settled; } - // A subagent's task_started still in the wake buffer registers or - // re-opens it only when a continuation drains it, and only that run - // stores the work that follows. So that subagent's own frames, and - // the result of the Agent call that launched it, wait behind it. const bufferedStarts = ( (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [] ).flatMap((entry) => @@ -6772,13 +6378,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return null; } - // A frame whose owner is named later, by progress, waits in the hold - // used during a turn, keyed by that owner, so its later frames queue - // behind it there and are released in order. if (taskId === undefined) { return parentToolUseId === null ? null : settled; } - // Without the launching call's id, any result may be its launch. if ( returnedToolUseIds.size > 0 && bufferedStarts.some((start) => start.tool_use_id === undefined) @@ -6792,8 +6394,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const handleSdkMessageFrame = Effect.fnUntraced(function* (input: { readonly query: ClaudeAgentSdkQuerySession; readonly message: SDKMessage; - // A held subagent frame replayed after its owner registered. Its - // turn-level assistant bookkeeping already ran when it arrived. readonly replayed?: boolean; }) { const liveQuery = yield* Ref.get(queryContext); @@ -6832,11 +6432,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const context = yield* Ref.get(activeTurn); if (context === null) { - // A rejected window can open the CLI's notification wake, - // before the continuation turn exists to record it on. Park - // the frame with the wake output so the drain replays it to - // the turn; dropping it here loses the reset time the - // provider just reported. yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message, @@ -6867,8 +6462,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context.rejectedRateLimitTypes.delete(limitType); context.rateLimitResetTimes.delete(limitType); } - // Rejected windows pause the SDK without ending its turn. Overage - // and warnings keep running; repeats of a window need only one notice. if (blocked) { const limitKey = `${limitType}:${rateLimitInfo.resetsAt ?? "unknown"}`; if (!context.announcedUsageLimits.has(limitKey)) { @@ -6916,12 +6509,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (yield* Ref.get(activeTurn)) ?? (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { - // A workflow is normally launched in the background, so most of its - // run happens after the turn that started it has settled. Nothing - // here can build turn items or nodes without a turn context, but the - // coordinator entity is what Lineage renders, so refresh it from the - // session registry and a live run keeps reporting its phases, members - // and usage instead of freezing at turn end. yield* applyWorkflowProgressWithoutTurn(message); // task_notification must buffer wake evidence while still tracked // on the roster; clearing first would drop the wake pin. @@ -6932,7 +6519,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message, activeContext: null, }); - // Wake delivery must not delay a known child's actual outcome. // Record wake evidence first, while the child is still running. const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id); const settled = settledTurnByNativeThread.get(liveQuery.nativeThreadId); @@ -6945,8 +6531,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( entry.subtype === "task_started" && entry.task_id === message.task_id, ) ?? false; - // Starts and workflow transcripts still need the continuation's - // context. A pending Stop must receive its native outcome now. const canApplyOutcome = subagentStopOutcomes.has(message.task_id) || (!hasBufferedStart && registered?.task.workflow === undefined); @@ -6979,7 +6563,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } yield* trackClaudeGoal({ nativeThreadId: liveQuery.nativeThreadId, context, message }); - // Subagent narration belongs to its child thread, never the parent log. if (message.type === "stream_event" && !message.parent_tool_use_id) { const event = message.event; const reasoning = context.reasoning; @@ -7045,7 +6628,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( update.followsThinking && claudeWritesUpdatesAsThinking(message.message.model) ) { - // Same item ID, so the update replaces the Thought in place. yield* emitAssistantTextArtifacts({ context, nativeItemId: update.itemId, @@ -7308,8 +6890,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( frameParentToolUseId !== null && (yield* resolveSubagentByToolUseId(context, frameParentToolUseId)) === undefined ) { - // Owner not registered yet: hold the frame rather than letting its - // text and tools fall back to the parent thread. const droppedToolUseId = yield* Ref.modify( pendingSubagentFramesByToolUseId, (current) => { @@ -7404,7 +6984,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // With no model of its own, a nested subagent runs on its // owner's (the SDK's default for a subagent's Agent call). const model = launch?.model ?? owner?.task.model ?? undefined; - // A nested subagent's end goes to its owner, so it never wakes the root. if ( message.is_backgrounded === true && !(yield* isNestedSubagentTask(message.task_id)) @@ -7420,12 +6999,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolUseId: message.tool_use_id, title: message.description, }); - // A workflow coordinator is marked as one the moment it starts, so - // Lineage can expand it before the first member spawns rather than - // after the first progress snapshot. const isWorkflow = claudeTaskTypeFromSdkMessage(message) === CLAUDE_WORKFLOW_TASK_TYPE; - // `meta.name` from the workflow script; only workflow tasks carry it. const workflowName = isWorkflow ? text(field(message, "workflow_name")) : undefined; yield* updateClaudeSubagentNode({ @@ -7447,8 +7022,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( status: "running", reopen: true, }); - // A resume supersedes earlier buffered starts and outcomes for this task. - // Replaying a start preserves the completion buffered after it. yield* Ref.update(wakeBuffers, (current) => { const entry = current.get(liveQuery.nativeThreadId); if (entry === undefined) return current; @@ -7476,8 +7049,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( liveQuery.nativeThreadId, message.task_id, ); - // A workflow frame is worth an update even with an empty description: - // it carries the phase plan, the member roster and the run's usage. const carriesWorkflowTelemetry = hasClaudeWorkflowSnapshot(message); const isWorkflowCoordinator = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)?.task.workflow !== @@ -7499,8 +7070,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } if (message.type === "system" && message.subtype === "task_notification") { - // A live outcome supersedes earlier idle starts. During replay only - // remove frames before this notification, preserving a later resume. if ( !appliedSubagentOutcomes.has(message) && (yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id) @@ -7511,16 +7080,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message, ); } - // A wake-replay turn has empty ignoredTaskIds, so opaque-task - // tracking (live roster, wake eligibility, or the short-lived - // post-buffer replay tombstone) classifies local_bash before any - // subagent handling. const wasBackgroundTask = yield* isKnownOpaqueBackgroundTaskOnNativeThread( liveQuery.nativeThreadId, message.task_id, ); - // Backgrounded work that ends during a user turn wakes the root after - // it. Drained wake frames replay here too; they were recorded idle. if (!isClaudeProviderContinuationTurn(context.input)) { if (wasBackgroundTask) { yield* recordWakeReport( @@ -7541,7 +7104,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); } } - // A resume starts the subagent again and says again whether it is backgrounded. yield* Ref.update(backgroundedSubagentTaskIds, (current) => { if (!current.has(message.task_id)) return current; const updated = new Set(current); @@ -7562,7 +7124,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context, taskId: message.task_id, ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), - // Terminal usage totals arrive here, after the last snapshot. workflowFrame: message, nativeOutcome: message, result: message.summary, @@ -7574,8 +7135,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : "failed", }); } - // Replay tombstone only needs to outlive buffering until this - // drained/live classification runs; drop it so it cannot leak. if (wasBackgroundTask) { yield* clearOpaqueBackgroundTaskReplayTombstone( liveQuery.nativeThreadId, @@ -7633,20 +7192,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) { const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id); const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output)); - // Independent of the branches below: unlike the Agent tool, a - // Workflow tool_use is an ordinary tool call and so lands in - // toolCalls, which skips the subagent branch entirely. Its launch - // acknowledgement is the only carrier of the run's filesystem - // handles, so harvest them wherever the result arrives. if (subagent !== undefined && runHandles !== undefined) { yield* updateClaudeSubagentNode({ context, taskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id), toolUseId: toolResult.tool_use_id, workflowRunHandles: runHandles, - // The acknowledgement arrives at launch, so the coordinator - // is still running; a late one is dropped by the same - // terminal protection that guards every other update. status: "running", }); } @@ -7719,7 +7270,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* stopTaskOutputTail(toolCall.nativeItemId); yield* emitToolCallArtifacts(artifacts); toolCallsFor(context, toolCall).delete(toolCall.nativeItemId); - // A subagent's task tools must not replace the parent's list. if (parentToolUseId === null && !isClaudeToolResultError(toolResult)) { const taskLists = yield* Ref.get(taskListsByNativeThread); const taskList = applyClaudeTaskTool( @@ -7733,7 +7283,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( taskListsByNativeThread, new Map(taskLists).set(liveQuery.nativeThreadId, taskList), ); - // One list per turn, updated in place; a later turn supersedes it. yield* emitClaudePlanProjection({ context, nativeItemId: `claude-tasks:${context.providerTurnId}`, @@ -7746,8 +7295,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const assistantParentToolUseId = parentToolUseIdFromSdkMessage(message); if (message.type === "assistant" && assistantParentToolUseId !== null) { - // Claude never streams subagent output, so each thinking block - // arrives whole in its own snapshot and lands in the child thread. const thinking = message.message.content.flatMap((block) => block.type === "thinking" && block.thinking.trim().length > 0 ? [block.thinking] : [], ); @@ -7798,14 +7345,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( assistantText.text.length > 0 && assistantParentToolUseId !== null ) { - // Subagent text belongs to the subagent's child thread, never the - // parent transcript. Unowned frames were already held above. const subagent = yield* resolveSubagentByToolUseId(context, assistantParentToolUseId); if (subagent === undefined) { return; } const now = yield* DateTime.now; - // One native message arrives as one snapshot per content block. const nativeMessageId = message.type === "assistant" && typeof message.message.id === "string" ? message.message.id @@ -7835,25 +7379,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } - // A zero-turn task-notification-origin result is almost always - // lifecycle debris, so it must not finalize a normal turn or supply - // fallback assistant text. Provider continuation turns still consume - // it when draining buffered wake messages. - // - // "Almost always" is the honest word: num_turns is a workload count, - // not a causal link to the prompt this turn is waiting on. A genuine - // wake that fails before any model turn also reports zero, and is - // dropped here, so that turn hangs until query exit. Narrowing the - // drop to zero-turn results only shrinks a pre-existing drop; closing - // the residue needs a correlation id on the result, which the wire - // does not carry today. if ( isClaudeTaskNotificationOriginResult(message) && !isClaudeProviderContinuationTurn(context.input) && message.num_turns === 0 ) { - // Routine lifecycle noise, so debug rather than warning: interrupt - // recovery produces this every time. yield* Effect.logDebug("orchestration-v2.claude-task-notification-result-dropped", { providerTurnId: context.providerTurnId, num_turns: message.num_turns, @@ -7867,16 +7397,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } - // The converse of the drop above, and the case actually worth - // watching: a positive-turn task-notification result settling a turn - // T3 did not mark as a continuation. That is the hang fix working, - // but it is also the shape a stale result would take if one ever - // carried model turns, which nothing on the wire lets us rule out. if ( isClaudeTaskNotificationOriginResult(message) && !isClaudeProviderContinuationTurn(context.input) ) { - // This user turn ran the wake itself, so no offer will name its work. yield* clearWakeReports(liveQuery.nativeThreadId); yield* Effect.logInfo("orchestration-v2.claude-task-notification-result-accepted", { providerTurnId: context.providerTurnId, @@ -7890,8 +7414,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); } - // Failed result text belongs on the terminal-failure item, including - // structured failures whose SDK result still has is_error=false. const resultText = message.type === "result" && ((message.subtype === "success" && message.is_error) || @@ -7962,9 +7484,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); - // Held subagent frames whose owner this lifecycle frame resolves. The - // frame's (task_id, tool_use_id) pair is authoritative, so it also - // fills the tool-use index when task_started lacked a tool_use_id. const takeReleasableSubagentFrames = Effect.fnUntraced(function* (message: SDKMessage) { if ( message.type !== "system" || @@ -7996,21 +7515,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly query: ClaudeAgentSdkQuerySession; readonly message: SDKMessage; }) { - // Progress or a notification can be the first frame naming a known - // subagent's tool_use_id; its held frames must precede the result. if (input.message.type !== "system" || input.message.subtype !== "task_started") { for (const message of yield* takeReleasableSubagentFrames(input.message)) { yield* handleSdkMessageFrame({ query: input.query, message, replayed: true }); } } yield* handleSdkMessageFrame(input); - // task_started registers its subagent while being handled. for (const message of yield* takeReleasableSubagentFrames(input.message)) { yield* handleSdkMessageFrame({ query: input.query, message, replayed: true }); } }); - // Root tool_use ids among a turn's held frames. const heldToolUseIds = (context: ActiveClaudeTurnContext): ReadonlySet => new Set( context.heldRootFrames.flatMap((frame) => @@ -8020,7 +7535,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ); - // Gives held root output to the pending prompt turn. const releaseHeldRootFrames = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, ) { @@ -8035,19 +7549,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); - // A prompt offered while Claude has a turn of its own queued (a - // background task or subagent finished, a peer message arrived, a - // resume reports work the previous process left) is answered only - // after that turn runs, and the two look alike until each turn's - // result. Claude echoes the prompt's uuid on the turn that answers - // it, so a result for another turn (see isClaudeResultForOtherTurn) - // never settles this one. On a CLI that echoes on the turn's first - // frame, root output that arrives before the echo is held: the echo - // releases it to this turn, and another turn's result sends it to the - // wake buffer and so to a continuation run. The echo lands on the - // turn's first frame, so the prompt's own turn streams without delay. - // A CLI that echoes only on the result (or not at all) is never held, - // exactly as before this gate. const handleSdkMessage = Effect.fnUntraced(function* (input: { readonly query: ClaudeAgentSdkQuerySession; readonly message: SDKMessage; @@ -8106,10 +7607,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } if (!isClaudePromptEchoGatedFrame(message)) { - // Frames the held turn produced through its own tool uses (a - // subagent it launched, its task lifecycle) follow that turn; - // anything else, such as an earlier subagent still streaming, - // keeps its own owner. if ( context.heldRootFrames.length > 0 && claudeFrameToolUseIds(message).some((id) => heldToolUseIds(context).has(id)) @@ -8130,11 +7627,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }) ) { const held = context.heldRootFrames.splice(0); - // The prompt's own turn follows, so echo timing is learned from it alone. context.gatedFramesBeforeEcho = 0; if (message.num_turns === 0) { - // Lifecycle debris, not a turn: any held output stays held for - // its real owner. context.heldRootFrames.push(...held); yield* Effect.logDebug("orchestration-v2.claude-result-for-other-turn-dropped", { providerTurnId: context.providerTurnId, @@ -8144,8 +7638,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } if (liveQuery.promptEchoMode === "early") { - // The held turn ran before this prompt's turn, which still - // follows on the same stream. yield* Effect.logInfo("orchestration-v2.claude-wake-turn-before-prompt", { providerTurnId: context.providerTurnId, origin: message.origin?.kind, @@ -8163,7 +7655,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // stays out: settling here would end the turn before its prompt // runs. The prompt's own turn ends it with an echoing result. if (isClaudeTaskNotificationOriginResult(message)) { - // This turn showed the wake, so no offer will name its work. yield* clearWakeReports(liveQuery.nativeThreadId); } yield* Effect.logInfo("orchestration-v2.claude-result-for-other-turn", { @@ -8182,8 +7673,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context.heldRootFrames.push(message); return; } - // A result from no other turn (an interrupt, a startup failure) - // settles the prompt's turn. yield* releaseHeldRootFrames(context); yield* handleRoutedSdkMessage(input); }); @@ -8204,11 +7693,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const nativeRequestId = callbackOptions.toolUseID; const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); - // While root output awaits its prompt echo, the tool's streamed - // frames are held and may belong to a queued wake turn, so the - // callback must not attach anything to the pending prompt turn: - // the held tool_use frame starts the tool (and projects an - // ExitPlanMode plan) in whichever run it is released to. const heldForEcho = context.heldRootFrames.length > 0; if (toolName === "Agent") { rememberClaudeSubagentLaunch( @@ -8319,9 +7803,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (toolName === "ExitPlanMode") { const markdown = claudeProposedPlan(nativeToolInput); - // Defer only while the tool_use frame itself is still held, so - // the plan is published when that frame is handled; otherwise - // publish now. Claude is told the plan was captured either way. if (markdown !== null && heldToolUseIds(context).has(nativeRequestId)) { heldProposedPlansByToolUseId.set(nativeRequestId, markdown); } else if (markdown !== null) { @@ -8495,8 +7976,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ); - // Work the live process still runs. A subagent whose completion is - // already buffered is done: the buffer outlives the process. const liveProcessRunsBackgroundWork = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, ) { @@ -8552,8 +8031,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return existing; } - // Model, effort, ultracode and fast mode change on the live process. - // A restart would kill its background agents and shells. if ( existing !== null && existing.nativeThreadId === nativeThreadId && @@ -8567,8 +8044,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.andThen( live.query.applyFlagSettings(claudeLiveSelectionFlagSettings(compiledSelection)), ), - // A partial change leaves the process state unknown. A blank - // identity matches no selection, so the next turn sends both again. Effect.tapError(() => Effect.sync(() => { live.selection = { ...live.selection, queryIdentity: "" }; @@ -8593,11 +8068,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return existing; } - // Background agents and shells run inside the CLI process, so a - // new selection would kill them and lose their results. Refuse until - // they finish or the user presses Stop, which closes the process. - // Another native thread on this session is one the app thread has - // left (Claude sessions serve one app thread), so it is replaced. + // Background agents and shells run inside the CLI process, so a new selection would + // kill them and lose their results. Refuse until they finish or the user presses Stop, + // which closes the process. if ( existing !== null && existing.nativeThreadId === nativeThreadId && @@ -8607,11 +8080,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); } - // openQuery owns one live process. Closing it for another native - // thread kills that sibling's CLI; it can never emit a roster clear, - // so drop its process-scoped Waiting/wake state immediately. Closing - // for the same native thread leaves a non-authoritative roster until - // the replacement open succeeds or fails below. const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; if (existing !== null) { yield* existing.query.close.pipe(Effect.ignore); @@ -8627,8 +8095,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // openedNativeThreads is per session instance and is lost when the // provider session is idle-released. A prior turn on this native id // requires resume; sessionId would fail with "already in use". - // A fresh-session fallback keeps provider-thread history but binds - // a new native id, which must be created before it can be resumed. const hasPersistedProviderTurn = turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; const shouldResume = @@ -8666,12 +8132,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( options: queryOptions, }) .pipe( - // An interrupted open leaves the old process just as dead. Effect.onError(() => - // Same-native-thread replacement: the old process is already - // dead, so its process-scoped roster is not authoritative. - // First-ever failed open (no prior live query) must not invent - // native-session reset events. closedExistingNativeThreadId === nativeThreadId ? Effect.gen(function* () { yield* clearWakeStateForNativeThread(nativeThreadId); @@ -8693,13 +8154,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.add(nativeThreadId); return updated; }); - // Level is per CLI process: reset Waiting roster and wake - // eligibility whenever this native thread's process starts or is - // replaced. Membership repopulates on the next snapshot/edge. - // openQuery only runs from startTurn after ProviderTurnStartService - // marked the provider thread active, and before activeTurn is set. - // Buffered local_bash task_notification classification is preserved - // across this reset (see resetBackgroundTaskStateForNativeThreadProcess). yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { status: "active", }); @@ -8740,9 +8194,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.exit, Effect.flatMap( Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { - // Output held for a prompt echo that never came still - // belongs to the turn this stream ended in. A replaced - // query's exit must not touch the next query's turn. const heldContext = yield* Ref.get(activeTurn); if ( (yield* Ref.get(queryContext))?.query === querySession && @@ -8858,10 +8309,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( gatedFramesBeforeEcho: 0, heldRootFrames: [], }; - // Continuation turns attach to the wake output the CLI already - // produced instead of prompting it again: drain the buffered wake - // messages into this turn and let any still-streaming messages - // follow live. The continuation prompt text never reaches the CLI. const userMessage = promptUuid === null ? null @@ -8892,10 +8339,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); if (userMessage !== null) { yield* attachTurn; - // A user turn that races a wake leaves the buffer alone: the - // continuation run the worker queued behind this run drains it - // afterwards with correct attribution. - // Counted only here, so a turn that failed to start does not age reports. yield* startUserTurnForWakeReports(nativeThreadId); yield* querySession.query.offer(userMessage); return; @@ -8913,10 +8356,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( detail: "Claude provider query ended before the continuation could start.", }); } - // Live frames can finish while this continuation waits for the permit. yield* attachTurn; - // Exit and interrupt cleanup can run during transcript reads. - // Keep pending outcomes visible until replay applies them. const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; yield* Ref.update(requestedContinuations, (current) => { const updated = new Set(current); @@ -8924,9 +8364,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return updated; }); if (drained.length === 0) { - // Spurious continuation (buffer already lost with a recycled - // session, or a duplicate request): settle immediately instead - // of leaving a run waiting on a prompt that was never sent. const completedAt = yield* DateTime.now; yield* finalizeActiveTurn({ context, status: "completed", completedAt }); return; @@ -8959,7 +8396,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (buffered === undefined) return current; const index = buffered.messages.indexOf(entry); if (index === -1) return current; - // An idle handler may requeue this same frame for the next continuation. const messages = [ ...buffered.messages.slice(0, index), ...buffered.messages.slice(index + 1), @@ -8975,8 +8411,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* handleSdkMessage({ query: querySession.query, message: lastResult }); return; } - // A drained `init` means Claude began the wake turn, so its output - // may still be on the way: stay open for it. const hasNativeWakeFrame = drained.some( (entry) => entry.type === "user" || @@ -9011,10 +8445,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const currentTurn = yield* Ref.get(activeTurn); const nativeThreadId = turnInput.providerThread.nativeThreadRef?.nativeId ?? null; if (currentTurn === null && turnInput.requestRuntimeRestart === true) { - // Stop after the turn settled. With no CLI process of this - // native thread left, nothing it started is still running: its - // roster is not authoritative any more, and the orchestrator - // settles the items the thread still shows. if (nativeThreadId === null) return; if (existing === null || existing.nativeThreadId !== nativeThreadId) { yield* clearWakeStateForNativeThread(nativeThreadId); @@ -9023,11 +8453,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); return; } - // The background shells belong to the CLI process, so closing - // its query is what stops them. yield* closeLiveQueryForNativeThread(nativeThreadId); - // A turn started while the close was pending may have opened a - // replacement process. Its Waiting and wake state are its own. const current = yield* Ref.get(queryContext); if (current === null || current.query === existing.query) { yield* clearWakeStateForNativeThread(nativeThreadId); @@ -9077,7 +8503,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* finalizeActiveTurnAfterQueryExit(existing); yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted"); } else { - // The stream may already be finalizing while another process starts. yield* finalizeActiveTurn({ context: currentTurn, status: "interrupted", @@ -9104,7 +8529,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // running tool calls. A subagent the root launched in the foreground is // one of them, so it moves to the background first and keeps running; // its result arrives as a task notification like any background agent. - // Subagents a subagent launched stay put: they follow their owner. const backgroundForegroundSubagents = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, ) { @@ -9119,19 +8543,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( subagent.task.status !== "running" || subagent.task.parentNodeId !== subagent.rootNodeId || live.subagentsFromEarlierProcesses.has(subagent) || - // A nested launch whose owner was not known yet falls back to - // the root as its parent node; its spawn depth still marks it. (yield* isNestedSubagentTask(taskId)) ) { continue; } foreground.push({ taskId, toolUseId }); } - // Backgrounding is refused when the session disables background - // tasks, and the SDK puts no deadline on the request. Either way the - // steer still goes through, as it did before. A move answered after - // the steer stopped waiting for it still marks the subagent, and a - // later steer waits on that same move instead of asking again. for (const [toolUseId, move] of backgroundMovesByToolUseId) { if (move.pollUnsafe() !== undefined) backgroundMovesByToolUseId.delete(toolUseId); } @@ -9143,15 +8560,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( continue; } const move = yield* live.query.backgroundTasks(toolUseId).pipe( - // Now a background subagent: its end wakes the root like one - // Claude started in the background. One that already ended - // while the move was answered has nothing left to report. Effect.flatMap((moved) => moved ? Ref.get(sessionSubagentsByTaskId).pipe( Effect.flatMap((registry) => registry.get(taskId)?.task.status === "running" && - // Not a later run of it that started meanwhile. registry.get(taskId)?.runToolUseId === toolUseId ? Ref.update(backgroundedSubagentTaskIds, (current) => new Set(current).add(taskId), @@ -9272,8 +8685,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( providerSessionId: input.providerSessionId, nativeThreadId, }); - // Once ownership is dropped here, the old stream's exit skips the - // subagent's open calls, so they end now. const ownedLiveQuery = yield* Ref.modify(queryContext, (current) => current?.query === existing.query ? [true, null] : [false, current], ); @@ -9293,10 +8704,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( getModelContextWindow: (selection) => resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection), events: Stream.fromEffectRepeat(Queue.take(events)), - // Background shells, agents, monitors, and workflows end with the CLI. backgroundTasksEndWithProcess: true, hasPendingBackgroundWork: Effect.gen(function* () { - // Session capability: any native thread with pending work pins idle. for (const roster of (yield* Ref.get(pendingBackgroundTasksByNativeThread)).values()) { if (roster.size > 0) { return true; @@ -9328,8 +8737,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (nativeThreadId === undefined || nativeThreadId === null) { return false; } - // Root-run stop gate: only this native thread's roster. Session - // subagents and wake buffers stay on the session-wide probe. return ( rosterForNativeThread( yield* Ref.get(pendingBackgroundTasksByNativeThread), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 2533e356e8f9..a691d7e8459a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -95,7 +95,6 @@ const realQueryRunner = layerQueryRunner.pipe( Layer.provide(NodeServices.layer), ); -/** Writes an executable stand-in for the Claude CLI into a fresh directory. */ function fakeClaudeCli(script: string): string { const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-cli-")); const cli = NodePath.join(dir, "claude"); @@ -121,9 +120,8 @@ const openFakeCli = (cli: string) => describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI process", () => { // The CLI stops the MCP servers it started, but not their children: a - // plugin's `bun server.ts` outlives its `bun run` wrapper (#3089). The fake - // CLI leaves a child running the same way. The child holds a FIFO open, so - // its death reads as end-of-file here, with no polling. + // plugin's `bun server.ts` outlives its `bun run` wrapper (#3089). The child + // holds a FIFO open, so its death reads as end-of-file here, with no polling. it.effect("stops what the CLI left running when the session closes", () => Effect.gen(function* () { const cli = fakeClaudeCli( @@ -138,7 +136,6 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "alive")]); yield* Effect.addFinalizer(() => Effect.sync(() => { - // Only does anything when the test fails: do not leak its child. try { process.kill(Number(NodeFS.readFileSync(NodePath.join(dir, "leftover.pid"), "utf8"))); } catch {} diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 8f6d0edb7eb8..6e4a6494fb78 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -473,7 +473,6 @@ describe("CodexAdapterV2 runtime policy", () => { assert.deepEqual(alone.additionalContext, { "mcp_app_todos_list_todos_item-1": { kind: "untrusted", value: "Filtered to overdue" }, }); - // Alongside T3's own context, both are kept. const withT3 = yield* CodexAdapterV2.buildCodexTurnStartParams({ nativeThreadId: "native-app-context", codexInput: [{ type: "text", text: "what's on my list?" }], @@ -616,7 +615,6 @@ describe("CodexAdapterV2 runtime policy", () => { assert.equal(params.collaborationMode?.settings.model, "gpt-5.4"); assert.equal(params.collaborationMode?.settings.reasoning_effort, "xhigh"); - // ChatGPT token sharing rejects service tiers, so managed sessions drop a stale pick. const managed = yield* CodexAdapterV2.buildCodexTurnStartParams({ nativeThreadId: "native-model-options", codexInput: [{ type: "text", text: "test" }], @@ -1283,7 +1281,6 @@ describe("CodexAdapterV2 rollback mapping", () => { const secondTurn = providerTurn("provider-turn-second", 2, "completed"); const runningTurn = providerTurn("provider-turn-running", 3, "running"); const interruptedTurn = providerTurn("provider-turn-interrupted", 4, "interrupted"); - // `/goal` control turns never reached Codex, so revert must not count them. const goalCommandTurn = { ...providerTurn("provider-turn-goal-command", 5, "completed"), nativeTurnRef: null, @@ -1610,7 +1607,6 @@ function codexReplayPreamble(input: { readonly nativeThreadId: string; readonly nativeTurnId: string; readonly prompt: string; - /** Text the adapter should send, when it differs from what the user typed. */ readonly sentPrompt?: string; }): Array { return [ @@ -1850,7 +1846,6 @@ describe("CodexAdapterV2 session initialize", () => { .ensureThread("thread-concurrent-first") .pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(initializeAwaitingResponse); - // The second thread arrives while the handshake is still unanswered. const second = yield* session .ensureThread("thread-concurrent-second") .pipe(Effect.forkChild({ startImmediately: true })); @@ -1911,8 +1906,6 @@ describe("CodexAdapterV2 session initialize", () => { scenario: "initialize-interrupted", entries: [ ...preamble.slice(0, 2), - // Codex handled the interrupted caller's `initialize`, so it - // rejects the next one. ...preamble.slice(0, 1).map((entry) => withReplayRequestId(entry, 2)), { type: "emit_inbound", @@ -2214,8 +2207,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { if (method === "initialize") initializeParams.push(params); }), ); - // Codex uses clientInfo.name as the request originator. Replays ignore the - // version, so pin the whole value here. assert.deepEqual(initializeParams, [ { clientInfo: { name: "T3 Code", title: "T3 Code", version: packageJson.version }, @@ -2247,14 +2238,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { const transcript = makeCodexReplayTranscript({ scenario: "unload-thread", entries: [ - // initialize + thread/start only; no turn runs. ...preamble.slice(0, 5), { type: "expect_outbound", label: "thread/unsubscribe", frame: { id: 3, method: "thread/unsubscribe", params: { threadId: nativeThreadId } }, }, - // Response shape recorded from codex app-server 0.156.1. { type: "emit_inbound", label: "thread/unsubscribe", @@ -2906,10 +2895,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { itemId: "first-command", decision: "acceptForSession", }), - // Only commands were allowed, so a file change still asks. The user - // answers it after the turn ended and the mode changed. patchRequest!, - // Answered without asking: Codex alone would ask for every new command. ...approval({ id: 101, turnId: turnIds[0]!, @@ -2922,7 +2908,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { patchDecision!, turnEvent("turn/completed", turnIds[1]!), ...startTurn(2), - // Back in the first mode, neither allowance comes back. ...approval({ id: 103, turnId: turnIds[2]!, @@ -3903,7 +3888,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { const nativeThreadId = `native-${scenario}-thread`; const nativeTurnId = `native-${scenario}-turn`; const prompt = "Plan the change."; - // 100 deltas, 20 ms apart: a two second stream. const deltas = Array.from({ length: 100 }, (_, index) => `step ${index}\n`); const transcript = makeCodexReplayTranscript({ scenario, @@ -3967,7 +3951,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { ? [event.turnItem] : [], ); - // One update per 400 ms of the stream, plus the completion. assert.isAtLeast(plans.length, 2); assert.isAtMost(plans.length, 7); assert.equal(planItems.length, plans.length); @@ -4692,8 +4675,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - // "thread_unloaded": the thread was settled, so T3 unsubscribed and Codex - // unloaded it (killing its terminals) before Stop arrived. const backgroundStopCases = [true, false, "still_running", "thread_unloaded"] as const; const makeBackgroundStopTranscript = (terminated: (typeof backgroundStopCases)[number]) => { const stillRunning = terminated === "still_running"; @@ -4980,10 +4961,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }); - // The app-server exits after the root turn, before the command's own - // item/completed (Codex always sends one, so only a lost notification or a - // gone process leaves it running). Nothing tracks the command any more, yet - // the thread still shows it, and Stop is the only way to clear it. it.effect("Stop ends a background command no Codex process tracks any more", () => Effect.scoped( Effect.gen(function* () { @@ -5468,8 +5445,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.isFalse(yield* harness.hasPendingBackgroundWork); assert.lengthOf(harness.continuationRequests, 0); - // Late provider item/completed after interrupt must not revive the card - // or request a background-command wake continuation. yield* TestClock.adjust("30 seconds"); for (let attempt = 0; attempt < 100; attempt++) { yield* Effect.yieldNow; @@ -7046,7 +7021,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { const item = yield* Deferred.await(appCaptured); assert.equal(harness.terminalEvents()[0]?.status, "completed"); - // The completed tool row is emitted once, with the app, never as a bare row first. const completedRows = harness.events.filter( (event) => event.type === "turn_item.updated" && @@ -7080,7 +7054,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { attachmentId: reference!.attachmentId, })!, ); - // Stored with the app's declared policy ahead of its scripts. assert.include(stored, "connect-src https://api.weather.test"); assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf("")); yield* Effect.yieldNow; @@ -771,9 +760,7 @@ describe("HookForwarder", () => { expect(hook?.query).toBe("a=1"); expect([...(hook?.body ?? [])]).toEqual([0, 255, 10]); expect(hook?.headers["x-sig"]).toBe("s"); - // Every spelling of the hook id shares one per-hook cap. expect(hook?.hookKey).toBe("hook-1"); - // The sender cannot choose the delivery id. expect(hook?.headers["x-t3-relay-delivery-id"]).toBeUndefined(); expect(hook?.id).not.toBe("forged"); }), diff --git a/infra/relay/src/hooks/HookForwarder.ts b/infra/relay/src/hooks/HookForwarder.ts index 5fffd86cda95..65bed83d758b 100644 --- a/infra/relay/src/hooks/HookForwarder.ts +++ b/infra/relay/src/hooks/HookForwarder.ts @@ -71,23 +71,20 @@ const DROPPED_REQUEST_HEADER_PREFIXES = ["proxy-", "cf-", "x-forwarded-", "x-b3- export const isRelayHookPath = (url: string): boolean => url.startsWith(RELAY_HOOK_PATH_PREFIX); -/** Replaces the hook token (and any query) so traces and logs never record the secret. */ export const redactRelayHookUrl = (url: string): string => { const path = url.split("?", 1)[0] ?? url; const segments = path.split("/"); if (segments.length >= 6) { - // URL-safe, so span attributes built from the parsed URL keep it readable. segments[5] = "redacted"; } return segments.join("/"); }; /** - * Request budget for public hook forwarding, keyed by a hash of the hook URL - * (endpoint, hook and token). Requests with a wrong token get their own - * budget, so they cannot use up a real sender's; the environment rejects them. - * Built from decoded segments, because the environment decodes them too: two - * spellings of one token (`token`, `%74oken`) must share one budget. + * Requests with a wrong token get their own budget, so they cannot use up a + * real sender's; the environment rejects them. Built from decoded segments, + * because the environment decodes them too: two spellings of one token + * (`token`, `%74oken`) must share one budget. */ const hookBudgetKey = (hook: { readonly endpointKey: string; @@ -113,9 +110,7 @@ const hookBudgetKey = (hook: { export class HookRateLimiter extends Context.Service< HookRateLimiter, { - /** One hook URL's budget, keyed by `hookBudgetKey`. */ readonly allowHook: (key: string) => Effect.Effect; - /** One endpoint's overall budget, keyed by its endpoint key. */ readonly allowEndpoint: (endpointKey: string) => Effect.Effect; } >()("t3code-relay/hooks/HookForwarder/HookRateLimiter") {} @@ -156,7 +151,6 @@ function parseHookPath(url: string) { const path = queryIndex === -1 ? url : url.slice(0, queryIndex); const search = queryIndex === -1 ? "" : url.slice(queryIndex); const segments = path.split("/"); - // ["", "v1", "hooks", endpointKey, hookId, token] if (segments.length !== 6) return null; const [, , , endpointKey, rawHookId, rawToken] = segments; if (!endpointKey || !MANAGED_ENDPOINT_KEY_PATTERN.test(endpointKey) || !rawHookId || !rawToken) { @@ -277,7 +271,6 @@ const make = Effect.gen(function* () { yield* outcome("method_not_allowed"); return errorResponse(405, "method_not_allowed", { allow: "GET, POST, PUT, PATCH" }); } - // When the sender called, not when the environment failed to answer. const receivedAt = DateTime.formatIso(yield* DateTime.now); const parsed = parseHookPath(request.url); if (!parsed) { @@ -288,13 +281,10 @@ const make = Effect.gen(function* () { "relay.hook.endpoint_key": parsed.endpointKey, "relay.hook_id": parsed.hookId, }); - // A coarse budget per endpoint first, so minting new hook ids or tokens - // cannot buy unlimited lookups and forwards, or fill the inbox. const endpointAllowed = yield* rateLimiter.allowEndpoint(parsed.endpointKey); const hookAllowed = endpointAllowed && (yield* rateLimiter.allowHook(yield* hookBudgetKey(parsed))); if (!endpointAllowed || !hookAllowed) { - // Which budget ran out: the whole endpoint's, or this one hook URL's. yield* Effect.annotateCurrentSpan({ "relay.hook.rate_limit": endpointAllowed ? "hook" : "endpoint", }); @@ -364,7 +354,6 @@ const make = Effect.gen(function* () { headers: { ...forwardedHeaders(request.headers), [RELAY_HOOK_DELIVERY_HEADER]: proof }, body: body.success, }; - // Held only for environments that opted in; otherwise the relay is a plain proxy. const holdOrFail = (status: 503 | 504, error: string) => Effect.gen(function* () { if (!endpoint.holdWhileOffline) { @@ -390,7 +379,6 @@ const make = Effect.gen(function* () { return errorResponse(status, error); } if (!stored) { - // The inbox span carries which cap refused it (relay.inbox.refused). yield* outcome("inbox_full"); return errorResponse(503, "inbox_full"); } @@ -421,8 +409,6 @@ const make = Effect.gen(function* () { ? {} : { "relay.hook.upstream_outcome": response.outcome }), }); - // Only content-type is passed through: no location (redirects are never - // followed or relayed), no cookies, no upstream infrastructure headers. const headers = { ...SANDBOXED_RESPONSE_HEADERS, ...(response.contentType ? { "content-type": response.contentType } : {}), @@ -442,11 +428,6 @@ const make = Effect.gen(function* () { export const layer = Layer.effect(HookForwarder, make); -/** - * Implements the RelayApi `hooks` group. The endpoints are raw: the forwarder - * re-reads the encoded path segments from the request so the token and hook - * id reach the environment byte for byte, and streams the body itself. - */ export const layerApi = HttpApiBuilder.group( RelayApi, "hooks", diff --git a/infra/relay/src/hooks/HookInbox.ts b/infra/relay/src/hooks/HookInbox.ts index cc3d1517df4c..2fb5fba02ce5 100644 --- a/infra/relay/src/hooks/HookInbox.ts +++ b/infra/relay/src/hooks/HookInbox.ts @@ -14,11 +14,6 @@ export class HookInboxError extends Schema.TaggedError()("HookIn } } -/** - * Webhook requests held for environments that opted in, while they are - * offline. Each managed endpoint's requests live in its own `HookInboxObject`, - * named by endpoint key, which delivers them itself once the environment is back. - */ export class HookInbox extends Context.Service< HookInbox, { @@ -28,7 +23,6 @@ export class HookInbox extends Context.Service< readonly baseUrl: string; readonly hook: HeldHook; }) => Effect.Effect; - /** Delivers what is waiting now; true when anything was waiting. */ readonly wake: (input: { readonly endpointKey: string; readonly baseUrl: string; diff --git a/infra/relay/src/hooks/HookInboxObject.ts b/infra/relay/src/hooks/HookInboxObject.ts index d5a9f455c614..2d1c8ef97682 100644 --- a/infra/relay/src/hooks/HookInboxObject.ts +++ b/infra/relay/src/hooks/HookInboxObject.ts @@ -10,7 +10,6 @@ import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HookInboxStore from "./HookInboxStore.ts"; import { sendUpstream, TUNNEL_OFFLINE_STATUS } from "./upstream.ts"; -/** Statuses that mean the environment is not there; the whole inbox waits and backs off. */ const UNREACHABLE_STATUSES = new Set([502, 503, 504, TUNNEL_OFFLINE_STATUS]); /** * Statuses that mean the environment is there but did not take this request @@ -18,32 +17,20 @@ const UNREACHABLE_STATUSES = new Set([502, 503, 504, TUNNEL_OFFLINE_STATUS]); * The environment drops a delivery id it has already run, so retrying is safe. */ const BUSY_STATUSES = new Set([429, 500]); -/** When a run itself fails, the next one is tried after this long. */ const RUN_FAILURE_RETRY_MS = 60_000; type Call = Effect.Effect; -/** - * One per managed endpoint, addressed by endpoint key. Holds webhook requests - * the environment could not take, in SQLite, and pushes them back through - * its tunnel from the alarm, oldest first, backing off while it stays away. - */ export class HookInboxObject extends Cloudflare.DurableObject< HookInboxObject, { /** Holds a request for `baseUrl`; false when the inbox is full and nothing was stored. */ readonly hold: (hook: HookInboxStore.HeldHook, baseUrl: string) => Call; - /** The environment is back at `baseUrl`: deliver what is waiting now. */ readonly wake: (baseUrl: string) => Call; readonly clear: () => Call; } >()("HookInboxObject") {} -/** - * Each call into an inbox is its own trace: the alarm has no parent, and a - * hold arrives over Durable Object RPC without the forwarding span. The - * object's name is the endpoint key, which the forward spans carry too. - */ const withInboxSpan = (name: string, inboxId: string) => (effect: Effect.Effect) => @@ -97,14 +84,11 @@ export const layer = HookInboxObject.make( // @effect-diagnostics-next-line returnEffectInGen:off return Effect.gen(function* () { const layerSql = SqliteClient.layer({ storage: state.raw.storage }); - // Inboxes are opened by endpoint key, so spans line up with the - // forward spans that held their requests. const inboxId = state.raw.id.name ?? state.raw.id.toString(); const run = (effect: Effect.Effect) => effect.pipe(Effect.provide(layerSql), Effect.orDie); yield* run(HookInboxStore.migrate); - /** Moves the alarm to `at`, unless one is already due sooner. */ const scheduleBy = (at: number) => Effect.gen(function* () { const current = yield* state.storage.getAlarm(); diff --git a/infra/relay/src/hooks/HookInboxStore.test.ts b/infra/relay/src/hooks/HookInboxStore.test.ts index 121ce84a686c..55cffb27a334 100644 --- a/infra/relay/src/hooks/HookInboxStore.test.ts +++ b/infra/relay/src/hooks/HookInboxStore.test.ts @@ -25,7 +25,6 @@ const hook = (id: string, overrides: Partial = {}) => ...overrides, })); -/** Delivers with `outcome` per request and records what the environment was sent. */ const deliverer = (outcome: (hook: HookInboxStore.HeldHook) => HookInboxStore.DeliveryOutcome) => { const sent: Array<{ readonly baseUrl: string; readonly hook: HookInboxStore.HeldHook }> = []; const send = (baseUrl: string, held: HookInboxStore.HeldHook) => @@ -53,7 +52,6 @@ describe("HookInboxStore", () => { expect(yield* HookInboxStore.deliverDue(send)).toBeNull(); expect(sent.map((entry) => entry.hook.id)).toEqual(["first", "second"]); expect(sent[0]).toEqual({ baseUrl: BASE_URL, hook: first }); - // Delivered requests are gone. expect(yield* HookInboxStore.deliverDue(send)).toBeNull(); expect(sent).toHaveLength(2); }), @@ -67,7 +65,6 @@ describe("HookInboxStore", () => { yield* HookInboxStore.hold(yield* hook("second"), BASE_URL); const offline = deliverer(() => "unreachable"); const now = yield* Clock.currentTimeMillis; - // Stops at the first failure, so order is kept. expect(yield* HookInboxStore.deliverDue(offline.send)).toBe(now + 10_000); expect(offline.sent.map((entry) => entry.hook.id)).toEqual(["first"]); expect(yield* HookInboxStore.deliverDue(offline.send)).toBe(now + 10_000); @@ -88,10 +85,8 @@ describe("HookInboxStore", () => { yield* HookInboxStore.hold(yield* hook("stuck-2", stuck), BASE_URL); const busy = deliverer((held) => (held.hookKey === "stuck" ? "busy" : "delivered")); const now = yield* Clock.currentTimeMillis; - // The other hook is delivered; the busy hook keeps its order and runs again soon. expect(yield* HookInboxStore.deliverDue(busy.send)).toBe(now); expect(busy.sent.map((entry) => entry.hook.id)).toEqual(["stuck-1", "other-1"]); - // Only the busy hook is left, so the next run waits rather than spinning. expect(yield* HookInboxStore.deliverDue(busy.send)).toBe(now + 30_000); const drained = deliverer(() => "delivered"); @@ -155,7 +150,6 @@ describe("HookInboxStore", () => { yield* HookInboxStore.deliverDue(offline.send); yield* HookInboxStore.deliverDue(offline.send); - // Past the fast phase: the environment was away a while. for (let failure = 0; failure < 20; failure++) { yield* HookInboxStore.deliverDue(offline.send); } @@ -180,7 +174,6 @@ describe("HookInboxStore", () => { for (let failure = 0; failure < 30; failure++) { yield* HookInboxStore.deliverDue(offline.send); } - // Nothing got through before the request expired. yield* TestClock.adjust(Duration.hours(25)); expect(yield* HookInboxStore.deliverDue(offline.send)).toBeNull(); @@ -211,7 +204,6 @@ describe("HookInboxStore", () => { expect(yield* HookInboxStore.hold(yield* hook(`a-${index}`), BASE_URL)).not.toBeNull(); } expect(yield* HookInboxStore.hold(yield* hook("one-too-many"), BASE_URL)).toBeNull(); - // Another hook still has room. const other = yield* hook("other", { rawHookId: "hook-2", hookKey: "hook-2" }); expect(yield* HookInboxStore.hold(other, BASE_URL)).not.toBeNull(); }), diff --git a/infra/relay/src/hooks/HookInboxStore.ts b/infra/relay/src/hooks/HookInboxStore.ts index 64049f6b86ab..ae09d84fd260 100644 --- a/infra/relay/src/hooks/HookInboxStore.ts +++ b/infra/relay/src/hooks/HookInboxStore.ts @@ -8,36 +8,27 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; -/** - * The storage and delivery rules of one environment's held webhook requests. - * Runs inside the environment's `HookInboxObject` Durable Object against its SQLite - * storage; written against `SqlClient` so tests can run it on any SQLite. - */ - -/** How long a held request waits for its environment. */ const HOOK_INBOX_TTL_MS = 24 * 60 * 60 * 1000; -/** Requests one environment may have waiting at once. */ + const HOOK_INBOX_MAX_REQUESTS = 1_000; -/** Body bytes one environment may have waiting at once. */ + export const HOOK_INBOX_MAX_BYTES = 50 * 1_048_576; /** - * Requests one hook may have waiting at once. The relay cannot check tokens, - * so this keeps junk sent to one hook id from crowding out the others. + * The relay cannot check tokens, so this keeps junk sent to one hook id from + * crowding out the others. */ export const HOOK_INBOX_MAX_PER_HOOK = 100; -/** Requests pushed per alarm run; the next run starts right away while more wait. */ + const DELIVERIES_PER_RUN = 20; -/** Requests read at a time; a run keeps reading past busy hooks' backlogs. */ + const ROWS_READ_PER_PAGE = 50; -/** Wait before trying a hook whose environment answered busy again. */ + const BUSY_RETRY_MS = 30_000; const MAX_RETRY_DELAY = Duration.minutes(10); -/** Past this many failures the schedule is at its cap, so stepping further changes nothing. */ + const MAX_COUNTED_FAILURES = 25; /** - * Delays between delivery attempts while the environment is unreachable, - * indexed by consecutive failures since it was last reached or woke us. * A wake means its tunnel just connected, but Cloudflare can take a few * minutes to route the hostname to it, so the first 3 minutes retry every * 10 s. After that the environment is likely gone again: 30 s, 1 min, 2 min, @@ -63,21 +54,14 @@ export interface HeldHook { readonly rawToken: string; /** The decoded hook id, so every spelling of one hook shares its cap. */ readonly hookKey: string; - /** Without the leading `?`. */ + readonly query: string; readonly headers: Readonly>; readonly body: Uint8Array; } -/** - * `delivered` deletes the request. `unreachable` keeps it and backs off the - * whole inbox, since nothing else will get through either. `busy` keeps it - * and its hook's later requests for the next run, but lets other hooks' - * requests go ahead, so one stuck task cannot hold up the rest. - */ export type DeliveryOutcome = "delivered" | "busy" | "unreachable"; -/** The `retrySchedule` delay after `failures` consecutive unreachable attempts. */ export const retryDelayMs = Effect.fn("HookInboxStore.retryDelayMs")(function* (failures: number) { const step = yield* Schedule.toStep(retrySchedule); let delay = MAX_RETRY_DELAY; @@ -88,7 +72,7 @@ export const retryDelayMs = Effect.fn("HookInboxStore.retryDelayMs")(function* ( ) { const next = yield* step(0, undefined).pipe( Effect.map(([, duration]) => Option.some(duration)), - // The schedule never ends; stay at the cap if it ever does. + Pull.catchDone(() => Effect.succeedNone), ); if (Option.isNone(next)) break; @@ -138,7 +122,7 @@ export const migrate = Effect.gen(function* () { ) `; yield* sql`CREATE INDEX IF NOT EXISTS held_hooks_hook ON held_hooks (hook_key)`; - // Where to push, and how many attempts in a row have failed. One row. + yield* sql` CREATE TABLE IF NOT EXISTS held_hooks_target ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -174,8 +158,8 @@ const resetFailures = Effect.gen(function* () { }); /** - * Counts one more unreachable attempt. Incremented in place, so a wake that - * reset the count while this run's request was in flight is not overwritten. + * Incremented in place, so a wake that reset the count while this run's request + * was in flight is not overwritten. */ const countFailure = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -193,8 +177,8 @@ const hasPending = Effect.gen(function* () { }); /** - * Stores a request for later delivery to `baseUrl`. Returns the time the - * first attempt is due, or null when the inbox is full and nothing was stored. + * Returns the time the first attempt is due, or null when the inbox is full and + * nothing was stored. */ export const hold = Effect.fn("HookInboxStore.hold")(function* (hook: HeldHook, baseUrl: string) { const sql = yield* SqlClient.SqlClient; @@ -234,7 +218,6 @@ const backlogSize = Effect.gen(function* () { return rows[0] ?? { count: 0, bytes: 0 }; }); -/** Which cap refused a request, or that it was already held, for traces. */ const refusalReason = (hook: HeldHook) => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -248,10 +231,6 @@ const refusalReason = (hook: HeldHook) => return "max_per_hook"; }); -/** - * The environment is reachable again at `baseUrl`. Returns whether anything - * is waiting, so the caller can deliver right away. - */ export const wake = Effect.fn("HookInboxStore.wake")(function* (baseUrl: string) { if (!(yield* hasPending)) return false; yield* setTarget(baseUrl, { resetFailures: true }); @@ -264,12 +243,6 @@ export const clear = Effect.gen(function* () { yield* sql`DELETE FROM held_hooks_target`; }); -/** - * Pushes the oldest held requests to the environment, one at a time, in - * order per hook. Stops at the first sign the environment is unreachable; - * skips past a hook whose environment answered busy. Drops requests older - * than the TTL. Returns when the next run is due, or null when nothing is left. - */ export const deliverDue = Effect.fn("HookInboxStore.deliverDue")(function* ( send: (baseUrl: string, hook: HeldHook) => Effect.Effect, ) { @@ -280,7 +253,7 @@ export const deliverDue = Effect.fn("HookInboxStore.deliverDue")(function* ( RETURNING id `; const target = yield* readTarget; - /** Oldest held requests after `afterSeq`, skipping hooks already found busy. */ + const readPage = (afterSeq: number, skip: ReadonlySet) => sql` SELECT seq, id, received_at, method, raw_hook_id, raw_token, hook_key, query, headers, body @@ -292,7 +265,6 @@ export const deliverDue = Effect.fn("HookInboxStore.deliverDue")(function* ( let batch = yield* readPage(0, new Set()); if (batch.length === 0 || target === null) { if (target === null) yield* sql`DELETE FROM held_hooks`; - // Empty, so the next request held starts the schedule from the top. else yield* resetFailures; yield* Effect.annotateCurrentSpan({ "relay.inbox.expired": expired.length }); return null; @@ -304,7 +276,7 @@ export const deliverDue = Effect.fn("HookInboxStore.deliverDue")(function* ( let unreadable = 0; let longestWaitMs = 0; const busyHooks = new Set(); - /** What this run did, for the alarm's span; never request contents. */ + const annotateRun = (result: string) => Effect.gen(function* () { const backlog = yield* backlogSize; @@ -326,7 +298,7 @@ export const deliverDue = Effect.fn("HookInboxStore.deliverDue")(function* ( pages: while (batch.length > 0) { for (const row of batch) { if (sent === DELIVERIES_PER_RUN) break pages; - // Later requests to a busy hook wait their turn, so its order is kept. + if (busyHooks.has(row.hook_key)) continue; const headers = decodeHeaders(row.headers); if (Option.isNone(headers)) { @@ -372,7 +344,7 @@ export const deliverDue = Effect.fn("HookInboxStore.deliverDue")(function* ( yield* annotateRun("drained"); return null; } - // Everything left this run was busy: give those tasks a moment to drain. + const now = yield* Clock.currentTimeMillis; const idle = delivered === 0 && busyHooks.size > 0; yield* annotateRun(idle ? "busy" : "more_pending"); diff --git a/infra/relay/src/hooks/upstream.ts b/infra/relay/src/hooks/upstream.ts index 9871317f99bf..902cc3f3b80d 100644 --- a/infra/relay/src/hooks/upstream.ts +++ b/infra/relay/src/hooks/upstream.ts @@ -11,17 +11,13 @@ import type * as HttpClientResponse from "effect/http/HttpClientResponse"; import { withoutRedirects } from "../environments/EnvironmentConnector.ts"; -/** Set by the relay on every forward; the environment uses it as the delivery id. */ const RELAY_DELIVERY_ID_HEADER = "x-t3-relay-delivery-id"; -/** When the relay received the request; the environment trusts it only with the delivery id. */ const RELAY_RECEIVED_AT_HEADER = "x-t3-relay-received-at"; -/** The environment answers with a small JSON status; anything past this is cut off. */ const MAX_RESPONSE_BYTES = 64 * 1024; export const RELAY_HOOK_UPSTREAM_TIMEOUT_MS = 8_000; // Cloudflare answers 530 when the tunnel for a hostname has no connected origin. export const TUNNEL_OFFLINE_STATUS = 530; -/** A webhook request as the relay sends it on to the environment. */ export interface UpstreamHook { readonly id: string; readonly receivedAt: string; @@ -38,17 +34,14 @@ export interface UpstreamHook { export interface UpstreamResponse { readonly status: number; readonly contentType: string | undefined; - /** What the environment did with the request (`x-t3-hook-outcome`). */ readonly outcome: string | undefined; readonly body: Uint8Array; } -/** Outcome names are short identifiers; anything else is not recorded. */ const OUTCOME_PATTERN = /^[a-z_]{1,32}$/; class ResponseTooLarge extends Schema.TaggedError()("ResponseTooLarge", {}) {} -/** Reads a response body, failing once it passes the cap rather than buffering it all. */ const readCapped = (response: HttpClientResponse.HttpClientResponse) => Effect.suspend(() => { const chunks: Array = []; @@ -69,7 +62,6 @@ const readCapped = (response: HttpClientResponse.HttpClientResponse) => } return body; }), - // A response without a body, such as a redirect, has no stream at all. Effect.catchIf( (error) => error._tag === "HttpClientError" && error.reason._tag === "EmptyBodyError", () => Effect.succeed(new Uint8Array(0)), @@ -88,8 +80,6 @@ export const sendUpstream = (baseUrl: string, hook: UpstreamHook) => // The environment's span joins this trace. Set by hand: the client span // that would propagate it is off, because it records the token in url.full. const parent = yield* Effect.currentSpan.pipe(Effect.option); - // `hook.headers` carries the signed delivery proof, set once when the - // relay received the request, so a held request sends the same proof. const headers: Record = { ...hook.headers, ...(Option.isSome(parent) ? HttpTraceContext.toHeaders(parent.value) : {}), diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 48dda4ba9406..80070bff78bf 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -951,7 +951,6 @@ describe("relay environment unlink", () => { return Effect.gen(function* () { expect(yield* unlink(false)).toBe(true); expect(cleared).toEqual([endpointKey]); - // The link is already revoked; a failed clear must not fail the unlink. expect(yield* unlink(true)).toBe(true); }); }); @@ -1190,7 +1189,6 @@ describe("relay request tracing", () => { expect(spans[0]?.kind).toBe("server"); expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices"); expect(spans[0]?.attributes.get("http.response.status_code")).toBe(204); - // Headers are not captured at all, so credentials never reach the span. expect(spans[0]?.attributes.get("http.request.header.authorization")).toBeUndefined(); expect(spans[0]?.attributes.get("http.request.header.dpop")).toBeUndefined(); expect(Option.isNone(spans[0]!.parent)).toBe(true); @@ -1212,7 +1210,6 @@ describe("relay request tracing", () => { new Request("https://relay.test/v1/mobile/devices", { method: "POST" }), ); - // As the worker runtime runs it: its own tracer around ours, turned off. yield* HttpMiddleware.tracer( traceRelayHttpRequestWith( Effect.succeed(HttpServerResponse.empty({ status: 204 })), @@ -1484,7 +1481,6 @@ const sendRelayRequest = Effect.fnUntraced(function* ( Deferred.succeed(sent, response), ).pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)), - // A handler failure still fails this effect after its 500 has been sent. Effect.exit, ); return yield* Deferred.await(sent); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 6a4ec0ce4e23..1db129ab834b 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -131,9 +131,6 @@ const relayCorsMiddleware = HttpMiddleware.cors({ maxAge: 86_400, }); -// The CORS headers come from a pre-response handler, so they reach every response -// the request sends: handler failures and defects, and the deadline 504 that -// `traceRelayHttpRequest` produces outside the router. export const layerCors = HttpRouter.middleware( Effect.fnUntraced(function* ( httpEffect: Effect.Effect< @@ -143,7 +140,6 @@ export const layerCors = HttpRouter.middleware( >, ) { const request = yield* HttpServerRequest.HttpServerRequest; - // Public webhook forwarding is server-to-server: no preflight, no CORS grants. if (isRelayHookPath(request.url)) { return yield* httpEffect; } @@ -168,9 +164,6 @@ export const layerDocsRedirectRoute = HttpRouter.add( // request hangs (e.g. a stuck upstream query), the client would otherwise // abort first, the invocation would die with the request span still open, and // the batched spans would never export β€” leaving no server-side trace at all. -// Failing server-side first turns the hang into a completed 504 whose trace -// contains the exact child span that stalled, and the response still carries -// the traceparent back to the client. export const RELAY_REQUEST_DEADLINE_MS = 9_000; /** * Webhook forwarding reads a body of up to 1 MiB, waits up to the upstream @@ -212,7 +205,6 @@ const relayRequestDeadline = ( ), ); -/** Trace context headers in every format the tracer reads (W3C and B3). */ const SENDER_TRACE_HEADER = /^(traceparent|tracestate|b3|x-b3-.*)$/i; export const traceRelayHttpRequest = ( @@ -229,7 +221,6 @@ export const traceRelayHttpRequest = ( ); if (!isRelayHookPath(request.url)) { return yield* HttpMiddleware.tracer(traced).pipe( - // The worker turns its own request span off; this one is ours. Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => false), // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. Effect.ensuring(Effect.yieldNow), @@ -257,14 +248,11 @@ export const traceRelayHttpRequest = ( ), ).pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, redacted), - // The worker turns its own request span off; this one is ours. Effect.provideService(HttpMiddleware.TracerDisabledWhen, () => false), Effect.ensuring(Effect.yieldNow), ); }); -// Webhook senders put shared secrets and signatures in headers such as -// x-hub-signature-256, stripe-signature, x-gitlab-token and x-webhook-key. const layerWebhookHeaderRedaction = Layer.effect( Headers.CurrentRedactedNames, Effect.map(Headers.CurrentRedactedNames, (names) => [ @@ -506,7 +494,6 @@ export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnviron function* (input: { readonly userId: string; readonly environmentId: string; - /** The stage's tunnel-name namespace, to find this link's held webhook requests. */ readonly managedEndpointNamespace?: string | undefined; }) { const links = yield* EnvironmentLinks.EnvironmentLinks; @@ -527,17 +514,14 @@ export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnviron environmentId: link.environmentId, environmentPublicKey: link.environmentPublicKey, }); - // External teardown cannot share the SQL transaction. Run it only after - // revocation commits so a database failure leaves a fully usable active - // link. Still run teardown when the link is already revoked, allowing a - // retry to finish cleanup after an earlier Cloudflare failure. + // Run it only after revocation commits so a database failure leaves a fully + // usable active link. Still run teardown when the link is already revoked, + // allowing a retry to finish cleanup after an earlier Cloudflare failure. const deprovisioned = yield* managedEndpointProvider.deprovision({ userId: input.userId, environmentId: input.environmentId, target: deprovisionTarget, }); - // Requests held for this link's endpoint go with it. Best effort: the link - // is already gone, and its inbox drops anything left after its TTL. const endpointKey = deprovisionTarget && input.managedEndpointNamespace ? HeldHooks.endpointKeyForTunnelName( diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index cb469c9166d3..db3eb17c4187 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -209,11 +209,6 @@ const withSchemaErrorAttributes = (delegate: Tracer.Tracer): Tracer.Tracer => ...(delegate.context ? { context: delegate.context } : {}), }); -/** - * Adds a failed span's schema error fields (`error.type`, `error.`) to - * the span, on whichever tracer is current. Provide it around the work whose - * spans should carry them; the Worker's telemetry still owns export. - */ export const withSchemaErrorSpanAttributes = ( effect: Effect.Effect, ): Effect.Effect => diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index eb6357bfa5d3..3ed6a75a5f6d 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -74,7 +74,6 @@ export const relayEnvironmentLinks = pgTable( notificationsEnabled: boolean("notifications_enabled").notNull().default(true), liveActivitiesEnabled: boolean("live_activities_enabled").notNull().default(true), managedTunnelsEnabled: boolean("managed_tunnels_enabled").notNull().default(false), - // Opt-in: hold webhook requests while the environment is offline. holdWebhooksWhileOffline: boolean("hold_webhooks_while_offline").notNull().default(false), createdByDeviceId: varchar("created_by_device_id", { length: 191 }), revokedAt: varchar("revoked_at", { length: 64 }), @@ -99,7 +98,6 @@ export const relayManagedEndpointAllocations = pgTable( readyAt: varchar("ready_at", { length: 64 }), recoveryEnabledAt: varchar("recovery_enabled_at", { length: 64 }), recoveryEnvironmentPublicKey: text("recovery_environment_public_key"), - // Set when cleanup deletes the recorded tunnel; cleared when a tunnel is recorded again. tunnelReleasedAt: varchar("tunnel_released_at", { length: 64 }), origin: jsonb("origin").$type(), generation: integer("generation").notNull().default(0), diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index adb77288057c..29e5359fa3f6 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -122,9 +122,6 @@ export const layer = Api.make( Effect.orDie, ), Effect.gen(function* () { - // - // 1. Provision Infrastructure for the Worker to use - // const { relayPublicOrigin, stage } = yield* RelayDeploymentConfig; const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; @@ -136,9 +133,6 @@ export const layer = Api.make( const randomApnsDeliveryJobSigningSecret = yield* ApnsDeliveryJobSigningSecret; yield* RelayObservability; - // - // 2. Create bindings - // const apnsEnabled = yield* Config.Boolean("APNS_ENABLED").pipe(Config.withDefault(true)); const apnsCredentials = apnsEnabled ? { @@ -203,9 +197,6 @@ export const layer = Api.make( }); const hookInboxes = yield* HookInboxObject.HookInboxObject; - // - // 3. Runtime layers and app construction - // const alchemyRuntimeContext: Alchemy.BaseRuntimeContext = yield* Cloudflare.Worker; const loadSettings = Effect.gen(function* () { @@ -227,7 +218,6 @@ export const layer = Api.make( }); }); - // Each managed endpoint's held webhook requests live in its own Durable Object. const inboxCall = (operation: HookInbox.HookInboxError["operation"], endpointKey: string) => (effect: Effect.Effect) => @@ -327,7 +317,6 @@ export const layer = Api.make( Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), Effect.catch((error) => Effect.logWarning("Hook rate limiter unavailable", { error: error.message }).pipe( - // Visible on the forward span, so an outage that disables limits shows up. Effect.andThen( Effect.annotateCurrentSpan({ "relay.hook.rate_limiter_failed_open": true }), ), @@ -397,7 +386,6 @@ export const layer = Api.make( [ DpopProofs.DpopProofReplay.pipe( Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired), - // Keep completed thread rows long enough to show their final state. Effect.andThen( Effect.all([AgentActivityRows.AgentActivityRows, DateTime.now]).pipe( Effect.flatMap(([activityRows, now]) => @@ -472,14 +460,10 @@ export const layer = Api.make( // The worker runtime opens its own HTTP span around ours. Ours carries // the route, header redaction and webhook URL redaction, and drops a // webhook sender's traceparent, so the runtime's span is off for every - // request rather than duplicating it. Registered as telemetry: - // request-time context is assembled per event, and only these layers - // are built into it. + // request rather than duplicating it. Layer.provideMerge( Alchemy.Telemetry.layer(Layer.succeed(HttpMiddleware.TracerDisabledWhen)(() => true)), ), - // The Worker's only trace exporter: every event (fetch, queue, cron, - // HookInboxObject calls and alarms) exports through it to Axiom. Layer.provideMerge( Layer.unwrap( Effect.map(RelayObservability, (observability) => diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index 969769ac9b5e..19ebfb6df8a6 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -4,6 +4,7 @@ import namespaceNodeImports from "./rules/namespace-node-imports.ts"; import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts"; import noHermesUnsupportedApis from "./rules/no-hermes-unsupported-apis.ts"; import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; +import noLowValueComments from "./rules/no-low-value-comments.ts"; import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; @@ -26,6 +27,7 @@ export default definePlugin({ "no-global-process-runtime": noGlobalProcessRuntime, "no-hermes-unsupported-apis": noHermesUnsupportedApis, "no-inline-schema-compile": noInlineSchemaCompile, + "no-low-value-comments": noLowValueComments, "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, "no-native-title-tooltip": noNativeTitleTooltip, diff --git a/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts index b06b522376e0..3981b9cb23bb 100644 --- a/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts +++ b/oxlint-plugin-t3code/rules/no-hermes-unsupported-apis.ts @@ -1,6 +1,5 @@ import { defineRule, type ESTree } from "@oxlint/plugins"; -// Add global APIs by dotted path, or instance methods by name. Values explain the replacement. const UNSUPPORTED_GLOBAL_APIS = new Map([ [ "Intl.Segmenter", @@ -17,7 +16,6 @@ const UNSUPPORTED_METHODS = new Map([ "toReversed", "Hermes does not implement Array#toReversed. Copy the array first: [...array].reverse().", ], - // splice returns the removed elements, so the copy itself is the result. [ "toSpliced", "Hermes does not implement Array#toSpliced. Copy the array first: const copy = [...array]; copy.splice(...); use copy.", diff --git a/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts b/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts index 7cb316001a7b..c79b171f78a4 100644 --- a/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts +++ b/oxlint-plugin-t3code/rules/no-inline-schema-compile.ts @@ -4,8 +4,6 @@ import * as Schema from "effect/Schema"; import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; -// Effect Schema decoder/encoder APIs allocate compiled functions. Keep them -// outside function bodies so hot paths do not rebuild compilers per call. const COMPILER_METHODS = new Set([ "is", "asserts", diff --git a/oxlint-plugin-t3code/rules/no-low-value-comments.ts b/oxlint-plugin-t3code/rules/no-low-value-comments.ts new file mode 100644 index 000000000000..f8c0dd138977 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-low-value-comments.ts @@ -0,0 +1,118 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; + +const TS_FILE_PATTERN = /\.[cm]?tsx?$/u; +const DIRECTIVE_PATTERN = + /^\s*(?:(?:oxlint|eslint)-|@ts-|@effect-diagnostics|prettier-ignore|biome-ignore|c8 |istanbul |#(?:end)?region)/u; +const DIVIDER_LINE_PATTERN = /^(?:[-=*#~_/+]{3,}.*|.*[-=*#~_]{3,})$/u; +const TEST_STEP_PATTERN = /^(?:arrange|act|assert|given|when|then)\s*[:.]?$/iu; +const TASK_MARKER_PATTERN = /\b(?:TODO|FIXME|XXX|HACK)\b/u; +const TASK_LINK_PATTERN = /#\d+|https?:\/\//u; +const JSDOC_TYPE_PATTERN = /^@(?:param|returns?|type|typedef)\s+\{/u; +const EMPTY_JSDOC_TAG_PATTERN = /^@(?:param(?:\s+\[?[\w$.]+\]?)?|returns?)$/u; +const COMMENTED_CODE_PATTERN = + /^(?:(?:(?:const|let|var)\s+[\w${}[\], ]+=.*|import\s.+\sfrom\s+["'].+["']|(?:await\s+|return\s+)?[\w$.]+\(.*\)|[\w$.[\]]+\s*=\s*.+);|(?:if|for|while|switch)\s*\(.*\)\s*\{|\}(?:\s*else\s*\{)?)$/u; +const DECLARATION_PATTERN = + /^\s*(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:async\s+)?(?:static\s+)?(?:readonly\s+)?(?:(?:const|let|var|function\*?|class|interface|type|enum)\s+)?([A-Za-z_$][\w$]*)\s*[?!]?\s*[:=(<{]/u; +const FILLER_WORDS = new Set( + "a an the of for to in on at by with from and or is are be this that it its as get set return create new current value given whether".split( + " ", + ), +); + +const stem = (word: string) => (word.length > 3 ? word.replace(/([^s])s$/u, "$1") : word); +const words = (text: string) => + (text.toLowerCase().match(/[a-z0-9]+/gu) ?? []) + .filter((word) => !FILLER_WORDS.has(word)) + .map(stem) + .filter((word) => !FILLER_WORDS.has(word)); +const identifierWords = (name: string) => + words(name.replace(/([a-z0-9])([A-Z])/gu, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2")); + +const commentLines = (comment: ESTree.Comment) => + comment.value + .split("\n") + .map((line) => line.replace(/^\s*\*?\s?/u, "").trim()) + .filter((line) => line !== ""); + +export default defineRule({ + meta: { + type: "suggestion", + docs: { + description: "Reject comments that only repeat the code, divide sections, or narrate tests.", + }, + }, + create(context) { + const { sourceCode } = context; + const isTypeScript = TS_FILE_PATTERN.test(context.filename); + + // Consecutive `//` lines at one indent read as one comment. + const groups: Array<{ comments: Array; lines: Array }> = []; + for (const comment of sourceCode.getAllComments()) { + const previous = groups.at(-1)?.comments.at(-1); + const continues = + comment.type === "Line" && + previous?.type === "Line" && + previous.loc.end.line === comment.loc.start.line - 1 && + previous.loc.start.column === comment.loc.start.column; + if (continues) { + groups.at(-1)!.comments.push(comment); + groups.at(-1)!.lines.push(...commentLines(comment)); + } else { + groups.push({ comments: [comment], lines: commentLines(comment) }); + } + } + + const restatesNextName = (lastLine: number, lines: ReadonlyArray) => { + if (lines.some((line) => line.startsWith("@"))) return false; + const name = DECLARATION_PATTERN.exec(sourceCode.lines[lastLine] ?? "")?.[1]; + if (name === undefined) return false; + const content = words(lines.join(" ")); + if (content.length === 0 || content.length > 6) return false; + const nameWords = new Set(identifierWords(name)); + return content.every((word) => nameWords.has(word)); + }; + + const problem = (comments: ReadonlyArray, lines: ReadonlyArray) => { + const text = comments.map((comment) => comment.value).join("\n"); + if (lines.length === 0 || comments.some((comment) => DIRECTIVE_PATTERN.test(comment.value))) { + return undefined; + } + const isLine = comments[0]!.type === "Line"; + if (lines.some((line) => DIVIDER_LINE_PATTERN.test(line))) { + return "Drop the section divider; split the file or let the code's structure show the grouping."; + } + if (isLine && lines.length === 1 && TEST_STEP_PATTERN.test(lines[0]!)) { + return "Drop the test step label."; + } + if (TASK_MARKER_PATTERN.test(text) && !TASK_LINK_PATTERN.test(text)) { + return "Link the issue that tracks this, or drop the marker."; + } + if (isTypeScript && lines.some((line) => JSDOC_TYPE_PATTERN.test(line))) { + return "Drop the JSDoc type; TypeScript already has it."; + } + if (lines.some((line) => EMPTY_JSDOC_TAG_PATTERN.test(line))) { + return "Drop the JSDoc tag that has no description."; + } + if (isLine && lines.every((line) => COMMENTED_CODE_PATTERN.test(line))) { + return "Delete commented-out code; git keeps the history."; + } + if (restatesNextName(comments.at(-1)!.loc.end.line, lines)) { + return "Drop the comment; it only repeats the name below it. Say why, or say nothing."; + } + return undefined; + }; + + return { + Program() { + for (const { comments, lines } of groups) { + const message = problem(comments, lines); + if (message === undefined) continue; + context.report({ + loc: { start: comments[0]!.loc.start, end: comments.at(-1)!.loc.end }, + message, + }); + } + }, + }; + }, +}); diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index 5a5d13b696f7..2840f9509159 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -19,9 +19,6 @@ const EFFECT_RUNTIME_METHODS = new Set([ "runSyncWith", ]); -// The lint config can set `maxOccurrences` per file to track existing manual -// runners as debt. The rule permits no net-new occurrences in those files, -// while every other test file must have zero. const readMaxOccurrences = (options: ReadonlyArray): number => { const [first] = options; return typeof first === "object" && diff --git a/oxlint-plugin-t3code/rules/no-outset-state-indicators.ts b/oxlint-plugin-t3code/rules/no-outset-state-indicators.ts index 42111809c0b8..8a0a75c30709 100644 --- a/oxlint-plugin-t3code/rules/no-outset-state-indicators.ts +++ b/oxlint-plugin-t3code/rules/no-outset-state-indicators.ts @@ -4,11 +4,9 @@ const CLASS_COMPOSERS = new Set(["cn", "clsx", "classNames", "cva", "twMerge"]); const STATE_VARIANT = /(?:^|[^\w])(?:focus(?:-visible|-within)?|selected|checked|pressed)(?=$|[^\w])/u; const RING_WIDTH = /^ring(?:-(?:[1-9]\d*|\[[^\]]+\]|\(length:[^)]+\)))?$/u; -// Pixel widths only; arbitrary values like `outline-[#243c5a]` are colors. const OUTLINE_WIDTH = /^outline-(?:(\d+)|\[(?:length:)?(\d+(?:\.\d+)?)px\])$/u; const INWARD_OFFSET = /^-outline-offset-(\d+)$/u; -/** Splits `dark:[&_a]:ring-2!` into its variants and utility, ignoring colons inside brackets. */ function parseClass(token: string) { const variants: string[] = []; let depth = 0; @@ -27,7 +25,6 @@ function parseClass(token: string) { type ParsedClass = ReturnType; -/** A companion applies when its variants are a subset of the indicator's variants. */ const covers = (companion: ParsedClass, indicator: ParsedClass) => companion.variants.every((variant) => indicator.variants.includes(variant)); @@ -36,7 +33,6 @@ const isClassComposer = (node: ESTree.Node) => node.callee.type === "Identifier" && CLASS_COMPOSERS.has(node.callee.name); -/** Strings a composition always includes; branches, class maps, and other calls are not guaranteed. */ function collectStrings(node: unknown, out: string[]) { if (Array.isArray(node)) { for (const child of node) collectStrings(child, out); @@ -53,7 +49,6 @@ function collectStrings(node: unknown, out: string[]) { } } -/** Classes guaranteed alongside a node by each enclosing `cn(...)`, so sibling companions count. */ function scopeClasses(node: ESTree.Node, own: ParsedClass[]) { const strings: string[] = []; for (let current = node.parent; current; current = current.parent) { @@ -70,7 +65,6 @@ function offenders(text: string, node: ESTree.Node) { for (const token of own) { const { utility, variants } = token; const label = [...variants, utility].join(":"); - // Positive offsets push the outline out of the element; the app default is inward. if (/^outline-offset-|^\[outline-offset:/u.test(utility)) { found.push(label); continue; diff --git a/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts index 6f3b3df49f59..d7689bc1893b 100644 --- a/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts +++ b/oxlint-plugin-t3code/rules/no-raw-mcp-registration.ts @@ -3,11 +3,8 @@ import * as Option from "effect/Option"; import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; -// Effect's MCP server, and the modules that re-export it. const MCP_SERVER_MODULE = "effect/ai/McpServer"; const AI_MODULES = new Set(["effect/ai", "effect/ai/index"]); -// Registering anything straight on the MCP server skips McpToolAccess, where -// every T3 MCP tool declares who may call it. These are the module functions... const MODULE_REGISTRATIONS = new Set([ "toolkit", "registerToolkit", @@ -16,7 +13,6 @@ const MODULE_REGISTRATIONS = new Set([ "prompt", "registerPrompt", ]); -// ...and the methods on the McpServer service itself. const SERVICE_REGISTRATIONS = new Set([ "addTool", "addResource", @@ -33,7 +29,6 @@ const importMessage = `Only McpHttpServer may use Effect's McpServer: a registra const testImportMessage = 'Tests import McpServer only as `import { McpServer } from "effect/ai"`, so a registration on it is reported.'; -/** A module specifier: a string, or a template with nothing substituted. */ const literalString = (node: unknown): Option.Option => Option.flatMap(unwrapExpression(node), (expression) => { if (expression.type === "Literal" && typeof expression.value === "string") { @@ -51,16 +46,6 @@ const namesMcpServer = (node: unknown) => const exposesMcpServer = (source: Option.Option) => Option.isSome(source) && (source.value === MCP_SERVER_MODULE || AI_MODULES.has(source.value)); -/** - * Keeps every registration on `/mcp` inside McpHttpServer, whose helpers - * accept only handlers McpToolAccess built; the lint config exempts that file. - * Elsewhere it reports runtime imports of Effect's McpServer, under any name - * or path, and reading a registration method or function in any form. Tests - * may import McpServer to build a server, but only under its own name, so a - * registration on it is still reported. It guards against a registration that - * skips the checks by accident; deliberately working around it needs a - * reviewed change here. - */ export default defineRule({ meta: { type: "problem", @@ -83,7 +68,6 @@ export default defineRule({ return ( AI_MODULES.has(source.value) && namesMcpServer(specifier.imported) && - // A test's `McpServer.toolkit(...)` is reported below by name. !(isTest && namesMcpServer(specifier.local)) ); }); @@ -124,7 +108,6 @@ export default defineRule({ context.report({ node, message: registrationMessage(`McpServer.${name.value}`) }); } }, - // `const { addTool } = server`, `const { toolkit } = McpServer` ObjectPattern(node) { const fromMcpServer = node.parent.type === "VariableDeclarator" && diff --git a/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.ts b/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.ts index e968507c25c3..30624342fcad 100644 --- a/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.ts +++ b/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.ts @@ -18,7 +18,6 @@ const readOptions = (options: ReadonlyArray) => { }; }; -/** A guardrail for ordinary edits, not a security boundary against deliberate casts or aliases. */ export default defineRule({ meta: { type: "problem", diff --git a/oxlint-plugin-t3code/rules/no-test-in-loop.ts b/oxlint-plugin-t3code/rules/no-test-in-loop.ts index b963fdee62df..8a84a9b08896 100644 --- a/oxlint-plugin-t3code/rules/no-test-in-loop.ts +++ b/oxlint-plugin-t3code/rules/no-test-in-loop.ts @@ -6,14 +6,11 @@ import { getPropertyName, unwrapExpression } from "../utils.ts"; const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; // node:test has no `.each`, so files written against it keep their loops. const NODE_TEST_IMPORT_PATTERN = /["']node:test["']/u; -// Calls that declare a test or a group of tests; all of them expose `.each`. const TEST_FUNCTIONS = new Set(["describe", "it", "suite", "test"]); -// Modifiers whose tester also exposes `.each` (@effect/vitest's `it.effect` and `it.live`). const EACH_CAPABLE_MODIFIERS = new Set(["effect", "live"]); const LOOPS = new Set(["ForStatement", "ForInStatement", "ForOfStatement"]); const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]); -/** Returns the reported name for `it(…)`, `describe(…)`, `it.effect(…)`, and friends. */ const testCallName = (callee: unknown): Option.Option => { const expression = unwrapExpression(callee); if (Option.isNone(expression)) return Option.none(); @@ -35,16 +32,10 @@ const testCallName = (callee: unknown): Option.Option => { return Option.some(`${object.value.name}.${property.value}`); }; -/** - * Reports a test declaration that runs once per loop iteration. A loop around a - * `describe` is reported on the `describe`, so the tests inside it stay quiet. - */ const isInsideLoop = (node: ESTree.Node): boolean => { let current = node.parent; while (current) { if (LOOPS.has(current.type)) return true; - // Anything inside a function (a describe body, a helper, a test body) runs - // once per call of that function, not once per iteration. if (FUNCTIONS.has(current.type)) return false; current = current.parent; } diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts index 088505b3e0cf..962178c0dd24 100644 --- a/oxlint-plugin-t3code/rules/no-unscoped-has.ts +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -13,10 +13,8 @@ const ANCESTOR_PREFIX_PATTERN = /(?:^|:)in-$/u; const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u; const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu; -// A variant's closing bracket is followed by ":" or a group/peer name like "/row:". const VARIANT_END_PATTERN = /^(?:\/[\w-]+)?:/u; -/** Tailwind arbitrary variants in a class token: top-level `[...]` groups used as variants. */ function variantGroups(token: string): { prefix: string; group: string }[] { const groups: { prefix: string; group: string }[] = []; let depth = 0; @@ -36,7 +34,6 @@ function variantGroups(token: string): { prefix: string; group: string }[] { return groups; } -/** Index of the ")" closing the "(" at `open`, or the selector's length. */ function closingParen(selector: string, open: number): number { let depth = 0; for (let index = open; index < selector.length; index++) { @@ -46,10 +43,6 @@ function closingParen(selector: string, open: number): number { return selector.length; } -/** - * Whether nothing after `from` in the enclosing branch is joined by a combinator, - * i.e. the element ending at `from` is the subject that its wrapper matches. - */ function isWrapperSubject(selector: string, from: number): boolean { let depth = 0; for (let index = from; index < selector.length; index++) { @@ -69,7 +62,6 @@ function isWrapperSubject(selector: string, from: number): boolean { return true; } -/** The compound selector each `:has(` in `selector` is attached to. */ function hasCompounds(selector: string): string[] { const compounds: string[] = []; let index = selector.indexOf(":has("); @@ -104,7 +96,6 @@ function hasCompounds(selector: string): string[] { return compounds; } -/** `compound` without any `:not(...)`, including one left open around the `:has()`. */ function withoutNegations(compound: string): string { let result = ""; let index = 0; @@ -124,9 +115,7 @@ function withoutNegations(compound: string): string { return result; } -/** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */ function findUnscopedHasVariants(text: string): string[] { - // Selectors are ASCII case-insensitive. if (!text.toLowerCase().includes(":has(")) return []; const offenders: string[] = []; for (const token of text.split(/\s+/u)) { diff --git a/oxlint-plugin-t3code/rules/prefer-catch-tags.ts b/oxlint-plugin-t3code/rules/prefer-catch-tags.ts index 743b94442cc5..9c1cbe375286 100644 --- a/oxlint-plugin-t3code/rules/prefer-catch-tags.ts +++ b/oxlint-plugin-t3code/rules/prefer-catch-tags.ts @@ -2,7 +2,6 @@ import { defineRule, type ESTree } from "@oxlint/plugins"; const MESSAGE = "Catch known tags with `Effect.catchTags({ Tag: handler })`, even for one tag."; -/** Reports `Effect.catchTag`, through any namespace import of `effect/Effect` or a named import. */ export default defineRule({ meta: { type: "suggestion", @@ -11,7 +10,6 @@ export default defineRule({ }, }, create(context) { - // Whether `identifier` is bound by `import * as X from "effect/Effect"`, not a shadowing local. const isEffectNamespace = (identifier: ESTree.IdentifierReference) => { let scope = context.sourceCode.getScope(identifier); while (true) { diff --git a/oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts b/oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts index 3a6bf389234b..b3416f122cd0 100644 --- a/oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts +++ b/oxlint-plugin-t3code/rules/require-centered-scroll-gutter.ts @@ -4,8 +4,8 @@ import * as Option from "effect/Option"; import { unwrapExpression } from "../utils.ts"; const SCROLLS_VERTICALLY = /(?:^|:)overflow-(?:y-)?auto$/u; -// Either utility keeps the content box the same width whether or not the scrollbar shows. Both -// exemptions must be unprefixed: a variant such as lg: leaves every other state unprotected. +// Both exemptions must be unprefixed: a variant such as lg: leaves every other state +// unprotected. const RESERVES_GUTTER = /^(?:scrollbar-gutter-(?:stable|both)|\[scrollbar-gutter:stable(?:_both-edges)?\])$/u; const HIDES_SCROLLBAR = /^\[scrollbar-width:none\]$/u; @@ -18,16 +18,10 @@ const CENTERS_ROW_ITEMS = /(?:^|:)(?:justify-center(?:-safe)?|\[justify-content:safe_center\]|place-items-center|place-content-center)$/u; interface ClassNames { - /** Classes that apply in at least one render. */ readonly possible: Set; - /** Classes that apply in every render, outside any conditional branch. */ readonly always: Set; } -/** - * The class names a className value can produce: string literals, template text, and the - * branches and arguments of conditionals and cn()-style calls. Conditions themselves are skipped. - */ function collectClassNames(node: unknown, classNames: ClassNames, always: boolean) { const expression = unwrapExpression(node); if (Option.isNone(expression)) return; @@ -92,11 +86,6 @@ function classNamesOf(element: ESTree.JSXElement): ClassNames { const hasClass = (classNames: ReadonlySet, pattern: RegExp) => [...classNames].some((className) => pattern.test(className)); -/** - * Reports a native vertical scroller that centers its content without reserving the scrollbar - * lane. A classic scrollbar narrows the scroller when it appears, so centered content jumps by - * half its width whenever the content grows past the fold. - */ export default defineRule({ meta: { type: "problem", @@ -126,7 +115,6 @@ export default defineRule({ if (possible.size === 0) return; if (hasClass(possible, SCROLLS_VERTICALLY)) { - // A responsive or conditional direction can render as either axis, so check both. const centersColumn = hasClass(possible, COLUMN) && hasClass(possible, CENTERS_COLUMN_ITEMS); const centersRow = @@ -136,7 +124,6 @@ export default defineRule({ } if (!hasClass(possible, CENTERED_BY_MARGIN)) return; - // The nearest enclosing scroller is the one whose scrollbar moves this element. for (let ancestor: ESTree.Node | null = node.parent; ancestor; ancestor = ancestor.parent) { // An element passed as a prop renders wherever that component puts it. if (ancestor.type === "JSXAttribute") return; diff --git a/oxlint-plugin-t3code/rules/require-suppression-reason.ts b/oxlint-plugin-t3code/rules/require-suppression-reason.ts index 2f7387383880..a856f11d1347 100644 --- a/oxlint-plugin-t3code/rules/require-suppression-reason.ts +++ b/oxlint-plugin-t3code/rules/require-suppression-reason.ts @@ -1,16 +1,10 @@ import { defineRule, type ESTree } from "@oxlint/plugins"; -// Comments that switch a diagnostic off or on. None of them explains another. const DIRECTIVE_PATTERN = /^\s*\*?\s*(?:(?:oxlint|eslint)-(?:disable|enable)|@ts-(?:expect-error|ignore|nocheck)|@effect-diagnostics)/u; -// TypeScript's own directives; whatever follows one is its reason. const TS_DIRECTIVE_PATTERN = /^\s*\*?\s*@ts-(?:expect-error|ignore|nocheck)(?![\w-])(.*)$/su; const EMPTY_COMMENT_PATTERN = /^[\s*]*$/u; -/** - * Reports a directive that disables a lint rule or the type checker without saying why. The reason - * is a `-- reason` suffix (text after the directive, for `@ts-*`) or a comment on the line above. - */ export default defineRule({ meta: { type: "suggestion", @@ -31,8 +25,6 @@ export default defineRule({ } } - // A comment directly above explains a directive, even across stacked directives and empty - // comment lines. const explainedAbove = (directive: ESTree.Comment) => { let above = ownLineCommentsByEndLine.get(directive.loc.start.line - 1); while ( diff --git a/oxlint-plugin-t3code/test/utils.ts b/oxlint-plugin-t3code/test/utils.ts index 594b3b80a6db..8982a5513552 100644 --- a/oxlint-plugin-t3code/test/utils.ts +++ b/oxlint-plugin-t3code/test/utils.ts @@ -15,8 +15,6 @@ import * as NodeModule from "node:module"; // varies by package manager: pnpm hoists it into the virtual store, while // other layouts expose vite-plus's LSP-only wrapper instead. Resolve the real // package through vite-plus rather than hardcoding either layout, and do it at -// module scope so a broken install fails once with a resolution error instead -// of as an opaque defect in every test. const oxlintPackageJsonPath = NodeModule.createRequire( NodeModule.createRequire(import.meta.url).resolve("vite-plus/package.json"), ).resolve("oxlint/package.json"); @@ -61,7 +59,6 @@ interface RuleHarness { interface RuleHarnessOptions { readonly filename?: string; - /** Rule options, as they would appear after the severity in the lint config. */ readonly ruleOptions?: ReadonlyArray; } diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index 91cbdae1426f..21b871eb63a0 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -408,7 +408,6 @@ describe("RemoteEnvironmentAuthorization", () => { expect(harness.fetch.calls.map(([url]) => String(url))).toEqual([ "http://192.168.1.10:3773/.well-known/t3/environment", ]); - // The token stays saved for the T3 Connect route. expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)?.accessToken).toBe( "cached-access-token", ); diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 425d95d3ac17..b80396602d41 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -37,8 +37,6 @@ const clientMetadataTokenExchangeFields = ( }; }; -// The server reads these off the /ws upgrade URL next to wsTicket. Optional on -// both ends: old servers ignore unknown params, old clients never send them. export const appendClientConnectionParams = ( url: URL, clientMetadata: AuthClientPresentationMetadata | undefined, diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 780c3a77fdc0..3b91a0f0aba3 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -72,10 +72,9 @@ export class RemoteEnvironmentAuthorization extends Context.Service< readonly authorizeDpop: (input: { readonly expectedEnvironmentId: EnvironmentId; /** - * Connect to this origin instead of the T3 Connect tunnel. The access - * token is not bound to an origin, and each proof is minted for the URL - * it signs, so a token from the relay also works on the environment's - * LAN or tailnet address. + * The access token is not bound to an origin, and each proof is minted + * for the URL it signs, so a token from the relay also works on the + * environment's LAN or tailnet address. */ readonly directEndpoint?: { readonly httpBaseUrl: string; readonly wsBaseUrl: string }; }) => Effect.Effect; @@ -140,9 +139,7 @@ export const make = Effect.gen(function* () { >(new Map()); /** - * Confirms a direct address still serves the expected environment before a - * credential is sent there. A saved LAN address can belong to another - * machine on a different network. + * A saved LAN address can belong to another machine on a different network. */ const verifyDirectEndpoint = Effect.fn("clientRuntime.connection.remote.verifyDirectEndpoint")( function* (input: { @@ -518,7 +515,6 @@ export const make = Effect.gen(function* () { } // An unreachable direct address says nothing about the token, so keep // it for the T3 Connect route rather than forcing a relay round trip. - // A rejected token falls through and is replaced like on the tunnel. if (endpoint !== undefined && cachedSocket.failure._tag !== "EnvironmentAuthInvalidError") { return yield* mapDpopSocketError(cachedSocket.failure); } @@ -531,7 +527,6 @@ export const make = Effect.gen(function* () { Effect.result, ); if (Result.isFailure(socket)) { - // A direct address that refuses the socket says nothing about the token. if (endpoint === undefined || socket.failure._tag === "ConnectionBlockedError") { yield* tokenLock.withPermits(1)( removeRejectedToken(input.expectedEnvironmentId, selected.token.accessToken), diff --git a/packages/client-runtime/src/composerThreadItems.ts b/packages/client-runtime/src/composerThreadItems.ts index 3ec98ca1e808..f94de963afe5 100644 --- a/packages/client-runtime/src/composerThreadItems.ts +++ b/packages/client-runtime/src/composerThreadItems.ts @@ -19,9 +19,8 @@ export interface ComposerThreadItem { } /** - * Threads the `@` picker offers next to file paths. The agent can only read threads on its - * own server, so candidates stay within the composer's environment. A query is required: - * bare `@` stays a file picker. + * The agent can only read threads on its own server, so candidates stay within the + * composer's environment. */ export function matchComposerThreadItems(input: { shells: ReadonlyArray; diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index b0b1526a4507..6ef207410780 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -22,16 +22,7 @@ export class BearerConnectionProfile extends Schema.TaggedClass; } -/** - * A saved environment. `target` and `profile` are its preferred route; - * `alternateRoutes` holds the others in preference order. Read them together - * with `connectionRoutes`. - */ export interface ConnectionCatalogEntry { readonly target: ConnectionTarget; readonly profile: Option.Option; readonly alternateRoutes?: ReadonlyArray; - /** False when the user switched the environment off: saved, but never connects. */ readonly enabled: boolean; - /** Discovery rejection stays visible while the saved connection is switched off. */ readonly unsupportedReason?: string; - /** The rejection came from an outdated host, which can still be updated remotely. */ readonly serverUpdateRequired?: boolean; } @@ -124,13 +106,10 @@ export const ConnectionRegistration = Schema.Union([ export type ConnectionRegistration = typeof ConnectionRegistration.Type; /** - * Platform-managed registrations are reconciled from the host (the desktop - * bootstrap IPC) rather than persisted by the user. They cover the primary - * local environment plus any additional desktop-local backends running - * alongside it (e.g. a parallel WSL backend). The primary stays on same-origin - * cookie auth (`PrimaryConnectionRegistration`); secondary local backends live - * on a separate loopback origin and authenticate with a bearer token minted - * from their bootstrap credential (`BearerConnectionRegistration`). + * The primary stays on same-origin cookie auth + * (`PrimaryConnectionRegistration`); secondary local backends live on a + * separate loopback origin and authenticate with a bearer token minted from + * their bootstrap credential (`BearerConnectionRegistration`). */ export const PlatformConnectionRegistration = Schema.Union([ PrimaryConnectionRegistration, diff --git a/packages/client-runtime/src/connection/compatibility.ts b/packages/client-runtime/src/connection/compatibility.ts index c0ec739e55e0..3ca2e226db95 100644 --- a/packages/client-runtime/src/connection/compatibility.ts +++ b/packages/client-runtime/src/connection/compatibility.ts @@ -26,7 +26,6 @@ export function orchestrationProtocolCompatibilityError( }); } -/** Whether this client can drive the host's update remotely. */ function canSelfUpdate(descriptor: ExecutionEnvironmentDescriptor): boolean { const { serverSelfUpdate, desktopAppUpdate } = descriptor.capabilities; return ( diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index d34bc1a9ba49..5f5f233dedf0 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -32,13 +32,8 @@ export interface EnvironmentConnectionLease { readonly session: RpcSession.RpcSession; } -/** - * The result of an unauthenticated reachability check. T3 Connect and SSH - * routes have no cheap check, so they are "unchecked". - */ export type RouteCheck = "answered" | "silent" | "unchecked"; -/** How long a direct route has to answer before it counts as unreachable from here. */ const ROUTE_CHECK_TIMEOUT_MS = 2_500; export class ConnectionDriver extends Context.Service< @@ -48,16 +43,10 @@ export class ConnectionDriver extends Context.Service< entry: ConnectionCatalogEntry, reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, ) => Effect.Effect; - /** Whether a direct route answers as the entry's environment, without credentials. */ readonly checkRoute: ( entry: ConnectionCatalogEntry, route: ConnectionRoute, ) => Effect.Effect; - /** - * Whether a direct route answers and accepts this client's credential, - * without opening a socket. Switching to a route that fails this would - * drop a working connection for nothing. - */ readonly preflight: ( entry: ConnectionCatalogEntry, route: ConnectionRoute, @@ -65,20 +54,6 @@ export class ConnectionDriver extends Context.Service< } >()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {} -/** - * Connects over the first route, in preference order, that is worth trying. - * Every route is checked at once, but a route only waits for its own check, - * so a reachable LAN address connects without waiting on a silent tailnet - * one. A silent route is skipped on the first pass so a LAN address from - * another network costs one short check, not a connection timeout. A route - * that fails to connect moves on to the next: a signed-out T3 Connect must - * not hide a working LAN. Silent routes are tried last, since a check is not - * proof. - * - * The reported error is a transient one when any route failed transiently, - * so the supervisor keeps retrying a route that may come back; a blocked - * error is reported only when every attempted route was blocked. - */ export const connectOverRoutes = Effect.fn("ConnectionDriver.connectOverRoutes")(function* ( entry: ConnectionCatalogEntry, checkRoute: (route: ConnectionRoute) => Effect.Effect, diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index 4cdfc3eee71e..81ed19419b97 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -152,9 +152,6 @@ export function mapRemoteEnvironmentError( traceId: error.traceId, }); case "EnvironmentResourceNotFoundError": - // Not expected during connection authorization, but the shared request - // error type now includes it (used by resource fetches like the thread - // snapshot). Treat it as a configuration issue with the endpoint. return new ConnectionBlockedError({ reason: "configuration", detail: "The environment endpoint could not be found.", @@ -186,11 +183,9 @@ export function mapRemoteEnvironmentError( } /** - * Map an environment error from a request that used DPoP authentication. An - * older environment server reports a DPoP clock failure as the same generic - * invalid-credential response as other failures, so keep the compatibility - * hint cautious when the server omits the category. Newer servers can identify - * clock and non-clock proof failures precisely. + * An older environment server reports a DPoP clock failure as the same generic + * invalid-credential response as other failures, so keep the compatibility hint + * cautious when the server omits the category. */ export function mapRemoteDpopEnvironmentError( error: RemoteEnvironmentAuthError, diff --git a/packages/client-runtime/src/connection/githubRoutingPermissions.ts b/packages/client-runtime/src/connection/githubRoutingPermissions.ts index da02c2a41ed3..3da40fb65156 100644 --- a/packages/client-runtime/src/connection/githubRoutingPermissions.ts +++ b/packages/client-runtime/src/connection/githubRoutingPermissions.ts @@ -25,8 +25,6 @@ export type StoredGitHubRoutingPermission = typeof StoredGitHubRoutingPermission * reordering keeps trust but adding or changing an address revokes it. */ export function gitHubRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null { - // Learned routes come and go with the server's addresses and reuse a saved - // route's credential, so they leave trust where the user put it. const routes = [ { target: entry.target, profile: entry.profile }, ...(entry.alternateRoutes ?? []), diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 7c6d1d6e9a49..0ab9e0d2f96b 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -42,8 +42,7 @@ export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscovere // Discovery describes the server behind the relay route. A direct // connection (the desktop's own server, a saved URL, SSH) can reach // a different server with the same environment id, such as a - // preview app that shares the home directory. Its socket handshake - // already checks the protocol. + // preview app that shares the home directory. const saved = registered.get(environmentId); if ( saved === undefined || @@ -82,7 +81,6 @@ export function layerWithOptions(options: RpcSession.RpcSessionOptions) { layerRegistry, RelayEnvironmentDiscovery.layer, layerOnboarding, - // Exposed for updating hosts too old to connect through the driver. ConnectionResolver.layer, ); const layerConnectionStartup = Layer.effectDiscard( diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index 78d11b84b9e4..fc95bf4faaf7 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -94,7 +94,7 @@ export class ConnectionBlockedError extends Schema.TaggedError void; -/** Answers update RPCs the way a protocol-1 server does. */ class OutdatedHostSocket { static readonly OPEN = 1; readyState = 0; @@ -186,7 +185,6 @@ describe("updateOutdatedHost", () => { ), Layer.succeed(HttpClient.HttpClient, httpClient), Layer.succeed(Socket.WebSocketConstructor, (url) => { - // The host relaunches on a compatible protocol once the update lands. const socket = new OutdatedHostSocket(url, () => { served = descriptor(ORCHESTRATION_PROTOCOL_VERSION, "0.0.46"); }); diff --git a/packages/client-runtime/src/connection/outdatedHostUpdate.ts b/packages/client-runtime/src/connection/outdatedHostUpdate.ts index b406c6957453..3703744321ec 100644 --- a/packages/client-runtime/src/connection/outdatedHostUpdate.ts +++ b/packages/client-runtime/src/connection/outdatedHostUpdate.ts @@ -43,12 +43,9 @@ export class OutdatedHostUpdateError extends Schema.TaggedError resolver.prepareForUpdate(routeEntry(entry, route))), ); @@ -160,8 +155,6 @@ export const updateOutdatedHost = Effect.fn("clientRuntime.connection.updateOutd ); yield* onStage("resuming"); - // The restarted host may answer on another saved route, so each poll asks - // every direct address, the one that carried the update first. const pollUrls = [ prepared.httpBaseUrl, ...connectionRoutes(entry).flatMap((route) => routeHttpBaseUrl(route) ?? []), diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 61d6278a5305..4358cb45248b 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -106,7 +106,6 @@ describe("connection presentation", () => { expect(environmentMcpUrl({ entry, connectedTarget: tailnet.target })).toBe( "http://100.115.1.44:3773/mcp", ); - // Not connected: the preferred route, plain http or not. expect(environmentMcpUrl({ entry })).toBe("http://192.168.4.53:3773/mcp"); }); @@ -122,7 +121,6 @@ describe("connection presentation", () => { alternateRoutes: [{ target: ENTRY.target, profile: ENTRY.profile }], }; - // Relay discovery has not reported the tunnel address yet. expect(environmentMcpUrl({ entry, connectedTarget: relay })).toBe( "https://environment.example.test/mcp", ); diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 9015a755133e..991b326eda50 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -94,11 +94,8 @@ export function presentEnvironmentConnection( } /** - * The address an agent outside T3 (Claude Code, Codex) uses to reach this - * environment's MCP server: the route this device is connected over, since an - * agent beside this client can reach it too, else the first route in - * preference order that has an address. SSH connections ride a local forward - * that disappears with the client, so they have no stable address. + * SSH connections ride a local forward that disappears with the client, so they have no stable + * address. */ export function environmentMcpUrl(input: { readonly entry: ConnectionCatalogEntry; diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 18d1f062f043..1b83162fd9e4 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -159,12 +159,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ) => Effect.Effect; readonly checkRoute?: (route: ConnectionRoute) => RouteCheck; readonly prepareRoute?: (target: ConnectionTarget) => ConnectionBlockedError | undefined; - /** Runs before preparing a route; may suspend to hold an attempt in flight. */ readonly beforePrepareRoute?: ( target: ConnectionTarget, ) => Effect.Effect; readonly initialDisabled?: ReadonlyArray; - /** Direct addresses the connected server reports. */ readonly directEndpoints?: ServerConfig["directEndpoints"]; }, ) { @@ -1369,8 +1367,6 @@ describe("EnvironmentRegistry", () => { Effect.gen(function* () { let storedCredentials: Ref.Ref> | undefined; const harness = yield* makeHarness([], [], [], { - // Authentication fails while the old bearer is stored, as the server - // would answer once that session was revoked. beforePrepareRoute: (target) => target._tag !== "BearerConnectionTarget" || storedCredentials === undefined ? Effect.succeed(undefined) @@ -1410,9 +1406,6 @@ describe("EnvironmentRegistry", () => { }), ); - // The desktop rotates the bootstrap token, so the renderer exchanges it again - // and the server revokes the old bearer while the socket stays open. HTTP - // reads such as the session's scopes must move to the new bearer (#13324). it.effect("a replaced platform bearer reaches a connected session's HTTP requests", () => Effect.gen(function* () { const harness = yield* makeHarness([]); @@ -1458,8 +1451,6 @@ describe("EnvironmentRegistry", () => { const releaseFirstAttempt = yield* Deferred.make(); const attempts = yield* Ref.make(0); const harness = yield* makeHarness([], [], [], { - // The first attempt read the old bearer; it stalls, then fails - // authentication after the bearer has already been replaced. beforePrepareRoute: (target) => target._tag !== "BearerConnectionTarget" ? Effect.succeed(undefined) @@ -1491,8 +1482,6 @@ describe("EnvironmentRegistry", () => { yield* registry.reconcilePlatform([registration("old-bearer")]); yield* Deferred.await(attemptStarted).pipe(Effect.timeout("1 second")); - // A live state subscription, as the UI holds, keeps the runtime in place - // so the stalled attempt's failure lands on the same supervisor. yield* registry .stateChanges(BEARER_TARGET.environmentId) .pipe(Stream.runDrain, Effect.forkScoped({ startImmediately: true })); @@ -1751,7 +1740,6 @@ describe("EnvironmentRegistry", () => { (state) => state.phase === "connected", ); - // Platform polls and explicit retries reach a healthy connection at once. yield* Effect.all( [ ...Array.from({ length: 5 }, () => registry.registerPlatform(registration)), @@ -1875,7 +1863,6 @@ describe("EnvironmentRegistry", () => { }); describe("EnvironmentRegistry routes", () => { - // The relay environment, reachable on the LAN as well. const LAN_TARGET = new BearerConnectionTarget({ environmentId: RELAY_TARGET.environmentId, label: RELAY_TARGET.label, @@ -2006,7 +1993,6 @@ describe("EnvironmentRegistry routes", () => { const targets = yield* Ref.get(harness.storedTargets); expect(routesOf(targets, LAN_TARGET.environmentId)).toEqual([LAN_TARGET]); expect(hasRoutes(targets, SECOND_RELAY_TARGET.environmentId)).toBe(false); - // Only the environment that lost its last route is forgotten. expect(yield* Ref.get(harness.cacheClears)).toEqual([SECOND_RELAY_TARGET.environmentId]); expect( (yield* SubscriptionRef.get(registry.entries)).get(LAN_TARGET.environmentId)?.target, @@ -2120,7 +2106,6 @@ describe("EnvironmentRegistry routes", () => { const httpBaseUrl = "http://100.101.102.103:3773/"; const learnedId = `learned:${LAN_TARGET.environmentId}:http://100.101.102.103:3773@${LAN_TARGET.connectionId}`; const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }); - // Learned by an earlier build, which saved no network. const learnedProfile = new BearerConnectionProfile({ connectionId: learnedId, environmentId: LAN_TARGET.environmentId, @@ -2161,7 +2146,6 @@ describe("EnvironmentRegistry routes", () => { Effect.gen(function* () { const learnedId = `learned:${LAN_TARGET.environmentId}:100.64.0.9:3773@${LAN_TARGET.connectionId}`; const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }); - // An earlier build saved the learned target twice and never its profile. const harness = yield* makeHarness( [LAN_TARGET, learned, learned], [LAN_PROFILE], diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 501a19bb0b9e..19bed9d75263 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -100,8 +100,8 @@ export class EnvironmentRegistry extends Context.Service< readonly networkStatus: SubscriptionRef.SubscriptionRef; readonly start: Effect.Effect; /** - * Saves a user registration. Succeeds with `false` and changes nothing when - * the environment is platform-managed, since the host owns that entry. + * Succeeds with `false` and changes nothing when the environment is + * platform-managed, since the host owns that entry. */ readonly register: ( registration: ConnectionRegistration, @@ -120,8 +120,8 @@ export class EnvironmentRegistry extends Context.Service< | PlatformEnvironmentRemovalError >; /** - * Drops one route. Removing an environment's last route removes the - * environment, the same as `remove`. + * Removing an environment's last route removes the environment, the same as + * `remove`. */ readonly removeRoute: ( environmentId: EnvironmentId, @@ -133,7 +133,6 @@ export class EnvironmentRegistry extends Context.Service< | EnvironmentNotRegisteredError | PlatformEnvironmentRemovalError >; - /** Reorders an environment's routes; `routeIds` lists every route, preferred first. */ readonly reorderRoutes: ( environmentId: EnvironmentId, routeIds: ReadonlyArray, @@ -144,10 +143,6 @@ export class EnvironmentRegistry extends Context.Service< | PlatformEnvironmentRemovalError | ConnectionBlockedError >; - /** - * Drops the T3 Connect route of every environment, after a cloud sign-out - * or account change. Environments with no other route are removed. - */ readonly removeRelayEnvironments: () => Effect.Effect< void, | Persistence.ConnectionPersistenceError @@ -155,11 +150,6 @@ export class EnvironmentRegistry extends Context.Service< | PlatformEnvironmentRemovalError >; readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect; - /** - * Switches a saved environment on or off. Off drops the socket, stops the - * retry ladder, and persists so the next launch stays off. Registration, - * credentials, and cache are untouched. - */ readonly setEnabled: ( environmentId: EnvironmentId, enabled: boolean, @@ -244,9 +234,6 @@ export const make = Effect.gen(function* () { persistedRoutesByEnvironment, Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* ([environmentId, targets]) { const loaded = yield* Effect.forEach(targets, loadRoute, { concurrency: "unbounded" }); - // A learned route without its profile has no address to reach; it is - // learned again on the next connection. A paired route keeps its slot - // so its missing profile still surfaces as a connection error. const seen = new Set(); const usable = loaded.filter((route) => { const id = connectionRouteId(route.target); @@ -457,7 +444,6 @@ export const make = Effect.gen(function* () { SubscriptionRef.changes(entries), ).pipe( Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))), - // Re-pairing can replace the supervisor while its catalog details stay unchanged. Stream.changesWith( (previous, current) => Option.getOrNull(previous) === Option.getOrNull(current), ), @@ -543,10 +529,8 @@ export const make = Effect.gen(function* () { connectionRoutes(entry).map((route) => route.target as PersistedConnectionTarget); /** - * The entry after its routes change. GitHub trust and an unsupported - * verdict both belong to the saved addresses: a changed set may reach a - * different server, so both are dropped and the next connection decides. - * Reordering keeps the same addresses, so both stay. + * GitHub trust and an unsupported verdict both belong to the saved addresses: a changed set may + * reach a different server, so both are dropped and the next connection decides. */ const withRoutes = (previous: ConnectionCatalogEntry, routes: ReadonlyArray) => { const next = entryWithRoutes(previous, routes); @@ -568,9 +552,6 @@ export const make = Effect.gen(function* () { if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { return false; } - // A new route joins the environment's saved routes. Registering one it - // already has (the same id, or a pairing to the same address) replaces - // it in place. Editing keeps the disabled state. const previous = (yield* SubscriptionRef.get(entries)).get(environmentId); let entry = registered; if (previous !== undefined) { @@ -598,7 +579,6 @@ export const make = Effect.gen(function* () { ); }); - /** Writes a new route list for a user-saved environment and restarts its connection. */ const replaceRoutesLocked = Effect.fn("EnvironmentRegistry.replaceRoutesLocked")(function* ( previous: ConnectionCatalogEntry, routes: ReadonlyArray, @@ -709,13 +689,6 @@ export const make = Effect.gen(function* () { yield* installEntryLocked(entry, { retainEquivalentRuntime: true }); - // The catalog entry carries no credential, so a fresh bearer keeps - // the equivalent runtime. Anything not yet connected may be using the - // old bearer: an attempt in flight would fail with it and then wait - // blocked for a manual retry. Retry with the new one instead. A - // connected session keeps its socket, but the server revoked the old - // bearer, so its HTTP requests (such as the session's scopes) move to - // the new one. if (bearerReplaced && registration._tag === "BearerConnectionRegistration") { const scope = (yield* SubscriptionRef.get(serviceScopes)).get(target.environmentId); if (scope !== undefined) { @@ -753,10 +726,6 @@ export const make = Effect.gen(function* () { }, ); - // Tear down a platform-managed environment that the host no longer reports - // (e.g. the user turned the parallel WSL backend off). Platform environments - // bypass the user-facing `remove` guard since they are reconciled from the - // bootstrap rather than removed by hand. const removePlatformEnvironment = Effect.fn("EnvironmentRegistry.removePlatformEnvironment")( function* (environmentId: EnvironmentId) { yield* withLeaseLock( @@ -822,9 +791,6 @@ export const make = Effect.gen(function* () { yield* installPlatformRegistration(registration); }); - // Reconcile the full set of platform-managed environments against what the - // host currently reports: add/refresh the desired ones and tear down any - // platform environment that disappeared (WSL toggled off, distro switched). const reconcilePlatform = Effect.fn("EnvironmentRegistry.reconcilePlatform")(function* ( platformRegistrations: ReadonlyArray, ) { @@ -841,7 +807,7 @@ export const make = Effect.gen(function* () { yield* Effect.forEach(platformRegistrations, installPlatformRegistration, { discard: true }); }); - /** Forgets a user-saved environment. Callers hold its lease lock. */ + /** Callers hold its lease lock. */ const removeLocked = Effect.fn("EnvironmentRegistry.removeLocked")(function* ( environmentId: EnvironmentId, ) { @@ -908,10 +874,8 @@ export const make = Effect.gen(function* () { typeof globalThis.location === "undefined" || globalThis.location.protocol !== "https:"; /** - * Saves the direct addresses a connected server reports as learned routes, - * replacing learned routes it no longer reports. Routes the user saved are - * never changed. Learned routes reuse the active route's credential, so they - * do not revoke GitHub routing trust the way a newly paired address does. + * Learned routes reuse the active route's credential, so they do not revoke GitHub routing + * trust the way a newly paired address does. */ const learnRoutes = Effect.fn("EnvironmentRegistry.learnRoutes")(function* (input: { readonly environmentId: EnvironmentId; @@ -934,9 +898,6 @@ export const make = Effect.gen(function* () { }); if (routes === null) return Option.none(); const next = entryWithRoutes(entry, routes); - // Save new learned profiles and any whose Tailscale mark changed. A - // learned route owns its profile; the credential stays with the route - // it borrows from. const previousProfiles = new Map( connectionRoutes(entry).map((route) => [ connectionRouteId(route.target), @@ -954,8 +915,6 @@ export const make = Effect.gen(function* () { yield* profiles.put(profile); } yield* registrations.setRoutes(input.environmentId, persistedRoutes(next)); - // Update the lease in place: the live session already works, and - // `installEntryLocked` would replace it for a route list change. const lease = (yield* SubscriptionRef.get(serviceScopes)).get(input.environmentId); if (lease !== undefined) { yield* SubscriptionRef.update(serviceScopes, (current) => @@ -1069,8 +1028,6 @@ export const make = Effect.gen(function* () { if (entry.enabled === enabled) { return; } - // Platform-managed environments are reconciled from the host and are - // never persisted, so only user-saved ones write the flag. if (!(yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { yield* registrations.setEnabled(environmentId, enabled); } diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index a70a7531a799..f0db52beb001 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -147,7 +147,6 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") }); return { ...authorized, target } satisfies PreparedConnection; } - // A learned route borrows the credential of the route it was learned from. const credential = yield* credentials.get(credentialConnectionId(target.connectionId)).pipe( Effect.flatMap( Option.match({ diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index 3079294305c7..fb127918854f 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -73,7 +73,6 @@ describe("connection routes", () => { }); it("keeps a user's order when a saved route is replaced", () => { - // The user preferred T3 Connect over the LAN; re-pairing the LAN keeps that. const repaired = direct("lan", "http://192.168.1.11:3773/"); expect(upsertRoute([RELAY, LAN], repaired)).toEqual([RELAY, repaired]); }); @@ -134,7 +133,6 @@ describe("learned routes", () => { reported: [lan], allowInsecure: true, })!; - // The user prefers T3 Connect over the learned LAN address. const reordered = entryWithRoutes(relayOnly, [first[1]!, first[0]!]); expect( mergeLearnedRoutes({ @@ -144,7 +142,6 @@ describe("learned routes", () => { allowInsecure: true, }), ).toBeNull(); - // A newly reported address is still placed by speed. const next = mergeLearnedRoutes({ entry: reordered, activeRoute: RELAY, @@ -169,7 +166,6 @@ describe("learned routes", () => { })!; expect(first.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]); - // The server later finds the address is not on its Tailscale interface. const corrected = mergeLearnedRoutes({ entry: entryWithRoutes(relayOnly, first), activeRoute: RELAY, @@ -199,7 +195,6 @@ describe("learned routes", () => { reported: [{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }], allowInsecure: true, })!; - // Same routes in the same order; only the label of the confirmed one changes. expect(ids(confirmed)).toEqual(["paired", "other", "relay"]); expect(confirmed.map(connectionRouteLabel)).toEqual(["Tailscale", "VPN", "T3 Connect"]); }); @@ -219,7 +214,6 @@ describe("learned routes", () => { allowInsecure: true, }), ).toBeNull(); - // The server stops reporting the LAN address; the paired route stays. expect( mergeLearnedRoutes({ entry, activeRoute: RELAY, reported: [], allowInsecure: true }), ).toBeNull(); @@ -278,7 +272,6 @@ describe("learned routes", () => { allowInsecure: true, })!; expect(ids(routesAfterRemoving(overLan, "lan"))).toEqual(["relay"]); - // Removing T3 Connect keeps the paired LAN and what it learned. expect(ids(routesAfterRemoving(overLan, "relay"))).toHaveLength(2); }); diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 4691aa9389d8..3467677ff760 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -14,13 +14,6 @@ import { } from "./catalog.ts"; import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts"; -/** - * A saved environment can hold several routes: T3 Connect, direct URLs (LAN, - * tailnet, other VPNs, public), and SSH. The client connects over the first route in - * preference order that answers as the expected environment, and moves back - * to a better one when it becomes reachable again. - */ - export type ConnectionRouteKind = | "relay" | "loopback" @@ -30,7 +23,6 @@ export type ConnectionRouteKind = | "public" | "ssh"; -/** An environment has at most one T3 Connect route, so it needs no per-route id. */ export const RELAY_ROUTE_ID = "relay"; export function connectionRouteId(target: ConnectionTarget): string { @@ -45,12 +37,10 @@ export function connectionRouteId(target: ConnectionTarget): string { } } -/** Every route of an entry, preferred first. */ export function connectionRoutes(entry: ConnectionCatalogEntry): ReadonlyArray { return [{ target: entry.target, profile: entry.profile }, ...(entry.alternateRoutes ?? [])]; } -/** Builds an entry whose preferred route is the first of `routes`, which must not be empty. */ export function entryWithRoutes( entry: ConnectionCatalogEntry, routes: ReadonlyArray, @@ -68,7 +58,6 @@ export function entryWithRoutes( }; } -/** The entry a single route connects with. */ export function routeEntry( entry: ConnectionCatalogEntry, route: ConnectionRoute, @@ -76,7 +65,6 @@ export function routeEntry( return entryWithRoutes(entry, [route]); } -/** The base URL of a direct route, or null for T3 Connect and SSH. */ export function routeHttpBaseUrl(route: ConnectionRoute): string | null { if (route.target._tag === "PrimaryConnectionTarget") return route.target.httpBaseUrl; const profile = Option.getOrNull(route.profile); @@ -124,11 +112,6 @@ const ROUTE_KIND_RANK: Record = { relay: 5, }; -/** - * Where a newly added route goes: after every saved route of the same or a - * faster kind, so LAN lands ahead of tailnet and both ahead of T3 Connect. - * Users can reorder afterwards; this only picks a sensible starting point. - */ export function insertRoute( routes: ReadonlyArray, route: ConnectionRoute, @@ -142,7 +125,6 @@ export function insertRoute( : [...routes.slice(0, index), route, ...routes.slice(index)]; } -/** Replaces the route with the same id in place, or inserts it by kind. */ export function upsertRoute( routes: ReadonlyArray, route: ConnectionRoute, @@ -153,12 +135,6 @@ export function upsertRoute( : insertRoute(routes, route); } -/** - * A saved route that reaches the same address as `route`: the same bearer - * URL, or the same SSH target (alias, host, user, and port, as desktop keys - * its tunnels). Registering it again replaces that route, even when it was - * saved under another id. - */ export function findRouteToSameAddress( routes: ReadonlyArray, route: ConnectionRoute, @@ -179,7 +155,6 @@ function routeAddressKey(route: ConnectionRoute): string | null { } } -/** Short user-facing route description: "LAN", "Tailscale", "VPN", "T3 Connect", a URL, or an SSH host. */ export function connectionRouteLabel(route: ConnectionRoute): string { switch (connectionRouteKind(route)) { case "relay": @@ -203,7 +178,6 @@ export function connectionRouteLabel(route: ConnectionRoute): string { } } -/** The address shown under a route, or null for T3 Connect. */ export function connectionRouteAddress(route: ConnectionRoute): string | null { if (route.target._tag === "SshConnectionTarget") { const profile = Option.getOrNull(route.profile); @@ -212,7 +186,6 @@ export function connectionRouteAddress(route: ConnectionRoute): string | null { return routeHttpBaseUrl(route); } -/** Whether the environment can be reached through T3 Connect. */ export function hasRelayRoute( entry: Pick, ): boolean { @@ -221,18 +194,6 @@ export function hasRelayRoute( ); } -/** - * The routes after the server reports where it listens. Each newly reported - * address becomes a learned route that authenticates the same way as the route - * in use: the paired token for a bearer route, the T3 Connect credential for - * relay. A learned route the server still reports keeps its place, so the - * user's order holds; one it no longer reports is dropped, so a changed LAN - * address replaces the old one. Routes the user saved are never removed or - * moved, and an address already saved is not learned twice. Any direct route - * the server reports is marked as Tailscale while the server finds it on its - * Tailscale interface, so a route paired by its numeric Tailscale address keeps - * that label. - */ export function mergeLearnedRoutes(input: { readonly entry: ConnectionCatalogEntry; readonly activeRoute: ConnectionRoute; @@ -243,12 +204,9 @@ export function mergeLearnedRoutes(input: { const { entry } = input; const active = input.activeRoute.target; const saved = connectionRoutes(entry); - // The primary and SSH routes have no credential a learned route could reuse. if (active._tag !== "RelayConnectionTarget" && active._tag !== "BearerConnectionTarget") { return null; } - // A route learned over another learned route inherits what that one uses: - // the T3 Connect credential, or the paired token it borrows. const activeProfile = Option.getOrNull(input.activeRoute.profile); const authorization = active._tag === "RelayConnectionTarget" || @@ -261,7 +219,6 @@ export function mergeLearnedRoutes(input: { ? credentialConnectionId(active.connectionId) : undefined; - // Usable reported addresses, by origin. const reported = new Map(); const tailscaleOrigins = new Set(); for (const endpoint of input.reported) { @@ -327,7 +284,6 @@ export function mergeLearnedRoutes(input: { ), }); } - // Compare addresses too: a scheme or port change keeps no id stable. const signature = (routes: ReadonlyArray) => routes .map( @@ -338,13 +294,11 @@ export function mergeLearnedRoutes(input: { return signature(saved) === signature(next) ? null : next; } -/** A direct address the server reports, as `ServerDirectEndpoint`. */ export interface ReportedEndpoint { readonly httpBaseUrl: string; readonly kind?: string; } -/** The direct route with its Tailscale mark set or cleared; the same route when unchanged. */ function withNetwork(route: ConnectionRoute, tailscale: boolean): ConnectionRoute { const profile = Option.getOrNull(route.profile); if (profile?._tag !== "BearerConnectionProfile") return route; @@ -366,10 +320,6 @@ function withNetwork(route: ConnectionRoute, tailscale: boolean): ConnectionRout }; } -/** - * A learned bearer route borrows the credential of the route it was learned - * from, so its id points back at that credential's owner. - */ function learnedConnectionId( environmentId: string, origin: string, @@ -380,10 +330,7 @@ function learnedConnectionId( : `learned:${environmentId}:${origin}@${sharedCredential}`; } -/** The connection id whose stored credential a bearer route uses. */ export function credentialConnectionId(connectionId: string): string { - // The borrowed id follows the first "@"; neither an environment id nor an - // origin contains one. const at = connectionId.indexOf("@"); return connectionId.startsWith("learned:") && at !== -1 ? connectionId.slice(at + 1) @@ -396,10 +343,10 @@ export function isLearned(route: ConnectionRoute): boolean { } /** - * Routes left after the user removes one. A learned route borrows the - * credential of the route it was learned over, so it cannot outlive that - * route: removing T3 Connect also removes routes learned through it, and - * removing a paired address removes routes that borrow its token. + * A learned route borrows the credential of the route it was learned over, so + * it cannot outlive that route: removing T3 Connect also removes routes learned + * through it, and removing a paired address removes routes that borrow its + * token. */ export function routesAfterRemoving( routes: ReadonlyArray, @@ -418,15 +365,10 @@ export function routesAfterRemoving( }); } -/** - * Whether removing T3 Connect leaves the environment no route, so signing out - * removes it entirely. Routes learned through T3 Connect go with it. - */ export function removedWithRelay(entry: ConnectionCatalogEntry): boolean { return routesAfterRemoving(connectionRoutes(entry), RELAY_ROUTE_ID).length === 0; } -/** One SSH target, as desktop keys its tunnels: alias, host, user, and port. */ export function sshTargetKey(target: DesktopSshEnvironmentTarget): string { return JSON.stringify([target.alias, target.hostname, target.username, target.port]); } diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index f83598b80952..75822b7eb480 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -93,7 +93,6 @@ const LAN_ROUTE: ConnectionRoute = { }), ), }; -// LAN first, T3 Connect as the fallback. const LAN_THEN_RELAY_ENTRY: ConnectionCatalogEntry = { target: LAN_ROUTE.target, profile: LAN_ROUTE.profile, @@ -234,7 +233,6 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: }); const layerDependencies = Layer.mergeAll( - // Jitter at its maximum, so each retry waits exactly its ceiling: 2s, 4s, 8s... Layer.succeed(Random.Random, { nextDoubleUnsafe: () => 1 - Number.EPSILON, nextIntUnsafe: () => 0, @@ -524,7 +522,6 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "connecting" && state.stage === "synchronizing", ); - // 15 seconds of setup plus 75 for the snapshot; setup finished instantly. yield* TestClock.adjust("89 seconds"); expect((yield* SubscriptionRef.get(supervisor.state)).stage).toBe("synchronizing"); @@ -753,7 +750,6 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "connected" && state.generation === 1, ); - // A loopback server, or a flap shorter than the probe, keeps working. yield* harness.setNetworkStatus("offline"); for (let attempt = 0; attempt < 100; attempt += 1) { if ((yield* Ref.get(probeCount)) > 0) break; @@ -786,7 +782,6 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "connected" && state.generation === 1, ); - // A wrong offline report: the probe answers, so the session stays. yield* harness.setNetworkStatus("offline"); for (let attempt = 0; attempt < 100; attempt += 1) { if ((yield* Ref.get(probeCount)) > 0) break; @@ -794,7 +789,6 @@ describe("EnvironmentSupervisor", () => { } expect(yield* Ref.get(harness.sessionCount)).toBe(1); - // The replacement connects although the network still reports offline. yield* harness.wake("application-active-reconnect"); const replaced = yield* awaitState( supervisor.state, @@ -891,8 +885,6 @@ describe("EnvironmentSupervisor", () => { } expect(yield* Ref.get(probeCount)).toBe(1); - // The flapping session keeps climbing the ladder: the answered retry does - // not reset it after this unrelated close. yield* harness.closeLatestSession(); yield* awaitState( supervisor.state, @@ -1220,10 +1212,8 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active-probe"); yield* Deferred.await(probeStarted); - // The OS reports the suspended socket's close before the probe answers. yield* harness.closeLatestSession(); - // No TestClock advance: the unanswered probe skips the first backoff rung. const reconnected = yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -1257,7 +1247,6 @@ describe("EnvironmentSupervisor", () => { expect(reconnecting.attempt).toBe(1); expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); - // No TestClock advance: a failed wake probe skips the first backoff rung. yield* Deferred.succeed(allowReconnect, undefined); yield* awaitState( supervisor.state, @@ -1283,8 +1272,6 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active"); - // The immediate follow-up attempt fails: only the first attempt after - // the wake probe skips the ladder, so this failure backs off normally. yield* awaitState( supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, @@ -1337,7 +1324,6 @@ describe("EnvironmentSupervisor", () => { yield* harness.wake("application-active"); yield* TestClock.adjust("5 seconds"); yield* supervisor.retryNow; - // The retry's 3 second limit applies, not the 10 seconds left of the 15. yield* TestClock.adjust("2999 millis"); expect(yield* Ref.get(harness.sessionCount)).toBe(1); yield* TestClock.adjust("1 milli"); @@ -1362,8 +1348,6 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active-probe"); yield* TestClock.adjust("3 seconds"); - // The timed-out wake probe reconnects immediately without a backoff - // sleep: no further clock advance is needed. yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, @@ -1726,7 +1710,6 @@ describe("EnvironmentSupervisor routes", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); const prepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)); expect(prepared.target._tag).toBe("RelayConnectionTarget"); - // The silent route never reached the resolver. expect(yield* Ref.get(harness.prepareCount)).toBe(1); }), ); @@ -1773,7 +1756,6 @@ describe("EnvironmentSupervisor routes", () => { const harness = yield* makeHarness({ checkRoute: () => Effect.succeed("answered"), prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), - // The LAN session fails 50 seconds into its snapshot; T3 Connect needs 40. ready: (attempt) => attempt === 1 ? Effect.sleep("50 seconds").pipe(Effect.andThen(Effect.fail(transient()))) @@ -1803,7 +1785,6 @@ describe("EnvironmentSupervisor routes", () => { const harness = yield* makeHarness({ checkRoute: (route) => Effect.succeed(route.target._tag === "BearerConnectionTarget" ? "silent" : "unchecked"), - // Signed out of T3 Connect; the LAN is up but its check timed out. prepare: (_attempt, target) => target._tag === "RelayConnectionTarget" ? Effect.fail(blocked("Sign in to T3 Connect.")) @@ -1833,7 +1814,6 @@ describe("EnvironmentSupervisor routes", () => { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); - // A blocked state would stop retrying; the LAN may come back. const state = yield* awaitState(supervisor.state, (value) => value.phase === "backoff"); expect(state.lastFailure?._tag).toBe("ConnectionTransientError"); }), @@ -1863,7 +1843,6 @@ describe("EnvironmentSupervisor routes", () => { "RelayConnectionTarget", ); - // Back home: the LAN answers, and a network change asks for a better route. yield* Ref.set(lanReachable, true); yield* harness.setNetworkStatus("unknown"); yield* harness.setNetworkStatus("online"); @@ -1920,7 +1899,6 @@ describe("EnvironmentSupervisor routes", () => { route.target._tag === "BearerConnectionTarget" ? Ref.get(lanAnswers).pipe(Effect.map((answers) => (answers ? "answered" : "silent"))) : Effect.succeed("unchecked"), - // The LAN answers its check but the socket never opens. prepare: (_attempt, target) => target._tag === "BearerConnectionTarget" ? Effect.fail(transient("Socket refused.")) @@ -1936,15 +1914,12 @@ describe("EnvironmentSupervisor routes", () => { ); yield* Ref.set(lanAnswers, true); yield* TestClock.adjust("60 seconds"); - // The switch lands back on T3 Connect. yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, ); const preparesAfterSwitch = yield* Ref.get(harness.prepareCount); - // The failed LAN is cooling down, so the next periodic check leaves the - // session alone. yield* TestClock.adjust("60 seconds"); yield* Effect.yieldNow; expect(yield* Ref.get(harness.prepareCount)).toBe(preparesAfterSwitch); @@ -1989,7 +1964,6 @@ describe("EnvironmentSupervisor routes", () => { ) : Effect.succeed("unchecked"), prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), - // The LAN socket never answers once the phone has left the network. probe: () => Ref.get(lanReachable).pipe( Effect.flatMap((reachable) => (reachable ? Effect.void : Effect.never)), @@ -2058,7 +2032,6 @@ describe("EnvironmentSupervisor routes", () => { ? Effect.succeed("answered") : Effect.succeed("unchecked"), prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), - // Only the reported endpoints matter to the supervisor. initialConfig: () => Ref.get(lanAddress).pipe( Effect.map((httpBaseUrl): Pick => ({ @@ -2090,8 +2063,6 @@ describe("EnvironmentSupervisor routes", () => { }), }).pipe(Effect.provide(harness.dependencies)); - // Connected over T3 Connect, the server reports its LAN address; the - // learned route ranks first, answers, and the session moves to it. yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 88a53216541d..f9cda4c02a05 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -39,22 +39,14 @@ import { const RETRY_BASE_DELAY_MS = 1_000; const RETRY_MAX_DELAY_MS = 300_000; -// Per-route budget to resolve credentials and create the session, which ends -// when the driver reports the synchronizing stage. Each route that gets that -// far adds its own budget for the socket to open (bounded at 15 seconds by the -// RPC session) and the server to deliver the first config snapshot. A cold -// server with a large database can take longer than 15 seconds to answer, and -// that is not a reason to drop a working connection. +// A cold server with a large database can take longer than 15 seconds to +// answer, and that is not a reason to drop a working connection. const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; const establishmentTimeout = Duration.fromInputUnsafe(CONNECTION_ESTABLISHMENT_TIMEOUT); const synchronizationTimeout = Duration.fromInputUnsafe("75 seconds"); const CONNECTION_PROBE_TIMEOUT = "15 seconds"; -// Mobile resumes, explicit retries, and offline events want a fast answer: -// the user is waiting, or the network may be gone. const QUICK_CONNECTION_PROBE_TIMEOUT = "3 seconds"; const BACKOFF_RESET_AFTER_MS = 30_000; -// While connected over a fallback route, how often to look for a better one. -// Network changes and returning to the app also trigger a check. const BETTER_ROUTE_CHECK_INTERVAL = "60 seconds"; // A better route that answered the check but then failed to connect is not // tried again for this long, so a flaky LAN cannot bounce the connection. @@ -125,27 +117,12 @@ function exitUnlessInterrupted( export interface EnvironmentSupervisorOptions { readonly initiallyDesired?: boolean; - /** - * Saves the direct addresses the server reports once a session is ready and - * returns the entry with its updated routes, which later attempts use. The - * live session is left alone: it already works. - */ readonly learnRoutes?: (input: { readonly activeRoute: ConnectionRoute; readonly reported: ReadonlyArray; }) => Effect.Effect>; } -/** - * Delay before the next attempt after `failureCount` consecutive failures - * (0 for the first retry). The ceiling doubles from 2s up to 5 minutes, and - * the delay is a random point in its upper half: never quicker than half the - * ceiling, and spread out so clients that lost the same server do not all - * reconnect in the same second. `random` is in [0, 1). - * - * The long cap only applies to a connection that keeps failing. Returning to - * the app, the network coming back, and an explicit retry all skip the wait. - */ export function retryDelayMs(failureCount: number, random: number): number { const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failureCount + 1)); return Math.round(ceiling / 2 + (ceiling / 2) * random); @@ -266,8 +243,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( | ConnectionWakeups.ConnectionWakeups > { const target = entry.target; - // Relay-specific handling applies when any route is T3 Connect, since the - // attempt or the live session may be using it. const usesRelay = connectionRoutes(entry).some( (route) => route.target._tag === "RelayConnectionTarget", ); @@ -286,10 +261,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); const resetRetryState = yield* Ref.make(false); - // Set while a probe of the live session is running, and kept when it fails - // or times out: something asked whether the connection still works and it - // closed or failed before answering, so the follow-up reconnect skips the - // first backoff rung instead of sleeping. const probeUnanswered = yield* Ref.make(false); const state = yield* SubscriptionRef.make( !initialIntent.desired @@ -304,10 +275,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( // fresh by each attempt and check instead of being fixed at creation. const currentEntry = yield* Ref.make(entry); const currentRoutes = Ref.get(currentEntry).pipe(Effect.map(connectionRoutes)); - // Set when a better route answered while connected over a worse one; the - // replacement attempt tries it first. Cleared once an attempt starts. const preferredRouteId = yield* Ref.make(Option.none()); - // Monotonic deadline per route that answered a check but failed to connect. const routeCooldowns = yield* Ref.make>(new Map()); const attemptEntry = Effect.gen(function* () { @@ -338,7 +306,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const updated = yield* learn({ activeRoute, reported }); if (Option.isSome(updated)) { yield* Ref.set(currentEntry, updated.value); - // A learned route may rank above the one in use. yield* requestBetterRouteCheck(lease); } }); @@ -352,11 +319,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ), ); - /** - * Preflights the routes ranked above the one in use and signals the best - * that would connect. Routes without a cheap check (T3 Connect, SSH) never - * pass, so they are fallbacks, not destinations. - */ const checkBetterRoutes = Effect.fnUntraced(function* ( lease: ConnectionDriver.EnvironmentConnectionLease, ) { @@ -375,7 +337,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); const index = passed.indexOf(true); if (index === -1) return; - // The check may outlive the session it was asked about. const live = yield* SubscriptionRef.get(session); if (Option.isNone(live) || live.value !== lease.session) return; yield* signal({ @@ -384,7 +345,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }); }); - // One check at a time; a trigger during a check is dropped, not queued. const betterRouteChecks = yield* Queue.dropping(1); const requestBetterRouteCheck = (lease: ConnectionDriver.EnvironmentConnectionLease) => routeIndex(lease).pipe( @@ -542,8 +502,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } }); - // Signals that end a connected lease whatever its health: "reset" ends it - // and restarts the retry ladder, "end" ends it, undefined keeps it. const isRelayLease = (lease: ConnectionDriver.EnvironmentConnectionLease) => lease.prepared.target._tag === "RelayConnectionTarget"; @@ -555,8 +513,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( return "end" as const; } if (next._tag === "BetterRouteAvailable") { - // Replaced like a long resume: the new attempt prefers the better route - // and its session takes over the durable subscriptions. yield* Ref.set(preferredRouteId, Option.some(next.routeId)); return "reset" as const; } @@ -577,8 +533,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( return undefined; }); - // How long a signal waits for the live session to answer a probe, or - // undefined when the signal does not question the connection. const probeTimeoutFor = (next: SupervisorSignal): Duration.Input | undefined => { switch (next._tag) { case "RetryRequested": @@ -589,7 +543,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( if (next.reason === "application-active") { return CONNECTION_PROBE_TIMEOUT; } - // A socket opened on the previous network may now be unroutable. return next.reason === "application-active-probe" || next.reason === "network-changed" ? QUICK_CONNECTION_PROBE_TIMEOUT : undefined; @@ -600,13 +553,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } }; - // Holds a connected lease until it must end, and returns whether to restart - // the retry ladder. Returning to the app, an explicit retry, and the network - // reporting offline all probe the live session instead of replacing it, so a - // healthy socket is not torn down (the offline report is often wrong, for - // example for a loopback server). Only a long mobile resume replaces the - // session without a probe. A failed probe fails this effect, and the - // supervisor reconnects. + // Returning to the app, an explicit retry, and the network reporting offline + // all probe the live session instead of replacing it, so a healthy socket is + // not torn down (the offline report is often wrong, for example for a + // loopback server). const monitorConnectedLease = Effect.fnUntraced(function* ( lease: ConnectionDriver.EnvironmentConnectionLease, ) { @@ -630,7 +580,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( if (end !== undefined) { return end === "reset"; } - // A new network or a return to the app may have brought a better route back. if ( (next._tag === "NetworkChanged" && next.network !== "offline") || (next._tag === "Wakeup" && ConnectionWakeups.resetsRetryBackoff(next.reason)) @@ -643,7 +592,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } yield* Ref.set(probeUnanswered, true); const probe = yield* Effect.forkChild(lease.session.probe); - // Monotonic nanoseconds, so a wall-clock correction cannot move the deadline. let deadline = (yield* Clock.monotonicTimeNanos) + Duration.toNanosUnsafe(probeTimeout); for (;;) { const remaining = deadline - (yield* Clock.monotonicTimeNanos); @@ -675,8 +623,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( yield* Fiber.interrupt(probe); return endDuringProbe === "reset"; } - // A retry or an offline report during a desktop foreground probe wants - // its quicker answer, so it shortens the running probe. const signalTimeout = probeTimeoutFor(probeEvent.signal); if (signalTimeout !== undefined) { const signalDeadline = @@ -847,8 +793,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( connectedForMs >= BACKOFF_RESET_AFTER_MS, ); if (outcome._tag === "Failure") { - // A live session ending is otherwise invisible in the client trace, so - // record why, and how long it lasted, as its own root span. yield* Effect.void.pipe( Effect.withSpan("EnvironmentSupervisor.connectionLost", { root: true, @@ -889,7 +833,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ); }); - // A better route only matters to a live session, so idle states ignore it. const waitForSignal = Queue.take(signals).pipe( Effect.repeat({ while: (next) => next._tag === "BetterRouteAvailable" }), Effect.map( @@ -906,9 +849,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( failureCount = 0; pendingRetry = Option.none(); }; - // Set after a long resume ends an attempt or a session. The fresh attempt - // runs even while the network reports offline: the report is often wrong, - // and the replaced session must not leave the client offline. let replacing = false; for (;;) { @@ -942,8 +882,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( runAttempt(attempt, nextGeneration, latestFailure, pendingRetry, replacing), ); replacing = false; - // Consumed on every iteration so a stale marker can never leak into a - // later, unrelated failure. const failedProbe = yield* Ref.getAndSet(probeUnanswered, false); if (outcome.established) { generation = nextGeneration; @@ -983,11 +921,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } if (failedProbe) { - // A probe found a dead transport, or the transport closed while a probe - // waited for an answer (the user returned to the app, asked to retry, - // or the network changed), so reconnect immediately instead - // of sleeping the first backoff rung. Only this first attempt skips the - // ladder; if it fails too, normal backoff resumes. resetRetryLadder(); yield* setState(connectingState(yield* Ref.get(intent), generation, 1, error)); continue; diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts index 38a45b092bbe..b4315ad3a732 100644 --- a/packages/client-runtime/src/connection/wakeups.ts +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -7,8 +7,6 @@ export type ConnectionWakeup = | "application-active-probe" | "application-active-reconnect" | "credentials-changed" - // The device moved to a different network (another Wi-Fi, Wi-Fi to - // cellular) while staying online. Saved routes may have changed reach. | "network-changed"; function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean { @@ -19,7 +17,6 @@ function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean { ); } -/** Conditions changed enough that a pending retry should run now, from the first rung. */ export function resetsRetryBackoff(reason: ConnectionWakeup): boolean { return isApplicationActiveWakeup(reason) || reason === "network-changed"; } diff --git a/packages/client-runtime/src/delayedStatus.test.ts b/packages/client-runtime/src/delayedStatus.test.ts index 4d48d2fe211f..26e801463679 100644 --- a/packages/client-runtime/src/delayedStatus.test.ts +++ b/packages/client-runtime/src/delayedStatus.test.ts @@ -37,7 +37,6 @@ describe("createDelayedStatus", () => { vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS); expect(changes).toEqual([{ key: "a", value: "loading" }]); - // A new label gets its own full hold, even during the first label's hold. vi.advanceTimersByTime(1); status.update("a", "syncing"); status.update("a", null); diff --git a/packages/client-runtime/src/delayedStatus.ts b/packages/client-runtime/src/delayedStatus.ts index faca3d2f9ff5..552cfcde449b 100644 --- a/packages/client-runtime/src/delayedStatus.ts +++ b/packages/client-runtime/src/delayedStatus.ts @@ -1,30 +1,20 @@ // @effect-diagnostics globalTimers:off - Display timing for React hooks, outside an Effect runtime. -/** How long a status must last before a client shows it. */ export const STATUS_SHOW_DELAY_MS = 400; -/** How long a shown status stays up, so it cannot flash at the show delay. */ export const STATUS_MIN_VISIBLE_MS = 400; -/** The status a client should show, and the key it belongs to. */ export interface ShownStatus { readonly key: string; readonly value: A; } export interface DelayedStatus { - /** Reports the real status for `key`. A new key drops the shown status at once. */ readonly update: (key: string, value: A | null) => void; - /** Cancels pending timers. A later `update` starts again from the real status. */ readonly dispose: () => void; } /** - * Turns a real status (for example the thread sync phase) into the status a - * client shows. A status that clears within `STATUS_SHOW_DELAY_MS` is never - * shown. A shown status stays for at least `STATUS_MIN_VISIBLE_MS`. Values - * compare by identity, so use strings or other stable values. - * - * Web and mobile wrap this in a small `useDelayedStatus` hook. + * Values compare by identity, so use strings or other stable values. */ export function createDelayedStatus( onChange: (shown: ShownStatus | null) => void, @@ -32,7 +22,6 @@ export function createDelayedStatus( let key = ""; let latest: A | null = null; let shown: A | null = null; - // While hidden, this is the show delay. While shown, the minimum visible time. let timer: ReturnType | undefined; const clearTimer = () => { @@ -43,7 +32,6 @@ export function createDelayedStatus( shown = null; onChange(null); }; - // Every shown value, including a new label, gets the full minimum visible time. const show = (value: A) => { shown = value; onChange({ key, value }); diff --git a/packages/client-runtime/src/device/androidFoldScene.ts b/packages/client-runtime/src/device/androidFoldScene.ts index b2c1f84fe038..98d393db9794 100644 --- a/packages/client-runtime/src/device/androidFoldScene.ts +++ b/packages/client-runtime/src/device/androidFoldScene.ts @@ -24,15 +24,9 @@ const DEPTH = 0.075; const INSET = 0.026; const CREASE = 0.004; const BEVEL = 0.008; -// The hinge axis sits just above the inner screens so closed halves meet face to face. const PIVOT_Z = DEPTH / 2 + 0.005; const SPINE_RADIUS = PIVOT_Z + DEPTH / 2 - 0.002; -/** Width over height of the unfolded inner display until a live frame reports its own. */ export const DEFAULT_FOLD_INNER_ASPECT = 2076 / 2152; -/** - * Unfolded inner displays are near square in either orientation. Cover displays are - * phone shaped (about 0.4-0.5, or 2-2.6 when rotated), so they never retune the body. - */ export const isFoldInnerAspect = (aspect: number) => Number.isFinite(aspect) && aspect > 0.75 && aspect < 1.5; @@ -87,10 +81,6 @@ function coverPath(halfWidth: number) { return roundedRectPath(halfWidth - INSET * 2 - 0.02, HEIGHT - INSET * 2 - 0.04, 0.07); } -/** - * A procedural book-style foldable: one fixed half, one half rotating around a shared hinge. - * The inner display keeps the raw framebuffer's native aspect, portrait or landscape. - */ export function createAndroidFoldScene( texture: Texture, layout: PhoneDisplayLayout, @@ -157,7 +147,6 @@ export function createAndroidFoldScene( coverMaterial, ]; - // Each half's meshes live in body coordinates; `left` pivots them around the hinge axis. left.position.z = PIVOT_Z; const leftBody = new Group(); leftBody.position.z = -PIVOT_Z; @@ -183,7 +172,6 @@ export function createAndroidFoldScene( ); frame.position.z = DEPTH / 2 + 0.001; group.add(frame); - // A back-facing shape mirrors X, so it is drawn from the opposite side's outline. const rear = new Mesh( new ShapeGeometry( panelPath(halfWidth, side === "left" ? "right" : "left", 0.01, 0.095, 0.002), @@ -206,8 +194,6 @@ export function createAndroidFoldScene( const innerLeft = half(leftBody, "left", bezel); const innerRight = half(right, "right", backGlass); - // One indexed surface keeps adjacent pixels joined at the crease. The - // physical halves move separately underneath it. const screenWidth = 2 * (halfWidth - INSET); const screenGeometry = new PlaneGeometry(screenWidth, screenHeight, 40, 48); const screenPositions = screenGeometry.getAttribute("position"); @@ -228,8 +214,6 @@ export function createAndroidFoldScene( const innerSurface = new Mesh(screenGeometry, displayMaterial); innerSurface.name = "continuous-inner-screen"; orientation.add(innerSurface); - // The outer half of the hinge housing. It tucks behind the back glass when - // open and becomes the rounded spine when closed. const spineSlack = 0.15; const spine = new Mesh( new CylinderGeometry( @@ -254,7 +238,6 @@ export function createAndroidFoldScene( cover.rotation.y = Math.PI; leftBody.add(cover); - // Rear components use back-surface coordinates, with outward positive Z. const rearCamera = new Group(); rearCamera.name = "rear-camera"; const islandWidth = 0.46; @@ -302,7 +285,6 @@ export function createAndroidFoldScene( flash.position.set(0.185, 0.045, plateFront + 0.0005); rearCamera.add(flash); - // Power and volume keys sit on the fixed half's outer edge. for (const [y, length] of [ [0.52, 0.16], [0.2, 0.3], diff --git a/packages/client-runtime/src/device/deviceFraming.ts b/packages/client-runtime/src/device/deviceFraming.ts index 1d8dddb10eb3..60ee0170ee78 100644 --- a/packages/client-runtime/src/device/deviceFraming.ts +++ b/packages/client-runtime/src/device/deviceFraming.ts @@ -1,6 +1,5 @@ import { Box3, Vector3 } from "three"; -/** Fit the current assembly, with independent framing dynamics so a leaf pivot does not drag it off-screen. */ export function createDeviceFraming() { const center = new Vector3(); const target = new Vector3(); @@ -62,7 +61,6 @@ export function createDeviceFraming() { }, advance, distance() { - // Spring lag must never clip the fitted assembly. const clearance = Math.max( Math.abs(bounds.min.x - center.x) / tanX, Math.abs(bounds.max.x - center.x) / tanX, diff --git a/packages/client-runtime/src/device/deviceMotion.ts b/packages/client-runtime/src/device/deviceMotion.ts index d2383c65665a..ceb793ca32e4 100644 --- a/packages/client-runtime/src/device/deviceMotion.ts +++ b/packages/client-runtime/src/device/deviceMotion.ts @@ -1,6 +1,6 @@ import { Quaternion, Vector3 } from "three"; -/** Shortest rotation vector. q and -q represent the same orientation. */ +/** q and -q represent the same orientation. */ export function rotationVector(rotation: Quaternion) { const q = rotation.clone().normalize(); if (q.w < 0) q.set(-q.x, -q.y, -q.z, -q.w); @@ -16,21 +16,17 @@ function fromVector(vector: Vector3) { : new Quaternion().setFromAxisAngle(vector.clone().divideScalar(angle), angle); } -// Recovered from Bitrig 0.25's rotation dynamics: point gain, drag force limit, -// velocity limits, release prediction/blend, and the 0.5 / 0.78 release spring. const gain = 0.006; const response = 0.5; const damping = 0.78; const frequency = (2 * Math.PI) / response; const decay = frequency * damping; const stepSeconds = 1 / 120; -// Fast releases combine exponential friction with a critically damped correction. const flickThreshold = 4.5; const flickLimit = 22; const spinDecay = 1.1; const settleFrequency = 2; -/** A moving spring target follows cumulative camera-space gestures; release latches the nearest useful view. */ export function createDeviceMotion(options: { choose: (rotation: Quaternion) => Quaternion }) { const rotation = new Quaternion(); const target = new Quaternion(); @@ -61,7 +57,6 @@ export function createDeviceMotion(options: { choose: (rotation: Quaternion) => }; const release = (now: number) => { if (!drag) return; - // A pause before lifting a pointer should not resurrect an old flick. if (now - lastInput > 100) gestureVelocity.set(0, 0, 0); const prediction = target .clone() @@ -71,8 +66,6 @@ export function createDeviceMotion(options: { choose: (rotation: Quaternion) => const rest = drag.rest; drag = null; if (moved && velocity.length() >= flickThreshold) { - // Choose where the free spin would finish, then correct toward that view - // throughout the coast rather than handing off to a spring at low speed. const projected = rotation .clone() .premultiply(fromVector(velocity.clone().multiplyScalar(1 / spinDecay))); @@ -155,8 +148,7 @@ export function createDeviceMotion(options: { choose: (rotation: Quaternion) => q.premultiply(fromVector(speed.clone().multiplyScalar(dt))).normalize(); }; // Fixed ticks make release independent of display refresh rate. The final - // partial tick interpolates the next tick without committing it. This also - // keeps displayed angular speed bounded between tick boundaries. + // partial tick interpolates the next tick without committing it. for (; spring.steps < steps; spring.steps++) integrate(spring.rotation, spring.velocity, stepSeconds); rotation.copy(spring.rotation); @@ -210,8 +202,6 @@ export function createDeviceMotion(options: { choose: (rotation: Quaternion) => }, dragActive(active: boolean, now: number) { if (active) { - // A pointer takes over at the displayed pose, even if a wheel gesture - // was paused with its target still ahead of the spring. if (drag || spin) { advance(now); drag = null; @@ -223,7 +213,6 @@ export function createDeviceMotion(options: { choose: (rotation: Quaternion) => release(now); } }, - /** Deltas are CSS pixels, independent of panel size and event partitioning. */ orbit(x: number, y: number, now: number) { if (held || ![x, y, now].every(Number.isFinite) || (!x && !y)) return; advance(now); @@ -243,7 +232,6 @@ export function createDeviceMotion(options: { choose: (rotation: Quaternion) => else gestureVelocity.set(0, 0, 0); lastInput = now; }, - /** Captured device touches and hinge edits freeze projection, independently of orbit dragging. */ hold(active: boolean, now: number) { if (held === active) return; if (active) { diff --git a/packages/client-runtime/src/device/deviceViewSnap.ts b/packages/client-runtime/src/device/deviceViewSnap.ts index 20402a80a35e..46fb4b5be488 100644 --- a/packages/client-runtime/src/device/deviceViewSnap.ts +++ b/packages/client-runtime/src/device/deviceViewSnap.ts @@ -1,7 +1,6 @@ import { Quaternion, Vector3 } from "three"; const y = new Vector3(0, 1, 0); -/** Each useful view permits a small yaw. Pick the closest member of each family, then the closest family. */ export function nearestDeviceView( rotation: Quaternion, snaps: readonly T[], diff --git a/packages/client-runtime/src/device/duoControl.test.ts b/packages/client-runtime/src/device/duoControl.test.ts index 2755e1975939..dc8ba7e522b1 100644 --- a/packages/client-runtime/src/device/duoControl.test.ts +++ b/packages/client-runtime/src/device/duoControl.test.ts @@ -114,7 +114,6 @@ it("pinches only a hit device, accumulates independently of native readback, cla expect(pinch.active).toBe(false); }); -// Screen configs as an iPhone Duo simulator reports them for each way of holding the device. it.each([ [ "vertical phone, closed", @@ -146,14 +145,12 @@ it.each([ }); it("marks a display handoff unsettled while the cover reports the inner display's orientation", () => { - // Recorded from the iPhone Duo simulator opening a closed vertical phone to a book. const handoff = [ { screenId: 1, orientation: "portrait", hingeAngle: 90 }, { screenId: 1, orientation: "landscape_left", hingeAngle: 90 }, { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, ] as const; expect(handoff.map((screen) => duoFoldState(screen).settled)).toEqual([false, false, true]); - // Closing hands back to the cover the same way. expect(duoFoldState({ screenId: 3, orientation: "landscape_left", hingeAngle: 0 }).settled).toBe( false, ); @@ -170,7 +167,6 @@ it("falls back like the 3D view when hinge fields are missing", () => { fold: "closed", phoneVertical: true, }); - // Without a display ID the 3D view draws the open inner panel, so the controls do too. expect(duoFoldState({ orientation: "landscape_left" })).toMatchObject({ fold: "open", phoneVertical: true, @@ -183,7 +179,6 @@ it("rotates a stand back to how the phone was held, in the frame of the display // leaves a phone that opens and closes vertical. expect(duoHoldOrientation(true, 3)).toBe("landscape_left"); expect(duoHoldOrientation(true, 1)).toBe("portrait"); - // A quarter turn apart, these describe the same horizontal phone. expect(duoHoldOrientation(false, 1)).toBe("landscape_left"); expect(duoHoldOrientation(false, 3)).toBe("portrait_upside_down"); for (const screenId of [1, 3]) diff --git a/packages/client-runtime/src/device/duoControl.ts b/packages/client-runtime/src/device/duoControl.ts index 3a4026b97650..696bea7a9b65 100644 --- a/packages/client-runtime/src/device/duoControl.ts +++ b/packages/client-runtime/src/device/duoControl.ts @@ -1,7 +1,6 @@ // @effect-diagnostics globalTimers:off - The stream owns this browser control queue and its timeout. import type { DeviceScreenSize } from "./stream.ts"; -/** Native hinge presets. Each one also sets the device's physical orientation. */ export type DuoPose = "closed" | "book" | "open" | "laptop" | "tent"; export type DuoOrientation = | "portrait" @@ -21,10 +20,9 @@ export type DuoControlState = { }; /** - * Whether the reporting display is the one the fold shows: the cover when closed, the inner - * display when open. A fold across the closed position hands off in steps, and the outgoing - * display first reports the incoming one's orientation, so an unsettled screen's orientation - * does not say how the phone is held. Stands such as Tent can also rest unsettled on the cover. + * A fold across the closed position hands off in steps, and the outgoing display first reports + * the incoming one's orientation, so an unsettled screen's orientation does not say how the + * phone is held. Stands such as Tent can also rest unsettled on the cover. */ export function duoScreenSettled(screen: Pick) { const angle = screen.hingeAngle ?? (screen.screenId === 1 ? 0 : 180); @@ -32,9 +30,8 @@ export function duoScreenSettled(screen: Pick, @@ -49,11 +46,6 @@ export function duoFoldState( } as const; } -/** - * The rotation that holds the phone vertical or horizontal. Native rotation is - * read in the frame of the display active when it arrives, and the inner panel - * is mounted a quarter turn from the cover. - */ export function duoHoldOrientation( vertical: boolean, screenId: number | undefined, @@ -62,7 +54,6 @@ export function duoHoldOrientation( return vertical ? "landscape_left" : "portrait_upside_down"; } -/** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */ export function createDuoControl(options: { send: (request: { requestId: number; command: DuoCommand }) => boolean; onChange: (state: DuoControlState) => void; @@ -119,7 +110,6 @@ export function createDuoControl(options: { }; } -/** A pinch keeps its own accumulator across asynchronous native acknowledgements. */ export function createDuoPinch(options: { angle: () => number; contains: (x: number, y: number) => boolean; diff --git a/packages/client-runtime/src/device/duoScene.ts b/packages/client-runtime/src/device/duoScene.ts index eada17e5a8fc..2b018bdf41d8 100644 --- a/packages/client-runtime/src/device/duoScene.ts +++ b/packages/client-runtime/src/device/duoScene.ts @@ -21,7 +21,6 @@ export type DuoPanelId = 1 | 3; export type DuoHingeLeaf = "left" | "right"; export type DuoFrameLayout = { width: number; height: number }; -/** Hardware mounting is independent of app orientation and the model's orbit. The inverse is shared by taps and drags. */ export function duoRawPoint(panel: DuoPanelId, x: number, y: number) { return panel === 1 ? { x, y } : { x: y, y: 1 - x }; } @@ -77,7 +76,6 @@ export function createDuoScene(asset: Group, textures: Record(); - // The two inner meshes sample halves of one framebuffer. Cover UVs face outward on the rear leaf. for (const id of [1, 3] as const) { const { bounds, size } = boundsByPanel[id === 1 ? 0 : 1]!; for (const mesh of surfaces[id]) { @@ -109,7 +107,6 @@ export function createDuoScene(asset: Group, textures: Record 20 && currentAngle < 165 ? [inside, frame([innerLeft], "left"), frame([innerRight], "right")] : [inside]; @@ -206,7 +202,6 @@ export function createDuoScene(asset: Group, textures: Record; diff --git a/packages/client-runtime/src/device/duoViewer.test.ts b/packages/client-runtime/src/device/duoViewer.test.ts index abe3da650650..845f71c2eadf 100644 --- a/packages/client-runtime/src/device/duoViewer.test.ts +++ b/packages/client-runtime/src/device/duoViewer.test.ts @@ -246,7 +246,7 @@ it("keeps the chosen release view when an app locks orientation, freezes it duri expect(onOrientationRequested).toHaveBeenCalledOnce(); const chosen = state.views.at(-1)!.quaternion.clone(); expect(chosen.angleTo(initial)).toBeGreaterThan(1); - viewer.setScreen({ ...screen }); // Native config confirms the sensor command, even if the app stays portrait. + viewer.setScreen({ ...screen }); draw(); expect(state.views.at(-1)!.quaternion.angleTo(chosen)).toBeLessThan(1e-6); expect(pending.size).toBe(0); @@ -335,7 +335,6 @@ it("faces a display handed off by native rotation without requesting another sen // position lets the cover report the inner display's orientation before handing // off. A button's pending angle reaches the viewer as a hinge preview first. it("presents fold buttons centered on the hinge: book, tablet, and an upright closed phone", async () => { - // Configurations arrive while the view is still easing; time only advances to settle. let now = 0; vi.stubGlobal("performance", { now: () => now }); const { viewer, draw, state, onOrientationRequested } = fixture(false); @@ -356,7 +355,6 @@ it("presents fold buttons centered on the hinge: book, tablet, and an upright cl ...config, hingeAngle: angle, }); - // The command reply follows the first configuration. if (index === 0) viewer.setHingePreview(null); draw(); }); @@ -388,12 +386,10 @@ it("presents fold buttons centered on the hinge: book, tablet, and an upright cl { screenId: 1, orientation: "portrait" }, { screenId: 1, orientation: "landscape_left" }, { screenId: 3, orientation: "landscape_left" }, - // Election can flap back to the cover once before settling. { screenId: 1, orientation: "landscape_left" }, { screenId: 3, orientation: "landscape_left" }, ]); expectInnerFacing(0.45); - // Opening the book flat faces the whole tablet; folding back centers the book again. press(180, [{ screenId: 3, orientation: "landscape_left" }]); expectInnerFacing(0.99); press(90, [{ screenId: 3, orientation: "landscape_left" }]); @@ -603,7 +599,6 @@ it.each([true, false])( if (!configurationFirst) viewer.setScreen(tent); draw(); draw(); - // Each leaf runs from the hinge down to the surface it rests on. for (const [name, outward] of [ ["left-half", -1], ["right-half", 1], @@ -645,7 +640,7 @@ it("lets standalone rotation leave Laptop and Tent and stand a closed device upr }); viewer.setHingePreview(null); draw(); - viewer.cancelInput(); // Explicit toolbar rotation ends folding ownership. + viewer.cancelInput(); viewer.setScreen({ width: 1398, height: 2034, @@ -695,7 +690,7 @@ it("snaps onto the opposite screen, requests native handoff once and retains the expect(onOrientationRequested).not.toHaveBeenCalled(); expect(viewer.screenPoint(0.5, 0.5)).toBeNull(); const chosen = state.views.at(-1)!.quaternion.clone(); - viewer.setScreen({ ...inner, hingePose: null }); // Command acknowledgement precedes sensor readback. + viewer.setScreen({ ...inner, hingePose: null }); draw(); expect(state.views.at(-1)!.quaternion.angleTo(chosen)).toBeLessThan(1e-6); viewer.setScreen({ ...inner, width: 1398, height: 2034, screenId: 1 }); @@ -822,10 +817,10 @@ it.each(["left", "right"] as const)( viewer.setScreen(next); viewer.setHingePreview(null, true); draw(); - viewer.setScreen({ ...next }); // Late native readback cannot change the view. + viewer.setScreen({ ...next }); draw(); expect(leaf.getWorldQuaternion(new Rotation()).angleTo(orientation)).toBeLessThan(1e-6); - expect(viewer.screenPoint(0.5, 0.5)).toBeNull(); // No fresh native frame yet. + expect(viewer.screenPoint(0.5, 0.5)).toBeNull(); if (value === 0) { const cover = loaded.getObjectByName("cover-display") as Mesh; const center = cover.geometry.boundingBox!.getCenter(new Vector3()); diff --git a/packages/client-runtime/src/device/duoViewer.ts b/packages/client-runtime/src/device/duoViewer.ts index 6e4b443da317..7d82c15e3f2d 100644 --- a/packages/client-runtime/src/device/duoViewer.ts +++ b/packages/client-runtime/src/device/duoViewer.ts @@ -33,7 +33,6 @@ import { duoScreenSettled, type DuoPose } from "./duoControl.ts"; export interface DuoViewer { readonly setScreen: (screen: DeviceScreenSize | null) => void; - /** A pinch holds the leaf under it and follows the fingers; a fold button eases the hinge and re-presents the device. */ readonly setHingePreview: (angle: number | null, pinch?: boolean) => void; readonly setInteractionActive: (active: boolean, mode?: "touch" | "orbit") => void; readonly rejectOrientation: () => void; @@ -51,7 +50,6 @@ export interface DuoViewer { readonly dispose: () => void; } -/** On-demand renderer for the articulated body. One inner framebuffer spans both leaves; HID belongs to the stream. */ export function createDuoViewer(options: { canvas: HTMLCanvasElement; sources: Record; @@ -140,7 +138,6 @@ export function createDuoViewer(options: { if (!model || !screen?.supportsPhysicalOrientation || !options.onPanelRequested) return activeSnaps(); const cover = duoViewSnaps(model.restFrames(1), 1); - // A shut inner display is occluded and cannot become a useful rest view. return angle > 20 && angle < 180 ? [...duoViewSnaps(model.restFrames(3), 3), ...cover] : activeSnaps(); @@ -200,7 +197,6 @@ export function createDuoViewer(options: { model.setAngle(angle); appliedAngle = angle; if (before && hingeLeaf) { - // The primary surface stays in camera space; its partner supplies the fold. // Rebase both the displayed rotation and its rest target so releasing a // pinch cannot resume the pre-fold body rotation. const correction = before.multiply(model.leafRotation(hingeLeaf).invert()); @@ -332,10 +328,6 @@ export function createDuoViewer(options: { screen = next; const changedPose = next?.hingePose && next.hingePose !== previous?.hingePose; const folding = hingeLeaf !== null && !changedPose; - // A hinge change no held leaf owns re-presents the device, centered on the - // hinge, once the display that shows the new fold reports. Across the closed - // position that takes steps: the outgoing display first reports the incoming - // one's orientation. const angleBefore = previous?.hingeAngle ?? (previous?.screenId === 1 ? 0 : 180); const angleAfter = next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180); if (!next || folding || changedPose) foldPending = false; @@ -360,9 +352,6 @@ export function createDuoViewer(options: { } // A command reply/config confirms hinge state. Display identity, never an angle heuristic, owns input. targetAngle = previewAngle ?? next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180); - // Native angle commands clear hingePose. While a leaf is held (a pinch, or a - // fold on a stand) they change articulation only and preserve the viewing - // pose. A separate rotation clears the physical preset and follows panel orientation. if ( firstPose || changedPose || @@ -431,8 +420,6 @@ export function createDuoViewer(options: { if (!moving()) lastTime = performance.now(); previewAngle = next; if (next !== null && !pinch) { - // A fold button holds no leaf, so the device is presented, centered on - // the hinge, once the fold lands. hingeLeaf = null; } else if (next !== null && !hingeLeaf && model) { clearHandoff(); @@ -442,7 +429,6 @@ export function createDuoViewer(options: { } orbit.hold(interactionActive || (pinch && next !== null), performance.now()); targetAngle = next ?? screen?.hingeAngle ?? (screen?.screenId === 1 ? 0 : 180); - // A pinch follows the fingers; anything else eases toward the target. if (next !== null && pinch) { angle = next; applyPose(); @@ -561,9 +547,6 @@ export function createDuoViewer(options: { if (!leaf) return false; clearHandoff(); requestedOrientation = null; - // The right inner leaf and the shut cover share the front-facing plane. - // Holding that leaf lets the cover replace the inner image without a - // half turn, even when the pinch starts over the moving partner. hingeLeaf = screen.screenId === 1 && angle > 20 ? "left" : "right"; orbit.setPose(orbit.rotation.clone(), performance.now(), true); model.cancelInput(); diff --git a/packages/client-runtime/src/device/frame.ts b/packages/client-runtime/src/device/frame.ts index bb875b0b4a24..706d38f8cc3c 100644 --- a/packages/client-runtime/src/device/frame.ts +++ b/packages/client-runtime/src/device/frame.ts @@ -3,7 +3,6 @@ export interface DeviceFrameSink { readonly present: (source: CanvasImageSource, width: number, height: number) => boolean; } -/** Retains the latest frame in a canvas; consumers can invalidate textures after each draw. */ export function createCanvasFrameSink( canvas: HTMLCanvasElement, onFrame?: (width: number, height: number) => void, diff --git a/packages/client-runtime/src/device/hubAccess.ts b/packages/client-runtime/src/device/hubAccess.ts index 9c010a2583f7..77ff00d7a06d 100644 --- a/packages/client-runtime/src/device/hubAccess.ts +++ b/packages/client-runtime/src/device/hubAccess.ts @@ -1,12 +1,7 @@ -/** Credentials for media requests that cannot set bearer or DPoP headers. */ export interface DeviceHubAccess { - /** Absolute environment URL ending in `/api/device-hub`. */ readonly httpBase: string; - /** Same base with the `ws(s)` scheme. */ readonly wsBase: string; - /** Empty for cookie sessions; includes a short-lived ticket for bearer and DPoP sessions. */ readonly query: Readonly>; - /** Whether requests must include session cookies. */ readonly credentials: boolean; } diff --git a/packages/client-runtime/src/device/model.ts b/packages/client-runtime/src/device/model.ts index c1a66f33365e..4036c2d741ab 100644 --- a/packages/client-runtime/src/device/model.ts +++ b/packages/client-runtime/src/device/model.ts @@ -15,7 +15,6 @@ export interface DeviceAccessorySource { export type DeviceAssetSource = DeviceModelSource | DeviceAccessorySource; -/** Match actual hardware, never stretch an available model to impersonate another device. */ export function resolveDeviceModelId(platform: DevicePlatform, name: string): DeviceModelId | null { if (platform !== "ios") return null; if (/^iPhone Duo$/i.test(name)) return "iphone-duo"; @@ -73,7 +72,6 @@ export function createDeviceModelSlot< request = null; options.onError?.(cause); } - // The procedural body stays installed on download, decoding or validation failure. }, ); }, diff --git a/packages/client-runtime/src/device/modelScene.ts b/packages/client-runtime/src/device/modelScene.ts index 6e1ce6eb82b3..3354cb1a31c6 100644 --- a/packages/client-runtime/src/device/modelScene.ts +++ b/packages/client-runtime/src/device/modelScene.ts @@ -30,7 +30,6 @@ export function disposeDeviceModel(root: Object3D) { for (const image of images) image.close(); } -/** Assets are converted offline: portrait, front +Z, centered display, height 2.2 and planar display UVs. */ export function createImportedPhoneScene( asset: Group, texture: Texture, diff --git a/packages/client-runtime/src/device/phoneInteraction.ts b/packages/client-runtime/src/device/phoneInteraction.ts index dd701f1a8b57..332864d6819b 100644 --- a/packages/client-runtime/src/device/phoneInteraction.ts +++ b/packages/client-runtime/src/device/phoneInteraction.ts @@ -7,7 +7,7 @@ export type PhoneNavigation = | { readonly type: "orbit"; readonly x: number; readonly y: number } | { readonly type: "zoom"; readonly delta: number }; -/** Browser wheel units vary by device. Navigation uses viewport fractions and logarithmic zoom. */ +/** Browser wheel units vary by device. */ export function phoneWheelNavigation(input: { readonly deltaX: number; readonly deltaY: number; @@ -30,7 +30,6 @@ export function phoneWheelNavigation(input: { }; } -/** A single pointer owns either a device gesture or an orbit until released or cancelled. */ export function createPhoneInteraction(options: { readonly screenPoint: (point: Point, captured: boolean) => Point | null; readonly touch: (phase: "begin" | "move" | "end", point: Point) => void; diff --git a/packages/client-runtime/src/device/phoneScene.test.ts b/packages/client-runtime/src/device/phoneScene.test.ts index 188b8891803f..a62ffe588b41 100644 --- a/packages/client-runtime/src/device/phoneScene.test.ts +++ b/packages/client-runtime/src/device/phoneScene.test.ts @@ -77,7 +77,6 @@ describe("3D phone input", () => { phone.root.rotation.set(0.3, -0.45, 0, "YXZ"); phone.orientation.rotation.z = layout.rotation; phone.root.updateMatrixWorld(true); - // A point one quarter across and three quarters down the portrait display. const point = new Vector3((-2.2 * layout.aspect) / 4, -2.2 / 4, 0.043); phone.orientation.localToWorld(point); point.project(camera); diff --git a/packages/client-runtime/src/device/phoneScene.ts b/packages/client-runtime/src/device/phoneScene.ts index 824288031452..6bf74cf0c95b 100644 --- a/packages/client-runtime/src/device/phoneScene.ts +++ b/packages/client-runtime/src/device/phoneScene.ts @@ -39,7 +39,6 @@ function roundedPath(width: number, height: number, radius: number, path = new S return path; } -/** Display orientation is independent of orbit. Texture coordinates stay in raw framebuffer space. */ export function phoneDisplayLayout( screen: DeviceScreenSize | null, rawWidth: number, @@ -65,7 +64,6 @@ export function phoneDisplayLayout( }; } -/** An original procedural device body. It makes no claim to reproduce a particular hardware model. */ export function createPhoneScene( texture: Texture, layout: ReturnType, @@ -154,7 +152,6 @@ export function createPhoneScene( ); orientation.add(button); } - // Rear components share a surface-relative coordinate system, with outward positive Z. const rearCamera = new Group(); rearCamera.name = "rear-camera"; rearCamera.position.set( @@ -236,7 +233,6 @@ export function createPhoneScene( export type PhoneDisplayLayout = ReturnType; -/** Canonical portrait geometry maps to raw framebuffer coordinates in every OS orientation. */ export function updateDisplayUv( geometry: BufferGeometry, width: number, @@ -263,7 +259,6 @@ export function updateDisplayUv( uv.needsUpdate = true; } -/** New touches hit only the front display; captured drags project onto its plane and clamp. */ export function createDisplayProjection( display: Mesh, orientation: Group, diff --git a/packages/client-runtime/src/device/phoneViewer.ts b/packages/client-runtime/src/device/phoneViewer.ts index 3dfc25048e02..af45745f8e48 100644 --- a/packages/client-runtime/src/device/phoneViewer.ts +++ b/packages/client-runtime/src/device/phoneViewer.ts @@ -106,12 +106,10 @@ export function createPhoneViewer(options: { let accessory: Awaited> | null = null; let accessoryBounds: Box3 | null = null; let foldTurn: { from: number; to: number; startedAt: number } | null = null; - // The inner display's raw width over height. Cover frames leave the last unfolded shape. const rawAspect = () => options.source.width / options.source.height; let foldAspect = isFoldInnerAspect(rawAspect()) ? rawAspect() : DEFAULT_FOLD_INNER_ASPECT; const createFoldScene = (angle: number, displayLayout = layout) => createAndroidFoldScene(texture, displayLayout, angle, foldAspect); - /** The hinge angle currently on screen, including an unfinished turn. */ const visibleFoldAngle = (fallback: number) => { if (!foldTurn) return fallback; const progress = Math.min(1, (performance.now() - foldTurn.startedAt) / ANDROID_FOLD_TURN_MS); @@ -229,7 +227,6 @@ export function createPhoneViewer(options: { next.rawLandscape !== layout.rawLandscape || next.rotation !== layout.rotation ) { - // The model and renderer survive framebuffer rotation and native resolution changes. if (resized) { const previous = texture; texture = makeTexture(); @@ -238,12 +235,10 @@ export function createPhoneViewer(options: { phone.setDisplay(texture, next); previous.dispose(); } - // Learn the inner display shape from any unfolded frame, including before fold mode. const frameAspect = rawAspect(); const innerChanged = isFoldInnerAspect(frameAspect) && frameAspect !== foldAspect; if (innerChanged) foldAspect = frameAspect; if (!imported && "setAngle" in phone && innerChanged) { - // A new inner display shape resizes the body; the hinge keeps its visible angle. const angle = visibleFoldAngle(foldAngle ?? 180); scene.remove(phone.root); phone.dispose(); @@ -288,7 +283,6 @@ export function createPhoneViewer(options: { load: loadDeviceModel, onError: options.onModelError, install(model) { - // Validate and prepare the next scene before releasing the visible one. const next = disposed ? null : model @@ -355,7 +349,6 @@ export function createPhoneViewer(options: { if (disposed || next === foldAngle) return; const previous = foldAngle; foldAngle = next; - // A loaded model owns the scene; install() reads foldAngle if it is removed. if (imported) return; if (next === null || !("setAngle" in phone)) { scene.remove(phone.root); diff --git a/packages/client-runtime/src/device/renderScheduler.ts b/packages/client-runtime/src/device/renderScheduler.ts index 203b15bfc054..059165ec331b 100644 --- a/packages/client-runtime/src/device/renderScheduler.ts +++ b/packages/client-runtime/src/device/renderScheduler.ts @@ -1,4 +1,3 @@ -/** Coalesces invalidations into one render. No work is scheduled while the view is idle. */ export function createRenderScheduler( render: () => void, request: (callback: FrameRequestCallback) => number = requestAnimationFrame, diff --git a/packages/client-runtime/src/device/screenshot.ts b/packages/client-runtime/src/device/screenshot.ts index 547fabfaa381..922295c69840 100644 --- a/packages/client-runtime/src/device/screenshot.ts +++ b/packages/client-runtime/src/device/screenshot.ts @@ -11,7 +11,6 @@ export class DeviceScreenshotError extends Error { } } -/** Captures native pixels through the same host and credentials as the live stream, in either presentation. */ export async function captureDeviceScreenshot(target: DeviceStreamTarget, signal: AbortSignal) { const vendor = target.platform === "ios" ? "serve-sim" : "serve-emu"; const url = withDeviceHubQuery( diff --git a/packages/client-runtime/src/device/shapeProfile.ts b/packages/client-runtime/src/device/shapeProfile.ts index 5ea04778e3ab..687c8ec45abc 100644 --- a/packages/client-runtime/src/device/shapeProfile.ts +++ b/packages/client-runtime/src/device/shapeProfile.ts @@ -23,7 +23,6 @@ export interface DeviceShapeProfile { }; } -/** Original family silhouettes, rather than claims to reproduce individual hardware models. */ export const IOS_PHONE_SHAPE: DeviceShapeProfile = { id: "ios-phone", bezel: 0.055, @@ -104,7 +103,6 @@ const ANDROID_TABLET_SHAPE: DeviceShapeProfile = { backColor: 0x697b80, }; -/** Names identify a family when available; wide unknown screens get a generic tablet shell. */ export function resolveDeviceShape(options: { platform: DevicePlatform; name?: string; diff --git a/packages/client-runtime/src/device/stream.test.ts b/packages/client-runtime/src/device/stream.test.ts index e92f1a8fa198..76dae57abd9c 100644 --- a/packages/client-runtime/src/device/stream.test.ts +++ b/packages/client-runtime/src/device/stream.test.ts @@ -602,7 +602,6 @@ describe("shared device stream readiness and recovery", () => { expect(socket.send).toHaveBeenCalledOnce(); await vi.advanceTimersByTimeAsync(1_000); expect(videoBodies).toHaveLength(2); - // The old feed never produces another IDR. Only a fresh subscription can recover. videoBodies[1]!.enqueue( new Uint8Array([...envelope(1, [1, 0x64, 0, 0x1f]), ...envelope(2, [4])]), ); diff --git a/packages/client-runtime/src/device/stream.ts b/packages/client-runtime/src/device/stream.ts index ae83bf93555c..6eeeb66cb9dc 100644 --- a/packages/client-runtime/src/device/stream.ts +++ b/packages/client-runtime/src/device/stream.ts @@ -1,24 +1,6 @@ // @effect-diagnostics globalFetch:off globalTimers:off - This browser and WebView transport runs without an Effect runtime. /* oxlint-disable unicorn/prefer-add-event-listener -- Each client owns its sockets and their handlers. */ -/** - * Framework-free client for expo-device-hub's per-device streams, reached - * through the T3 proxy. One class handles both platforms because the hub - * vendors two servers with different wire formats: - * - * - iOS (serve-sim): video is an HTTP `stream.avcc` body of length-prefixed - * envelopes (`u32be length, u8 tag, payload`; tag 1 avcC description, - * 2 keyframe, 3 delta, 4 JPEG seed) decoded with WebCodecs; input goes over - * `helper/ws?device=` as `[tag][json]` packets. When WebCodecs is - * unavailable (plain-http remote origins) the MJPEG endpoint is used as an - * `` source instead. - * - Android (serve-emu): one WebSocket at `ws?device=&frame-meta=1` - * carries H.264 access units prefixed with a 16-byte "SEMU" header - * (magic, version, key flag, pts) and accepts JSON gestures upstream. - * - * The decoder only runs while frames arrive and the viewer is attached; a - * hidden panel calls `stop()` so an idle device costs nothing on the GPU. - */ import { createDuoControl, type DuoCommand, @@ -85,11 +67,9 @@ export interface DuoPanelSinks { export interface DeviceStreamEvents { readonly onDuoControl?: (state: DuoControlState) => void; - /** A fixed panel cannot be decoded; the owner should return to the active flat feed. */ readonly onDuoUnavailable?: (detail?: string) => void; readonly onStatus: (status: DeviceStreamStatus, detail?: string) => void; readonly onScreen: (screen: DeviceScreenSize) => void; - /** The proxy rejected the credential; the owner should refresh access and reconnect. */ readonly onUnauthorized: () => void; /** * H.264 cannot be decoded here (no WebCodecs, or the simulator's profile is @@ -97,7 +77,6 @@ export interface DeviceStreamEvents { * and attach it with `setMjpegImage` so the client can observe real frames. */ readonly onMjpegFallback: (url: string) => void; - /** Whether touches and keys can currently reach the device. */ readonly onInputConnected: (connected: boolean, detail?: string) => void; } @@ -107,7 +86,6 @@ export interface DeviceStreamTarget { readonly access: DeviceHubAccess; /** Native iOS WebViews can use MJPEG without cross-origin fetch or secure-context support. */ readonly preferMjpeg?: boolean; - /** Internal fixed-panel feeds share their parent's input session. */ readonly panelId?: 1 | 3; readonly videoOnly?: boolean; } @@ -123,12 +101,10 @@ const SEMU_HEADER_BYTES = 16; const SEMU_FLAG_KEY = 1; const SOFT_DECODE_QUEUE = 8; -// serve-sim binary WS message tags (browser -> helper). const IOS_MSG_TOUCH = 0x03; const IOS_MSG_BUTTON = 0x04; const IOS_MSG_KEY = 0x06; const IOS_MSG_ORIENTATION = 0x07; -// helper -> browser. const IOS_TAG_SCREEN_CONFIG = 0x82; const IOS_TAG_INPUT_ADMITTED = 0x83; @@ -148,14 +124,12 @@ function taggedJson(tag: number, payload: unknown): Uint8Array { return out; } -/** Build the WebCodecs `avc1.PPCCLL` string from an avcC record or an SPS NAL. */ export function avcCodecString(bytes: Uint8Array): string { if (bytes.length < 4) return "avc1.42E01E"; const hex = (byte: number) => byte.toString(16).padStart(2, "0"); return `avc1.${hex(bytes[1]!)}${hex(bytes[2]!)}${hex(bytes[3]!)}`; } -/** Split serve-emu's SEMU-framed message into metadata and the Annex-B payload. */ export function parseSemuPacket(raw: ArrayBuffer): { readonly data: Uint8Array; readonly isKey: boolean | null; @@ -185,7 +159,6 @@ const isVideoSessionMessage = (text: string) => { } }; -/** Walk an Annex-B access unit for its keyframe flag and SPS bytes. */ export function scanAccessUnit(buf: Uint8Array): { isKey: boolean; sps: Uint8Array | null } { let isKey = false; let sps: Uint8Array | null = null; @@ -221,7 +194,6 @@ const AVCC_TAGS: Record = { 4: "seed", }; -/** Turns a fragmented AVCC byte stream into complete envelopes. */ export class AvccDemuxer { private buffer = new Uint8Array(64 * 1024); private length = 0; @@ -267,7 +239,6 @@ export interface DeviceStreamClient { readonly start: () => void; readonly stop: () => void; /** - * Own the displayed MJPEG image's source and frame/error observation. * `stop()` detaches it; attach a fresh image for each restart. */ readonly setMjpegImage: (image: HTMLImageElement | null) => void; @@ -278,9 +249,7 @@ export interface DeviceStreamClient { readonly rotate: () => void; readonly setOrientation: (orientation: DeviceScreenSize["orientation"]) => void; readonly controlDuo: (command: DuoCommand) => void; - /** Switch between one active feed and two fixed-panel feeds without replacing HID. */ readonly setDuoPanels: (panels: DuoPanelSinks | null) => void; - /** Model UVs already map to the hardware framebuffer. */ readonly sendRawTouch: (phase: "begin" | "move" | "end", x: number, y: number) => void; } @@ -467,7 +436,7 @@ export function createDeviceStreamClient( timer = null; if (image.naturalWidth > 0 && image.naturalHeight > 0) frameReceived(); else { - // Multipart images may not emit load until the response ends. Stop checking after the first frame. + // Multipart images may not emit load until the response ends. timer = setTimeout(check, MJPEG_FRAME_CHECK_MS); } }; @@ -523,9 +492,7 @@ export function createDeviceStreamClient( decoderEpoch++; try { videoDecoder?.close(); - } catch { - // Already closed. - } + } catch {} videoDecoder = null; awaitingKeyframe = true; }; @@ -568,7 +535,6 @@ export function createDeviceStreamClient( return decoder; }; - /** iOS can fall back to MJPEG when the stream's H.264 profile is unsupported. */ const configureDecoder = async ( config: VideoDecoderConfig, isCurrent = () => !stopped, @@ -645,7 +611,6 @@ export function createDeviceStreamClient( events.onUnauthorized(); }; - // iOS video: fetch the AVCC body and demux into the decoder. const readIosVideo = async () => { const lifecycle = generation; const feedGeneration = ++videoGeneration; @@ -768,7 +733,6 @@ export function createDeviceStreamClient( if (response.status === 401 || response.status === 403) return handleUnauthorized(); await response.body?.getReader().read(); } catch { - // A failed prime just means the socket may take a retry to come up. } finally { clearTimeout(timeout); controller.abort(); @@ -812,7 +776,6 @@ export function createDeviceStreamClient( for (const panel of panelClients) panel.start(); }; - // iOS input socket; also carries the screen config the helper pushes. const connectIosInput = async () => { if (stopped) return; const session = generation; @@ -874,9 +837,7 @@ export function createDeviceStreamClient( } } } - } catch { - // Ignore malformed config frames. - } + } catch {} }; ws.onclose = (event) => { if (socket !== ws) return; @@ -901,7 +862,6 @@ export function createDeviceStreamClient( ws.onerror = () => ws.close(); }; - // Android: one socket for video and input. const connectAndroid = () => { if (stopped) return; const ws = new WebSocket(wsUrl(`/ws?device=${device}&frame-meta=1`)); diff --git a/packages/client-runtime/src/diffCount.ts b/packages/client-runtime/src/diffCount.ts index b1008988fd14..fcd3836b1090 100644 --- a/packages/client-runtime/src/diffCount.ts +++ b/packages/client-runtime/src/diffCount.ts @@ -1,6 +1,5 @@ const DIFF_COUNT_FORMAT = new Intl.NumberFormat(); -/** Formats a diff line count with the viewer's digit grouping, so `83065` reads `83,065`. */ export function formatDiffCount(count: number): string { return DIFF_COUNT_FORMAT.format(count); } diff --git a/packages/client-runtime/src/errors/network.ts b/packages/client-runtime/src/errors/network.ts index 1e726bdd4d98..dce7935d9449 100644 --- a/packages/client-runtime/src/errors/network.ts +++ b/packages/client-runtime/src/errors/network.ts @@ -1,4 +1,2 @@ -// A failed request cannot distinguish filtering from an outage. Keep this a -// possible cause, and suggest a way to check without changing server settings. export const NETWORK_BLOCKING_HINT = "Your DNS or firewall may be blocking T3 Connect. Try another network, such as a phone hotspot."; diff --git a/packages/client-runtime/src/errors/transport.ts b/packages/client-runtime/src/errors/transport.ts index 52ef687a50a9..757b889c2c60 100644 --- a/packages/client-runtime/src/errors/transport.ts +++ b/packages/client-runtime/src/errors/transport.ts @@ -18,11 +18,6 @@ const TRANSPORT_ERROR_PATTERNS = [ /\bping timeout\b/i, ] as const; -/** - * Check whether an error message originates from a transport-level connection - * failure (socket close, socket open, ping timeout, etc.) rather than a - * business-logic error. - */ export function isTransportConnectionErrorMessage(message: string | null | undefined): boolean { if (typeof message !== "string") { return false; @@ -36,11 +31,6 @@ export function isTransportConnectionErrorMessage(message: string | null | undef return TRANSPORT_ERROR_PATTERNS.some((pattern) => pattern.test(normalizedMessage)); } -/** - * Strip transport connection errors from user-facing error messages. - * Returns `null` for transport errors so the UI can distinguish between - * real errors and transient connection issues. - */ export function sanitizeThreadErrorMessage(message: string | null | undefined): string | null { return isTransportConnectionErrorMessage(message) ? null : (message ?? null); } diff --git a/packages/client-runtime/src/handoff.ts b/packages/client-runtime/src/handoff.ts index 7c93ecbf28f6..1ac4db202bb7 100644 --- a/packages/client-runtime/src/handoff.ts +++ b/packages/client-runtime/src/handoff.ts @@ -7,11 +7,6 @@ import type { ServerProvider, } from "@t3tools/contracts"; -/** - * The subset of a projection run that handoff rows read. Kept minimal so the - * timeline can hold a content-stable snapshot: run status/timestamps churn on - * every stream event, but these fields only change when a run is added. - */ export type HandoffTimelineRun = Pick< OrchestrationV2Run, "id" | "ordinal" | "providerInstanceId" | "modelSelection" @@ -19,7 +14,6 @@ export type HandoffTimelineRun = Pick< export type HandoffEndpoints = ReturnType; -/** Preview the next send's handoff without adding anything to durable history. */ export function resolvePendingHandoff( projection: OrchestrationV2ThreadProjection | null | undefined, selection: ModelSelection | null | undefined, @@ -37,8 +31,7 @@ export function resolvePendingHandoff( const needsHandoff = from.instanceId === selection.instanceId ? from.model !== selection.model && target?.requiresNewThreadForModelChange === true - : // Accounts sharing a continuation group resume the same native thread. - source?.continuation?.groupKey === undefined || + : source?.continuation?.groupKey === undefined || source.driver !== target?.driver || source.continuation.groupKey !== target.continuation?.groupKey; return needsHandoff ? { from: [from], to: selection } : null; @@ -51,10 +44,6 @@ export function resolveHandoffEndpoints( >, runs: ReadonlyArray, ) { - // Items persisted before models were stamped only carry instance ids; - // recover the models from the thread's runs (the handoff's own run is - // the target, the newest earlier run per source instance is the origin). - // Clients fall back to the provider display name when neither source has a model. const handoffRun = item.runId === null ? undefined : runs.find((run) => run.id === item.runId); const toModel = item.toModel ?? @@ -74,11 +63,6 @@ export function resolveHandoffEndpoints( return { from: fromEndpoints, to: { instanceId: item.toProviderInstanceId, model: toModel } }; } -/** - * Model of the newest run for `instanceId` that started before the handoff's - * own run. Legacy handoff items don't record their source models, but the - * covered runs are still in the projection. - */ function latestRunModelBefore( runs: ReadonlyArray, instanceId: ProviderInstanceId, diff --git a/packages/client-runtime/src/imageAttachmentSize.ts b/packages/client-runtime/src/imageAttachmentSize.ts index 400a991cec46..06d987f76c0c 100644 --- a/packages/client-runtime/src/imageAttachmentSize.ts +++ b/packages/client-runtime/src/imageAttachmentSize.ts @@ -1,12 +1,10 @@ /** - * Longest edge, in pixels, a PNG or JPEG attachment keeps. Anthropic rejects an image over - * 2000px on a side once a request holds more than 20 images, and Claude Code forwards images - * sent into a running turn unchanged, so one large paste breaks every later request in that - * thread. Web and mobile scale attachments down to this edge when they are attached. + * Anthropic rejects an image over 2000px on a side once a request holds more than 20 images, and + * Claude Code forwards images sent into a running turn unchanged, so one large paste breaks + * every later request in that thread. */ export const IMAGE_ATTACHMENT_MAX_EDGE = 2000; -/** Formats a client re-encodes in place. GIF, WebP, and SVG are left as attached. */ export type DownscalableImageMimeType = "image/png" | "image/jpeg"; export function downscalableImageMimeType(mimeType: string): DownscalableImageMimeType | null { @@ -16,10 +14,6 @@ export function downscalableImageMimeType(mimeType: string): DownscalableImageMi return null; } -/** - * The size an image should be scaled to so its long edge fits `IMAGE_ATTACHMENT_MAX_EDGE`, - * keeping its aspect ratio. Returns null when it already fits. - */ export function fitImageAttachmentSize(size: { readonly width: number; readonly height: number; diff --git a/packages/client-runtime/src/markdownImages.ts b/packages/client-runtime/src/markdownImages.ts index 59a3228037b6..55e597f4d3c2 100644 --- a/packages/client-runtime/src/markdownImages.ts +++ b/packages/client-runtime/src/markdownImages.ts @@ -27,7 +27,6 @@ function joinWorkspacePath(workspaceRoot: string, relativePath: string): string } /** - * Classifies a markdown image or video source by where its bytes must be loaded from. * Filesystem paths belong to the environment host and must never reach a * browser or native image component without first becoming a signed asset URL. */ diff --git a/packages/client-runtime/src/mcpApps/host.test.ts b/packages/client-runtime/src/mcpApps/host.test.ts index 6c2a9f61666f..76ca106358c9 100644 --- a/packages/client-runtime/src/mcpApps/host.test.ts +++ b/packages/client-runtime/src/mcpApps/host.test.ts @@ -22,7 +22,6 @@ const context = ( platform: "web", }); -// Lets the host settle the promises its callbacks return. const flush = () => new Promise((resolve) => queueMicrotask(resolve)).then(() => undefined); const initialize = ( @@ -177,7 +176,6 @@ describe("makeMcpAppHost", () => { await flush(); expect(messages).toEqual(["one", "two"]); expect(sent.find((m) => m.id === 3)?.error).toMatchObject({ code: -32602 }); - // Blank text would ask to send an empty message. expect(sent.find((m) => m.id === 4)?.error).toMatchObject({ code: -32602 }); }); @@ -305,7 +303,6 @@ describe("makeMcpAppHost", () => { displayMode: "fullscreen", }); - // An app that declared only inline stays inline. const declined = setup(); initialize(declined.host, { availableDisplayModes: ["inline"] }); declined.host.receive({ @@ -373,7 +370,6 @@ describe("makeMcpAppHost", () => { }, ]); expect(sent.find((m) => m.id === 2)?.result).toEqual({}); - // A refused download is reported in the result, as the draft defines. expect(sent.find((m) => m.id === 3)?.result).toEqual({ isError: true }); expect(sent.find((m) => m.id === 4)?.error).toMatchObject({ code: -32602 }); }); @@ -386,7 +382,6 @@ describe("makeMcpAppHost", () => { host.receive({ jsonrpc: "2.0", method: "ui/notifications/request-teardown" }); expect(closeRequested).toBe(true); - // Before initialization there is nothing to ask. await host.teardown(); expect(sent.some((m) => m.method === "ui/resource-teardown")).toBe(false); @@ -408,7 +403,6 @@ describe("makeMcpAppHost", () => { expect(timedOut).toBe(false); await vi.advanceTimersByTimeAsync(1); expect(timedOut).toBe(true); - // A torn-down host posts nothing more. const before = silent.sent.length; silent.host.updateHostContext(); expect(silent.sent).toHaveLength(before); diff --git a/packages/client-runtime/src/mcpApps/host.ts b/packages/client-runtime/src/mcpApps/host.ts index 2a3bbd3f8e45..ed59c9770d7b 100644 --- a/packages/client-runtime/src/mcpApps/host.ts +++ b/packages/client-runtime/src/mcpApps/host.ts @@ -4,19 +4,8 @@ import * as Base64 from "effect/encoding/Base64"; import * as Predicate from "effect/Predicate"; import * as Result from "effect/Result"; -/** - * The host side of the MCP Apps bridge (spec 2026-01-26, plus the draft - * additions the current SDK sends: `ui/download-file` and - * `ui/notifications/request-teardown`): JSON-RPC 2.0 over postMessage between a - * client and one app document. It is transport-free so web (an iframe) and - * mobile (a WebView) share every protocol rule; each platform supplies how to - * post a message and how to reach the environment. - */ - const MAX_MESSAGE_BYTES = 256 * 1024; -/** Requests an app may have in flight at once; more are refused, not queued. */ const MAX_PENDING_REQUESTS = 16; -/** How long the host waits for an app to answer `ui/resource-teardown`. */ const TEARDOWN_TIMEOUT_MS = 2000; const encoder = new TextEncoder(); @@ -48,11 +37,9 @@ export interface McpAppHostContext { readonly bottom: number; readonly left: number; }; - /** The tool call that created the app: the server's MCP `Tool` definition. */ readonly toolInfo?: { readonly tool: unknown }; } -/** A file an app asked the host to save (`ui/download-file`): embedded, or linked on its server. */ export type McpAppDownload = | { readonly _tag: "embedded"; @@ -67,10 +54,6 @@ export type McpAppDownload = readonly mimeType?: string; }; -/** - * The bytes of an MCP resource's contents: `text` as UTF-8, `blob` decoded - * from base64. Undefined when it has neither or the base64 is malformed. - */ export function mcpResourceBytes(content: unknown): Uint8Array | undefined { if (!Predicate.isObject(content)) return undefined; if (typeof content.text === "string") return encoder.encode(content.text); @@ -88,13 +71,11 @@ export interface McpAppCallToolResult { readonly _meta?: unknown; } -/** Thrown by host callbacks to refuse a request with a message the app can show. */ export class McpAppHostRefusal extends Error {} export interface McpAppHostOptions { readonly app: McpAppReference; readonly hostVersion: string; - /** Posts one JSON-RPC message to the app document. */ readonly post: (message: unknown) => void; readonly hostContext: () => McpAppHostContext; @@ -110,13 +91,8 @@ export interface McpAppHostOptions { readonly content?: ReadonlyArray; readonly structuredContent?: Record; }) => Promise; - /** - * Switches the app to a mode the host and the app both offer, returning the - * resulting mode. The host has already checked both lists. - */ readonly requestDisplayMode: (mode: McpAppDisplayMode) => Promise; readonly downloadFile: (files: ReadonlyArray) => Promise; - /** The app asked to be closed (`ui/notifications/request-teardown`). */ readonly onRequestTeardown: () => void; readonly onSizeChanged: (size: { readonly width?: number; readonly height?: number }) => void; } @@ -124,15 +100,10 @@ export interface McpAppHostOptions { export interface McpAppHost { /** Handles one message the app posted; the caller has already checked its source. */ readonly receive: (data: unknown) => void; - /** - * Supplies the original tool call's arguments and result. They reach the - * app once it has initialized, whichever happens last, and only once. - */ readonly setToolCall: (call: { readonly arguments: unknown; readonly result: McpAppCallToolResult | undefined; }) => void; - /** Sends `host-context-changed` with the fields that differ from what the app last saw. */ readonly updateHostContext: () => void; /** * Asks the app to wrap up before its document goes away (`ui/resource-teardown`), @@ -155,9 +126,8 @@ function isId(value: unknown): value is JsonRpcId { } /** - * A `CallToolResult` as the spec's schema accepts it. Providers can report - * absent fields as null (Codex sends `_meta: null`), which the MCP Apps SDK - * rejects, dropping the whole notification or response. + * Providers can report absent fields as null (Codex sends `_meta: null`), which + * the MCP Apps SDK rejects, dropping the whole notification or response. */ function normalizeMcpAppToolResult(result: McpAppCallToolResult): McpAppCallToolResult { return { @@ -186,7 +156,6 @@ export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost { let toolCallSent = false; let pending = 0; let sentContext: McpAppHostContext | undefined; - /** Modes the app declared at initialize; undefined when it declared none. */ let appDisplayModes: ReadonlyArray | undefined; let nextHostRequestId = 0; const hostRequests = new Map void>(); @@ -372,7 +341,6 @@ export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost { answer(id, () => options.downloadFile(files).then( () => ({}), - // The draft reports a refused or failed download in the result. (error: unknown) => error instanceof McpAppHostRefusal ? { isError: true } : Promise.reject(error), ), @@ -390,7 +358,6 @@ export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost { if (initialized) return; initialized = true; sendToolCall(); - // The theme or size may have changed since the initialize response. hostHandle.updateHostContext(); return; } @@ -409,11 +376,9 @@ export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost { options.onRequestTeardown(); return; default: - // notifications/message (logging) and unknown notifications are ignored. } }; - /** A response to a request the host sent the app. */ const handleResponse = (id: JsonRpcId) => { const settle = hostRequests.get(String(id)); if (settle === undefined) return; @@ -490,7 +455,6 @@ export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost { const MAX_DOWNLOAD_NAME = 200; -/** The file name an app gave a download, or the last part of its URI. */ function downloadName(uri: unknown, name: unknown): string { const raw = typeof name === "string" && name.trim() !== "" @@ -498,14 +462,12 @@ function downloadName(uri: unknown, name: unknown): string { : typeof uri === "string" ? (uri.split(/[/?#]/).findLast(Boolean) ?? "download") : "download"; - // Path separators, reserved characters, and control characters. const safe = Array.from(raw, (char) => /[\\/:*?"<>|]/.test(char) || char.charCodeAt(0) < 32 ? "-" : char, ).join(""); return safe.slice(0, MAX_DOWNLOAD_NAME) || "download"; } -/** `ui/download-file` contents: MCP embedded resources and resource links. */ function readDownloads(contents: unknown): ReadonlyArray | undefined { if (!Array.isArray(contents) || contents.length === 0) return undefined; const files: Array = []; @@ -536,10 +498,6 @@ function readDownloads(contents: unknown): ReadonlyArray | undef return files; } -/** - * Maps T3's resolved theme variables to the spec's standardized style names, so - * apps written for any MCP Apps host pick up the thread's colors and fonts. - */ export function mcpAppStyleVariables( theme: Readonly>, ): Record { diff --git a/packages/client-runtime/src/mediaActions.ts b/packages/client-runtime/src/mediaActions.ts index e0256dff93fe..fda507b17bb1 100644 --- a/packages/client-runtime/src/mediaActions.ts +++ b/packages/client-runtime/src/mediaActions.ts @@ -1,4 +1,3 @@ -/** Menu action ids shared by every client so labels and handlers line up across surfaces. */ export type MediaActionId = | "copy-full-path" | "copy-relative-path" diff --git a/packages/client-runtime/src/mediaReference.ts b/packages/client-runtime/src/mediaReference.ts index da98d4b7ee5e..71455bf36d52 100644 --- a/packages/client-runtime/src/mediaReference.ts +++ b/packages/client-runtime/src/mediaReference.ts @@ -2,7 +2,6 @@ import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { safeDecodeURIComponent } from "@t3tools/shared/markdownLinks"; -/** The authored media location, never the temporary URL used to load its bytes. */ export type MediaReference = | { readonly kind: "file"; @@ -31,7 +30,6 @@ function absolutePathParts(path: string) { return { root: windows ? root.toLowerCase() : root, segments, windows }; } -/** Compares paths lexically for the copy menu; it does not resolve filesystem symlinks. */ export function mediaFileReference( path: string, workspaceRoot?: string | null, diff --git a/packages/client-runtime/src/mediaSource.test.ts b/packages/client-runtime/src/mediaSource.test.ts index 9db7c5fee428..952a256c1be5 100644 --- a/packages/client-runtime/src/mediaSource.test.ts +++ b/packages/client-runtime/src/mediaSource.test.ts @@ -53,7 +53,6 @@ describe("resolveMediaSource", () => { }); describe("host paths", () => { - // POSIX, Windows, UNC, and file URLs must all reach the same media-file resource. it.each([ ["/tmp/frame%23one.png:12", "/tmp/frame#one.png"], ["/tmp/frame%3Fone.png:12:3", "/tmp/frame?one.png"], diff --git a/packages/client-runtime/src/mediaSource.ts b/packages/client-runtime/src/mediaSource.ts index 5760536a7220..e248f0661823 100644 --- a/packages/client-runtime/src/mediaSource.ts +++ b/packages/client-runtime/src/mediaSource.ts @@ -18,7 +18,6 @@ import { export type MediaSourceResource = Extract; -/** What a piece of authored media is and how its bytes can be reached. */ export type ResolvedMediaSource = { readonly kind: "image" | "video"; readonly mimeType: string; diff --git a/packages/client-runtime/src/operations/commands.performance.test.ts b/packages/client-runtime/src/operations/commands.performance.test.ts index 112560bf5f34..279c994eb33e 100644 --- a/packages/client-runtime/src/operations/commands.performance.test.ts +++ b/packages/client-runtime/src/operations/commands.performance.test.ts @@ -33,18 +33,9 @@ import { startThreadTurn } from "./commands.ts"; const ROW_COUNT = 600; const OUTPUT_BYTES_PER_ROW = 8_192; -// Recorded before server-resolved dispatch and raw tool-output wire omission at -// 91b193653ec. The old path read this entire response once for every routine -// implicit-auto message send; keep the raw fixture to preserve that baseline. const HISTORICAL_FULL_PROJECTION_APPLICATION_BYTES = 10_371_419; -// Projection response + dispatch command + dispatch receipt, excluding RPC -// envelope bytes. The omitted projection request only makes the old path larger. const HISTORICAL_FORMER_SEND_APPLICATION_BYTES = 10_371_678; -// Corrected measurement: contract-encoded payloads in Effect RPC Request/Exit -// envelopes before WebSocket framing and compression. Trace values use the -// production 32/16 hex-character widths so the byte cost is representative. const FORMER_SEND_RPC_JSON_BYTES = 10_372_184; -// The corrected first server-resolved implementation measured 522 bytes. const MAX_CURRENT_SEND_RPC_JSON_BYTES = 1_024; const RPC_TRACE_CONTEXT = { diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 2c38e18f3e5c..a02554bb0fb7 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -716,7 +716,6 @@ describe("V2 environment commands", () => { attachments: [{ id: "attachment-kept" }], }, ]); - // A text-only edit must not send an attachments replacement list. expect(commands[5]).not.toHaveProperty("attachments"); }).pipe(Effect.provide(layerTestCrypto)), ); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 83ca6b4f663a..b16a2f5b4895 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -92,18 +92,15 @@ export interface UnsettleThreadInput extends ThreadCommandInput { } export interface PinThreadInput extends ThreadCommandInput { - /** Initial slot in the user-arranged pinned order; omit to pin keyless. */ readonly orderKey?: string; } export type UnpinThreadInput = ThreadCommandInput; export interface ReorderPinnedThreadInput extends ThreadCommandInput { - /** Fractional-index key that sorts between the drop position's neighbors. */ readonly orderKey: string; } export interface ReorderActiveThreadInput extends ThreadCommandInput { - /** Fractional-index key that sorts between the drop position's neighbors. */ readonly orderKey: string; } @@ -116,7 +113,6 @@ export interface UnsnoozeThreadInput extends ThreadCommandInput { } export interface VisitThreadInput extends ThreadCommandInput { - /** Watermark of the thread state the viewer has seen (ISO timestamp). */ readonly visitedAt: string; } @@ -128,11 +124,8 @@ export interface UpdateThreadMetadataInput extends ThreadCommandInput { readonly modelSelection?: ModelSelection; readonly branch?: string | null; readonly worktreePath?: string | null; - /** Kick off an async title regeneration for the thread. */ readonly regenerateTitle?: boolean; - /** Link (object) or unlink (null) a pull request (#8160). */ readonly linkedPullRequest?: ThreadLinkedPullRequest | null; - /** Apply only while no message or run has landed on the thread. */ readonly expectedEmpty?: boolean; } @@ -156,7 +149,6 @@ interface StartThreadBootstrap { readonly createdAt: string; }; readonly prepareWorktree?: { - /** V2 worktree launches always fail rather than falling back to the project checkout. */ readonly requireWorktree?: boolean; readonly projectCwd: string; readonly baseBranch: string; @@ -187,7 +179,6 @@ export interface StartThreadTurnInput extends ThreadCommandInput { export interface InterruptThreadTurnInput extends ThreadCommandInput { readonly runId?: RunId; readonly subagentId?: NodeId; - /** Temporary caller compatibility while UI naming moves from turns to runs. */ readonly turnId?: string; } @@ -251,9 +242,7 @@ export interface EditQueuedRunInput extends ThreadCommandInput { readonly text: string; /** * Full replacement attachment list for the queued message. Omitted = - * text-only edit that leaves attachments untouched. `dataUrl` entries are - * persisted against `messageId` (the queued run's user message) before - * dispatch, so `messageId` is required whenever attachments are present. + * text-only edit that leaves attachments untouched. */ readonly edit?: { readonly messageId: MessageId; @@ -777,11 +766,6 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( }); }); -/** - * Stop for the thread's latest work: interrupts the active run, or the settled run whose - * background work still runs. With no run to stop, it ends the thread's pull request - * watches, the only background work that has no run. - */ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( input: InterruptThreadTurnInput, ) { diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index a7013c668e6f..90729a640465 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -27,7 +27,6 @@ import type { EnvironmentProject } from "../state/models.ts"; import { sourceControlClients } from "../sourceControlClients.ts"; import type { SourceControlClientDefinition } from "../sourceControlClients.ts"; -/** A host to clone from, or `url` for a pasted clone URL. */ export type AddProjectRemoteSource = SourceControlProviderKind | "url"; export function canCreateProjectInEnvironment( @@ -36,10 +35,6 @@ export function canCreateProjectInEnvironment( return connectionPhase === "connected"; } -/** - * The Scratch folder an environment offers threads without a project right - * now, or null while it is not connected or has none. - */ export function availableScratchWorkspaceRoot( connectionPhase: EnvironmentConnectionPhase | null | undefined, serverConfig: Pick | null | undefined, @@ -54,7 +49,6 @@ export interface AddProjectRemoteSourceReadinessEntry { readonly hint: string | null; } -/** Whether each clone source can be used on an environment, and why not. */ export type AddProjectRemoteSourceReadiness = ( source: AddProjectRemoteSource, ) => AddProjectRemoteSourceReadinessEntry; @@ -74,7 +68,6 @@ export type AddProjectCloneFlow = readonly remoteUrl: string; }; -/** The hosts the clone picker offers, in the order the built-in definitions list them. */ const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = sourceControlClients.definitions.map((definition) => definition.kind); @@ -92,7 +85,6 @@ export function addProjectRemoteSourceProvider( return source === "url" ? null : source; } -/** A clone source named in a route or link, or `url` for anything this client does not ship. */ export function parseAddProjectRemoteSource( value: string | null | undefined, ): AddProjectRemoteSource { @@ -102,7 +94,6 @@ export function parseAddProjectRemoteSource( const GITHUB_REPOSITORY_SHORTHAND = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]+(?:\.git)?$/; -/** Treat the common owner/repository shorthand as a public GitHub HTTPS URL. */ export function normalizePastedCloneUrl(input: string): string { const trimmed = input.trim(); if (!GITHUB_REPOSITORY_SHORTHAND.test(trimmed)) return trimmed; @@ -110,7 +101,6 @@ export function normalizePastedCloneUrl(input: string): string { return `https://github.com/${repository}`; } -/** The clone URL for the transport the repository's host defaults to. */ export function getDefaultCloneUrl( repository: Pick, ): string { @@ -171,20 +161,9 @@ export function getAddProjectInitialQuery(baseDirectory: string | null | undefin return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed); } -/** - * Folder name `git clone` would pick, from either a looked-up repository or a - * pasted clone URL. Providers report `owner/repo`, Azure DevOps reports - * `org/project/repo`, and a URL can arrive in any form: `https://host/owner/ - * repo.git`, `ssh://git@host:22/owner/repo`, `git@host:owner/repo.git`, with - * or without a query, a fragment or a trailing slash. The repository is always - * the last segment, minus the `.git` suffix. - */ export function getCloneDirectoryName(repositoryOrRemoteUrl: string | null | undefined): string { const withoutQuery = (repositoryOrRemoteUrl ?? "").split(/[?#]/)[0]?.trim() ?? ""; const schemeIndex = withoutQuery.indexOf("://"); - // A remote URL carries a host before the repository path. The host is never - // the repository, so a link that stops at the host, or at a port, names - // nothing and the destination falls back to the browsed folder. const hasHost = schemeIndex >= 0 || /^[^/\\:]+@[^/\\:]+:/.test(withoutQuery); const pathPart = schemeIndex >= 0 ? withoutQuery.slice(schemeIndex + "://".length) : withoutQuery; const segments = pathPart.split(/[/\\:]+/).filter((segment) => segment.trim().length > 0); @@ -193,20 +172,12 @@ export function getCloneDirectoryName(repositoryOrRemoteUrl: string | null | und } const lastSegment = segments.at(-1)?.trim() ?? ""; - // A port can only sit directly behind the authority, so it is a port only - // when nothing follows it. Deeper segments are path, even when numeric: the - // repository in `https://host/acme/123` really is named `123`. if (hasHost && segments.length === 2 && /^\d+$/.test(lastSegment)) { return ""; } return lastSegment.endsWith(".git") ? lastSegment.slice(0, -".git".length) : lastSegment; } -/** - * Clone destination proposed for a directory: the directory the user picked - * plus the repository folder inside it. Without a name the directory is the - * destination, which is what the raw clone URL flow keeps doing. - */ export function getCloneDestinationPath( directoryPath: string, directoryName: string | null | undefined, @@ -218,26 +189,15 @@ export function getCloneDestinationPath( return `${ensureBrowseDirectoryPath(directoryPath)}${name}`; } -/** - * Where `projects.createNew` will put a project named `name`. The server adds - * `-2`, `-3`, ... when that folder is taken, so this is a preview. - */ export function getNewProjectPathPreview(newProjectsRoot: string, name: string): string { return getCloneDestinationPath(newProjectsRoot, newProjectFolderName(name)); } -/** A host a new project can be published to, and who the repository goes under there. */ export interface NewProjectPublishTarget { readonly definition: SourceControlClientDefinition; - /** Null lets the host's CLI place it under the signed-in user. */ readonly owner: string | null; } -/** - * The hosts a new project can be published to on that environment, in definition order so - * GitHub leads. A host is offered when it is ready and its account names where the repository - * goes. - */ export function getNewProjectPublishTargets( discovery: SourceControlDiscoveryResult | null, ): ReadonlyArray { @@ -254,7 +214,6 @@ export function getNewProjectPublishTargets( }); } -/** The chosen host's target, or the first one when the choice is not on offer. */ export function getNewProjectPublishTarget( targets: ReadonlyArray, kind: SourceControlProviderKind | null, @@ -262,7 +221,6 @@ export function getNewProjectPublishTarget( return targets.find((target) => target.definition.kind === kind) ?? targets[0] ?? null; } -/** `owner/folder` for publishing a new project, or just the folder for the host to place. */ export function getNewProjectRepository( target: Pick, workspaceRoot: string, @@ -271,11 +229,6 @@ export function getNewProjectRepository( return target.owner ? `${target.owner}/${folderName}` : folderName; } -/** - * Destination query after choosing a directory while the clone folder is - * pinned in the path input. Selecting an existing directory with the pinned - * name uses that directory directly instead of producing `repo/repo`. - */ export function getCloneDestinationBrowsePath(input: { readonly browseDirectoryPath: string; readonly selectedDirectoryName: string; diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index 2de4522a592e..62dd304c8463 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -58,8 +58,7 @@ export class SshEnvironmentGateway extends Context.Service< SshEnvironmentGateway, { /** - * Starts or reuses the remote server and pairs with it. With - * `expectedEnvironmentId`, a server that is a different machine fails + * With `expectedEnvironmentId`, a server that is a different machine fails * before its one-time pairing credential is spent. */ readonly provision: ( diff --git a/packages/client-runtime/src/platform/orchestrationCache.test.ts b/packages/client-runtime/src/platform/orchestrationCache.test.ts index 06f4987c557b..64269bf81ca6 100644 --- a/packages/client-runtime/src/platform/orchestrationCache.test.ts +++ b/packages/client-runtime/src/platform/orchestrationCache.test.ts @@ -161,7 +161,6 @@ describe("orchestration cache envelopes", () => { ); it("rejects thread snapshots saved before queued-run items were kept, but not shells", () => { - // Version 3 thread windows may have dropped a queued run's items (#16987). const staleThread = encodeStoredThreadSnapshotJson({ schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, environmentId, diff --git a/packages/client-runtime/src/platform/orchestrationCache.ts b/packages/client-runtime/src/platform/orchestrationCache.ts index 4f1f910827f1..f0b95d3bb603 100644 --- a/packages/client-runtime/src/platform/orchestrationCache.ts +++ b/packages/client-runtime/src/platform/orchestrationCache.ts @@ -10,10 +10,6 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 3 as const; -/** - * Thread snapshots version on their own so a thread-only invalidation keeps - * shell caches warm. 4 drops bounded windows that lost queued runs' items (#16987). - */ export const ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION = 4 as const; export const StoredOrchestrationShellSnapshot = Schema.Struct({ @@ -32,7 +28,6 @@ export const StoredOrchestrationThreadSnapshot = Schema.Struct({ })), }); -/** Invalid orchestration caches are disposable and must never block live synchronization. */ export function decodeOrDiscardOrchestrationCache( decode: Effect.Effect, E, R>, discard: Effect.Effect, diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 29463ce6da58..e043279d3e57 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -46,7 +46,6 @@ export class ConnectionTargetStore extends Context.Service< ConnectionTargetStore, { readonly list: Effect.Effect, ConnectionPersistenceError>; - /** Saved environments the user switched off. See `ConnectionRegistrationStore.setEnabled`. */ readonly listDisabled: Effect.Effect, ConnectionPersistenceError>; } >()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} @@ -55,19 +54,19 @@ export class ConnectionRegistrationStore extends Context.Service< ConnectionRegistrationStore, { /** - * Saves one route's records and sets the environment's full route list, - * preferred first. Records of routes missing from `routes` are dropped. + * Records of routes missing from `routes` are dropped. */ readonly register: ( registration: ConnectionRegistration, routes: ReadonlyArray, ) => Effect.Effect; - /** Reorders or drops routes without adding one. `routes` must not be empty. */ + /** + * `routes` must not be empty. + */ readonly setRoutes: ( environmentId: EnvironmentId, routes: ReadonlyArray, ) => Effect.Effect; - /** Forgets the environment and every route it had. */ readonly remove: ( environmentId: EnvironmentId, ) => Effect.Effect; @@ -103,10 +102,6 @@ export class EnvironmentCacheStore extends Context.Service< environmentId: EnvironmentId, threadId: ThreadId, ) => Effect.Effect; - /** - * The last complete server configuration. This deliberately includes provider - * metadata so offline task creation can still offer the models a user last saw. - */ readonly loadServerConfig: ( environmentId: EnvironmentId, ) => Effect.Effect, ConnectionPersistenceError>; @@ -115,8 +110,8 @@ export class EnvironmentCacheStore extends Context.Service< config: ServerConfig, ) => Effect.Effect; /** - * The unfiltered branch list for a workspace. Query-specific lists are not - * cached because they are incomplete and unsafe to present as a full picker. + * Query-specific lists are not cached because they are incomplete and unsafe + * to present as a full picker. */ readonly loadVcsRefs: ( environmentId: EnvironmentId, @@ -132,9 +127,8 @@ export class EnvironmentCacheStore extends Context.Service< cwd: string, ) => Effect.Effect; /** - * Removes every persisted branch-list snapshot for an environment. Git ref - * mutations are repository-wide, and linked worktrees may have cached the - * same refs under different working-directory keys. + * Git ref mutations are repository-wide, and linked worktrees may have + * cached the same refs under different working-directory keys. */ readonly clearVcsRefs: ( environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/platform/shellCacheEncoding.ts b/packages/client-runtime/src/platform/shellCacheEncoding.ts index da6b3c98617f..26a501830614 100644 --- a/packages/client-runtime/src/platform/shellCacheEncoding.ts +++ b/packages/client-runtime/src/platform/shellCacheEncoding.ts @@ -16,8 +16,6 @@ const yieldToHostTimer = Effect.callback((resume) => { return Effect.sync(() => clearTimeout(timer)); }); -// Rows go through the canonical row codec in chunks. The envelope codec then -// validates and encodes everything else, passing the encoded rows through as-is. const encodeThreadChunk = Schema.encodeEffect(Schema.Array(OrchestrationV2ThreadShellJson)); const EncodedRows = Schema.Array(Schema.Unknown); const encodeEnvelope = Schema.encodeEffect( @@ -34,11 +32,6 @@ const encodeEnvelope = Schema.encodeEffect( ); /** - * Make an encoder for the shell cache payload that produces exactly what - * `fromJsonString(StoredOrchestrationShellSnapshot)` would, yielding to the host - * between bounded chunks of thread rows. The final envelope encode and JSON - * stringify still run synchronously over the whole payload. - * * Shell rows are immutable and the shell reducer keeps unchanged rows by * reference, so each encoder remembers the canonical encoding of rows it has * already encoded successfully and only runs the row codec for new rows. diff --git a/packages/client-runtime/src/platform/source.ts b/packages/client-runtime/src/platform/source.ts index 517f4b9b5b8c..050e0828e697 100644 --- a/packages/client-runtime/src/platform/source.ts +++ b/packages/client-runtime/src/platform/source.ts @@ -8,8 +8,7 @@ export class PlatformConnectionSource extends Context.Service< { // Each emission is the full current set of platform-managed environments // (the primary local environment plus any desktop-local backends running - // alongside it). The registry reconciles the set, so the source can drive - // both additions and removals by re-emitting. + // alongside it). readonly registrations: Stream.Stream>; } >()("@t3tools/client-runtime/platform/source/PlatformConnectionSource") {} diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index e235ca3c9bd7..f1ffa1493c73 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -365,7 +365,6 @@ describe("ConnectionCatalogDocument", () => { expect(disabled.disabledEnvironmentIds).toEqual([ENVIRONMENT_ID]); expect(disabled.targets).toEqual(registered.targets); expect(disabled.credentials).toEqual(registered.credentials); - // Idempotent: switching off twice stores the id once. expect( setConnectionEnabledInCatalog(disabled, ENVIRONMENT_ID, false).disabledEnvironmentIds, ).toEqual([ENVIRONMENT_ID]); @@ -373,7 +372,6 @@ describe("ConnectionCatalogDocument", () => { expect( setConnectionEnabledInCatalog(disabled, ENVIRONMENT_ID, true).disabledEnvironmentIds, ).toEqual([]); - // Re-registering (editing label or URL) keeps the flag. expect( registerConnectionInCatalog( disabled, @@ -433,14 +431,12 @@ describe("ConnectionCatalogDocument", () => { expect(catalogRoutes(withRelay, ENVIRONMENT_ID)).toEqual([BEARER_TARGET, RELAY_TARGET]); expect(withRelay.credentials).toHaveLength(1); - // Dropping the bearer route forgets its credential; T3 Connect keeps its token. const relayOnly = setRoutesInCatalog(withRelay, ENVIRONMENT_ID, [RELAY_TARGET]); expect(relayOnly.targets).toEqual([RELAY_TARGET]); expect(relayOnly.profiles).toEqual([]); expect(relayOnly.credentials).toEqual([]); expect(relayOnly.remoteDpopTokens).toEqual([REMOTE_TOKEN]); - // Dropping T3 Connect forgets its token; the bearer route keeps its records. const bearerOnly = setRoutesInCatalog(withRelay, ENVIRONMENT_ID, [BEARER_TARGET]); expect(bearerOnly.credentials).toHaveLength(1); expect(bearerOnly.remoteDpopTokens).toEqual([]); diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 99a187c6c333..7fb29993b17e 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -24,9 +24,6 @@ export const ConnectionCatalogDocument = Schema.Struct({ credentials: Schema.Array(StoredConnectionCredential), remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), githubRoutingPermissions: Schema.optionalKey(Schema.Array(StoredGitHubRoutingPermission)), - // Saved environments the user switched off. They stay registered with their - // credentials and cache but never connect until switched back on. Older - // documents predate the key, so decoding defaults it to none. disabledEnvironmentIds: Schema.Array(EnvironmentId).pipe( Schema.withDecodingDefaultKey(Effect.succeed([])), ), @@ -85,18 +82,12 @@ function removeRouteMetadata( ...document, profiles: document.profiles.filter((value) => !connectionIds.has(value.connectionId)), credentials: document.credentials.filter((value) => !connectionIds.has(value.connectionId)), - // The DPoP token belongs to the T3 Connect route. remoteDpopTokens: relayRemoved ? document.remoteDpopTokens.filter((value) => !environmentIds.has(value.environmentId)) : document.remoteDpopTokens, }; } -/** - * An environment's saved routes in preference order. Targets of one - * environment keep their relative order in `targets`, so a document written - * before routes existed is one environment with one route. - */ export function catalogRoutes( document: ConnectionCatalogDocument, environmentId: EnvironmentId, @@ -105,10 +96,9 @@ export function catalogRoutes( } /** - * Replaces an environment's routes with `routes`, preferred first. Records - * owned by a dropped route go with it; the environment keeps its position in - * the catalog. An empty list leaves the environment's other records in place; - * use `removeConnectionFromCatalog` to forget the environment. + * Records owned by a dropped route go with it; the environment keeps its + * position in the catalog. An empty list leaves the environment's other records + * in place; use `removeConnectionFromCatalog` to forget the environment. */ export function setRoutesInCatalog( document: ConnectionCatalogDocument, @@ -132,14 +122,11 @@ export function setRoutesInCatalog( }; } -/** Saves one route of an environment, keeping its other routes. */ export function registerConnectionInCatalog( document: ConnectionCatalogDocument, registration: ConnectionRegistration, routes: ReadonlyArray = [registration.target], ): ConnectionCatalogDocument { - // Re-registering (for example editing a label or URL) keeps the disabled - // flag; only `setConnectionEnabledInCatalog` or removal changes it. const next = setRoutesInCatalog(document, registration.target.environmentId, routes); switch (registration._tag) { @@ -170,7 +157,6 @@ export function registerConnectionInCatalog( } } -/** Forgets an environment and every route it had. */ export function removeConnectionFromCatalog( document: ConnectionCatalogDocument, environmentId: EnvironmentId, @@ -198,7 +184,6 @@ export function removeConnectionFromCatalog( }; } -/** Flips the disabled flag for a saved environment; unknown ids are ignored. */ export function setConnectionEnabledInCatalog( document: ConnectionCatalogDocument, environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/preview/serverBrowserStream.ts b/packages/client-runtime/src/preview/serverBrowserStream.ts index 34a3786976d0..b71fff335f88 100644 --- a/packages/client-runtime/src/preview/serverBrowserStream.ts +++ b/packages/client-runtime/src/preview/serverBrowserStream.ts @@ -26,7 +26,6 @@ export interface PreviewStreamControl { }; } -/** Status line shown above a viewer; `null` control means the socket is not connected. */ export const previewStreamControlLabel = (control: PreviewStreamControl | null): string => !control ? "Connecting..." @@ -50,7 +49,7 @@ const isPreviewStreamDialog = (value: unknown): value is PreviewStreamControl["d "defaultValue" in value && typeof value.defaultValue === "string"); -/** Client-to-server messages. Coordinates are page CSS px. */ +/** Coordinates are page CSS px. */ export type PreviewStreamInput = | { readonly type: "takeControl" } | { readonly type: "releaseControl" } @@ -91,7 +90,6 @@ export type PreviewStreamInput = | { readonly type: "navigate"; readonly url: string } | { readonly type: "history"; readonly delta: -1 | 1 } | { readonly type: "reload"; readonly ignoreCache?: boolean } - /** Asks whether the page point takes text. Touch viewers send it on touch start. */ | { readonly type: "probe"; readonly x: number; readonly y: number }; export interface PreviewStreamDownload { @@ -103,11 +101,9 @@ export interface PreviewStreamDownload { export interface PreviewStreamFileChooser { readonly multiple: boolean; - /** The input's `accept` attribute, ready for a local ``. */ readonly accept: string; /** POST multipart `file` parts here; an empty form cancels the page's picker. */ readonly uploadUrl: string; - /** Cookie sessions must send credentials with the upload. */ readonly credentials: boolean; } @@ -124,7 +120,6 @@ const previewStreamUploadUrl = ( target.access, ); -/** Sends files to a page's open picker. Rejects when the server refuses them. */ export async function uploadPreviewStreamFiles( chooser: PreviewStreamFileChooser, files: ReadonlyArray, @@ -139,7 +134,6 @@ export async function uploadPreviewStreamFiles( if (!response.ok) throw new Error((await response.text()) || "The upload was refused."); } -/** A file a server tab downloaded, served with the stream's own access. */ export const previewStreamDownloadUrl = ( target: Pick, id: string, @@ -153,14 +147,12 @@ export const previewStreamDownloadUrl = ( target.access, ); -/** Answer to a `probe`, echoing its point. */ export interface PreviewStreamProbe { readonly x: number; readonly y: number; readonly editable: boolean; } -/** Where an agent action is about to land, in page CSS pixels. */ export interface PreviewStreamPointer { readonly phase: "move" | "click"; readonly x: number; @@ -168,7 +160,6 @@ export interface PreviewStreamPointer { readonly sequence: number; } -/** CDP modifier bitmask: Alt 1, Ctrl 2, Meta 4, Shift 8. */ export const previewStreamModifiers = (event: { readonly altKey: boolean; readonly ctrlKey: boolean; @@ -192,33 +183,23 @@ export interface PreviewStreamTarget { } export interface PreviewStreamEvents { - /** One complete JPEG frame. */ readonly onFrame: (jpeg: ArrayBuffer) => void; readonly onViewport: (viewport: PreviewStreamViewport) => void; readonly onProbe?: (probe: PreviewStreamProbe) => void; - /** The agent's cursor moved to, or clicked at, a page point. */ readonly onPointer?: (pointer: PreviewStreamPointer) => void; readonly onControl?: (control: PreviewStreamControl) => void; - /** Text the page just copied or cut while this viewer had control. */ readonly onClipboard?: (text: string) => void; - /** A file the page downloaded while this viewer had control. */ readonly onDownload?: (download: PreviewStreamDownload) => void; - /** The page this viewer controls opened a new tab (a link or `window.open`). */ readonly onPopup?: (tabId: string) => void; - /** The page opened a file picker (`null` once answered or replaced). */ readonly onFileChooser?: (chooser: PreviewStreamFileChooser | null) => void; - /** Input sent while disconnected is dropped. */ readonly onConnectedChange: (connected: boolean) => void; /** The upgrade was refused; refresh access and start a new client. */ readonly onUnauthorized: () => void; - /** The tab was closed on the server. The client has stopped. */ readonly onGone?: () => void; - /** The server's browser cannot start until its host is set up. The client has stopped. */ readonly onHostSetup?: (setup: PreviewStreamHostSetup) => void; } export interface PreviewStreamClient { - /** False when the socket is not open and the message was dropped. */ readonly send: (input: PreviewStreamInput) => boolean; readonly stop: () => void; } @@ -439,7 +420,6 @@ export function createPreviewFramePainter( bitmap.close(); onPainted?.(); }) - // A frame that fails to decode is skipped; the next one repaints. .catch(() => undefined) .finally(() => { decoding = false; @@ -461,7 +441,6 @@ export function createPreviewFramePainter( }; } -/** A malformed reason still stops the viewer and offers the usual command. */ const decodeHostSetup = (reason: string): PreviewStreamHostSetup => { try { const value = JSON.parse(reason) as Partial; @@ -475,7 +454,6 @@ const decodeHostSetup = (reason: string): PreviewStreamHostSetup => { return { need: "sandbox", command: "sudo npx t3 browser setup" }; }; -/** What a viewer tells the person; `command` is shown beside it, ready to copy. */ export const previewStreamHostSetupMessage = (setup: PreviewStreamHostSetup) => setup.need === "sandbox" ? "This server's host blocks the sandbox its browser runs in. Run this once on the host, then try again:" diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts index 32a502677df0..b194f809e141 100644 --- a/packages/client-runtime/src/projectFaviconCache.ts +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -11,7 +11,6 @@ import * as Schema from "effect/Schema"; export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024; -/** Larger sources are not worth decoding for an icon and are left to the remote URL. */ export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024; export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024; export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128; @@ -44,7 +43,6 @@ function keyFor(target: ProjectFaviconTarget) { } export interface ProjectFaviconStorage { - /** Every persisted record; entries that fail validation are ignored. */ readonly list: () => Promise>; readonly put: (key: string, entry: ProjectFaviconEntry) => Promise; readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise; @@ -82,10 +80,8 @@ async function readBounded(response: Response, maxBytes: number) { } /** - * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs - * and small bitmaps are stored exactly as served. Larger bitmaps go through the - * platform downscaler; larger SVGs stay remote because rasterizing them without - * intrinsic dimensions is unreliable. + * Larger bitmaps go through the platform downscaler; larger SVGs stay remote because rasterizing + * them without intrinsic dimensions is unreliable. */ export function createProjectFaviconImageLoader(input: { readonly fetch?: typeof fetch; @@ -118,7 +114,6 @@ export function createProjectFaviconImageLoader(input: { }; } -/** Stores small, self-contained images so startup never needs an old signed URL. */ export function createProjectFaviconCache(input: { readonly storage: ProjectFaviconStorage; readonly load: (url: string, signal: AbortSignal) => Promise; @@ -132,9 +127,7 @@ export function createProjectFaviconCache(input: { const persist = (operation: () => Promise) => { const task: Promise = operation() - .catch(() => { - // Keep the in-memory image if local storage is full or unavailable. - }) + .catch(() => {}) .finally(() => pending.delete(task)); pending.add(task); }; @@ -168,9 +161,7 @@ export function createProjectFaviconCache(input: { if (Option.isSome(entry)) entries.set(keyFor(entry.value), entry.value); } trim(); - } catch { - // A missing, corrupt, or unavailable cache must not prevent startup. - } + } catch {} })()); const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null; @@ -219,9 +210,7 @@ export function createProjectFaviconCache(input: { trim(); return dataUrl; } - } catch { - // An outage or failed decode leaves the last successful image visible. - } + } catch {} return peek(target) ?? url; }; diff --git a/packages/client-runtime/src/promptLinks.ts b/packages/client-runtime/src/promptLinks.ts index f4c007c4c387..4e4c1214180e 100644 --- a/packages/client-runtime/src/promptLinks.ts +++ b/packages/client-runtime/src/promptLinks.ts @@ -14,17 +14,11 @@ import { visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; -/** - * How web and mobile resolve a prompt link against what this client knows. Both read the same - * shells, so a link lands in the same project and thread on every surface. - */ - const repositoryKey = (repository: PromptLinkRepository) => `github.com/${repository.owner}/${repository.name}`.toLowerCase(); /** - * The project a link's repository names. Callers pass projects most recently active first, so - * the user lands where they last worked. A fork only matches its own owner, not its upstream. + * Callers pass projects most recently active first, so the user lands where they last worked. */ export function findPromptLinkProject< P extends { readonly repositoryIdentity?: RepositoryIdentity | null | undefined }, @@ -37,7 +31,6 @@ export function findPromptLinkProject< ); } -/** The most recently updated thread linked to the pull request, across every environment given. */ export function findPromptLinkPullRequestThread< T extends { readonly updatedAt: string; @@ -61,13 +54,12 @@ export function findPromptLinkPullRequestThread< return latest; } -// People write the name they know. Driver kinds are what the server reports. const PROVIDER_ALIASES: Record = { claude: "claudeagent" }; /** - * The selection a link asks for, on one of the user's own provider instances. `exact` is false - * when the instance exists but the model does not, so its default model stands in. Null when - * no usable instance of that provider exists; the user's default applies then. + * `exact` is false when the instance exists but the model does not, so its default model + * stands in. Null when no usable instance of that provider exists; the user's default + * applies then. */ export function resolvePromptLinkModelSelection( providers: ReadonlyArray< diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 1b9ca671d10c..d22a876967fd 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -153,7 +153,6 @@ describe("getProviderSkillsForSlashMenu", () => { name: "deploy", path: "/Users/matt/.claude/skills/deploy/SKILL.md", enabled: true, - // Reserved for the user, not the agent: still a valid pick. userInvocationOnly: true, }, ]; diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index fe69c371fc6c..005c058615fa 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -26,11 +26,11 @@ export function dedupeProviderSkillsByName( } /** - * Whether a composer pick can start this skill. A skill switched off in the - * provider's settings will not run, and one the provider reserves for the - * agent (Claude Code's `user-invocable: false`) rejects a user invocation. - * Everything else, including skills the agent may not start on its own, is - * fair game: the server dispatches the pick in the provider's native form. + * A skill switched off in the provider's settings will not run, and one the + * provider reserves for the agent (Claude Code's `user-invocable: false`) + * rejects a user invocation. Everything else, including skills the agent may + * not start on its own, is fair game: the server dispatches the pick in the + * provider's native form. */ export function isProviderSkillUserInvocable( skill: Pick, @@ -101,7 +101,6 @@ export function hasCompleteProviderWorkspaceSnapshot( return Boolean(snapshot && !snapshot.slashCommandsPending); } -/** A complete snapshot young enough that opening a composer need not rescan. */ export function hasCurrentProviderWorkspaceSnapshot( provider: ServerProvider | null | undefined, cwd: string | null | undefined, diff --git a/packages/client-runtime/src/providerUpdateRequiredModels.ts b/packages/client-runtime/src/providerUpdateRequiredModels.ts index caf44b2bfe53..730478f483a7 100644 --- a/packages/client-runtime/src/providerUpdateRequiredModels.ts +++ b/packages/client-runtime/src/providerUpdateRequiredModels.ts @@ -1,7 +1,6 @@ import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; import { compareSemverVersions } from "@t3tools/shared/semver"; -// Name the thing the user updates; "Claude" alone reads like the app or model. const RUNTIME_NAMES: Partial> = { claudeAgent: "Claude Code", codex: "the Codex CLI", @@ -12,13 +11,6 @@ function formatModelList(names: ReadonlyArray): string { return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; } -/** - * Model picker notice for models the manifest announces but the installed - * provider is too old to run, e.g. "Update Claude Code to v2.1.300 or newer to - * use Claude Opus 6." With a search query, only gated models it matches are - * named, so searching for one explains why it is missing. Null when nothing - * gated matches. - */ export function formatProviderUpdateRequiredNotice( provider: Pick, searchQuery = "", @@ -31,7 +23,6 @@ export function formatProviderUpdateRequiredNotice( model.slug.toLocaleLowerCase().includes(query), ); if (models.length === 0) return null; - // The highest bar unlocks every listed model. const minVersion = models .map((model) => model.minVersion) .reduce((highest, version) => diff --git a/packages/client-runtime/src/pullRequestMarkdown.ts b/packages/client-runtime/src/pullRequestMarkdown.ts index a9447c36b75f..67ac68e92619 100644 --- a/packages/client-runtime/src/pullRequestMarkdown.ts +++ b/packages/client-runtime/src/pullRequestMarkdown.ts @@ -5,12 +5,6 @@ export type PullRequestBodySegment = readonly id: string; readonly kind: "attachment"; readonly url: string; - /** - * What the reader can be told the upload is. The uuid says nothing on its own β€” the type - * only appears in the signed redirect GitHub answers with β€” but the shape does: GitHub - * writes a dropped image into the body as an `` tag, so a bare attachment link on its - * own line is what it does with a video. Anything else keeps the neutral name. - */ readonly media: "video" | "image" | "unknown"; }; @@ -20,20 +14,19 @@ const FENCE_PATTERN = /^\s{0,3}((?:`{3,})|(?:~{3,}))(.*)$/u; * and the bound keeps an unclosed tag from making each one rescan the rest of the body. */ const VIDEO_TAG_MAX_LINES = 8; -/** Four spaces open an indented code block, so its contents stay verbatim markdown. */ + const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u; const BARE_URL_PATTERN = /^?$/u; const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|svg)(?:$|[?#])/iu; const MARKDOWN_LINK_PATTERN = /^\[[^\]]*\]\((https?:\/\/[^\s)]+)\)$/u; const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu; -/** A dropped video becomes a bare asset link; a dropped image becomes an `` tag. */ + const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu; const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu; -/** Only a tag that owns its line is an embed; inline, it is prose the renderer should keep. */ + const STANDALONE_VIDEO_TAG_PATTERN = /^\s*\s*$/iu; -/** Anything else β€” `javascript:`, `data:`, a relative path β€” is not an upload to link out to. */ function isWebUrl(url: string): boolean { try { const parsed = new URL(url); @@ -56,11 +49,6 @@ function attachmentFromLine( return null; } -/** - * Lifts standalone media links and video tags out of prose for both clients. - * Ordinary links and code examples remain markdown. Images already embedded - * with markdown or HTML keep their original renderer. - */ export function splitPullRequestBody(body: string): ReadonlyArray { const segments: PullRequestBodySegment[] = []; const markdown: string[] = []; @@ -114,8 +102,6 @@ export function splitPullRequestBody(body: string): ReadonlyArray { // a real sign-in which always happens long after service start. yield* Effect.yieldNow; - // Sign-in activates the session and emits credentials-changed; the - // list must populate without any screen having asked for a refresh. yield* harness.wake("credentials-changed"); const populated = yield* SubscriptionRef.changes(discovery.state).pipe( Stream.filter( diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index 8aee35347e2b..bd7126f7f2b6 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -333,10 +333,6 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { yield* Ref.set(activeAccountId, Option.none()); yield* Ref.set(offlineReportFingerprints, new Map()); yield* SubscriptionRef.set(state, EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); - // Refresh proactively β€” this wakeup fires when a session activates - // (sign-in or cold start), and the list should be populated before - // any screen asks for it. A signed-out refresh settles back to the - // clean empty state. yield* refresh.pipe(Effect.forkScoped); }) : Effect.void, diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index ed8f16ac48e3..d6df6985019a 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -76,11 +76,6 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { export const RELAY_GATEWAY_TIMEOUT_MESSAGE = "Relay timed out before it finished the request (HTTP 504). Try again in a few minutes. If it keeps failing, copy the trace ID when you report it."; -/** - * User-facing detail for a failed relay request: the relay's typed error, or - * the HTTP status when the relay sent a response the contract does not declare. - * Null when the request got no response. - */ export function relayRequestFailureDetail(error: { readonly relayError?: RelayProtectedError | undefined; readonly status?: number | undefined; @@ -91,13 +86,9 @@ export function relayRequestFailureDetail(error: { return null; } -// A host with a current build gets a new tunnel on its own within minutes of -// coming back, which clears this reason. While it is still reported, the host -// is either still off or running a build too old to do that. export const RELAY_TUNNEL_RELEASED_MESSAGE = "Offline for a while, so its T3 Connect tunnel was removed. Start T3 Code on that computer and update it to the latest version to reconnect."; -/** User-facing text for an offline status, or null when the relay gave no known reason. */ export function relayOfflineReasonMessage( status: Pick, ): string | null { diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index 339d28f421e8..b6e37fd7afa8 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -120,9 +120,6 @@ export class ManagedRelayRequestTimeoutError extends Schema.TaggedError tokenMatches(token, match), ); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 217d2e9a6d07..af8fc0a83667 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -190,7 +190,6 @@ function readSessionClerkToken( ); } -/** Removes an environment from the signed-in account without contacting that environment. */ export const deregisterManagedRelayEnvironment = Effect.fn( "clientRuntime.managedRelaySession.deregisterEnvironment", )(function* ( diff --git a/packages/client-runtime/src/reviewGuideTour.test.ts b/packages/client-runtime/src/reviewGuideTour.test.ts index f784a3433fcb..ea925c73085c 100644 --- a/packages/client-runtime/src/reviewGuideTour.test.ts +++ b/packages/client-runtime/src/reviewGuideTour.test.ts @@ -109,8 +109,6 @@ describe("rowsToPatch", () => { }); describe("excerptExpansionOldContents", () => { - // The new file behind PATCH: lines 1-9, the hunk at 10-15, then lines up to 41-42 around the - // second hunk, which removed a line. const newFile = Array.from({ length: 50 }, (_, index) => `line ${index + 1}\n`); newFile.splice( 9, @@ -130,12 +128,9 @@ describe("excerptExpansionOldContents", () => { const old = excerptExpansionOldContents(newContents, shown); if (old === null) throw new Error("expected contents"); const oldLines = old.split("\n"); - // The excerpt's old side sits at its real old line. expect(oldLines[10]).toBe("const size = pick();"); - // Around it, the new file's lines, so the two sides agree outside the excerpt. expect(oldLines[9]).toBe("const a = 1;"); expect(oldLines[11]).toBe("const b = 2;"); - // One line replaced by two: the old side is a line shorter, everything after lines up. expect(oldLines.length).toBe(newContents.split("\n").length - 1); }); diff --git a/packages/client-runtime/src/reviewGuideTour.ts b/packages/client-runtime/src/reviewGuideTour.ts index 653aae14549a..c4df983dde9f 100644 --- a/packages/client-runtime/src/reviewGuideTour.ts +++ b/packages/client-runtime/src/reviewGuideTour.ts @@ -1,12 +1,10 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; -/** One step forward or back, held to the tour's first and last stops. */ export function stepTourPosition(current: number, delta: 1 | -1, stopCount: number): number { return Math.max(Math.min(current + delta, stopCount - 1), 0); } -/** The stop's files in reading order: the excerpted ones first, then the rest it owns. */ export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray { return [ ...new Set([ @@ -16,34 +14,26 @@ export function stopFilePaths(stop: PullRequestReviewGuideStop): ReadonlyArray { const rows: DiffRow[] = []; file.hunks.forEach((hunk, hunkIndex) => { @@ -95,11 +85,6 @@ export function fileDiffRows(file: FileDiffMetadata): ReadonlyArray { return rows; } -/** - * The rows an excerpt covers. Its range is in one side's line numbers, so the other side's half - * of a replacement at either edge comes along: an excerpt starting on an added line also shows - * the lines it replaced, which is what makes it read as a change rather than as new code. - */ export function excerptRows( rows: ReadonlyArray, excerpt: Pick, @@ -132,10 +117,6 @@ export function excerptRows( const ROW_PREFIX = { context: " ", addition: "+", deletion: "-" } as const; -/** - * A unified patch of just these rows, so the shared diff renderer can draw a slice of a file - * with its real line numbers instead of a second diff view being written for excerpts. - */ export function rowsToPatch( path: string, previousPath: string, @@ -166,11 +147,8 @@ export function rowsToPatch( } /** - * The old side an excerpt expands against: the new file with only the excerpt's lines put back - * as they were. The renderer fills the code around a hunk from both files and needs them to - * agree outside it, which the real old file does not wherever another hunk changed lines. Only - * the lines the reader can expand into are read, and those are the new file's, so the code - * around the excerpt shows as it reads after the change. Null when the excerpt spans hunks. + * The renderer fills the code around a hunk from both files and needs them to agree + * outside it, which the real old file does not wherever another hunk changed lines. */ export function excerptExpansionOldContents( newContents: string, @@ -181,8 +159,6 @@ export function excerptExpansionOldContents( const newLines = newContents.match(/[^\n]*\n|[^\n]+$/g) ?? []; const before = head.newAt - 1; const newCount = rows.filter((row) => row.newLine !== null).length; - // The old side keeps its own numbering, so its prefix is as long as the old file's: trimmed - // from the front, or padded there with lines no expansion reaches. const oldBefore = head.oldAt - 1; const prefix = newLines.slice(0, before); const oldPrefix = @@ -195,16 +171,11 @@ export function excerptExpansionOldContents( return [...oldPrefix, ...oldRows, ...newLines.slice(before + newCount)].join(""); } -/** A stop's notes lettered in guide order, with the excerpt lines each one marks. */ export interface LetteredNote { readonly marker: string; readonly note: Note; } -/** - * Letters a stop's notes and groups them by the excerpt line they sit on. A note outside every - * excerpt keeps its letter in the list; it just has no line to mark. - */ export function letterStopNotes(stop: PullRequestReviewGuideStop) { const lettered = stop.notes.map((note, index) => ({ marker: noteMarker(index), note })); const byExcerpt = stop.excerpts.map((excerpt) => { diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index cd3053c4bb25..536d54a3f522 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -510,12 +510,10 @@ describe("environment RPC", () => { yield* TestClock.adjust("100 millis"); expect(yield* Queue.take(subscriptions)).toBe(2); yield* Queue.take(failures); - // The second retry waits 200ms, so 100ms is not enough. yield* TestClock.adjust("100 millis"); expect(Option.isNone(yield* Queue.poll(subscriptions))).toBe(true); yield* TestClock.adjust("100 millis"); expect(yield* Queue.take(subscriptions)).toBe(3); - // Attempt 3 delivered a value before failing, so the delay is back to 100ms. yield* Queue.take(failures); yield* TestClock.adjust("100 millis"); expect(yield* Queue.take(subscriptions)).toBe(4); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index f5ab86545d5c..5b670fd622a5 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -105,7 +105,6 @@ export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); -/** Ceiling for the doubling delay between same-session expected-failure retries. */ const MAX_EXPECTED_FAILURE_RETRY_DELAY_MS = 30_000; export type EnvironmentRpcInput = Parameters>[0]; @@ -237,19 +236,12 @@ export function runStreamGuarded( } interface SubscriptionOptions { - /** Reports protocol or programming defects without changing their recovery policy. */ readonly onDefect?: ( cause: Cause.Cause>, ) => Effect.Effect; readonly onExpectedFailure?: ( cause: Cause.Cause>, ) => Effect.Effect; - /** - * First delay before resubscribing on the same session after an expected - * failure. Each consecutive failure doubles it up to 30 seconds, and the - * first value from a healthy stream resets it. Authorization failures are - * not retried; they wait for the next session or `resubscribe` signal. - */ readonly retryExpectedFailureAfter?: Duration.Input; readonly resubscribe?: Stream.Stream; } @@ -296,8 +288,6 @@ function subscribeDynamicMapped( EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure >; - // Consecutive expected-failure retries on this session. Reset by - // the first value a resubscribed stream delivers. let expectedFailureRetries = 0; const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => @@ -440,7 +430,6 @@ export function subscribe( return subscribeDynamic(tag, () => Effect.succeed(input), options); } -/** Protected writes must go through the permission-aware command layer. */ export const request = >( tag: TTag, input: EnvironmentRpcInput, diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts index bccd0ef51606..76414e30bef9 100644 --- a/packages/client-runtime/src/rpc/http.ts +++ b/packages/client-runtime/src/rpc/http.ts @@ -113,7 +113,6 @@ export const makeEnvironmentHttpApiGroupClient = < }), ); -/** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */ export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) => HttpApiClient.urlBuilder(EnvironmentHttpApi, { baseUrl: remoteApiBaseUrl(httpBaseUrl), diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 38e1b7f89d31..77ce244aad5c 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -522,7 +522,6 @@ describe("RpcSessionFactory", () => { Effect.forkChild, ); yield* Deferred.await(started); - // A live end marker makes a missing or stale replay event fail without a timeout. const marker: ServerConfigStreamEventType = { version: 1, type: "keybindingsUpdated", @@ -1125,7 +1124,6 @@ describe("RpcSessionFactory", () => { yield* completeInitialConfig(socket); yield* Fiber.join(readyFiber); - // The consumer takes one event and stops pulling, so the stream buffer fills. const consuming = yield* Deferred.make(); const streamFiber = yield* session.client[WS_METHODS.subscribeServerConfig]({}).pipe( Stream.runForEach(() => diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 62410d7c6c5b..d6368980ac83 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -58,7 +58,6 @@ export interface RpcSession { export interface RpcSessionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; - /** This client answers /usage-limits itself, so the server may advertise it. */ readonly usageLimitsCommand?: boolean; } @@ -170,8 +169,6 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( const connected = yield* Deferred.make(); const disconnected = yield* Deferred.make(); - // Set when the socket closes because the server stopped answering, so the failure says so - // instead of looking like the server closed the connection. const pingTimedOut = yield* Ref.make(false); const hooks = RpcClient.ConnectionHooks.of({ onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), diff --git a/packages/client-runtime/src/scheduledTaskWebhook.ts b/packages/client-runtime/src/scheduledTaskWebhook.ts index 785a3bcfe8d1..028591709659 100644 --- a/packages/client-runtime/src/scheduledTaskWebhook.ts +++ b/packages/client-runtime/src/scheduledTaskWebhook.ts @@ -1,6 +1,5 @@ import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES } from "@t3tools/contracts"; -/** Prompt a new webhook task starts with: just the body, so request headers stay out unless the user adds them. */ export const DEFAULT_WEBHOOK_PROMPT = "Handle this webhook:\n{{body}}"; /** Blank means "no limit"; undefined means the input is not a valid limit, which blocks saving. */ diff --git a/packages/client-runtime/src/secretRequest.ts b/packages/client-runtime/src/secretRequest.ts index 2bee8bcaf2eb..91640c0958ce 100644 --- a/packages/client-runtime/src/secretRequest.ts +++ b/packages/client-runtime/src/secretRequest.ts @@ -5,11 +5,9 @@ export type SecretRequestItem = Extract< { readonly type: "secret_request" } >; -/** Shown under the field: the one promise the card makes about the value. */ export const SECRET_REQUEST_PRIVACY_NOTE = "Stored securely, never shown to the agent"; export const SECRET_REQUEST_DEFAULT_PLACEHOLDER = "Paste the secret"; -/** What a secret request card shows: the form while pending, otherwise a one-line outcome. */ export type SecretRequestDisplay = | { readonly kind: "pending" } | { readonly kind: "pending-elsewhere"; readonly label: string } @@ -60,10 +58,6 @@ export function secretRequestDisplay( } } -/** - * Builds the RPC payload for an answer. A save with a blank value returns null, - * since the server rejects it; callers keep Save disabled instead. - */ export function secretRequestAnswerInput( item: Pick, answer: { readonly type: "save"; readonly secret: string } | { readonly type: "decline" }, @@ -76,13 +70,11 @@ export function secretRequestAnswerInput( return { threadId: item.threadId, turnItemId: item.id, answer: { type: "save", secret } }; } -/** Failures whose message is written for the user and never echoes the request payload. */ const USER_FACING_FAILURE_TAGS = new Set(["SecretRequestError", "EnvironmentAuthorizationError"]); /** - * Inline error copy for a failed answer. Only known server errors pass their - * message through: anything else (transport or encoding failures) gets the - * generic copy, so the typed value can never surface in the UI. + * Only known server errors pass their message through: anything else (transport or encoding + * failures) gets the generic copy, so the typed value can never surface in the UI. */ export function secretRequestFailureMessage(failure: unknown): string { if ( diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts index 11e1e1c09c9c..dd7db2b61aee 100644 --- a/packages/client-runtime/src/sourceControlClients.test.ts +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -9,7 +9,6 @@ describe("sourceControlClients", () => { }); it("gives a change request URL to the first host whose shape it has", () => { - // GitCafe and Forgejo share `/pulls/`; GitCafe only claims its own hostnames. const kindOf = (url: string) => sourceControlClients.findByChangeRequestUrl(url)?.kind; expect(kindOf("https://git.cafe/acme/web/pulls/7")).toBe("gitcafe"); expect(kindOf("https://codeberg.org/acme/web/pulls/7")).toBe("forgejo"); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index de68831cf58b..55c2cde6456e 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -1,12 +1,3 @@ -/** - * The source control host definitions web and mobile ship, and the one lookup they use. - * - * Adding a host means writing its `@t3tools/source-control-` package with a - * `./client/definition` entry and adding it here; clients then label, list, and check out its - * change requests without further branches. - * - * @module client-runtime/sourceControlClients - */ import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; import { @@ -29,7 +20,7 @@ import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; * GitHub comes first because a repository that has not reported its host yet reads as GitHub, * which is what clients have always shown there. A change request URL goes to the first host * whose shape it has, so GitCafe, which claims `/pulls/` only on its own hostnames, comes before - * Forgejo, which claims it everywhere. Pickers sort by readiness and label, not by this order. + * Forgejo, which claims it everywhere. */ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ GitHub.definition, @@ -40,17 +31,12 @@ const BUILT_IN_SOURCE_CONTROL_CLIENTS = [ AzureDevOps.definition, ]; -/** `definitions` lists the built-in hosts in that order, for pickers that offer every host. */ export const sourceControlClients = makeSourceControlClientRegistry( BUILT_IN_SOURCE_CONTROL_CLIENTS, ); export type { GitHubSettings } from "@t3tools/source-control-github/client/definition"; -/** - * An environment's GitHub host choices and tokens, decoded with GitHub's settings schema. GitHub's - * settings panels are its own, since its logins come from `gh` at runtime. - */ export function readGitHubSettings(settings: Pick) { return readSourceControlHostSettings(GitHub.settings, settings.sourceControlHosts.github); } diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index c572f51370e8..fd665263254f 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -438,7 +438,6 @@ describe("project favicon URL cache", () => { function mountClonedProjectFavicon(initialClones: ReadonlyArray) { const registry = AtomRegistry.make(); - // Stands in for the server, which reports the icon missing until the clone lands. const server = { lookups: 0, landed: false }; const result = Atom.make(() => { server.lookups += 1; @@ -475,7 +474,6 @@ describe("project favicon URL cache", () => { expect(registry.get(favicon)).toBe( "https://remote.test/api/assets/token-a/project-favicon-missing", ); - // Progress, and another folder's clone landing, do not ask again. registry.set(clones, [ { ...cloning, percent: 80, sequence: 2 }, { diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 8543dd957731..2ecd01adc508 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -71,30 +71,19 @@ export type AssetUrlState = | { readonly _tag: "Loading" } | { readonly _tag: "Failure"; - /** Set when the session lacks `filesystem:read`; the file itself may be fine. */ readonly reason?: "missing-file-access"; } | { readonly _tag: "Success"; readonly url: string; - /** When the signed URL stops working, in epoch milliseconds. */ readonly expiresAt: number; - /** The host path the server chose to serve, when it differs from what was asked for. */ readonly sourcePath?: string; - /** Pixel size from the image header, when the server could read one. */ readonly imageDimensions?: AssetImageDimensions; }; -/** Shown in place of a host file the session lacks `filesystem:read` for. */ export const MISSING_FILE_ACCESS_MESSAGE = "This connection cannot read host files. Pair again with a new link to view it."; -/** - * The state for a host file this session cannot read yet, or null when the client - * should ask the host. The host requires `filesystem:read` for these resources, so - * clients skip the request. A connected session without the scope gets its own - * reason, so the UI can say to pair again instead of implying the file is gone. - */ export function blockedAssetUrlState( resource: AssetResource | null, fileAccess: { @@ -113,7 +102,6 @@ export function blockedAssetUrlState( return null; } if (fileAccess.isPending) return { _tag: "Loading" }; - // An error means the session check itself failed, such as when offline. return fileAccess.error === null ? { _tag: "Failure", reason: "missing-file-access" } : { _tag: "Failure" }; @@ -172,7 +160,6 @@ export function createAssetEnvironmentAtoms( local.environmentId, request(WS_METHODS.assetsCreateUrl, input), ); - // Callers resolve against the thread's server, so preserve the local server's origin. return { ...asset, relativeUrl: new URL(asset.relativeUrl, local.httpBaseUrl).href }; }); const createUrl = createEnvironmentQueryAtomFamily(runtime, { @@ -210,10 +197,6 @@ export function createAssetEnvironmentAtoms( }; } -/** - * Keeps project icons visible while their environment reconnects. Each resource - * owns its last resolved URL, including a confirmed missing-icon response. - */ export function createProjectFaviconUrlAtomFamily(input: { readonly imageCache?: ProjectFaviconCache; readonly createUrl: (target: { @@ -223,7 +206,6 @@ export function createProjectFaviconUrlAtomFamily(input: { readonly preparedConnection: ( environmentId: EnvironmentId, ) => Atom.Atom>; - /** The environment's tracked clones, empty when it reports none. */ readonly projectClones?: ( environmentId: EnvironmentId, ) => Atom.Atom>; diff --git a/packages/client-runtime/src/state/attachments.test.ts b/packages/client-runtime/src/state/attachments.test.ts index 4192d471f22f..d13f2c4e885c 100644 --- a/packages/client-runtime/src/state/attachments.test.ts +++ b/packages/client-runtime/src/state/attachments.test.ts @@ -19,8 +19,6 @@ import { } from "./attachments.ts"; const environmentId = EnvironmentId.make("environment-1"); -// The cycle threads the registry through to the commands untouched, so the -// fakes below can ignore it. const registry = {} as AtomRegistry.AtomRegistry; type CreateUploadUrlCommand = AtomCommand< @@ -128,13 +126,8 @@ describe("runAttachmentUploadCycle", () => { describe("verifyPersistedAttachmentUpload", () => { it("hits the server on every verification instead of reusing a cached failure", async () => { - // Mirrors the app's asset URL query atom: SWR-cached with a long stale - // window and kept alive across calls. Without a forced refresh, the - // second verification would read the cached failure and never retry. let lookups = 0; const assetUrlAtom = Atom.make( - // Async like the real RPC, so the first read is still in flight when - // the query decides whether a refresh is needed. Effect.promise(() => Promise.resolve()).pipe( Effect.flatMap(() => { lookups += 1; diff --git a/packages/client-runtime/src/state/attachments.ts b/packages/client-runtime/src/state/attachments.ts index b58a8e34e5a9..a78113a20109 100644 --- a/packages/client-runtime/src/state/attachments.ts +++ b/packages/client-runtime/src/state/attachments.ts @@ -17,11 +17,6 @@ import { type AtomCommand, } from "./runtime.ts"; -/** - * RPC commands for pending chat attachment uploads. Mirrors - * `createAssetEnvironmentAtoms`: each client instantiates it with its own - * connection runtime (`attachmentEnvironment` in web and mobile). - */ export function createAttachmentEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { @@ -38,13 +33,9 @@ export function createAttachmentEnvironmentAtoms( } /** - * Whether a failed asset lookup means the attachment no longer exists on the - * server, as opposed to a transient transport failure. Pending uploads expire, - * so this is the signal to upload the bytes again rather than retry the lookup. - * - * A structural `_tag` check rather than a schema check: the squashed cause of - * a failed RPC is not guaranteed to be a decoded error class instance, only a - * tagged value. + * Pending uploads expire, so this is the signal to upload the bytes again rather than retry the + * lookup. A structural `_tag` check rather than a schema check: the squashed cause of a failed + * RPC is not guaranteed to be a decoded error class instance, only a tagged value. */ export function isAssetAttachmentNotFoundFailure(error: unknown): boolean { return ( @@ -60,12 +51,6 @@ export type PersistedAttachmentVerification = | { readonly status: "missing" } | { readonly status: "failed"; readonly error: unknown }; -/** - * Checks that a previously uploaded pending attachment still exists on the - * server by minting an asset URL for it. `verified` means the send can reuse - * the stored bytes, `missing` means the upload expired and the bytes must be - * uploaded again, `failed` means the server could not be asked. - */ export async function verifyPersistedAttachmentUpload(input: { readonly registry: AtomRegistry.AtomRegistry; readonly createAssetUrl: (query: { @@ -109,7 +94,6 @@ type AttachmentRemoveCommand = AtomCommand< E >; -/** Fire-and-forget delete of a pending upload the client no longer references. */ export function deletePendingAttachmentUpload(input: { readonly registry: AtomRegistry.AtomRegistry; readonly remove: AttachmentRemoveCommand; @@ -124,7 +108,6 @@ export function deletePendingAttachmentUpload(input: { ); } -/** A running byte transfer: resolves when the server accepted the bytes. */ export interface AttachmentByteUpload { readonly done: Promise; readonly abort: () => void; @@ -141,14 +124,10 @@ export type AttachmentUploadCycleResult = }; /** - * The platform-neutral upload cycle: mint a signed upload URL, resolve it - * against the environment's HTTP base, and hand the bytes to a - * platform-specific transport (XHR on web, `expo-file-system` on mobile). - * - * The cycle never deletes the minted pending upload on failure: callers keep - * the returned `attachmentId` and decide between retry and release. The one - * exception is `onMinted` returning `"cancel"`, where the caller already - * abandoned the upload and the fresh mint is deleted before returning. + * The cycle never deletes the minted pending upload on failure: callers keep the returned + * `attachmentId` and decide between retry and release. The one exception is `onMinted` + * returning `"cancel"`, where the caller already abandoned the upload and the fresh mint is + * deleted before returning. */ export async function runAttachmentUploadCycle(input: { readonly registry: AtomRegistry.AtomRegistry; @@ -158,7 +137,6 @@ export async function runAttachmentUploadCycle(input: { readonly upload: AttachmentCreateUploadUrlInput; readonly resolveUploadUrl: (relativeUrl: string) => string | null; readonly transport: (url: string) => AttachmentByteUpload; - /** Observe the minted id (for cancellation bookkeeping) before bytes move. */ readonly onMinted?: (attachmentId: string) => "continue" | "cancel"; readonly onTransferStart?: (abort: () => void) => void; }): Promise { @@ -208,23 +186,19 @@ export async function runAttachmentUploadCycle(input: { } /** - * The effective per-file byte limit for a server that advertises - * `capabilities.fileAttachments.maxUploadBytes`. The contract caps what a - * turn may reference, so a larger advertised value must not admit files the - * send would then refuse. + * The contract caps what a turn may reference, so a larger advertised value must not admit + * files the send would then refuse. */ export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number): number { return Math.min(advertisedMaxUploadBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES); } -/** "3.2 MB" / "48 KB" label for attachment rows. Never shows "0 KB". */ export function formatAttachmentSize(sizeBytes: number): string { return sizeBytes >= 1024 * 1024 ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`; } -/** User-facing rejection for a file over the effective upload limit. */ export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string { const maxUploadSize = maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0 diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts index 906f06fb95aa..dc6579217f3c 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts @@ -158,7 +158,6 @@ describe("boundedThreadSnapshotLoader", () => { const fetchFn = ((input: RequestInfo | URL) => { const url = String(input); if (url.includes(`/api/orchestration/threads/${THREAD_ID}/bounded`)) { - // Generic HTML/route 404: not a structured thread_not_found payload. return Promise.resolve( new Response("Not Found", { status: 404, diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index 9f9b896b42c1..5a903226a319 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -15,14 +15,8 @@ import { } from "./environmentHttpAuth.ts"; import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; -// Same cold-open budget as the full snapshot path; bounded payloads should fit. const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; -/** - * Load a bounded recent-window thread snapshot over HTTP. Opts into compact - * turnItems and restores them, so callers always see the full bounded shape. - * Older servers ignore the query and send the full shape. - */ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentBoundedThreadSnapshot", )(function* (input: { @@ -50,7 +44,6 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( headers: withOrchestrationProtocolHeader(headers), }), }).pipe( - // Drop the marker with the restore so nothing can restore twice. Effect.map(({ turnItemsOmitLocalVisible, ...snapshot }) => ({ ...snapshot, projection: boundedSnapshotProjection({ ...snapshot, turnItemsOmitLocalVisible }), @@ -59,12 +52,9 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( }); /** - * Shared ThreadSnapshotLoader for clients that render progressive history. - * Seeds from the bounded HTTP snapshot when available. Older servers that lack - * the bounded route (plain/generic 404) fall back to the existing full HTTP - * thread snapshot. Structured EnvironmentResourceNotFoundError from either - * endpoint still means missing. Transient failures report `unavailable` so the - * socket path remains a last resort for connectivity issues. + * Older servers that lack the bounded route (plain/generic 404) fall back to + * the existing full HTTP thread snapshot. Structured + * EnvironmentResourceNotFoundError from either endpoint still means missing. */ export const layer: Layer.Layer< ThreadSnapshotLoader.ThreadSnapshotLoader, diff --git a/packages/client-runtime/src/state/cachePersistence.ts b/packages/client-runtime/src/state/cachePersistence.ts index 57e2661184e3..c9da5fbc8c02 100644 --- a/packages/client-runtime/src/state/cachePersistence.ts +++ b/packages/client-runtime/src/state/cachePersistence.ts @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; -// The sliding queue keeps only the latest pending snapshot while writes cool down. // Do not debounce upstream: its consumer can retain an older snapshot during a save. export const runCachePersistence = Effect.fn("runCachePersistence")(function* ( queue: Queue.Queue, diff --git a/packages/client-runtime/src/state/commandPermissions.ts b/packages/client-runtime/src/state/commandPermissions.ts index 289757bc6ab5..4d92ec63c53b 100644 --- a/packages/client-runtime/src/state/commandPermissions.ts +++ b/packages/client-runtime/src/state/commandPermissions.ts @@ -13,7 +13,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { createEnvironmentSessionAtoms } from "./session.ts"; -/** UI availability and dispatch use the same target session and method policy. */ function makeCommandPermissions( runtime: Atom.AtomRuntime, method: string, @@ -77,7 +76,6 @@ const permissionsByRuntime = new WeakMap< Map> >(); -/** Reuse availability atoms and dispatch guards across commands on the same runtime. */ export function createCommandPermissions( runtime: Atom.AtomRuntime, method: string, diff --git a/packages/client-runtime/src/state/composerDispatch.ts b/packages/client-runtime/src/state/composerDispatch.ts index 4e5f9b6ae119..b6f76499a331 100644 --- a/packages/client-runtime/src/state/composerDispatch.ts +++ b/packages/client-runtime/src/state/composerDispatch.ts @@ -1,15 +1,7 @@ -/** - * How a composer submission is delivered when the thread already has a turn in - * flight. Shared by web (where the alternate is Mod+Enter) and mobile (where it - * is a long-press on the send button), so both clients agree on what the user's - * configured follow-up behavior means. - */ export type ComposerDispatchMode = "auto" | "queue" | "steer" | "restart"; export type ActiveTurnComposerAction = Exclude; -/** The alternate switches between queue and steer relative to the configured action. */ export function resolveComposerDispatchMode(input: { - /** A turn is in flight, so the follow-up has to queue behind it or steer it. */ readonly running: boolean; readonly alternateModifier: boolean; readonly activeTurnDefault?: ActiveTurnComposerAction; @@ -20,7 +12,6 @@ export function resolveComposerDispatchMode(input: { return defaultAction; } -/** What the alternate would do, for labelling the affordance that triggers it. */ export function alternateComposerDispatchAction( activeTurnDefault?: ActiveTurnComposerAction, ): ActiveTurnComposerAction { diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index d47761a2ce66..d2117122c6a9 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -25,11 +25,6 @@ export interface EnvironmentCatalogState { readonly entries: ReadonlyMap; } -/** - * Environments that take part in the workspace: projects, threads, and shell - * summaries only come from these. Disabled environments stay in `entries` so - * Settings can list them and switch them back on. - */ export function* enabledEnvironmentIds( catalog: EnvironmentCatalogState, ): Generator { diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts index b84d1d92b64f..c56a10103fdc 100644 --- a/packages/client-runtime/src/state/device.ts +++ b/packages/client-runtime/src/state/device.ts @@ -17,7 +17,6 @@ export function createDeviceEnvironmentAtoms( key: ({ environmentId }: { environmentId: string }) => environmentId, }; return { - /** Server-pushed device hosts, devices, and open sessions for one environment. */ state: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:device:state", tag: WS_METHODS.subscribeDeviceState, @@ -71,7 +70,6 @@ export function createDeviceEnvironmentAtoms( }; } -/** Unknown inventory is distinct from a completed check that found no install. */ export function deviceToolVersionLabels(tools: DeviceToolVersions | undefined) { if (!tools) return ["Device tool versions have not been checked."]; return ( diff --git a/packages/client-runtime/src/state/deviceHubAccess.ts b/packages/client-runtime/src/state/deviceHubAccess.ts index f585efbcb727..5e9800cbad12 100644 --- a/packages/client-runtime/src/state/deviceHubAccess.ts +++ b/packages/client-runtime/src/state/deviceHubAccess.ts @@ -1,15 +1,8 @@ /** - * Credentials for the Device panel's media requests. - * - * The panel reaches simulator streams through `/api/device-hub/*` on the - * environment origin. ``, `EventSource`, and `WebSocket` cannot set - * bearer or DPoP headers, so bearer and DPoP connections mint a - * short-lived WebSocket ticket and pass it as `wsTicket`, the same way the - * app's own `/ws` upgrade authenticates. Cookie sessions send the cookie. - * - * A ticket lives five minutes server-side and is bound to the session, not - * to one request, so one ticket covers everything a panel opens at once. - * Callers fetch a fresh one each time they (re)connect a stream. + * ``, `EventSource`, and `WebSocket` cannot set bearer or DPoP headers, so bearer and DPoP + * connections mint a short-lived WebSocket ticket and pass it as `wsTicket`, the same way the + * app's own `/ws` upgrade authenticates. Callers fetch a fresh one each time they (re)connect a + * stream. */ import * as Effect from "effect/Effect"; import type { HttpClient } from "effect/http"; diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index f72e65723260..02d9fa11696f 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -227,7 +227,6 @@ describe("V2 client presentation", () => { pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], }); - // Sidebar and mobile list read runtime "idle" as Waiting before failure. expect(shell.runtime).toMatchObject({ status: "failed", lastError: "Provider turn failed" }); expect(shell.pendingBackgroundTasks).toHaveLength(1); }); @@ -244,13 +243,11 @@ describe("V2 client presentation", () => { providerInstanceHistory: [codex, claude, cursor, grok], }); - // Three slots: the two most recent earlier owners, then the current one. expect(resolveThreadProviderStack(shell)).toEqual([claude, cursor, grok]); expect( resolveThreadProviderStack({ ...shell, providerInstanceHistory: [codex, grok] }), ).toEqual([codex, grok]); expect(resolveThreadProviderStack({ ...shell, providerInstanceHistory: [] })).toEqual([grok]); - // Servers that predate the field decode to an empty history. expect( presentThreadShell(environmentId, { ...v2ThreadShell, providerInstanceHistory: undefined }) .providerInstanceHistory, @@ -347,8 +344,6 @@ describe("V2 client presentation", () => { ...v2ThreadShell, latestRunId: runId, activeRunId: runId, - // Stale: server already projected a post-settlement roster, but shell - // status still says running (packaged orchestrator-v2 bug). status: "running", pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], }); @@ -369,7 +364,6 @@ describe("V2 client presentation", () => { ...v2ThreadShell, latestRunId: runId, activeRunId: runId, - // Stale: checkpoint-oriented waiting masks post-settlement background work. status: "waiting", pendingBackgroundTasks: [{ taskId: "bg-2", description: "Watch build", kind: "monitor" }], }); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 587d707b8a07..e81a12bc3d4f 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -214,7 +214,6 @@ const LOADERS: ReadonlyArray<{ path: "/api/orchestration/shell", response: SHELL, expected: SHELL, - // Pull request links decode separately; the rows match the response on their own. load: (input: HttpInput) => fetchEnvironmentShellSnapshot(input).pipe( Effect.map(({ loadPullRequests: _links, ...snapshot }) => snapshot), diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index 9693c7215b3c..6f75be719abf 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -52,17 +52,6 @@ const withEnvironmentCredentials = ( ? request.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" })) : request; -/** - * Build request-bound headers from the current environment credential: - * - primary/local connections carry no credential, - * - bearer connections send a static `Bearer` token, - * - relay connections send a `DPoP` access token with a freshly signed proof - * bound to this request's method and URL. - * - * The DPoP signer is passed in (not resolved from context) and is only required - * for relay/DPoP connections, so bearer/primary connections work even when no - * signer is available. - */ const buildEnvironmentAuthHeaders = ( authorization: PreparedHttpAuthorization | null, method: HttpMethod.HttpMethod, @@ -97,10 +86,6 @@ const buildEnvironmentAuthHeaders = ( }); /** - * Resolve relay credentials at request time without replacing the live socket. - * A rejected credential gets one refresh and retry, with a new request-bound - * proof. Cookie and bearer requests keep their existing authentication behavior. - * * A DPoP request is T3 Connect work, so its span starts an exported trace that * the environment continues; its local caller's span would leave that trace * without a root. diff --git a/packages/client-runtime/src/state/fileMetadata.ts b/packages/client-runtime/src/state/fileMetadata.ts index d0d59db97990..cf58cfb015b5 100644 --- a/packages/client-runtime/src/state/fileMetadata.ts +++ b/packages/client-runtime/src/state/fileMetadata.ts @@ -52,8 +52,6 @@ class FileMetadataRequest extends Request.Class< EnvironmentRpcFailure | EnvironmentRpcUnavailableError > {} -/** Shared by file chips, search results, and the file tree in one client. Cache - * entries belong to an RPC session so reconnects and grant changes recheck them. */ export function createFileMetadataAtoms( runtime: Atom.AtomRuntime, ) { @@ -250,7 +248,6 @@ export function createFileMetadataAtoms( const path = resolveWorkspaceFilePath(entry.path, cwd); const previous = knownEntry(session.value, path, now); if (previous?.value?.kind === entry.kind) continue; - // Listing kinds avoid another request; size and MIME hints are optional. remember(session.value, path, { kind: entry.kind }, now, true); } }); diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index b674b8953249..07372c2a40e6 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -9,7 +9,6 @@ import { resolveFilesystemReadAccess, } from "./filesystem.ts"; -/** A server that already splits scopes; it never falls back to a parent grant. */ const SPLIT_SCOPES_SERVER = { serverUpdateScope: "environment:maintain" } as const; describe("filesystem read access", () => { diff --git a/packages/client-runtime/src/state/itemSupport.ts b/packages/client-runtime/src/state/itemSupport.ts index 936ac4d9c4e0..6d5b22ac31e5 100644 --- a/packages/client-runtime/src/state/itemSupport.ts +++ b/packages/client-runtime/src/state/itemSupport.ts @@ -32,11 +32,6 @@ export const EMPTY_V2_ITEM_SUPPORT: V2ItemSupport = Object.freeze({ contextTransfer: null, }); -/** - * Resolves the relational V2 entities that enrich one projected item. The - * caller supplies the item's source-thread projection, including for inherited - * rows, so every returned entity retains its original projection identity. - */ export function resolveV2ItemSupport(projection: Projection, itemId: TurnItemId): V2ItemSupport { const item = projection.turnItems.find((candidate) => candidate.id === itemId) ?? diff --git a/packages/client-runtime/src/state/mcpApps.ts b/packages/client-runtime/src/state/mcpApps.ts index c952add37e02..26c069f558d3 100644 --- a/packages/client-runtime/src/state/mcpApps.ts +++ b/packages/client-runtime/src/state/mcpApps.ts @@ -4,11 +4,6 @@ import type { Atom } from "effect/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { createEnvironmentRpcCommand } from "./runtime.ts"; -/** - * RPC commands an MCP App host makes on an app's behalf. Each client - * instantiates them with its own connection runtime (`mcpAppEnvironment` in - * web and mobile). - */ export function createMcpAppEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index f5d6df4f001e..020b690a6023 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -22,16 +22,12 @@ import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; export interface EnvironmentProject extends OrchestrationProjectShell { readonly environmentId: EnvironmentId; - /** Derived from the environment's configured scratch root, never persisted. */ readonly isScratch?: true; } /** - * A pristine V2 thread projection paired with the environment that produced it. - * * The projection stays nested so the server projection and all structurally - * shared collections retain their identities. Rich consumers read V2 state - * directly instead of a second presentation-shaped thread graph. + * shared collections retain their identities. */ export interface EnvironmentThread { readonly environmentId: EnvironmentId; @@ -67,8 +63,6 @@ export function threadRuntimeIsActive(runtime: ThreadRuntimeSummary | null | und return runtime !== null && runtime !== undefined && threadRunStatusIsActive(runtime.status); } -/** Archiving may discard queued work, but it must not detach a provider that - * is preparing, starting, or running a turn. */ export function threadRuntimeCanArchive(runtime: ThreadRuntimeSummary | null | undefined): boolean { if (runtime?.status === "queued") return runtime.activeRunId === null; return ( @@ -105,20 +99,15 @@ export interface EnvironmentThreadShell { readonly latestRun: ThreadRunSummary | null; readonly runtime: ThreadRuntimeSummary | null; readonly latestUserMessageAt: string | null; - /** The last message the user wrote. `undefined` means the server predates it. */ readonly latestUserAuthoredMessageAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; - /** What a live subagent below this thread waits on the user for. Subagent - rows are hidden, so the threads above them carry it. */ readonly pendingSubagentRequest?: PendingSubagentRequest | null; readonly hasActionableProposedPlan: boolean; readonly pendingBackgroundTasks: ReadonlyArray< NonNullable[number] >; - /** Provider instances that have owned the root conversation, oldest first. */ readonly providerInstanceHistory: ReadonlyArray; - /** Native `/goal` on the active provider thread. */ readonly goal: OrchestrationV2ProviderGoal | null; readonly itemCount: number; readonly visibleItemCount: number; @@ -133,9 +122,7 @@ export interface EnvironmentThreadShell { readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null; readonly pinnedAt: string | null; readonly autoSettleDisabledAt?: string | null; - /** Slot in the user-arranged pinned order; null for keyless (legacy) pins. */ readonly pinOrderKey: string | null; - /** Slot in the user-arranged active order; null for keyless active threads. */ readonly activeOrderKey: string | null; readonly pullRequests: ReadonlyArray; readonly linkedPullRequest?: ThreadLinkedPullRequest | null; @@ -146,7 +133,6 @@ export interface EnvironmentThreadShell { * local visited state they keep. */ readonly lastVisitedAt?: string | null; - /** Pending title regeneration marker; null when no request is in flight. */ readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null; readonly deletedAt: string | null; readonly source: OrchestrationV2ThreadShell; @@ -170,19 +156,14 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { ); } -// Park runtime at idle when the post-settlement background roster holds the -// run's completion, so #4415 waiting-presentation Waiting (session.idle) can -// consume CTM runtime. Only work that wakes the agent holds it: commands it -// left running, such as a dev server, present the run's own status (#14872). -// The server suppresses the roster while an interruptible activity run exists, -// so a remaining roster is stronger than checkpoint-oriented waiting. -// latestRun keeps the latest run's status for history presentation. -// A failed latest run outranks the roster, so the failure stays visible. +// Only work that wakes the agent holds it: commands it left running, such as a +// dev server, present the run's own status (#14872). The server suppresses the +// roster while an interruptible activity run exists, so a remaining roster is +// stronger than checkpoint-oriented waiting. function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { const parkAtIdle = backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && thread.status !== "failed"; - // A pull request watch can hold a thread that never ran. if (thread.latestRunId === null && thread.activeProviderThreadId === null && !parkAtIdle) { return null; } @@ -309,11 +290,6 @@ export function scopeThreadShell( const THREAD_PROVIDER_STACK_LIMIT = 3; -/** - * Provider instances to draw in a thread row's trailing stack, back to front: - * the current one is always last, earlier owners precede it oldest first. - * Newest history wins when the thread has been handed off more times than fit. - */ export function resolveThreadProviderStack( thread: Pick, ): ReadonlyArray { @@ -336,7 +312,6 @@ export function resolveThreadWorkingStartedAt(input: { const valid = (value: string | null | undefined) => value != null && Number.isFinite(Date.parse(value)) ? value : null; if (input.runtime?.activityStartedAt !== undefined) return valid(input.runtime.activityStartedAt); - // Older servers can supply a timestamp only if the newest run owns the work. const run = input.latestRun; if (run?.completedAt === null && run.runId === input.runtime?.activeRunId) { return valid(run.startedAt) ?? valid(run.requestedAt); diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index aab0edd2fad0..e01759225aa8 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -88,7 +88,6 @@ export function createOrchestrationEnvironmentAtoms( workflowScript: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:workflow-script", tag: ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, - // Scripts are immutable per run: cache generously. staleTimeMs: 300_000, idleTtlMs: 300_000, }), @@ -96,7 +95,6 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:stop-workflow", tag: ORCHESTRATION_V2_WS_METHODS.stopWorkflow, }), - // Keyed by the item revision, so a live row refetches as its output grows. turnItem: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:turn-item", tag: ORCHESTRATION_V2_WS_METHODS.getTurnItem, diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index 478365a2e531..eb7a6217c772 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -346,8 +346,6 @@ describe("applyOrchestrationV2ProjectionEvent", () => { }) as OrchestrationV2DomainEvent; it("shows a queued run that starts after a newer steer raised the watermark", () => { - // Run 2 was queued first, run 3 was steered in and ran first. Its items - // sit in run 3's band and already raised the watermark past run 2's band. const steerItems = [ runItem("item-steer-user", steerRunId, 3_000_001), runItem("item-steer-reply", steerRunId, 3_000_002), @@ -357,8 +355,6 @@ describe("applyOrchestrationV2ProjectionEvent", () => { steerItems, ); let latestLocalTurnOrdinal = 3_000_002; - // Same order the orchestrator commits a queued start: the user message - // lands while the run is still queued, then the run starts and replies. const queuedUser = runItem("item-queued-user", queuedRunId, 2_000_001); const queuedReply = runItem("item-queued-reply", queuedRunId, 2_000_002); for (const event of [ @@ -385,8 +381,6 @@ describe("applyOrchestrationV2ProjectionEvent", () => { }); it("keeps a running run's item when the window starts after its earlier items", () => { - // The bounded window begins inside run 3; run 2 started and kept going, - // but none of its rows made it into the window. const runningRun = { ...queuedRun, status: "running" } as const; const steerItems = [ runItem("item-steer-user", steerRunId, 3_000_001), diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 3c9c6dfa79ca..c93044e93f27 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -116,7 +116,6 @@ function shouldDropMissingPartialTurnItem( ) { return false; } - // Later items of a run already in the window can raise the watermark first. if ( projection.visibleTurnItems.some( (row) => @@ -190,7 +189,6 @@ function upsertVisibleTurnItem( return renumberVisibleItems(updated); } -/** Applies one committed event to a matching thread projection. */ export function applyOrchestrationV2ProjectionEvent( projection: OrchestrationV2ThreadProjection | null, event: OrchestrationV2DomainEvent, diff --git a/packages/client-runtime/src/state/outdatedServerUpdate.ts b/packages/client-runtime/src/state/outdatedServerUpdate.ts index 9ae5bd201e44..d2a6dec32d6b 100644 --- a/packages/client-runtime/src/state/outdatedServerUpdate.ts +++ b/packages/client-runtime/src/state/outdatedServerUpdate.ts @@ -24,10 +24,6 @@ export interface OutdatedServerUpdateTarget { readonly fromVersion?: string; } -/** - * Updates a host too old for this client to connect to. Progress lands in the - * same per-environment update state as a normal server update. - */ export function createOutdatedServerUpdateCommand( runtime: Atom.AtomRuntime< | EnvironmentRegistry.EnvironmentRegistry diff --git a/packages/client-runtime/src/state/presentation.test.ts b/packages/client-runtime/src/state/presentation.test.ts index cd610608e309..cd56297999b6 100644 --- a/packages/client-runtime/src/state/presentation.test.ts +++ b/packages/client-runtime/src/state/presentation.test.ts @@ -169,7 +169,6 @@ describe("environment summary subscriptions", () => { expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(false); h.registry.set(h.configs(FIRST), config(true)); expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(true); - // The first true result short-circuits: the second must be read when the first stops supporting it. h.registry.set(h.configs(SECOND), config(true)); h.registry.set(h.configs(FIRST), null); expect(h.registry.get(h.pullRequestsSupportedAtom)).toBe(true); diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index deca7ff162b3..74c5ed602fdc 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -34,7 +34,6 @@ export function createEnvironmentPresentationAtoms(input: { readonly stateAtom: ( environmentId: EnvironmentId, ) => Atom.Atom>; - /** Authoritative live server config, including streamed provider/settings updates. */ readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom; }) { const presentationAtom = Atom.family((environmentId: EnvironmentId) => @@ -107,7 +106,6 @@ export function projectEnvironmentConnectionSummary( }; } -// Keep list membership and connection chrome independent of provider/config refreshes. export function createEnvironmentSummaryAtoms(input: { readonly catalogValueAtom: Atom.Atom; readonly presentationAtom: ( @@ -147,7 +145,6 @@ export function createEnvironmentSummaryAtoms(input: { if (presentation === null) return null; const next = projectEnvironmentConnectionSummary(environmentId, presentation); const previous = Option.getOrNull(get.self()); - // Provider refreshes and transport heartbeats do not change connection UI. if ( previous !== null && previous.environmentId === next.environmentId && diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 46b85f7ff064..12b57e48a46b 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -70,7 +70,6 @@ function writtenFilePaths( ); } -/** The Scratch project was created, but its event never reached this client. */ export class ScratchProjectNotLoadedError extends Schema.TaggedError()( "ScratchProjectNotLoadedError", { projectId: ProjectId }, @@ -83,7 +82,6 @@ export class ScratchProjectNotLoadedError extends Schema.TaggedError( runtime: Atom.AtomRuntime, options: { - /** The client store's project; openScratch waits here for the created project. */ readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom; }, ) { @@ -196,7 +194,6 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), - // Makes a new folder and repository from just a name, then the project. createNew: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:create-new", tag: WS_METHODS.projectsCreateNew, diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 169cd2adc15f..ca3c5213ab7a 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -163,7 +163,6 @@ export function deriveLogicalProjectKeyFromSettings( }); } -/** Resolves a scratch filter selected before its environment config identified the project. */ export function resolveScratchProjectScopeKey( key: string | null, projects: ReadonlyArray< @@ -285,19 +284,10 @@ function selectProjectIdentitySource( return freshestIdentifiedProject ?? winner; } -/** - * Builds logical project groups without losing the physical projects that - * remain the actual navigation and task-creation targets. - * - * Presentation-specific metadata, filtering, and activity sorting stay in - * each client. Grouping modes, overrides, physical deduplication, labels, and - * member preservation live here so web and mobile cannot drift. - */ export function buildProjectGroups(input: { readonly projects: ReadonlyArray; readonly settings: ProjectGroupingSettings; readonly preferredEnvironmentId?: EnvironmentId | null; - /** Filter equivalence does not change the default ownership of mutable project settings. */ readonly groupScratchProjects?: boolean; }): ReadonlyArray> { const projectsByPhysicalKey = new Map(); @@ -377,10 +367,6 @@ export function buildProjectGroups(input: { }); } -/** - * Orders projects the way a sorted group list shows them: by the first group holding each - * project. Projects in no group follow, and ties keep their incoming order. - */ export function sortProjectsByGroupOrder< TProject extends { readonly id: string; readonly environmentId: string }, >( diff --git a/packages/client-runtime/src/state/projects.ts b/packages/client-runtime/src/state/projects.ts index 36b1f033ac36..6268519ea5cb 100644 --- a/packages/client-runtime/src/state/projects.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -135,7 +135,6 @@ export function findProjectByPath char.toUpperCase()); } -/** - * Resolve an instance's label with a tiered priority: - * - * 1. A snapshot `displayName` that differs from the driver's brand label β€” - * the server has explicitly named this instance, trust it. - * 2. For non-default instances, a humanized `instanceId` β€” the server fell - * back to the driver-level label (the same for every instance of that - * kind), so the slug is what keeps "Codex" and "Codex Personal" apart. - * 3. The snapshot's `displayName`, or the brand label from contracts. - */ export function resolveProviderInstanceDisplayName( snapshot: Pick, ): string { @@ -48,11 +26,6 @@ export function resolveProviderInstanceDisplayName( return trimmedSnapshotName || kindLabel; } -/** - * Turn a display name into up to two initials for the badge: the first two - * characters of a single word, or the first character of each of the first - * two words. Iterates by code point so an emoji never splits into surrogates. - */ export function providerInstanceInitials(label: string): string { const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean); if (words.length === 0) return ""; @@ -63,19 +36,12 @@ export function providerInstanceInitials(label: string): string { .join(""); } -/** Only `#rrggbb` accent colors render; anything else is treated as unset. */ export function normalizeProviderAccentColor(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined; } -/** - * Whether an instance's icon carries the account badge: accent color set, or - * several instances sharing a provider so the brand glyph alone is ambiguous. - * ACP agents have distinct glyphs even though they share the registry driver. - * Shared by the composer trigger, the picker rail, and sidebar/thread rows. - */ export function shouldShowInstanceBadge( entry: { readonly driverKind: ProviderDriverKind; diff --git a/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts b/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts index 849553e89359..b762c35a99eb 100644 --- a/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts +++ b/packages/client-runtime/src/state/pullRequestDiffHttp.test.ts @@ -69,8 +69,6 @@ describe("fetchEnvironmentPullRequestDiff", () => { : init.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : ""; - // The assertion deliberately inspects the serialized wire body rather than decoding a - // domain value for use in application code. expect(JSON.parse(body)).toEqual({ projectId: "project-1", repository: "owner/repository", diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index 7b7addaa8c72..b95b3d1ec7f4 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -90,7 +90,6 @@ type PendingMetadata = WeakMap = new WeakMap(); const pendingIdentities: PendingMetadata = new WeakMap(); -/** Share only active read probes. Completed probes and mutations always read the current account. */ function shareMetadataRead( pending: PendingMetadata, key: string, @@ -140,7 +139,6 @@ function isLocal(entry: ConnectionCatalogEntry): boolean { } } -/** A guard rejection is returned before the operation starts. Other write failures are ambiguous. */ function rejectedBeforeDispatch(error: unknown): boolean { return ( typeof error === "object" && @@ -214,7 +212,7 @@ const invalidateTarget = Effect.fn("PullRequestRouting.invalidateTarget")(functi /** * The origin owns the threads linked to this pull request, but a routed read never reaches it. - * Tell it which state the read saw so it checks again. Best effort, and never holds up the read. + * Tell it which state the read saw so it checks again. */ const reportState = ( { allowStale: _allowStale, expectedAccountId: _expectedAccountId, ...reference }: PullRequestRef, @@ -351,7 +349,6 @@ export function createPullRequestRouter() { identityProbe, ) : identityProbe; - // Old servers and unknown accounts retain the existing path. if (identity === null || identity.provider !== "github") return yield* finish(source); const routedInput = { diff --git a/packages/client-runtime/src/state/pullRequestStatsPersistence.ts b/packages/client-runtime/src/state/pullRequestStatsPersistence.ts index ba38a299921e..41b45876b770 100644 --- a/packages/client-runtime/src/state/pullRequestStatsPersistence.ts +++ b/packages/client-runtime/src/state/pullRequestStatsPersistence.ts @@ -13,7 +13,6 @@ const requestKey = Schema.fromJsonString(Schema.Tuple([Schema.String, PullReques const statKey = (ref: PullRequestRef) => JSON.stringify(["pull-request-stat", ref.projectId, ref.host ?? "", ref.repository, ref.number]); -/** Visible rows are batched differently after a restart, so store their counts individually. */ export function persistPullRequestStats(): QueryPersistence { const row = persistQuery(PullRequestDiffStat); return { diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 0217cfd00078..2bb7fdd82485 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -851,7 +851,6 @@ it.effect.each( }).pipe( Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - // This also represents a user enabling the stale catalog entry after re-resolution. Effect.provideService(GitHubRoutingPermissions, trustedRouting), Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), ); @@ -1497,7 +1496,6 @@ it.effect("shares close, reopen, and merge with an untouched client's mounted PR }), ); expect(AsyncResult.isSuccess(result)).toBe(true); - // The second client receives only the server push: no local refresh or timer tick. yield* detailChanged.await; yield* summaryChanged.await; yield* listChanged.await; @@ -1912,7 +1910,6 @@ it.effect("updates reviewer requests and enriched reviewers without rereading th ]); } expect(reads).toBe(3); - // A slow activity read started before the write must not hide the new request. pauseActivity = true; registry.refresh(activity); yield* activityStarted.await; @@ -1931,7 +1928,6 @@ it.effect("updates reviewer requests and enriched reviewers without rereading th yield* request(false); expect((yield* AtomRegistry.getResult(registry, activity)).reviewers).toEqual([hostActor]); - // A caller without an open picker still needs authoritative reviewer identities. const otherTarget = { ...target, input: { ...target.input, number: 2 } }; const otherDetail = atoms.detail(otherTarget); registry.mount(otherDetail); @@ -2009,8 +2005,6 @@ it.effect("refreshes stack state after reopening and head SHAs after a turn", () ), ); -// Transport fixtures have a source-control-only session; authorization edge cases -// are exercised by commandPermissions.test.ts. vi.mock("./session.ts", () => ({ createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), })); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index a700f4de1032..be7d22c0eb67 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -54,7 +54,6 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; -/** Keep confirmed edits on the same cached reference regardless of input property order. */ function writableQueryFamily( family: (target: { readonly environmentId: EnvironmentId; @@ -115,7 +114,6 @@ function createPullRequestRefreshAtomFamily( }); } -/** Refresh only the live fields a linked thread renders. A merged pull request is not re-read. */ export function createLinkedPullRequestSummaryAtomFamily( runtime: Atom.AtomRuntime, refreshes = createPullRequestRefreshAtomFamily(runtime), @@ -133,7 +131,6 @@ export function createLinkedPullRequestSummaryAtomFamily( }); } -/** The host-native stack a pull request belongs to; null where it is not stacked. */ export function createPullRequestStackAtomFamily( runtime: Atom.AtomRuntime, refreshes = createPullRequestRefreshAtomFamily(runtime), @@ -164,11 +161,6 @@ export function pullRequestDetailToVcsStatus( }; } -/** - * Reopening a PR within a minute reuses detail and activity. Explicit refreshes and - * turn notifications still revalidate. Mutations run serially per environment: actions on the same - * pull request are order-sensitive. Confirmed label and reviewer edits update cached state. - */ export function createPullRequestEnvironmentAtoms( runtime: Atom.AtomRuntime< EnvironmentRegistry | PullRequestDiffLoader.PullRequestDiffLoader | R, @@ -220,7 +212,6 @@ export function createPullRequestEnvironmentAtoms( staleTimeMs: 60_000, }), ); - // The stored guide only changes when somebody generates one, which refreshes it below. const reviewGuide = writableQueryFamily( createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:review-guide", @@ -258,10 +249,9 @@ export function createPullRequestEnvironmentAtoms( input.cursors === undefined ? refreshes({ environmentId, input: {} }) : undefined, }), /** - * The line counts for rows the listing has already handed over. Its own query because the - * listing is quicker without them β€” measured over twelve repositories, ~4.0s against ~7.1s β€” - * so the rows arrive first and their stats a moment later. Kept longer than the listing: - * a change request's size only moves when somebody pushes to it. + * Its own query because the listing is quicker without them β€” measured over twelve + * repositories, ~4.0s against ~7.1s β€” so the rows arrive first and their stats a moment + * later. */ listStats: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:list-stats", @@ -452,12 +442,6 @@ export function createPullRequestEnvironmentAtoms( scheduler: commandScheduler, concurrency: serialPerEnvironment, }), - /** - * Its own query rather than part of the detail: the people who may be asked are only wanted - * once somebody opens the reviewer menu, so this atom is read then and not before. Kept fresh - * for a minute, because who has access to a repository changes far more slowly than the - * change request it is being read for. - */ reviewerCandidates, requestReviewers: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:request-reviewers", @@ -535,19 +519,15 @@ export function createPullRequestEnvironmentAtoms( }), }), reviewGuide, - /** Runs a model on the environment; never fired without the reader asking for it. */ generateReviewGuide: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:generate-review-guide", tag: WS_METHODS.pullRequestsGenerateReviewGuide, execute: (input) => routedRequest(WS_METHODS.pullRequestsGenerateReviewGuide, input), onSuccess: (target, registry, result) => Effect.sync(() => { - // The command returns the accepted run. Publish it before starting ends so readers - // never fall back to the empty/failed state while the follow-up query is in flight. updateCached(registry, reviewGuide(target), () => result, true); }), }), - /** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */ labelCandidates, setLabels: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-labels", @@ -600,9 +580,8 @@ export function createPullRequestEnvironmentAtoms( concurrency: serialPerEnvironment, }), /** - * Explicit refresh: forget the server's cached answers, then re-run the reads. A separate - * request rather than a flag on a read, so only a person's refresh spends host requests - * while every silent re-read shares the cache. + * A separate request rather than a flag on a read, so only a person's refresh spends + * host requests while every silent re-read shares the cache. */ invalidate: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:invalidate", diff --git a/packages/client-runtime/src/state/queryPersistence.ts b/packages/client-runtime/src/state/queryPersistence.ts index 46b578ccbb99..05fdfbc6d711 100644 --- a/packages/client-runtime/src/state/queryPersistence.ts +++ b/packages/client-runtime/src/state/queryPersistence.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -/** Platforms opt in to storing selected query responses beside their other offline data. */ export class EnvironmentQueryStore extends Context.Reference<{ readonly load: ( environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 5bb3ed6801bc..055bec1a3e11 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -140,8 +140,8 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" }); /** - * Reads a query's settled value without leaving it mounted. `getResult` resumes from inside the - * query's listener, so it only unsubscribes once the reader yields. + * `getResult` resumes from inside the query's listener, so it only unsubscribes + * once the reader yields. */ const readEnvironmentQuery = ( registry: AtomRegistry.AtomRegistry, @@ -320,7 +320,6 @@ describe("finite environment subscription lifecycle", () => { Deferred.await(releaseFinal).pipe(Effect.as({ complete: true, count: 123 })), ), ).pipe(Stream.ensuring(Deferred.succeed(closed, undefined))); - // Following a registered environment normally remains open for replacements. const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ followStream: (_environmentId: EnvironmentId, source: typeof stream) => Stream.concat(source, Stream.never), @@ -667,7 +666,6 @@ describe("environment query lifecycle", () => { registry.set(trigger, 1); expect(yield* read).toBe(2); - // Unmounted, the query stays alive on its idle TTL, but nothing reads it. unmount(); registry.set(trigger, 2); registry.set(trigger, 3); @@ -729,7 +727,6 @@ describe("environment query lifecycle", () => { it.effect("refreshes an idle query the registry swept and rebuilt after its trigger moved", () => Effect.scoped( Effect.gen(function* () { - // Only the registry's idle-TTL sweeps run on these; effects keep their own scheduler. vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers())); let executions = 0; @@ -745,12 +742,10 @@ describe("environment query lifecycle", () => { const unmount = registry.mount(harness.atom); expect(yield* read).toBe(1); unmount(); - // Lets the registry start the query node's idle TTL. yield* Effect.yieldNow; vi.advanceTimersByTime(500); registry.set(trigger, 1); yield* Effect.yieldNow; - // Sweeps the query node but not the data it read, whose idle TTL started with the trigger. vi.advanceTimersByTime(700); expect(executions).toBe(1); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 95aabd6d843d..72136cd0a5d2 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -74,7 +74,6 @@ interface EnvironmentQueryAtomOptions extends Omit< readonly environmentId: EnvironmentIdType; readonly input: Input; }) => Atom.Atom | undefined; - /** A value that can no longer change. It stops the interval and `refreshTrigger` re-reads. */ readonly isFinal?: (value: A) => boolean; } @@ -111,13 +110,8 @@ export interface AtomCommand { } export type AtomCommandConcurrency = - /** Every invocation runs independently. */ | { readonly mode: "parallel" } | { - /** - * `serial` preserves every invocation in FIFO order, `singleFlight` shares an active - * invocation, and `latest` coalesces queued invocations to the newest input. - */ readonly mode: "serial" | "singleFlight" | "latest"; readonly key: (input: W) => string; }; @@ -279,7 +273,6 @@ export function createAtomCommandScheduler(): AtomCommandScheduler { }; } -/** Runs one effect inside an existing command scheduler lane. */ export function scheduleAtomCommandEffect( registry: AtomRegistry.AtomRegistry, scheduler: AtomCommandScheduler, @@ -359,7 +352,6 @@ export interface AtomQueryOptions extends AtomCommandOptions { * verification flows where a cached failure must not satisfy a retry. */ readonly refresh?: boolean; - /** Interrupt the query wait when its caller no longer wants the result. */ readonly signal?: AbortSignal; } @@ -374,8 +366,6 @@ export async function executeAtomQuery( yield* AtomRegistry.mount(registry, atom); if (options.refresh) { yield* Effect.sync(() => { - // Only a settled value can be a leftover from an earlier read; a - // computation that mounting just started is already fresh. const current = registry.get(atom); if (current._tag !== "Initial" && !current.waiting) { registry.refresh(atom); @@ -480,7 +470,6 @@ function runInEnvironment( } /** - * Refreshes a query when `signal` changes, but only while something reads it. * `Atom.makeRefreshOnSignal` subscribes to the signal, so a query that outlives its view on an * idle TTL still re-runs on every change. Reading the signal as a dependency instead lets the * registry re-run a mounted query right away and only mark an idle one stale, which then @@ -508,7 +497,6 @@ const refreshOnSignalWhileRead = ); }; -/** `Atom.withRefresh`, except the timer stops once the value is final. */ const refreshEveryUntilFinal = (intervalMs: number, isFinal: (value: V) => boolean) => (self: Atom.Atom): Atom.Atom => diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index b55b09d38340..bec8fb2b990f 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -59,8 +59,6 @@ const CONFIG = { observability: null, providers: [], settings: {}, - // Capabilities drive version-skew behaviour in the projection, so the - // fixture carries them rather than leaving the field absent. environment: { capabilities: { environmentThemes: true } }, } as unknown as ServerConfig; @@ -182,8 +180,6 @@ describe("update restart reconnect nudges", () => { interval: Duration.zero, }); - // Three backoff entries, three nudges. The old one-shot behavior fired - // once and then let the supervisor's ladder stretch to 16-second gaps. expect(yield* Ref.get(retries)).toBe(3); }), ); @@ -200,7 +196,6 @@ describe("update restart reconnect nudges", () => { { startImmediately: true }, ); - // Each nudge waits out the interval first, so nothing fires immediately. yield* TestClock.adjust(Duration.millis(999)); expect(yield* Ref.get(retries)).toBe(0); @@ -320,7 +315,6 @@ describe("server state projection", () => { fromVersion: "0.0.30", targetVersion: "0.0.31", }); - // A prepared desktop app update restarts only after the client commits it. expect( serverUpdateStateForProgressEvent("0.0.30", "0.0.31", { type: "complete", @@ -455,8 +449,6 @@ describe("server state projection", () => { }); expect(Option.getOrThrow(published).config.environmentThemes).toEqual(themes); - // A machine that stops publishing has to clear the palettes, not freeze - // clients on the last set it sent. const unpublished = applyServerConfigProjection(published, { version: 1, type: "environmentThemesUpdated", @@ -465,8 +457,6 @@ describe("server state projection", () => { expect(Option.getOrThrow(unpublished).config.environmentThemes).toBeUndefined(); }); - // A snapshot never carries published themes, so taking it wholesale would - // clear them on every reconnect and repaint anyone wearing one. it("keeps published themes across a reconnect snapshot", () => { const themes = [ { @@ -491,8 +481,6 @@ describe("server state projection", () => { }); expect(Option.getOrThrow(afterReconnect).config.environmentThemes).toEqual(themes); - // A server that predates the feature never sends another theme event, so - // carrying the set forward would leave a palette nothing can update. const downgraded = applyServerConfigProjection(withThemes, { version: 1, type: "snapshot", @@ -576,8 +564,6 @@ describe("server state projection", () => { ); yield* Deferred.await(firstSubscribed); - // Model the point after the ref changed but before either subscriber - // processed its publication. supervisorSession.value = Option.some(secondSession); const handled = yield* SubscriptionRef.changes(state).pipe( Stream.filter( diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index c896f43c64f2..d0cd59c56e96 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -54,7 +54,6 @@ import { withoutEnvironmentThemes, } from "./serverConfigProjection.ts"; -// Exported server state includes this type in its inferred public return type. export type { ServerConfigProjection } from "./serverConfigProjection.ts"; export type ServerUpdateStage = "downloading" | "installing" | "resuming"; @@ -84,7 +83,6 @@ const IDLE_SERVER_UPDATE_STATE: ServerUpdateState = { status: "idle" }; const EMPTY_SERVER_UPDATE_STATE_ATOM = Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( Atom.withLabel("environment-data:server:update-state:empty"), ); -/** Shared with the outdated-host update, which reports through the same state. */ export const serverUpdateStateAtom = Atom.family((environmentId: EnvironmentId) => Atom.make(IDLE_SERVER_UPDATE_STATE).pipe( Atom.withLabel(`environment-data:server:update-state:${environmentId}`), @@ -176,19 +174,8 @@ export function validateServerUpdateReadyEvent( } /** - * Keeps reconnect attempts ~1s apart for the whole update restart. - * - * A restart takes the server down for ~15 seconds, but the supervisor's normal - * backoff assumes an unexpected failure and doubles its delay after each failed - * attempt, so a 15-second restart can be observed as a ~30-second "Resuming". - * Nudging on every backoff entry (not just the first) - * holds the retry cadence flat until the server answers again. The sleep before - * each nudge is the pacer: a connection that fails instantly re-enters backoff - * immediately and would otherwise spin a tight retry loop. - * - * A newly restarted server can also reject the first environment credential. - * Authentication blocks need the same paced retry during this known restart; - * permission and configuration failures remain blocked. + * The sleep before each nudge is the pacer: a connection that fails instantly + * re-enters backoff immediately and would otherwise spin a tight retry loop. * * Callers fork this as a child of the update command so it is interrupted as * soon as the update settles, whether it succeeds, fails, or times out. @@ -283,11 +270,6 @@ export const waitForDesktopUpdateTarget = Effect.fn("waitForDesktopUpdateTarget" }); }); -/** - * "resuming" means the server has its update and restarts without this - * client. A desktop app update is not there yet when its prepare step - * completes: the client still has to commit it, so it stays "installing". - */ export function serverUpdateStateForProgressEvent( fromVersion: string, targetVersion: string, @@ -392,8 +374,6 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf ), ); const state = yield* SubscriptionRef.make>( - // Stripped on load as well as on save: a cache written by an earlier - // build can still carry published themes. Option.map(cachedConfig, (cached) => ({ config: withoutEnvironmentThemes(cached), latestEvent: cachedConfigSnapshotEvent(withoutEnvironmentThemes(cached)), @@ -628,19 +608,12 @@ export function createServerEnvironmentAtoms( readonly initialConfigValueAtom: ( environmentId: EnvironmentId, ) => Atom.Atom; - /** - * Whether this surface renders themes the environment publishes. Mobile - * keeps its own appearance settings, so it neither asks for the stream nor - * receives the payload. - */ readonly environmentThemes?: boolean; - /** Whether this surface renders quota from configured usage-limit sources. */ readonly usageLimitSources?: boolean; readonly usageLimitsCommand?: boolean; }, ) { const configScheduler = createAtomCommandScheduler(); - // Updates stay serial end-to-end, but only their handoff phase occupies the config lane. const updateScheduler = createAtomCommandScheduler(); const configConcurrency = { mode: "serial" as const, @@ -839,9 +812,6 @@ export function createServerEnvironmentAtoms( }), ); - // The update restart is intentional and the server stays unreachable - // for the whole restart, so hold the retry cadence flat instead of - // letting the supervisor climb its backoff ladder. yield* nudgeReconnectDuringUpdateRestart({ stateChanges: environmentRegistry.stateChanges(target.environmentId), retryNow: environmentRegistry.retryNow(target.environmentId), @@ -1111,15 +1081,12 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:scheduled-task:webhook-delivery", tag: WS_METHODS.scheduledTasksGetWebhookDelivery, }), - /** Live scheduled-task list: snapshot on subscribe, fresh list after every server-side change. */ scheduledTasksLive: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:scheduled-tasks:live", tag: WS_METHODS.scheduledTasksSubscribe, }), // A cold transcript scan is measured in seconds, so keep the result around // long enough that switching windows or re-rendering does not rescan. - // Slow sources answer from cache first: that summary stays on screen, with - // the query waiting, until one `awaitRefresh` request replaces it. usageSummary: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:usage-summary", tag: WS_METHODS.serverGetUsageSummary, @@ -1133,8 +1100,6 @@ export function createServerEnvironmentAtoms( Effect.andThen( request(WS_METHODS.serverGetUsageSummary, { ...input, awaitRefresh: true }), ), - // The cached summary is still the best data, and a failure - // would read as the environment not reporting usage at all. Effect.catch((error) => Effect.logWarning("Could not refresh slow usage sources.").pipe( Effect.annotateLogs({ ...safeErrorLogAttributes(error) }), @@ -1159,8 +1124,6 @@ export function createServerEnvironmentAtoms( searchAcpRegistry: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:acp-registry:search", tag: WS_METHODS.serverSearchAcpRegistry, - // Each submitted search refreshes the server-side registry. Dropping an - // abandoned query immediately also interrupts stale in-flight requests. staleTimeMs: 0, idleTtlMs: 0, }), @@ -1222,9 +1185,6 @@ export function createServerEnvironmentAtoms( concurrency: configConcurrency, }), updateSettings, - // Provider-instance mutations share the settings command and its - // environment-serial scheduler. The named boundary keeps clients on the - // atomic map-entry payload instead of rebuilding a stale whole map. mutateProviderInstance: updateSettings, prepareAcpRegistryAgent: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:acp-registry:prepare", diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts index 77883980332a..2bfa3983507d 100644 --- a/packages/client-runtime/src/state/serverConfigProjection.ts +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -8,10 +8,8 @@ export interface ServerConfigProjection { } /** - * Cached config keeps the provider and model catalog available across reconnects. * Published themes and usage-limit sources are current machine state, so a - * cache could restore a set the machine no longer reports. Replay sends both - * as separate events. + * cache could restore a set the machine no longer reports. */ export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig { if (config.environmentThemes === undefined && config.usageLimitSources === undefined) { diff --git a/packages/client-runtime/src/state/serverUsage.test.ts b/packages/client-runtime/src/state/serverUsage.test.ts index 3410fd3506fe..87c7434fe104 100644 --- a/packages/client-runtime/src/state/serverUsage.test.ts +++ b/packages/client-runtime/src/state/serverUsage.test.ts @@ -220,7 +220,6 @@ function settledSummary( ); } -/** Answers with a refreshing Cursor source; the `awaitRefresh` request runs `followUp` first. */ const makeRefreshingHarness = Effect.fn("ServerUsageTest.makeRefreshingHarness")(function* ( followUp: Effect.Effect, ) { diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index d185d3b7ee91..2a62496b343d 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -35,15 +35,8 @@ function initialConfigOption( ); } -// Bounded so a wedged environment cannot pin the permissions check (and with it -// the settings UI) in a loading state for long. const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; -/** - * Read the granted scopes of this client's session on one environment via its - * `/api/auth/session` endpoint, using the connection's authentication method - * and refreshing relay credentials when needed. - */ export const fetchEnvironmentSessionState = Effect.fn( "clientRuntime.state.fetchEnvironmentSessionState", )(function* (input: { @@ -127,9 +120,6 @@ function makeEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); - // Keyed on the prepared connection's identity: a reconnect (new credential, - // new base URL) swaps the prepared value, which re-runs the fetch, so scope - // changes from re-pairing are picked up without an explicit refresh. const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => runtime .atom((get) => { @@ -177,7 +167,6 @@ function makeEnvironmentSessionAtoms( const sessionAtomsByRuntime = new WeakMap>(); -/** Commands and UI share one session fetch and the same reconnect invalidation. */ export function createEnvironmentSessionAtoms( runtime: Atom.AtomRuntime, ): ReturnType> { diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 1d6fb5e14ca7..549ab5422e40 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -1,13 +1,3 @@ -/** - * Shared server settings. - * - * Every server keeps its own `settings.json`, but some keys are user - * preferences that only live on the server because the server has to act on - * them (auto-settlement runs with no client attached). A user does not want - * those to differ per machine. Clients write these keys to every shared-settings - * sync target, and warn when another target still holds a different value so - * the user can push their current value out. - */ import type { EnvironmentId, ExecutionEnvironmentCapabilities, @@ -20,7 +10,6 @@ import * as Struct from "effect/Struct"; import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; -/** Server keys that hold a user preference rather than machine config. */ const SHARED_SERVER_SETTING_KEYS = [ "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", @@ -36,7 +25,6 @@ export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number] const SHARED_KEY_SET = new Set(SHARED_SERVER_SETTING_KEYS); -/** Split a server patch into the keys every environment should receive and the primary-only rest. */ export function splitSharedServerPatch(patch: ServerSettingsPatch): { sharedPatch: ServerSettingsPatch; localPatch: ServerSettingsPatch; @@ -56,7 +44,6 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { }; } -/** Filter unsupported preferences; direct model writes retain the server's fallback behavior. */ export function filterSharedServerPatch( patch: ServerSettingsPatch, capabilities: Pick | undefined, @@ -86,7 +73,6 @@ export function filterSharedServerPatch( : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); } -/** The shared subset supported by one environment. */ export function pickSharedServerSettings( settings: ServerSettings, capabilities?: Pick, @@ -98,10 +84,6 @@ export function pickSharedServerSettings( ); } -/** - * Whether an environment can participate in shared-settings sync right now. - * Auto-settlement establishes baseline support; newer preferences are filtered separately. - */ export function supportsSharedSettingsSync(environment: { readonly connection: { readonly phase: EnvironmentConnectionPhase }; readonly serverConfig: { @@ -127,12 +109,8 @@ export interface SharedSettingsEnvironment { } /** - * Shared-settings sync targets whose values differ from the primary - * environment's. Other environments are skipped: nothing can be read from or - * written to them, or their server lacks baseline shared-settings support. With no - * primary settings loaded there is nothing to compare against, so nothing is - * reported. Callers must pass the real loaded settings, never a default - * fallback, or "apply to all" would push defaults over real values. + * Callers must pass the real loaded settings, never a default fallback, or + * "apply to all" would push defaults over real values. */ export function findSharedSettingsMismatches(input: { readonly primaryEnvironmentId: EnvironmentId | null; diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index b20d3e9cda19..5f783854ee55 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -99,8 +99,6 @@ describe("environment shell synchronization", () => { clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); - // Cold cache with no HTTP snapshot available β†’ falls back to the - // socket-embedded snapshot. const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ load: () => Effect.succeedNone, }); @@ -221,7 +219,6 @@ describe("environment shell synchronization", () => { Stream.runHead, ); - // Observe before publishing so no batch can arrive before the subscription. const observed = yield* SubscriptionRef.changes(shellState).pipe( Stream.drop(1), Stream.takeUntil( @@ -313,7 +310,6 @@ describe("environment shell synchronization", () => { Effect.provideService(ShellSnapshotLoader.ShellSnapshotLoader, snapshotLoader), ); - // Wait until the subscription is established from the warm cache. yield* SubscriptionRef.changes(capturedAfterSequence).pipe( Stream.filter((value) => value !== undefined), Stream.runHead, @@ -396,7 +392,6 @@ describe("environment shell synchronization", () => { ), ); - // A new session starts from an authoritative HTTP snapshot. for (let attempt = 0; attempt < 100; attempt += 1) { if ((yield* Ref.get(capturedAfterSequences)).length >= 1) break; yield* Effect.yieldNow; @@ -408,7 +403,6 @@ describe("environment shell synchronization", () => { Stream.runHead, ); - // A newer snapshot arrives on the stream and advances the cursor. yield* Queue.offer(events, { kind: "snapshot", snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 40 }, @@ -442,7 +436,6 @@ describe("environment shell synchronization", () => { expect((yield* Ref.get(capturedAfterSequences)).length).toBe(3); expect(yield* Ref.get(loaderCalls)).toBe(1); - // Replacing the session performs another authoritative refresh. yield* SubscriptionRef.set(activeSession, Option.some(session(client))); for (let attempt = 0; attempt < 100; attempt += 1) { if ((yield* Ref.get(capturedAfterSequences)).length >= 4) break; @@ -529,7 +522,6 @@ describe("environment shell synchronization", () => { Stream.filter((state) => state.status === "cached"), Stream.runHead, ); - // The disconnect flush runs independently from the connection-state update. yield* TestClock.adjust("0 millis"); expect((yield* Ref.get(saved)).map((snapshot) => snapshot.snapshotSequence)).toEqual([ 1, 10, 11, @@ -763,8 +755,6 @@ describe("environment shell synchronization", () => { }); yield* Deferred.await(unenrichedSaveStarted); - // Same sequence, different content. Session stays open so the stream can - // still apply enrichment while the older save is in flight. yield* Queue.offer(events, { kind: "snapshot", snapshot: enrichedSnapshot, @@ -778,7 +768,6 @@ describe("environment shell synchronization", () => { Stream.runHead, ); - // Completing the older same-sequence save must re-persist the latest object. yield* Deferred.succeed(releaseUnenrichedSave, undefined); for (let index = 0; index < 100; index += 1) { const values = yield* Ref.get(saved); @@ -871,8 +860,6 @@ describe("environment shell synchronization", () => { Stream.runHead, ); - // Close scope so the finalizer flush blocks on save. Workers must be - // interrupted before that flush; late stream events must not extend it. const closeFiber = yield* Effect.forkChild(Scope.close(scope, Exit.void)); yield* Deferred.await(saveStarted); yield* Queue.offer(events, { @@ -1013,7 +1000,6 @@ describe("environment shell synchronization", () => { clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); - // No HTTP snapshot, so the cached rows stay until the socket sends deltas. const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ load: () => Effect.succeedNone, }); @@ -1028,7 +1014,6 @@ describe("environment shell synchronization", () => { }) => Option.getOrThrow(state.snapshot).threads; expect(threadsOf(yield* SubscriptionRef.get(shellState))).toEqual(cachedSnapshot.threads); - // The server updates one row before the cached links finish decoding. const serverRow = { ...v2ThreadShell, title: "From server", pullRequests: [link(9)] }; yield* Queue.offer(events, { kind: "thread.updated", @@ -1129,7 +1114,6 @@ describe("environment shell synchronization", () => { ); expect(filled?.pullRequests).toEqual([link]); - // The cache must end up with the links: the last save carries them. yield* TestClock.adjust("30 seconds"); const saves = yield* Queue.takeAll(saved); expect(saves.at(-1)?.threads[0]?.pullRequests).toEqual([link]); @@ -1180,7 +1164,6 @@ describe("environment shell synchronization", () => { Effect.succeedSome({ ...v2ShellSnapshot, snapshotSequence: 3, - // Links never finish decoding before the shell closes. loadPullRequests: Effect.never.pipe(Effect.as(new Map([[v2ThreadShell.id, [link]]]))), }), }); @@ -1196,7 +1179,6 @@ describe("environment shell synchronization", () => { Stream.runHead, ); - // Neither the throttled worker nor the closing flush may save the rows without links. yield* TestClock.adjust("30 seconds"); yield* Scope.close(scope, Exit.void); expect(yield* Ref.get(saved)).toEqual([]); @@ -1217,7 +1199,6 @@ describe("environment shell synchronization", () => { }); const firstLinks = yield* Deferred.make(); const secondLinks = yield* Deferred.make(); - // Set once each snapshot's links start decoding, i.e. once its rows are applied. const firstFillStarted = yield* Deferred.make(); const loads = yield* Ref.make(0); const subscriptions = yield* Ref.make(0); @@ -1251,7 +1232,6 @@ describe("environment shell synchronization", () => { clearVcsRefs: () => Effect.void, clear: () => Effect.void, }); - // Both snapshots carry the same row; only its pull request links differ. const snapshotLoader = ShellSnapshotLoader.ShellSnapshotLoader.of({ load: () => Ref.updateAndGet(loads, (n) => n + 1).pipe( @@ -1276,7 +1256,6 @@ describe("environment shell synchronization", () => { ); yield* Deferred.await(firstFillStarted); - // A new session loads a second snapshot that reuses the bare row. yield* SubscriptionRef.set(activeSession, Option.some(session(client))); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter( @@ -1285,7 +1264,6 @@ describe("environment shell synchronization", () => { Stream.runHead, ); - // The superseded first fill finishes first, then the second. yield* Deferred.succeed(firstLinks, undefined); yield* Deferred.succeed(secondLinks, undefined); const filled = yield* SubscriptionRef.changes(shellState).pipe( diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index dbc4405a9faa..53132f627c14 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -122,8 +122,6 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") ); if (!saved) return; lastPersisted = snapshot; - // Only lifecycle flushes chase updates that arrived during an in-flight save. - // The regular worker leaves those updates for the next write window. if (!flush) return; } }, persistenceLock.withPermit); @@ -180,12 +178,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") // time, so neither can overwrite what the other wrote in between. const snapshotWriteLock = yield* Semaphore.make(1); - // Apply each received batch with one state write. The RPC client's bounded - // buffer can split a server chunk, so a bulk action can still need several - // writes, but each write includes every event in that batch. const applyItems = Effect.fn("EnvironmentShellState.applyItems")(function* ( items: ReadonlyArray, - // Ids of rows whose links will fill later; they are held out of saves before one queues. pendingLinkIds?: ReadonlySet, ) { const initial = yield* SubscriptionRef.get(state); @@ -248,14 +242,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } }, snapshotWriteLock.withPermit); - // Rows can arrive before their pull request links (see DeferredShellSnapshot), so the list - // paints first. The links then fill into the rows that still hold the object they arrived - // with; a row the server has replaced since carries its own links. - // Captured so fills started from the subscription callback still end with this state. const stateScope = yield* Effect.scope; // Each full snapshot supersedes the previous one's fill; links from an older snapshot must - // not land on rows a newer snapshot reused. A cancelled fill's rows stay pending until the - // next snapshot replaces the pending set. + // not land on rows a newer snapshot reused. + let activeFill: Fiber.Fiber | undefined; const cancelActiveFill = Effect.suspend(() => { const fill = activeFill; @@ -293,7 +283,6 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const threads = snapshot.threads.map((thread) => { if (!arrivedRows.has(thread) || !arrivedIds.has(thread.id)) return thread; const links = linksByThreadId.get(thread.id); - // A reused row may already hold these links from an earlier fill. if (sameThreadPullRequests(thread.pullRequests, links)) return thread; changed = true; if (links === undefined) { @@ -307,8 +296,6 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") return [Option.some(next), { ...current, snapshot: Option.some(next) }]; }, ); - // Saves skipped while links were pending are queued again. Cached rows are never live: - // until a server snapshot arrives there is nothing new to save. pendingLinkRows = new Set(); const live = yield* Ref.get(latestLiveSnapshot); if (Option.isNone(live)) return; @@ -384,14 +371,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") } } - // If the authoritative refresh failed, omit the cached cursor so the - // socket fallback sends a complete snapshot for this new session. if (!canResume || Option.isNone(current.snapshot)) { return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; } if (!supportsCompletionMarker) { - // Without a completion marker there is no synchronized signal for a - // resumed subscription, so report live immediately, like threads. yield* SubscriptionRef.update(state, (value) => ({ ...value, status: "live" as const, diff --git a/packages/client-runtime/src/state/shellPullRequests.ts b/packages/client-runtime/src/state/shellPullRequests.ts index aedc90d25d90..340b1403f293 100644 --- a/packages/client-runtime/src/state/shellPullRequests.ts +++ b/packages/client-runtime/src/state/shellPullRequests.ts @@ -7,12 +7,6 @@ import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; -/** - * A shell snapshot whose threads may arrive before their pull request links are decoded. Those - * links are most of a large shell's decode cost and only feed PR badges, so the thread list - * can paint first. `loadPullRequests` then decodes the links by thread id; until it runs, - * those threads carry no `pullRequests`. - */ export interface DeferredShellSnapshot extends OrchestrationV2ShellSnapshot { readonly loadPullRequests?: Effect.Effect< ReadonlyMap> @@ -26,7 +20,6 @@ const yieldToHost = Effect.sleep("1 millis"); const decodeThreadLinks = Schema.decodeUnknownEffect( Schema.UndefinedOr(Schema.toCodecJson(Schema.Array(ThreadPullRequestLink))), ); -// Bounds each uninterrupted stretch of decoding, so taps and frames run between batches. const LINKS_PER_BATCH = 400; /** @@ -45,10 +38,6 @@ export function detachPullRequests(snapshot: unknown): ReadonlyArray { }); } -/** - * Attaches a `loadPullRequests` that decodes the detached links after yielding to the host. - * A thread whose links cannot be read gets none; its row stays usable. - */ export function deferPullRequests( snapshot: S, rawLinks: ReadonlyArray, @@ -67,7 +56,6 @@ export function deferPullRequests( batchSize = 0; } batchSize += size; - // An unreadable thread keeps no links; the others still fill in. const links = yield* decodeThreadLinks(raw).pipe( Effect.catch((cause) => Effect.logWarning("Discarding unreadable shell pull request links.", { @@ -87,7 +75,6 @@ const decodeShellSnapshotJson = Schema.decodeUnknownEffect( Schema.toCodecJson(OrchestrationV2ShellSnapshot), ); -/** Decodes a JSON shell snapshot with its threads' pull request links deferred. */ export const decodeShellSnapshotDeferringPullRequests = Effect.fnUntraced(function* ( json: unknown, ) { diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index b7a69b2a5152..42a32aca7020 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -182,7 +182,6 @@ describe("applyShellStreamEvent", () => { threads: [{ ...v2ThreadShell, title: "Server thread" }], }; - // Lower-sequence marked enrichment is identity-only and must not replace structure. const afterEnrichment = mergeShellSnapshotProjects(previous, serverSnapshot, { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot], }); @@ -191,7 +190,6 @@ describe("applyShellStreamEvent", () => { expect(afterEnrichment.projects[0]?.repositoryIdentity).toBeNull(); expect(afterEnrichment.threads[0]?.title).toBe("Local-only thread"); - // Unmarked authoritative at the same lower sequence still performs the reset. const afterAuthoritative = mergeShellSnapshotProjects(afterEnrichment, serverSnapshot); expect(afterAuthoritative.snapshotSequence).toBe(3); expect(afterAuthoritative.projects[0]?.title).toBe("Server reset"); diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index f99f5e7681a4..2cd2468d039f 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -10,7 +10,6 @@ import * as Schema from "effect/Schema"; const sameThreadShell = Schema.toEquivalence(OrchestrationV2ThreadShell); const sameLinks = Schema.toEquivalence(Schema.UndefinedOr(Schema.Array(ThreadPullRequestLink))); -/** Whether two rows' pull request links are equal, including both absent. */ export function sameThreadPullRequests( left: OrchestrationV2ThreadShell["pullRequests"], right: OrchestrationV2ThreadShell["pullRequests"], @@ -19,9 +18,8 @@ export function sameThreadPullRequests( } /** - * Keep the previous object for each row a full snapshot left unchanged, so the list only - * re-renders rows that changed. Links are compared apart from the rest of the row because a - * deferred snapshot arrives without them; such a row keeps its links until they fill in. + * Links are compared apart from the rest of the row because a deferred snapshot arrives without + * them; such a row keeps its links until they fill in. */ export function reuseUnchangedThreadShells( previous: OrchestrationV2ShellSnapshot | null, @@ -76,17 +74,6 @@ export interface MergeShellSnapshotOptions { readonly resolvedRepositoryIdentityRoots: ReadonlyArray; } -/** - * Merge an incoming full shell snapshot into prior client state. - * - * Authoritative snapshots (no options) replace structure and sequence even when - * lower than cache, while retaining a prior non-null identity when the candidate - * is still unresolved/null for the same root. - * - * Enrichment snapshots (options present) only patch repository identity for - * matching current projects. They never replace projects, threads, archives, - * or sequence, regardless of the incoming snapshot sequence. - */ export function mergeShellSnapshotProjects( previous: OrchestrationV2ShellSnapshot | null | undefined, next: OrchestrationV2ShellSnapshot, @@ -132,7 +119,6 @@ export function mergeShellSnapshotProjects( }; } -/** Applies one committed V2 shell delta while preserving active/archive exclusivity. */ export function applyShellStreamEvent( snapshot: OrchestrationV2ShellSnapshot, event: Exclude< @@ -163,8 +149,6 @@ export function applyShellStreamEvent( snapshotSequence: event.sequence, }; case "thread.updated": { - // An unchanged shell keeps its object and the list, so subscribers that - // compare by reference skip the update. Only the cursor moves. const existing = event.location === "active" ? snapshot.threads.find((thread) => thread.id === event.thread.id) diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index e0294ba0dfe7..cdb5ca77db56 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -19,20 +19,11 @@ import { type DeferredShellSnapshot, } from "./shellPullRequests.ts"; -// Long enough for a slow but alive server to finish. On timeout the socket asks -// the same server for the same full snapshot, so a short deadline only throws -// the first build away. The socket fallback is for setups where /api fails but -// /ws works, such as a proxy that blocks /api. A dead server is caught by the -// socket ping, which drops the session and interrupts this load. The cached -// shell renders while this runs. +// On timeout the socket asks the same server for the same full snapshot, so a +// short deadline only throws the first build away. The socket fallback is for +// setups where /api fails but /ws works, such as a proxy that blocks /api. const DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS = 20_000; -/** - * Load the environment shell snapshot (projects + thread shells) over HTTP - * instead of as the WebSocket subscription's first frame. The response is - * gzip-compressible by the transport and keeps the (potentially large) list off - * the socket. - */ export const fetchEnvironmentShellSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentShellSnapshot", )(function* (input: { @@ -49,9 +40,6 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( method: "GET", url: (urls) => urls.shellSnapshot(), timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, - // The body is decoded here so each thread's pull request links can decode after its - // rows. Every declared error is an `EnvironmentHttpCommonError`, so decoding one keeps - // the error the credential retry checks for. request: ({ client, headers }) => client .shellSnapshot({ @@ -75,7 +63,6 @@ const decodeShellSnapshotResponse = ( if (response.status === 200) { return response.json.pipe(Effect.flatMap(decodeShellSnapshotDeferringPullRequests)); } - // A body that is not a declared error keeps its status, as the generated decoder reported. const statusError = new HttpClientError.HttpClientError({ reason: new HttpClientError.StatusCodeError({ request: response.request, response }), }); @@ -88,12 +75,6 @@ const decodeShellSnapshotResponse = ( ); }; -/** - * Loads the environment shell snapshot over HTTP, returning `Option.none()` when - * it cannot be loaded (so the caller falls back to the socket-embedded snapshot). - * Decouples the shell state machine from the underlying HTTP + DPoP details and - * keeps them out of test contexts. - */ export class ShellSnapshotLoader extends Context.Service< ShellSnapshotLoader, { diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index ad974ea6841a..30d9b1594554 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -172,8 +172,6 @@ describe("source control environment atoms", () => { ); }); -// Transport fixtures have a source-control-only session; authorization edge cases -// are exercised by commandPermissions.test.ts. vi.mock("./session.ts", () => ({ createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), })); diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index 69c7103d092d..86d320850498 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -65,8 +65,6 @@ export function createSourceControlEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), - // Every clone the environment tracks. Empty until a clone starts; a - // finished clone drops out after a grace period, a failed one stays. projectClones: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:source-control:project-clones", tag: WS_METHODS.subscribeProjectClones, diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index 480cc8e0c7ec..d4d0db34e2ac 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -8,7 +8,6 @@ import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/mod import { fileBasename } from "@t3tools/shared/path"; import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; -/** Summarizes one adjacent group, without changing its member identities or order. */ export function subagentGroupSummary( members: ReadonlyArray<{ readonly status: OrchestrationV2TurnItemStatus }>, ) { @@ -22,10 +21,6 @@ export function subagentGroupSummary( }; } -/** - * Counts a group's states in the order a reader scans them: what is still - * running first, then outcomes, using the agents panel's words. - */ export function summarizeSubagentStatuses( statuses: ReadonlyArray, ): string { @@ -43,7 +38,6 @@ export function summarizeSubagentStatuses( .join(" Β· "); } -/** Formats Codex task paths for display while leaving provider identity untouched. */ export function formatSubagentDisplayTitle(title: string): string { const displayTitle = title.replace(/^Subagent:\s*/i, ""); const path = /^\/root\/(?:[^/]+\/)*([^/]+)\/?$/u.exec(displayTitle); @@ -53,7 +47,6 @@ export function formatSubagentDisplayTitle(title: string): string { return name.replace(/(^|\s)\S/gu, (letter) => letter.toUpperCase()) || displayTitle; } -/** Match desktop's model resolution and show only changes from the parent's workspace. */ export function resolveSubagentMetadata(input: { readonly model: string | null; readonly provider?: Pick | null | undefined; @@ -117,7 +110,6 @@ export function resolveSubagentMetadata(input: { return { modelLabel, workspace }; } -/** Live work leads with progress; settled work leads with its result. */ export function subagentDetailPreview(input: { readonly status: OrchestrationV2TurnItemStatus; readonly result?: string | null | undefined; diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index 1c4756b081f2..2df8a46da932 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -70,7 +70,6 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { phaseTitle: "Alpha", status: "running", }); - // A queued member is not started work: it must not report an elapsed clock. expect(rows[2]?.status).toBe("pending"); expect(rows[2]?.startedAt).toBeNull(); }); @@ -97,7 +96,6 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { expect(settled?.status).toBe("completed"); expect(settled?.completedAt).toBe("2026-08-01T10:00:01.500Z"); expect(settled?.result).toBe("A1"); - // The prompt stays on progress so the detail view can show both halves. expect(settled?.progress).toBe("Reply with exactly: A1"); expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 }); }); @@ -119,7 +117,6 @@ describe("projectedSubagentsToRuntime workflow expansion", () => { }, }), ]); - // The settled run keeps its members reachable under the coordinator. expect(groups[0]?.workflow.status).toBe(status); const members = groups[0]?.phases.flatMap((phase) => phase.members); expect(members?.map((agent) => agent.status)).toEqual([status, status, "completed"]); @@ -175,7 +172,6 @@ describe("deriveWorkflowGroups over a dynamic workflow", () => { const groups = groupsOf([ subagent({ workflow: { - // The declared plan has not caught up with the member's phase yet. phases: [{ index: 1, title: "Alpha" }], agents: [ member({ state: "completed" }), diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index fe9eaea9258f..5df9d1e2ea9a 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -1,7 +1,3 @@ -/** - * Subagent status helpers shared by web and mobile, and the runtime shape the - * web agent rows render. - */ import { groupBy } from "effect/Array"; import * as DateTime from "effect/DateTime"; import type { @@ -41,7 +37,6 @@ export interface SubagentWorkflowPhase { readonly title: string; } -// Optional-and-undefined, matching the contract's own optionals. export interface SubagentRunHandles { readonly runId?: string | undefined; readonly scriptPath?: string | undefined; @@ -72,10 +67,8 @@ export interface RuntimeSubagent { readonly workflowName: string | null; readonly phases: ReadonlyArray; readonly runHandles: SubagentRunHandles | null; - /** The thread this agent owns, when it has one. */ readonly childThreadId: string | null; readonly recentActivity: ReadonlyArray; - /** First retained observation, used as the roster's stable display order. */ readonly firstSeenAt: string; readonly startedAt: string | null; readonly completedAt: string | null; @@ -93,8 +86,6 @@ export function isTerminalSubagentStatus(status: RuntimeSubagentStatus): boolean return TERMINAL_STATUSES.has(status); } -/** Active = the user may still need to care while it runs. Idle is settled-ish - * but resumable; waiting counts as active because it needs the user. */ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean { return isOrchestrationV2WorkActive(status); } @@ -103,18 +94,15 @@ export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean { return item.type === "subagent" && isOrchestrationV2WorkActive(item.status); } -/** A workflow coordinator with its members grouped by phase, as Lineage renders it. */ export interface SubagentWorkflowGroup { readonly workflow: RuntimeSubagent; readonly phases: ReadonlyArray<{ readonly index: number; readonly title: string; readonly members: ReadonlyArray; - /** done = every member settled (success or error); running = any active. */ readonly state: "pending" | "running" | "done"; readonly settledCount: number; }>; - /** Members with no resolvable phase (orphans render under the workflow). */ readonly unphasedMembers: ReadonlyArray; } @@ -135,19 +123,11 @@ function isoFromEpochMillis(value: number | undefined): string | null { return value === undefined ? null : DateTime.formatIso(DateTime.makeUnsafe(value)); } -/** - * Expands a coordinator's workflow roster into member rows. Members are - * synthesized from the coordinator's snapshot rather than read from their own - * subagent entities, so a parent thread's Lineage can show them without - * loading the coordinator's child thread; their identity is the coordinator id - * plus the spawn ordinal. - */ function workflowMembersToRuntime( coordinator: RuntimeSubagent, workflow: OrchestrationV2SubagentWorkflow, ): ReadonlyArray { return workflow.agents.map((agent) => { - // Every member state but queued is already a runtime status. const status = (agent.state === "queued" || agent.state === "running") && isTerminalSubagentStatus(coordinator.status) @@ -157,26 +137,20 @@ function workflowMembersToRuntime( : agent.state; const failed = status === "failed"; const startedAt = isoFromEpochMillis(agent.startedAt); - // The provider reports a settled member's duration, not its end instant. const completedAt = isTerminalSubagentStatus(status) ? agent.startedAt !== undefined && agent.durationMs !== undefined ? isoFromEpochMillis(agent.startedAt + agent.durationMs) : coordinator.completedAt : null; return { - // Members share the run's handles: the transcript directory is what - // makes their conversation readable, and only the run knows it. ...coordinator, id: `${coordinator.id}:agent:${agent.index}`, kind: "workflow_agent" as const, title: agent.label, model: agent.model ?? null, status, - // A member the workflow retried counts each attempt as a run. activationCount: agent.attempt ?? 1, usage: workflowUsage(agent), - // A member keeps its prompt on `progress` even once settled: that is the - // question half of its conversation, and the detail view shows both halves. progress: agent.prompt ?? null, result: failed ? null : (agent.result ?? null), error: failed ? (agent.result ?? null) : null, @@ -196,9 +170,6 @@ function workflowMembersToRuntime( }); } -/** - * Projects subagents and workflow members into the runtime roster. - */ export function projectedSubagentsToRuntime( subagents: ReadonlyArray<{ readonly id: string; diff --git a/packages/client-runtime/src/state/terminalOutput.ts b/packages/client-runtime/src/state/terminalOutput.ts index fcb0389c434b..1b42491ce89a 100644 --- a/packages/client-runtime/src/state/terminalOutput.ts +++ b/packages/client-runtime/src/state/terminalOutput.ts @@ -1,5 +1,4 @@ export interface TerminalOutputChunk { - /** UTF-16 string offset within this generation and reset. */ readonly startOffset: number; readonly data: string; readonly byteLength: number; @@ -19,7 +18,6 @@ export interface TerminalOutputCursor { readonly offset: number; } -/** Forces the first `readTerminalOutputUpdate` to resynchronize from a reset. */ export const INITIAL_TERMINAL_OUTPUT_CURSOR = Object.freeze({ generation: -1, resetVersion: -1, @@ -62,14 +60,6 @@ interface Utf8Chunk { readonly byteLength: number; } -/** - * Split a string into chunks of at most `maxBytes` UTF-8 bytes without cutting - * a code point in half. The retained-output budget always supplies a positive - * size. Only new output is encoded on live updates. - * - * A chunk that fits whole is returned as the original string, so the common - * small-write path pays one encode and no decode. - */ function splitStringByUtf8Bytes(data: string, maxBytes: number): ReadonlyArray { if (data.length === 0) return []; @@ -153,10 +143,6 @@ function splitOutputChunks( }; } -/** - * Merge adjacent chunks without changing their string positions. A reader can - * still append the unread suffix when its cursor falls inside a merged chunk. - */ function compactRetainedChunks(chunks: ReadonlyArray) { const compacted: TerminalOutputChunk[] = []; for (const chunk of chunks) { @@ -178,7 +164,6 @@ function compactRetainedChunks(chunks: ReadonlyArray) { return compacted; } -// Scan only the removed prefix instead of encoding retained output again. function trimOutputChunkStart( chunk: TerminalOutputChunk, bytesToDrop: number, diff --git a/packages/client-runtime/src/state/threadCheckpoints.ts b/packages/client-runtime/src/state/threadCheckpoints.ts index 6d4ad38b87a6..476a4d8b1674 100644 --- a/packages/client-runtime/src/state/threadCheckpoints.ts +++ b/packages/client-runtime/src/state/threadCheckpoints.ts @@ -25,7 +25,6 @@ export interface ThreadCheckpointSummary { readonly completedAt: string; } -/** Derives the checkpoint/diff rows needed by review UIs from native V2 entities. */ export function deriveThreadCheckpointSummaries( projection: OrchestrationV2ThreadProjection, ): ReadonlyArray { diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index ba426b8195fe..2d61f6d95f0e 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -203,11 +203,9 @@ describe("thread execution presentation", () => { activeRunId: null, }); - // Resuming clears the hold, and the run reads as queued until it starts. const resumed = { ...projection, runs: [interrupted, { ...held, queueHeld: false }] }; expect(deriveThreadRuntime(resumed)).toMatchObject({ status: "queued" }); - // Recovery can hold a first message before any run executed; it is not work. const onlyHeld = { ...projection, runs: [held], @@ -317,7 +315,6 @@ describe("deriveRunlessWorkStartedAt", () => { it("times a provider-native subagent from its runless root turn while it works", () => { const projection = { ...nativeChild, nodes: [rootTurn("running", later)] }; expect(deriveRunlessWorkStartedAt(projection)).toBe("2026-07-28T10:05:00.000Z"); - // The subagent has no run, so it stays unstoppable and unqueueable. expect(deriveThreadRuntime(projection)).toBeNull(); }); @@ -552,7 +549,6 @@ describe("presentPendingBackgroundWork", () => { expect(presentPendingBackgroundWork([])).toBeNull(); }); - // A command left running, such as a dev server, does not wake the agent. it("says only commands are running, not waited on", () => { expect( presentPendingBackgroundWork([ diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 8206ccea2739..75ae3404e4ba 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -79,7 +79,6 @@ function presentedUsageLimitRun( ); } -/** The run that stands for the thread's outcome when no run is executing. */ function presentedLatestRun( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2ThreadProjection["runs"][number] | null { @@ -93,11 +92,6 @@ export function deriveLatestThreadRun( return run === null ? null : summarizeThreadRun(projection, run); } -/** - * Returns the run that owns live provider work, falling back to the newest run - * once the thread is idle. A newer queued run must not make an older executing - * run look settled in clients that render per-run activity. - */ export function deriveThreadActivityRun( projection: OrchestrationV2ThreadProjection, ): ThreadRunSummary | null { @@ -109,9 +103,7 @@ export function deriveThreadActivityRun( /** * Provider-native subagent threads never get app runs: their work is a runless - * root turn whose status follows the subagent. Returns when that work started - * while it is still active, so clients can show the same working state (and - * timer) as a run. Stop, queue, and steer stay run-only. + * root turn whose status follows the subagent. */ export function deriveRunlessWorkStartedAt( projection: OrchestrationV2ThreadProjection, @@ -126,11 +118,6 @@ export interface ProviderSubagentStatus { readonly completedAt: string | null; } -/** - * Status of a provider-native subagent thread (see - * isProviderNativeSubagentThread), read from its runless root turn. Null - * until that root turn arrives, and for every other thread. - */ export function deriveProviderSubagentStatus( projection: OrchestrationV2ThreadProjection, ): ProviderSubagentStatus | null { @@ -146,7 +133,6 @@ export function deriveProviderSubagentStatus( }; } -/** The observed selection belongs to the active provider thread, never a previous handoff. */ export function deriveReportedModelSelection( projection: OrchestrationV2ThreadProjection, ): ModelSelection | null { @@ -158,16 +144,8 @@ export function deriveReportedModelSelection( return providerThread?.nativeMetadata?.modelSelection ?? null; } -// Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode). const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const; -/** - * The reasoning effort a thread's model runs at, resolved and named the way - * the composer's effort picker does: the stored choice when valid, else the - * descriptor's current value, else the model's default. Null when the - * provider catalog has no effort option for this model (a subagent on a - * model the catalog does not describe), rather than guessing. - */ export function formatModelSelectionEffort( selection: ModelSelection, models: ReadonlyArray = [], @@ -197,10 +175,6 @@ const SUBAGENT_STATUS_LABELS: Record ACTIVITY_RUN_STATUSES.has(run.status), ); - // Same rule as the shell runtime: only background work that holds the - // completion parks the thread at idle; a dev server left running does not. const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion( derivePendingBackgroundWork({ latestRun: latestRunProjection, @@ -289,7 +260,6 @@ export function threadRuntimeHasInterruptibleRun( type BackgroundWorkKind = OrchestrationV2PendingBackgroundTask["kind"]; -// `order` groups work the way a reader thinks about it: agents first, loose tasks last. const BACKGROUND_WORK_KINDS: Record< BackgroundWorkKind, { readonly order: number; readonly singular: string; readonly plural: string } @@ -303,23 +273,13 @@ const BACKGROUND_WORK_KINDS: Record< export interface PendingBackgroundWorkItem { readonly taskId: string; readonly kind: BackgroundWorkKind; - /** The work's name, or its noun when the provider gave none. */ readonly label: string; - /** A subagent's own thread, when it has one. */ readonly childThreadId: ThreadId | undefined; } export interface PendingBackgroundWorkPresentation { - /** - * "Waiting on subagent Review src/math.ts", "Waiting on 2 subagents and 1 command", - * or "Running: Start the dev server" when only commands remain. - */ readonly title: string; readonly items: ReadonlyArray; - /** - * True when the work will wake the agent (subagents, monitors). False when - * only commands remain, such as a dev server: the agent is done. - */ readonly waiting: boolean; } @@ -328,7 +288,6 @@ function joinWithAnd(parts: ReadonlyArray): string { return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`; } -/** Names what a settled thread still runs, grouped by kind, for the composer strip. */ export function presentPendingBackgroundWork( tasks: ReadonlyArray, ): PendingBackgroundWorkPresentation | null { @@ -379,12 +338,9 @@ export function presentPendingBackgroundWork( } export interface ProviderGoalPresentation { - /** "Pursuing goal", "Goal paused", "Goal complete"... */ readonly title: string; readonly objective: string; - /** "12k / 50k tokens Β· 4m" for Codex, "2 checks" for Claude; null when nothing is reported. */ readonly usage: string | null; - /** Only Codex stops a goal short of done, and only a stopped goal can resume. */ readonly canResume: boolean; } @@ -403,10 +359,6 @@ function formatGoalTokens(tokens: number): string { return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; } -/** - * Status line for a native `/goal`, shared by the web composer row and mobile. - * An active goal on an idle thread (Claude after Stop) is set, not pursued. - */ export function presentProviderGoal( goal: OrchestrationV2ProviderGoal, working: boolean, @@ -433,7 +385,6 @@ export function presentProviderGoal( }; } -/** The thread a notification row opens: that of the one subagent or delegated task it reports. */ export function notificationChildThreadId( source: OrchestrationV2NotificationSource, ): ThreadId | undefined { diff --git a/packages/client-runtime/src/state/threadFeedback.ts b/packages/client-runtime/src/state/threadFeedback.ts index 800bde64830b..b060f56dd53f 100644 --- a/packages/client-runtime/src/state/threadFeedback.ts +++ b/packages/client-runtime/src/state/threadFeedback.ts @@ -53,7 +53,6 @@ export function beginCodexFeedbackSubmission( return () => submissionsInFlight.delete(threadKey); } -/** A chat row that exists only on this client, such as a feedback exchange. */ export interface LocalChatMessage { readonly id: MessageId; readonly role: "user" | "assistant"; diff --git a/packages/client-runtime/src/state/threadHistoryController.test.ts b/packages/client-runtime/src/state/threadHistoryController.test.ts index f449f1120a23..55e9e0501c4e 100644 --- a/packages/client-runtime/src/state/threadHistoryController.test.ts +++ b/packages/client-runtime/src/state/threadHistoryController.test.ts @@ -38,7 +38,6 @@ describe("ThreadHistoryController", () => { const olderRegistration = yield* controller.register(ENV, THREAD, older); const newerRegistration = yield* controller.register(ENV, THREAD, newer); - // Older fiber finalizes after the newer handler is already registered. yield* controller.unregister(olderRegistration); const result = yield* controller.loadEarlier(ENV, THREAD); diff --git a/packages/client-runtime/src/state/threadHistoryController.ts b/packages/client-runtime/src/state/threadHistoryController.ts index 4319d1c4215c..4c7a3dcc76d2 100644 --- a/packages/client-runtime/src/state/threadHistoryController.ts +++ b/packages/client-runtime/src/state/threadHistoryController.ts @@ -16,20 +16,12 @@ export type ThreadHistoryHandler = { readonly loadEarlier: (throughEntryId?: string) => Effect.Effect; }; -/** - * Opaque registration token returned by `register`. Finalizers must pass the - * same token so an older fiber cannot delete a newer handler for the key. - */ export type ThreadHistoryRegistration = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly handler: ThreadHistoryHandler; }; -/** - * Per-thread registration for progressive history loads. Thread state fibers - * register while active; mobile UI dispatches through `loadEarlier`. - */ export class ThreadHistoryController extends Context.Service< ThreadHistoryController, { diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 56ef3d235df9..c2c30506022f 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -218,11 +218,9 @@ describe("threadHistoryMerge", () => { ...loading, loading: false, }); - // Stale cursor must not mutate replacement progressive meta. expect( clearActiveHistoryLoading("cursor-a", { ...loading, historyCursor: "cursor-b" }), ).toEqual({ ...loading, historyCursor: "cursor-b" }); - // Already idle: identity-preserving no-op shape. const idle = { ...loading, loading: false }; expect(clearActiveHistoryLoading("cursor-a", idle)).toBe(idle); }); @@ -289,8 +287,6 @@ describe("threadHistoryMerge", () => { }); it("keeps a history-page interrupt result visible when its request was retained at cold open", () => { - // Cold bounded window: only a recent filler. Request was retained in - // turnItems (not visible). Result arrives later via load-earlier merge. const runId = "run-interrupt-cold" as never; const nodeId = "node-interrupt-cold" as never; const recent = row(5); @@ -373,7 +369,6 @@ describe("threadHistoryMerge", () => { completedAt: null, }, ], - // Request retained from full projection; no result yet in the window. turnItems: [requestItem, recent.item], visibleTurnItems: [{ ...recent, position: 0 }], }; @@ -453,7 +448,6 @@ describe("threadHistoryMerge", () => { "item-5", ]); - // Without the cold-open retained request, the same update would hide the result. const withoutRequest = { ...merged, turnItems: merged.turnItems.filter((item) => item.type !== "run_interrupt_request"), diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index ef8bd219b9ea..24840ee28420 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -9,12 +9,7 @@ export type ThreadHistoryMeta = { readonly hasMoreHistory: boolean; readonly loading: boolean; readonly error: string | null; - /** True after at least one older page was merged; skip monolithic cache growth. */ readonly expanded: boolean; - /** - * Max local turn ordinal known for partial progressive windows. Null when - * unknown (legacy cache) or when history meta is cleared for a full snapshot. - */ readonly latestLocalTurnOrdinal: number | null; }; @@ -27,7 +22,6 @@ export const EMPTY_THREAD_HISTORY_META: ThreadHistoryMeta = { latestLocalTurnOrdinal: null, }; -/** Whether a history-page response still matches the cursor that started it. */ export function isActiveHistoryRequestCursor( requestCursor: string, history: { readonly historyCursor: string | null }, @@ -35,10 +29,6 @@ export function isActiveHistoryRequestCursor( return history.historyCursor === requestCursor; } -/** - * Clear history loading after an interrupted load-earlier only when the - * request cursor is still active. Does not touch stream/status/error. - */ export function clearActiveHistoryLoading( requestCursor: string, history: ThreadHistoryMeta, @@ -59,11 +49,6 @@ function renumberVisible( return rows.map((row, position) => (row.position === position ? row : { ...row, position })); } -/** - * Merge an older history page into the live projection. Dedupes by - * sourceThreadId + sourceItemId, keeps chronological order (older first), and - * preserves newer live rows already present in state. - */ export function mergeOlderHistoryIntoProjection( projection: OrchestrationV2ThreadProjection, olderItems: ReadonlyArray, @@ -87,8 +72,6 @@ export function mergeOlderHistoryIntoProjection( (row.visibility === "local" || row.sourceThreadId === projection.thread.id) ) { const currentItem = turnItemById.get(String(row.sourceItemId)); - // Hidden live items stay hidden. Cold snapshots also retain interrupt - // requests outside the visible window; paging may reveal those requests. if (currentItem !== undefined) { if ( currentItem.type !== "run_interrupt_request" || @@ -112,8 +95,6 @@ export function mergeOlderHistoryIntoProjection( turnItemById.set(String(row.sourceItemId), row.item); } if (page.length === 0) return projection; - // Server page order fills gaps between rows from a partial conversation load. - // Existing rows supply live values; rows outside this range keep their order. const visible = [ ...page, ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), @@ -143,12 +124,10 @@ export function applyHistoryPageMeta( loading: false, error: null, expanded: true, - // Preserve the partial-timeline watermark across load-earlier pages. latestLocalTurnOrdinal: current.latestLocalTurnOrdinal, }; } -/** Whether a client timeline should render its load-earlier control. */ export function shouldShowLoadEarlierControl(history: ThreadHistoryMeta): boolean { return history.hasMoreHistory || history.error !== null; } diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index 2ae5670933c7..d4836b537ef0 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -51,7 +51,6 @@ describe("createInboxReturnTracker", () => { tracker.observe(null); expect(tracker.returnedAt(thread("b", false))).toBeUndefined(); - // After a reset the next call is a fresh baseline again. tracker.observe([thread("b", true)]); tracker.observe([thread("b", false)]); expect(tracker.returnedAt(thread("b", false))).toBeDefined(); @@ -63,7 +62,6 @@ describe("sortWorkingThreadsBySend", () => { const sentFirst = { ...thread("sent-first", true), latestUserAuthoredMessageAt: "2026-06-01T01:00:00.000Z", - // A wake run requested after the other thread's send. latestRun: { runId: RunId.make("run:wake"), status: "running" as const, @@ -77,7 +75,6 @@ describe("sortWorkingThreadsBySend", () => { ...thread("sent-last", true), latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z", }; - // Launched by an agent: no user message, so creation time is the send. const launched = { ...thread("launched", true), latestUserAuthoredMessageAt: null }; expect( sortWorkingThreadsBySend([launched, sentFirst, sentLast]).map((thread) => thread.id), diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 8f73ea0504d0..880e6ebf31a0 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -1,9 +1,6 @@ import { threadRuntimeIsActive, type EnvironmentThreadShell } from "./models.ts"; import { toSortableTimestamp } from "./threadSort.ts"; -// Working section beta, shared so web and mobile fold and order the inbox the -// same way. Off by default; each client owns its own toggle. - type WorkingThreadInput = Pick< EnvironmentThreadShell, | "hasActionableProposedPlan" @@ -15,15 +12,10 @@ type WorkingThreadInput = Pick< | "runtime" >; -/** Threads busy with work that does not need the user fold into the Working - section: a running run, or one stopped with background work that will wake - it. Approvals, questions (its own or a subagent's), plan prompts, and - failures stay in the inbox. */ export function isThreadWorking(thread: WorkingThreadInput): boolean { if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; if (thread.pendingSubagentRequest != null) return false; if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false; - // A plan prompt outranks lingering background work: the user has to act on it. const run = thread.latestRun; const runSettled = run !== null && @@ -41,10 +33,6 @@ type InboxThreadInput = Pick< "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun" >; -/** The inbox lists threads newest first by when each last came back to the - user, so a thread that leaves the Working section lands on top. - `observedReturnAt` adds returns the server does not stamp, such as an - approval request mid-turn or background work ending. */ export function sortInboxThreadsByReturn( threads: readonly T[], observedReturnAt?: (thread: T) => number | undefined, @@ -69,11 +57,6 @@ type WorkingSortInput = Pick< "id" | "environmentId" | "createdAt" | "latestRun" | "latestUserAuthoredMessageAt" >; -/** The Working section lists threads newest first by the last message the - user sent. Runs ending and wakes (background results, delegated results, - PR watches) do not move a row, so the order stays put while agents finish - and resume. Servers without the authored stamp fall back to the latest - run's request time. */ export function sortWorkingThreadsBySend(threads: readonly T[]): T[] { const timestamps = new Map( threads.map((thread) => [ @@ -104,11 +87,9 @@ function sortNewestFirst) => @@ -129,12 +110,9 @@ export function createInboxReturnTracker() { present.add(key); if (isThreadWorking(thread)) working.add(key); } - // Drop deleted threads so the map stays bounded by the live thread list. for (const key of returns.keys()) { if (!present.has(key)) returns.delete(key); } - // The moment this client saw the change is the data; there is no - // server stamp to read instead. // @effect-diagnostics-next-line globalDate:off const at = Date.now(); for (const key of lastWorkingKeys ?? []) { diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 5077dfbb1ca7..6aa0cea3f8a7 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -408,9 +408,6 @@ describe("web thread lineage ordering", () => { ], }); - // The shell snapshot arrives ordered by `updated_at`, and a client-side - // `thread.updated` re-appends the shell it replaces, so both the input - // order and the mutable timestamps move under us while the panel is open. const after = orderedLineageIds({ threads: [ forkShell({ @@ -437,10 +434,6 @@ describe("web thread lineage ordering", () => { }); it("pins the parent first and a distinct merge-back target second", () => { - // The panel keeps its two action rows in place regardless of creation time: - // parent first, then a merge-back target that is not the parent. The two - // diverge while a shell update and its projection disagree about the fork - // source, which is exactly when a moving row would misfire a merge. const parent = ThreadId.make("thread-parent"); const mergeTarget = ThreadId.make("thread-merge-target"); const newestFork = ThreadId.make("thread-newest-fork"); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index 74f8893684f0..d218de9d1831 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -98,8 +98,6 @@ export function deriveThreadRelationshipGraph(input: { for (const subagent of input.projection.subagents) { if (subagent.childThreadId === null) continue; const child = threadsById.get(subagent.childThreadId); - // The row describes the child conversation, including queued follow-ups - // and their outcomes. A child without an app run still uses its task. addEdge({ sourceThreadId: ownerThreadId, targetThreadId: subagent.childThreadId, @@ -187,7 +185,6 @@ export function immediateThreadRelationships( return rows; } -/** True when `edge` reaches `currentThreadId` from its parent or owning agent. */ export function isParentThreadRelationship( edge: ThreadRelationshipEdge, currentThreadId: ThreadId, @@ -195,7 +192,6 @@ export function isParentThreadRelationship( return edge.kind !== "transfer" && edge.targetThreadId === currentThreadId; } -/** An incoming parent row shows its own activity, not the child's edge status. */ export function threadRelationshipRowStatus( graph: ThreadRelationshipGraph, row: Pick, @@ -208,8 +204,6 @@ export function threadRelationshipRowStatus( } function threadCreatedAtMillis(node: ThreadRelationshipNode | undefined): number | null { - // `createdAt` is typed as a DateTime, but the value reaches here from a - // decoded shell that may be missing (a related thread we have no shell for). const createdAt: unknown = node?.thread?.createdAt; if (!DateTime.isDateTime(createdAt)) return null; const millis = DateTime.toEpochMillis(createdAt); @@ -217,18 +211,11 @@ function threadCreatedAtMillis(node: ThreadRelationshipNode | undefined): number } /** - * Orders the web thread-details Lineage rows for display. - * * Web-specific by design: the panel pins the parent row first and a distinct * merge-back target second so their actions stay where the user expects, and * only then falls back to newest-created-first. Mobile does not share that * exception, so this is not the canonical relationship order and should not be * reused as one. - * - * Ordering below the pins is `createdAt` descending, which is immutable, so - * rows never move when messages or status changes arrive on a related thread. - * Threads whose shell is missing (or whose `createdAt` did not decode) sink to - * the bottom. Ties break by thread id ascending so the order is total. */ export function orderWebThreadLineageRows(input: { readonly graph: ThreadRelationshipGraph; diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index 031cbf50c48f..20dacefca5bc 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -13,9 +13,7 @@ export interface ThreadPendingApproval { readonly requestKind: ProviderRequestKind; readonly createdAt: string; readonly detail?: string; - /** App requesting access for mcp-elicitation approvals (#8058). */ readonly appName?: string; - /** Approval choices advertised by the provider (#8058); defaults apply when absent. */ readonly options?: ReadonlyArray; readonly responseCapability: "live" | "not_resumable"; } @@ -41,7 +39,6 @@ export interface PendingThreadRequests { readonly userInputs: ReadonlyArray; } -/** Seed each question once. Existing edits, cleared answers, and option selections win. */ export function seedUserInputDraftAnswers( questions: ReadonlyArray< Pick @@ -62,7 +59,6 @@ export function seedUserInputDraftAnswers, ): PendingThreadRequests { @@ -114,7 +110,6 @@ export function derivePendingThreadRequests( return { approvals, userInputs }; } -/** Older V2 answers were saved on requests without updating their timeline items. */ export function createQuestionHistoryProjector() { type Rows = OrchestrationV2ThreadProjection["visibleTurnItems"]; type Requests = OrchestrationV2ThreadProjection["runtimeRequests"]; diff --git a/packages/client-runtime/src/state/threadRetention.ts b/packages/client-runtime/src/state/threadRetention.ts index e921a10d31ad..3f2b8a6fd016 100644 --- a/packages/client-runtime/src/state/threadRetention.ts +++ b/packages/client-runtime/src/state/threadRetention.ts @@ -1,3 +1 @@ -// Keep recent thread snapshots for back navigation. Live subscriptions end -// when the last detail consumer leaves. export const THREAD_SNAPSHOT_IDLE_TTL_MS = 5 * 60_000; diff --git a/packages/client-runtime/src/state/threadSearch.ts b/packages/client-runtime/src/state/threadSearch.ts index ff7874d77bb6..c22cf9819d88 100644 --- a/packages/client-runtime/src/state/threadSearch.ts +++ b/packages/client-runtime/src/state/threadSearch.ts @@ -42,11 +42,6 @@ export function threadSearchMatchKey( return JSON.stringify([match.environmentId, match.threadId]); } -/** - * Combines one search query atom per environment. Invalid search keys, failed - * requests, and disconnected environments contribute no content matches, - * preserving local title search as the compatibility fallback. - */ export function createThreadSearchResultsAtomFamily(options: { readonly getSearchAtom: ( environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 887b1b79b19a..af89742160d9 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -41,15 +41,6 @@ interface QueuedThreadShell { export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; const DAY_MS = 24 * 60 * 60 * 1_000; -/** - * A user message no turn has picked up yet: the turn.start command was - * dispatched (message-sent + turn-start-requested) but no session has - * adopted it, so `session` is still null and the pending work is invisible - * to the session-status checks. Detectable as a user message strictly newer - * than every timestamp on the latest turn β€” on adoption the new turn's - * requestedAt equals the message time, clearing the condition β€” and only - * within the adoption grace window. - */ export function hasQueuedTurnStart( shell: QueuedThreadShell, options: { readonly now: string }, @@ -62,8 +53,6 @@ export function hasQueuedTurnStart( return true; } if (shell.latestUserMessageAt == null) return false; - // A failed session start clears the queued state: the failure is already - // visible (status edge / error). if (shell.session?.status === "error") return false; const messageAt = Date.parse(shell.latestUserMessageAt); if (Number.isNaN(messageAt)) return false; @@ -81,12 +70,6 @@ export function hasQueuedTurnStart( ); } -/** - * The snooze lifecycle fields plus everything needed to detect a raised - * hand. Snooze is an overlay on the active state: a snoozed thread stays - * "active" in the data model and is only suppressed from the inbox until - * its wake time passes or the thread demands attention. - */ export interface ThreadSnoozeShell extends QueuedThreadShell { readonly snoozedUntil?: string | null; readonly snoozedAt?: string | null; @@ -95,23 +78,11 @@ export interface ThreadSnoozeShell extends QueuedThreadShell { readonly pendingSubagentRequest?: PendingSubagentRequest | null; } -/** - * A snoozed thread "raises its hand" when something happens that outranks - * the user's snooze: the agent or one of its subagents is blocked on them - * (approval / user input), the session failed, or a run completed after the - * snooze was set β€” the v1 taste of event-based snooze ("something happened" - * wakes early). Raising a hand never clears the server-side snooze fields; it - * only stops the thread from classifying as snoozed. - */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; if (shell.pendingSubagentRequest != null) return true; const runtime = shell.runtime ?? shell.session ?? null; const latestRun = shell.latestRun ?? shell.latestTurn ?? null; - // Only a FRESH failure raises the hand: a thread snoozed while already - // failed stays snoozed β€” that snooze was the user saying "I saw it, not - // now". session.updatedAt stamps the status edge, so an error newer than - // the snooze is new information. if ( (runtime?.status === "error" || runtime?.status === "failed") && (shell.snoozedAt == null || @@ -130,14 +101,6 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean return false; } -/** - * A thread may be snoozed unless the agent or one of its subagents is blocked - * on the user: hiding a pending approval or user-input request defeats the - * request, and a queued turn start (a message no turn has adopted yet) is - * invisible pending work the same way it is for settle. A running session IS snoozable β€” snooze - * only affects visibility, never the agent. Client-side twin of the server - * invariants so the UI can reject before a round trip. - */ export function canSnooze( shell: Pick< ThreadSnoozeShell, @@ -158,36 +121,17 @@ export function canSnooze( return true; } -/** - * Snoozed resolution: hidden from the inbox while the wake time is in the - * future and the thread has not raised its hand. Timer wakes are derived β€” - * no server event fires when snoozedUntil passes; the stale fields simply - * stop classifying as snoozed (and feed the woke indicator until the user - * visits or re-engages). - */ export function effectiveSnoozed( shell: ThreadSnoozeShell, options: { readonly now: string }, ): boolean { if (shell.snoozedUntil == null) return false; const wakeAtMs = Date.parse(shell.snoozedUntil); - // Malformed data never hides a thread. if (Number.isNaN(wakeAtMs)) return false; if (wakeAtMs <= Date.parse(options.now)) return false; return !threadRaisedHandWhileSnoozed(shell); } -/** - * When a previously-snoozed thread woke, or null if it never snoozed / is - * still snoozed. Used for the "Woke" indicator: the thread reappears in its - * original sort position (the inbox sort is deliberately static), so the - * wake signal has to carry the weight. Compare against the client's - * lastVisitedAt β€” visiting clears the indicator like it clears unread. - * - * Timer wakes report the wake time itself; raised-hand wakes report the - * triggering timestamp so a visit BEFORE the early wake doesn't suppress - * the indicator. - */ export function threadWokeAt( shell: ThreadSnoozeShell, options: { readonly now: string }, @@ -212,7 +156,6 @@ export function threadWokeAt( } return runtime?.updatedAt ?? shell.snoozedAt ?? null; } - // No raised hand: woke iff the timer elapsed (still-snoozed β†’ null). return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null; } @@ -225,10 +168,7 @@ export type SnoozePresetId = "hour" | "three-hours" | "evening" | "tomorrow" | " export interface SnoozePreset { readonly id: SnoozePresetId; readonly label: string; - /** Menu-row time column. Complements the label instead of repeating it: - "Tomorrow" pairs with "9:00 AM", not "tomorrow 9:00 AM". */ readonly whenLabel: string; - /** ISO wake time. */ readonly snoozedUntil: string; } @@ -251,13 +191,6 @@ function addSnoozeDays(base: Date, days: number): Date { return next; } -/** - * Shared "snooze until" choices for every client. "This evening" only - * appears while it is meaningfully before evening; after that the calendar - * choices start at "Tomorrow". Calendar presets that land on the same - * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday - * morning, so only "Tomorrow" is offered. - */ export function resolveSnoozePresets(now: Date): ReadonlyArray { const inAnHour = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + HOUR_MS)); const inThreeHours = DateTime.toDate(DateTime.makeUnsafe(now.getTime() + 3 * HOUR_MS)); @@ -308,11 +241,6 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { return presets; } -/** - * Compact "wakes in" label for snoozed rows: "2h", "18h", "3d". Minutes - * round up so a snooze never reads "0m" while still hidden. Shared by web - * and mobile so the same wake time never reads differently per client. - */ export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: string }): string { const wakeMs = Date.parse(snoozedUntil); const nowMs = Date.parse(options.now); @@ -332,7 +260,6 @@ export type CustomSnoozeInput = readonly unit: "minutes" | "hours" | "days"; }; -/** Resolve local calendar input or elapsed time, rejecting past and invalid dates. */ export function resolveCustomSnooze(input: CustomSnoozeInput, now: Date): string | null { let wake: Date; if (input.mode === "duration") { diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 9f2e15fda48d..b32169830f0c 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -130,19 +130,16 @@ describe("v2 thread shell lists", () => { const dispose = registry.mount(inputs); registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, child] }); expect(registry.get(inputs).map((thread) => thread.id)).toEqual([child.id]); - // A follow-up queued behind the blocked run leaves the question live. registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, { ...child, status: "queued" as const }], }); expect(registry.get(inputs).map((thread) => thread.id)).toEqual([child.id]); - // An async question stays pending after the run that asked it completes. registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, { ...child, status: "completed" as const, activityRunStatus: null }], }); expect(registry.get(inputs)).toEqual([]); - // Older servers omit the activity run, so the latest run's status decides. const { activityRunStatus: _omitted, ...legacyChild } = child; registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, @@ -172,7 +169,6 @@ describe("v2 thread shell lists", () => { id: ThreadId.make("review-helper"), lineage: { ...child.lineage, parentThreadId: child.id }, }; - // The parent's turn ended; the delegated review keeps it in Working. const root = { ...v2ThreadShell, latestRunId: RunId.make("root-run"), diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index a4315f2f8162..1710eb38bfb8 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -45,7 +45,7 @@ const isChildRunLive = ( thread.status === "running" || thread.status === "waiting"; -/** What a subagent's live run waits on the user for. Provider-native +/** Provider-native subagents ask on their parent, so they are skipped. */ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentRequest | null { const kind = thread.pendingRuntimeRequest?.kind; @@ -61,11 +61,6 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq return kind === "user_input" ? "user_input" : "approval"; } -/** - * Threads above a subagent that waits on the user, by what it waits on. Every - * thread up to the visible one carries it, because subagent rows are hidden. - * An approval outranks a question. - */ export function pendingSubagentRequestsByThread( threads: ReadonlyArray, ): ReadonlyMap { @@ -88,7 +83,6 @@ export function pendingSubagentRequestsByThread( return requests.size === 0 ? EMPTY_SUBAGENT_REQUESTS : requests; } -/** Banner title for the subagents `childThreadInputsAtom` lists. */ export function childThreadInputsTitle( children: ReadonlyArray>, ): string { diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 4987a86bf0a6..f27e2d0a31b6 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -12,29 +12,18 @@ import { withOrchestrationProtocolHeader, } from "./environmentHttpAuth.ts"; -// Long enough for a slow but alive server to finish. On a cold open a timeout -// makes the socket ask the same server for the same snapshot again, and older -// turn pages have no fallback, so a short deadline only drops work. The socket -// fallback is for setups where /api fails but /ws works, such as a proxy that -// blocks /api. A dead server drops the socket session, which interrupts a -// cold-open load. Older turn pages wait for this deadline. +// Long enough for a slow but alive server to finish. On a cold open a timeout makes the socket +// ask the same server for the same snapshot again, and older turn pages have no fallback, so a +// short deadline only drops work. The socket fallback is for setups where /api fails but /ws +// works, such as a proxy that blocks /api. const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 20_000; -/** Progressive history metadata returned by a bounded snapshot loader. */ export type ThreadSnapshotHistoryMeta = { readonly historyCursor: string | null; readonly hasMoreHistory: boolean; - /** Max local turn ordinal from the full projection; optional on older servers. */ readonly latestLocalTurnOrdinal?: number | null; }; -/** - * Outcome of an HTTP thread-detail snapshot load. - * - * - `present`: snapshot body is available (seed projection, resume via socket). - * - `missing`: server definitively reported the thread does not exist (404). - * - `unavailable`: transport/timeout/5xx/etc.; fall back to the socket path. - */ export type ThreadSnapshotLoadResult = | { readonly _tag: "present"; @@ -44,11 +33,6 @@ export type ThreadSnapshotLoadResult = | { readonly _tag: "missing" } | { readonly _tag: "unavailable" }; -/** - * Load a thread's detail snapshot over HTTP instead of embedding it in the - * WebSocket subscription's first frame. The response is gzip-compressible by - * the transport and keeps the (potentially multi-KB) snapshot off the socket. - */ export const fetchEnvironmentThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentThreadSnapshot", )(function* (input: { @@ -76,14 +60,6 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError; -/** - * Loads a thread's detail snapshot over HTTP. - * - * Distinguishes a definitive missing thread (HTTP 404) from transient snapshot - * unavailability so the thread state machine can mark the thread deleted without - * opening a socket subscription, while still falling back to the socket when the - * HTTP path is merely unavailable. - */ export class ThreadSnapshotLoader extends Context.Service< ThreadSnapshotLoader, { diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index b53301f3cd5e..ff3899ddb9f0 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -99,7 +99,6 @@ describe("effectiveSnoozed", () => { }); it("wakes early on a failure that happened after the snooze", () => { - // makeShell stamps session.updatedAt at 11:00, after SNOOZED_AT (9:00). expect( effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "error" }), { now: NOW, @@ -113,7 +112,6 @@ describe("effectiveSnoozed", () => { makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "error", - // Snoozed AFTER the error's status edge. snoozedAt: "2026-04-10T11:30:00.000Z", }), { now: NOW }, @@ -198,14 +196,12 @@ describe("canSnooze", () => { }); it("refuses a queued turn start β€” same invisible-pending-work rule as settle", () => { - // Fresh user message, no turn has adopted it, within the grace window. expect( canSnooze( { ...makeShell({}), latestUserMessageAt: "2026-04-10T11:59:30.000Z" }, { now: NOW }, ), ).toBe(false); - // Outside the grace window the message is stale data, not queued work. expect( canSnooze( { ...makeShell({}), latestUserMessageAt: "2026-04-10T11:00:00.000Z" }, @@ -292,10 +288,6 @@ describe("threadWokeAt", () => { }); it("keeps the early wake authoritative after the scheduled time passes", () => { - // Woke early at 10:30 via run-completed; the scheduled wake (PAST_WAKE - // 10:00 relative to a later now) has ALSO passed. Reporting the - // scheduled time would resurface a Woke pill the user already cleared - // by visiting between the early wake and now. expect( threadWokeAt( makeShell({ snoozedUntil: PAST_WAKE, turnCompletedAt: "2026-04-10T09:30:00.000Z" }), @@ -361,7 +353,6 @@ describe("resolveSnoozePresets", () => { }); it("drops next week on Sundays, when it lands on the same Monday as tomorrow", () => { - // Sunday 2026-08-30 07:01: "Tomorrow" and "Next week" are both Monday 9:00. const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7, 1)); expect(presets.map((preset) => preset.id)).toEqual([ "hour", diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index bb9b15bc922b..0f15bfc2fe83 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -93,7 +93,6 @@ describe("sortSettledThreads", () => { settled({ id: "settled-first", settledAt: "2026-03-09T10:00:00.000Z", - // Created/active later than the other thread: settle time must win. latestUserMessageAt: "2026-03-09T09:59:00.000Z", }), settled({ @@ -117,8 +116,6 @@ describe("sortSettledThreads", () => { }); it("counts a turn completion as activity for auto-settled threads", () => { - // The message came in before the other thread's, but its turn finished - // after: completion time is the real "work ended" moment. const sorted = sortSettledThreads([ settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), settled({ @@ -155,13 +152,10 @@ describe("sortSettledThreads", () => { { updatedAt: "2026-03-09T09:00:00.000Z" }, { updatedAt: "invalid" }, ]; - // Ids repeat every 3 rows and stamps every 5, so rows tie on the time, - // on the id, and on both. (index * 7) % 30 scrambles the input order. const threads = Array.from({ length: 30 }, (_, index) => { const row = (index * 7) % 30; return { ...settled({ id: `thread-${row % 3}`, ...stamps[row % 5] }), row }; }); - // The comparator this sort replaced: it resolved both keys on every call. const timestampMs = (thread: SettledThreadTimestampInput) => { const timestamp = resolveSettledThreadTimestamp(thread); return timestamp === null ? 0 : Date.parse(timestamp); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 1dcd3d370cd8..e6b0e67f164c 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -25,8 +25,6 @@ export type SettledThreadTimestampInput = Pick< "settledAt" | "latestUserMessageAt" | "latestRun" | "updatedAt" >; -/** The timestamp a settled row sorts and labels by on every client: settledAt - when stamped, otherwise the latest message or turn stamp, then updatedAt. */ export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInput): string | null { if (thread.settledAt != null && toSortableTimestamp(thread.settledAt) !== null) { return thread.settledAt; @@ -50,9 +48,6 @@ export function resolveSettledThreadTimestamp(thread: SettledThreadTimestampInpu return toSortableTimestamp(thread.updatedAt) === null ? null : thread.updatedAt; } -/** Settled rows are history, so they order by when the work ENDED, newest - first, with an id tiebreak. Each key resolves once per sort, not once - per comparison. Shared by web and mobile so both render the same order. */ export function sortSettledThreads( threads: readonly T[], ): T[] { @@ -118,14 +113,6 @@ export function getThreadSortTimestamp( return getLatestUserMessageTimestamp(thread); } -/** - * Sort anchor for the active thread list: creation time, re-anchored to - * unsettledAt when the thread last re-entered the active list (an explicit - * un-settle, or a settled thread waking on activity). The list stays static - * between lifecycle transitions, but an un-settled thread surfaces at the - * top instead of sinking back to its creation-order slot. Shared by web and - * mobile so both render the same order. Malformed timestamps sink to 0. - */ export function activeThreadAnchorTimestampMs(thread: { readonly createdAt: string; readonly unsettledAt?: string | null | undefined; @@ -175,13 +162,6 @@ export function getLatestThreadForProject< return latest; } -// ── Pinned reorder: fractional index keys ────────────────────────────── -// Pinned threads carry an optional pinOrderKey (a base-26 string). The -// pinned block sorts keyed threads by plain string comparison, so a drag -// (web) or Move up/down (mobile) writes ONE key to ONE thread on that -// thread's own server β€” neighbors, possibly living on other servers, are -// never touched, and every client connected to the same servers converges -// on the same order. const PIN_ORDER_DIGITS = "abcdefghijklmnopqrstuvwxyz"; function isValidPinOrderKey(key: string): boolean { @@ -199,7 +179,6 @@ function isValidPinOrderKey(key: string): boolean { function pinOrderMidpoint(a: string, b: string): string { if (b !== "" && a >= b) throw new Error("pinOrderMidpoint: bounds out of order"); if (b !== "") { - // Recurse past the longest common prefix ("a" pads the shorter side). let n = 0; while ((a.charAt(n) || PIN_ORDER_DIGITS[0]) === b.charAt(n)) n += 1; if (n > 0) return b.slice(0, n) + pinOrderMidpoint(a.slice(n), b.slice(n)); @@ -209,9 +188,6 @@ function pinOrderMidpoint(a: string, b: string): string { if (digitB - digitA > 1) { return PIN_ORDER_DIGITS.charAt(Math.round((digitA + digitB) / 2)); } - // Consecutive leading digits: either b has spare digits to shorten into, - // or we extend a (never producing a trailing minimum digit β€” the base - // case midpoint("", "") is the middle of the alphabet). if (b.length > 1) return b.charAt(0); return PIN_ORDER_DIGITS.charAt(digitA) + pinOrderMidpoint(a.slice(1), ""); } @@ -229,8 +205,6 @@ export function pinOrderKeyBetween(before: string | null, after: string | null): return pinOrderMidpoint(a, b); } -/** Evenly spaced keys for materializing an order. Wider keys keep a large - active list from exhausting the space between two-digit keys. */ export function generateSpreadPinOrderKeys(count: number): string[] { let width = 2; let space = PIN_ORDER_DIGITS.length ** width; @@ -242,7 +216,6 @@ export function generateSpreadPinOrderKeys(count: number): string[] { const keys: string[] = []; for (let i = 0; i < count; i += 1) { let value = Math.round(step * (i + 1)); - // Skip values whose low digit is the minimum (a trailing "a" key). if (value % PIN_ORDER_DIGITS.length === 0) value += 1; let key = ""; for (let digit = 0; digit < width; digit += 1) { @@ -254,16 +227,7 @@ export function generateSpreadPinOrderKeys(count: number): string[] { return keys; } -/** - * Assignments needed to realize a new pinned order. When the moved thread - * sits between two keyed (or absent) neighbors, this is a single write to - * the moved thread. When a neighbor is keyless (threads pinned before - * reordering shipped), the whole section gets fresh spread keys β€” a - * one-time materialization; every move after that is single-write. Active - * reordering uses the same planner with activeOrderKey values. - */ export function planPinnedReorder(input: { - /** Thread ids in the desired visual order (after the move). */ readonly orderedIds: readonly string[]; /** Include retained keys from hidden rows; only orderedIds receive writes. */ readonly keysById: ReadonlyMap; @@ -287,7 +251,6 @@ export function planPinnedReorder(input: { while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); if (key !== null) return [{ id: movedId, orderKey: key }]; } - // Keyless neighbor (or corrupt keys): rewrite the section in the new order. const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) .filter((key) => !reservedKeys.has(key)) .slice(0, orderedIds.length); @@ -297,12 +260,6 @@ export function planPinnedReorder(input: { }); } -/** - * Pinned block order: user-arranged keys first (string comparison, id - * tiebreak), then keyless threads newest-created first β€” so threads on - * servers that predate reordering keep the static creation order at the - * bottom of the block instead of breaking the section. - */ export function sortPinnedThreadsByOrderKey< T extends { readonly id: string; @@ -338,8 +295,6 @@ export function sortPinnedThreadsByOrderKey< return [...keyed, ...keyless]; } -/** New and reopened threads lead the active list. Arranged threads follow - their saved keys; activity leaves both groups in place. */ export function sortActiveThreadsByOrderKey< T extends { readonly id: string; @@ -375,14 +330,7 @@ export function sortActiveThreadsByOrderKey< }); } -/** - * planPinnedReorder specialized for mobile's Move up / Move down menu - * actions: swap the moved thread with its displayed neighbor. Null when the - * move falls off either end of the list. Same single-write-per-move - * semantics as a web drag. - */ export function planPinnedMove(input: { - /** Reorder-capable pinned thread ids in displayed order. */ readonly orderedIds: readonly string[]; readonly keysById: ReadonlyMap; readonly movedId: string; diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index d534f50ce916..67b227089ab1 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -10,9 +10,8 @@ export interface EnvironmentThreadState { readonly status: EnvironmentThreadStatus; readonly error: Option.Option; /** - * Progressive history cursor for bounded hydration. Absent/cleared on full - * snapshot paths. Errors here are thread/history-local and must not be - * promoted into the environment disconnect path. + * Errors here are thread/history-local and must not be promoted into the environment + * disconnect path. */ readonly history: ThreadHistoryMeta; } diff --git a/packages/client-runtime/src/state/threadSubagents.ts b/packages/client-runtime/src/state/threadSubagents.ts index bbb0535c84ac..1b064f184925 100644 --- a/packages/client-runtime/src/state/threadSubagents.ts +++ b/packages/client-runtime/src/state/threadSubagents.ts @@ -1,12 +1,3 @@ -/** - * The subagent roster for one turn, and the compact label the mobile composer - * pill renders for it. - * - * Scoped to a run rather than the whole thread: a thread accumulates every - * subagent it ever spawned, while the pill and its sheet answer "what is this - * turn doing right now". Statuses come from the v2 projection directly, so - * this stays a pure fold over `runs` and `subagents`. - */ import * as DateTime from "effect/DateTime"; import type { OrchestrationV2Subagent, OrchestrationV2ThreadProjection } from "@t3tools/contracts"; import { copySorted } from "@t3tools/shared/Array"; @@ -19,10 +10,8 @@ type Subagent = OrchestrationV2Subagent; type RunId = Projection["runs"][number]["id"]; export interface ThreadTurnSubagents { - /** The run the roster belongs to: the active one, else the most recent one that spawned agents. */ readonly runId: RunId | null; readonly turnActive: boolean; - /** Spawn order: startedAt, falling back to updatedAt for agents that never started. */ readonly subagents: ReadonlyArray; readonly liveCount: number; readonly settledCount: number; @@ -37,14 +26,11 @@ function orderKey(subagent: Subagent): number { return DateTime.toEpochMillis(subagent.startedAt ?? subagent.updatedAt); } -/** null when the thread has never spawned a subagent. */ export function deriveThreadTurnSubagents( projection: Pick, ): ThreadTurnSubagents | null { if (projection.subagents.length === 0) return null; const activeRun = resolveActiveThreadRun(projection); - // With no live run the newest roster is still worth showing: a turn that - // just finished leaves results the user has not read yet. const latestUpdated = projection.subagents.reduce((latest, subagent) => DateTime.toEpochMillis(subagent.updatedAt) > DateTime.toEpochMillis(latest.updatedAt) ? subagent @@ -66,11 +52,6 @@ export function deriveThreadTurnSubagents( return { runId, turnActive: activeRun !== null, subagents, liveCount, settledCount }; } -/** - * What the pill's agents segment says, or null while it should stay hidden. - * The segment is for work in flight: once the turn settles it goes away - * rather than leaving a stale count above the composer. - */ export function resolveSubagentPillSegment( turn: ThreadTurnSubagents | null, ): SubagentPillSegment | null { diff --git a/packages/client-runtime/src/state/threadWorkflows.ts b/packages/client-runtime/src/state/threadWorkflows.ts index cb2b4f1fdb4a..69e691840b89 100644 --- a/packages/client-runtime/src/state/threadWorkflows.ts +++ b/packages/client-runtime/src/state/threadWorkflows.ts @@ -23,7 +23,6 @@ export interface QueuedThreadRun { readonly run: Run; readonly text: string; readonly attachments: ReadonlyArray; - /** Editing replaces this message's content, so its id and context travel with the row. */ readonly messageId: Message["id"]; readonly context?: Message["context"]; } @@ -107,7 +106,6 @@ export function threadSupportsProviderHandoff(projection: Projection | null | un ); } -/** Automatic completion/notification runs are not messages in the user's queue. */ export function getUserQueuedThreadRuns( projection: Pick, ): ReadonlyArray { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index b78377b2b1ec..61c7f01c9b73 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -520,8 +520,6 @@ describe("EnvironmentThreads", () => { return 8; }, }); - // Hold the projection write while the event advances its cursor. Closing - // the scope must resume from the last fully applied projection. yield* first.threadState.semaphore.take(1); yield* Queue.offer(first.inputs, update); yield* Deferred.await(applying); @@ -678,8 +676,6 @@ describe("EnvironmentThreads", () => { Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_PROJECTION }); - // The warm cache reaches live from the cached data, and a live event - // applies on top of it. yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1)); yield* awaitThreadState( harness.observed, @@ -689,8 +685,6 @@ describe("EnvironmentThreads", () => { value.data.value.thread.title === "Live title", ); - // The subscription resumed from the cached sequence and never fetched the - // full snapshot over HTTP. expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); expect(yield* Ref.get(harness.loaderCalls)).toBe(0); }), @@ -826,8 +820,6 @@ describe("EnvironmentThreads", () => { expanded: false, latestLocalTurnOrdinal: 47, }); - // A transient cold HTTP failure still requests a bounded socket snapshot. - // Its cursor remains available for a later history request. expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBeUndefined(); expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); @@ -964,7 +956,6 @@ describe("EnvironmentThreads", () => { } if (mode === "retry") { expect(result._tag).toBe("error"); - // The ordinary load-earlier button must also finish a failed search load. expect(yield* harness.loadEarlier()).toEqual({ _tag: "loaded" }); } else expect(result).toEqual({ _tag: "loaded" }); const complete = yield* SubscriptionRef.get(harness.threadState); @@ -1033,7 +1024,6 @@ describe("EnvironmentThreads", () => { yield* Queue.offer(harness.inputs, { kind: "snapshot", snapshotSequence: 5, - // Only the dependency that is not a visible local row is on the wire. projection: { ...BASE_PROJECTION, turnItems: [retainedRequest], visibleTurnItems }, historyCursor: "compact-cursor", hasMoreHistory: true, @@ -1050,7 +1040,6 @@ describe("EnvironmentThreads", () => { expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); expect(yield* Ref.get(harness.lastAcceptCompactTurnItems)).toBe(true); - // Live updates reduce against the restored list, not the compact one. const updated = { ...local[1]!, output: "updated", ordinal: 2 }; yield* Queue.offer(harness.inputs, { kind: "event", @@ -1100,8 +1089,6 @@ describe("EnvironmentThreads", () => { snapshot: { snapshotSequence: 1, projection: httpProjection }, }, }); - // No socket snapshot is pushed; only a live event arrives over the socket. - // It can only be applied if the HTTP snapshot already seeded the thread. yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2)); const state = yield* awaitThreadState( @@ -1113,8 +1100,6 @@ describe("EnvironmentThreads", () => { ); expect(Option.getOrThrow(state.data).thread.title).toBe("Live title"); - // Cold cache: the full snapshot was loaded over HTTP and the socket - // resumed from that snapshot's sequence. expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(1); expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); @@ -1157,7 +1142,6 @@ describe("EnvironmentThreads", () => { }); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(11); expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); - // Bounded HTTP success must not require a socket snapshot frame. expect(Option.getOrThrow(state.data).thread.title).toBe("Live after bounded"); }), ); @@ -1178,8 +1162,6 @@ describe("EnvironmentThreads", () => { }, }, }); - // Bounded install alone (settled) must enqueue progressive meta with the - // projection. Never persist the partial window as a complete full snapshot. yield* awaitThreadState( harness.observed, (value) => @@ -1197,7 +1179,6 @@ describe("EnvironmentThreads", () => { expect(first?.projection.thread.title).toBe("Bounded cache title"); expect(first?.historyCursor).toBe("cursor-oldest"); expect(first?.hasMoreHistory).toBe(true); - // No earlier complete-looking entry without progressive meta. expect( savedAll.some( (entry) => @@ -1207,7 +1188,6 @@ describe("EnvironmentThreads", () => { ), ).toBe(false); - // A later live update still carries the progressive cursor. yield* Queue.offer(harness.inputs, titleUpdated("Settled bounded", 5)); yield* awaitThreadState( harness.observed, @@ -1240,7 +1220,6 @@ describe("EnvironmentThreads", () => { }, }); - // Apply a live event so the subscription is known to have resumed from cache. yield* Queue.offer( harness.inputs, titleUpdated("Cached bounded live", CACHED_SNAPSHOT_SEQUENCE + 1), @@ -1259,7 +1238,6 @@ describe("EnvironmentThreads", () => { hasMoreHistory: true, expanded: false, }); - // Warm progressive cache must not re-download; resume via afterSequence. expect(yield* Ref.get(harness.loaderCalls)).toBe(0); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); expect(yield* Ref.get(harness.lastAcceptBoundedSnapshot)).toBe(true); @@ -1326,8 +1304,6 @@ describe("EnvironmentThreads", () => { ? { resumeCache } : { loadCached: Effect.succeed(Option.some(saved!)) }, ); - // The thread may have grown past the replay budget while closed. Its - // warm subscription must still allow the server to bound that fallback. yield* Queue.offer(warm.inputs, titleUpdated("Grown while closed", 200)); yield* awaitThreadState( warm.observed, @@ -1418,7 +1394,6 @@ describe("EnvironmentThreads", () => { error: null, }); - // Settled full snapshot persistence must not keep the stale cursor. yield* TestClock.adjust("500 millis"); yield* Effect.yieldNow; const saved = (yield* Ref.get(harness.savedThreads)).at(-1); @@ -1514,7 +1489,6 @@ describe("EnvironmentThreads", () => { it.effect("bounded HTTP install sets projection and progressive meta atomically", () => Effect.gen(function* () { - // One setThread with explicit history (not applyItem reset + later meta). const harness = yield* makeHarness({ httpSnapshot: { _tag: "present", @@ -1634,11 +1608,9 @@ describe("EnvironmentThreads", () => { }, }); - // Allow the event to be processed without requiring a state transition. for (let attempt = 0; attempt < 30; attempt += 1) { yield* Effect.yieldNow; } - // Drive a later unrelated title update so we know the stream continued. yield* Queue.offer(harness.inputs, titleUpdated("After dropped event", 7)); const after = yield* awaitThreadState( harness.observed, @@ -1648,19 +1620,14 @@ describe("EnvironmentThreads", () => { value.data.value.thread.title === "After dropped event", ); - // The dropped event must not have appended the old item. expect(Option.getOrThrow(after.data).turnItems.map((item) => String(item.id))).toEqual([ String(recent.id), ]); - // Watermark unchanged (only advanced on successful newer turn-item applies). expect(after.history.latestLocalTurnOrdinal).toBe(10); - // No persistence enqueue from the dropped event itself. const savedAfterDrop = (yield* Ref.get(harness.savedThreads)).length; expect(savedAfterDrop).toBe(savedBefore); - // Seeded projection reference path: event path kept partial meta intact. expect(after.history.historyCursor).toBe("partial-cursor"); expect(after.history.hasMoreHistory).toBe(true); - // Title event applied; drop itself did not clear progressive meta. expect(seededProjection.turnItems.map((item) => String(item.id))).toEqual([ String(recent.id), ]); @@ -1796,7 +1763,6 @@ describe("EnvironmentThreads", () => { expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); expect(yield* Ref.get(harness.subscriptionCount)).toBe(0); - // A definitive miss must not schedule the expected-failure retry path. yield* TestClock.adjust("1 second"); for (let attempt = 0; attempt < 20; attempt += 1) { yield* Effect.yieldNow; @@ -2073,7 +2039,6 @@ describe("EnvironmentThreads", () => { }, unknown(CACHED_SNAPSHOT_SEQUENCE + 2), titleUpdated("After", CACHED_SNAPSHOT_SEQUENCE + 3), - // A trailing unknown event must still advance the resume cursor. unknown(CACHED_SNAPSHOT_SEQUENCE + 4), synchronized(), ]); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index a6e31cf40c4a..fc8b8e500813 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -81,7 +81,6 @@ function historyMetaFromCachedSnapshot( hasMoreHistory, loading: false, error: null, - // Cache never stores expanded progressive history (load-earlier growth). expanded: false, latestLocalTurnOrdinal: snapshot.latestLocalTurnOrdinal ?? null, }; @@ -150,11 +149,6 @@ function matchesThreadSnapshot( ); } -// A retained "live" state stays live: the cursor resume that follows only -// replays what the thread missed, and on servers that send the completion -// marker the first replayed event moves the status to "synchronizing" on its -// own. Downgrading here would flash a sync label on every return to a -// recently viewed thread. function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState { return { ...value, @@ -215,8 +209,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }), }; const state = yield* SubscriptionRef.make(initialState); - // Paging support belongs to the client, even when the initial HTTP request - // fails. A bounded socket reset retains a cursor so history can be retried. const canLoadHistory = Option.isSome(httpClient) && Option.isSome(historyController); const acceptsBoundedSocketSnapshots = yield* Ref.make(canLoadHistory); // Seed the resume cursor from the cached snapshot so a warm cache can catch up @@ -255,7 +247,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make snapshot: OrchestrationV2ThreadDetailSnapshot, ) { if (resumeCache !== undefined && resumeCache.owner !== owner) return; - // A deletion can arrive while an older snapshot waits in the persistence queue. if (committed.state.status === "deleted") return; if ( committed.persisted && @@ -357,12 +348,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationV2ThreadProjection, options?: { - /** Socket/full snapshots: drop progressive meta with the new timeline. */ readonly resetHistory?: boolean; - /** - * Explicit progressive meta installed atomically with the projection - * (bounded HTTP). Wins over resetHistory when both are supplied. - */ readonly history?: ThreadHistoryMeta; }, ) { @@ -380,7 +366,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return { ...previous, data: Option.some(thread), - // Buffered values from a failed attempt can arrive after its error. status: Option.isSome(previous.error) ? ("cached" as const) : waiting @@ -449,7 +434,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const fresh: EventItem[] = []; for (const item of items) { if (item.sequence <= sequence) continue; - // An event type from a newer server still moves the resume cursor past it. sequence = item.sequence; if (item.kind === "event") { fresh.push(item); @@ -459,7 +443,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.annotateLogs({ environmentId, threadId, - // Bounded: the type comes from a newer server and is not validated here. eventType: item.eventType.slice(0, 64), sequence: item.sequence, }), @@ -501,7 +484,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make // Incomplete progressive windows only (hasMore or open cursor). Do not // use expanded: it remains true after the last page as a cache marker. - // Full/web and fully-loaded timelines keep default append-on-miss. const partial = current.history.hasMoreHistory || current.history.historyCursor !== null ? { @@ -510,9 +492,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } : undefined; const next = applyOrchestrationV2ProjectionEvent(current.data.value, item.event, partial); - // True no-op when the reducer deliberately returns the current projection - // reference (e.g. dropped old partial-timeline turn-item). Do not clear - // stream error/status or enqueue persistence. if (next === null || next === current.data.value) { return [{ _tag: "noop" }, current]; } @@ -587,8 +566,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make item.historyCursor !== undefined || item.hasMoreHistory !== undefined || item.latestLocalTurnOrdinal !== undefined; - // Bounded socket fallbacks carry their cursor. Legacy-compatible full - // snapshots omit these fields and still replace progressive state. yield* setThread( boundedSnapshotProjection(item), hasProgressiveHistory @@ -701,8 +678,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const page = pageResult.success; const complete = view === undefined; const waiting = yield* Ref.get(awaitingCompletion); - // Both stages merge against live state under the same lock. Only the - // complete page advances the cursor and releases the loading indicator. const result = yield* applyLock.withPermits(1)( SubscriptionRef.modify( state, @@ -736,8 +711,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; }); - // On Effect interruption only: clear loading when this request cursor is - // still active. Never mutate stream status/error from the interrupt path. return yield* runLoad.pipe( Effect.onInterrupt(() => SubscriptionRef.update(state, (latest) => ({ @@ -783,8 +756,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const resumingLive = yield* Ref.make(initialState.status === "live"); const markSynchronizing = Effect.gen(function* () { if (yield* Ref.get(resumingLive)) return; - // Connection notifications do not establish that a terminated load restarted. - // Clear its diagnostic only when this subscription actually tries again. yield* SubscriptionRef.update(state, (current) => current.status === "deleted" ? current @@ -860,13 +831,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make break; } case "missing": { - // Definitive HTTP 404: clear any stale cache and do not open or - // retry a socket subscription for this attempt. yield* setDeleted(); return yield* Effect.never; } case "unavailable": { - // Transient HTTP failure: fall through to the socket path. break; } } @@ -967,7 +935,6 @@ export * from "./archivedThreads.ts"; export * from "./checkpointDiff.ts"; export * as BoundedThreadSnapshotLoader from "./boundedThreadSnapshotHttp.ts"; export * as ThreadHistoryController from "./threadHistoryController.ts"; -// Flat so consumers' inferred types can name it. export type { ThreadHistoryLoadEarlierResult } from "./threadHistoryController.ts"; export * from "./threadHistoryMerge.ts"; export * from "./threadSnapshotHttp.ts"; diff --git a/packages/client-runtime/src/state/turnItemPresentation.test.ts b/packages/client-runtime/src/state/turnItemPresentation.test.ts index bfccbe63cf5f..2219dc5d9d17 100644 --- a/packages/client-runtime/src/state/turnItemPresentation.test.ts +++ b/packages/client-runtime/src/state/turnItemPresentation.test.ts @@ -89,11 +89,8 @@ describe("workspacePreparationRetryRunIds", () => { expect([...workspacePreparationRetryRunIds([run("failed", worktree)], failure)]).toEqual([ RunId.make("run-1"), ]); - // Retried: the run is preparing again and the old failure is cancelled. expect(workspacePreparationRetryRunIds([run("preparing", worktree)], failure).size).toBe(0); - // An older server records no preparation, so there is nothing to repeat. expect(workspacePreparationRetryRunIds([run("failed")], failure).size).toBe(0); - // A provider error on a run that did reach the provider is not a preparation failure. expect( workspacePreparationRetryRunIds( [run("failed", worktree)], diff --git a/packages/client-runtime/src/state/turnItemPresentation.ts b/packages/client-runtime/src/state/turnItemPresentation.ts index 5268aee52321..4e70159dc36c 100644 --- a/packages/client-runtime/src/state/turnItemPresentation.ts +++ b/packages/client-runtime/src/state/turnItemPresentation.ts @@ -6,10 +6,6 @@ import { const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; -/** - * Workspace setup is client bookkeeping; preparation failures have their own - * error item. A retry cancels that item, which then has nothing left to say. - */ export function turnItemIsWorkspacePreparation(item: OrchestrationV2TurnItem): boolean { return ( (item.type === "command_execution" && item.input === WORKSPACE_PREPARATION_INPUT) || @@ -19,11 +15,6 @@ export function turnItemIsWorkspacePreparation(item: OrchestrationV2TurnItem): b ); } -/** - * Runs a Retry can prepare again: their workspace preparation failed and the - * run still ended there. Older servers record no preparation on the run, so - * they never offer it. - */ export function workspacePreparationRetryRunIds( runs: ReadonlyArray>, items: ReadonlyArray, diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index dec6b909d266..7bc582eb7da3 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -14,7 +14,6 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); -/** Offer the Cursor Keychain prompt only where a working Cursor provider could use it. */ export function needsCursorKeychainAccess( summary: UsageSummary | null, providers: readonly ServerProvider[] | null, @@ -27,11 +26,8 @@ export function needsCursorKeychainAccess( } /** - * Environments to offer the Cursor Keychain prompt in the usage summary. - * * Any environment reading a Cursor account already reports that account's - * history from every machine, so the prompt only adds duplicates there. A - * different account on another machine stays reachable from provider settings. + * history from every machine, so the prompt only adds duplicates there. */ export function cursorKeychainAccessEnvironments< E extends { readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean }, @@ -61,12 +57,9 @@ export async function refreshUsageLimits( if (afterPending) { try { await pending; - } catch { - // The new check still needs to run if the earlier one failed. - } + } catch {} return refreshUsageLimits(environmentId, refresh, false, true); } - // Manual refresh waits for the current check; automatic refresh does not repeat it. return automatic ? undefined : ((await pending) as A); } const refreshAfter = limitsRefreshAfter.get(environmentId) ?? 0; @@ -83,7 +76,6 @@ export async function refreshUsageLimits( return await current; } -/** Refresh pricing, then await each selected environment's rescan while it remains connected. */ export async function refreshUsage({ registry, server, @@ -120,7 +112,6 @@ export async function refreshUsage({ const sessionUnavailable = ratesResult._tag === "Failure" && isEnvironmentRpcUnavailable(squashAtomCommandFailure(ratesResult)); - // Invalidate even on failure so reconnects cannot reuse the old summary. registry.refresh(query); if (sessionUnavailable || controller.signal.aborted) return; await executeAtomQuery(registry, query, { diff --git a/packages/client-runtime/src/state/usageProgress.test.ts b/packages/client-runtime/src/state/usageProgress.test.ts index 889e696bfdd4..3ee640fda953 100644 --- a/packages/client-runtime/src/state/usageProgress.test.ts +++ b/packages/client-runtime/src/state/usageProgress.test.ts @@ -91,7 +91,6 @@ describe("usageProgress", () => { options, ), ).toEqual({ dimmed: false, label: "Updating 2 providers…" }); - // Without a follow-up in flight, a leftover refreshing source is not waited on. expect(usageProgress([{ ...partway, isPending: false }], options)).toEqual({ dimmed: false, label: null, @@ -117,7 +116,6 @@ describe("usageProgress", () => { }); it("dims every answered environment while a manual refresh runs", () => { - // Pricing refreshes first, so neither query is pending yet. expect( usageProgress([environment("a"), environment("b")], { ...options, refreshing: true }), ).toEqual({ dimmed: true, label: "Updating 2 environments…" }); diff --git a/packages/client-runtime/src/state/usageProgress.ts b/packages/client-runtime/src/state/usageProgress.ts index 7e5f885c2874..7ca515191800 100644 --- a/packages/client-runtime/src/state/usageProgress.ts +++ b/packages/client-runtime/src/state/usageProgress.ts @@ -8,17 +8,6 @@ interface UsageProgressEnvironment { readonly summary: UsageSummary | null; } -/** - * Where one environment's answer stands for the usage on screen. - * - * - `inactive`: disconnected or failed, so waiting on it changes nothing. - * - `loading`: no summary yet. - * - `stale`: its summary on screen predates the current request. - * - `partway`: answered this round while slow sources finish refreshing. - * - `ready`: answered. - * - * While a manual `refreshing` runs, every summary counts as stale. - */ export function usageEnvironmentProgress( environment: UsageProgressEnvironment, refreshing = false, @@ -27,7 +16,6 @@ export function usageEnvironmentProgress( if (!environment.isConnected || environment.error !== null) return { phase: "inactive" } as const; if (summary === null) return { phase: "loading" } as const; if (refreshing) return { phase: "stale" } as const; - // A refreshing source without a pending follow-up has nothing left to wait on. if (!environment.isPending) return { phase: "ready" } as const; const providers = [ ...new Set( @@ -48,13 +36,6 @@ export function updatingProvidersLabel( : `Updating ${providers.length} providers…`; } -/** - * Loading state for the selected environments' merged usage. - * - * The page dims only while old numbers are all that is on screen. Once any - * environment answers, the label carries what is still coming, so one slow - * device never holds the page dimmed. - */ export function usageProgress( environments: readonly UsageProgressEnvironment[], { @@ -89,15 +70,6 @@ export function usageProgress( }; } -/** - * Which figures in the merged usage on screen are still coming in, so clients - * can mute just those. - * - * - `partial`: totals will change because some environment is still answering. - * - `providers`: providers whose own figures will change. An environment that - * has not answered yet, or whose whole summary is stale, could change any - * provider, so it sets `everyProvider`. - */ export function usageLoadingState( environments: readonly UsageProgressEnvironment[], refreshing = false, diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index b72f804194ca..4f9bb6598a3b 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -646,8 +646,6 @@ describe("cached VCS refs", () => { ); }); -// Transport fixtures have a source-control-only session; authorization edge cases -// are exercised by commandPermissions.test.ts. vi.mock("./session.ts", () => ({ createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), })); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index c319e3e3ad1f..3a638e2b512d 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -35,8 +35,6 @@ import { const OFFLINE_BRANCH_LIST_LIMIT = 100; const VCS_REFS_IDLE_TTL_MS = 30_000; -// Rows keep the last status they rendered, so the live stream only needs a -// short grace period when virtualization or scrolling releases its consumer. const VCS_STATUS_IDLE_TTL_MS = 10_000; const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( Schedule.modifyDelay(({ duration }) => @@ -115,7 +113,6 @@ export const commitVcsRefsRefresh = Effect.fn("CachedVcsRefsState.commitRefresh" }); /** - * Retains the last unfiltered branch-list response for the new-task picker. * Filtered or paginated lists intentionally stay live-only: treating a * partial result as a complete offline list would make branch selection * misleading. @@ -315,9 +312,6 @@ export function createVcsEnvironmentAtoms( concurrency: vcsCommandConcurrency, onSettled: invalidateRefs, }), - // Live stages of a bootstrap worktree setup. Null until the server begins - // tracking, then a snapshot per change, then null again after the setup - // is dropped. Short TTL so a closed thread releases its subscription. worktreeSetup: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:worktree-setup", tag: WS_METHODS.subscribeWorktreeSetup, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 32dda6f6cba2..e9065b2cffaa 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -680,7 +680,6 @@ describe("vcsActionState", () => { expect(registry.get(manager.stateAtom(targetKey))).toEqual(EMPTY_VCS_ACTION_STATE); expect(registry.get(state).revision).toBe(1); expect(removed).toEqual([`${environmentId}:*`]); - // The server links a created pull request to this thread, so the id must ride along. expect(rpcInputs).toEqual([ { actionId: successfulTransportActionId, cwd, action, threadId, projectId }, ]); @@ -721,8 +720,6 @@ describe("vcsActionState", () => { ); }); -// Transport fixtures have a source-control-only session; authorization edge cases -// are exercised by commandPermissions.test.ts. vi.mock("./session.ts", () => ({ createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), })); diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 57885838c1e5..30d98b248022 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -79,7 +79,6 @@ export interface RunVcsStackedActionInput { readonly commitMessage?: string; readonly featureBranch?: boolean; readonly filePaths?: ReadonlyArray; - /** The thread the action runs beside; the server links a pull request it creates to it. */ readonly threadId?: ThreadId; readonly projectId?: ProjectId; readonly onProgress?: (event: GitActionProgressEvent) => void; @@ -501,9 +500,7 @@ export function createVcsActionManager( if (input.onProgress !== undefined) { try { input.onProgress(event); - } catch { - // Presentation callbacks must not fail the source-control operation. - } + } catch {} } }), }, diff --git a/packages/client-runtime/src/state/vcsRefInvalidation.ts b/packages/client-runtime/src/state/vcsRefInvalidation.ts index f6d52df2a17f..6b695d35e668 100644 --- a/packages/client-runtime/src/state/vcsRefInvalidation.ts +++ b/packages/client-runtime/src/state/vcsRefInvalidation.ts @@ -50,11 +50,9 @@ export function withVcsRefsPersistenceLock( } /** - * Clears persisted snapshots and then restarts live ref streams while holding - * the same lock used by generation-checked refresh writes. Clearing first - * prevents the restarted stream from rehydrating the invalidated snapshot. A - * refresh that wins the lock is cleared afterward; one that loses observes the - * new revision and cannot repersist its pre-mutation result. + * Clearing first prevents the restarted stream from rehydrating the invalidated + * snapshot. A refresh that wins the lock is cleared afterward; one that loses + * observes the new revision and cannot repersist its pre-mutation result. */ export const invalidateCachedVcsRefs = Effect.fn("VcsRefsState.invalidateCached")(function* ( registry: AtomRegistry.AtomRegistry, diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 276a4dc18e15..25878d4ff5bc 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -21,7 +21,6 @@ interface ToolResult { readonly failed: boolean; } -/** Reads the structured/JSON MCP result envelopes retained by the provider adapters. */ function readResult(value: unknown, depth = 0): ToolResult { if (depth > 4) return { failed: false }; if (typeof value === "string") { @@ -80,7 +79,6 @@ function quantity(count: number, noun: string, plural = `${noun}s`): string { return `${count} ${count === 1 ? noun : plural}`; } -/** Counts successful effects separately from failed or unfinished tool calls. */ export function summarizeT3ToolCalls( action: T3McpToolSummaryAction, calls: ReadonlyArray, @@ -203,7 +201,6 @@ export function summarizeT3ToolCalls( ); break; case "schedule-delete": - // A successful delete can report deleted:false; it still represents a deletion request. label = phrase( "Requested deletion of", "delete", @@ -351,7 +348,6 @@ export function summarizeT3ToolCalls( ); break; case "attachment-send": { - // A message can contain several attachments. Count retries by message identity. const messages = new Map(); selected.forEach((call, index) => messages.set(id(call.output?.messageId) ?? `call-${index}`, call), diff --git a/packages/client-runtime/src/textPaste.ts b/packages/client-runtime/src/textPaste.ts index 4155f0baad78..422018d92404 100644 --- a/packages/client-runtime/src/textPaste.ts +++ b/packages/client-runtime/src/textPaste.ts @@ -17,7 +17,6 @@ export function isPasteAsTextShortcut( } /** - * Large clipboard text becomes a file so an agent can inspect it selectively. * The threshold is byte-based: character counts substantially understate the * context cost of some Unicode-heavy clipboard contents. */ @@ -37,7 +36,6 @@ export function pastedTextDisposition(input: { : "inline"; } -/** Stable, human-readable names when a draft contains several folded pastes. */ export function nextPastedTextFileName(existingNames: ReadonlyArray): string { const names = new Set(existingNames.map((name) => name.toLowerCase())); if (!names.has("pasted-text.txt")) { diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.ts b/packages/client-runtime/src/threadPullRequestCompatibility.ts index 4a31f82ec052..922fc119c993 100644 --- a/packages/client-runtime/src/threadPullRequestCompatibility.ts +++ b/packages/client-runtime/src/threadPullRequestCompatibility.ts @@ -11,7 +11,6 @@ type LinkingCapabilities = Pick< "threadPullRequests" | "threadPullRequestLinking" >; -/** Capability negotiation keeps both single-link and multi-link environments usable. */ export function threadPullRequestLinkMode(capabilities: LinkingCapabilities | null | undefined) { return capabilities?.threadPullRequests === true ? "multiple" diff --git a/packages/client-runtime/src/userMessage.ts b/packages/client-runtime/src/userMessage.ts index b292bafe0ca4..497941232668 100644 --- a/packages/client-runtime/src/userMessage.ts +++ b/packages/client-runtime/src/userMessage.ts @@ -7,10 +7,6 @@ import { const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/; const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/; -/** - * Text and sender label for a user-role message. Older scheduled messages - * stored their attribution in the prompt itself. - */ export function resolveUserMessagePresentation(message: { readonly id?: string; readonly role: string; @@ -20,7 +16,6 @@ export function resolveUserMessagePresentation(message: { readonly scheduledTaskId?: ScheduledTaskId; }): { readonly text: string; - /** Who sent a user-role message the user did not type. */ readonly attribution: "automation" | "agent" | "t3code" | null; readonly scheduledTaskId: ScheduledTaskId | undefined; } { @@ -47,7 +42,6 @@ export function resolveUserMessagePresentation(message: { } return { text: message.text, - // Restart continuations were sent as the agent before they became notices. attribution: message.createdBy !== "agent" ? null diff --git a/packages/client-runtime/src/voice-input/controller.ts b/packages/client-runtime/src/voice-input/controller.ts index 80a8a528f335..39dfd8542d88 100644 --- a/packages/client-runtime/src/voice-input/controller.ts +++ b/packages/client-runtime/src/voice-input/controller.ts @@ -462,9 +462,7 @@ export class VoiceInputController { for (const uri of this.ownedRecordingUris) { try { this.dependencies.deleteRecording(uri); - } catch { - // The cache may already have removed a failed or interrupted recording. - } + } catch {} } this.ownedRecordingUris.clear(); await this.releaseAudioSession(); @@ -484,9 +482,7 @@ export class VoiceInputController { try { await this.dependencies.releaseRecording(); this.recordingConfigured = false; - } catch { - // Final cleanup retries if the prompt release before transcription fails. - } + } catch {} } private invalidateOperation(): void { diff --git a/packages/client-runtime/src/voice-input/transcription.ts b/packages/client-runtime/src/voice-input/transcription.ts index f3ce377acb5f..9e927f7caa5b 100644 --- a/packages/client-runtime/src/voice-input/transcription.ts +++ b/packages/client-runtime/src/voice-input/transcription.ts @@ -3,7 +3,6 @@ export type VoiceTranscriptionOptions = { readonly signal: AbortSignal; }; -/** Binds a recording to its selected implementation and resolved locale. */ export type PreparedVoiceTranscription = { readonly locale: string; readonly transcribe: (uri: string, options: VoiceTranscriptionOptions) => Promise; diff --git a/packages/client-runtime/src/webhookAddress.ts b/packages/client-runtime/src/webhookAddress.ts index cd1424fd6f86..74c07eddae91 100644 --- a/packages/client-runtime/src/webhookAddress.ts +++ b/packages/client-runtime/src/webhookAddress.ts @@ -1,17 +1,9 @@ import type { ScheduledTaskWebhookEndpoint } from "@t3tools/contracts"; import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification"; -/** - * Where a sender can call a webhook task, as a client shows it. With T3 - * Connect the server returns a public URL; without it the path is resolved on - * the address this client reaches the environment at. - */ export interface WebhookAddress { - /** The URL to give a sender, or the bare path when no address is known. */ readonly address: string; - /** Whether `address` is a full URL a sender can call. */ readonly copyable: boolean; - /** One line on who can reach `address`; null for a T3 Connect URL. */ readonly note: string | null; } diff --git a/packages/client-runtime/src/work-log/commandLabel.test.ts b/packages/client-runtime/src/work-log/commandLabel.test.ts index 2ba38fd37a57..f6394ee87c56 100644 --- a/packages/client-runtime/src/work-log/commandLabel.test.ts +++ b/packages/client-runtime/src/work-log/commandLabel.test.ts @@ -499,11 +499,6 @@ describe("commandDisplayText", () => { }); describe("commands recorded from each provider", () => { - // Inputs are copied from the recorded replay transcripts in - // apps/server/src/orchestration-v2/testkit/fixtures (turn_interrupt_mid_tool, - // todo_list, grok_background_bash and - // opencode_running_child_approval). In turn_interrupt_mid_tool every provider - // ran the same script, and only Codex wraps it in a login shell. const interruptScript = `node -e "console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)"`; it.each([ ["Claude", interruptScript, interruptScript], @@ -583,7 +578,6 @@ describe("commandHighlightLanguage", () => { ["git status; pwsh -c 'Get-Date'", "shellscript"], ["cat <<'EOF' > notes.txt\npwsh\nEOF", "shellscript"], ["", "shellscript"], - // Windows agents also run PowerShell directly, without a pwsh wrapper. ["Get-Content package.json | Select-String version", "powershell"], ["$env:CI='1'; npm test", "powershell"], ["$tmp = Join-Path $env:TEMP repo; git clone example", "powershell"], diff --git a/packages/client-runtime/src/work-log/commandLabel.ts b/packages/client-runtime/src/work-log/commandLabel.ts index 12557bdc8698..9b7c8d550dd2 100644 --- a/packages/client-runtime/src/work-log/commandLabel.ts +++ b/packages/client-runtime/src/work-log/commandLabel.ts @@ -62,9 +62,6 @@ const SKIPPABLE_SUDO_PROBES = new Set(["[", "[[", "test", "true"]); const NON_PROGRAM_PREFIX_CHARACTERS = "<>(){}[];|&$`#!%@:"; const NON_PROGRAM_SUFFIX_CHARACTERS = "){]}`"; -// These tokens describe shell syntax or shell-local control flow, not a useful -// executable name. Falling back to "command" is less misleading than labels -// such as "Ran if", "Ran [", or "Ran function". const NON_DESCRIPTIVE_SHELL_PROGRAMS = new Set([ "!", "#", @@ -160,8 +157,6 @@ const NON_DESCRIPTIVE_SHELL_PROGRAMS = new Set([ "while", ]); -// Unlike setup builtins, these can make later segments part of control flow or -// otherwise unreachable, so do not use a later program as the command label. const TERMINAL_SHELL_PROGRAMS = new Set([ "and", "begin", @@ -784,8 +779,6 @@ function powerShellAssignmentProgramName( ); if (!assignment) return { matched: false, program: null }; - // Environment assignments are setup. Their right-hand side is a value, not - // a command, so prefer the next top-level segment when one exists. if (assignment[1]?.toLowerCase() === "env") { return { matched: true, @@ -922,9 +915,6 @@ function referencedCommandAlias(token: string): string | null { return reference?.[1] ?? reference?.[2] ?? null; } -// Recover only literal aliases declared by an earlier top-level shell segment. -// This covers common `SSH=(ssh ...)` and `TOOL=/path/to/tool` forms without -// evaluating expansions or trying to model general shell state. function literalCommandAliasProgramName(command: string): string | null { const aliases = new Map(); let remainingCommand: string | null = command; @@ -1374,15 +1364,10 @@ export function commandProgramName(command: string, depth = 0): string | null { // oxlint-disable-next-line no-control-regex const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu; -/** - * The command as rows show it: a plain shell -c wrapper removed and control - * characters made visible. Callers retain the original for details. - */ export function commandDisplayText(command: string): string { return withVisibleControlCharacters(withoutShellWrapper(command)); } -/** Replaces each control character with its control picture; the length does not change. */ export function withVisibleControlCharacters(text: string): string { return text.replace(CONTROL_CHARACTERS, (character) => character === "\u007f" ? "␑" : String.fromCharCode(0x2400 + character.charCodeAt(0)), @@ -1410,9 +1395,6 @@ function withoutShellWrapper(command: string): string { const POWERSHELL_PROGRAMS = new Set(["powershell", "pwsh"]); -// PowerShell's approved verbs (`Get-Verb`), plus the ForEach, Where, Sort and -// Tee of its built-in *-Object cmdlets. Cmdlets match only capitalized, as -// agents write them: lowercase `update-grub` or `install-info` are Linux tools. const POWERSHELL_VERBS = new Set( `Add Approve Assert Backup Block Build Checkpoint Clear Close Compare Complete Compress Confirm Connect Convert ConvertFrom ConvertTo Copy Debug Deny Deploy Disable Disconnect Dismount Edit Enable Enter Exit Expand Export Find ForEach Format Get Grant Group Hide Import Initialize Install Invoke Join Limit Lock Measure Merge Mount Move New Open Optimize Out Ping Pop Protect Publish Push Read Receive Redo Register Remove Rename Repair Request Reset Resize Resolve Restart Restore Resume Revoke Save Search Select Send Set Show Skip Sort Split Start Step Stop Submit Suspend Switch Sync Tee Test Trace Unblock Undo Uninstall Unlock Unprotect Unpublish Unregister Update Use Wait Watch Where Write`.split( " ", @@ -1424,10 +1406,6 @@ function isPowerShellCmdlet(word: string): boolean { return cmdlet !== null && POWERSHELL_VERBS.has(cmdlet[1]!); } -/** - * The grammar to highlight a command with: PowerShell when pwsh or powershell - * runs it, or when it is written in PowerShell, as Windows agents run it. - */ export function commandHighlightLanguage(command: string): "powershell" | "shellscript" { const program = /^(?:&\s*)?(?:"([^"]*)"|'([^']*)'|(\S+))/u.exec(command.trim()); const name = (program?.[1] ?? program?.[2] ?? program?.[3] ?? "") @@ -1439,16 +1417,13 @@ export function commandHighlightLanguage(command: string): "powershell" | "shell return isPowerShellScript(command) ? "powershell" : "shellscript"; } -/** Whether any statement starts like PowerShell and never like POSIX shell. */ function isPowerShellScript(command: string): boolean { let rest: string | null = command.trim(); - // A leading call operator; POSIX shell cannot start a command with `&`. if (/^&\s*\S/u.test(rest)) return true; for (let segment = 0; rest && segment < MAX_COMMAND_SEGMENTS; segment += 1) { const { firstCommand, remainingCommand } = splitFirstShellCommand(rest); const statement = firstCommand.trim(); if ( - // `$env:NAME` or `$name = value`; neither parses as POSIX shell. /^\$(?:env|global|local|script):/iu.test(statement) || /^\$[A-Za-z_]\w*\s+=/u.test(statement) || isPowerShellCmdlet(statement.match(/^\S+/u)?.[0] ?? "") diff --git a/packages/client-runtime/src/work-log/itemDetail.test.ts b/packages/client-runtime/src/work-log/itemDetail.test.ts index b7a6360f5c47..1287b205a42b 100644 --- a/packages/client-runtime/src/work-log/itemDetail.test.ts +++ b/packages/client-runtime/src/work-log/itemDetail.test.ts @@ -19,7 +19,6 @@ const screenshot = { title: null, toolName: "mcp__t3-code__device_screenshot", input: { deviceId: "phone" }, - // What a detail read returns: the image's position without its bytes. output: { content: [ { type: "text", text: "Captured the home screen." }, diff --git a/packages/client-runtime/src/work-log/itemDetail.ts b/packages/client-runtime/src/work-log/itemDetail.ts index 548c96dd1c33..0a32e913337b 100644 --- a/packages/client-runtime/src/work-log/itemDetail.ts +++ b/packages/client-runtime/src/work-log/itemDetail.ts @@ -8,7 +8,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** Wire projection replaces a large dynamic input with `{ summary, truncated: true }`. */ function isSummarizedValue(value: unknown): boolean { return isRecord(value) && value.truncated === true && typeof value.summary === "string"; } @@ -24,7 +23,6 @@ function textFromBlocks(value: unknown, depth: number): string | null { } if (!isRecord(value)) return null; if (value.type === "text" && typeof value.text === "string") return value.text; - // Images clients can show render on their own (see turnItemOutputImages). if (value.type === "image") return readToolOutputImage(value) ? "" : "[image]"; if (value.type === "resource_link" && typeof value.uri === "string") return value.uri; if (value.type === "resource" && isRecord(value.resource)) { @@ -33,9 +31,6 @@ function textFromBlocks(value: unknown, depth: number): string | null { if (typeof resource.uri === "string") return resource.uri; } const keys = Object.keys(value).filter((key) => key !== "isError" && key !== "is_error"); - // MCP results and provider tool results wrap their text in `content`. - // `structuredContent` usually repeats it as data, so it only shows when the - // text is empty. if (keys.length === 1 && keys[0] === "content") return textFromBlocks(value.content, depth + 1); if (keys.length === 2 && keys.includes("content") && keys.includes("structuredContent")) { const text = textFromBlocks(value.content, depth + 1); @@ -52,7 +47,6 @@ function parseJson(text: string): unknown { } } -/** MCP tools often return JSON as minified text, one document per line. Indent each. */ function prettyJsonText(text: string): string { const trimmed = text.trim(); if (!/^[[{]/.test(trimmed)) return text; @@ -64,10 +58,8 @@ function prettyJsonText(text: string): string { return documents.map((document) => JSON.stringify(document, null, 2)).join("\n\n"); } -/** Formats a tool input or output for display: text blocks as text, the rest as JSON. */ function formatToolValue(value: unknown, hasImagePreview = false): string | null { if (value === undefined || value === null) return null; - // Claude Read returns one structured image; lists may contain other images. if (hasImagePreview && isRecord(value) && value.type === "image") return null; const text = textFromBlocks(value, 0); if (text !== null) return text.trim() ? prettyJsonText(text) : null; @@ -81,10 +73,6 @@ function formatToolValue(value: unknown, hasImagePreview = false): string | null return json; } -/** - * The call a tool row's body shows above its result: the full command, the - * arguments as `key value` pairs, or formatted text when they are not flat. - */ export function toolCallLines(input: { readonly command?: string | undefined; readonly args?: unknown; @@ -94,7 +82,6 @@ export function toolCallLines(input: { readonly argsText: string | null; } { if (input.command !== undefined) { - // The row title truncates to its width, so the body always has the full command. const command = input.command.trim(); return { command: command || null, args: null, argsText: null }; } @@ -131,14 +118,13 @@ const LIVE_TURN_ITEM_STATUSES: ReadonlySet = ]); /** - * Cache key for a fetched item. A running item keeps one key, so an open row - * fetches once while it streams and again when it finishes, not on every update. + * A running item keeps one key, so an open row fetches once while it streams and + * again when it finishes, not on every update. */ export function turnItemDetailRevision(item: OrchestrationV2TurnItem): string { return LIVE_TURN_ITEM_STATUSES.has(item.status) ? "live" : DateTime.formatIso(item.updatedAt); } -/** True when the timeline item withholds content that `getTurnItem` returns. */ export function turnItemNeedsDetailFetch(item: OrchestrationV2TurnItem): boolean { switch (item.type) { case "command_execution": @@ -150,7 +136,6 @@ export function turnItemNeedsDetailFetch(item: OrchestrationV2TurnItem): boolean } } -/** Older Claude bash rows stored the raw `{ stdout, stderr, interrupted, ... }` result. */ function commandOutputText(output: string): string { if (!output.trimStart().startsWith('{"stdout"')) return output; const parsed = parseJson(output.trim()); @@ -167,7 +152,6 @@ function commandOutputText(output: string): string { .join("\n"); } -/** The tool output carried by a fetched item, formatted for display. */ export function turnItemOutputText(item: OrchestrationV2TurnItem): string | null { switch (item.type) { case "command_execution": @@ -208,10 +192,6 @@ export function turnItemOutputText(item: OrchestrationV2TurnItem): string | null } } -/** - * Images in a fetched item's tool output, as assets. The detail read leaves - * the bytes out, so each loads over HTTP by its index. - */ export function turnItemOutputImages( item: OrchestrationV2TurnItem, ): ReadonlyArray> { @@ -224,10 +204,6 @@ export function turnItemOutputImages( })); } -/** - * Whether expanding the item shows anything. Rows without content must not - * offer a disclosure, otherwise they open to an empty panel. - */ export function turnItemHasDetail(item: OrchestrationV2TurnItem): boolean { switch (item.type) { case "reasoning": diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 4b0078fdb737..212d6600cdfd 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -151,7 +151,7 @@ describe("workEntryIndicatesToolFailure", () => { } satisfies WorkLogPresentationEntry; expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(false); - // Older activities can store output in this field, so that path stays separate. + expect(workEntryIndicatesToolFailure(entry)).toBe(true); expect(workEntryDisplayIndicatesToolFailure({ ...entry, detail: "File not found" })).toBe(true); }); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index cb5ac00b0e96..727f1b4ccde0 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -29,7 +29,6 @@ import { export type WorkLogToolLifecycleStatus = RuntimeItemStatus | "stopped" | "idle"; -/** Inspection and copying omit raw outputs, including values from older caches. */ export function toolItemForDisplay(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { switch (item.type) { case "command_execution": @@ -114,7 +113,6 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } -/** Structured identity is authoritative, including when it identifies a foreign server. */ function workEntryToolName( entry: Pick, ): string | undefined { @@ -186,7 +184,6 @@ function resolveT3McpToolPresentation( }; } -/** Only active or completed calls can inherit the live group’s present tense. */ export function liveActivityToolStatus(status: string | undefined, presentTense: boolean) { if (status === "failed" || status === "declined" || status === "stopped" || status === "idle") { return status; @@ -195,7 +192,6 @@ export function liveActivityToolStatus(status: string | undefined, presentTense: return "completed"; } -/** Resolves tool identity before choosing labels or icons in either client. */ export function resolveWorkEntryToolPresentation( entry: Pick< WorkLogPresentationEntry, @@ -241,7 +237,6 @@ function commandResultContent(value: unknown): string | null { return chunks.length > 0 ? chunks.join("\n") : null; } -/** Returns provider command output before it is formatted for a work-log row. */ export function extractCommandOutputText(dataValue: unknown): string | null { const data = asRecord(dataValue); const item = asRecord(data?.item); @@ -304,12 +299,11 @@ function textRepeatsCommand(text: string, commands: ReadonlyArray } /** - * Decides whether a command row's `detail` is a synthetic echo of the command - * rather than real output. OpenCode stores completed output in `detail` with no - * other output channel, so plain equality is only treated as synthetic when the - * payload shape shows the detail came from the command: Codex item metadata, - * an ACP tool call (`data.toolCallId`, `kind: "execute"`), a Claude tool-name - * prefix, or no structured command at all. + * OpenCode stores completed output in `detail` with no other output channel, so + * plain equality is only treated as synthetic when the payload shape shows the + * detail came from the command: Codex item metadata, an ACP tool call + * (`data.toolCallId`, `kind: "execute"`), a Claude tool-name prefix, or no + * structured command at all. */ export function commandDetailRepeatsCommand(input: { readonly detail: string; @@ -383,8 +377,6 @@ function workEntryIndicatesToolFailureFromOutput( if (item.outputIndicatesFailure || (item.exitCode !== undefined && item.exitCode !== 0)) { return true; } - // Older servers/caches can still carry output. Read only the previous - // preview-sized prefix for status, without exposing it in the row detail. if (item.output && toolOutputIndicatesFailure(item.output.slice(0, 32_768))) { return true; } @@ -395,17 +387,14 @@ function workEntryIndicatesToolFailureFromOutput( return output.length > 0 && toolOutputIndicatesFailure(output); } -/** Includes legacy activities that stored error output in the command field. */ export function workEntryIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { return workEntryIndicatesToolFailureFromOutput(entry, true); } -/** Checks rendered output without treating the user's command as an error. */ export function workEntryDisplayIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { return workEntryIndicatesToolFailureFromOutput(entry, false); } -/** Decides whether the row can show a success marker. */ export function workEntryIndicatesToolSuccess(entry: WorkLogPresentationEntry): boolean { return ( workLogEntryIsToolLike(entry) && @@ -432,7 +421,6 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio if (presentation?.icon === "browser") return "browser"; if (presentation?.icon === "device") return "device"; if (entry.requestKind === "file-read" || entry.viewedImagePath !== undefined) return "read"; - // Approvals and questions describe requested work, not work that ran. if (entry.itemType === "approval_request" || entry.itemType === "user_input_request") { return workLogEntryIsToolLike(entry) ? "other" : "update"; } @@ -505,8 +493,6 @@ export function resolveViewedImageAsset( readonly workspaceRoot?: string | null | undefined; }, ): ViewedImageAsset | null { - // A relative path with no known workspace still names a media-file relative - // to the thread's workspace, so classify against "." and drop the prefix. const imageSource = classifyMarkdownImageSource(source, input.workspaceRoot ?? "."); if (imageSource._tag !== "WorkspaceFile") return null; const resolvedFilePath = @@ -642,7 +628,6 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction) } } -/** Summarizes at most two action categories; every omitted call still counts in the remainder. */ export function summarizeToolGroup(entries: ReadonlyArray): { summary: string; hasFailure: boolean; @@ -756,11 +741,6 @@ export function toolGroupSummaryKind( return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; } -/** - * Plain-text line for the latest thought in the live activity row. A - * bold-only opening line (the Codex summary heading) wins; otherwise this is - * the first sentence of the reasoning text. Web and mobile both render it. - */ export function liveThoughtLine(markdown: string): string { const heading = /^\s*\*\*([^*\r\n]+)\*\*[ \t]*\r?(?:\n|$)/.exec(markdown)?.[1]; const text = (heading ?? markdown) @@ -771,7 +751,6 @@ export function liveThoughtLine(markdown: string): string { .replace(/\s+/g, " ") .trim(); if (heading !== undefined) return text; - // Cut after the first . ? or ! (plus a closing quote or paren) that a space follows. const end = /[.?!]["'”’)]?(?=\s)/.exec(text); return end ? text.slice(0, end.index + end[0].length) : text; } diff --git a/packages/client-runtime/src/worktreeSetup.ts b/packages/client-runtime/src/worktreeSetup.ts index 7b74f94950cd..5e8eb76bc9d3 100644 --- a/packages/client-runtime/src/worktreeSetup.ts +++ b/packages/client-runtime/src/worktreeSetup.ts @@ -8,12 +8,6 @@ import * as Schema from "effect/Schema"; const decodeWorktreeSetupSnapshot = Schema.decodeUnknownOption(WorktreeSetupSnapshot); -/** - * The worktree setup the server recorded on the thread, if any: running once - * the bootstrap created the thread, then the settled outcome. It is what a - * reload or a second client renders, and what tells them to attach the live - * stream while it still says running. - */ export function findRecordedWorktreeSetup( activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, threadId: ThreadId, @@ -27,24 +21,10 @@ export function findRecordedWorktreeSetup( return null; } -/** - * Which setup snapshot the timeline shows, if any. The live stream wins while - * it has a newer sequence; the recorded activity covers everything else. A - * running setup always shows. The setup belongs to the thread's first turn: - * once the user has sent a follow-up it is history and nothing about it is - * shown again, whatever its outcome. Within that first turn, a clean finish - * leaves no trace once the turn is live (the setup is a means to the reply, - * not part of the conversation), while a failed script, a failed setup, or a - * cancelled one stays so the outcome, exit code, and terminal are reachable. - * Before the turn is live everything stays so nothing collapses in the - * handoff gap. Visibility never depends on whether a turn happens to be - * running, which would make the row come and go. - */ export function resolveVisibleWorktreeSetup(input: { live: WorktreeSetupSnapshot | null; recorded: WorktreeSetupSnapshot | null; turnStarted: boolean; - /** The user sent a message after the one that created the worktree. */ followUpSent: boolean; }): WorktreeSetupSnapshot | null { const snapshot = diff --git a/packages/contracts/src/acpRegistry.ts b/packages/contracts/src/acpRegistry.ts index 1c88e0a87666..559ac6e47761 100644 --- a/packages/contracts/src/acpRegistry.ts +++ b/packages/contracts/src/acpRegistry.ts @@ -13,7 +13,6 @@ const AcpRegistryAgentId = TrimmedNonEmptyString.check( const ACP_REGISTRY_CDN_HOSTNAME = "cdn.agentclientprotocol.com"; const ACP_REGISTRY_AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; -/** Derives the official Registry icon URL without trusting stored URL metadata. */ export function officialAcpRegistryIconUrlForAgentId( agentId: string | null | undefined, ): string | null { @@ -23,7 +22,6 @@ export function officialAcpRegistryIconUrlForAgentId( return `https://${ACP_REGISTRY_CDN_HOSTNAME}/registry/v1/latest/${agentId}.svg`; } -/** Allows provider icons only from the credential-free official Registry CDN origin. */ export function resolveOfficialAcpRegistryIconUrl(icon: string | null | undefined): string | null { if (!icon) return null; try { @@ -110,12 +108,8 @@ export const AcpRegistryProbeAuthMethod = Schema.Struct({ name: AcpRegistryProbeText, description: AcpRegistryProbeDescription, type: Schema.Literals(["agent", "env_var", "terminal"]), - // Terminal methods: the full command line the user runs in a thread - // terminal on the owning environment to complete interactive auth. command: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(2_048))), - // Env-var methods: variable names the user sets on the provider instance. envVarNames: Schema.optionalKey(Schema.Array(AcpRegistryProbeText).check(Schema.isMaxLength(16))), - // Env-var methods may advertise where credentials can be created. link: Schema.optionalKey(AcpRegistryUrl), }); export type AcpRegistryProbeAuthMethod = typeof AcpRegistryProbeAuthMethod.Type; @@ -124,8 +118,6 @@ export const AcpRegistryUrlAuthAction = Schema.Struct({ elicitationId: AcpRegistryProbeText, url: AcpRegistryUrl, message: Schema.String.check(Schema.isMaxLength(1_024)), - // Optional for mixed-version clients. New servers include both timestamps - // so a pending browser login remains understandable on another device. createdAt: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))), expiresAt: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(128))), }); @@ -280,8 +272,6 @@ export const AcpRegistryProbeResult = Schema.Struct({ authMethods: Schema.Array(AcpRegistryProbeAuthMethod).check(Schema.isMaxLength(32)), models: Schema.Array(AcpRegistryProbeModel).check(Schema.isMaxLength(256)), currentModelId: Schema.NullOr(AcpRegistryProbeText), - // Non-model session config options and session modes, pre-mapped onto T3's - // provider option descriptors so model capabilities can carry them directly. configOptions: Schema.Array(ProviderOptionDescriptor).check(Schema.isMaxLength(16)), sessionManagement: Schema.Struct({ canList: Schema.Boolean, diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts index fefb9d085c84..f24ffef26089 100644 --- a/packages/contracts/src/agentSessions.ts +++ b/packages/contracts/src/agentSessions.ts @@ -2,11 +2,9 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -/** Coding agent home directories the scanner knows how to read. */ export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); export type AgentSessionSource = typeof AgentSessionSource.Type; -/** File identity saved with an imported session so bounded retries can skip unchanged history. */ export const AgentSessionImportSource = Schema.Struct({ provider: AgentSessionSource, providerInstanceId: ProviderInstanceId, @@ -20,24 +18,9 @@ export const AgentSessionImportSource = Schema.Struct({ }); export type AgentSessionImportSource = typeof AgentSessionImportSource.Type; -/** - * Empty for now. Kept as a struct so future scan options (source filters, - * explicit roots) can be added without a new method. - */ export const AgentSessionScanInput = Schema.Struct({}); export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; -/** - * A directory that at least one agent CLI has run in, suitable for import as a - * T3 Code project. `alreadyImported` marks candidates that already have an - * active project rooted at the same path. - */ -/** - * Git identity of a candidate directory, read from `.git/config` without - * spawning git. `remoteKey` is the normalized origin URL, shared by every - * clone of the same repository so the client can group them. `repository` - * is the GitHub `owner/name` when the origin is on GitHub. - */ export const AgentSessionProjectGit = Schema.Struct({ remoteKey: Schema.NullOr(Schema.String), repository: Schema.NullOr(Schema.String), @@ -52,11 +35,6 @@ export const AgentSessionProjectCandidate = Schema.Struct({ threadCount: NonNegativeInt, lastActiveAt: Schema.NullOr(IsoDateTime), alreadyImported: Schema.Boolean, - /** - * `null` when the directory is not the root of a git repository. Missing on - * servers that predate the git scan, where the client cannot tell repositories - * from plain folders and should treat every candidate as a standalone project. - */ git: Schema.optionalKey(Schema.NullOr(AgentSessionProjectGit)), }); export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; diff --git a/packages/contracts/src/applicationEvent.ts b/packages/contracts/src/applicationEvent.ts index 6b712889c848..17ffdf91599f 100644 --- a/packages/contracts/src/applicationEvent.ts +++ b/packages/contracts/src/applicationEvent.ts @@ -16,19 +16,12 @@ import { ModelSelection } from "./modelSelection.ts"; import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts"; import { ProjectScript, StoredProjectIcon } from "./project.ts"; -/** - * Which client dispatched the command that produced this event (#7774). - * Stamped by the orchestration engine on client-dispatched commands; absent on - * provider/server-originated events and on commands from clients too old to - * report it. - */ export const OrchestrationClientOrigin = Schema.Struct({ surface: Schema.optional(ClientSurface), appVersion: Schema.optional(TrimmedNonEmptyString), }); export type OrchestrationClientOrigin = typeof OrchestrationClientOrigin.Type; -/** Metadata retained by the shared application event source. */ export const ApplicationEventMetadata = Schema.Struct({ deferredTurn: Schema.optional(Schema.Boolean), providerTurnId: Schema.optional(TrimmedNonEmptyString), @@ -46,10 +39,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), - // Per-project override for where new threads start; optional so persisted - // events from older servers still decode. defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), - // Optional so persisted events from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)), scripts: Schema.Array(ProjectScript), @@ -120,5 +110,4 @@ export const ApplicationProjectEvent = Schema.Union([ ]); export type ApplicationProjectEvent = typeof ApplicationProjectEvent.Type; -/** Events exposed by the retained application event source. */ export type ApplicationStoredEvent = ApplicationProjectEvent | OrchestrationV2StoredEvent; diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index dd7d3eb7c542..6737e01d47b7 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -16,35 +16,23 @@ export const AssetResource = Schema.Union([ threadId: ThreadId, path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), }), - // One file served in place from anywhere the environment host can read: - // images, videos, HTML, and PDF. An absolute path may lie outside the - // workspace; a relative one resolves against the thread's workspace. Schema.TaggedStruct("media-file", { threadId: ThreadId, path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), }), - // A workspace file named by a draft that has no thread yet. The draft names - // its workspace root explicitly instead of resolving one from a thread. Schema.TaggedStruct("draft-workspace-file", { cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), }), Schema.TaggedStruct("attachment", { attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), - /** Display name and mime from the `ChatAttachment` the caller holds. The - server bakes both into the signed URL so downloads carry the real - filename and Content-Type. Absent on older clients, which fall back to - an octet-stream download without a filename. */ fileName: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(255))), mimeType: Schema.optionalKey(TrimmedNonEmptyString.check(Schema.isMaxLength(100))), /** Generic attachments download by default. Document viewers opt into an inline response after deciding the file type is safe to preview. */ disposition: Schema.optionalKey(Schema.Literals(["inline", "attachment"])), - /** Render an MCP App snapshot with the host theme bootstrap. */ renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")), }), - // An image a tool returned inline, such as a device screenshot, by its order - // in the stored output. The timeline never carries these bytes. Schema.TaggedStruct("tool-output-image", { threadId: ThreadId, itemId: TurnItemId, @@ -86,7 +74,6 @@ export const AssetCreateUrlResult = Schema.Struct({ sourcePath: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), ), - /** Pixel size read from the image header, so a client can reserve the exact box before the bytes arrive. */ imageDimensions: Schema.optional(AssetImageDimensions), }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; @@ -196,8 +183,6 @@ export class AssetPreviewTypeValidationError extends Schema.TaggedError([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, @@ -139,7 +86,6 @@ const legacyScopes = new Set([ AuthRelayWriteScope, ]); -/** Format public auth metadata without changing the server's authorization grant. */ export function authScopeResponse(scopes: ReadonlyArray) { return { scopes: scopes.filter((scope) => legacyScopes.has(scope)), permissions: scopes }; } @@ -185,13 +131,11 @@ export function sessionGrantsScope( if (!session.authenticated) return false; if (session.permissions !== undefined) return session.permissions.includes(scope); if (session.scopes?.includes(scope)) return true; - // Also recognize servers from the first granular-scope release. if (session.auth?.serverUpdateScope !== undefined) return false; const parent = legacyParents[scope]; return parent !== undefined && session.scopes?.includes(parent) === true; } -/** Old-only grants lost the child permissions formerly implied by their broad scopes. */ export function sessionHasLegacyPermissions(session: SessionGrantInput): boolean { const permissions = session.permissions; return ( @@ -230,26 +174,6 @@ export const AuthAccessTokenType = "urn:ietf:params:oauth:token-type:access_toke export const AuthEnvironmentBootstrapTokenType = "urn:t3:params:oauth:token-type:environment-bootstrap" as const; -/** - * Server-advertised auth capabilities for a specific execution environment. - * - * Clients should treat this as the authoritative description of how that - * environment expects to be paired and how authenticated requests should be - * made afterward. - * - * Field meanings: - * - `policy`: high-level auth posture for the environment - * - `bootstrapMethods`: pairing/bootstrap methods the server is currently - * willing to accept - * - `sessionMethods`: authenticated request/session methods the server supports - * once pairing is complete - * - `sessionCookieName`: cookie name clients should expect when - * `browser-session-cookie` is in use - * - * This descriptor is intentionally capability-oriented. It lets clients choose - * the right UX without embedding server-specific auth logic or assuming a - * single access method. - */ export const ServerAuthDescriptor = Schema.Struct({ policy: ServerAuthPolicy, bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod), @@ -330,7 +254,6 @@ export const AuthPairingCredentialResult = Schema.Struct({ }); export type AuthPairingCredentialResult = typeof AuthPairingCredentialResult.Type; -// Read models contain metadata only. Credentials are returned by creation alone. export const AuthPairingLink = Schema.Struct({ id: TrimmedNonEmptyString, ...authScopeResponseFields, @@ -468,10 +391,6 @@ export const AuthSessionState = Schema.Struct({ }); export type AuthSessionState = typeof AuthSessionState.Type; -/** - * What an agent signed in through MCP OAuth may do, least to most: only read, - * or act on threads that never run above the given runtime mode. - */ export const AuthMcpClientAccess = Schema.Literals([ "read-only", "approval-required", @@ -481,7 +400,6 @@ export const AuthMcpClientAccess = Schema.Literals([ ]); export type AuthMcpClientAccess = typeof AuthMcpClientAccess.Type; -/** RFC 9728 metadata for an environment's `/mcp` resource. */ export const AuthMcpProtectedResourceMetadata = Schema.Struct({ resource: Schema.String, authorization_servers: Schema.Array(Schema.String), @@ -491,7 +409,6 @@ export const AuthMcpProtectedResourceMetadata = Schema.Struct({ }); export type AuthMcpProtectedResourceMetadata = typeof AuthMcpProtectedResourceMetadata.Type; -/** RFC 8414 metadata for the authorization server MCP clients sign in through. */ export const AuthMcpAuthorizationServerMetadata = Schema.Struct({ issuer: Schema.String, authorization_endpoint: Schema.String, @@ -506,7 +423,6 @@ export const AuthMcpAuthorizationServerMetadata = Schema.Struct({ }); export type AuthMcpAuthorizationServerMetadata = typeof AuthMcpAuthorizationServerMetadata.Type; -/** RFC 7591 client metadata. Fields the server does not use are dropped. */ export const AuthMcpClientRegistration = Schema.Struct({ client_name: Schema.optionalKey(Schema.String), redirect_uris: Schema.optionalKey(Schema.Array(Schema.String)), @@ -524,7 +440,6 @@ export const AuthMcpRegisteredClient = Schema.Struct({ }).pipe(HttpApiSchema.status(201)); export type AuthMcpRegisteredClient = typeof AuthMcpRegisteredClient.Type; -/** RFC 7591 Β§3.2.2 registration error. */ export class AuthMcpRegistrationError extends Schema.Error( "AuthMcpRegistrationError", )( @@ -540,8 +455,7 @@ export class AuthMcpRegistrationError extends Schema.Error()( "AuthMcpApprovalError", { message: Schema.String }, { httpApiStatus: 400 }, ) {} -/** RFC 6749 Β§4.1.3 token request. Every field is checked by the server. */ export const AuthMcpTokenRequest = Schema.Struct({ grant_type: Schema.optionalKey(Schema.String), code: Schema.optionalKey(Schema.String), @@ -633,7 +534,6 @@ export const AuthMcpTokenResult = Schema.Struct({ }); export type AuthMcpTokenResult = typeof AuthMcpTokenResult.Type; -/** RFC 6749 Β§5.2 token error. */ export class AuthMcpTokenError extends Schema.Error("AuthMcpTokenError")( { error: Schema.Literals([ diff --git a/packages/contracts/src/baseSchemas.test.ts b/packages/contracts/src/baseSchemas.test.ts index d1bcd4c153a0..07ea8b966aec 100644 --- a/packages/contracts/src/baseSchemas.test.ts +++ b/packages/contracts/src/baseSchemas.test.ts @@ -21,7 +21,6 @@ const Shape = Schema.Union([ Schema.Struct({ kind: Schema.Literal("square"), side: Schema.Number }), ]); const members = Shape.members; -/** How clients decode: the JSON wire codec over the runtime schema. */ const fromWire = (schema: S) => Schema.decodeUnknownSync(Schema.toCodecJson(schema) as never) as (value: unknown) => S["Type"]; @@ -65,7 +64,6 @@ describe("ForwardCompatibleArray", () => { }); it("drops it too when a wrapper reads the encoded array as JSON values", () => { - // How context records are bounded before forward-compatible decoding. const Wrapped = Schema.Array(Schema.Unknown).pipe(Schema.decodeTo(Named)); expect( Schema.encodeUnknownSync(Schema.toCodecJson(Wrapped))([{ name: "a" }, { name: " " }]), @@ -81,7 +79,6 @@ describe("ForwardCompatibleArray", () => { it("does not accept holes as a decoded value", () => { expect(Schema.is(Named)([undefined])).toBe(false); - // A sparse array's missing index is a hole too. const sparse: Array<{ name: string }> = [{ name: "a" }]; sparse.length = 2; expect(Schema.is(Named)(sparse)).toBe(false); @@ -188,8 +185,6 @@ describe("trimmed non-empty strings", () => { ...entityIds.map((name) => [name, BaseSchemas[name]] as const), ] as const; - // Schema-derived strings are rarely padded, so pad them with every kind of - // whitespace `trim()` removes, and include whitespace-only values. const whitespace = Arbitrary.schema( Schema.Literals([" ", "\t", "\n", "\r\n", "\v", "\f", "\u00a0", "\u2028", "\u3000", "\ufeff"]), ); diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 3ca88df66b19..60cf9df62c3a 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -14,10 +14,9 @@ export const TrimmedString = Schema.String.pipe( ), ); /** - * Non-empty once trimmed. A `TrimmedString` only trims when decoding or - * encoding, so `make` and encode see the untrimmed value: a plain - * `isNonEmpty` would accept `" "` there and encode it to `""`, which no - * longer decodes. + * A `TrimmedString` only trims when decoding or encoding, so `make` and encode see the untrimmed + * value: a plain `isNonEmpty` would accept `" "` there and encode it to `""`, which no longer + * decodes. */ const isNonBlank = Schema.makeFilter((value: string) => value.trim().length > 0, { expected: "a non-blank string", @@ -30,10 +29,6 @@ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); -/** - * Safe categories for a failed DPoP proof. These describe the class of failure - * without exposing proof contents or server-side authentication details. - */ export const DpopFailureReason = Schema.Literals([ "time_window", "key_mismatch", @@ -47,11 +42,6 @@ export type DpopFailureReason = typeof DpopFailureReason.Type; export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; -/** - * Same idea for one optional value whose literal set grows over time: a - * member this build does not know decodes as absent rather than failing the - * enclosing struct. Encoding is the plain encoding. - */ export const ForwardCompatibleOptional = (value: Value) => { const decodeValue = Schema.decodeUnknownOption(value as never); return Schema.optionalKey( @@ -68,11 +58,6 @@ export const ForwardCompatibleOptional = (value: Value ); }; -/** - * The nullable form, for a persisted setting whose literal set grows over - * time: a member this build does not know (or a missing key) decodes as null - * rather than failing the enclosing struct. Encoding is the plain encoding. - */ export const ForwardCompatibleNullable = (value: Value) => { const decodeValue = Schema.decodeUnknownOption(value as never); return Schema.Unknown.pipe( @@ -112,26 +97,13 @@ export const OmittedWhenNull = (value: Value) => { }; /** - * Wire codec for a serverβ†’client value whose shape grows over time: a union - * a newer server may add members to, or an array of such values. Clients keep - * decoding payloads from servers newer than themselves instead of failing the - * connection over data they could not act on anyway. - * - * Decoding runs each value through its own schema, so transformations (dates, - * trimming, decoding defaults) apply as usual. Only values that schema - * rejects are dropped, on either side. - * - * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only - * values whose tag this build does not know, so a known member with a broken - * payload still fails loudly. + * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only values whose tag this + * build does not know, so a known member with a broken payload still fails loudly. */ export const ForwardCompatibleArray = (element: Element) => Schema.Array( Schema.UndefinedOr(element).pipe( - // An element this build cannot read becomes a hole, filtered out below. Schema.catchDecoding(() => Effect.succeedSome(undefined)), - // Likewise an element that cannot be encoded is sent as a hole, so one - // bad element costs only itself rather than the whole payload. Schema.catchEncoding(() => Effect.succeedSome(undefined)), ), ).pipe( @@ -161,9 +133,6 @@ export const ForwardCompatibleArray = (element: Elem ReadonlyArray >({ decode: (values) => values.filter((value) => value !== undefined), - // An element that fails its own checks is dropped before the wire, so - // a wrapper that reads the encoded array as JSON values never meets - // the hole it left. encode: (values) => values.filter((value) => value !== undefined), }), ), @@ -176,9 +145,8 @@ export type ForwardCompatibleArray = Schema.Codec< >; /** - * A member of a {@link ForwardCompatibleUnion} whose tag this build does not - * know. A class, so only the codec can make one: no decoded payload, however - * it is shaped, is ever mistaken for it. + * A class, so only the codec can make one: no decoded payload, however it is shaped, is ever + * mistaken for it. */ export class UnknownUnionMember { readonly tag: Tag; @@ -190,12 +158,6 @@ export class UnknownUnionMember { } /** - * Wire codec for a serverβ†’client tagged union a newer server may add members - * to. A value whose `tag` field holds a value this build does not know decodes - * to {@link UnknownUnionMember} instead of failing; a known member decodes - * through its own schema, so a broken known payload still fails. Callers - * decide what an unknown member means: skip it, or show a fallback. - * * Unknown members are decode-only; encoding one fails. */ export const ForwardCompatibleUnion = < @@ -235,11 +197,6 @@ export type ForwardCompatibleUnion< Members[number]["EncodingServices"] >; -/** - * Whether a raw, undecoded value carries a `tag` this build does not know - * among `members`. For checks outside a {@link ForwardCompatibleUnion}, such - * as an envelope that must skip a payload of an unknown kind. - */ export const hasUnknownUnionTag = ( members: ReadonlyArray, tag: string, @@ -252,16 +209,10 @@ export const hasUnknownUnionTag = ( !known.has((value as Record)[tag] as string); }; -/** Whether a decoded {@link ForwardCompatibleUnion} value is a member this build does not know. */ export const isUnknownUnionMember = ( value: A, ): value is Extract> => value instanceof UnknownUnionMember; -/** - * An array of a growing tagged union that drops members this build does not - * know. Known members decode through their own schema and still fail when - * broken. Encoding is the plain array encoding. - */ export const ForwardCompatibleUnionArray = < const Members extends ReadonlyArray, const Tag extends string, @@ -304,9 +255,6 @@ const knownTags = ( return tags; }; -/** - * Construct a branded identifier. Enforces non-empty trimmed strings - */ const makeEntityId = (brand: Parameters>[0]) => { return TrimmedNonEmptyString.pipe(Schema.brand(brand)); }; @@ -336,12 +284,6 @@ export type AuthSessionId = typeof AuthSessionId.Type; export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); export type RpcClientId = typeof RpcClientId.Type; -/** - * Which client surface a connection or command comes from. Unlike - * `AuthClientMetadataDeviceType` (a UA-style device class where web and - * desktop are both "desktop"), this names the actual product surface. - * Optional everywhere it appears: old clients never send it. - */ export const ClientSurface = Schema.Literals(["web", "desktop", "mobile", "cli"]); export type ClientSurface = typeof ClientSurface.Type; diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index e560bbadaed0..f355e110c69d 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -1,17 +1,3 @@ -/** - * Browser import - pulling cookies from a browser already installed on the - * machine into a T3 Code browser profile. - * - * Only cookies are imported. They carry the logged-in sessions, which is what - * makes an imported profile useful; saved passwords are deliberately out of - * scope because Electron exposes no password store to put them in. - * - * Availability is per source and per platform, and the reasons are modelled - * explicitly: some are a permission the user can grant, one is a limitation - * no amount of consent works around. The UI needs to tell those apart. - * - * @module BrowserImport - */ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; @@ -31,14 +17,6 @@ const BROWSER_IMPORT_SOURCE_IDS = [ export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS); export type BrowserImportSourceId = typeof BrowserImportSourceId.Type; -/** - * Why a detected source cannot be imported right now. - * - * `needsKeychainApproval` and `browserRunning` are recoverable β€” the user - * grants access or quits the browser. `unsupportedPlatform` is not: it covers - * cases like Chrome on Windows, whose App-Bound Encryption is designed to stop - * exactly this, and which we will not work around. - */ export const BrowserImportUnavailableReason = Schema.Literals([ "notInstalled", "needsKeychainApproval", @@ -49,49 +27,21 @@ export const BrowserImportUnavailableReason = Schema.Literals([ ]); export type BrowserImportUnavailableReason = typeof BrowserImportUnavailableReason.Type; -/** - * Why an import that was actually attempted failed. - * - * A superset of the unavailable reasons: a source can pass the pre-flight - * check and still fail, most often because the user declined the keychain - * prompt the read triggers. - */ export const BrowserImportFailureReason = Schema.Literals([ ...BrowserImportUnavailableReason.literals, - /** The operating system's keyring or its bundled reader is unavailable. */ "keychainUnavailable", - /** No source registered under the requested id. */ "unknownSource", - /** The requested profile directory is not one the source reported. */ "unknownSourceProfile", - /** The target profile's Electron session could not be opened. */ "sessionUnavailable", - /** - * The cookies were written, but the new profile could not be saved to - * settings, so its partition was cleared again rather than left orphaned. - */ "profileNotSaved", - /** - * The cookies were written, but the profile count reached its cap while the - * import ran, so the new profile was not saved and its partition was cleared. - */ "profileLimitReached", - /** Anything else: a corrupt database, a failed decrypt, a vanished file. */ "readFailed", ]); export type BrowserImportFailureReason = typeof BrowserImportFailureReason.Type; -/** A profile inside the source browser, e.g. Chromium's "Default" directory. */ export const BrowserImportSourceProfile = Schema.Struct({ - /** Directory name under the source's user-data dir. */ directory: TrimmedNonEmptyString, - /** The name the source browser shows for it. */ name: TrimmedNonEmptyString, - /** - * How many cookies the profile holds. Counted without decrypting, so it is - * cheap; absent when the store could not be read yet (Safari before Full - * Disk Access is granted). - */ cookieCount: Schema.optional(Schema.Int), }); export type BrowserImportSourceProfile = typeof BrowserImportSourceProfile.Type; @@ -100,7 +50,6 @@ export const BrowserImportSource = Schema.Struct({ id: BrowserImportSourceId, name: TrimmedNonEmptyString, profiles: Schema.Array(BrowserImportSourceProfile), - /** Absent when the source is importable. */ unavailable: Schema.optional(BrowserImportUnavailableReason), }); export type BrowserImportSource = typeof BrowserImportSource.Type; @@ -108,12 +57,10 @@ export type BrowserImportSource = typeof BrowserImportSource.Type; export const BrowserImportInput = Schema.Struct({ sourceId: BrowserImportSourceId, sourceProfileDirectory: TrimmedNonEmptyString, - /** T3 Code profile the cookies are written into. */ targetProfileId: BrowserProfileId, }); export type BrowserImportInput = typeof BrowserImportInput.Type; -/** IPC payload: the import input plus the environment the partition belongs to. */ export const DesktopPreviewImportCookiesInputSchema = Schema.Struct({ environmentId: TrimmedNonEmptyString, sourceId: BrowserImportSourceId, @@ -122,19 +69,8 @@ export const DesktopPreviewImportCookiesInputSchema = Schema.Struct({ }); export const BrowserImportResult = Schema.Struct({ - /** Cookies successfully written into the target partition. */ imported: Schema.Int, - /** - * Cookies read but not written β€” expired, rejected as malformed, or held - * under a key we could not use. Surfaced rather than hidden so a - * mostly-failed import doesn't look like a success. - */ skipped: Schema.Int, - /** - * The distinct hosts those skipped cookies belonged to, so the user can be - * told what didn't come over rather than just how many. Capped, since a - * broken key can skip thousands across many sites. - */ skippedDomains: Schema.Array(Schema.String), }); export type BrowserImportResult = typeof BrowserImportResult.Type; @@ -150,7 +86,6 @@ const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly> = { ...BROWSER_IMPORT_UNAVAILABLE_COPY, keychainUnavailable: diff --git a/packages/contracts/src/browserProfile.test.ts b/packages/contracts/src/browserProfile.test.ts index d53423bd6eef..88776f3aa5c2 100644 --- a/packages/contracts/src/browserProfile.test.ts +++ b/packages/contracts/src/browserProfile.test.ts @@ -27,9 +27,6 @@ describe("resolveBrowserProfiles", () => { }); it("drops stored entries that collide with a built-in id", () => { - // Built-ins are synthesized rather than stored, so a hand-edited settings - // file must not be able to shadow Default with a persistent partition of - // its own β€” every tab already opened under Default would follow it. const resolved = resolveBrowserProfiles([ { id: DEFAULT_BROWSER_PROFILE_ID, name: "Hijacked", kind: "persistent" }, { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Not incognito", kind: "persistent" }, @@ -48,8 +45,6 @@ describe("resolveBrowserProfiles", () => { describe("findBrowserProfile", () => { it("returns nothing for an id that no longer exists", () => { - // The settings UI relies on this to fall back rather than opening tabs - // into a partition with no profile behind it. expect(findBrowserProfile(resolveBrowserProfiles([]), work.id)).toBeUndefined(); expect(findBrowserProfile(resolveBrowserProfiles([work]), undefined)).toBeUndefined(); }); @@ -65,8 +60,6 @@ describe("isBuiltInBrowserProfileId", () => { describe("resolveBrowserProfiles normalization", () => { it("keeps only the first entry for a repeated id", () => { - // Both map to the same Electron partition, so presenting two would offer - // isolated identities that in fact share every cookie. const resolved = resolveBrowserProfiles([ { id: "work", name: "Work", kind: "persistent" }, { id: "work", name: "Work (old)", kind: "persistent" }, @@ -78,9 +71,6 @@ describe("resolveBrowserProfiles normalization", () => { }); it("reports a custom incognito profile as persistent", () => { - // Partition persistence is keyed off the built-in incognito id alone, so - // a custom profile claiming that kind keeps its cookies across restarts. - // Labelling it ephemeral would be a promise the partition layer breaks. const resolved = resolveBrowserProfiles([ { id: "throwaway", name: "Throwaway", kind: "incognito" }, ]); @@ -103,8 +93,6 @@ describe("resolveBrowserProfiles normalization", () => { describe("BrowserProfileId", () => { it("rejects control characters", () => { - // Ids are folded into delimiter-joined cache keys on the client, so one - // carrying the delimiter would resolve to another profile's partition. expect(Schema.is(BrowserProfileId)("profile-a\u0000b")).toBe(false); expect(Schema.is(BrowserProfileId)("profile-a")).toBe(true); }); diff --git a/packages/contracts/src/browserProfile.ts b/packages/contracts/src/browserProfile.ts index 39dd58dfb336..a58f44821695 100644 --- a/packages/contracts/src/browserProfile.ts +++ b/packages/contracts/src/browserProfile.ts @@ -1,18 +1,3 @@ -/** - * Browser profiles - named identities for the in-app preview browser. - * - * Each profile maps to its own Electron session partition, so cookies and - * storage are isolated between them: a tab opened under "Work" cannot see - * "Personal"'s logins. Profiles are client-local, like the other browser - * defaults, because the Chromium guest they configure is desktop-local. - * - * Two profiles are built in and cannot be edited or removed: - * - `default` keeps the partition scope the browser used before profiles - * existed, so upgrading does not sign anyone out. - * - `incognito` maps to a non-persistent partition for throwaway sessions. - * - * @module BrowserProfile - */ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; @@ -34,10 +19,6 @@ export const BrowserProfileName = TrimmedNonEmptyString.check( Schema.isMaxLength(BROWSER_PROFILE_NAME_MAX_LENGTH), ); -/** - * `persistent` profiles keep cookies on disk across restarts; `incognito` - * uses an in-memory partition that Chromium discards with the process. - */ export const BrowserProfileKind = Schema.Literals(["persistent", "incognito"]); export type BrowserProfileKind = typeof BrowserProfileKind.Type; @@ -51,10 +32,6 @@ export type BrowserProfile = typeof BrowserProfile.Type; export const DEFAULT_BROWSER_PROFILE_ID: BrowserProfileId = "default"; export const INCOGNITO_BROWSER_PROFILE_ID: BrowserProfileId = "incognito"; -/** - * Built-ins are synthesized rather than stored, so they cannot be renamed out - * of existence or deleted by editing the settings file by hand. - */ export const BUILT_IN_BROWSER_PROFILES: ReadonlyArray = [ { id: DEFAULT_BROWSER_PROFILE_ID, name: "Default", kind: "persistent" }, { id: INCOGNITO_BROWSER_PROFILE_ID, name: "Incognito", kind: "incognito" }, @@ -65,15 +42,8 @@ export function isBuiltInBrowserProfileId(id: string): boolean { } /** - * The full picker list: built-ins first, then the user's own profiles. - * - * Three things are normalized away, because each would present a profile the - * partition layer does not actually deliver: - * - * - Entries colliding with a built-in id, so a hand-edited settings file - * cannot shadow "Default" or "Incognito". * - Repeated ids, which map to one partition and would otherwise appear as - * two isolated identities sharing every cookie. First entry wins. + * two isolated identities sharing every cookie. * - `kind: "incognito"` on anything but the built-in, since persistence is * keyed off that one id; such a profile is labelled ephemeral while its * cookies survive restarts. diff --git a/packages/contracts/src/chatAttachment.test.ts b/packages/contracts/src/chatAttachment.test.ts index 918f968763eb..ba2df3b16657 100644 --- a/packages/contracts/src/chatAttachment.test.ts +++ b/packages/contracts/src/chatAttachment.test.ts @@ -13,9 +13,6 @@ import { const decodeAttachment = Schema.decodeUnknownEffect(ChatAttachment); const decodeSnapShotAccessibility = Schema.decodeUnknownEffect(SnapShotAccessibility); -// Attachments ride on persisted events and thread streams with no client -// version negotiation. A type this build does not know must decode instead of -// failing the whole message. it.effect("tolerates attachment types from newer builds", () => Effect.gen(function* () { const attachment = yield* decodeAttachment({ @@ -29,9 +26,6 @@ it.effect("tolerates attachment types from newer builds", () => }), ); -// The tolerant member must not catch malformed known attachments: a file over -// the size cap or an image with a bad mime has to fail its own schema, not -// slide through the open one with those constraints unchecked. it.effect("rejects malformed known attachment types instead of tolerating them", () => Effect.gen(function* () { const base = { diff --git a/packages/contracts/src/chatAttachment.ts b/packages/contracts/src/chatAttachment.ts index 48769c1a4728..56e555b066be 100644 --- a/packages/contracts/src/chatAttachment.ts +++ b/packages/contracts/src/chatAttachment.ts @@ -25,7 +25,6 @@ const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET = new Set( PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, ); -/** Whether a pasted or picked image mime type can be sent on a provider turn. */ export function isProviderSendTurnSupportedImageMimeType(mimeType: string): boolean { return PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET.has(mimeType.toLowerCase()); } @@ -168,7 +167,7 @@ export const ChatFileAttachment = Schema.Struct({ Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_FILE_BYTES), ), - /** Clipboard text folded by a client. Providers keep these path-only so the + /** Providers keep these path-only so the agent can inspect the file selectively instead of eagerly spending the same context the fold is intended to preserve. */ source: Schema.optional(PastedTextAttachmentSource), @@ -176,14 +175,10 @@ export const ChatFileAttachment = Schema.Struct({ export type ChatFileAttachment = typeof ChatFileAttachment.Type; /** - * Catch-all for attachment types this build does not know. Attachments ride on - * persisted events and thread streams, so a newer server or client must be able - * to introduce a type without making older readers fail to decode the whole - * message. Decoders keep the shared base fields; consumers skip these or render - * them as unsupported. Mirrors how `OrchestrationThreadActivity` keeps `kind` - * open. The known discriminators are excluded so a malformed image or file - * attachment fails its own schema instead of sliding through here with its - * size and mime constraints unchecked. + * Attachments ride on persisted events and thread streams, so a newer server or client must be + * able to introduce a type without making older readers fail to decode the whole message. + * The known discriminators are excluded so a malformed image or file attachment fails its own + * schema instead of sliding through here with its size and mime constraints unchecked. */ export const ChatUnknownAttachment = Schema.Struct({ type: TrimmedNonEmptyString.check( diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index e85e5df7769c..e111eb244c5b 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -12,7 +12,6 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, - // Endpoint checks exist to configure exports, so they need the same grant as saving them. [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/composerContext.test.ts b/packages/contracts/src/composerContext.test.ts index b43e59c2840b..147d1c6f37e6 100644 --- a/packages/contracts/src/composerContext.test.ts +++ b/packages/contracts/src/composerContext.test.ts @@ -223,7 +223,6 @@ describe("OrchestrationMessageContext", () => { payload: { count: Number.NaN }, }, { ...knownRecords["review-comment"], contextId: "ctx_3", fenceLanguage: undefined }, - // JSON.stringify throws on a bigint on every engine. { ...base, contextId: "ctx_4", kind: "future-kind", label: "y", payload: { n: 1n } }, ], }); diff --git a/packages/contracts/src/composerContext.ts b/packages/contracts/src/composerContext.ts index 116c141c9dfb..0f6e9cce329e 100644 --- a/packages/contracts/src/composerContext.ts +++ b/packages/contracts/src/composerContext.ts @@ -10,12 +10,7 @@ import { } from "./baseSchemas.ts"; /** - * Inline context records: the typed payload behind every composer chip. - * A message's `text` carries position through canonical reference links - * (`[label](t3-context://v1//)`, see - * `@t3tools/shared/composerContextReferences`); these records carry the payload, - * keyed by `contextId`. Bytes never live here: image and file records bind to a - * `ChatAttachment` by id. + * Bytes never live here: image and file records bind to a `ChatAttachment` by id. */ export const COMPOSER_CONTEXT_KINDS = [ @@ -40,27 +35,22 @@ export const COMPOSER_CONTEXT_LABEL_MAX_CHARS = 200; export const COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS = 64_000; const COMPOSER_CONTEXT_ELEMENT_HTML_MAX_CHARS = 8_000; const COMPOSER_CONTEXT_ELEMENT_STYLES_MAX_CHARS = 8_000; -/** Exported so producers can clamp to the same boundary the schema enforces, rather than - minting a record the send path would fail to encode. */ export const COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS = 16_000; export const COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS = 32_000; const COMPOSER_CONTEXT_PREVIEW_COMMENT_MAX_CHARS = 8_000; -/** Durable identity of one payload. Shared by every chip that points at it. */ export const ComposerContextId = TrimmedNonEmptyString.check( Schema.isMaxLength(CONTEXT_ID_MAX_CHARS), Schema.isPattern(CONTEXT_ID_PATTERN), ).pipe(Schema.brand("ComposerContextId")); export type ComposerContextId = typeof ComposerContextId.Type; -/** Identity of one occurrence in a document. Changes when a chip is duplicated. */ export const ComposerContextReferenceId = TrimmedNonEmptyString.check( Schema.isMaxLength(CONTEXT_ID_MAX_CHARS), Schema.isPattern(CONTEXT_ID_PATTERN), ).pipe(Schema.brand("ComposerContextReferenceId")); export type ComposerContextReferenceId = typeof ComposerContextReferenceId.Type; -/** Open kind: known kinds get typed records, everything else preserves its payload. */ export const ComposerContextKind = TrimmedNonEmptyString.check( Schema.isPattern(CONTEXT_KIND_PATTERN), ); @@ -77,7 +67,6 @@ const BoundedString = (max: number) => Schema.String.check(Schema.isMaxLength(ma const ShortString = BoundedString(2_048); const NullableShortString = Schema.NullOr(ShortString); -/** Snapshot used to identify and present a pull request carried by a review-context record. */ export const PullRequestContextMetadata = Schema.Struct({ number: PositiveInt, title: ShortString, @@ -135,7 +124,6 @@ export const ElementContextSource = Schema.Struct({ }); export type ElementContextSource = typeof ElementContextSource.Type; -/** What a picked page element looks like to the agent; shared by element and annotation records. */ export const ElementContextDetails = Schema.Struct({ pageUrl: ShortString, pageTitle: NullableShortString, @@ -167,12 +155,8 @@ export const PreviewAnnotationContextRecord = Schema.Struct({ comment: BoundedString(COMPOSER_CONTEXT_PREVIEW_COMMENT_MAX_CHARS), targetSummary: ShortString, styleChanges: Schema.Array(ShortString).check(Schema.isMaxLength(200)), - /** Picked elements inside the annotation, with the detail the agent needs to find them. */ elements: Schema.optionalKey(Schema.Array(ElementContextDetails).check(Schema.isMaxLength(50))), - /** Original target ids and edits allow pasted annotations to retain exact style changes. */ elementIds: Schema.optionalKey(Schema.Array(ShortString).check(Schema.isMaxLength(50))), - /** Region and stroke geometry is lossy on purpose, but their counts feed the target summary, - so a pasted annotation still says what it marked. */ regionCount: Schema.optionalKey(NonNegativeInt), strokeCount: Schema.optionalKey(NonNegativeInt), styleChangeDetails: Schema.optionalKey( @@ -186,7 +170,6 @@ export const PreviewAnnotationContextRecord = Schema.Struct({ }), ).check(Schema.isMaxLength(200)), ), - /** The screenshot travels as its own image record; this links the two. */ screenshotContextId: Schema.optionalKey(ComposerContextId), }); export type PreviewAnnotationContextRecord = typeof PreviewAnnotationContextRecord.Type; @@ -221,10 +204,6 @@ export const SkillContextRecord = Schema.Struct({ }); export type SkillContextRecord = typeof SkillContextRecord.Type; -/** - * Another thread on the same server, attached so the agent can read its history through - * `t3_thread_read`. Only identity travels; the title is a display snapshot. - */ export const ThreadContextRecord = Schema.Struct({ ...recordBase, kind: Schema.Literal("thread"), @@ -237,9 +216,8 @@ export type ThreadContextRecord = typeof ThreadContextRecord.Type; const isJson = Schema.is(Schema.Json); /** - * Catch-all for kinds this build does not know. Known discriminators are excluded so a - * malformed known record fails its own schema instead of sliding through unchecked. - * Mirrors `ChatUnknownAttachment`. + * Known discriminators are excluded so a malformed known record fails its own schema instead of + * sliding through unchecked. */ export const UnknownContextRecord = Schema.Struct({ ...recordBase, @@ -250,11 +228,8 @@ export const UnknownContextRecord = Schema.Struct({ try { encoded = JSON.stringify(payload); } catch { - // Cycles, bigints, and payloads nested deeper than this engine's stack. return false; } - // Only JSON values, so a payload the wire cannot carry (a Date, NaN, an - // undefined field) fails this record alone instead of the whole message. return encoded !== undefined && encoded.length <= 64_000 && isJson(payload); }), ), diff --git a/packages/contracts/src/composerContextClipboard.ts b/packages/contracts/src/composerContextClipboard.ts index 34c5fbfeb13f..2b9c8babdb25 100644 --- a/packages/contracts/src/composerContextClipboard.ts +++ b/packages/contracts/src/composerContextClipboard.ts @@ -4,11 +4,6 @@ import { EnvironmentId, MessageId, ThreadId } from "./baseSchemas.ts"; import { COMPOSER_CONTEXT_MAX_RECORDS, ComposerContextRecord } from "./composerContext.ts"; import { ForwardCompatibleArray } from "./baseSchemas.ts"; -/** - * Structured clipboard payload for context copied between drafts and messages. Carries the - * records behind the copied links plus where they came from, never bytes or URLs; the receiver - * resolves binaries through its own environment. - */ export const COMPOSER_CONTEXT_CLIPBOARD_MIME = "web application/x-t3-context-fragment+json"; export const ComposerContextClipboardFragment = Schema.Struct({ diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index 8a3f379df329..bc0a723b57b0 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -31,9 +31,7 @@ export const DesktopBackendBootstrap = Schema.Struct({ otlpLogsUrl: Schema.optional(Schema.String), desktopTelemetryFd: Schema.optionalKey(PositiveInt), desktopTelemetryControlFd: Schema.optionalKey(PositiveInt), - /** Desktop -> server: the desktop's browser tabs, as newline-delimited JSON. */ desktopBrowserFd: Schema.optionalKey(PositiveInt), - /** Server -> desktop: commands for those tabs. */ desktopBrowserControlFd: Schema.optionalKey(PositiveInt), resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString), }); diff --git a/packages/contracts/src/desktopBrowser.ts b/packages/contracts/src/desktopBrowser.ts index caac5f473de4..c70b37491007 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -2,39 +2,22 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; -/** - * The desktop app renders browser tabs for the server it launched, and the - * server drives them with the same engine as its headless tabs. Messages - * travel as newline-delimited JSON over two bootstrap file descriptors, so - * only that one server can reach the desktop's pages; nothing listens on a port. - * - * Each tab carries one CDP connection, multiplexed by `tabId`. CDP frames pass - * through untouched; the desktop answers them with `CdpRelay`. - */ - const TabKey = { threadId: TrimmedNonEmptyString, tabId: TrimmedNonEmptyString, }; -/** Desktop -> server. */ export const DesktopBrowserEvent = Schema.Union([ - /** A desktop `` for this server tab is attached and can be driven. */ Schema.Struct({ type: Schema.Literal("attached"), ...TabKey }), /** Its `` went away: closed, crashed, swapped, or devtools took the debugger. */ Schema.Struct({ type: Schema.Literal("detached"), ...TabKey }), - /** One CDP message from the tab's relay. */ Schema.Struct({ type: Schema.Literal("cdp"), ...TabKey, message: Schema.String }), ]); export type DesktopBrowserEvent = typeof DesktopBrowserEvent.Type; -/** Server -> desktop. */ export const DesktopBrowserCommand = Schema.Union([ - /** One CDP message for the tab's relay. */ Schema.Struct({ type: Schema.Literal("cdp"), ...TabKey, message: Schema.String }), - /** The server stopped driving this tab, so the relay can drop its sessions. */ Schema.Struct({ type: Schema.Literal("release"), ...TabKey }), - /** Where an agent action is about to land, so the desktop draws its cursor there. */ Schema.Struct({ type: Schema.Literal("pointer"), ...TabKey, diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 63859cfd3619..93c41663fa75 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -1,17 +1,3 @@ -/** - * Device - Schemas for first-class iOS Simulator and Android Emulator support. - * - * The server owns device discovery, the streaming helper (expo-device-hub), - * and the agent driver (agent-device). Clients render the live screen from the - * server-proxied stream, and agents reach devices through the `device_*` MCP - * tools plus the `agent-device` CLI the server preconfigures for them. - * - * Devices live on a *host*. Only the local host (the machine the server runs - * on) exists today; the host id is carried everywhere so SSH and cloud hosts - * can be added without changing the client contract. - * - * @module Device - */ import { Schema } from "effect"; import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; @@ -22,10 +8,8 @@ export type DevicePlatform = typeof DevicePlatform.Type; export const DeviceHostId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); export type DeviceHostId = typeof DeviceHostId.Type; -/** The server machine. Always present; other host kinds are future work. */ export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId; -/** SSH aliases and key paths are resolved on the environment server. */ export const SshDeviceHostConfig = Schema.Struct({ id: DeviceHostId.check( Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), @@ -46,7 +30,6 @@ export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check( ), ); -/** Simulator udid or adb serial (an AVD name while it is not running). */ export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); export type DeviceId = typeof DeviceId.Type; @@ -55,18 +38,12 @@ export const DeviceSummary = Schema.Struct({ id: DeviceId, platform: DevicePlatform, name: TrimmedNonEmptyString, - /** OS label such as "iOS 18.0" or "Android 15.0". */ version: Schema.String, booted: Schema.Boolean, physical: Schema.Boolean, }); export type DeviceSummary = typeof DeviceSummary.Type; -/** - * What the host can do right now. Platforms missing their toolchain are - * reported rather than hidden so the picker and the agent can explain why a - * platform is absent instead of showing an empty list. - */ export const DevicePlatformAvailability = Schema.Struct({ platform: DevicePlatform, available: Schema.Boolean, @@ -99,11 +76,6 @@ export const DeviceHostSummary = Schema.Struct({ }); export type DeviceHostSummary = typeof DeviceHostSummary.Type; -/** - * Lifecycle of the helper processes on a host. Tools are installed on first - * use, so a fresh install spends a while in `installing` before any device can - * stream; the UI shows that instead of an empty picker. - */ export const DeviceHostStatus = Schema.Literals([ "disabled", "idle", @@ -114,10 +86,6 @@ export const DeviceHostStatus = Schema.Literals([ ]); export type DeviceHostStatus = typeof DeviceHostStatus.Type; -/** - * A device a thread is looking at. One session per (thread, device); the same - * device may be open in several threads, since the stream is shared. - */ export const DeviceSession = Schema.Struct({ threadId: ThreadId, hostId: DeviceHostId, @@ -148,18 +116,14 @@ export const DeviceServiceState = Schema.Struct({ ), onboardingCompleted: Schema.Boolean, agentAccessEnabled: Schema.Boolean, - /** Origin-relative path the client prefixes to hub routes. */ hubBasePath: Schema.String, revision: Schema.Int, }); export type DeviceServiceState = typeof DeviceServiceState.Type; export const DeviceListInput = Schema.Struct({ - /** Install this server's pinned tool without enabling access or starting helpers. */ updateTool: Schema.optional(Schema.Literals(["hub", "agent"])), - /** Read inventory without installing tools or starting helpers. */ inspectOnly: Schema.optional(Schema.Boolean), - /** Retry this host only, including agent tools if access was already granted. */ retryHostId: Schema.optional(DeviceHostId), }); export type DeviceListInput = typeof DeviceListInput.Type; @@ -198,18 +162,9 @@ export const DeviceShutdownInput = Schema.Struct({ }); export type DeviceShutdownInput = typeof DeviceShutdownInput.Type; -// Device settings and actions. Each setting names the platforms that support -// it; the panel hides the rest. Values are normalized across platforms where -// both have the concept (appearance, text size) and platform-specific where -// only one does. - export const DeviceAppearance = Schema.Literals(["light", "dark"]); export type DeviceAppearance = typeof DeviceAppearance.Type; -/** - * iOS content-size categories map onto twelve steps; Android `font_scale` - * is continuous. Four shared steps cover what people actually reach for. - */ export const DeviceTextSize = Schema.Literals(["small", "default", "large", "extra-large"]); export type DeviceTextSize = typeof DeviceTextSize.Type; @@ -230,7 +185,6 @@ export const DeviceOrientation = Schema.Literals([ ]); export type DeviceOrientation = typeof DeviceOrientation.Type; -/** Current values as read from the device; `undefined` means unsupported or unread. */ export const DeviceSettings = Schema.Struct({ appearance: Schema.optional(DeviceAppearance), textSize: Schema.optional(DeviceTextSize), @@ -248,7 +202,6 @@ export const DeviceSettings = Schema.Struct({ }); export type DeviceSettings = typeof DeviceSettings.Type; -/** The app in the foreground, when the platform can tell us. */ export const DeviceForegroundApp = Schema.Struct({ id: Schema.String, name: Schema.optional(Schema.String), @@ -350,7 +303,6 @@ export const DeviceActionInput = Schema.Union([ ...DeviceTarget, type: Schema.Literal("sendPush"), appId: TrimmedNonEmptyString, - /** APNs-style payload; a bare string becomes the alert body. */ payload: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Unknown)]), }), ]); @@ -467,14 +419,10 @@ export const DeviceError = Schema.Union([ ]); export type DeviceError = typeof DeviceError.Type; -// MCP tool shapes. Kept next to the RPC shapes so the tool surface and the -// panel describe devices the same way. - export const DeviceToolListResult = Schema.Struct({ hostStatuses: DeviceServiceState.fields.hostStatuses, hosts: Schema.Array(DeviceHostSummary), devices: Schema.Array(DeviceSummary), - /** Devices already open in this thread's Device panel. */ open: Schema.Array(Schema.Struct({ hostId: DeviceHostId, deviceId: DeviceId })), }); export type DeviceToolListResult = typeof DeviceToolListResult.Type; @@ -502,10 +450,8 @@ export type DeviceToolOpenInput = typeof DeviceToolOpenInput.Type; export const DeviceToolOpenResult = Schema.Struct({ device: DeviceSummary, - /** Ready-to-run agent-device invocation pinned to this device. */ agentDevice: Schema.Struct({ command: Schema.String, - /** Flags that pin every command to this device, e.g. `--udid `. */ targetArgs: Schema.Array(Schema.String), }), quickStart: Schema.String, diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 7fa324a425ad..f40d1823b9ad 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -10,18 +10,7 @@ type EditorDefinition = { readonly commands: readonly [string, ...string[]] | null; readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; - /** - * URL scheme for editors that support VS Code's remote deep links - * (`://vscode-remote/ssh-remote+`). Only set for VS Code - * and forks that ship the Remote-SSH machinery, plus Zed, which uses its own - * `zed://ssh/` shape. - */ readonly remoteScheme?: string; - /** - * JetBrains product code (`IU`, `PY`, ...). JetBrains IDEs open remote - * projects through the Toolbox App's `jetbrains://gateway/ssh/...` link, - * which uses the code to choose the IDE backend. - */ readonly jetbrainsProductCode?: string; }; @@ -179,7 +168,6 @@ export type LaunchEditorInput = typeof LaunchEditorInput.Type; const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme ?? (editor.jetbrainsProductCode === undefined ? undefined : "jetbrains"); -/** Editors that can open a remote workspace via an SSH deep link. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => remoteSchemeOf(editor) !== undefined ? [editor.id] : [], ); @@ -189,13 +177,6 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { return editor === undefined ? undefined : remoteSchemeOf(editor); }; -/** - * Builds a `://vscode-remote/ssh-remote+` deep link (Zed - * takes `zed://ssh/`, JetBrains IDEs a Toolbox App - * `jetbrains://gateway/ssh/environment?...` link) that opens `absolutePath` on - * `host` in the local editor over SSH. Returns undefined for editors without - * remote deep-link support. - */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; readonly host: string; @@ -207,8 +188,6 @@ export const buildRemoteOpenUrl = (input: { } const editor = EDITORS.find((candidate) => candidate.id === input.editor); if (editor !== undefined && "jetbrainsProductCode" in editor) { - // Like the VS Code link, no user or port: the SSH config entry for `host` - // supplies them. A bare product code lets Toolbox pick the backend build. const params = new URLSearchParams({ h: input.host, launchIde: "true", @@ -234,12 +213,6 @@ export const buildRemoteOpenUrl = (input: { return `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; }; -/** - * SSH hostnames an environment advertises for remote open links. Reachability - * is client-side; the server only advertises names that resolve to itself and - * gates them on a local sshd listen check. Ordered most-reachable first - * (tailnet MagicDNS name, then mDNS `.local`). - */ export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; @@ -283,7 +256,6 @@ export class ExternalLauncherCommandNotFoundError extends Schema.TaggedError()( "ExternalLauncherUnsupportedTargetError", { diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index cc2e31fadaef..b78cf0f99971 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -9,7 +9,6 @@ import { TrimmedNonEmptyString, } from "./baseSchemas.ts"; -/** Wire version for orchestration snapshots, streams, commands, and RPC payloads. */ export const ORCHESTRATION_PROTOCOL_VERSION = 2; export const ORCHESTRATION_PROTOCOL_VERSION_TEXT = "2"; export const ORCHESTRATION_PROTOCOL_QUERY_PARAM = "orchestrationProtocol"; @@ -26,11 +25,6 @@ export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatform export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]); export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; -/** - * The curated set of machine shapes and OS identities an environment can wear as its icon. - * Servers detect one from the hardware they run on (`platform.machine`), and - * the `environmentIcon` server setting lets a user pick one instead. - */ export const ENVIRONMENT_MACHINE_KINDS = [ "server", "cloud", @@ -47,47 +41,29 @@ export const isEnvironmentMachineKind = Schema.is(EnvironmentMachineKind); export const ExecutionEnvironmentPlatform = Schema.Struct({ os: ExecutionEnvironmentPlatformOs, arch: ExecutionEnvironmentPlatformArch, - /** Hardware shape detected at startup. Absent when the host gives no usable - signal (containers, Windows, unknown DMI), on servers that predate it, or - when a newer server names a kind this build cannot draw. */ machine: ForwardCompatibleOptional(EnvironmentMachineKind), }); /** - * Where a new thread runs: the project's current checkout ("local") or a - * fresh git worktree ("worktree"). Lives here (not settings.ts) so - * orchestration contracts can reference it without an import cycle. + * Lives here (not settings.ts) so orchestration contracts can reference it + * without an import cycle. */ export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; -/** - * How a new worktree populates git submodules: every nested level, only the - * ones this repository declares, or not at all. - */ export const WorktreeSubmodules = Schema.Literals(["recursive", "top-level", "none"]); export type WorktreeSubmodules = typeof WorktreeSubmodules.Type; export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; -/** How a server can replace itself with another version when asked over RPC. - New servers only advertise the stable launcher-backed "boot-service" path; - "respawn" remains decodable for compatibility with older servers. - "desktop-app" means the supervising desktop app updated and relaunched - itself, bringing the server back with it. */ export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]); export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; -/** Proven ownership for a manual update; unknown installs omit this descriptor. */ export const ServerInstallation = Schema.Union([ Schema.Struct({ kind: Schema.Literals(["npx", "pnpm-dlx", "bunx"]) }), Schema.Struct({ kind: Schema.Literal("npm-global"), prefix: TrimmedNonEmptyString }), ]); export type ServerInstallation = typeof ServerInstallation.Type; -/** What update path a client should offer for a server: one of the RPC - self-update methods above, or "desktop-managed" when the backend's - version belongs to the T3 Code desktop app supervising it β€” updating the - app on that machine is the only way to update the server. */ export const ServerSelfUpdateCapability = Schema.Literals([ "boot-service", "respawn", @@ -98,31 +74,20 @@ export type ServerSelfUpdateCapability = typeof ServerSelfUpdateCapability.Type; export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), - /** Missing on older servers, which still accept inline image attachments. */ attachmentUploads: Schema.optionalKey(Schema.Boolean), - /** Uploaded files may accompany question answers. */ questionAttachments: Schema.optionalKey(Schema.Boolean), - /** Missing on servers that only accept image attachments. */ fileAttachments: Schema.optionalKey( Schema.Struct({ maxUploadBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), }), ), - /** Server exposes the pull-request list, detail, activity, diff, and mutation APIs. Absent on - servers from before the pull-request workspace shipped, so clients must not probe them. */ pullRequests: Schema.optionalKey(Schema.Boolean), pullRequestChecks: Schema.optionalKey(Schema.Boolean), - /** Server understands canonical inline context links plus their message context records. - Absent on servers from before inline context shipped, which drop the records and forward + /** Absent on servers from before inline context shipped, which drop the records and forward the links as literal text -- so a client must serialize context the legacy way for them. */ inlineMessageContext: Schema.optionalKey(Schema.Boolean), - /** Server rejects required worktrees instead of falling back to the project checkout. */ requiredWorktreeBootstrap: Schema.optionalKey(Schema.Boolean), - /** Server understands thread.settle / thread.unsettle commands. Absent on - pre-settlement servers, so clients treat missing as unsupported and - never send the commands under version skew. */ threadSettlement: Schema.optionalKey(Schema.Boolean), - /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), storageCleanupRun: Schema.optionalKey(Schema.Boolean), @@ -132,94 +97,50 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ otlpEndpointCheck: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), settledWorktreeCleanup: Schema.optionalKey(Schema.Boolean), - /** Server honors the `worktreesDirectory` setting. */ worktreesDirectory: Schema.optionalKey(Schema.Boolean), - /** Server can exclude PR-linked threads from inactivity settlement. */ threadAutoSettlementScope: Schema.optionalKey(Schema.Boolean), - /** Server persists the opt-in for continuing interrupted threads after restarts. */ threadRestartContinuation: Schema.optionalKey(Schema.Boolean), - /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */ projectSettingsOverrides: Schema.optionalKey(Schema.Boolean), - /** Server resolves a project's `providerInstances` override entry. Older + /** + * Older servers decode the key away (unknown fields are stripped, not rejected), so the per-project provider switch would silently do - nothing; clients hide it instead of sending a patch that goes nowhere. */ + nothing; clients hide it instead of sending a patch that goes nowhere. + */ projectProviderInstanceOverrides: Schema.optionalKey(Schema.Boolean), - /** Server understands thread.snooze / thread.unsnooze commands. Same - version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), - /** Server streams themes an environment publishes. Absent on servers from + /** Absent on servers from before environment themes shipped, which never emit the events -- so a client reconnecting to one must drop published themes rather than keep showing a set nothing will ever update. */ environmentThemes: Schema.optionalKey(Schema.Boolean), - /** Server streams quota from configured usage-limit sources. Same - version-skew contract as environmentThemes. */ usageLimitSources: Schema.optionalKey(Schema.Boolean), - /** Server persists custom model rates and applies them to usage summaries. */ usagePriceOverrides: Schema.optionalKey(Schema.Boolean), - /** Server persists model mappings and folds mapped usage into the target model. */ usageModelAliases: Schema.optionalKey(Schema.Boolean), - /** Server understands thread.pin / thread.unpin commands. Same - version-skew contract as threadSettlement. */ threadPinning: Schema.optionalKey(Schema.Boolean), - /** Server understands thread.pin.reorder (and orderKey on thread.pin). - Same version-skew contract as threadSettlement. */ threadPinReorder: Schema.optionalKey(Schema.Boolean), - /** Server persists manual Active order through thread.active.reorder. */ threadActiveReorder: Schema.optionalKey(Schema.Boolean), - /** Server understands thread.auto-settle.set (per-thread auto-settle off). - Same version-skew contract as threadSettlement. */ threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean), - /** Server understands regenerateTitle on thread.meta.update. Absent on - older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), - /** Server understands thread.visit / thread.mark-unread commands and - projects lastVisitedAt on thread shells. Same version-skew contract as - threadSettlement: clients keep their local visited state against - servers that lack this. */ threadVisitedTracking: Schema.optionalKey(Schema.Boolean), - /** Server persists a pull request reference on thread.meta.update. */ threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), - /** Server resolves message delivery and model-selection context and validates - identified rollback readiness. Clients retain projection-based command - shaping and validation when this is absent. */ serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), threadPullRequests: Schema.optionalKey(Schema.Boolean), - /** Server understands thread.pull-request.watch and wakes agents on pull request changes. */ threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), - /** The update path clients should offer for this server. Absent on - servers that must be relaunched manually (dev checkouts, Windows - foreground runs, pre-update servers). */ serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability), /** Manual commands must update this install, not the host's default global prefix. */ serverInstallation: ForwardCompatibleOptional(ServerInstallation), - /** Server can stream self-update progress before acknowledging the - restart. Clients fall back to server.updateServer when absent. */ serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), - /** Server can durably mark running provider turns before a self-update and - continue them after the replacement process starts. */ serverUpdateThreadContinuation: Schema.optionalKey(Schema.Boolean), - /** Agent-activity publishes (push notifications and Live Activities) - currently leave this environment: the publish opt-in is enabled and the - relay link credentials exist. Clients skip seeding a Live Activity when + /** + * Clients skip seeding a Live Activity when this is false β€” no update would ever repaint it. Absent on older - servers, which may still publish, so only an explicit false skips. */ + servers, which may still publish, so only an explicit false skips. + */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), - /** Server runs repository clones for new projects in the background and - streams their progress (`projectClone.*`, `subscribeProjectClones`). - Absent on older servers, where clients must clone with the blocking - `sourceControl.cloneRepository` call instead. */ projectCloneTracking: Schema.optionalKey(Schema.Boolean), - /** Server detects `platform.machine` and persists the `environmentIcon` - setting. Older servers drop the key on write, so clients show the - picker inert rather than offering a choice that would never stick. */ environmentIcon: Schema.optionalKey(Schema.Boolean), - /** The desktop app supervising this server can be driven over RPC: - server.updateServer runs its check -> download -> relaunch. Absent on - desktop servers whose app predates the remote trigger, where clients - must keep telling the user to update the app on that machine. */ desktopAppUpdate: Schema.optionalKey(Schema.Boolean), /** Server answers orchestration.subscribeCommandOutput. Absent on older servers, which never send command output, so clients must not subscribe. */ @@ -236,7 +157,6 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({ label: TrimmedNonEmptyString, platform: ExecutionEnvironmentPlatform, serverVersion: TrimmedNonEmptyString, - /** Absent on hosts from before explicit orchestration protocol negotiation. */ orchestrationProtocolVersion: Schema.optionalKey(Schema.Int), capabilities: ExecutionEnvironmentCapabilities, }); @@ -249,11 +169,6 @@ export const RepositoryIdentityLocator = Schema.Struct({ }); export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; -/** - * The checkout's own remote when it names a different repository than the canonical one, such as - * a fork that tracks its upstream. Clients group and label by it so a fork stays distinct from the - * repository it forked, while pull request features keep the canonical identity. - */ export const RepositoryOrigin = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, displayName: Schema.optionalKey(TrimmedNonEmptyString), @@ -263,7 +178,6 @@ export type RepositoryOrigin = typeof RepositoryOrigin.Type; export const RepositoryIdentity = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, locator: RepositoryIdentityLocator, - /** Repository browser URL resolved from the server's configured hosting account. */ webUrl: Schema.optionalKey(TrimmedNonEmptyString), rootPath: Schema.optionalKey(TrimmedNonEmptyString), displayName: Schema.optionalKey(TrimmedNonEmptyString), @@ -274,12 +188,10 @@ export const RepositoryIdentity = Schema.Struct({ }); export type RepositoryIdentity = typeof RepositoryIdentity.Type; -/** Key clients group checkouts by: a fork's own remote, otherwise the canonical repository. */ export function repositoryGroupingKeyOf(identity: RepositoryIdentity): string { return identity.origin?.canonicalKey ?? identity.canonicalKey; } -/** Label clients show for a checkout's repository, matching `repositoryGroupingKeyOf`. */ export function repositoryGroupingDisplayNameOf(identity: RepositoryIdentity): string | undefined { return identity.origin ? (identity.origin.displayName ?? identity.origin.canonicalKey) diff --git a/packages/contracts/src/environmentHttp.test.ts b/packages/contracts/src/environmentHttp.test.ts index 4cd39074f3ea..08244ee635ea 100644 --- a/packages/contracts/src/environmentHttp.test.ts +++ b/packages/contracts/src/environmentHttp.test.ts @@ -12,8 +12,6 @@ import { const traceId = "trace-1"; describe("environment HTTP errors", () => { - // A client squashes the cause and shows `message`; an empty one becomes a generic - // "The environment request failed." that names nothing the reader can act on. it("each carries a message that names its reason", () => { const errors = [ new EnvironmentRequestInvalidError({ diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index fc07bb48505f..31b69997f43c 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -152,7 +152,6 @@ export class EnvironmentAuthInvalidError extends Schema.TaggedError { label: string; destructive?: boolean; disabled?: boolean; - /** Renders as a non-interactive section header label. Web fallback only β€” stripped on desktop native menus. */ header?: boolean; - /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ icon?: string; - /** Inserts a visual section divider immediately before this item. */ separatorBefore?: boolean; - /** Shows a check mark. Used to mark the current option inside a submenu. */ checked?: boolean; children?: readonly ContextMenuItem[]; } @@ -214,7 +210,7 @@ export const DesktopSnapShotId = TrimmedNonEmptyString.check( ); export type DesktopSnapShotId = typeof DesktopSnapShotId.Type; -/** Main-process capture lifecycle pushes. `id` is absent for failures before a capture exists. */ +/** `id` is absent for failures before a capture exists. */ export const DesktopSnapShotEvent = Schema.Union([ Schema.Struct({ type: Schema.Literal("requested"), id: DesktopSnapShotId }), Schema.Struct({ type: Schema.Literal("started"), id: DesktopSnapShotId }), @@ -284,11 +280,6 @@ export interface DesktopUpdateState { message: string | null; errorContext: "check" | "download" | "install" | null; canRetry: boolean; - /** - * The version the last install handed to the OS installer that did not - * apply: the app came back on its old version. Server-triggered updates do - * not install it again; the user can retry it from the update button. - */ notAppliedVersion?: string; } @@ -324,18 +315,10 @@ export const DesktopUpdateStateSchema = Schema.Struct({ notAppliedVersion: Schema.optionalKey(Schema.String), }); -/** The desktop app's `t3` command on PATH, managed from Settings. */ export const DesktopCliCommandStateSchema = Schema.Struct({ - /** Only installed builds have a launcher to put on PATH. */ supported: Schema.Boolean, - /** The `t3` the app installed, or null when it is not installed. */ installedPath: Schema.NullOr(Schema.String), - /** Whether a new terminal finds it; false when the folder is not on PATH yet. */ onPath: Schema.Boolean, - /** - * Another `t3` a new terminal runs instead, earlier on PATH. Install refuses - * while it is there, since a link behind it would never run. - */ shadowedBy: Schema.optionalKey(Schema.String), }); export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type; @@ -362,16 +345,11 @@ export const DesktopUpdateCheckResultSchema = Schema.Struct({ state: DesktopUpdateStateSchema, }); -// Stable id for the Windows-native primary backend. Desktop side wraps -// this with a brand inside DesktopBackendManager; web side keeps it as -// a plain string so the env-runtime can compare against it without -// importing brand machinery from the desktop package. export const PRIMARY_LOCAL_ENVIRONMENT_ID = "primary"; export interface DesktopEnvironmentBootstrap { - // Stable backend instance id (e.g. "primary" or "wsl:ubuntu"). The - // web env runtime keys local environments off this so projects - // routed to a specific backend reopen against the same one. + // The web env runtime keys local environments off this so projects routed to + // a specific backend reopen against the same one. id: string; label: string; // Concrete WSL distro used by the current backend run. This stays separate @@ -522,11 +500,6 @@ export const DesktopServerExposureStateSchema = Schema.Struct({ export interface PickFolderOptions { initialPath?: string | null; - // When set, the desktop dialog opens against the named backend's - // filesystem instead of the primary's. Used by callers that already - // know which local environment they're targeting (e.g. opening a - // project that lives inside WSL). Omitting it keeps the historical - // behavior so non-WSL users never see a different picker. targetEnvironmentId?: string; } @@ -536,9 +509,8 @@ export const PickFolderOptionsSchema = Schema.Struct({ }); /** - * A file returned by the desktop theme-file picker. Oversized files carry an - * empty text so the renderer can reject them by size without the main - * process ever holding their contents. + * Oversized files carry an empty text so the renderer can reject them by size + * without the main process ever holding their contents. */ export interface PickedThemeFile { name: string; @@ -565,23 +537,14 @@ export const DesktopWslDistroSchema = Schema.Struct({ }); export interface DesktopWslState { - // True when the user has opted the WSL backend in; the actual backend - // process is registered with the desktop pool independently of this - // flag and may take a moment to come up after the user enables it. enabled: boolean; // null means "track the current WSL default distro". distro: string | null; available: boolean; - // When true (and `enabled` is also true) the desktop runs only the - // WSL backend as the primary; the Windows-side Node backend is not - // started. Toggling this requires an app restart because the - // primary backend's spec is captured once at layer init. wslOnly: boolean; distros: readonly DesktopWslDistro[]; - // Reason the dual-mode WSL backend last failed preflight (no node, wrong - // version, missing build tools), or null. Surfaced inline in Connections - // settings. Always null in wsl-only mode β€” that path shows a dialog and - // falls back to Windows instead. + // Always null in wsl-only mode β€” that path shows a dialog and falls back to + // Windows instead. preflightError: string | null; } @@ -594,10 +557,6 @@ export const DesktopWslStateSchema = Schema.Struct({ preflightError: Schema.NullOr(Schema.String), }); -/** - * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process - * PreviewTabState shape but uses serialisable primitives only. - */ export type DesktopPreviewNavStatus = | { kind: "Idle" } | { kind: "Loading"; url: string; title: string } @@ -610,10 +569,6 @@ export type DesktopPreviewNavStatus = description: string; }; -/** - * Emulated `prefers-color-scheme` for the guest page. "system" clears the - * override so the page follows the OS appearance. - */ export type DesktopPreviewColorScheme = "system" | "light" | "dark"; export const DesktopPreviewColorSchemeSchema: Schema.Codec = @@ -634,16 +589,9 @@ export interface DesktopPreviewTabState { navStatus: DesktopPreviewNavStatus; canGoBack: boolean; canGoForward: boolean; - /** Current zoom factor (1.0 = 100%). */ zoomFactor: number; - /** Whether this tab is currently mirrored into a desktop picture-in-picture window. */ pictureInPicture: boolean; colorScheme: DesktopPreviewColorScheme; - /** - * Whether the user has silenced this tab. Per tab rather than per origin, so - * two tabs on the same site mute independently. Survives navigation and - * webview swaps, but is dropped when the tab closes. - */ audioMuted: boolean; /** * Whether the guest is currently emitting audio. Observed from Chromium, and @@ -670,15 +618,12 @@ export interface DesktopPreviewPointerEvent { createdAt: string; } -/** A `target="_blank"` link the previewed page asked to open beside itself. */ export interface DesktopPreviewOpenLinkEvent { tabId: string; url: string; - /** True for middle-click / Cmd-click, which should not take focus. */ background: boolean; } -/** Recording decorations are forwarded separately from the captured page pixels. */ export const DesktopPreviewRecordingInputSchema = Schema.Union([ Schema.Struct({ type: Schema.Literal("pointer"), @@ -702,19 +647,11 @@ export interface DesktopPreviewRecordingInputEvent { readonly input: DesktopPreviewRecordingInput; } -/** - * Static config a renderer needs to mount a preview ``. Returned - * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer - * doesn't have to wait on three separate IPC round-trips before the webview - * can attach. - */ export interface DesktopPreviewWebviewConfig { - /** `persist:t3code-preview` (or whatever the desktop chose). */ partition: string; /** - * Canonical `` string. Encodes the security - * posture (sandboxed but contextIsolation off so the picker preload can - * read the page's React DevTools hook). Always present. + * Encodes the security posture (sandboxed but contextIsolation off so the + * picker preload can read the page's React DevTools hook). */ webPreferences: string; /** @@ -819,11 +756,6 @@ export const DesktopPreviewScreenshotArtifactSchema: Schema.Codec` of that page (best-effort). */ pageTitle: string | null; - /** Lowercase tag name, e.g. `"button"`. */ tagName: string; - /** CSS selector resolving back to the element on a re-render. */ selector: string | null; - /** Truncated outer-HTML preview (matches react-grab's `htmlPreview`). */ htmlPreview: string; - /** Nearest React component display name, or null when unavailable. */ componentName: string | null; - /** First source-mapped stack frame (file + line of the JSX source). */ source: PickedElementStackFrame | null; - /** Full owner-stack frames; can be empty. Useful for richer context. */ stack: ReadonlyArray; - /** Author CSS only (UA defaults stripped) β€” react-grab's `styles`. */ styles: string; - /** Wall-clock pick time as ISO-8601 string. */ pickedAt: string; } @@ -977,11 +893,6 @@ export const PreviewAnnotationScreenshotSchema: Schema.Codec = @@ -1032,11 +942,10 @@ export const DesktopPreviewTabInputSchema = Schema.Struct({ }); /** - * Tab creation carries the client's configured browser defaults so the guest - * is born already zoomed and color-scheme-emulated. Applying them after - * creation instead would paint one frame at 100%/system first, which reads as - * a flash on every tab open. Both fields are optional so an older renderer - * still gets the historical defaults. + * Tab creation carries the client's configured browser defaults so the guest is + * born already zoomed and color-scheme-emulated. Applying them after creation + * instead would paint one frame at 100%/system first, which reads as a flash on + * every tab open. */ export const DesktopPreviewCreateTabInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, @@ -1050,7 +959,6 @@ export const DesktopPreviewCreateTabInputSchema = Schema.Struct({ export interface DesktopPreviewTabDefaults { readonly zoomFactor?: number | undefined; readonly colorScheme?: DesktopPreviewColorScheme | undefined; - /** A tab of the desktop's own server: the server drives it through the desktop browser channel. */ readonly serverTab?: { readonly threadId: string; readonly tabId: string } | undefined; } @@ -1067,10 +975,9 @@ export const DesktopPreviewNavigateInputSchema = Schema.Struct({ export const DesktopPreviewConfigInputSchema = Schema.Struct({ environmentId: EnvironmentId, /** - * Browser profile the partition is derived from. Derivation stays in main: - * `will-attach-webview` only prefix-checks the partition string, so a - * renderer-supplied partition could attach to a session that never had the - * UA rewrite or permission handlers installed. + * Derivation stays in main: `will-attach-webview` only prefix-checks the + * partition string, so a renderer-supplied partition could attach to a + * session that never had the UA rewrite or permission handlers installed. */ profileId: Schema.optional(BrowserProfileId), }); @@ -1115,18 +1022,12 @@ export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ data: Schema.Uint8Array, }); -/** - * A System Settings pane the app can deep-link to. The identifier crosses IPC - * rather than a URL, so the renderer can only reach these known destinations. - */ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; - /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */ getPathForFile?: (file: File) => string; - /** The desktop client's OS platform, read from Electron's preload process. */ getClientPlatform?: () => string; setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear?: (listener: () => void) => () => void; @@ -1138,9 +1039,6 @@ export interface DesktopBridge { * regardless of OS settings. */ getSystemLocale?: () => string | null; - // One bootstrap per pool instance currently registered with bootstrap - // info (omits instances whose backend hasn't produced a config yet). - // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; getLocalEnvironmentEnabled?: () => boolean; setLocalEnvironmentEnabled?: (enabled: boolean) => Promise; @@ -1151,7 +1049,6 @@ export interface DesktopBridge { setConnectionCatalog?: (catalog: string) => Promise; clearConnectionCatalog?: () => Promise; discoverSshHosts: () => Promise; - /** Resolves a suggested SSH alias before populating the connection form. */ resolveSshHost: (alias: string) => Promise; requestSnapShotPermissions?: (includeAccessibility: boolean) => Promise; getSnapShotState?: () => Promise; @@ -1202,41 +1099,24 @@ export interface DesktopBridge { pickFolder: (options?: PickFolderOptions) => Promise; /** Optional while older desktop shells can host a newer web client. */ pickProjectFavicon?: (initialPath?: string) => Promise; - /** - * Multi-select JSON file picker that opens in the VS Code extensions - * directory when one exists. Optional: older desktop builds lack it, and - * web callers fall back to a plain file input. - */ pickThemeFiles?: () => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, ) => Promise; - /** Receives a local OAuth code for a sign-in owned by a remote environment. */ receiveProviderAuthCallback?: (authorizationUrl: string) => Promise; cancelProviderAuthCallback?: (authorizationUrl: string) => Promise; openExternal: (url: string) => Promise; - /** - * Open a System Settings pane by identifier. Optional: older desktop builds - * lack it, and callers no-op when it is missing. - */ openSystemSettings?: (pane: SystemSettingsPane) => Promise; checkSystemPermission?: (pane: SystemSettingsPane) => Promise; /** - * Probe this desktop machine for installed remote-capable editor CLIs - * (used for remote open-in-editor deep links). Optional: older desktop - * builds lack it; callers fall back to VS Code only. + * Optional: older desktop builds lack it; callers fall back to VS Code only. */ probeRemoteEditors?: () => Promise; - /** Present when the desktop shell can perform an ordered plain-text paste. */ pasteAsText?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; onSnapShotEvent?: (listener: (event: DesktopSnapShotEvent) => void) => () => void; - /** - * Quit-confirmation hint pushes. Optional: older desktop builds never emit - * them. - */ onQuitShortcut?: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; @@ -1246,17 +1126,11 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; - /** Settings β†’ `t3` command. Optional: older desktop builds lack it. */ cliCommand?: { getState: () => Promise; install: () => Promise; uninstall: () => Promise; }; - /** Present when the desktop shell accepts `t3 app` activation requests. */ - /** - * Web links the OS opens with T3 Code as the default browser (macOS). The - * renderer reports when it listens; links queue in the main process until then. - */ webLinks?: { setReady: (ready: boolean) => Promise; onOpen: (listener: (url: string) => void) => () => void; @@ -1266,10 +1140,6 @@ export interface DesktopBridge { complete: (response: DesktopAppActivationResponse) => Promise; onRequest: (listener: (request: DesktopAppActivationRequest) => void) => () => void; }; - /** - * Desktop-only preview surface. Present iff the renderer is hosted by the - * Electron desktop build; web builds have `preview === undefined`. - */ preview?: DesktopPreviewBridge; } @@ -1288,38 +1158,17 @@ export interface DesktopPreviewBridge { zoomIn: (tabId: string) => Promise; zoomOut: (tabId: string) => Promise; resetZoom: (tabId: string) => Promise; - /** Sets a tab's zoom to the factor its environment published, for tabs the server drives. */ setZoomFactor: (tabId: string, zoomFactor: number) => Promise; - /** Reload bypassing the HTTP cache. */ hardReload: (tabId: string) => Promise; - /** - * Emulate `prefers-color-scheme` on the guest page ("system" clears the - * override). Persists per tab and is re-applied across webview swaps. - */ setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; - /** - * Silence the tab's audio output. Persists per tab and is re-applied across - * webview swaps, but is dropped when the tab closes. Muting a silent tab is - * allowed; it simply takes effect once the page plays something. - */ setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; - /** Open the guest webview's DevTools (detached). */ openDevTools: (tabId: string) => Promise; - /** Drop cookies + storage data for the preview partition (all tabs). */ clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise; - /** Drop the HTTP cache for the preview partition (all tabs). */ clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise; - /** - * One-shot config for mounting a preview ``. Replaces three - * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`, - * `getPickPreloadPath`) so adding a new field here only requires touching - * the contract + main, not the renderer's mount logic. - */ getPreviewConfig: ( environmentId: EnvironmentId, profileId?: string, ) => Promise; - /** Browsers on this machine whose cookies can be imported. */ listBrowserImportSources: () => Promise>; importBrowserCookies: (input: { readonly environmentId: EnvironmentId; @@ -1328,7 +1177,6 @@ export interface DesktopPreviewBridge { readonly targetProfileId: string; }) => Promise; setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise; - /** Keep an open annotation picker's send shortcut in sync with its thread grant. */ setAnnotationSendEnabled: (tabId: string, enabled: boolean) => Promise; /** * Activate the in-page element picker for the given tab. Resolves with @@ -1337,7 +1185,6 @@ export interface DesktopPreviewBridge { * activated (no webview, etc.). */ pickElement: (tabId: string) => Promise; - /** Cancel an in-flight preview annotation session. */ cancelPickElement: (tabId: string) => Promise; captureScreenshot: (tabId: string) => Promise; revealArtifact: (path: string) => Promise; @@ -1368,16 +1215,6 @@ export interface ConfirmDialogOptions { readonly variant?: ConfirmDialogVariant; } -/** - * APIs bound to the local app shell, not to any particular backend environment. - * - * These capabilities describe the desktop/browser host that the user is - * currently running: dialogs, external-link opening, context menus, and - * app-level settings/config access. They must not be used as a proxy for - * "whatever environment the user is targeting", because in a multi-environment - * world the local shell and a selected backend environment are distinct - * concepts. - */ export interface LocalApi { dialogs: { pickFolder: (options?: PickFolderOptions) => Promise; @@ -1385,7 +1222,6 @@ export interface LocalApi { }; shell: { openExternal: (url: string) => Promise; - /** Opens a known System Settings pane; no-ops outside the desktop app. */ openSystemSettings: (pane: SystemSettingsPane) => Promise; }; contextMenu: { diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 04c3acab803d..4f966ba909f3 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -202,8 +202,6 @@ export const ResolvedKeybindingRule = Schema.Struct({ export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; /** - * The command set grows over time, so a client may receive rules it cannot - * represent (a command or `when` node added after that client shipped). * Decoding drops those rules instead of failing the whole payload β€” * rejecting the config would take down the connection over a shortcut the * client couldn't dispatch anyway. diff --git a/packages/contracts/src/mcpApps.ts b/packages/contracts/src/mcpApps.ts index 39925090ca80..513804f23e0e 100644 --- a/packages/contracts/src/mcpApps.ts +++ b/packages/contracts/src/mcpApps.ts @@ -3,7 +3,6 @@ import * as Schema from "effect/Schema"; import { ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts"; /** - * Requests an MCP App makes of its own MCP server, through the environment. * Each names the tool call that produced the app; the environment resolves the * app's server from that item, so an app can only reach the server it came from. */ @@ -19,7 +18,6 @@ export const McpAppCallToolInput = Schema.Struct({ }); export type McpAppCallToolInput = typeof McpAppCallToolInput.Type; -/** MCP `CallToolResult`, passed through to the app unchanged. */ export const McpAppCallToolResult = Schema.Struct({ content: Schema.Array(Schema.Unknown), structuredContent: Schema.optional(Schema.Unknown), @@ -34,15 +32,10 @@ export const McpAppToolInfoInput = Schema.Struct({ }); export type McpAppToolInfoInput = typeof McpAppToolInfoInput.Type; -/** - * What a client needs to decide whether to ask before a call: whether the tool - * exists for apps and whether its server declares it read-only. - */ export const McpAppToolInfo = Schema.Struct({ callable: Schema.Boolean, readOnly: Schema.Boolean, title: Schema.optional(Schema.String), - /** The server's MCP `Tool` definition, which the host passes to the app as `toolInfo`. */ tool: Schema.optional(Schema.Unknown), }); export type McpAppToolInfo = typeof McpAppToolInfo.Type; @@ -53,22 +46,16 @@ export const McpAppReadResourceInput = Schema.Struct({ }); export type McpAppReadResourceInput = typeof McpAppReadResourceInput.Type; -/** MCP `ReadResourceResult`, passed through to the app unchanged. */ export const McpAppReadResourceResult = Schema.Struct({ contents: Schema.Array(Schema.Unknown), }); export type McpAppReadResourceResult = typeof McpAppReadResourceResult.Type; /** - * What an app wants the agent to know on its next turn (`ui/update-model-context`). * Each update replaces the app's previous one; no content clears it. */ export const McpAppUpdateModelContextInput = Schema.Struct({ ...McpAppTarget, - /** - * The thread whose next turn gets the context: the one on screen, which is - * the app's own thread or a fork that shows it. - */ conversationThreadId: ThreadId, content: Schema.optional(Schema.Array(Schema.Unknown)), structuredContent: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), @@ -100,8 +87,6 @@ export class McpAppRequestError extends Schema.TaggedError() case "provider-unsupported": return "This thread's provider cannot run MCP app requests."; case "session-stopped": - // A forked thread shows its source's apps, so this names the thread - // that made the app rather than the one on screen. return "The app's thread is not running. Send a message in the thread that created it to use the app again."; case "tool-not-callable": return "This app cannot call that tool."; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9bf2f345e8e2..36356146fd40 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -52,17 +52,6 @@ export const ProviderOptionSelection = Schema.Struct({ }); export type ProviderOptionSelection = typeof ProviderOptionSelection.Type; -/** - * Legacy on-disk shape for provider option selections, kept readable by the - * decoder so we can tolerate stored data written before the v3 array shape. - * - * Persisted historically as `{ effort: "max", fastMode: true, ... }` inside - * `modelSelection.options`. Migration 026 rewrites stored rows to the - * canonical array shape, but we still see the legacy form in: - * - `settings.json` files from older client builds, - * - SQLite databases that have not yet run migration 026, - * - any future regression that re-introduces the legacy shape. - */ const LegacyProviderOptionSelectionsObject = Schema.Record(Schema.String, Schema.Unknown); const ProviderOptionSelectionsFromLegacyObject = LegacyProviderOptionSelectionsObject.pipe( @@ -75,18 +64,6 @@ const ProviderOptionSelectionsFromLegacyObject = LegacyProviderOptionSelectionsO ), ); -/** - * Schema for the `options` field of every `ModelSelection` variant. - * - * Accepts both: - * - the canonical array shape `Array<{ id, value }>` (preferred), and - * - the legacy object shape `Record` from - * pre-migration data. - * - * Always normalizes to the canonical array on decode and re-encodes as the - * canonical array, so any legacy storage gets cleaned up the next time the - * containing record is written back. - */ export const ProviderOptionSelections = Schema.Union([ Schema.Array(ProviderOptionSelection), ProviderOptionSelectionsFromLegacyObject, @@ -127,11 +104,6 @@ export const ModelCapabilities = Schema.Struct({ }); export type ModelCapabilities = typeof ModelCapabilities.Type; -/** - * A user-authored custom model. `name` and `capabilities` are optional so a - * bare slug keeps its driver-default presentation; when `capabilities` is - * set, its descriptors replace the driver default in the model picker. - */ export const CustomModelEntry = Schema.Struct({ slug: TrimmedNonEmptyString, name: Schema.optional(TrimmedNonEmptyString), @@ -139,7 +111,6 @@ export const CustomModelEntry = Schema.Struct({ }); export type CustomModelEntry = typeof CustomModelEntry.Type; -/** On-disk custom model setting: the legacy bare slug, or a full entry. */ export const CustomModelSetting = Schema.Union([Schema.String, CustomModelEntry]); export type CustomModelSetting = typeof CustomModelSetting.Type; @@ -155,9 +126,9 @@ const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-6-astra"; /** - * Codex default-model preference, most preferred first. The provider snapshot - * marks the first of these present in the live `model/list` response as - * default; when none are available, Codex's own `isDefault` flag wins. + * The provider snapshot marks the first of these present in the live + * `model/list` response as default; when none are available, Codex's own + * `isDefault` flag wins. */ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ DEFAULT_MODEL, @@ -186,7 +157,6 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial > = { @@ -223,8 +193,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< [OPENCODE_DRIVER_KIND]: {}, }; -// ── Provider display names ──────────────────────────────────────────── - export const PROVIDER_DISPLAY_NAMES: Partial> = { [ProviderDriverKind.make("antigravity")]: "Antigravity", [CODEX_DRIVER_KIND]: "Codex", diff --git a/packages/contracts/src/modelSelection.test.ts b/packages/contracts/src/modelSelection.test.ts index 6baf1ca57b11..93caf5d94cc2 100644 --- a/packages/contracts/src/modelSelection.test.ts +++ b/packages/contracts/src/modelSelection.test.ts @@ -5,20 +5,6 @@ import * as Schema from "effect/Schema"; import { ModelSelection } from "./modelSelection.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -// ── ModelSelection: instance-keyed wire shape + legacy decoder ──────── -// -// `ModelSelection` is routing-keyed on `instanceId` β€” never a driver kind. -// Persisted and in-flight payloads from pre-instance builds carry a -// `provider` field whose value was a driver kind; those payloads are migrated -// at the wire boundary by -// promoting `provider` to the default instance id for that driver -// (built-in drivers use the driver kind slug as their default instance id, so -// the migration is a 1:1 rename). -// -// These tests pin the rollback/fork tolerance invariant: legacy payloads -// decode cleanly for fork-provided drivers, and the decoded form uses -// `instanceId` uniformly regardless of origin. - const decodeModelSelection = Schema.decodeUnknownEffect(ModelSelection); const encodeModelSelection = Schema.encodeUnknownEffect(ModelSelection); @@ -90,7 +76,7 @@ it.effect("ModelSelection rejects malformed instance ids", () => Effect.gen(function* () { const result = yield* Effect.exit( decodeModelSelection({ - instanceId: "1invalid", // must start with a letter + instanceId: "1invalid", model: "x", }), ); @@ -108,7 +94,7 @@ it.effect( options: { effort: "max", fastMode: true, - // Falsy/garbage entries are dropped, matching migration 026. + emptyStr: " ", nullish: null, nested: { foo: 1 }, diff --git a/packages/contracts/src/modelSelection.ts b/packages/contracts/src/modelSelection.ts index 253a74924271..e1ee73cfcbaf 100644 --- a/packages/contracts/src/modelSelection.ts +++ b/packages/contracts/src/modelSelection.ts @@ -6,13 +6,6 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderOptionSelections } from "./model.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -/** - * Selection of a model on a configured provider instance. - * - * `instanceId` is the routing key. The provider driver, credentials, - * environment, and continuation identity are resolved by the provider - * instance registry rather than encoded into the selection itself. - */ const ModelSelectionWire = Schema.Struct({ instanceId: ProviderInstanceId, model: TrimmedNonEmptyString, @@ -26,11 +19,6 @@ const ModelSelectionSource = Schema.Struct({ options: Schema.optional(Schema.Unknown), }); -/** - * The decoder still accepts the historical `{ provider, model }` shape while - * V1 persistence remains readable. Runtime code only receives the canonical - * instance-based representation. - */ export const ModelSelection = ModelSelectionSource.pipe( Schema.decodeTo( ModelSelectionWire, diff --git a/packages/contracts/src/orchestrationDispatch.ts b/packages/contracts/src/orchestrationDispatch.ts index 0dec60e48a85..696e4acd3017 100644 --- a/packages/contracts/src/orchestrationDispatch.ts +++ b/packages/contracts/src/orchestrationDispatch.ts @@ -2,10 +2,6 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; -/** - * A command dispatch failure. `bootstrapThreadDisposition` tells a client whether the - * thread a bootstrap turn would have created was removed or never created. - */ export class OrchestrationDispatchCommandError extends Schema.TaggedError()( "OrchestrationDispatchCommandError", { diff --git a/packages/contracts/src/orchestrationProject.ts b/packages/contracts/src/orchestrationProject.ts index 4248558c93ad..4b8203c7ce0a 100644 --- a/packages/contracts/src/orchestrationProject.ts +++ b/packages/contracts/src/orchestrationProject.ts @@ -5,20 +5,15 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; import { ProjectScript, ReceivedProjectIcon } from "./project.ts"; -/** Project summary shared by the V2 shell and application project APIs. */ export const OrchestrationProjectShell = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), - // Per-project override for where new threads start. Null/absent means - // "no override": clients fall back to t3.json, then the global setting. defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), // Opt-in because background sync performs network I/O and may move the checkout. - // Optional on the wire so cached snapshots from older servers still decode. autoPull: Schema.optional(Schema.Boolean), - // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), scripts: Schema.Array(ProjectScript), diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index bca9ff656ebe..7f31463a1119 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -198,7 +198,6 @@ describe("orchestration V2 contracts", () => { sequence: 2, event: { id: "event-2", - // A type no build of this client knows, standing in for a newer server's event. type: "run.from-a-future-server", threadId: "thread-1", occurredAt: DateTime.formatIso(now), @@ -214,7 +213,6 @@ describe("orchestration V2 contracts", () => { sequence: 2, eventType: "run.from-a-future-server", }); - // A known type with a broken payload is a real defect, not a newer event. expect(() => decodeWireItems([ { ...detached("event-4", 4), event: { ...detached("event-4", 4).event, payload: {} } }, @@ -248,7 +246,6 @@ describe("orchestration V2 contracts", () => { ...extra, }); const known = item("item-known", "system_notice", { message: "Hello" }); - // A type no build of this client knows, standing in for a newer server's item. const future = item("item-future", "hologram", { beam: "ref-1" }); const projected = (position: number, turnItem: { readonly id: string }) => ({ position, @@ -336,7 +333,6 @@ describe("orchestration V2 contracts", () => { }).items.map((row) => row.item.id), ).toEqual(["item-known"]); - // A known turn item type with a broken payload is a real defect, not a newer item. const broken = item("item-broken", "system_notice", {}); expect(() => decodeWireItems([turnItemEvent(4, broken)])).toThrow(); expect(() => @@ -371,7 +367,6 @@ describe("orchestration V2 contracts", () => { }); it("negotiates compact bounded turnItems without disturbing older peers", () => { - // Older servers strip the unknown opt-in, so they keep sending full turnItems. const legacyDecoded = decodeLegacySubscribeThreadInput({ threadId: "thread-1", acceptBoundedSnapshot: true, @@ -391,7 +386,6 @@ describe("orchestration V2 contracts", () => { snapshotSequence: 1, projection: emptyThreadProjection(), }); - // Older servers never send the marker; newer clients treat absence as full turnItems. expect(projection.kind === "snapshot" && projection.turnItemsOmitLocalVisible).toBe(undefined); const marked = decodeStreamItem({ kind: "snapshot", @@ -400,7 +394,6 @@ describe("orchestration V2 contracts", () => { turnItemsOmitLocalVisible: true, }); expect(marked.kind === "snapshot" && marked.turnItemsOmitLocalVisible).toBe(true); - // The marker only means "omitted"; any other value is a protocol error. expect(() => decodeStreamItem({ kind: "snapshot", @@ -412,8 +405,6 @@ describe("orchestration V2 contracts", () => { }); it("decodes persisted capability snapshots that predate runtimePolicy", () => { - // Events written before the field existed must replay; absent decodes to - // the weaker client-boundary guarantee so history never overclaims. const legacyCapabilities = { sessions: { supportsMultipleProviderThreadsPerSession: false, @@ -679,7 +670,6 @@ describe("orchestration V2 contracts", () => { expect(command.taskId).toBe(NodeId.make("node-subagent-1")); expect(command.completionWake).toBe("always"); - // The policy carries the whole decision, so it is required and closed. expect(() => decodeOrchestrationV2Command({ type: "delegated_task.wake-policy", @@ -1017,7 +1007,6 @@ describe("orchestration V2 contracts", () => { }; const decode = Schema.decodeUnknownSync(OrchestrationV2Subagent); - // Legacy records predate the field and must behave as settled_only. expect(decode(appOwnedSubagent).completionWake).toBeUndefined(); expect(decode({ ...appOwnedSubagent, completionWake: "always" }).completionWake).toBe("always"); expect(decode({ ...appOwnedSubagent, completionWake: "settled_only" }).completionWake).toBe( @@ -1384,7 +1373,6 @@ it("round-trips typed notifications and keeps work outcome separate from item st decodeOrchestrationV2TurnItem({ ...base, source, summary: "", updatedAt: now }), ).toThrow(); } - // A known kind with fields that do not decode is a real defect, not a newer kind. expect(() => decodeOrchestrationV2TurnItem({ ...base, source: { kind: "delegated_task" }, updatedAt: now }), ).toThrow(); @@ -1432,8 +1420,6 @@ describe("background work kinds from older or newer servers", () => { }); it("sends and stores sources that clients from before specific kinds still decode", () => { - // The notification source schema clients shipped with before #13948. They - // reject a kind outside it, which fails the whole thread load. const PreSpecificKindsNotification = Schema.Struct({ type: Schema.Literal("notification"), source: Schema.Union([ @@ -1464,7 +1450,6 @@ describe("background work kinds from older or newer servers", () => { const item = decodeOrchestrationV2TurnItemJson(storedNotification(source)); for (const encoded of [encodeOrchestrationV2TurnItemJson(item), sendOverWire(item)]) { expect(decodePreSpecificKinds(encoded).source).toEqual(preSpecificKindsSource); - // Current clients read the specific kind back. expect(decodeOrchestrationV2TurnItemJson(encoded)).toEqual(item); } expect(item).toMatchObject({ source }); @@ -1510,10 +1495,8 @@ describe("background work kinds from older or newer servers", () => { { taskId: "bg-4", description: "npm test", kind: "command" }, { taskId: "bg-5", kind: "subagent", childThreadId: "thread-child" }, ]); - // The fallback is decode-only: what was decoded encodes as its known member. const encoded = encodeOrchestrationV2ProviderThreadJson(providerThread).pendingBackgroundTasks; expect(encoded).toEqual(providerThread.pendingBackgroundTasks); - // Clients from before kinds read a roster entry as this struct and ignore `kind`. const decodePreKindsRoster = Schema.decodeUnknownSync( Schema.Array( Schema.Struct({ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index f08eec7345b8..fa4b7738eaae 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -296,13 +296,6 @@ export type OrchestrationV2CheckpointCapabilities = typeof OrchestrationV2CheckpointCapabilities.Type; export const OrchestrationV2RuntimePolicyCapabilities = Schema.Struct({ - /** - * Where T3 runtime modes are actually enforced. "native" providers receive - * the approval and sandbox policy each turn and confine their own execution. - * "client-boundary" providers only have policy applied where T3 mediates the - * work (permission requests and client fs/terminal handlers); provider-owned - * execution is not confined, so sandbox guarantees are reduced. - */ enforcement: Schema.Literals(["native", "client-boundary"]), }); export type OrchestrationV2RuntimePolicyCapabilities = @@ -345,7 +338,6 @@ export const OrchestrationV2LimitRecovery = Schema.Struct({ }); export type OrchestrationV2LimitRecovery = typeof OrchestrationV2LimitRecovery.Type; -/** A choice update preserves omitted options for this same run and reset. */ export const OrchestrationV2LimitRecoveryUpdate = Schema.Struct({ runId: RunId, resetAt: IsoDateTime, @@ -372,11 +364,8 @@ export const OrchestrationV2AppThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), - /** Pull request the user linked to this thread (#8160); optional so - pre-linking servers still decode. */ linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), pullRequests: Schema.optional(Schema.Array(ThreadPullRequestLink)), - /** Pull request discovered from the thread's current branch. */ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), activeProviderThreadId: Schema.NullOr(ProviderThreadId), historyOrigin: Schema.optional(OrchestrationV2ThreadHistoryOrigin), @@ -404,20 +393,15 @@ export const OrchestrationV2AppThread = Schema.Struct({ unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), - /** Manual wakes restart inactivity without changing the sidebar's sort position. */ lastSnoozeWakeAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), - // Fractional-index slot in the user-arranged pinned order. Optional so - // payloads from pre-reorder servers still decode. pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - /** Fractional-index slot in the user-arranged active order. */ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), - /** In-flight title regeneration marker; cleared when a new title lands. */ titleRegeneration: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -426,9 +410,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ }), ), ), - /** Latest accepted rollback. Only its failure is recorded in `rollbackFailure`. */ rollbackRequestId: Schema.optional(CommandId), - /** Latest rollback that failed after every retry; cleared when the next rollback starts. */ rollbackFailure: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -442,9 +424,8 @@ export const OrchestrationV2AppThread = Schema.Struct({ export type OrchestrationV2AppThread = typeof OrchestrationV2AppThread.Type; /** - * A subagent the provider spawned on its own (Claude's Agent tool, Codex or - * Cursor native subagents). The provider owns its conversation, so it cannot - * take messages; T3 delegate_task children (`creationSource: "mcp"`) can. + * The provider owns its conversation, so it cannot take messages; T3 delegate_task children + * (`creationSource: "mcp"`) can. */ export function isProviderNativeSubagentThread( thread: Pick, @@ -499,17 +480,14 @@ export const OrchestrationV2DelegatedCompletionCohort = Schema.Struct({ export type OrchestrationV2DelegatedCompletionCohort = typeof OrchestrationV2DelegatedCompletionCohort.Type; -/** Background work that restart recovery cancelled; the next provider turn is told once. */ export const OrchestrationV2RestartCancelledBackgroundWork = Schema.Struct({ kind: Schema.Literals(["subagent", "shell", "monitor", "task"]), label: TrimmedNonEmptyString, - /** Stable identity (turn item or provider task id) so same-named work is not merged. */ id: Schema.optional(TrimmedNonEmptyString), }); export type OrchestrationV2RestartCancelledBackgroundWork = typeof OrchestrationV2RestartCancelledBackgroundWork.Type; -/** Replaces a run's recorded restart-cancelled work without touching its lifecycle. */ export const OrchestrationV2RunBackgroundWorkCancelled = Schema.Struct({ runId: RunId, restartCancelledBackgroundWork: Schema.Array(OrchestrationV2RestartCancelledBackgroundWork), @@ -537,7 +515,6 @@ export const OrchestrationV2ThreadLaunchWorkspaceStrategy = Schema.Union([ export type OrchestrationV2ThreadLaunchWorkspaceStrategy = typeof OrchestrationV2ThreadLaunchWorkspaceStrategy.Type; -/** Failure code on the error item a failed workspace preparation leaves. */ export const ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE = "workspace_preparation_failed"; export const OrchestrationV2Run = Schema.Struct({ @@ -552,27 +529,15 @@ export const OrchestrationV2Run = Schema.Struct({ activeAttemptId: Schema.NullOr(RunAttemptId), status: OrchestrationV2RunStatus, queuePosition: Schema.optional(Schema.NullOr(PositiveInt)), - /** Restart recovery holds the queue until the user explicitly resumes it. */ queueHeld: Schema.optional(Schema.Boolean), requestedAt: Schema.DateTimeUtc, startedAt: Schema.NullOr(Schema.DateTimeUtc), completedAt: Schema.NullOr(Schema.DateTimeUtc), checkpointId: Schema.NullOr(CheckpointId), contextHandoffId: Schema.NullOr(ContextHandoffId), - /** Links server-generated restart continuations to the interrupted run. */ restartContinuationOfRunId: Schema.optional(RunId), - /** The delegated task this run answers, including a follow-up in an existing child thread. */ delegatedTaskId: Schema.optional(NodeId), - /** - * Set on wake runs (background notifications, delegated task results, - * restart continuations): when the work they continue started. Read it - * through orchestrationV2RunWorkStartedAt. - */ workStartedAt: Schema.optional(Schema.DateTimeUtc), - /** - * Set by restart recovery on the thread's latest started run. Delivered to - * the provider with the first later run that reaches a provider turn. - */ restartCancelledBackgroundWork: Schema.optional( Schema.Array(OrchestrationV2RestartCancelledBackgroundWork), ), @@ -583,15 +548,10 @@ export const OrchestrationV2Run = Schema.Struct({ }), ), delegatedCompletion: Schema.optional(OrchestrationV2DelegatedCompletionCohort), - /** How a launch prepares this run's workspace; prepared-run.retry repeats it. */ workspacePreparation: Schema.optional(OrchestrationV2ThreadLaunchWorkspaceStrategy), }); export type OrchestrationV2Run = typeof OrchestrationV2Run.Type; -/** - * When the work a run belongs to started. A wake does not start new work, so - * working timers count from the prompt that did, not from the latest wake. - */ export function orchestrationV2RunWorkStartedAt( run: Pick, ): OrchestrationV2Run["requestedAt"] { @@ -665,10 +625,9 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({ export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type; /** - * One entry of a dynamic workflow's phase plan. The plan grows while the run - * executes: a phase a script only reaches at runtime joins the list when it - * starts, so a client must treat the list as append-only rather than as a - * fixed arc declared up front. + * The plan grows while the run executes: a phase a script only reaches at runtime joins the list + * when it starts, so a client must treat the list as append-only rather than as a fixed arc + * declared up front. */ export const OrchestrationV2WorkflowPhase = Schema.Struct({ index: NonNegativeInt, @@ -677,50 +636,30 @@ export const OrchestrationV2WorkflowPhase = Schema.Struct({ export type OrchestrationV2WorkflowPhase = typeof OrchestrationV2WorkflowPhase.Type; /** - * One agent inside a workflow run. Timestamps are epoch milliseconds rather - * than `DateTimeUtc` because only the top-level entity fields are remapped for - * the JSON wire shape (see `OrchestrationV2SubagentJson`), so a nested + * Timestamps are epoch milliseconds rather than `DateTimeUtc` because only the top-level entity + * fields are remapped for the JSON wire shape (see `OrchestrationV2SubagentJson`), so a nested * `DateTimeUtc` would never be encoded. */ export const OrchestrationV2WorkflowAgent = Schema.Struct({ - /** Spawn ordinal within the run. Stable identity: an agent keeps it across updates. */ index: NonNegativeInt, label: TrimmedNonEmptyString, - /** - * Provider-assigned id, absent until the agent actually starts. Names the - * member's transcript file inside the run's transcript directory. - */ agentId: Schema.optional(TrimmedNonEmptyString), - /** The member's own thread, so the UI can open it like any subagent's. */ childThreadId: Schema.optional(ThreadId), state: Schema.Literals(["queued", "running", "completed", "failed"]), phaseIndex: Schema.optional(NonNegativeInt), phaseTitle: Schema.optional(TrimmedNonEmptyString), model: Schema.optional(TrimmedNonEmptyString), - /** Retry counter; > 1 means the agent was restarted after a provider error. */ attempt: Schema.optional(NonNegativeInt), totalTokens: Schema.optional(NonNegativeInt), toolCalls: Schema.optional(NonNegativeInt), durationMs: Schema.optional(NonNegativeInt), queuedAt: Schema.optional(NonNegativeInt), startedAt: Schema.optional(NonNegativeInt), - /** - * Prompt and result excerpts. A workflow member's whole conversation is one - * prompt and one answer, so together these are its transcript as far as the - * provider exposes it β€” both are capped by the CLI at a few hundred - * characters. - */ prompt: Schema.optional(Schema.NonEmptyString), result: Schema.optional(Schema.NonEmptyString), }); export type OrchestrationV2WorkflowAgent = typeof OrchestrationV2WorkflowAgent.Type; -/** - * Filesystem handles the provider reports when a run launches. The transcript - * directory is what makes a member's full answer readable: the progress - * snapshot only carries a capped excerpt, so the server reads the member's - * transcript from here to fill its thread. - */ export const OrchestrationV2WorkflowRunHandles = Schema.Struct({ runId: Schema.optional(TrimmedNonEmptyString), transcriptDir: Schema.optional(TrimmedNonEmptyString), @@ -728,11 +667,6 @@ export const OrchestrationV2WorkflowRunHandles = Schema.Struct({ }); export type OrchestrationV2WorkflowRunHandles = typeof OrchestrationV2WorkflowRunHandles.Type; -/** - * Workflow-run shape carried by the coordinator subagent. The snapshot is the - * provider's own replace-in-full view of the run; members are additionally - * projected as subagents in their own right. - */ export const OrchestrationV2SubagentWorkflow = Schema.Struct({ name: Schema.optional(TrimmedNonEmptyString), runHandles: Schema.optional(OrchestrationV2WorkflowRunHandles), @@ -760,11 +694,6 @@ export const OrchestrationV2Subagent = Schema.Struct({ title: Schema.NullOr(Schema.String), model: Schema.NullOr(Schema.String), modelSelection: Schema.optional(ModelSelection), - // Parent-wake policy for app-owned tasks: "always" offers a continuation on - // every terminal (async delegations; queue_after_active sequences it behind - // a live parent run), "settled_only" offers only when the parent has no - // live run (wait-mode delegations, whose result returns through the - // blocking tool call). Absent on legacy records; treated as settled_only. completionWake: Schema.optional(Schema.Literals(["always", "settled_only"])), completionDelivery: Schema.optional(OrchestrationV2DelegatedCompletionTaskDelivery), status: Schema.Literals([ @@ -778,7 +707,6 @@ export const OrchestrationV2Subagent = Schema.Struct({ "interrupted", ]), progress: Schema.optional(Schema.String), - /** Present only on a workflow coordinator task; absent on ordinary subagents. */ workflow: Schema.optional(OrchestrationV2SubagentWorkflow), result: Schema.NullOr(Schema.String), startedAt: Schema.NullOr(Schema.DateTimeUtc), @@ -787,7 +715,6 @@ export const OrchestrationV2Subagent = Schema.Struct({ }); export type OrchestrationV2Subagent = typeof OrchestrationV2Subagent.Type; -/** Idle work is resumable, but does not keep a turn or its subscription alive. */ export function isOrchestrationV2WorkActive( status: OrchestrationV2ExecutionNode["status"], ): boolean { @@ -831,7 +758,6 @@ export const OrchestrationV2ProviderSessionDetached = Schema.Struct({ export type OrchestrationV2ProviderSessionDetached = typeof OrchestrationV2ProviderSessionDetached.Type; -/** The literal `kind` a union member is stored and sent with. */ function encodedKind(member: Schema.Top): string { const encoded = SchemaAST.toEncoded(member.ast); const kind = SchemaAST.isObjects(encoded) @@ -843,16 +769,6 @@ function encodedKind(member: Schema.Top): string { throw new Error("Each member of a kind union needs a literal string `kind`."); } -/** - * A union tagged by `kind` that tolerates kinds this build does not know. - * After the known members comes a decode-only arm: an object with an unknown - * `kind`, or none, decodes through `fallback` to a known member instead of - * failing, so a newer server can add kinds without breaking older clients and - * rows written before a kind existed still load. `unknown` builds that arm's - * input around the given `kind` field. A known kind whose fields do not decode - * still fails. Kinds are the encoded ones, which a member may rename on decode. - * The arm never encodes; values always match a known member first. - */ function kindUnionWithFallback< const Members extends ReadonlyArray, Unknown extends Schema.Top, @@ -882,23 +798,14 @@ function kindUnionWithFallback< const PendingBackgroundTaskFields = { taskId: TrimmedNonEmptyString, - /** The work's name: a subagent's title, a command's description, a monitor's. */ description: Schema.optional(TrimmedNonEmptyString), }; -/** - * Provider-owned background work that can outlive the root turn (for example a - * Claude background Bash task). Associated with the provider thread so shared - * runtimes cannot make an unrelated app thread look busy. Adapters pick the - * kind; `background_task` is work they cannot name. Rosters persisted before - * kinds existed carry no `kind` and load as `background_task`. - */ export const OrchestrationV2PendingBackgroundTask = kindUnionWithFallback( [ Schema.Struct({ ...PendingBackgroundTaskFields, kind: Schema.Literal("subagent"), - /** The subagent's own thread, when it has one. */ childThreadId: Schema.optional(ThreadId), }), Schema.Struct({ ...PendingBackgroundTaskFields, kind: Schema.Literal("command") }), @@ -914,13 +821,10 @@ export const OrchestrationV2PendingBackgroundTask = kindUnionWithFallback( ); export type OrchestrationV2PendingBackgroundTask = typeof OrchestrationV2PendingBackgroundTask.Type; -/** Provider and adapter metadata that should not overwrite the app thread's title. */ export const OrchestrationV2ProviderThreadNativeMetadata = Schema.Struct({ - /** Provider-reported selection for display, separate from the app's saved preferences. */ modelSelection: Schema.optional(ModelSelection), title: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), updatedAt: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - /** Version 2 scopes provider-derived item ids by provider instance. */ itemIdentityVersion: Schema.optional(Schema.Literal(2)), }); export type OrchestrationV2ProviderThreadNativeMetadata = @@ -936,19 +840,12 @@ export const OrchestrationV2ProviderGoalStatus = Schema.Literals([ ]); export type OrchestrationV2ProviderGoalStatus = typeof OrchestrationV2ProviderGoalStatus.Type; -/** - * A provider-native goal set with `/goal` (Codex and Claude). The provider - * keeps working until it judges the objective met and owns this state; T3 - * mirrors the latest native report. Usage fields are provider-specific. - */ export const OrchestrationV2ProviderGoal = Schema.Struct({ objective: TrimmedNonEmptyString, status: OrchestrationV2ProviderGoalStatus, - /** Codex accounting for the goal across its turns. */ tokensUsed: Schema.optional(NonNegativeInt), tokenBudget: Schema.optional(Schema.NullOr(NonNegativeInt)), timeUsedSeconds: Schema.optional(NonNegativeInt), - /** Claude: evaluator checks that found the goal unmet, and the latest reason. */ checks: Schema.optional(NonNegativeInt), lastCheck: Schema.optional(Schema.String), }); @@ -974,7 +871,6 @@ export const OrchestrationV2ProviderThread = Schema.Struct({ checkpointId: Schema.optional(CheckpointId), }), ), - // Optional Type so adapters can omit empty rosters; historical JSON decodes to []. pendingBackgroundTasks: Schema.optional(Schema.Array(OrchestrationV2PendingBackgroundTask)).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -984,7 +880,6 @@ export const OrchestrationV2ProviderThread = Schema.Struct({ nativeMetadata: Schema.optional(Schema.NullOr(OrchestrationV2ProviderThreadNativeMetadata)).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), - /** Native goal on this provider thread; rows written before goals decode to null. */ goal: Schema.optional(Schema.NullOr(OrchestrationV2ProviderGoal)).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), @@ -1027,13 +922,11 @@ export const OrchestrationV2ContextHandoff = Schema.Struct({ status: Schema.Literals(["pending", "ready", "failed", "superseded"]), summaryMessageId: Schema.NullOr(MessageId), summaryText: Schema.String, - // Optional fields keep existing preview events and projections readable without a migration. history: Schema.optional( Schema.Struct({ messages: Schema.Array(OrchestrationV2HistoricalMessage), coverage: Schema.String, omittedItems: NonNegativeInt, - // IDs omitted during preparation, before the target's delivery budget is known. omittedItemIds: Schema.optional(Schema.Array(TurnItemId)), }), ), @@ -1042,7 +935,6 @@ export const OrchestrationV2ContextHandoff = Schema.Struct({ nativeThreadId: Schema.String, status: Schema.Literals(["pending", "injected", "inline"]), itemIds: Schema.Array(TurnItemId), - // Covered by recovery instructions, but not present in native model history. omittedItemIds: Schema.optional(Schema.Array(TurnItemId)), }), ), @@ -1053,7 +945,6 @@ export const OrchestrationV2ContextHandoff = Schema.Struct({ }); export type OrchestrationV2ContextHandoff = typeof OrchestrationV2ContextHandoff.Type; -/** Live context usage reported by the provider mid-turn (#8144). */ export const OrchestrationV2ProviderTurnTokenUsage = Schema.Struct({ usedTokens: NonNegativeInt, maxTokens: Schema.optional(Schema.NullOr(NonNegativeInt)), @@ -1061,10 +952,7 @@ export const OrchestrationV2ProviderTurnTokenUsage = Schema.Struct({ cachedInputTokens: Schema.optional(NonNegativeInt), outputTokens: Schema.optional(NonNegativeInt), reasoningOutputTokens: Schema.optional(NonNegativeInt), - /** Prompt cache lifetime the provider wrote with this report. Clients treat - the cache as cold once `updatedAt` is this old. */ promptCacheTtlMs: Schema.optional(PositiveInt), - /** ISO timestamp of the provider's report; string so wire encoding is stable. */ updatedAt: Schema.String, }); export type OrchestrationV2ProviderTurnTokenUsage = @@ -1093,8 +981,7 @@ export const OrchestrationV2ProviderTurn = Schema.Struct({ export type OrchestrationV2ProviderTurn = typeof OrchestrationV2ProviderTurn.Type; /** - * The provider turn a run attempt is on or ended with. A Codex goal keeps one - * run open across several native turns, so an attempt's first turn is not + * A Codex goal keeps one run open across several native turns, so an attempt's first turn is not * always its last. */ export function latestProviderTurnForAttempt< @@ -1135,28 +1022,21 @@ export type OrchestrationV2RuntimeRequest = typeof OrchestrationV2RuntimeRequest const SubagentNotificationSource = Schema.Struct({ kind: Schema.Literal("subagent"), - /** The subagent's own thread, when the notification reports one subagent. */ childThreadId: Schema.optional(ThreadId), }); const CommandNotificationSource = Schema.Struct({ kind: Schema.Literal("command") }); /** - * What a notification reports on. Several pieces of work of one kind share - * that kind; mixed or unnamed work is `background_task`. - * - * Sources are stored and sent in the shape clients before `subagent` and - * `command` existed decode, since they reject a kind they do not know: a - * command is `background_command`, and a subagent is `background_task` with - * `work: "subagent"`, a field those clients ignore. Encoding picks the first - * member that fits, so those come first. The plain `subagent` and `command` - * members after them decode values that were already decoded once. + * Sources are stored and sent in the shape clients before `subagent` and `command` existed decode, + * since they reject a kind they do not know: a command is `background_command`, and a subagent is + * `background_task` with `work: "subagent"`, a field those clients ignore. Encoding picks the first + * member that fits, so those come first. */ export const OrchestrationV2NotificationSource = kindUnionWithFallback( [ Schema.Struct({ kind: Schema.Literal("delegated_task"), taskIds: Schema.Array(NodeId), - /** The task's own thread, when the notification reports one task. */ childThreadId: Schema.optional(ThreadId), }), Schema.Struct({ @@ -1182,7 +1062,6 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( CommandNotificationSource, Schema.Struct({ kind: Schema.Literal("monitor") }), Schema.Struct({ kind: Schema.Literal("background_task") }), - /** T3 Code itself, such as a restart continuing an interrupted turn. */ Schema.Struct({ kind: Schema.Literal("system") }), ], (kind) => Schema.Struct({ kind }), @@ -1190,11 +1069,8 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( ); export type OrchestrationV2NotificationSource = typeof OrchestrationV2NotificationSource.Type; -// A notification records an observed event, not whether its payload has reached the agent. -// Provider delivery, wake policy, and agent-facing instructions belong to the backend. export const OrchestrationV2Notification = Schema.Struct({ source: OrchestrationV2NotificationSource, - // Item status describes this timeline record; outcome describes the reported work. outcome: Schema.Literals(["completed", "failed", "cancelled", "updated", "unknown"]), summary: TrimmedNonEmptyString, detail: Schema.optional(Schema.String), @@ -1205,7 +1081,6 @@ export const OrchestrationV2ConversationMessage = Schema.Struct({ notification: Schema.optional(OrchestrationV2Notification), ...OrchestrationV2CreationFields, scheduledTaskId: Schema.optional(ScheduledTaskId), - // The sending agent's thread in this environment, separate from the receiving thread. senderThreadId: Schema.optional(ThreadId), id: MessageId, threadId: ThreadId, @@ -1232,9 +1107,7 @@ export const OrchestrationV2PlanStep = Schema.Struct({ id: TrimmedNonEmptyString, text: TrimmedNonEmptyString, status: Schema.Literals(["pending", "running", "completed"]), - /** Durable server-owned boundary used to calculate elapsed time. */ durationAnchorAt: Schema.optional(IsoDateTime), - /** Elapsed time for a completed step. */ durationMs: Schema.optional(NonNegativeInt), }); export type OrchestrationV2PlanStep = typeof OrchestrationV2PlanStep.Type; @@ -1252,7 +1125,6 @@ export const OrchestrationV2UserInputQuestion = Schema.Struct({ ), multiSelect: Schema.optional(Schema.Boolean), allowCustomAnswer: Schema.optional(Schema.Boolean), - /** Editable initial text. Answers preserve whitespace and allow an empty string when present. */ initialAnswer: Schema.optional(Schema.String), required: Schema.optional(Schema.Boolean), }); @@ -1339,7 +1211,6 @@ export const OrchestrationV2TurnItemStatus = Schema.Literals([ export type OrchestrationV2TurnItemStatus = typeof OrchestrationV2TurnItemStatus.Type; -/** One structured file operation reported by a provider inside a file_change item. */ export const OrchestrationV2FileChangeDetail = Schema.Struct({ operation: TrimmedNonEmptyString, path: TrimmedNonEmptyString, @@ -1365,25 +1236,17 @@ const OrchestrationV2ProviderFailureMessage = TrimmedNonEmptyString.check( const OrchestrationV2ProviderFailureCode = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); /** - * Transport-safe failure information suitable for persistence and display. - * Producers must redact credentials before constructing this value. The - * schema bounds every provider-controlled string as a second line of defense. + * Producers must redact credentials before constructing this value. */ export const OrchestrationV2ProviderFailure = Schema.Struct({ class: OrchestrationV2ProviderFailureClass, message: OrchestrationV2ProviderFailureMessage, code: Schema.NullOr(OrchestrationV2ProviderFailureCode), retryable: Schema.NullOr(Schema.Boolean), - /** Reported reset time; absent when the provider cannot name one. */ resetAt: Schema.optional(Schema.NullOr(IsoDateTime)), }); export type OrchestrationV2ProviderFailure = typeof OrchestrationV2ProviderFailure.Type; -/** - * Provider-reported retry progress. Some providers expose all fields (Claude), - * while others only expose `willRetry` and encode counters in display text - * (Codex), so the protocol-specific values remain nullable. - */ export const OrchestrationV2ProviderRetry = Schema.Struct({ attempt: PositiveInt, maxAttempts: Schema.NullOr(PositiveInt), @@ -1448,10 +1311,6 @@ export const OrchestrationV2SecretRequestStatus = Schema.Literals([ ]); export type OrchestrationV2SecretRequestStatus = typeof OrchestrationV2SecretRequestStatus.Type; -/** - * A secret an agent asked the user for. The value never passes through - * orchestration: the item carries only what was asked and how it was answered. - */ const OrchestrationV2SecretRequestFields = { type: Schema.Literal("secret_request"), label: TrimmedNonEmptyString, @@ -1553,9 +1412,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ requestId: RuntimeRequestId, requestKind: ProviderRequestKind, prompt: Schema.optional(Schema.String), - /** App requesting access, for mcp-elicitation approvals (#8058). */ appName: Schema.optional(Schema.String), - /** Approval choices advertised by the provider (#8058). */ options: Schema.optional(Schema.Array(ProviderApprovalOption)), }), Schema.Struct({ @@ -1602,9 +1459,6 @@ export const OrchestrationV2TurnItem = Schema.Union([ toProviderThreadId: ProviderThreadId, fromProviderInstanceIds: Schema.Array(ProviderInstanceId), toProviderInstanceId: ProviderInstanceId, - // Model selections active on the covered source runs and the target - // model, so timelines can label the handoff by model rather than by - // provider instance id. Absent on items persisted before these fields. fromModelSelections: Schema.optional(Schema.Array(ModelSelection)), toModel: Schema.optional(Schema.String), strategy: Schema.Literals([ @@ -1668,19 +1522,12 @@ export const OrchestrationV2TurnItem = Schema.Union([ ]); export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type; -/** - * Turn item types grow over time, so clients decode them forward-compatibly: - * an item whose type this build does not know is dropped from snapshots and - * history instead of failing the thread. A known type that does not decode - * still fails. Arrays of projected rows filter on the row's nested item. - */ const isUnknownTurnItem = hasUnknownUnionTag(OrchestrationV2TurnItem.members, "type"); const turnItemArray = >( union: Schema.Union, ) => ForwardCompatibleUnionArray(union.members, "type"); -/** Projected rows whose nested item may be of a type this build does not know. */ const projectedTurnItemArray = ( row: Row, rowWithUnknownItem: Item, @@ -1948,10 +1795,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), - /** Pull request the user linked to this thread (#8160). */ linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), pullRequests: Schema.optional(Schema.Array(ThreadPullRequestLink)), - /** Pull request discovered from the thread's current branch. */ branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), lineage: OrchestrationV2AppThreadLineage, forkedFrom: Schema.NullOr(OrchestrationV2AppThread.fields.forkedFrom), @@ -1962,10 +1807,6 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ latestRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunCompletedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), activeRunId: Schema.NullOr(RunId), - /** - * orchestrationV2RunWorkStartedAt of the activity-owning run: a wake keeps - * the start of the work it continues; request time while preparing. - */ activityRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), activityRunStatus: Schema.optional( Schema.NullOr(Schema.Literals(["preparing", "starting", "running", "waiting"])), @@ -1983,18 +1824,12 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ */ latestUserAuthoredMessageAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), hasActionableProposedPlan: Schema.Boolean, - // Normalized post-settlement background work for sidebar Waiting pills. - // Empty when the latest root run is still active or no pending work remains. pendingBackgroundTasks: Schema.optional(Schema.Array(OrchestrationV2PendingBackgroundTask)).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), - // Distinct provider instances that have owned a root provider thread here, - // in first-use order, so lists can show where a handed-off thread has been. - // Omitted by servers that predate it; decodes to []. providerInstanceHistory: Schema.optional(Schema.Array(ProviderInstanceId)).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), - /** Native goal on the active provider thread; omitted by servers without goals. */ goal: Schema.optional(Schema.NullOr(OrchestrationV2ProviderGoal)), itemCount: NonNegativeInt, visibleItemCount: NonNegativeInt, @@ -2007,19 +1842,11 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), - /** Omitted by servers that predate thread pinning. */ pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), - /** Slot in the user-arranged pinned order; omitted by pre-reorder servers. */ pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - /** Slot in the user-arranged active order; omitted by pre-reorder servers. */ activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - /** - * Omitted by servers that predate server-side visited tracking; clients fall - * back to their local visited state when the field is absent. - */ lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), - /** In-flight title regeneration marker; null/absent when no request is pending. */ titleRegeneration: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -2053,11 +1880,6 @@ export const OrchestrationV2ShellStreamItem = Schema.Union([ Schema.Struct({ kind: Schema.Literal("snapshot"), snapshot: OrchestrationV2ShellSnapshot, - /** - * Workspace roots whose repository-identity resolution completed for this - * enrichment refresh. Omitted on authoritative HTTP/initial WebSocket - * snapshots. Older clients ignore the field. - */ resolvedRepositoryIdentityRoots: Schema.optionalKey(Schema.Array(Schema.String)), }), Schema.Struct({ @@ -2345,9 +2167,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ requestId: RuntimeRequestId, requestKind: ProviderRequestKind, prompt: Schema.optional(Schema.String), - /** App requesting access, for mcp-elicitation approvals (#8058). */ appName: Schema.optional(Schema.String), - /** Approval choices advertised by the provider (#8058). */ options: Schema.optional(Schema.Array(ProviderApprovalOption)), }), Schema.Struct({ @@ -2764,10 +2584,9 @@ export const OrchestrationV2Command = Schema.Union([ settledAt: Schema.optional(Schema.DateTimeUtc), }), /** - * Server-internal settlement (#8600): dispatched by the settlement sweep, - * never by clients. Rejected when the thread changed after `snapshotAt` or - * carries any explicit settled override, so automatic settlement can never - * race a user action or clobber an explicit un-settle. + * Rejected when the thread changed after `snapshotAt` or carries any explicit + * settled override, so automatic settlement can never race a user action or + * clobber an explicit un-settle. */ Schema.Struct({ type: Schema.Literal("thread.auto-settle"), @@ -2804,9 +2623,6 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("thread.pin"), commandId: CommandId, threadId: ThreadId, - // Initial slot in the user-arranged pinned order (see thread.pin.reorder). - // Optional: clients on pre-reorder servers omit it, and the pinned block - // falls back to creation order for keyless threads. orderKey: Schema.optional(TrimmedNonEmptyString), }), Schema.Struct({ @@ -2818,10 +2634,6 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("thread.pin.reorder"), commandId: CommandId, threadId: ThreadId, - // Fractional-index key: pinned threads sort lexicographically by these - // keys, so a drag writes one key to one thread β€” neighbors are never - // touched. Clients compute a key that sorts between the dropped - // position's neighbors. orderKey: TrimmedNonEmptyString, }), Schema.Struct({ @@ -2834,11 +2646,6 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("thread.visit"), commandId: CommandId, threadId: ThreadId, - /** - * Watermark of the thread state the viewer has seen (typically the shell's - * updatedAt). The server keeps the maximum of the stored and supplied - * values, so replays and out-of-order deliveries cannot move it backwards. - */ visitedAt: IsoDateTime, }), Schema.Struct({ @@ -2851,7 +2658,6 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), - /** Kick off (true) or abandon (false) an async title regeneration. */ regenerateTitle: Schema.optional(Schema.Boolean), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -2885,14 +2691,12 @@ export const OrchestrationV2Command = Schema.Union([ snapshot: ThreadPullRequestSnapshot, stack: Schema.NullOr(ThreadPullRequestStack), }), - /** Start or stop the server watching a linked pull request for this thread's agent. */ Schema.Struct({ type: Schema.Literal("thread.pull-request.watch"), commandId: CommandId, threadId: ThreadId, ...ThreadPullRequestKey.fields, watching: Schema.Boolean, - /** Links the pull request first when starting a watch on one the thread has not linked. */ link: Schema.optional( Schema.Struct({ url: TrimmedNonEmptyString, source: ThreadPullRequestLinkSource }), ), @@ -2957,7 +2761,6 @@ export const OrchestrationV2Command = Schema.Union([ text: Schema.String, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(ChatAttachment), - /** Seed the temporary title and generate a durable replacement for the first message. */ titleSeed: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), sourcePlanRef: Schema.optional(Schema.Struct({ threadId: ThreadId, planId: PlanId })), @@ -2965,7 +2768,6 @@ export const OrchestrationV2Command = Schema.Union([ usageLimitContinuationOfRunId: Schema.optional(RunId), manualContinuationOfRunId: Schema.optional(RunId), usageLimitRecoveryRequestId: Schema.optional(CommandId), - /** Resolve untargeted delivery against the server's serialized thread state. */ deliveryIntent: Schema.optional(Schema.Literals(["auto", "steer", "restart"])), delegatedCompletion: Schema.optional( Schema.Struct({ @@ -2991,7 +2793,6 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, runId: RunId, }), - /** Provider acceptance of a mailbox delivery; distinct from the agent reading its result. */ Schema.Struct({ type: Schema.Literal("notification.delivery.accept"), commandId: CommandId, @@ -3012,14 +2813,12 @@ export const OrchestrationV2Command = Schema.Union([ runId: RunId, failure: OrchestrationV2ProviderFailure, }), - /** Puts a run whose workspace preparation failed back into preparation. */ Schema.Struct({ type: Schema.Literal("prepared-run.retry"), commandId: CommandId, threadId: ThreadId, runId: RunId, }), - /** Stops one provider-native child while its owning run keeps working. */ Schema.Struct({ type: Schema.Literal("subagent.stop"), commandId: CommandId, @@ -3033,10 +2832,6 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, runId: RunId, reason: Schema.optional(Schema.String), - /** - * Set by the Stop button. Stop also holds the queue, ends the thread's pull request - * watches, and stops every delegated task under the thread. - */ holdQueue: Schema.optional(Schema.Boolean), }), Schema.Struct({ @@ -3180,18 +2975,12 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; * send them. */ const OrchestrationV2InternalCommand = Schema.Union([ - /** - * Records what a pull request watch saw, and wakes the agent in the same transaction when - * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is - * rejected on a settled or archived thread, so a read that raced either changes nothing. - */ Schema.Struct({ type: Schema.Literal("thread.pull-request-watch.sync"), commandId: CommandId, threadId: ThreadId, ...ThreadPullRequestKey.fields, startedAt: IsoDateTime, - /** The watch to record, or null to end it. */ watch: Schema.NullOr(ThreadPullRequestWatch), wake: Schema.optional( Schema.Struct({ @@ -3201,7 +2990,6 @@ const OrchestrationV2InternalCommand = Schema.Union([ }), ), }), - /** Records that the provider rollback `requestId` failed for good. */ Schema.Struct({ type: Schema.Literal("checkpoint.rollback.fail"), commandId: CommandId, @@ -3209,14 +2997,6 @@ const OrchestrationV2InternalCommand = Schema.Union([ requestId: CommandId, message: TrimmedNonEmptyString, }), - /** - * Follows a Stop once its provider returned: background work the settled - * thread still shows on that provider thread is no longer reported by any - * provider process, so it is marked interrupted. Only the stopped turn's run - * and older runs are settled; a later run's work is its own. A subagent target - * limits repair to that task and its runless child work, using a confirmed - * provider outcome. - */ Schema.Struct({ type: Schema.Literal("thread.background-work.settle"), commandId: CommandId, @@ -3235,11 +3015,6 @@ const OrchestrationV2InternalCommand = Schema.Union([ }), ), }), - /** - * Stop for one thread, whatever it is doing: interrupts its running turn, holds its queue, - * ends its pull request watches, and drops pending delegated-task wakes. Nothing to stop is - * an accepted no-op. Delegated tasks under the thread get their own `thread.stop`. - */ Schema.Struct({ type: Schema.Literal("thread.stop"), commandId: CommandId, @@ -3247,8 +3022,8 @@ const OrchestrationV2InternalCommand = Schema.Union([ reason: Schema.optional(Schema.String), }), /** - * Records or updates a secret an agent asked the user for. Internal so no - * client can mark a request saved without the value being stored. + * Internal so no client can mark a request saved without the value being + * stored. */ Schema.Struct({ type: Schema.Literal("secret_request.record"), @@ -3265,7 +3040,6 @@ const OrchestrationV2InternalCommand = Schema.Union([ ]); export type OrchestrationV2InternalCommand = typeof OrchestrationV2InternalCommand.Type; -/** Everything the server's orchestrator accepts: client commands plus internal ones. */ export type OrchestrationV2ServerCommand = OrchestrationV2Command | OrchestrationV2InternalCommand; export const ORCHESTRATION_V2_WS_METHODS = { @@ -3391,38 +3165,16 @@ export type OrchestrationV2GetThreadProjectionInput = typeof OrchestrationV2GetThreadProjectionInput.Type; export const OrchestrationV2SubscribeShellInput = Schema.Struct({ - /** - * When provided, the server skips the initial full shell snapshot and instead - * replays shell events after this sequence before streaming live events. - * Clients that already hold a cached (or HTTP-loaded) shell snapshot pass its - * sequence here so the subscription resumes without re-sending the entire - * projects/threads list (overlapping events are deduped by sequence on the - * client). - */ afterSequence: Schema.optionalKey(NonNegativeInt), - /** Requests a marker between initial catch-up and live delivery. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeShellInput = typeof OrchestrationV2SubscribeShellInput.Type; export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ threadId: ThreadId, - /** - * When provided, the server skips the initial snapshot frame and instead - * replays events after this sequence before streaming live events. Clients - * that load the snapshot over HTTP pass the snapshot's sequence here so the - * live subscription resumes without a gap (overlapping events are deduped by - * sequence on the client). - */ afterSequence: Schema.optionalKey(NonNegativeInt), - /** Requests a marker between initial catch-up and live delivery. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), - /** Allows snapshot fallbacks to contain a bounded, pageable history window. */ acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), - /** - * Allows bounded snapshot fallbacks to omit `projection.turnItems` entries - * that repeat local visible rows. See `turnItemsOmitLocalVisible`. - */ acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), }); export type OrchestrationV2SubscribeThreadInput = typeof OrchestrationV2SubscribeThreadInput.Type; @@ -3431,37 +3183,25 @@ export const OrchestrationV2ThreadDetailSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projection: OrchestrationV2ThreadProjection, /** - * Progressive history metadata. Omitted on full-projection caches and older - * clients. When present with hasMoreHistory/cursor, the projection is a - * bounded window and must not be treated as a complete timeline. + * When present with hasMoreHistory/cursor, the projection is a bounded window and must not be + * treated as a complete timeline. */ historyCursor: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), hasMoreHistory: Schema.optionalKey(Schema.Boolean), - /** - * Max local turn ordinal from the authoritative full projection at snapshot - * time. Optional for backward-compatible warm cache entries. - */ latestLocalTurnOrdinal: Schema.optionalKey(Schema.NullOr(NonNegativeInt)), }); export type OrchestrationV2ThreadDetailSnapshot = typeof OrchestrationV2ThreadDetailSnapshot.Type; /** - * Progressive cold open: full control-plane projection arrays with only a - * recent window of timeline rows. `historyCursor` is opaque; clients must not - * parse it. Resume live events with `afterSequence = snapshotSequence`. + * `historyCursor` is opaque; clients must not parse it. Resume live events with `afterSequence = + * snapshotSequence`. */ export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, projection: OrchestrationV2ThreadProjection, historyCursor: Schema.NullOr(TrimmedNonEmptyString), hasMoreHistory: Schema.Boolean, - /** - * Max local turn ordinal over the authoritative full projection turnItems. - * Clients use this as a partial-timeline watermark when the bounded window - * has no local rows (inherited-only). - */ latestLocalTurnOrdinal: Schema.NullOr(NonNegativeInt), - /** True when complete turns or required live control state exceed the usual byte budget. */ payloadBudgetExceeded: Schema.optional(Schema.Boolean), /** * Set only for clients that opted in: `projection.turnItems` omits the items @@ -3472,7 +3212,6 @@ export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({ }); export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type; -/** Older timeline page for progressive history. Rows are chronological. */ export const OrchestrationV2ThreadHistoryPage = Schema.Struct({ snapshotSequence: NonNegativeInt, items: projectedTurnItemArray( @@ -3494,7 +3233,6 @@ const knownDomainEventTypes: ReadonlySet = new Set( }), ); -/** Whether `type` is an {@link OrchestrationV2DomainEvent} discriminant in this build. */ export function isKnownOrchestrationV2EventType(type: string): boolean { return knownDomainEventTypes.has(type); } @@ -3503,8 +3241,7 @@ export function isKnownOrchestrationV2EventType(type: string): boolean { * A thread event whose type this build does not know, or a turn-item.updated * carrying a turn item type it does not know. Newer servers add both; older * clients decode them to this case and skip them, still advancing their resume - * cursor, instead of failing the whole subscription. A known type whose payload - * does not decode still fails. Decode-only: servers never send it. + * cursor, instead of failing the whole subscription. */ const OrchestrationV2UnknownThreadStreamEvent = Schema.Struct({ kind: Schema.Literal("event"), @@ -3546,15 +3283,10 @@ export const OrchestrationV2ThreadStreamItem = Schema.Union([ kind: Schema.Literal("snapshot"), snapshotSequence: NonNegativeInt, projection: OrchestrationV2ThreadProjection, - /** - * Progressive history metadata is present on bounded socket fallbacks and - * absent on legacy-compatible full snapshots. - */ historyCursor: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), hasMoreHistory: Schema.optionalKey(Schema.Boolean), latestLocalTurnOrdinal: Schema.optionalKey(Schema.NullOr(NonNegativeInt)), payloadBudgetExceeded: Schema.optionalKey(Schema.Boolean), - /** Same meaning as on `OrchestrationV2ThreadBoundedSnapshot`. */ turnItemsOmitLocalVisible: Schema.optionalKey(Schema.Literal(true)), }), Schema.Struct({ @@ -3645,8 +3377,6 @@ export type OrchestrationV2RpcError = typeof OrchestrationV2RpcError.Type; export const OrchestrationV2GetWorkflowScriptInput = Schema.Struct({ threadId: ThreadId, - /** Absolute path from the workflow's runHandles.scriptPath. The server - * re-derives containment; the client value is a hint, never trusted. */ scriptPath: TrimmedNonEmptyString, }); export type OrchestrationV2GetWorkflowScriptInput = @@ -3660,11 +3390,6 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({ export type OrchestrationV2GetWorkflowScriptResult = typeof OrchestrationV2GetWorkflowScriptResult.Type; -/** - * Shared by every read of a file the Claude harness persisted for a workflow - * run β€” the script and each member's transcript. One vocabulary because both - * reads apply identical containment rules. - */ const WORKFLOW_FILE_ERROR_MESSAGES = { "invalid-path": "Workflow files must be absolute paths with the expected extension.", "root-unavailable": "Workflow file root unavailable.", @@ -3701,12 +3426,10 @@ export class OrchestrationWorkflowFileError extends Schema.TaggedError { target: { providerInstanceId: "codex", model: "gpt-5.6-luna", - // Must fail loudly instead of being dropped like legacy persistence. options: { reasoning: 3 }, }, }), diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 80328d279794..382cea55bb15 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -68,12 +68,6 @@ const OrchestratorMcpSchedule = Schema.Union([ "Trigger object: {type:'interval', everyMs}, {type:'fixed_time', timeOfDay, weekdays?}, or {type:'webhook'} to run on each request to a generated URL. Never stringify it unless the provider requires the compatibility form.", }); -/** - * Shorthand `{ id: value }` record form for target model options. Unlike the - * legacy-tolerant `ProviderOptionSelections` persistence schema, this decodes - * strictly: a value that is not a string or boolean fails the request rather - * than being silently dropped. - */ const OrchestratorMcpTargetOptionsFromRecord = Schema.Record( Schema.String, ProviderOptionSelectionValue, @@ -113,13 +107,6 @@ export const OrchestratorMcpTarget = Schema.Struct({ description: "Model id advertised for the selected provider instance.", }), ), - /** - * Model option selections for the child (for example reasoning effort). - * Accepts the canonical `[{ id, value }]` array or the shorthand - * `{ id: value }` record; valid ids come from the option descriptors - * advertised by orchestrator_capabilities. When omitted, options inherit - * from the parent only when the child runs the parent's provider and model. - */ options: Schema.optional( OrchestratorMcpTargetOptions.annotate({ description: "Model option selections advertised by orchestrator_capabilities.", @@ -332,7 +319,6 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ settled: Schema.Boolean, settledAt: Schema.NullOr(IsoDateTime), snoozed: Schema.Boolean, - /** When a snoozed thread wakes; null when it is not snoozed. */ snoozedUntil: Schema.NullOr(IsoDateTime), parentThreadId: Schema.NullOr(ThreadId), relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])), @@ -344,7 +330,6 @@ export type OrchestratorMcpThreadListItem = typeof OrchestratorMcpThreadListItem export const OrchestratorMcpThreadListResult = Schema.Struct({ projectId: ProjectId, - /** The calling thread, or null when the caller is not a T3 thread. */ currentThreadId: Schema.NullOr(ThreadId), threads: Schema.Array(OrchestratorMcpThreadListItem), nextCursor: Schema.NullOr(NonNegativeInt), @@ -390,7 +375,6 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ settled: Schema.Boolean, settledAt: Schema.NullOr(IsoDateTime), snoozed: Schema.Boolean, - /** When a snoozed thread wakes; null when it is not snoozed. */ snoozedUntil: Schema.NullOr(IsoDateTime), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -450,7 +434,6 @@ export const OrchestratorMcpThreadSendResult = Schema.Struct({ threadId: ThreadId, messageId: MessageId, runId: RunId, - /** Fresh task for a follow-up to an app-owned child; the previous task stays terminal. */ taskId: Schema.optional(NodeId), status: OrchestrationV2RunStatus, delivery: Schema.Literals(["started", "queued", "steered", "restarted"]), @@ -499,11 +482,9 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ Schema.Struct({ id: Schema.String, label: Schema.NullOr(Schema.String), - /** Model options a target may select (for example reasoning effort). */ options: Schema.optional(Schema.Array(ProviderOptionDescriptor)), }), ), - /** Runtime modes the provider offers; omitted when it offers all of them. */ runtimeModes: Schema.optional(Schema.Array(RuntimeMode)), canRunChildTask: Schema.Boolean, canRunCrossProviderChildTask: Schema.Boolean, @@ -512,9 +493,7 @@ export const OrchestratorMcpProviderCapability = Schema.Struct({ export type OrchestratorMcpProviderCapability = typeof OrchestratorMcpProviderCapability.Type; export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ - /** The calling thread, or null when the caller is not a T3 thread. */ parentThreadId: Schema.NullOr(ThreadId), - /** The calling thread's selection, or null when the caller is not a T3 thread. */ inheritedProviderInstanceId: Schema.NullOr(ProviderInstanceId), inheritedModel: Schema.NullOr(Schema.String), runtimeMode: RuntimeMode, @@ -543,11 +522,6 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ enabled: Schema.optional( Schema.Boolean.annotate({ description: "Whether the schedule starts enabled; defaults true." }), ), - /** - * When true (the default), the scheduled task fires into the calling thread - * on each run instead of launching a fresh thread. This is the recurring - * "wake up in this thread" behaviour reserved for agent-created tasks. - */ bindToCurrentThread: Schema.optional( Schema.Boolean.annotate({ description: @@ -558,7 +532,6 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ }); export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type; -/** Summary of a scheduled task returned by the schedule/list/update tools. */ export const OrchestratorMcpScheduledTask = Schema.Struct({ scheduledTaskId: ScheduledTaskId, title: Schema.String, @@ -569,7 +542,6 @@ export const OrchestratorMcpScheduledTask = Schema.Struct({ schedule: ScheduledTaskSchedule, nextRunAt: Schema.NullOr(IsoDateTime), lastRunStatus: ScheduledTaskRunStatus, - /** For webhook tasks: the public T3 Connect URL. Absent when this environment has no managed tunnel. */ webhookUrl: Schema.optional(Schema.String).annotate({ description: "Public URL to give the sender. Absent when this environment has no T3 Connect managed tunnel; the user must enable T3 Connect remote access first.", diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 802bbd4765b7..455663155cb4 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -1,13 +1,3 @@ -/** - * Preview - Schemas for the in-app browser preview surface. - * - * The preview is desktop-only (Chromium ); the server tracks per-thread - * tab metadata so it survives client reconnects and multi-window. The desktop - * renderer mediates: it owns the actual and reports navigation back to - * the server via these RPCs, the server fans events to all subscribers. - * - * @module Preview - */ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BROWSER_PROFILE_MAX_COUNT, BrowserProfile, BrowserProfileId } from "./browserProfile.ts"; @@ -82,11 +72,6 @@ export const PREVIEW_VIEWPORT_PRESET_IDS = [ export const PreviewViewportPresetId = Schema.Literals(PREVIEW_VIEWPORT_PRESET_IDS); export type PreviewViewportPresetId = typeof PreviewViewportPresetId.Type; -/** - * Preset IDs shipped before the Chrome-compatible catalog. Existing sessions - * can still reconnect with these values, but new resize requests only expose - * PREVIEW_VIEWPORT_PRESET_IDS. - */ const LEGACY_PREVIEW_VIEWPORT_PRESET_IDS = [ "desktop-1920x1080", "desktop-1440x900", @@ -120,9 +105,8 @@ export const FILL_PREVIEW_VIEWPORT = { } as const satisfies PreviewViewportSetting; /** - * Discrete zoom levels mirroring Chrome's preset ladder. Zoom is applied by the - * desktop main process to the Chromium guest, but the ladder lives here so the - * settings UI can offer exactly the steps the zoom controls step through. + * Zoom is applied by the desktop main process to the Chromium guest, but the ladder lives here so + * the settings UI can offer exactly the steps the zoom controls step through. */ export const PREVIEW_ZOOM_LEVELS = [ 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0, @@ -133,17 +117,11 @@ export type PreviewZoomFactor = typeof PreviewZoomFactor.Type; export const DEFAULT_PREVIEW_ZOOM_FACTOR: PreviewZoomFactor = 1.0; -/** - * Preferred `prefers-color-scheme` for preview guests. `system` clears the - * emulation override so the guest follows the OS. Structurally identical to - * `DesktopPreviewColorScheme`, which is the IPC-layer spelling of the same set. - */ export const PreviewAppearancePreference = Schema.Literals(["system", "light", "dark"]); export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Type; export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system"; -/** A file a server tab downloaded, served by the tab's download route by this id. */ const PreviewFileDownload = Schema.Struct({ id: Schema.String, fileName: Schema.String }); export const PreviewNavStatus = Schema.Union([ @@ -161,34 +139,21 @@ export const PreviewNavStatus = Schema.Union([ title: Title, code: Schema.Int, description: Schema.String, - /** - * Set when the address was a file the browser downloaded instead of a page - * it could show (a PDF in a server tab). The server tab's download route - * serves it by this id. - */ download: Schema.optional(PreviewFileDownload), }), ]); export type PreviewNavStatus = typeof PreviewNavStatus.Type; -/** - * Where a tab's page runs. `desktop` is an Electron owned by one - * desktop client; `server` is headless Chromium owned by the environment - * server, viewed by any client through `/api/preview-stream` and driven by - * agents with no client attached. Absent means `desktop`. - */ export const PreviewRuntime = Schema.Literals(["desktop", "server"]); export type PreviewRuntime = typeof PreviewRuntime.Type; /** * Host setup the server's browser is missing, sent as JSON in the reason when - * the preview stream closes with code 4503. Fixing it needs the host's - * operator, so viewers stop retrying and show `command`, the one line that - * fixes it. Close reasons are capped at 123 bytes, which this fits. + * the preview stream closes with code 4503. Close reasons are capped at 123 + * bytes, which this fits. */ export const PreviewStreamHostSetup = Schema.Struct({ need: Schema.Literals(["sandbox", "libraries"]), - /** For example `sudo npx t3 browser setup`, matching how the server was launched. */ command: Schema.String, }); export type PreviewStreamHostSetup = typeof PreviewStreamHostSetup.Type; @@ -200,11 +165,8 @@ export const PreviewSessionSnapshot = Schema.Struct({ navStatus: PreviewNavStatus, canGoBack: Schema.Boolean, canGoForward: Schema.Boolean, - /** Missing snapshots from older servers are treated as fill-panel mode. */ viewport: Schema.optional(PreviewViewportSetting), - /** Server tabs only; the desktop keeps its own tabs' appearance. Absent means `system`. */ colorScheme: Schema.optional(PreviewAppearancePreference), - /** Server tabs only. Absent means 1. */ zoomFactor: Schema.optional(PreviewZoomFactor), /** * Browser profile the tab's Chromium partition is derived from. Fixed at @@ -213,11 +175,8 @@ export const PreviewSessionSnapshot = Schema.Struct({ */ profileId: Schema.optional(BrowserProfileId), runtime: Schema.optional(PreviewRuntime), - /** Authenticated provider session owning an isolated server tab. */ automationOwner: Schema.optional(Schema.String), - /** An agent opened this tab and asked to show it, so viewers float it. */ reveal: Schema.optional(Schema.Boolean), - /** A fresh presentation request, including whether it overrides automatic-floating settings. */ revealRequest: Schema.optional(Schema.Struct({ id: Schema.String, force: Schema.Boolean })), updatedAt: Schema.String, }); @@ -225,20 +184,10 @@ export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; export const PreviewOpenInput = Schema.Struct({ threadId: ThreadId, - /** Omit to create an empty (Idle) tab the user can type into. */ url: Schema.optional(Url), - /** - * Initial viewport for the new tab. Omitting it keeps the historical - * fill-panel behaviour; clients that have a configured default send it here - * so the session is born at the right size instead of being resized a frame - * later (which the user would see as a visible reflow). - */ viewport: Schema.optional(PreviewViewportSetting), - /** Omit to open under the client's configured default profile. */ profileId: Schema.optional(BrowserProfileId), - /** Omit for a desktop tab. `server` requires the `serverBrowser` capability. */ runtime: Schema.optional(PreviewRuntime), - /** Set by agent opens that should float for viewers; see the snapshot field. */ reveal: Schema.optional(Schema.Boolean), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type; @@ -274,17 +223,14 @@ export const PreviewResizeInput = Schema.Struct({ export type PreviewResizeInput = typeof PreviewResizeInput.Type; /** - * Changes how a server tab renders, or clears its profile's site data, for any - * client. Unlike page input, these need no control of the tab. + * Unlike page input, these need no control of the tab. */ export const PreviewAdjustInput = Schema.Struct({ threadId: ThreadId, tabId: PreviewTabId, colorScheme: Schema.optional(PreviewAppearancePreference), zoomFactor: Schema.optional(PreviewZoomFactor), - /** Reloads the page past its cache, as Chrome's Shift+Reload does. */ hardReload: Schema.optional(Schema.Boolean), - /** Clears the tab's profile's cookies or HTTP cache; its other storage stays. */ clear: Schema.optional(Schema.Literals(["cookies", "cache"])), }); export type PreviewAdjustInput = typeof PreviewAdjustInput.Type; @@ -295,9 +241,7 @@ export const PreviewCloseInput = Schema.Struct({ }); export type PreviewCloseInput = typeof PreviewCloseInput.Type; -/** A client's browser profiles, which agents choose from when they open a tab. */ export const PreviewReportProfilesInput = Schema.Struct({ - /** The user's own profiles; the server adds the built-ins. */ profiles: Schema.Array(BrowserProfile).check(Schema.isMaxLength(BROWSER_PROFILE_MAX_COUNT)), defaultProfileId: BrowserProfileId, }); @@ -317,7 +261,6 @@ export const PreviewListResult = Schema.Struct({ sessions: Schema.Array(PreviewSessionSnapshot), /** Identifies the current server process so revision resets are safe. */ serverEpoch: TrimmedNonEmptyString, - /** Monotonic server state revision used to reject stale list responses. */ revision: NonNegativeInt, }); export type PreviewListResult = typeof PreviewListResult.Type; @@ -326,7 +269,6 @@ const PreviewEventBaseSchema = Schema.Struct({ threadId: TrimmedNonEmptyString, tabId: PreviewTabId, createdAt: Schema.String, - /** Identifies the server process that emitted this event. */ serverEpoch: TrimmedNonEmptyString, /** Monotonic server state revision shared with PreviewListResult. */ revision: PositiveInt, @@ -348,7 +290,6 @@ const PreviewResizedEvent = Schema.Struct({ ...PreviewEventBaseSchema.fields, type: Schema.Literal("resized"), snapshot: PreviewSessionSnapshot, - /** A one-off action for the server's browser, such as a hard reload. */ request: Schema.optional( Schema.Struct({ hardReload: Schema.optional(Schema.Boolean), @@ -381,10 +322,6 @@ export const PreviewEvent = Schema.Union([ ]); export type PreviewEvent = typeof PreviewEvent.Type; -/** - * A localhost server detected by the port scanner. Used to populate the - * "Local" recommendations in the empty-state of the preview panel. - */ export const DiscoveredLocalServer = Schema.Struct({ host: TrimmedNonEmptyString, port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 504d28148b29..0e9ff94d6ffe 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -25,7 +25,6 @@ const OptionalTimeoutMs = Schema.optional( .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }), ).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }); -/** Operations understood by desktop hosts predating viewport resizing. */ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [ "status", "open", @@ -41,7 +40,6 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [ "recordingStop", ] as const; -/** Advertised by current desktop hosts for mixed-version routing. */ const PREVIEW_AUTOMATION_OPERATIONS = [ ...PREVIEW_AUTOMATION_V1_OPERATIONS, "resize", @@ -75,7 +73,6 @@ const PreviewAutomationTabTargetFields = { export const PreviewAutomationTabTargetInput = Schema.Struct(PreviewAutomationTabTargetFields); export type PreviewAutomationTabTargetInput = typeof PreviewAutomationTabTargetInput.Type; -/** `human`: a person controls the tab now; `agent`: an agent session opened it; `unclaimed`: neither. */ export const PreviewAutomationTabOwner = Schema.Literals(["agent", "human", "unclaimed"]); export const PreviewAutomationProfile = Schema.Struct({ @@ -108,15 +105,11 @@ export const PreviewAutomationStatus = Schema.Struct({ }), ), ), - /** Optional for compatibility with desktop hosts predating viewport sizing. */ viewportSetting: Schema.optional(PreviewViewportSetting), - /** Measured guest-page viewport in CSS pixels when a webview is ready. */ viewport: Schema.optional(PreviewRenderedViewportSize), - /** Server hosts: a file picker the page opened, answered with preview_upload. */ fileChooser: Schema.optional( Schema.NullOr(Schema.Struct({ multiple: Schema.Boolean, accept: Schema.String })), ), - /** Server hosts: files this tab downloaded, saved on the environment until the tab closes. */ downloads: Schema.optional( Schema.Array( Schema.Struct({ @@ -146,9 +139,7 @@ export const PreviewAutomationStatus = Schema.Struct({ }), ), ), - /** Server hosts: the browser profiles preview_open accepts, as the user's client reported them. */ profiles: Schema.optional(Schema.Array(PreviewAutomationProfile)), - /** Server hosts: the profile preview_open uses when given none. */ defaultProfileId: Schema.optional(BrowserProfileId), }); export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; @@ -481,7 +472,7 @@ export const PreviewAutomationSelectResult = Schema.Struct({ }); export type PreviewAutomationSelectResult = typeof PreviewAutomationSelectResult.Type; -/** A required locator field. Its JSON schema keeps only the description on its last check. */ +/** Its JSON schema keeps only the description on its last check. */ const DescribedLocator = (description: string) => Schema.String.check(Schema.isTrimmed()) .check(Schema.isNonEmpty({ description })) @@ -775,8 +766,7 @@ export type PreviewAutomationHostIdentity = typeof PreviewAutomationHostIdentity export const PreviewAutomationHost = Schema.Struct({ ...PreviewAutomationHostIdentity.fields, /** - * Missing means the pre-capability-negotiation V1 operation set. This lets - * a newer server safely coexist with an older desktop during rollout. + * Missing means the pre-capability-negotiation V1 operation set. */ supportedOperations: Schema.optional(Schema.Array(PreviewAutomationOperation)), }); @@ -840,7 +830,6 @@ export const PreviewAutomationResponse = Schema.Struct({ }); export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; -// Thread fields are absent when the caller signed in from outside a T3 thread. const McpCapabilityErrorFields = { environmentId: EnvironmentId, threadId: Schema.optional(ThreadId), @@ -848,7 +837,6 @@ const McpCapabilityErrorFields = { providerInstanceId: Schema.optional(ProviderInstanceId), }; -/** Agents read this message, so it names the next step and not only the failure. */ export class PreviewAutomationUnavailableError extends Schema.TaggedError()( "PreviewAutomationUnavailableError", { @@ -861,7 +849,6 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedError()( "McpCapabilityUnavailableError", { @@ -882,7 +869,6 @@ const PreviewAutomationScopeErrorFields = { providerInstanceId: ProviderInstanceId, }; -/** The automation host id of an environment's own server browser. */ export const SERVER_BROWSER_AUTOMATION_CLIENT_ID = "server-browser"; const PreviewAutomationRequestErrorFields = { @@ -1010,10 +996,6 @@ export class PreviewAutomationExecutionError extends Schema.TaggedError { }); expect(Object.hasOwn(create, "scripts")).toBe(false); expect(Object.hasOwn(update, "title")).toBe(false); - // Internal RPC callers may explicitly supply undefined, as before the extraction. expect( decodeProjectMutation({ type: "project.update", ...envelope, title: undefined }), ).toHaveProperty("title", undefined); @@ -257,7 +256,6 @@ effectIt.effect("an icon kind from a newer server shows the default icon", () => updatedAt: "2026-01-01T00:00:00.000Z", }); assert.isNull(shell.projectIcon); - // A known kind with a broken payload still fails. const broken = yield* Effect.exit(decodeReceivedIcon({ kind: "emoji" })); assert.strictEqual(broken._tag, "Failure"); }), diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index baff3dc82705..d377311620a8 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -36,9 +36,7 @@ export const ProjectScript = Schema.Struct({ command: TrimmedNonEmptyString, icon: ProjectScriptIcon, runOnWorktreeCreate: Schema.Boolean, - /** Run in the thread's worktree each time the thread settles. */ runOnSettle: Schema.optional(Schema.Boolean), - /** Start the agent while setup runs unless explicitly disabled. */ async: Schema.optional(Schema.Boolean), previewUrl: Schema.optional(TrimmedNonEmptyString), autoOpenPreview: Schema.optional(Schema.Boolean), @@ -94,14 +92,12 @@ const ProjectMonogramIcon = Schema.Struct({ text: ProjectMonogramText, color: ProjectIconColor, }); -/** A workspace-relative image a project may use as its favicon. */ export const ProjectFaviconPath = TrimmedNonEmptyString.check( Schema.isMaxLength(1024), Schema.isPattern(/\.(?:avif|gif|icns|ico|jpe?g|png|svg|webp)$/i), ); export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; -/** A project's chosen icon, as clients set it. */ export const ProjectIconOverride = Schema.Union([ ProjectLucideIcon, ProjectEmojiIcon, @@ -109,11 +105,6 @@ export const ProjectIconOverride = Schema.Union([ ]); export type ProjectIconOverride = typeof ProjectIconOverride.Type; -/** - * Before v2, servers sent a monogram as a lucide icon carrying its text, so - * older clients showed a folder. Icons stored then, and v2 servers released - * before this change, still use that shape; it is read here, never written. - */ const ProjectLucideIconWithLegacyMonogram = Schema.Struct({ ...ProjectLucideIcon.fields, monogramText: Schema.optional(ProjectMonogramText), @@ -134,7 +125,6 @@ const fromLegacyMonogram = (icon: ProjectIconMember): ProjectIconOverride => { : { kind: "monogram", text, color: icon.color }; }; -/** An icon the server stores; it reads legacy monograms and writes the plain shape. */ export const StoredProjectIcon = Schema.Union(projectIconMembers).pipe( Schema.decodeTo( Schema.toType(ProjectIconOverride), @@ -145,10 +135,6 @@ export const StoredProjectIcon = Schema.Union(projectIconMembers).pipe( ), ); -/** - * An icon as clients receive it. A kind from a newer server decodes as no - * override, so the client shows the project's default icon. - */ export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "kind").pipe( Schema.decodeTo( Schema.NullOr(Schema.toType(ProjectIconOverride)), @@ -253,8 +239,6 @@ export type ProjectEntryKind = typeof ProjectEntryKind.Type; export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - // An empty query is a bounded browse: the index returns frecency-ordered - // entries, which the file picker uses for its initial results. query: TrimmedString.check(Schema.isMaxLength(256)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), kind: Schema.optional(ProjectEntryKind), @@ -310,8 +294,6 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - // Present for immediate filesystem children, including ignored entries; empty means root. - // Omitted preserves the indexed recursive listing used by older clients. directoryPath: Schema.optional(TrimmedString), }); export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; @@ -361,8 +343,6 @@ export class ProjectSearchEntriesError extends Schema.TaggedError, ): string { @@ -111,7 +97,6 @@ export function projectCloneDisplayName( return segments[segments.length - 1] ?? snapshot.destinationPath; } -/** One-line progress summary: `Receiving objects Β· 45% Β· 12.3 MiB | 5.0 MiB/s`. */ export function projectCloneProgressSummary( snapshot: Pick, ): string { diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index f48ca86fbf08..e1c18042f98e 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -36,9 +36,6 @@ const ProviderSessionStatus = Schema.Literals([ export const ProviderSession = Schema.Struct({ provider: ProviderDriverKind, - // Optional during the driver/instance migration. Once every producer - // populates it (post-slice-4), routing flips to instance-id-only and the - // legacy `provider` field is removed. providerInstanceId: Schema.optional(ProviderInstanceId), status: ProviderSessionStatus, runtimeMode: RuntimeMode, @@ -56,7 +53,6 @@ export type ProviderSession = typeof ProviderSession.Type; export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, provider: Schema.optional(ProviderDriverKind), - // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), title: Schema.optional(TrimmedNonEmptyString), @@ -148,7 +144,6 @@ export const ProviderEvent = Schema.Struct({ id: EventId, kind: ProviderEventKind, provider: ProviderDriverKind, - // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), threadId: ThreadId, createdAt: IsoDateTime, diff --git a/packages/contracts/src/providerInstance.ts b/packages/contracts/src/providerInstance.ts index c47bf992a4f4..09d90027b804 100644 --- a/packages/contracts/src/providerInstance.ts +++ b/packages/contracts/src/providerInstance.ts @@ -1,51 +1,8 @@ -/** - * Provider-instance contracts. - * - * Splits the historical "provider kind" concept into two: - * - * - `ProviderDriverKind` is the implementation kind selector (e.g. codex, - * claudeAgent, a fork's `ollama`, …). It picks which driver package - * handles the protocol, the probe, the adapter, and text generation. - * - * - `ProviderInstanceId` is the routing key (a user-defined slug). - * Threads, sessions, runtime events, and persisted bindings reference - * instance ids β€” never driver kinds β€” so a user can configure multiple - * instances of the same driver (e.g. `codex_personal` + `codex_work`), - * each with independent driver-specific configuration. - * - * Forward/backward compatibility invariant - * ---------------------------------------- - * `ProviderDriverKind` is intentionally an **open** branded slug, not a closed - * literal union. The server hosts forks, ships in PRs that add drivers, and - * users frequently roll between branches and forks. Any of those paths can - * leave `ServerSettings`, persisted thread state, or session bindings - * referencing a driver that the currently-running build does not know about. - * - * The rule: parsing any of those payloads must always succeed, and the - * runtime is responsible for marking the unknown driver/instance as - * "unavailable" rather than crashing. Built-in drivers shipped by the core - * product happens to register in a given build is not part of the contract - * layer. Driver availability is discovered through the runtime registry. - * - * Driver-specific configuration is similarly opaque at the contracts layer: - * drivers live in (or will be extracted to) their own packages and own their - * config schemas. The contracts package only knows the envelope. - * - * @module providerInstance - */ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROVIDER_SLUG_MAX_CHARS = 64; -/** - * Slug pattern shared by driver kinds and instance ids β€” letters, digits, - * dashes, underscores. The first character must be a letter so slugs remain - * JS-identifier friendly when used as object keys, log fields, or telemetry - * attributes. Mixed case is permitted so historical driver kinds (e.g. - * `claudeAgent`) can be used verbatim during the migration and so external - * fork authors retain reasonable freedom. - */ const PROVIDER_SLUG_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const ENVIRONMENT_VARIABLE_NAME_MAX_CHARS = 128; const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; @@ -55,18 +12,6 @@ const slugSchema = TrimmedNonEmptyString.check( Schema.isPattern(PROVIDER_SLUG_PATTERN), ); -/** - * `ProviderDriverKind` β€” open branded slug naming a driver implementation. - * - * Constraints (validated at the schema layer): - * - starts with a letter - * - only letters, digits, `-`, `_` after the first char - * - 1..64 characters - * - * Notably **not** validated: that the driver is one we know how to load. - * That check belongs to the runtime registry, which downgrades unknown - * drivers gracefully (see module docs). - */ export const ProviderDriverKind = slugSchema.pipe(Schema.brand("ProviderDriverKind")); export type ProviderDriverKind = typeof ProviderDriverKind.Type; @@ -74,20 +19,9 @@ const isProviderDriverKindValue = Schema.is(ProviderDriverKind); export const isProviderDriverKind = (value: unknown): value is ProviderDriverKind => isProviderDriverKindValue(value); -/** - * `ProviderInstanceId` β€” user-defined routing key for a configured provider - * instance. Same slug rules as `ProviderDriverKind`; branded separately so the - * type system cannot confuse the two. - */ export const ProviderInstanceId = slugSchema.pipe(Schema.brand("ProviderInstanceId")); export type ProviderInstanceId = typeof ProviderInstanceId.Type; -/** - * Lightweight reference identifying which driver implements an instance. - * Carried alongside `ProviderInstanceId` on wire shapes so consumers can - * branch on driver behavior (icons, capabilities, presentation) without - * having to look up the instance in the registry. - */ export const ProviderInstanceRef = Schema.Struct({ instanceId: ProviderInstanceId, driver: ProviderDriverKind, @@ -113,13 +47,10 @@ export const ProviderInstanceEnvironment = Schema.Array(ProviderInstanceEnvironm export type ProviderInstanceEnvironment = typeof ProviderInstanceEnvironment.Type; /** - * Envelope shape for a provider instance configuration in `ServerSettings`. - * - * `driver` is intentionally accepted as any well-formed slug (see module - * docs). The driver-specific config payload is left as `Schema.Unknown`; - * each driver registers its own decoder with the runtime registry, and - * envelopes for unknown drivers are preserved verbatim so they round-trip - * across version changes without data loss. + * The driver-specific config payload is left as `Schema.Unknown`; each driver + * registers its own decoder with the runtime registry, and envelopes for + * unknown drivers are preserved verbatim so they round-trip across version + * changes without data loss. */ export const ProviderInstanceConfig = Schema.Struct({ driver: ProviderDriverKind, @@ -131,7 +62,6 @@ export const ProviderInstanceConfig = Schema.Struct({ }); export type ProviderInstanceConfig = typeof ProviderInstanceConfig.Type; -/** Atomic mutation for one provider-instance map entry. */ export const ProviderInstanceMutation = Schema.Union([ Schema.Struct({ operation: Schema.Literal("create"), @@ -150,19 +80,14 @@ export const ProviderInstanceMutation = Schema.Union([ ]); export type ProviderInstanceMutation = typeof ProviderInstanceMutation.Type; -/** - * Map shape for `ServerSettings.providerInstances`. Keyed by - * `ProviderInstanceId`, values are envelopes the registry feeds to drivers. - */ export const ProviderInstanceConfigMap = Schema.Record(ProviderInstanceId, ProviderInstanceConfig); export type ProviderInstanceConfigMap = typeof ProviderInstanceConfigMap.Type; /** - * Construct the canonical `ProviderInstanceId` used as a back-compat default - * for a built-in driver. The legacy single-instance-per-driver world used - * the driver kind itself as the instance id; preserving that mapping keeps - * existing persisted threads, bindings, and cache files routable across the - * migration without rewriting their stored selection payloads. + * The legacy single-instance-per-driver world used the driver kind itself as + * the instance id; preserving that mapping keeps existing persisted threads, + * bindings, and cache files routable across the migration without rewriting + * their stored selection payloads. */ export const defaultInstanceIdForDriver = (driver: ProviderDriverKind): ProviderInstanceId => ProviderInstanceId.make(driver); diff --git a/packages/contracts/src/providerPolicy.ts b/packages/contracts/src/providerPolicy.ts index aa767e6dec1e..52e54bbf108e 100644 --- a/packages/contracts/src/providerPolicy.ts +++ b/packages/contracts/src/providerPolicy.ts @@ -59,7 +59,6 @@ export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; export const ProviderApprovalOption = Schema.Struct({ decision: ProviderApprovalDecision, label: TrimmedNonEmptyString, - /** Provider-supplied caution shown next to the option, such as a prompt injection warning. */ warning: Schema.optional(TrimmedNonEmptyString), }); export type ProviderApprovalOption = typeof ProviderApprovalOption.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 2f8de3dea24b..addcef327fc1 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -202,9 +202,6 @@ const RuntimeErrorType = Schema.Literal("runtime.error"); const ProviderRuntimeEventBase = Schema.Struct({ eventId: EventId, provider: ProviderDriverKind, - // Optional during the driver/instance migration. See providerInstance.ts - // for the routing-key-vs-driver-id distinction. Once every emitter - // populates it (post-slice-4), routing flips to instance-id-only. providerInstanceId: Schema.optional(ProviderInstanceId), threadId: ThreadId, createdAt: IsoDateTime, @@ -323,10 +320,8 @@ const TurnStartedPayload = Schema.Struct({ export type TurnStartedPayload = typeof TurnStartedPayload.Type; /** - * Normalized main-agent usage for one turn. Input includes cache reads and - * writes. Output includes reasoning, and reasoningTokens is an optional subset. - * Complete means the provider supplied full input and output totals. Partial - * means every included count is valid, but the full turn total is not known. + * Input includes cache reads and writes. Output includes reasoning, and + * reasoningTokens is an optional subset. */ const TurnTokenUsageCommonFields = { usageScope: Schema.Literal("main_agent"), @@ -444,11 +439,6 @@ export const ItemLifecyclePayload = Schema.Struct({ toolIcon: Schema.optional(ToolActivityIcon), toolSource: Schema.optional(ToolActivitySource), data: Schema.optional(Schema.Unknown), - /** - * Owning agent when this item ran inside a subagent (resolved from the - * SDK's parent_tool_use_id). Clients re-home attributed items out of the - * main timeline and into the owning agent's Agents-surface row. - */ agentId: Schema.optional(TrimmedNonEmptyStringSchema), parentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), }); @@ -491,7 +481,6 @@ export const UserInputQuestion = Schema.Struct({ question: TrimmedNonEmptyStringSchema, options: Schema.Array(UserInputQuestionOption), allowCustomAnswer: Schema.optional(Schema.Boolean), - /** Editable initial text. Answers preserve whitespace and allow an empty string when present. */ initialAnswer: Schema.optional(Schema.String), multiSelect: Schema.optional(Schema.Boolean).pipe( Schema.withConstructorDefault(Effect.succeed(false)), @@ -511,8 +500,6 @@ const UserInputResolvedPayload = Schema.Struct({ export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type; /** - * Typed per-task usage rollup. Field names match the orchestration-v2 subagent - * usage vocabulary (#4779) so the eventual migration is a rename, not a remap. * Claude reports per-activation deltas; Codex reports cumulative totals β€” the * merge strategy is provider-specific and lives in client-runtime. */ @@ -543,31 +530,16 @@ export const TaskRunHandles = Schema.Struct({ export type TaskRunHandles = typeof TaskRunHandles.Type; /** - * Optional agent-identity linkage carried on every task lifecycle payload. * Repeated on progress and terminal rows (not just start) so client folds can * reconstruct an agent even when its start row aged out of activity retention. - * All fields optional: old emitters and old rows decode unchanged. */ const taskAgentLinkageFields = { - /** SDK task_type (subagent/shell/monitor/local_workflow/…), repeated on - * every row so folds can classify without the start row. */ taskType: Schema.optional(TrimmedNonEmptyStringSchema), - /** - * Server-stamped classification, set at ingestion. - * Clients trust this stamp outright; rows without it (legacy, pre-stamp) - * fall back to client-side heuristics. - */ agentKind: Schema.optional(Schema.Literals(["agent", "background"])), - /** - * Owning agent when the task itself was launched from inside a subagent - * (e.g. a subagent's background shell). Clients treat such tasks as - * agent-internal and keep them out of the parent work log. - */ agentId: Schema.optional(TrimmedNonEmptyStringSchema), title: Schema.optional(TrimmedNonEmptyStringSchema), role: Schema.optional(TrimmedNonEmptyStringSchema), model: Schema.optional(TrimmedNonEmptyStringSchema), - /** Reasoning effort when known (e.g. "high"). Open string: provider vocabularies differ. */ effort: Schema.optional(TrimmedNonEmptyStringSchema), toolUseId: Schema.optional(TrimmedNonEmptyStringSchema), parentAgentId: Schema.optional(TrimmedNonEmptyStringSchema), @@ -579,7 +551,6 @@ const taskAgentLinkageFields = { attempt: Schema.optional(NonNegativeInt), runHandles: Schema.optional(TaskRunHandles), outputFile: Schema.optional(TrimmedNonEmptyStringSchema), - /** Codex agent hierarchy path, e.g. "/root/marlow". */ agentPath: Schema.optional(TrimmedNonEmptyStringSchema), /** * Set on provider-synthesized child-agent events (Codex) whose activity @@ -617,18 +588,12 @@ const TaskProgressPayload = Schema.Struct({ usage: Schema.optional(Schema.Unknown), typedUsage: Schema.optional(RuntimeTaskUsage), lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), - /** Present on synthesized member/child progress rows that carry state. */ status: Schema.optional(RuntimeTaskStatus), error: Schema.optional(TrimmedNonEmptyStringSchema), ...taskAgentLinkageFields, }); export type TaskProgressPayload = typeof TaskProgressPayload.Type; -/** - * Non-terminal status patch (from the Claude SDK's task_updated, which main - * previously dropped). killedβ†’cancelled and pausedβ†’idle are mapped at the - * adapter so the wire only carries the shared vocabulary. - */ const TaskUpdatedPayload = Schema.Struct({ taskId: RuntimeTaskId, status: Schema.optional(RuntimeTaskStatus), @@ -680,7 +645,6 @@ const ToolProgressPayload = Schema.Struct({ toolName: Schema.optional(TrimmedNonEmptyStringSchema), summary: Schema.optional(TrimmedNonEmptyStringSchema), elapsedSeconds: Schema.optional(Schema.Number), - /** Owning task/agent when the tool ran inside a subagent. */ taskId: Schema.optional(RuntimeTaskId), parentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema), }); @@ -704,10 +668,6 @@ const AccountUpdatedPayload = Schema.Struct({ }); export type AccountUpdatedPayload = typeof AccountUpdatedPayload.Type; -/** - * Adapters normalise their native rate-limit payload at the boundary so the - * consumer that folds it into the provider snapshot never sees driver shapes. - */ const AccountRateLimitsUpdatedPayload = Schema.Struct({ limits: ProviderUsageLimitsUpdate, }); diff --git a/packages/contracts/src/providerSetup.ts b/packages/contracts/src/providerSetup.ts index 985d29c38855..69394bed3c14 100644 --- a/packages/contracts/src/providerSetup.ts +++ b/packages/contracts/src/providerSetup.ts @@ -41,8 +41,6 @@ export const ProviderAuthMethod = Schema.Struct({ }); export type ProviderAuthMethod = typeof ProviderAuthMethod.Type; -// These describe client interactions, not OAuth grant types. The provider -// adapter remains responsible for credentials, callbacks, and refresh. export const ProviderAuthInteraction = Schema.Union([ Schema.Struct({ type: Schema.Literal("browser"), @@ -201,7 +199,6 @@ export class ProviderSetupError extends Schema.TaggedError() } } -// A selected registration is reused on the primary without copying refresh ownership. export const ChatGptReconnectProfile = Schema.Struct({ clientId: Schema.String.check(Schema.isPattern(/^oaiapp_[\w-]+$/u)), subject: Schema.optionalKey(Schema.String), diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0108bbb669eb..894a33a05e5f 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -10,12 +10,8 @@ import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; /** - * One rolling quota window a subscription provider reports for the signed-in - * account, e.g. Claude's five-hour session or Codex's weekly allowance. - * * `id` is stable per provider (`five_hour`, `seven_day_opus`, `primary`) so a * sparse turn-driven update lands on the same row a full probe produced. - * `kind` only orders and labels the bar. */ export const ServerProviderUsageWindow = Schema.Struct({ id: TrimmedNonEmptyString, @@ -27,11 +23,6 @@ export const ServerProviderUsageWindow = Schema.Struct({ }); export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; -/** - * Reset credits a provider banks on the account. Codex grants these when it - * has rate-limited the user unfairly; redeeming one clears the current - * windows. Only present when the provider reports them at all. - */ export const ServerProviderResetCredits = Schema.Struct({ availableCount: NonNegativeInt, nextExpiresAt: Schema.optional(IsoDateTime), @@ -41,8 +32,6 @@ export const ServerProviderResetCredits = Schema.Struct({ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; /** - * Subscription usage the provider knows about the signed-in account. - * * `unavailable` distinguishes an account that can never report windows (API * key, Bedrock) from a probe that failed this time, so clients can keep the * last good bars for the latter and clear them for the former. @@ -50,10 +39,8 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, windows: ForwardCompatibleArray(ServerProviderUsageWindow), - /** Opaque credential identity when the provider does not report an account. */ credentialFingerprint: Schema.optional(TrimmedNonEmptyString), resetCredits: Schema.optional(ServerProviderResetCredits), - /** Provider-owned usage settings when quota windows are not available to the client. */ externalUsage: Schema.optional( Schema.Struct({ label: TrimmedNonEmptyString, @@ -70,39 +57,25 @@ export const ServerProviderUsageLimits = Schema.Struct({ export type ServerProviderUsageLimits = typeof ServerProviderUsageLimits.Type; /** - * What an adapter reports when its runtime pushes a rate-limit update during - * a turn. Sparse by contract: Claude's `rate_limit_event` names one window at - * a time and Codex documents its notification as a partial. Windows merge by - * `id` onto the instance's published snapshot; omitted windows are unchanged. + * Sparse by contract: Claude's `rate_limit_event` names one window at a time + * and Codex documents its notification as a partial. Windows merge by `id` onto + * the instance's published snapshot; omitted windows are unchanged. */ export const ProviderUsageLimitsUpdate = Schema.Struct({ windows: Schema.Array(ServerProviderUsageWindow), }); export type ProviderUsageLimitsUpdate = typeof ProviderUsageLimitsUpdate.Type; -/** - * One account a usage-limit source reports on. `driver` is the provider the - * account belongs to, for the icon and colour clients already have; the - * account itself is not something this environment can run turns on. - */ export const UsageLimitSourceAccount = Schema.Struct({ id: TrimmedNonEmptyString, driver: ProviderDriverKind, - /** The signed-in address, when the source names one; clients blur it like provider auth. */ email: Schema.optional(TrimmedNonEmptyString), - /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ plan: Schema.optional(TrimmedNonEmptyString), - /** The workspace the quota belongs to, when the source names one; see `ServerProviderAuth`. */ workspaceId: Schema.optional(TrimmedNonEmptyString), usageLimits: ServerProviderUsageLimits, }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; -/** - * The published state of one configured `usageLimitSources` entry. A source - * that could not be read keeps `error` beside an empty account list rather - * than vanishing, so the user can see it is configured but failing. - */ export const UsageLimitSourceSnapshot = Schema.Struct({ id: UsageLimitSourceId, kind: Schema.Literal("cliproxy"), @@ -139,7 +112,6 @@ export class UsageLimitSourceError extends Schema.TaggedError { /** * The RPC builds this codec at call time, so a shape it cannot lower β€” an open-keyed record * with an optional value, for one β€” fails as an interrupted request rather than as a schema - * error. Building it here turns that into a test failure instead. + * error. */ it("round-trips through the JSON codec the RPC serializes with", () => { const codec = Schema.toCodecJson(PullRequestListResult); @@ -149,8 +149,6 @@ describe("PullRequestReviewerRequestInput", () => { const reviewer = { id: "octocat", kind: "user" }; it("carries the same shape whichever direction the request goes", () => { - // One operation, turned around: `requested` is the whole difference between asking somebody - // for a review and taking the request back. for (const requested of [true, false]) { expect(decodeReviewerRequest({ ...ref, reviewers: [reviewer], requested }).requested).toBe( requested, @@ -297,9 +295,6 @@ describe("naming the reader as the author to narrow by", () => { }); describe("naming the file a tick belongs to", () => { - // A space on either end of a name is part of the name as far as git is concerned. The patch on - // screen and the environment's record of what was cleared are both keyed by it, so a path - // tidied in transit ticks a file that does not exist and leaves the one on screen unticked. it("keeps the spaces around a path being ticked", () => { expect( decodeSetFilesViewed({ @@ -332,9 +327,6 @@ describe("naming the file a tick belongs to", () => { }); it("refuses a batch larger than a reader can press", () => { - // Every element of a batch is a statement of its own inside one transaction on an - // environment-kept host, or a field of its own in one GraphQL document on GitHub, so what a - // client may send has to be bounded rather than trusted to be a burst of presses. const press = (path: string) => ({ path, viewed: true }); const batch = (count: number) => ({ projectId: "p1", diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 57f775f29747..2415c59f056e 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -18,15 +18,9 @@ export type PullRequestInvolvement = typeof PullRequestInvolvement.Type; export const PullRequestState = Schema.Literals(["open", "closed", "merged"]); export type PullRequestState = typeof PullRequestState.Type; -/** - * What a listing asks for, which is the three states a change request can be in plus the option - * to span them. Separate from `PullRequestState` because a change request is never "all" β€” only - * a request for one can be. - */ export const PullRequestListState = Schema.Literals(["all", "open", "closed", "merged"]); export type PullRequestListState = typeof PullRequestListState.Type; -/** Where a review stands overall, as a host that summarises its reviews reports it. */ export const PullRequestReviewDecision = Schema.Literals([ "approved", "changes-requested", @@ -34,20 +28,11 @@ export const PullRequestReviewDecision = Schema.Literals([ ]); export type PullRequestReviewDecision = typeof PullRequestReviewDecision.Type; -/** One qualifier's value, bounded because it is written into a host's own search query. */ const PullRequestQualifierValue = TrimmedNonEmptyString.check(Schema.isMaxLength(200)); const PullRequestQualifierValues = Schema.Array(PullRequestQualifierValue).check( Schema.isMaxLength(10), ); -/** - * Narrowings beyond state and involvement, each absent by default β€” an absent field filters - * nothing, which is what every listing did before there were any. Optional as a whole so a page - * and a server of different ages still speak to each other. - * - * `checks` is host-side only: no row carries its own check state, so a host that cannot match it - * answers unnarrowed rather than the page pretending to know. - */ export const PullRequestListFilters = Schema.Struct({ draft: Schema.optional(Schema.Literals(["only", "hide"])), review: Schema.optional( @@ -57,17 +42,14 @@ export const PullRequestListFilters = Schema.Struct({ /** * Labels as GitHub's own search reads them: each group is one `label:` qualifier, a row must * satisfy every group, and a group holding several names is satisfied by any one of them β€” - * `label:size:S,size:XS` finds either size. Typed rather than picked, since a project's labels - * are its own and no menu can list them. Bounded because each becomes a host search qualifier. + * `label:size:S,size:XS` finds either size. */ labels: Schema.optional(Schema.Array(PullRequestQualifierValues).check(Schema.isMaxLength(10))), excludedLabels: Schema.optional(PullRequestQualifierValues), - /** One login, as `author:` names it. */ author: Schema.optional(PullRequestQualifierValue), }); export type PullRequestListFilters = typeof PullRequestListFilters.Type; -/** The one-glyph summary of a change request's checks, as its list row wears it. */ export const PullRequestChecksState = Schema.Literals(["passing", "failing", "pending"]); export type PullRequestChecksState = typeof PullRequestChecksState.Type; @@ -84,49 +66,24 @@ export const PullRequestAction = Schema.Literals([ "close", "reopen", "delete-branch", - /** Bring the base branch's commits into this one, which is what unblocks a stale branch. */ "update-branch", - /** - * Hand the merge to the host: it waits for whatever it requires β€” a green pipeline, the - * approvals, an unblocked branch β€” and merges without anybody coming back. Separate from - * `merge` because the two answer different questions. `merge` is refused outright by a host - * that is not ready yet, and this one is for exactly that moment. - */ "enable-auto-merge", - /** Take the standing instruction back, which leaves the change request where it was. */ "disable-auto-merge", - /** Open a new change request that reverses a merged one. */ "revert", - /** Allow Actions workflows from a fork pull request to begin running. */ "approve-workflows", ]); export type PullRequestAction = typeof PullRequestAction.Type; -/** - * How a stale branch catches up with its base: a merge commit, or a rebase onto it. The two are - * the host's own choices, not this page's β€” GitHub offers both and refuses a rebase it cannot - * replay, so what is offered comes from the host and what is allowed comes from the viewer. - */ export const PullRequestUpdateMethod = Schema.Literals(["merge", "rebase"]); export type PullRequestUpdateMethod = typeof PullRequestUpdateMethod.Type; -/** - * Where the branch stands against the base it would merge into. Separate from `mergeability`, - * which answers a different question: a branch can be behind and still merge cleanly, and that - * pairing β€” out of date, no conflicts β€” is the one an update button exists for. - * - * "unknown" where the host was not asked or could not say, which is every host but GitHub and - * every pull request whose head repository could not be compared. - */ export const PullRequestBaseComparison = Schema.Literals(["up-to-date", "behind", "unknown"]); export type PullRequestBaseComparison = typeof PullRequestBaseComparison.Type; export const PullRequestActor = Schema.Struct({ - /** Present when the host identifies an automated account. */ isBot: Schema.optional(Schema.Boolean), login: TrimmedNonEmptyString, name: Schema.NullOr(Schema.String), - /** Null where a host does not report one, which is what the initials fall back to. */ avatarUrl: Schema.NullOr(Schema.String), }); export type PullRequestActor = typeof PullRequestActor.Type; @@ -153,16 +110,10 @@ export const PullRequestCheck = Schema.Struct({ status: PullRequestCheckStatus, description: Schema.NullOr(Schema.String), url: Schema.NullOr(Schema.String), - /** The base branch requires this check to merge. Absent where the host does not say. */ required: Schema.optional(Schema.Boolean), }); export type PullRequestCheck = typeof PullRequestCheck.Type; -/** - * The reactions a remark can carry. GitHub's eight, which is also what the picker offers: GitLab - * accepts any emoji as an award, and the ones outside this set are read as nothing rather than - * shown under a name no other host would recognise. - */ export const PullRequestReactionContent = Schema.Literals([ "thumbs-up", "thumbs-down", @@ -175,18 +126,14 @@ export const PullRequestReactionContent = Schema.Literals([ ]); export type PullRequestReactionContent = typeof PullRequestReactionContent.Type; -/** One reaction and everyone behind it, which is what the hover on a reaction pill says. */ export const PullRequestReaction = Schema.Struct({ content: PullRequestReactionContent, count: PositiveInt, /** - * Who reacted, as far as the host named them. A host that reports fewer names than it counts - * leaves the rest out, so this is never longer than `count` and may be shorter. When - * `viewerHasReacted` is true, the viewer's own login is left out of this list β€” the page names - * them "You" instead β€” but `count` still counts them along with everyone else. + * When `viewerHasReacted` is true, the viewer's own login is left out of this list β€” the + * page names them "You" instead β€” but `count` still counts them along with everyone else. */ actors: Schema.Array(TrimmedNonEmptyString), - /** The signed-in account is one of them, so pressing the pill takes the reaction back. */ viewerHasReacted: Schema.Boolean, }); export type PullRequestReaction = typeof PullRequestReaction.Type; @@ -208,26 +155,14 @@ export const PullRequestComment = Schema.Struct({ url: Schema.NullOr(Schema.String), path: Schema.NullOr(Schema.String), reviewState: Schema.NullOr(Schema.String), - /** Absent from a host with no reactions at all, which is a different thing from none on this. */ reactions: Schema.optional(Schema.Array(PullRequestReaction)), - /** - * The host's own answer to whether this reader may rewrite this remark. Absent where the host - * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both - * here and in its review thread; a host that sets this sets it on both copies alike. - */ canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestComment = typeof PullRequestComment.Type; -/** - * Which file a diff line belongs to: `left` is the version before the change, `right` the - * version after. A comment has to name one, because a unified diff shows both at once and the - * same line number means two different lines. - */ export const PullRequestDiffSide = Schema.Literals(["left", "right"]); export type PullRequestDiffSide = typeof PullRequestDiffSide.Type; -/** What submitting a review says about the change, beyond the words in it. */ export const PullRequestReviewVerdict = Schema.Literals(["comment", "approve", "request-changes"]); export type PullRequestReviewVerdict = typeof PullRequestReviewVerdict.Type; @@ -239,85 +174,45 @@ export const PullRequestThreadComment = Schema.Struct({ editedAt: Schema.optional(Schema.NullOr(IsoDateTime)), url: Schema.NullOr(Schema.String), reactions: Schema.optional(Schema.Array(PullRequestReaction)), - /** - * The host's own answer to whether this reader may rewrite this remark. Absent where the host - * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both - * here and in its review thread; a host that sets this sets it on both copies alike. - */ canEdit: Schema.optional(Schema.Boolean), }); export type PullRequestThreadComment = typeof PullRequestThreadComment.Type; -/** - * A conversation anchored to a line of the diff. The detail carries these alongside `comments` - * rather than instead of them: the timeline wants one flat, chronological list, and the diff - * wants whole threads pinned to their line β€” the same remarks, read two different ways. - */ export const PullRequestReviewThread = Schema.Struct({ id: TrimmedNonEmptyString, path: TrimmedNonEmptyString, - /** Null when the host anchors the thread to a file rather than to a line. */ line: Schema.NullOr(PositiveInt), side: PullRequestDiffSide, isResolved: Schema.Boolean, - /** - * The line the thread was written against is no longer in the diff, so it cannot be shown - * against the code. Such a thread is listed separately rather than pinned to the wrong line. - */ isOutdated: Schema.Boolean, comments: Schema.Array(PullRequestThreadComment), - /** Host-reported total, when this thread was read in pages. */ commentCount: Schema.optional(NonNegativeInt), - /** Opaque cursor for the next comment page. Absent once this thread is whole. */ nextCommentsCursor: Schema.optional(TrimmedNonEmptyString), /** - * The host's own answer to whether this reader may resolve this thread. It can only narrow the - * reader's repository-wide `resolve` permission, never widen it. Absent where the host - * doesn't say per thread. + * It can only narrow the reader's repository-wide `resolve` permission, never widen + * it. Absent where the host doesn't say per thread. */ canResolve: Schema.optional(Schema.Boolean), }); export type PullRequestReviewThread = typeof PullRequestReviewThread.Type; -/** - * Whether a reviewer is a person or a group of them the host addresses as one. GitHub is the only - * host here that takes a review request for a team; the others name individuals only, so nothing - * they report is ever anything but a user. - */ export const PullRequestReviewerKind = Schema.Literals(["user", "team"]); export type PullRequestReviewerKind = typeof PullRequestReviewerKind.Type; -/** - * Somebody a review may be asked of. Carries the actor fields so the menu shows the same face and - * handle the rest of the page does, and a team wears them too β€” its slug stands in for a login. - */ export const PullRequestReviewerCandidate = Schema.Struct({ ...PullRequestActor.fields, - /** - * How the host addresses this reviewer when a review is requested, which is not always the - * handle it shows: GitHub takes a login or a team slug, GitLab a numeric user id, Bitbucket an - * account uuid. Opaque to the page, which sends back whatever the candidate arrived with. - */ id: TrimmedNonEmptyString, kind: PullRequestReviewerKind, - /** A review has already been asked of them, so pressing them takes the request back. */ isRequested: Schema.Boolean, }); export type PullRequestReviewerCandidate = typeof PullRequestReviewerCandidate.Type; export const PullRequestReviewerCandidateList = Schema.Struct({ - /** Never includes the author: nobody is asked to review their own change request. */ candidates: Schema.Array(PullRequestReviewerCandidate), - /** - * The host has more people with access than the read asked for, so somebody missing from the - * menu may still be somebody this change request can be sent to. Nothing is wrong with the - * list; it is simply not all of it. - */ truncated: Schema.Boolean, }); export type PullRequestReviewerCandidateList = typeof PullRequestReviewerCandidateList.Type; -/** A label the repository defines, with whether this change request already wears it. */ export const PullRequestLabelCandidate = Schema.Struct({ ...PullRequestLabel.fields, description: Schema.NullOr(Schema.String), @@ -327,7 +222,6 @@ export type PullRequestLabelCandidate = typeof PullRequestLabelCandidate.Type; export const PullRequestLabelCandidateList = Schema.Struct({ candidates: Schema.Array(PullRequestLabelCandidate), - /** The repository defines more labels than the read asked for; the list is not all of them. */ truncated: Schema.Boolean, }); export type PullRequestLabelCandidateList = typeof PullRequestLabelCandidateList.Type; @@ -336,175 +230,70 @@ export const PullRequestCommit = Schema.Struct({ oid: TrimmedNonEmptyString, messageHeadline: Schema.String, committedDate: IsoDateTime, - /** Per-commit line counts where the host can return them without a request per commit. */ additions: Schema.optional(NonNegativeInt), deletions: Schema.optional(NonNegativeInt), - /** - * Everyone the host attributes the commit to. Optional because older servers and hosts that - * do not expose commit authors still produce a useful timeline entry. - */ authors: Schema.optional(Schema.Array(PullRequestActor)), }); export type PullRequestCommit = typeof PullRequestCommit.Type; -/** - * What a host can do with a review, which is where the four differ most. GitLab has no way to - * say "changes requested", and Azure DevOps exposes no diff through its CLI, so it has no lines - * to write against at all. - */ export const PullRequestReviewCapabilities = Schema.Struct({ - /** A new comment can be anchored to a line of the diff. */ inlineComment: Schema.Boolean, - /** An existing thread can be replied to. */ reply: Schema.Boolean, - /** A thread can be marked resolved, and unresolved again. */ resolve: Schema.Boolean, - /** The verdicts a submitted review can carry. Empty means reviews cannot be submitted. */ verdicts: Schema.Array(PullRequestReviewVerdict), }); export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities.Type; -/** - * What a host lets be rewritten after it has been posted. The two are separate because a host can - * take one without the other: Azure DevOps takes a new title and description through the same - * command that closes a pull request, and has no way to post a remark here at all β€” so nothing it - * shows in a conversation can be rewritten either. - */ export const PullRequestEditCapabilities = Schema.Struct({ - /** The change request's own title and description can be rewritten. */ changeRequest: Schema.Boolean, - /** A remark can be rewritten by whoever wrote it. */ comment: Schema.Boolean, }); export type PullRequestEditCapabilities = typeof PullRequestEditCapabilities.Type; -/** - * What a host can do about who reviews. The two are independent: a host can take a request without - * publishing who may receive one, which is Azure DevOps. - */ export const PullRequestReviewerCapabilities = Schema.Struct({ - /** A review can be asked of somebody, and the request taken back. */ request: Schema.Boolean, - /** - * The people who may be asked can be listed. False where the host has no such list to give, - * which leaves the page to take a name rather than offer a menu β€” a guessed list would be worse, - * because a name missing from it reads as a name that cannot be asked. - */ listCandidates: Schema.Boolean, }); export type PullRequestReviewerCapabilities = typeof PullRequestReviewerCapabilities.Type; -/** - * Who remembers which files a reader has cleared. `host` is the host's own record, so the marks - * are the ones its web UI shows and a review can be carried on from either side. `environment` is - * this server's record, for a host that keeps none anything outside one browser can read. - */ export const PullRequestViewedFilesStore = Schema.Literals(["host", "environment"]); export type PullRequestViewedFilesStore = typeof PullRequestViewedFilesStore.Type; -/** - * What a provider can actually do, so a surface can hide what is missing rather than offer an - * action that would fail. Every provider fills this in for itself; nothing is assumed. - * - * Hosts differ more than they look: Azure DevOps exposes no patch through its CLI, and - * Bitbucket has no endpoint that reopens a declined pull request. Both would otherwise be dead - * buttons. - */ export const PullRequestCapabilities = Schema.Struct({ - /** A unified patch can be fetched for the change request. */ diff: Schema.Boolean, - /** A comment can be posted, and the conversation read back. */ comment: Schema.Boolean, - /** The actions this host can carry out; anything absent is never offered. */ actions: Schema.Array(PullRequestAction), - /** Merge strategies the provider itself offers, before repository settings narrow them. */ mergeMethods: Schema.Array(PullRequestMergeMethod), deleteBranch: Schema.optional(Schema.Boolean), /** - * How this host can bring a stale branch up to date. Absent where it cannot at all, which is - * every host that has not said otherwise β€” so a provider that says nothing offers nothing. + * Absent where it cannot at all, which is every host that has not said otherwise β€” so a + * provider that says nothing offers nothing. */ updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)), - /** - * The host can narrow a listing by free text. False means it answers unnarrowed and whoever - * asked has to do the narrowing β€” which is a different promise, so the page is told rather - * than left to show every change request on that host as a search result. - */ search: Schema.Boolean, - /** - * Reactions can be read from a remark, and added to one or taken back. One flag for both: - * neither host here reports reactions it will not also take, and a surface that could show a - * pill it may never press is a surface offering nothing. Optional for the same reason as - * `updateMethods` and `edit`: a server that says nothing about reactions has none, which is - * what every server before this field was. - */ reactions: Schema.optional(Schema.Boolean), - /** - * Where the reader's own marks are kept, or absent where they are kept nowhere and the - * checkbox is not offered at all. Optional for the same reason as `reactions`. - */ viewedFiles: Schema.optional(PullRequestViewedFilesStore), review: PullRequestReviewCapabilities, reviewers: PullRequestReviewerCapabilities, - /** - * What can be rewritten after the fact. Optional so a page and a server of different ages still - * speak to each other: a server that says nothing about rewriting is one that cannot, which is - * what every server before this one was. - */ edit: Schema.optional(PullRequestEditCapabilities), - /** - * The host keeps stacks of change requests as objects of its own, so a linked thread can show - * the stack the host shows. Absent means chains are only ever inferred from base branches. - */ stacks: Schema.optional(Schema.Boolean), stackActions: Schema.optional(Schema.Boolean), - /** - * The repository's labels can be listed, and one put on a change request or taken off it. - * Optional for the same reason `edit` is: a server that says nothing about labels has no way - * to change them, which is what every server before this field was. - */ labels: Schema.optional(Schema.Boolean), }); export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; /** - * What the signed-in account may do with this change request, which is a different question from - * `capabilities`: that says what the host is able to do at all, and this says whether this viewer - * is allowed to ask for it. A host that merges pull requests still will not let a stranger merge - * one, so a control belongs on the page only where the two agree. - * - * A permission the host reports nothing about is granted rather than withheld. Hiding a control - * from someone who may in fact use it leaves them no way through and no reason given, while - * offering one they may not use ends in the host's own refusal β€” which at least says why. + * A permission the host reports nothing about is granted rather than withheld. */ export const PullRequestViewerPermissions = Schema.Struct({ - /** May request remote stack rebases, including when this layer is already current. */ stackRebase: Schema.optional(Schema.Boolean), - /** Which of the actions this viewer may take; anything absent is theirs to look at only. */ actions: Schema.Array(PullRequestAction), - /** This viewer may write a remark: a comment, a reply, or a note against a line. */ comment: Schema.Boolean, - /** This viewer may mark a review conversation resolved, and unresolved again. */ resolve: Schema.Boolean, - /** The verdicts this viewer may submit a review with. Empty means they may not review. */ verdicts: Schema.Array(PullRequestReviewVerdict), - /** This viewer may ask somebody for a review, and take the request back again. */ requestReviewers: Schema.Boolean, - /** - * The ways this viewer may bring the branch up to date, narrowed from what the host offers. - * Absent or empty means they may not, which is also what a host with no such action says. - */ updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)), - /** - * This viewer may put a label on the change request, and take one off. Absent is granted, like - * every permission here; the capability beside it is what decides whether a label can be - * changed on this host at all. - */ labels: Schema.optional(Schema.Boolean), - /** - * This viewer may rewrite the change request's title and description. Absent where the host - * doesn't say, which leaves the page to guess from authorship and merge access. - */ editChangeRequest: Schema.optional(Schema.Boolean), }); export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; @@ -527,10 +316,6 @@ export type PullRequestStackMembership = typeof PullRequestStackMembership.Type; export const PullRequestListEntry = Schema.Struct({ stack: Schema.optional(PullRequestStackMembership), provider: SourceControlProviderKind, - /** - * The host below which `repository` is addressed, so the same provider kind can serve more - * than one account β€” github.com and a GitHub Enterprise install are different identities. - */ host: TrimmedNonEmptyString, projectId: ProjectId, projectTitle: TrimmedNonEmptyString, @@ -546,36 +331,22 @@ export const PullRequestListEntry = Schema.Struct({ mergeability: PullRequestMergeability, /** * Zero where the host has not been asked for the counts yet, which on GitHub is every row a - * listing hands over: they are read afterwards, through `PullRequestListStatsInput`. Zero is - * what a host that reports no counts at all has always sent, and what the page already draws - * as no stat rather than as an empty change. + * listing hands over: they are read afterwards, through `PullRequestListStatsInput`. */ additions: NonNegativeInt, deletions: NonNegativeInt, createdAt: IsoDateTime, updatedAt: IsoDateTime, - /** Server epoch milliseconds when the provider read started; preserved on cache hits. */ observedAt: Schema.optional(Schema.Finite), viewerReviewRequested: Schema.Boolean, labels: Schema.Array(PullRequestLabel), - /** Absent where the host does not summarise its reviews, which is every host but GitHub. */ reviewDecision: Schema.optional(PullRequestReviewDecision), - /** Absent where the host reports no check rollup, or the change request has no checks. */ checksState: Schema.optional(PullRequestChecksState), }); export type PullRequestListEntry = typeof PullRequestListEntry.Type; -/** - * Where each repository a listing already reached carries on from, keyed `" "` - * β€” which is how a listing tells two repositories apart, since the same `owner/repo` exists on - * github.com and on an Enterprise install at once. - * - * Each value is opaque: only the provider that issued one knows what it means, and the page hands - * back exactly what it was given rather than composing one. - */ export const PullRequestListCursors = Schema.Record( TrimmedNonEmptyString, - // Bounded because it arrives from the page and is unfolded into a host's own filter. TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), ); export type PullRequestListCursors = typeof PullRequestListCursors.Type; @@ -585,70 +356,25 @@ export const PullRequestListInput = Schema.Struct({ involvement: Schema.optional(PullRequestInvolvement), filters: Schema.optional(PullRequestListFilters), projectId: Schema.optional(ProjectId), - /** - * Only these projects, for a client that assigns each shared repository to one of its - * connections and asks the others to stay quiet about it. Absent means every project, which - * is what every listing asked for before there were several connections to spread across. - */ projectIds: Schema.optional(Schema.Array(ProjectId).check(Schema.isMaxLength(100))), - /** - * Narrows the listing to one host, named as the host itself rather than as its provider kind: - * github.com and a GitHub Enterprise install are two accounts, and a kind cannot tell them - * apart. Absent means every host the workspace has. - */ host: Schema.optional(TrimmedNonEmptyString), - /** Rows to return per repository, which with a continuation is rows per slice. */ limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 500 }))), - /** - * Carry on from an answer already on the page rather than read the listing again. Only the - * repositories named here are read, one slice each β€” which is what makes a further page cost - * one slice rather than every repository over again at a larger size. A repository the page has - * enough of is simply left out. - * - * Absent asks for the listing from the top, which is what raising `limit` alone has always - * done and still does. - */ cursors: Schema.optional(PullRequestListCursors), - /** - * Free text the hosts themselves are asked to match, rather than a filter over the rows that - * have already arrived: a listing only ever holds a page per repository, so a search that - * never leaves the client can only find what happened to be loaded. What a match means is the - * host's own business β€” GitHub reads a body, GitLab a description β€” and a host with no text - * filter answers unnarrowed rather than pretending. - * - * Bounded because it travels into a CLI argument and a query string, and no host makes - * anything of a search term this long. - */ query: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))), }); export type PullRequestListInput = typeof PullRequestListInput.Type; -/** - * A host the workspace has projects on, and whether it can be read right now. Drives the host - * switcher and explains projects the list leaves out. - * - * One per host rather than per provider kind, because signing in is a question about the host: - * a workspace can hold repositories on github.com and on a GitHub Enterprise install at once, - * and the two are separate accounts that succeed and fail independently. - */ export const PullRequestProviderSummary = Schema.Struct({ host: TrimmedNonEmptyString, kind: SourceControlProviderKind, - /** False where a search has to be applied to the rows after they arrive. */ searchesOnHost: Schema.Boolean, - /** - * The actions this host can carry out, so a row can offer them before its detail is read. - * Absent from servers older than this field; clients then offer what they always did. - */ actions: Schema.optional(Schema.Array(PullRequestAction)), projectCount: PositiveInt, - /** False when the provider's CLI or credentials are missing, with `detail` saying which. */ configured: Schema.Boolean, detail: Schema.NullOr(TrimmedNonEmptyString), }); export type PullRequestProviderSummary = typeof PullRequestProviderSummary.Type; -/** One project whose repository could not be read; healthy projects still return entries. */ export const PullRequestListProjectError = Schema.Struct({ projectId: ProjectId, projectTitle: TrimmedNonEmptyString, @@ -658,52 +384,33 @@ export type PullRequestListProjectError = typeof PullRequestListProjectError.Typ export const PullRequestListResult = Schema.Struct({ /** - * The signed-in account per host, which is what involvement filtering compares. Keyed by - * host rather than by provider kind: two GitHub hosts are two accounts. A host that could - * not be read is absent rather than present-and-undefined, because an open-keyed record - * cannot carry an optional value through the JSON codec. + * A host that could not be read is absent rather than present-and-undefined, because an + * open-keyed record cannot carry an optional value through the JSON codec. */ viewers: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), providers: Schema.Array(PullRequestProviderSummary), /** - * By update, newest first, across every repository this answer covers. A page that appends a - * continuation sorts what it then holds the same way: a repository's next slice is older than - * that repository's last row, but can still be newer than another repository's. + * A page that appends a continuation sorts what it then holds the same way: a repository's + * next slice is older than that repository's last row, but can still be newer than another + * repository's. */ entries: Schema.Array(PullRequestListEntry), errors: Schema.Array(PullRequestListProjectError), - /** At least one repository hit the per-repository listing cap. */ truncated: Schema.Boolean, - /** - * Where each repository carries on, to be sent straight back as `cursors`. A repository is - * absent from this once it has nothing more to give, and also where its host could not be asked - * in an order a continuation means anything in β€” so an empty record beside `truncated` means - * more rows are only reachable by raising `limit`, the way they always were. - */ nextCursors: PullRequestListCursors, }); export type PullRequestListResult = typeof PullRequestListResult.Type; -/** - * Addresses one pull request for reads and writes. `projectId` picks the checkout the host - * CLI runs in and, when its repository matches, the credentials; `host` lets the server - * route a pull request from another repository through any project on the same host - * (a frontend project's thread linking a backend PR). Absent `host` means "the project's - * own host", which is every reference from before thread links became host-level. - */ export const PullRequestRef = Schema.Struct({ projectId: ProjectId, host: Schema.optional(TrimmedNonEmptyString), - /** Refuse a routed operation unless this GitHub account still owns the active credential. */ expectedAccountId: Schema.optional(TrimmedNonEmptyString), - /** Let another environment answer when this one's cached response has expired. */ allowStale: Schema.optional(Schema.Boolean), repository: TrimmedNonEmptyString, number: PositiveInt, }); export type PullRequestRef = typeof PullRequestRef.Type; -/** Account discovery never needs the originating project's private repository metadata. */ export const PullRequestRoutingIdentityInput = Schema.Struct({ host: TrimmedNonEmptyString, }); @@ -739,7 +446,6 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({ }); export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type; -/** The complete hover card, without checks, permissions, or branch comparisons. */ export const PullRequestPreview = Schema.Struct({ projectId: ProjectId, repository: TrimmedNonEmptyString, @@ -753,10 +459,6 @@ export const PullRequestPreview = Schema.Struct({ }); export type PullRequestPreview = typeof PullRequestPreview.Type; -/** - * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar - * status check never loads permissions, repository settings, checks, or base comparison data. - */ export const PullRequestSummary = Schema.Struct({ provider: SourceControlProviderKind, projectId: ProjectId, @@ -765,14 +467,12 @@ export const PullRequestSummary = Schema.Struct({ title: TrimmedNonEmptyString, url: TrimmedNonEmptyString, state: PullRequestState, - /** Present when the host says the open pull request is still a draft. */ isDraft: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: IsoDateTime, - /** Server epoch milliseconds when the provider read started; preserved on cache hits. */ observedAt: Schema.optional(Schema.Finite), author: Schema.optional(Schema.NullOr(PullRequestActor)), additions: Schema.optional(NonNegativeInt), @@ -786,7 +486,6 @@ export const PullRequestSummary = Schema.Struct({ }); export type PullRequestSummary = typeof PullRequestSummary.Type; -/** The host-native stack a pull request belongs to, in the thread link's shape. */ export const PullRequestStack = Schema.Struct({ id: TrimmedNonEmptyString, number: PositiveInt, @@ -805,14 +504,6 @@ export const PullRequestStack = Schema.Struct({ }); export type PullRequestStack = typeof PullRequestStack.Type; -/** - * One row's line counts, read after the listing rather than inside it. On GitHub the pair is - * 40-60% of the wall clock of the search that answers the whole page β€” measured over twelve - * repositories, 7.1s with it and 4.0s without β€” for two small numbers at the end of a row. - * - * So the listing answers without them and the page asks for them next, which is what puts the - * rows on screen at the speed of everything else on them. - */ export const PullRequestDiffStat = Schema.Struct({ projectId: ProjectId, repository: TrimmedNonEmptyString, @@ -822,45 +513,26 @@ export const PullRequestDiffStat = Schema.Struct({ }); export type PullRequestDiffStat = typeof PullRequestDiffStat.Type; -/** - * The rows whose line counts are wanted, which is the rows the page is showing. - * - * Bounded at the largest listing a page can hold, since each ref becomes a lookup of its own on - * the host. - */ export const PullRequestListStatsInput = Schema.Struct({ refs: Schema.Array(PullRequestRef).check(Schema.isMaxLength(500)), }); export type PullRequestListStatsInput = typeof PullRequestListStatsInput.Type; -/** - * Only the rows that could be answered for. A host with no batched stat read of its own β€” every - * one but GitHub, which all report the counts in the listing itself β€” is simply absent here, and - * the numbers its listing already carried stand. - */ export const PullRequestListStatsResult = Schema.Struct({ stats: Schema.Array(PullRequestDiffStat), }); export type PullRequestListStatsResult = typeof PullRequestListStatsResult.Type; /** - * Forget what the server has cached, so the next read asks the host. With a reference it - * forgets that one change request's detail and diff; without one it forgets the listings. * A separate request rather than a flag on the reads, so an explicit "refresh" one person * presses is the only thing that spends host requests β€” every ordinary read shares. */ export const PullRequestInvalidateInput = Schema.Struct({ reference: Schema.optional(PullRequestRef), - /** Refresh review progress across routed environments without discarding the patch. */ filesViewedOnly: Schema.optional(Schema.Boolean), }); export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type; -/** - * The state a read routed to another environment saw, reported to the environment the read was - * for so its thread links can catch up. A hint only: that environment confirms with the host - * before writing anything. - */ export const PullRequestReportStateInput = Schema.Struct({ reference: PullRequestRef, state: PullRequestState, @@ -870,7 +542,6 @@ export type PullRequestReportStateInput = typeof PullRequestReportStateInput.Typ export const PullRequestDetail = Schema.Struct({ provider: SourceControlProviderKind, capabilities: PullRequestCapabilities, - /** What this viewer may do, which `capabilities` says nothing about. Both narrow the page. */ viewerPermissions: PullRequestViewerPermissions, projectId: ProjectId, projectTitle: TrimmedNonEmptyString, @@ -889,12 +560,10 @@ export const PullRequestDetail = Schema.Struct({ changedFiles: NonNegativeInt, headBranch: TrimmedNonEmptyString, headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - /** The head commit, where the host reports it with the detail. */ headSha: Schema.optional(TrimmedNonEmptyString), baseBranch: TrimmedNonEmptyString, createdAt: IsoDateTime, updatedAt: IsoDateTime, - /** Server epoch milliseconds when the provider read started; preserved on cache hits. */ observedAt: Schema.optional(Schema.Finite), mergedAt: Schema.NullOr(IsoDateTime), closedAt: Schema.NullOr(IsoDateTime), @@ -902,20 +571,8 @@ export const PullRequestDetail = Schema.Struct({ labels: Schema.Array(PullRequestLabel), checks: Schema.Array(PullRequestCheck), mergeCapabilities: PullRequestMergeCapabilities, - /** - * Who the host says the reader is, which is the one thing a conversation cannot be read without - * to tell the reader's own remarks from everybody else's β€” and rewriting a remark is offered - * only where the two names agree. Absent where the host could not say, which offers nothing - * rather than everything. - */ viewer: Schema.optional(TrimmedNonEmptyString), - /** - * Where the branch stands against its base. Optional so a host that cannot compare says - * nothing rather than claiming the branch is current β€” the page shows a banner only where the - * answer is "behind", and silence is not that answer. - */ baseComparison: Schema.optional(PullRequestBaseComparison), - /** How many commits the base is ahead by, where the host counted them. */ behindBy: Schema.optional(NonNegativeInt), /** * Whether the host is already armed to merge this on its own. Absent where the host does not @@ -923,9 +580,7 @@ export const PullRequestDetail = Schema.Struct({ * arm something that is already armed, and a second arming is a write nobody asked for. */ autoMergeEnabled: Schema.optional(Schema.Boolean), - /** The strategy the host will use for an armed auto-merge, where it reports one. */ autoMergeMethod: Schema.optional(PullRequestMergeMethod), - /** GitHub Actions runs on this head commit that are waiting for a maintainer's approval. */ workflowApprovalsRequired: Schema.optional(NonNegativeInt), }); export type PullRequestDetail = typeof PullRequestDetail.Type; @@ -936,47 +591,22 @@ export const PullRequestChecks = Schema.Struct({ }); export type PullRequestChecks = typeof PullRequestChecks.Type; -/** - * The slower, conversation-shaped half of a change request. It is read independently from the - * core detail so a host with a deeply paginated review history cannot hold the title, body, - * checks, and actions off screen. `author` and `reviewers` are optional enrichments: GitHub's - * conversation query carries avatars and completed reviewers that its basic detail does not. - */ export const PullRequestActivity = Schema.Struct({ author: Schema.optional(Schema.NullOr(PullRequestActor)), reviewers: Schema.optional(Schema.Array(PullRequestActor)), comments: Schema.Array(PullRequestComment), - /** - * How many remarks the host itself counts in the conversation, which is the number a surface - * showing a count has to show: `comments` carries what was read, and a reader who can see 225 - * on the host is not reassured by a page that says 10. Never less than `comments` holds, and - * equal to it wherever the host was read to the end. - */ commentCount: NonNegativeInt, - /** - * The read stopped at a bound of its own before the host ran out, so `comments` holds less - * than `commentCount` counts. Nothing else: a conversation read whole is never truncated, - * however long it is. - */ commentsTruncated: Schema.Boolean, - /** Whether the thread listing itself is incomplete, apart from pages within a thread. */ reviewThreadsTruncated: Schema.optional(Schema.Boolean), reviewThreads: Schema.Array(PullRequestReviewThread), commits: Schema.Array(PullRequestCommit), - /** - * The change request's own reactions β€” the ones on its description, which every host counts - * against the change request itself rather than against a remark in the conversation. - */ reactions: Schema.optional(Schema.Array(PullRequestReaction)), }); export type PullRequestActivity = typeof PullRequestActivity.Type; -/** The complete detail shape after the independently loaded activity has been applied. */ export const PullRequestDetailView = Schema.Struct({ ...PullRequestDetail.fields, ...PullRequestActivity.fields, - // A composed view always has the core identity fields, even when the activity did not enrich - // them. Re-declare them as required after the activity's optional overrides. author: Schema.NullOr(PullRequestActor), reviewers: Schema.Array(PullRequestActor), }); @@ -985,25 +615,15 @@ export type PullRequestDetailView = typeof PullRequestDetailView.Type; /** * A diff arrives a slice at a time, because a large change is more than any host will hand over * at once β€” GitHub refuses outright past 300 files β€” and more than a viewer can lay out in one - * go. A slice is a whole number of files, never a file cut in half, so each one parses on its - * own and the reader can start on the first while the rest is still coming. + * go. */ export const PullRequestDiffInput = Schema.Struct({ ...PullRequestRef.fields, - /** - * Where to carry on from. Absent asks for the first slice. Opaque to the reader: each host - * counts its files its own way, and only the provider that issued one knows what it means. - */ cursor: Schema.optional(TrimmedNonEmptyString), - /** - * One commit of the change rather than the whole of it. A large pull request is easier to - * read a commit at a time, and the author's own commits are the boundaries they chose. - */ commit: Schema.optional(TrimmedNonEmptyString), }); export type PullRequestDiffInput = typeof PullRequestDiffInput.Type; -/** Real line counts for a file whose hunks the host withheld from the patch. */ export const PullRequestOmittedFileStat = Schema.Struct({ path: TrimmedNonEmptyString, additions: Schema.Number, @@ -1018,20 +638,13 @@ export const PullRequestDiffResult = Schema.Struct({ * to inline. Not the same as there being more slices, which `nextCursor` answers. */ truncated: Schema.Boolean, - /** Where the next slice starts, or null once the diff is whole. */ nextCursor: Schema.NullOr(TrimmedNonEmptyString), - /** - * The host's own counts for the files whose hunks it withheld, so a file the patch cannot - * show still reports what changed instead of a zero the diff never had. - */ omittedFileStats: Schema.optional(Schema.Array(PullRequestOmittedFileStat)), }); export type PullRequestDiffResult = typeof PullRequestDiffResult.Type; -/** The complete old and new files Pierre needs to open omitted context in a host-backed patch. */ export const PullRequestDiffFileContentsInput = Schema.Struct({ ...PullRequestRef.fields, - /** One commit's own comparison; absent means the whole change request. */ commit: Schema.optional(TrimmedNonEmptyString), changeType: Schema.Literals(["change", "rename-pure", "rename-changed", "new", "deleted"]), oldPath: TrimmedNonEmptyString, @@ -1045,10 +658,6 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ }); export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; -/** - * Bounded because a path arrives from a client rather than from the host: one element of a write - * batch could otherwise carry a megabyte into a SQL statement or a GraphQL field. - */ const MAX_FILE_PATH_LENGTH = 4096; /** * Not trimmed: a leading or trailing space is a legal part of a file's name, and the mark is @@ -1056,11 +665,6 @@ const MAX_FILE_PATH_LENGTH = 4096; */ const FilePath = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(MAX_FILE_PATH_LENGTH)); -/** - * Where one file of a change request stands with the person reading it. `dismissed` is the file - * that was cleared and has since been pushed to, which is worth telling a reader apart from - * `unviewed`: this file and not the other forty is the one that moved. - */ export const PullRequestFileViewedState = Schema.Literals(["unviewed", "viewed", "dismissed"]); export type PullRequestFileViewedState = typeof PullRequestFileViewedState.Type; @@ -1070,30 +674,15 @@ export const PullRequestFileViewed = Schema.Struct({ }); export type PullRequestFileViewed = typeof PullRequestFileViewed.Type; -/** - * Which files of a change request the reader has cleared. Its own read rather than a field on the - * patch: a patch changes when somebody pushes and is cached by the minute, this changes on every - * press, so one read would have to be wrong for the other to be right. - */ export const PullRequestFilesViewedResult = Schema.Struct({ /** Only the files the host reported a state for. A file missing from this list is unviewed. */ files: Schema.Array(PullRequestFileViewed), - /** The host had more files than were read, so the count is short and says so. */ truncated: Schema.Boolean, }); export type PullRequestFilesViewedResult = typeof PullRequestFilesViewedResult.Type; -/** - * How many presses one write carries. Every element is a statement of its own inside one - * transaction here, or a field of its own in one GraphQL document on GitHub. Matched to what a - * read of the marks carries, so a client cannot write more of them than it can ever read back. - */ const MAX_FILES_VIEWED_PRESSES = 500; -/** - * Files to clear, or to put back. Several at once because a reader working down a diff ticks - * boxes far faster than a host answers, so a burst is gathered into one request. - */ export const PullRequestSetFilesViewedInput = Schema.Struct({ ...PullRequestRef.fields, files: Schema.Array( @@ -1117,21 +706,13 @@ export const PullRequestActionInput = Schema.Struct({ expectedStackHeads: Schema.optional(Schema.Array(PullRequestStackHead)), ...PullRequestRef.fields, action: PullRequestAction, - /** - * Which strategy the merge uses, read for `merge` and for `enable-auto-merge` alike β€” a merge - * the host performs later is still a merge, and the strategy is chosen when it is armed rather - * than at the moment it happens. Absent means the host's own default. - */ mergeMethod: Schema.optional(PullRequestMergeMethod), - /** Only read for `update-branch`, where absent means the host's own default. */ updateMethod: Schema.optional(PullRequestUpdateMethod), }); export type PullRequestActionInput = typeof PullRequestActionInput.Type; // Not trimmed: the body is markdown, where leading spaces open a code block and two trailing -// spaces are a line break. GitHub rejects bodies past 65536 characters, so that bound is -// enforced here to keep oversized payloads off the wire and out of subprocess plumbing; the -// service rejects a body that is only whitespace. +// spaces are a line break. const CommentBody = Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)); export const PullRequestCommentInput = Schema.Struct({ @@ -1140,16 +721,6 @@ export const PullRequestCommentInput = Schema.Struct({ }); export type PullRequestCommentInput = typeof PullRequestCommentInput.Type; -/** - * A change request's own words rewritten: its title, its description, or both. Each is optional - * because the page rewrites them apart β€” a title corrected on its own must not carry a description - * nobody opened β€” and the service refuses a request that carries neither. - * - * The title is bounded far past what any host takes, GitHub refusing one past 256 characters, so - * an oversized one is turned away before it reaches a subprocess. The body is not trimmed for the - * same reason a comment is not: it is markdown, where leading spaces open a code block. An empty - * one is allowed, which is how a description is cleared. - */ export const PullRequestUpdateInput = Schema.Struct({ ...PullRequestRef.fields, title: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(1024))), @@ -1158,13 +729,9 @@ export const PullRequestUpdateInput = Schema.Struct({ export type PullRequestUpdateInput = typeof PullRequestUpdateInput.Type; /** - * A remark already posted, rewritten by whoever wrote it. The kind travels beside the id because - * an id alone does not say which endpoint addresses it: GitHub rewrites a comment on the - * conversation and a comment on a line through two different mutations. - * - * A review's own summary is not among them. The hosts disagree about what one even is β€” the entry - * Bitbucket shows is a vote this page renders as a remark, with no words behind it to rewrite β€” - * and a control that worked on one host and failed on another is worse than one that is not there. + * The kind travels beside the id because an id alone does not say which endpoint + * addresses it: GitHub rewrites a comment on the conversation and a comment on a line + * through two different mutations. */ export const PullRequestCommentUpdateInput = Schema.Struct({ ...PullRequestRef.fields, @@ -1174,7 +741,6 @@ export const PullRequestCommentUpdateInput = Schema.Struct({ }); export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type; -/** The coordinates of one line in a pull request diff. */ export const PullRequestReviewPosition = Schema.Union([ Schema.Struct({ kind: Schema.Literal("added"), @@ -1188,19 +754,16 @@ export const PullRequestReviewPosition = Schema.Union([ kind: Schema.Literal("context"), oldLine: PositiveInt, newLine: PositiveInt, - /** Which copy of an unchanged line the reviewer selected in a split diff. */ side: PullRequestDiffSide, }), ]); export type PullRequestReviewPosition = typeof PullRequestReviewPosition.Type; -/** One remark in a review that has not been sent yet, anchored to a line of the diff. */ export const PullRequestReviewCommentDraft = Schema.Struct({ path: TrimmedNonEmptyString, /** - * What the file was called before the change, sent only when it differs. GitLab resolves a - * position against both sides of the diff, so a comment on a renamed file needs both names; - * the hosts that address a comment by one path ignore this. + * GitLab resolves a position against both sides of the diff, so a comment on a renamed file + * needs both names; the hosts that address a comment by one path ignore this. */ oldPath: Schema.optional(TrimmedNonEmptyString), position: PullRequestReviewPosition, @@ -1208,15 +771,9 @@ export const PullRequestReviewCommentDraft = Schema.Struct({ }); export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type; -/** - * A whole review, sent in one go. The line comments travel with the verdict rather than being - * posted as they are written, so a half-finished review is never visible to anyone else β€” and - * so hosts with no notion of a pending review behave the same as the ones that have it. - */ export const PullRequestSubmitReviewInput = Schema.Struct({ ...PullRequestRef.fields, verdict: PullRequestReviewVerdict, - /** The review's own words. May be empty, which is how an approval with no remarks is sent. */ body: Schema.String.check(Schema.isMaxLength(65_536)), comments: Schema.Array(PullRequestReviewCommentDraft), }); @@ -1249,34 +806,16 @@ export const PullRequestThreadResolutionInput = Schema.Struct({ }); export type PullRequestThreadResolutionInput = typeof PullRequestThreadResolutionInput.Type; -/** - * Reacting and taking the reaction back are one operation with `reacted` turned around, which is - * what pressing the same pill twice is. - */ export const PullRequestReactionInput = Schema.Struct({ ...PullRequestRef.fields, - /** - * Which remark to react to, as it arrived in the conversation. Absent reacts to the change - * request itself, which is where its description's reactions live β€” the id a host uses for - * that is its own to work out. - */ subjectId: Schema.optional(TrimmedNonEmptyString), content: PullRequestReactionContent, reacted: Schema.Boolean, }); export type PullRequestReactionInput = typeof PullRequestReactionInput.Type; -/** - * Asking for a review and taking the request back are one operation with `requested` turned - * around, and asking again somebody who has already reviewed is what a re-request is β€” so that is - * the same operation once more rather than a third one. - */ export const PullRequestReviewerRequestInput = Schema.Struct({ ...PullRequestRef.fields, - /** - * Who, as the candidate list named them. Bounded because every host bounds it as well β€” GitHub - * refuses past fifteen β€” and because these travel into a request body a page composed. - */ reviewers: Schema.Array( Schema.Struct({ id: TrimmedNonEmptyString, kind: PullRequestReviewerKind }), ).check(Schema.isMinLength(1), Schema.isMaxLength(25)), @@ -1284,11 +823,6 @@ export const PullRequestReviewerRequestInput = Schema.Struct({ }); export type PullRequestReviewerRequestInput = typeof PullRequestReviewerRequestInput.Type; -/** - * Putting a label on and taking it off are one operation with `applied` turned around, which is - * what pressing the same row in the menu twice is. Named by the label's own name, which is how - * GitHub addresses one. - */ export const PullRequestLabelChangeInput = Schema.Struct({ ...PullRequestRef.fields, labels: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(25)), @@ -1304,8 +838,7 @@ export const PullRequestUnavailableReason = Schema.Literals([ export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.Type; /** - * What each host needs before it can be read, so a failure names the fix rather than the - * symptom. The reason names keep their `cli-` prefix for wire compatibility; for GitHub and + * The reason names keep their `cli-` prefix for wire compatibility; for GitHub and * Bitbucket they mean "no credential" and "a refused credential", not a missing tool. */ const PROVIDER_REQUIREMENT = new Map< @@ -1357,14 +890,6 @@ const PROVIDER_REQUIREMENT = new Map< ], ]); -/** - * The host a project's repository is addressed below. `canonicalKey` is the normalized remote, - * `host/owner/repo`, so its first segment is the host; the provider kind stands in when there is - * no key to read, which keeps one bucket per kind for identities recorded before it existed. - * - * Shared between the server and the page so both bucket a workspace the same way β€” the page - * knows its hosts before the listing answers, and the two must agree on what they are called. - */ export function pullRequestHostOf( identity: | { @@ -1380,9 +905,7 @@ export function pullRequestHostOf( const remote = new URL(identity?.locator?.remoteUrl ?? ""); if (remote.protocol === "http:" || remote.protocol === "https:") return remote.host.toLowerCase(); - } catch { - // SSH remotes retain their canonical host; the CLI resolves their web endpoint. - } + } catch {} } const host = identity?.canonicalKey?.split("/")[0]?.trim(); return host === undefined || host.length === 0 ? kind : host.toLowerCase(); @@ -1393,11 +916,6 @@ export function pullRequestHostOf( * way. A host's own search would answer it, but the page and the server both re-check an author * against the row's login, so the name is resolved before either comparison rather than being * matched as the literal login "me". - * - * Null viewer where the host has not said who the reader is; the filter then stands as typed, - * which is the same answer a search for a user named "me" would give. - * - * Shared between the server and the page so both narrow by the same login. */ export function resolvePullRequestAuthorFilter( author: string, @@ -1408,11 +926,6 @@ export function resolvePullRequestAuthorFilter( return viewer === null || viewer === undefined || viewer.trim().length === 0 ? trimmed : viewer; } -/** - * What a host needs before it can be read, as a sentence to show wherever that host is reported - * as unusable β€” the whole page when nothing can be read, and one entry in the host switcher when - * only that host cannot. Null when the reason is not about setting a host up. - */ export function pullRequestProviderRequirement( provider: SourceControlProviderKind, reason: PullRequestUnavailableReason, @@ -1429,12 +942,6 @@ export function pullRequestProviderRequirement( } } -/** - * The feature is switched off entirely. The message is derived from `reason` and the host - * rather than from whatever the CLI printed, so it stays a stable sentence the UI can show - * as-is; the underlying failure travels in `cause` (absent for `provider-unsupported`, which - * has none). - */ export class PullRequestUnavailableError extends Schema.TaggedError()( "PullRequestUnavailableError", { diff --git a/packages/contracts/src/pullRequestReviewGuide.ts b/packages/contracts/src/pullRequestReviewGuide.ts index 6251450fe5a0..126bf60aa320 100644 --- a/packages/contracts/src/pullRequestReviewGuide.ts +++ b/packages/contracts/src/pullRequestReviewGuide.ts @@ -3,35 +3,12 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ModelSelection } from "./modelSelection.ts"; -/** - * A guided review is written for the maintainer deciding whether a change should land, not for - * someone checking it line by line. Review agents already read every line; what is left for a - * person is intent, design, and the calls an agent cannot make on their behalf. - * - * So a guide is a tour of stops in reading order, each building on the ones before (contract, then - * the service, then its callers), with the mechanical rest grouped at the end. Each stop shows only - * the code that carries its idea and names the one call a maintainer should make there, if any; - * that call is what tells the reader where to slow down. - * - * The model writes the tour; the server holds it to the change. Excerpts and notes are clamped to - * lines the change touched, and a changed file the model left out still lands in a closing skim - * stop, so every file is accounted for. - */ - -/** - * The one call a maintainer should make at a stop. Not a bug or a review nit; those belong to the - * review agents. The kind says what sort of judgment it asks for. - */ export const PullRequestReviewGuideAttention = Schema.Struct({ kind: Schema.Literals(["tradeoff", "contract", "product", "risk", "accessibility"]), note: TrimmedNonEmptyString, }); export type PullRequestReviewGuideAttention = typeof PullRequestReviewGuideAttention.Type; -/** - * Lines worth reading, always inside the change. `old` shows removed code, for a change that is - * mostly deletion; line numbers are on the side named. - */ export const PullRequestReviewGuideExcerpt = Schema.Struct({ path: TrimmedNonEmptyString, side: Schema.Literals(["new", "old"]), @@ -41,10 +18,6 @@ export const PullRequestReviewGuideExcerpt = Schema.Struct({ }); export type PullRequestReviewGuideExcerpt = typeof PullRequestReviewGuideExcerpt.Type; -/** - * What the code at one line does not say for itself: why it is shaped this way, what it connects - * to. An explanation, not a finding. Anchored inside one of its stop's excerpts. - */ export const PullRequestReviewGuideNote = Schema.Struct({ path: TrimmedNonEmptyString, line: PositiveInt, @@ -58,10 +31,6 @@ export const PullRequestReviewGuideStop = Schema.Struct({ summary: Schema.String, attention: Schema.NullOr(PullRequestReviewGuideAttention), excerpts: Schema.Array(PullRequestReviewGuideExcerpt), - /** - * Changed files the tour never excerpts, each listed by one stop. A file the tour does show may - * be excerpted by several stops, each showing the part that stop is about. - */ files: Schema.Array( Schema.Struct({ path: TrimmedNonEmptyString, @@ -69,13 +38,11 @@ export const PullRequestReviewGuideStop = Schema.Struct({ }), ), notes: Schema.Array(PullRequestReviewGuideNote), - /** Questions about intent a reader might ask here, for a follow-up conversation. */ questions: Schema.Array(TrimmedNonEmptyString), }); export type PullRequestReviewGuideStop = typeof PullRequestReviewGuideStop.Type; export const PullRequestReviewGuide = Schema.Struct({ - /** The head the guide was written against. A different head means the guide is stale. */ headSha: TrimmedNonEmptyString, generatedAt: IsoDateTime, modelSelection: ModelSelection, @@ -83,11 +50,6 @@ export const PullRequestReviewGuide = Schema.Struct({ }); export type PullRequestReviewGuide = typeof PullRequestReviewGuide.Type; -/** - * A guide run in progress or the last one that failed. A run reads the change in a checkout and - * takes minutes on a large one, so it runs on the server and the page watches this instead of - * holding a request open. A server restart drops a run in progress; nothing claims it. - */ export const PullRequestReviewGuideRun = Schema.Union([ Schema.TaggedStruct("Running", { headSha: TrimmedNonEmptyString, @@ -101,15 +63,9 @@ export const PullRequestReviewGuideRun = Schema.Union([ ]); export type PullRequestReviewGuideRun = typeof PullRequestReviewGuideRun.Type; -/** - * The stored guide for a pull request and the head it is now at. `guide` is null when none was - * generated; a guide whose `headSha` differs from `currentHeadSha` was written before a push. - * `run` is the generation in progress, or the last failed one; a finished run leaves only `guide`. - */ export const PullRequestReviewGuideResult = Schema.Struct({ guide: Schema.NullOr(PullRequestReviewGuide), run: Schema.NullOr(PullRequestReviewGuideRun), - /** Null where the host did not report a head with the detail. */ currentHeadSha: Schema.NullOr(TrimmedNonEmptyString), }); export type PullRequestReviewGuideResult = typeof PullRequestReviewGuideResult.Type; diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 916893c54fbe..9f49b03606c6 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -50,10 +50,8 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({ iosMajorVersion: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(18))), androidApiLevel: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(24))), appVersion: Schema.optional(TrimmedNonEmptyString), - // APNs routing for this install: the topic must match the app's bundle id - // (dev/preview/prod variants differ) and development-signed builds receive - // sandbox tokens. Optional so older app builds keep registering; the relay - // falls back to its configured defaults. + // APNs routing for this install: the topic must match the app's bundle id (dev/preview/prod + // variants differ) and development-signed builds receive sandbox tokens. bundleId: Schema.optional(TrimmedNonEmptyString), apsEnvironment: Schema.optional(RelayApnsEnvironment), pushToken: Schema.optional(TrimmedNonEmptyString), @@ -413,7 +411,6 @@ export class RelayAuthInvalidError extends Schema.TaggedError server: the desktop app's update state. Sent once when the - * backend attaches and again on every state change, so the server always - * knows whether the app on its machine can be updated and how a - * server-triggered run is progressing. - */ export const DesktopUpdateStatusReport = Schema.Struct({ version: Schema.Literal(1), type: Schema.Literal("desktopUpdateStatus"), - // Set while a server-triggered run owns the flow; absent for the attach - // snapshot and for locally driven update activity. requestId: Schema.optionalKey(TrimmedNonEmptyString), - // Terminal marker for a server-triggered run; absent while it is working. outcome: Schema.optionalKey(DesktopUpdateRemoteOutcome), reason: Schema.optionalKey(TrimmedNonEmptyString), state: DesktopUpdateStateSchema, diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index eee17de4e6f7..5a95442917c9 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -37,7 +37,6 @@ export const ReviewDiffPreviewSource = Schema.Struct({ diff: Schema.String, diffHash: TrimmedNonEmptyString, truncated: Schema.Boolean, - /** Complete statistics, independent of patch limits. Absent on older servers. */ files: Schema.optionalKey(Schema.Array(ReviewDiffFileStat)), }); export type ReviewDiffPreviewSource = typeof ReviewDiffPreviewSource.Type; diff --git a/packages/contracts/src/rpc.test.ts b/packages/contracts/src/rpc.test.ts index 7b2832e896ec..72b726a94fe5 100644 --- a/packages/contracts/src/rpc.test.ts +++ b/packages/contracts/src/rpc.test.ts @@ -5,12 +5,6 @@ import * as Schema from "effect/Schema"; import { ORCHESTRATION_V2_WS_METHODS } from "./orchestrationV2.ts"; import { WsRpcGroup, WsSubscribeServerConfigRpc } from "./rpc.ts"; -/** - * The client always sends `environmentThemes`, including to servers built - * before the field existed, whose payload schema was an empty struct. What - * makes that safe is that such a schema accepts the request rather than - * rejecting it -- an error here would take down the config subscription. - */ describe("subscribeServerConfig payload compatibility", () => { it("is accepted by a server whose schema predates the field", () => { const oldServerPayload = Schema.Struct({}); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fc99d1238956..9536aa93a7ee 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -372,7 +372,6 @@ import { VcsError } from "./vcs.ts"; import { Project, ProjectMutation, ProjectMutationError } from "./project.ts"; export const WS_METHODS = { - // Project registry methods projectsList: "projects.list", projectsAdd: "projects.add", projectsRemove: "projects.remove", @@ -385,10 +384,8 @@ export const WS_METHODS = { projectsEnsureScratch: "projects.ensureScratch", projectsCreateNew: "projects.createNew", - // Shell methods shellOpenInEditor: "shell.openInEditor", - // Filesystem methods filesystemBrowse: "filesystem.browse", filesystemGetMetadata: "filesystem.getMetadata", agentSessionsScan: "agentSessions.scan", @@ -398,13 +395,11 @@ export const WS_METHODS = { attachmentsCreateUploadUrl: "attachments.createUploadUrl", attachmentsDelete: "attachments.delete", - // MCP Apps methods mcpAppsCallTool: "mcpApps.callTool", mcpAppsToolInfo: "mcpApps.toolInfo", mcpAppsReadResource: "mcpApps.readResource", mcpAppsUpdateModelContext: "mcpApps.updateModelContext", - // Provider methods providerUploadFeedback: "provider.uploadFeedback", providerAuthStart: "provider.auth.start", providerConsumeResetCredit: "provider.consumeResetCredit", @@ -422,7 +417,6 @@ export const WS_METHODS = { providerInstallSubscribe: "provider.install.subscribe", providerInstallRemove: "provider.install.remove", - // VCS methods vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", @@ -432,16 +426,13 @@ export const WS_METHODS = { vcsSwitchRef: "vcs.switchRef", vcsInit: "vcs.init", - // Git workflow methods gitRunStackedAction: "git.runStackedAction", gitResolvePullRequest: "git.resolvePullRequest", gitPreparePullRequestThread: "git.preparePullRequestThread", - // Review methods reviewGetDiffPreview: "review.getDiffPreview", reviewGetDiffFileContents: "review.getDiffFileContents", - // Terminal methods terminalOpen: "terminal.open", terminalAttach: "terminal.attach", terminalObserve: "terminal.observe", @@ -451,7 +442,6 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", - // Preview methods previewOpen: "preview.open", previewNavigate: "preview.navigate", previewResize: "preview.resize", @@ -463,7 +453,6 @@ export const WS_METHODS = { previewReportProfiles: "preview.reportProfiles", previewReportStatus: "preview.reportStatus", - // Device methods deviceConfigure: "device.configure", deviceList: "device.list", deviceTestHost: "device.testHost", @@ -473,7 +462,6 @@ export const WS_METHODS = { deviceDetail: "device.detail", deviceAction: "device.action", - // Server meta serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", @@ -514,7 +502,6 @@ export const WS_METHODS = { serverGetUsageSummary: "server.getUsageSummary", serverRefreshUsageRates: "server.refreshUsageRates", - // Scheduled tasks scheduledTasksList: "scheduledTasks.list", scheduledTasksSubscribe: "scheduledTasks.subscribe", scheduledTasksUpsert: "scheduledTasks.upsert", @@ -526,11 +513,9 @@ export const WS_METHODS = { scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries", scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery", - // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", - // Pull request methods pullRequestsList: "pullRequests.list", pullRequestsListStats: "pullRequests.listStats", pullRequestsSummary: "pullRequests.summary", @@ -564,7 +549,6 @@ export const WS_METHODS = { pullRequestsLabelCandidates: "pullRequests.labelCandidates", pullRequestsSetLabels: "pullRequests.setLabels", - // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", @@ -573,7 +557,6 @@ export const WS_METHODS = { projectCloneRetry: "projectClone.retry", subscribeProjectClones: "subscribeProjectClones", - // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeWorktreeSetup: "subscribeWorktreeSetup", worktreeSetupCancel: "worktreeSetup.cancel", @@ -615,16 +598,8 @@ const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ - /** - * When supplied, only refresh this specific provider instance. When - * omitted, refresh all configured instances β€” the legacy `refresh()` - * behaviour retained for transports that still dispatch untargeted - * refreshes. - */ instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), - /** With `instanceId` and `cwd`: rescan the workspace's skills and slash - * commands even when a snapshot for that cwd already exists. */ fresh: Schema.optional(Schema.Boolean), /** Explicit user request: bypass T3-owned caches and rediscover models. * Background status refreshes must not open agent sessions. */ @@ -915,10 +890,6 @@ const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), }); -/** - * Refetches the model rate table ahead of its daily TTL, so a model released - * since the last fetch gets priced. The next usage summary uses the new table. - */ const WsServerRefreshUsageRatesRpc = Rpc.make(WS_METHODS.serverRefreshUsageRates, { payload: Schema.Struct({}), success: UsagePricing, @@ -1157,7 +1128,6 @@ const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequestsReques error: PullRequestRpcError, }); -/** Read when the label menu opens, for the same reason the reviewer candidates are. */ const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, { payload: PullRequestRef, success: PullRequestLabelCandidateList, @@ -1255,14 +1225,12 @@ const WsProjectsMutateRpc = Rpc.make(WS_METHODS.projectsMutate, { error: Schema.Union([ProjectMutationError, EnvironmentAuthorizationError]), }); -// Finds or creates the Scratch project rooted at ServerConfig.scratchWorkspaceRoot. const WsProjectsEnsureScratchRpc = Rpc.make(WS_METHODS.projectsEnsureScratch, { payload: Schema.Struct({}), success: ProjectEnsureScratchResult, error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); -// Makes a folder under ServerConfig.newProjectsRoot with a first commit, then the project. const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { payload: ProjectCreateNewInput, success: ProjectCreateNewResult, @@ -1440,11 +1408,6 @@ const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { error: Schema.Union([VcsError, EnvironmentAuthorizationError]), }); -/** - * Ephemeral live diff preview for compact/mobile surfaces. - * Not the persisted T3 Review model. Future review sessions should use - * review.open* + review.getSnapshot. - */ const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { payload: ReviewDiffPreviewInput, success: ReviewDiffPreviewResult, @@ -1766,14 +1729,11 @@ const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMeta export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { payload: Schema.Struct({ /** - * Whether this client understands `environmentThemesUpdated` events. - * Already-shipped clients decode the stream against the old event union - * and would die on an unknown member, so the server emits the theme - * stream only to subscribers that ask for it. Absent on old clients; - * dropped by old servers. + * Already-shipped clients decode the stream against the old event union and + * would die on an unknown member, so the server emits the theme stream only + * to subscribers that ask for it. */ environmentThemes: Schema.optional(Schema.Boolean), - /** Whether this client understands `usageLimitSourcesUpdated` events. */ usageLimitSources: Schema.optional(Schema.Boolean), /** * Whether this client answers `/usage-limits` itself. The server injects @@ -1800,7 +1760,6 @@ const WsScheduledTasksListRpc = Rpc.make(WS_METHODS.scheduledTasksList, { error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), }); -/** Streams the full scheduled-task list: one snapshot on subscribe, then a fresh list after every change. */ const WsScheduledTasksSubscribeRpc = Rpc.make(WS_METHODS.scheduledTasksSubscribe, { payload: ScheduledTaskListInput, success: ScheduledTaskListResult, @@ -1885,11 +1844,6 @@ const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTel stream: true, }); -/** - * Checks the connection's scopes against the scope each RPC declares, before - * the handler runs. Every RPC in `WsRpcGroup` carries it, so a handler cannot - * be added without authorization. - */ export class RpcScopeAuthorization extends RpcMiddleware.Service()( "t3/contracts/RpcScopeAuthorization", { error: EnvironmentAuthorizationError }, diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 126551071278..9a24e1b8abe3 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -68,7 +68,6 @@ const ScheduledTaskWebhookSignatureFields = { }), }; -/** HMAC-SHA256 over the raw request body. The secret is never part of the read model. */ export const ScheduledTaskWebhookSignature = Schema.Struct( ScheduledTaskWebhookSignatureFields, ).annotate({ description: "Optional HMAC-SHA256 signature check over the raw request body." }); @@ -89,7 +88,6 @@ const ScheduledTaskWebhookSchedule = Schema.Struct({ description: "Run when the task's webhook URL receives a request.", }), signature: Schema.NullOr(ScheduledTaskWebhookSignature), - // Optional so rows saved before this setting existed still decode. maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)), }).annotate({ description: @@ -136,7 +134,6 @@ export const ScheduledTaskSchedule = Schema.Union([ }); export type ScheduledTaskSchedule = typeof ScheduledTaskSchedule.Type; -/** Mutation model: newly created or updated interval schedules run at most once per minute. */ export const ScheduledTaskUpsertSchedule = Schema.Union([ Schema.Struct({ type: Schema.Literal("interval").annotate({ @@ -160,11 +157,8 @@ export type ScheduledTaskUpsertSchedule = typeof ScheduledTaskUpsertSchedule.Typ export const ScheduledTaskRunStatus = Schema.Literals(["never", "running", "succeeded", "failed"]); export type ScheduledTaskRunStatus = typeof ScheduledTaskRunStatus.Type; -/** Where a webhook task receives requests. Present only on webhook tasks. */ export const ScheduledTaskWebhookEndpoint = Schema.Struct({ - /** Environment-relative path including the secret token; works on any origin that reaches the environment. */ path: TrimmedNonEmptyString, - /** Public T3 Connect URL, or null when the environment is not linked to T3 Connect. */ url: Schema.NullOr(TrimmedNonEmptyString), hasSecret: Schema.Boolean, }); @@ -226,7 +220,6 @@ export const ScheduledTaskUpsertInput = Schema.Struct({ }); export type ScheduledTaskUpsertInput = typeof ScheduledTaskUpsertInput.Type; -/** Partial update that flips only the enabled flag β€” never overwrites other fields. */ export const ScheduledTaskSetEnabledInput = Schema.Struct({ id: ScheduledTaskId, enabled: Schema.Boolean, @@ -271,9 +264,7 @@ export const ScheduledTaskWebhookDeliverySummary = Schema.Struct({ contentType: Schema.NullOr(Schema.String), bodyBytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), outcome: ScheduledTaskWebhookDeliveryOutcome, - /** True when a configured signature matched; false when none was configured. */ signatureVerified: Schema.Boolean, - /** Template placeholders that had no value in this request and rendered empty. */ missingFields: Schema.Array(Schema.String), error: Schema.NullOr(Schema.String), }); @@ -283,7 +274,6 @@ export const ScheduledTaskWebhookDelivery = Schema.Struct({ ...ScheduledTaskWebhookDeliverySummary.fields, query: Schema.String, headers: Schema.Record(Schema.String, Schema.String), - /** Body as UTF-8 text, cut at the log limit; see bodyTruncated. */ body: Schema.String, bodyTruncated: Schema.Boolean, renderedPrompt: Schema.NullOr(Schema.String), diff --git a/packages/contracts/src/secretRequest.ts b/packages/contracts/src/secretRequest.ts index f76a84f6c628..3436fe087739 100644 --- a/packages/contracts/src/secretRequest.ts +++ b/packages/contracts/src/secretRequest.ts @@ -3,8 +3,8 @@ import * as Schema from "effect/Schema"; import { ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts"; /** - * The user's answer to an agent's request for a secret. A saved value is kept - * by the server under a one-use SecretRef; the thread learns only the status. + * A saved value is kept by the server under a one-use SecretRef; the thread + * learns only the status. */ export const SecretRequestAnswerInput = Schema.Struct({ threadId: ThreadId, @@ -38,7 +38,6 @@ export const SecretRequestFailureReason = Schema.Literals( ); export type SecretRequestFailureReason = typeof SecretRequestFailureReason.Type; -/** Answering a request or using its ref failed; the message is shown to users and agents. */ export class SecretRequestError extends Schema.TaggedError()( "SecretRequestError", { diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 091c41cfc4d9..9051fe6ff6a7 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -161,12 +161,6 @@ describe("server config forward compatibility", () => { expect(parsed).toEqual(["zed", "vscode"]); }); - // A provider status this build has never seen (a new ServerProviderState, - // ServerProviderAuthStatus, etc. member) previously failed the whole - // `providers` array, taking every other provider down with it and, since - // `providers` sits inside `ServerConfig`, failing the whole config decode β€” - // an older client would drop its connection over one provider it can't - // render. Dropping just that element keeps every other provider working. it("drops providers this build cannot decode instead of failing the whole array", () => { const decodedBase = decodeServerProvider(baseProviderSnapshot); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 985e6bbc126e..62258a03b6ce 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -46,8 +46,6 @@ export const ServerConfigIssue = Schema.Union([ ]); export type ServerConfigIssue = typeof ServerConfigIssue.Type; -// Issue kinds grow over time; older clients must not fail the whole config -// decode over a kind they cannot render. const ServerConfigIssues = ForwardCompatibleArray(ServerConfigIssue); export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); @@ -69,11 +67,6 @@ export const ServerProviderAuth = Schema.Struct({ canLogout: Schema.optional(Schema.Boolean), subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), - /** - * The workspace or organization whose quota the login draws on, such as a - * ChatGPT workspace or Claude organization id. One email can belong to - * several, each with its own quota. - */ workspaceId: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; @@ -92,11 +85,6 @@ export const ServerProviderModel = Schema.Struct({ }); export type ServerProviderModel = typeof ServerProviderModel.Type; -/** - * A model the model manifest announces that the installed provider version is - * too old to run. It is never selectable; clients show it so users learn that - * updating the provider unlocks it. - */ export const ServerProviderUpdateRequiredModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -144,17 +132,11 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({ cwd: TrimmedNonEmptyString, checkedAt: IsoDateTime, slashCommands: Schema.Array(ServerProviderSlashCommand), - /** Skills are available, but command discovery still needs a retry. */ slashCommandsPending: Schema.optional(Schema.Boolean), skills: Schema.Array(ServerProviderSkill), }); export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; -/** - * How long a workspace's skill and command scan stays current. Nothing watches - * skill directories, so a composer opened after this rescans on use, and the - * server answers repeat requests inside the window from its cache. - */ export const PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS = 5 * 60_000; export function isProviderWorkspaceSnapshotCurrent( @@ -165,18 +147,6 @@ export function isProviderWorkspaceSnapshotCurrent( } /** - * Availability of a configured provider instance from the runtime's POV. - * - * - `available` β€” the build ships this driver and an instance is wired - * up. Default for legacy snapshots produced from the closed - * `ServerSettings.providers` map. - * - `unavailable` β€” the user's `ServerSettings.providerInstances` (or a - * persisted thread / session binding) references a driver this build - * doesn't ship. Common after rolling back from a fork or PR branch - * that introduced a new driver. The snapshot is preserved so the UI - * can render "missing driver" affordances and so the data round-trips - * when the user moves back to the fork. - * * Snapshots with `availability: "unavailable"` MUST set * `installed: false` and `enabled: false`; the runtime refuses turn * starts against them with a structured error. @@ -247,8 +217,6 @@ export const ServerProvider = Schema.Struct({ // Routing key for the configured instance this snapshot represents. This // is the only stable identity consumers may use for provider routing. instanceId: ProviderInstanceId, - // Open driver kind slug that selects the implementation handling this - // instance. It is metadata/capability context, not a routing key. driver: ProviderDriverKind, displayName: Schema.optional(TrimmedNonEmptyString), accentColor: Schema.optional(TrimmedNonEmptyString), @@ -258,8 +226,6 @@ 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), supportedRuntimeModes: Schema.optional(ForwardCompatibleArray(RuntimeMode)), requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), @@ -294,14 +260,7 @@ export const ServerProvider = Schema.Struct({ auth: ServerProviderAuth, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), - // Optional for back-compat: every legacy producer omits this field and - // an absent value is interpreted as `"available"` by consumers (see - // `isProviderAvailable`). New `ProviderInstanceRegistry` outputs set it - // explicitly so the UI can render unavailable shadows from - // `ServerSettings.providerInstances`. availability: Schema.optional(ServerProviderAvailability), - // Human-readable reason populated when `availability === "unavailable"`. - // Surfaces in the UI alongside the missing-driver affordance. unavailableReason: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), // Kept apart from `models` so clients that predate it never offer them. @@ -311,7 +270,6 @@ export const ServerProvider = Schema.Struct({ ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), - // Absent when the driver has no notion of subscription usage. usageLimits: Schema.optional(ServerProviderUsageLimits), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), compatibilityAdvisory: Schema.optionalKey(ServerProviderCompatibilityAdvisory), @@ -319,19 +277,9 @@ export const ServerProvider = Schema.Struct({ }); export type ServerProvider = typeof ServerProvider.Type; -// Provider status kinds grow over time (ServerProviderState, -// ServerProviderAuthStatus, ServerProviderVersionAdvisoryStatus, -// ServerProviderUpdateStatus); an older client must not fail the whole config -// decode over one provider it cannot render. export const ServerProviders = ForwardCompatibleArray(ServerProvider); export type ServerProviders = typeof ServerProviders.Type; -/** - * Treat the optional `availability` as "available" when absent. This is - * the rule legacy producers (which omit the field) and new producers - * (which set it explicitly) agree on so consumers never have to thread - * `?? "available"` defaults through their code paths. - */ export const isProviderAvailable = (snapshot: ServerProvider): boolean => snapshot.availability !== "unavailable"; @@ -351,8 +299,6 @@ export const ServerObservability = Schema.Struct({ otlpMetricsUrl: Schema.optional(TrimmedNonEmptyString), otlpMetricsEnabled: Schema.Boolean, otlpLogsUrl: Schema.optional(TrimmedNonEmptyString), - // Absent on servers from before the log signal shipped, so a newer client - // reads those as having no log export rather than rejecting the whole config. otlpLogsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), }); export type ServerObservability = typeof ServerObservability.Type; @@ -362,15 +308,10 @@ export type OtlpSignal = typeof OtlpSignal.Type; export const OtlpEndpointCheckInput = Schema.Struct({ signal: OtlpSignal, - // Receivers are HTTP only; other schemes, such as data:, answer without contacting one. url: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/./i)), }); export type OtlpEndpointCheckInput = typeof OtlpEndpointCheckInput.Type; -/** - * What the server saw when it sent an empty export to an endpoint: accepted, - * answered with an error status, or never answered. - */ export const OtlpEndpointCheckResult = Schema.Union([ Schema.TaggedStruct("Accepted", { latencyMs: Schema.Number }), Schema.TaggedStruct("Rejected", { status: Schema.Number }), @@ -572,20 +513,12 @@ export const ServerSignalProcessResult = Schema.Struct({ }); export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type; -/** - * A palette the environment's machine publishes for T3 Code to follow, read - * from a theme file next to the rest of the environment's state. Two seed - * colors rather than a full palette: clients derive the remaining roles with - * the same generator the guided theme editor uses, so a desktop theme carries - * over as a coherent T3 Code palette instead of a foreign one. - */ export const EnvironmentThemeColor = Schema.String.check( Schema.isPattern(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/), ); export type EnvironmentThemeColor = typeof EnvironmentThemeColor.Type; /** - * Matches the client-side theme id rule, so a published id is selectable. * The appearance keywords are excluded outright: a published `dark.json` * would otherwise capture every client whose stored preference is the stock * `"dark"`, retinting people who never chose it. @@ -595,37 +528,23 @@ export const EnvironmentThemeId = Schema.String.check( ); export type EnvironmentThemeId = typeof EnvironmentThemeId.Type; -/** - * Role colors as published. Values are any CSS color the client's theme - * parser accepts (exported theme files use oklch), canonicalized client-side; - * roles a build does not know are dropped there, so a machine may publish - * roles a newer client added without breaking an older one. Keys must still - * be role-shaped and values color-sized, so the record stays open to future - * vocabulary without being an arbitrary-payload channel. - */ const EnvironmentThemeColors = Schema.Record( Schema.String.check(Schema.isPattern(/^[a-zA-Z][a-zA-Z0-9]{0,63}$/)), TrimmedNonEmptyString.check(Schema.isMaxLength(64)), ); const environmentThemeFields = { - /** - * Standard exported theme files (the Download button's output) carry - * `version: 1`; the seeded short form a desktop generates has no version. - */ version: Schema.optional(Schema.Literal(1)), - /** Shown on the theme card, e.g. the desktop theme's own name. */ name: TrimmedNonEmptyString.check(Schema.isMaxLength(48)), appearance: Schema.Literals(["light", "dark"]), /** - * Seed colors. When present, clients derive the full palette from them with - * the guided theme editor's generator and layer `colors` on top; when - * absent, `colors` is the palette, as in an exported theme file. + * When present, clients derive the full palette from them with the guided + * theme editor's generator and layer `colors` on top; when absent, `colors` + * is the palette, as in an exported theme file. */ canvas: Schema.optional(EnvironmentThemeColor), accent: Schema.optional(EnvironmentThemeColor), colors: Schema.optional(EnvironmentThemeColors), - /** The other appearance's palette, as exported theme files carry it. */ variants: Schema.optional( Schema.Struct({ light: Schema.optional(EnvironmentThemeColors), @@ -634,24 +553,15 @@ const environmentThemeFields = { ), }; -/** One published theme file. The id is the filename, not part of the content, - * so a file cannot claim another file's identity; an embedded `id` is ignored. */ export const EnvironmentThemeFile = Schema.Struct(environmentThemeFields); export type EnvironmentThemeFile = typeof EnvironmentThemeFile.Type; export const EnvironmentTheme = Schema.Struct({ - /** The publishing filename without its extension, stable across recolors. */ id: EnvironmentThemeId, ...environmentThemeFields, }); export type EnvironmentTheme = typeof EnvironmentTheme.Type; -/** - * Whether a theme file carries anything to render. A file with neither seeds - * nor colors would show as the stock palette wearing a name, which reads as a - * bug rather than a theme β€” the CLI and the server watcher both reject it, - * through this one predicate so they cannot drift. - */ export function environmentThemeFileHasColors(file: EnvironmentThemeFile): boolean { return ( (file.canvas !== undefined && file.accent !== undefined) || @@ -680,79 +590,42 @@ export const ServerConfig = Schema.Struct({ keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, providers: ServerProviders, - // Editor ids grow over time; drop ones this build does not know rather than - // failing the whole config decode. availableEditors: ForwardCompatibleArray(EditorId), - /** - * SSH hosts this environment advertises for remote open-in-editor links. - * Absent on servers that predate the feature; empty when the machine has no - * sshd or no advertisable name. - */ remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)), /** - * Direct addresses this server listens on right now (LAN and tailnet), so a - * client connected one way can learn the others. Hints only: the client - * checks each address answers as this environment before using it. Absent on - * servers that predate the feature; empty when bound to loopback only. + * Hints only: the client checks each address answers as this environment + * before using it. */ directEndpoints: Schema.optionalKey(ForwardCompatibleArray(ServerDirectEndpoint)), observability: ServerObservability, settings: ServerSettings, - /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), - /** Whether shell.openInEditor honors `LaunchEditorInput.reveal` for the - file-manager editor. */ shellRevealInFileManager: Schema.optionalKey(Schema.Boolean), - /** File-manager wording clients should use for reveal actions. */ shellRevealInFileManagerKind: Schema.optionalKey(FileManagerRevealKind), - /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** - * Whether thread detail reads accept a turn window (`turnLimit`/ - * `beforeCursor`) and return `page` metadata. Clients must not send window - * fields to servers that don't advertise this. + * Clients must not send window fields to servers that don't advertise this. */ threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), - /** Whether thread reads accept the reasoningMessages opt-in. */ reasoningMessages: Schema.optionalKey(Schema.Boolean), threadFind: Schema.optionalKey(Schema.Boolean), threadFindProgressive: Schema.optionalKey(Schema.Boolean), - /** - * Folder behind this environment's Scratch project, for threads that need - * no repository. Present only on servers that answer projects.ensureScratch - * and whose data dir is outside a Git checkout. - */ scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), - /** - * Folder that holds projects started from just a name. Present only on - * servers that answer projects.createNew. - */ newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), /** - * Palettes published by this environment's machine. Never sent in a config - * snapshot: the theme stream emits the current set before any change, so a - * snapshot carrying it too would hand every subscriber the same array twice - * per connect. Clients populate this by projecting `environmentThemesUpdated`, - * and it stays absent for subscribers that did not opt in. + * Never sent in a config snapshot: the theme stream emits the current set + * before any change, so a snapshot carrying it too would hand every + * subscriber the same array twice per connect. */ environmentThemes: Schema.optional(Schema.Array(EnvironmentTheme)), /** - * Quota reported by configured `usageLimitSources`. Like themes, never in - * a snapshot: the source stream emits the current set on subscribe, and it - * stays absent for subscribers that did not opt in. + * Like themes, never in a snapshot: the source stream emits the current set + * on subscribe, and it stays absent for subscribers that did not opt in. */ usageLimitSources: Schema.optional(UsageLimitSourceSnapshots), }); export type ServerConfig = typeof ServerConfig.Type; -/** - * The machine an environment should be drawn as: the user's pick, else what - * the server detected, else a generic server. Settings only exist once - * connected; a descriptor alone (relay discovery, before any connection) - * still yields the detected kind. A null config (nothing known yet, or an - * older server) resolves to the same generic so rows never flicker between - * glyphs. - */ export function resolveEnvironmentMachineKind( config: { readonly environment: Pick; @@ -844,7 +717,6 @@ export type ServerConfigStreamSettingsUpdatedEvent = typeof ServerConfigStreamSettingsUpdatedEvent.Type; export const ServerConfigEnvironmentThemesUpdatedPayload = Schema.Struct({ - /** The full published set; empty once the machine publishes none. */ themes: Schema.Array(EnvironmentTheme), }); export type ServerConfigEnvironmentThemesUpdatedPayload = @@ -859,7 +731,6 @@ export type ServerConfigStreamEnvironmentThemesUpdatedEvent = typeof ServerConfigStreamEnvironmentThemesUpdatedEvent.Type; export const ServerConfigUsageLimitSourcesUpdatedPayload = Schema.Struct({ - /** The full set; empty once no source is configured. */ sources: UsageLimitSourceSnapshots, }); export type ServerConfigUsageLimitSourcesUpdatedPayload = @@ -883,7 +754,6 @@ export const ServerConfigStreamEvent = Schema.Union([ ]); export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; -/** Terminal selection recorded by the service launcher for one update. */ export const ServerSelfUpdateOutcome = Schema.Struct({ id: TrimmedNonEmptyString, fromVersion: TrimmedNonEmptyString, @@ -896,7 +766,6 @@ export type ServerSelfUpdateOutcome = typeof ServerSelfUpdateOutcome.Type; export const ServerLifecycleReadyPayload = Schema.Struct({ at: IsoDateTime, environment: ExecutionEnvironmentDescriptor, - /** Present when this process resumed a launcher-managed update. */ updateOutcome: Schema.optionalKey(ServerSelfUpdateOutcome), }); export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; @@ -981,21 +850,14 @@ export const ServerSelfUpdateInput = Schema.Struct({ /** Exact npm version of the `t3` package to install (never a dist-tag, so the server and the acknowledging client agree on what was requested). */ targetVersion: TrimmedNonEmptyString, - /** Opt-in recovery for provider turns that are running when the server - hands off to its replacement. Missing and false keep restart behavior - conservative under version skew. */ continueRunningThreads: Schema.optionalKey(Schema.Boolean), }); export type ServerSelfUpdateInput = typeof ServerSelfUpdateInput.Type; -/** Acknowledgement that the update artifact is installed and the server is - about to restart into it β€” the connection will drop moments later. */ export const ServerSelfUpdateResult = Schema.Struct({ targetVersion: TrimmedNonEmptyString, method: ServerSelfUpdateMethod, - /** Launcher-generated correlation ID. Absent when talking to older servers. */ updateId: Schema.optionalKey(TrimmedNonEmptyString), - /** Desktop preparation token. Present only for the desktop-app method. */ desktopUpdateToken: Schema.optionalKey(TrimmedNonEmptyString), }); export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c289b4a25925..805ce643f1d7 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -815,9 +815,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { expect(decoded.providerInstances[personalId]?.driver).toBe("codex"); expect(decoded.providerInstances[workId]?.config).toEqual({ homePath: "~/.codex_work" }); - // Critical: a config naming a driver this build does not know about - // (`ollama` is not in `ProviderDriverKind`) must round-trip without loss. - // The runtime handles "driver not installed" β€” the schema must not. expect(decoded.providerInstances[ollamaId]?.driver).toBe("ollama"); expect(decoded.providerInstances[ollamaId]?.config).toEqual({ endpoint: "http://localhost:11434", @@ -856,21 +853,16 @@ describe("provider enabled defaults", () => { it("resolves instance enabled state with explicit false winning", () => { const grok = ProviderDriverKind.make("grok"); const codex = ProviderDriverKind.make("codex"); - // No flags anywhere: driver default applies. expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true); - // Unknown fork drivers stay enabled. expect( resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }), ).toBe(true); - // Envelope flag wins over the driver default. expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true); expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe( false, ); - // Legacy in-config flag fills in when the envelope is silent. expect(resolveProviderInstanceEnabled({ driver: grok, config: { enabled: true } })).toBe(true); - // Conflicting flags: the explicit false wins, whichever side it is on. expect( resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: { enabled: false } }), ).toBe(false); @@ -1149,9 +1141,6 @@ describe("resolveProjectProviderInstanceEnabled", () => { it("falls back to the unconfigured default when the instance has no settings entry", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, providerInstances: {} }; - // `codex_work` has no entry in `providerInstances`, so with no project - // override either, this is the same unconfigured-default fallback a - // machine-wide (no project) lookup would use. expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe( resolveProjectProviderInstanceEnabled(settings, null, instanceId), ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3ddbe726612e..60d2aceed7cf 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -46,8 +46,6 @@ import { } from "./providerInstance.ts"; import { PullRequestMergeMethod } from "./pullRequest.ts"; -// ── Client Settings (local-only) ─────────────────────────────── - export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; @@ -62,8 +60,6 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; -// Not exported: mobile was the last consumer of the value itself; the -// wire field keeps its decoding default below. const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; export const SidebarProjectGroupingMode = Schema.Literals([ @@ -121,11 +117,7 @@ export const PanelAnimationDurationMs = Schema.Int.check( ); export type PanelAnimationDurationMs = typeof PanelAnimationDurationMs.Type; const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0; -/** - * Font size preferences, in CSS pixels. The ranges are deliberately narrow: - * the interface size scales every rem-based dimension in the app, so the - * bounds keep layouts intact rather than offering unusable extremes. - */ + export const MIN_INTERFACE_FONT_SIZE = 12; export const MAX_INTERFACE_FONT_SIZE = 20; export const InterfaceFontSize = Schema.Int.check( @@ -246,10 +238,6 @@ const LegacyConfirmQuit = Schema.Boolean.pipe( const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyConfirmQuit]); -/** - * A user-chosen font family (a single name or a comma-separated list). Empty - * means "use the app default"; clients compose their own fallback stacks. - */ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); export type FontFamilyPreference = typeof FontFamilyPreference.Type; @@ -266,24 +254,12 @@ export const DefaultThemePreference = Schema.String.check(Schema.isMaxLength(64) // would let a client write a theme no client can resolve, bypassing both. export type DefaultThemePreference = typeof DefaultThemePreference.Type; -/** - * Defaults for the in-app preview browser, applied whenever a tab is opened - * without an explicit viewport/zoom/appearance β€” by the user opening a browser - * tab, or by an agent calling `preview_open` with no size. Recording quality is - * client-local for the same reason: the Chromium guest being captured belongs - * to the desktop app. - */ export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIEWPORT; export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true; export const BROWSER_RECORDING_FRAME_RATES = [30, 60] as const; export const BrowserRecordingFrameRate = Schema.Literals(BROWSER_RECORDING_FRAME_RATES); export type BrowserRecordingFrameRate = typeof BrowserRecordingFrameRate.Type; export const DEFAULT_BROWSER_RECORDING_FRAME_RATE: BrowserRecordingFrameRate = 30; -/** - * Where a clicked link goes: the OS default browser, or a tab in the in-app - * browser beside the thread. "system" is the default because that is what - * every link did before the setting existed. - */ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; @@ -295,13 +271,10 @@ export const LoadBalancingWeights = Schema.Record( export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); -/** Maximum width of the chat timeline and composer on wide screens. */ export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); export type ChatWidth = typeof ChatWidth.Type; -/** Preferences shared by clients connected to the same environment. */ export const ModelFavorite = Schema.Struct({ - // The historical field name is retained; default instance ids also decode old provider-kind favorites. provider: ProviderInstanceId, model: TrimmedNonEmptyString, }); @@ -355,36 +328,23 @@ export const ClientSettingsSchema = Schema.Struct({ browserRecordingShowMousePresses: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), - /** - * Where links clicked in a thread (chat markdown, terminal output) open. - * Only the desktop app has an in-app browser, so other clients ignore "app". - */ + browserLinkTarget: BrowserLinkTarget.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_LINK_TARGET)), ), - /** - * Whether an agent using a preview pops the floating mini player into - * view. Only applies when the agent didn't ask either way β€” an explicit - * `open`/`show` on `preview_open` still wins, since that is the agent - * deliberately showing or hiding its work. - */ + browserAutoShowFloatingPreview: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW)), ), - /** - * User-created browser profiles. The built-in Default and Incognito profiles - * are synthesized by `resolveBrowserProfiles`, not stored here, so they - * cannot be renamed away or deleted. - */ + browserProfiles: Schema.Array(BrowserProfile).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), - /** Profile new tabs open under. Falls back to Default if it no longer exists. */ + browserDefaultProfileId: BrowserProfileId.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_PROFILE_ID)), ), - // Desktop-only. Boolean values from older settings files decode to their - // equivalent mode and encode back as the canonical string value. + confirmQuit: QuitConfirmationModeSetting.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_QUIT_CONFIRMATION_MODE)), ), @@ -419,20 +379,18 @@ export const ClientSettingsSchema = Schema.Struct({ fontFamilyComposer: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilySans: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), fontFamilyTerminal: FontFamilyPreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); - // disabling restores the platform's heavier default. No effect off macOS. + fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - // When the first-run welcome wizard finished (or was skipped), as an ISO - // timestamp. `null` alone does not mean "show the wizard" β€” every install - // that predates this field decodes to `null` β€” so the gate also requires an - // empty workspace before it treats the client as a fresh install. + // `null` alone does not mean "show the wizard" β€” every install that predates + // this field decodes to `null` β€” so the gate also requires an empty workspace + // before it treats the client as a fresh install. onboardingCompletedAt: Schema.NullOr(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), persistComposerContextStrip: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), - // Legacy client values retained for migration to the connected environment. + favorites: ModelFavorites.pipe(Schema.withDecodingDefault(Effect.succeed([]))), providerModelPreferences: ProviderModelPreferences.pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -441,23 +399,12 @@ export const ClientSettingsSchema = Schema.Struct({ TrimmedNonEmptyString, PullRequestMergeMethod, ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), - /** - * Whether clicking a pull request link opens T3 Code's pull request view. - * When false, pull request links open like any other link, so they reach - * GitHub or the repository's other host. - */ + pullRequestLinksOpenInApp: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - // Legacy plan mode. The composer's Build/Plan toggle was removed from the - // default UI; this beta flag restores it (plus the /plan and /default slash - // commands) for users who still rely on the old workflow. + planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - // Legacy context window meter. The composer hides it by default; users who - // still want the old usage indicator can restore it from Settings. contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - // Desktop resting composer: scrolling an existing thread's conversation - // settles the composer into its single-line layout. Losing focus never does. composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - // Rich text is the default; users can opt out for literal Markdown editing. composerRichTextEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sendShortcut: Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"]).pipe( Schema.withDecodingDefault(Effect.succeed("enter")), @@ -467,14 +414,7 @@ export const ClientSettingsSchema = Schema.Struct({ ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - // Legacy sidebar (the original per-project tree). Deliberately a fresh key - // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the - // old keys, so everyone, including prior beta opt-outs, resets to the new - // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - // Beta: working and monitoring threads fold into a Working shelf and return - // to the top of the inbox once they need the user. The inbox then orders by - // time, so manual placement there is ignored (and kept) while it is on. sidebarWorkingShelfEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -516,14 +456,12 @@ export type ClientSettings = typeof ClientSettingsSchema.Type; export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({}); -// ── Server Settings (server-authoritative) ──────────────────── - const UsageModelTokenPrice = Schema.Number.check( Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0), ); -/** USD per million tokens. Omitted cache rates use the input rate. */ +/** Omitted cache rates use the input rate. */ export const UsageModelPriceOverride = Schema.Struct({ inputCostPerMillionTokens: UsageModelTokenPrice, outputCostPerMillionTokens: UsageModelTokenPrice, @@ -532,7 +470,6 @@ export const UsageModelPriceOverride = Schema.Struct({ }); export type UsageModelPriceOverride = typeof UsageModelPriceOverride.Type; -/** A binary path setting that decodes empty input to the provider's default executable. */ export const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( Schema.decodeTo( @@ -557,11 +494,10 @@ export interface ProviderSettingsFormAnnotation { readonly placeholder?: string | undefined; readonly hidden?: boolean | undefined; readonly clearWhenEmpty?: "omit" | "persist" | undefined; - /** Choices for a `select` control. The first entry is the default. */ + readonly options?: ReadonlyArray | undefined; } -/** Secret fields declared by a settings schema, shared by persistence and client redaction. */ export function secretSettingsFields(schema: { readonly fields: Readonly>; }): ReadonlyArray { @@ -585,7 +521,6 @@ declare module "effect/Schema" { } } -/** Mark a string or a record of strings as a credential in its settings schema. */ export function secretSetting(schema: S): S["Rebuild"] { return schema.annotateKey({ settingsSecret: true }); } @@ -600,7 +535,6 @@ export type ProviderSettingsOrder = readonl string >[]; -/** A provider settings struct whose fields the settings form renders in `order`. */ export function makeProviderSettingsSchema( fields: Fields, options?: { @@ -672,9 +606,6 @@ export const CodexSettings = makeProviderSettingsSchema( ); export type CodexSettings = typeof CodexSettings.Type; -// Empty, or an integer from 100,000 to 1,000,000. Shared by the full -// Claude settings schema and its patch so an out-of-range value fails at -// the update that introduced it. const CLAUDE_AUTO_COMPACT_WINDOW_PATTERN = /^(?:|[1-9]\d{5}|1000000)$/; export const ClaudeSettings = makeProviderSettingsSchema( @@ -735,11 +666,6 @@ export const ClaudeSettings = makeProviderSettingsSchema( ); export type ClaudeSettings = typeof ClaudeSettings.Type; -/** - * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in - * in the browser. The API key and Agent Platform methods take credentials from - * the instance config and never open a browser. - */ export const ANTIGRAVITY_AUTH_METHODS = [ { value: "oauth-personal", label: "Google account" }, { value: "oauth-business", label: "Gemini Enterprise" }, @@ -817,12 +743,6 @@ export const AntigravitySettings = makeProviderSettingsSchema( ); export type AntigravitySettings = typeof AntigravitySettings.Type; -/** - * A read-only quota source outside this environment's provider CLIs. The - * only kind today is a CLIProxyAPI hub, whose management API reports the - * windows of every pooled account. The key travels in settings for now, like - * provider environment secrets; it is redacted before reaching a client. - */ export const UsageLimitSourceConfig = Schema.Struct({ kind: Schema.Literal("cliproxy"), label: Schema.optional(TrimmedNonEmptyString), @@ -832,18 +752,10 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; -/** A GitHub host name, lowercased on decode so `GitHub.com` and `github.com` are one entry. */ export const GitHubHost = TrimmedNonEmptyString.pipe( Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), ); -/** - * One source control host's saved settings. Each host package declares the fields it reads in - * its client definition's settings schema and decodes this blob with it; the environment stores - * it whole, so a host this build does not ship keeps its settings. A field annotated as a secret - * holds a string, or a string per server host, whose value lives in the server's secret store: - * settings and clients only see a redaction marker for a saved one. - */ export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; @@ -923,20 +835,9 @@ export const BackgroundActivitySettings = Schema.Struct({ }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; -/** - * How assistant text reaches clients while a turn runs. - * - `turn`: hold the whole message until the turn finishes or pauses. - * - `paragraph`: deliver each finished paragraph or closed code block. - */ export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph"]); export type ResponseStreamingMode = typeof ResponseStreamingMode.Type; -/** - * Server settings a project may override. Every other server setting is - * environment-wide: providers, keybindings, observability, device hosts, - * background activity, theme. UI, search and the write planner derive - * eligibility from this list, so adding a key here is the whole opt-in. - */ const StorageRetentionDays = Schema.NullOr( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), ); @@ -1008,18 +909,13 @@ export const ProjectProviderInstanceOverride = Schema.Struct({ export type ProjectProviderInstanceOverride = typeof ProjectProviderInstanceOverride.Type; /** - * One project's overrides. An absent key inherits the environment value; - * `null` is a real value where the environment type is nullable (no default - * model, no dedicated writer model, never auto-settle). + * An absent key inherits the environment value; `null` is a real value where + * the environment type is nullable (no default model, no dedicated writer + * model, never auto-settle). * - * `providerInstances` sits outside `PROJECT_SCOPED_SERVER_SETTING_KEYS`: - * every other key replaces its `ServerSettings` field whole, while this one - * keeps the settings.json shape but overrides field by field, per - * `ProviderInstanceId`. Only `enabled` is overridable today. An instance id - * or field absent here inherits the machine value, and a present `enabled` - * wins in either direction, so a project can turn on an instance the machine - * disabled, or turn off one the machine enabled. See - * `resolveProjectProviderInstanceEnabled` for the combined resolution. + * `providerInstances` sits outside `PROJECT_SCOPED_SERVER_SETTING_KEYS`: every + * other key replaces its `ServerSettings` field whole, while this one keeps the + * settings.json shape but overrides field by field, per `ProviderInstanceId`. */ export const ProjectSettingsOverrides = Schema.Struct({ worktreeCleanup: Schema.optionalKey(WorktreeCleanup), @@ -1148,10 +1044,7 @@ export const ServerSettings = Schema.Struct({ storageCleanup: StorageCleanupSettings.pipe( Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))), ), - /** - * Absolute directory new worktrees are created under, e.g. `D:\worktrees` - * or `~/worktrees`. Empty uses `/worktrees`. - */ + worktreesDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), /** * Custom locations used before the current one. Server-maintained so @@ -1164,22 +1057,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("paragraph" as const)), ), enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - // Retain the update-era key. On by default so restarts resume the work they - // interrupted; recovery stops after three automatic continuations in a row. continueThreadsAfterServerUpdate: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), - /** - * Whether agents may drive the in-app preview browser. Turning this off - * withholds the MCP credential, so the `t3-code` server (and with it every - * `preview_*` tool) is never attached to a provider session, and the prompt - * text describing those tools is dropped along with them. The user's own - * browser panel is unaffected β€” this gates agent access only. - * - * Server-authoritative rather than client-local: tool injection and prompt - * construction both happen on the server, and the answer must not differ - * between a desktop window and a phone attached to the same server. - */ + enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -1200,37 +1081,22 @@ export const ServerSettings = Schema.Struct({ defaultRuntimeMode: RuntimeMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), - /** - * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`, - * plus `providerInstances`. The source of truth for project - * settings; `projectAgentBrowserAccessOverrides`, `projectAutoPullOverrides` - * and `projectScriptOverrides` are derived views kept for one release so - * older clients keep reading them. - */ + projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), /** - * Whether the legacy per-project fields have been folded into - * `projectSettingsOverrides`. The fold runs once so a later reset in the - * settings UI is not undone by the next server start. + * The fold runs once so a later reset in the settings UI is not undone by the + * next server start. */ projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - /** - * Whether agents may drive simulators and emulators. Gates the `device_*` - * MCP tools and the preconfigured `agent-device` CLI the same way - * `enableAgentBrowserAccess` gates the browser: server-authoritative, applied - * when the provider session is prepared. The user's own Device panel is - * unaffected. - */ + enableAgentDeviceAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** - * Whether this server may install and run T3's device helper processes. * Kept separate from agent access so enabling the user's Device panel does * not also grant providers control of simulators and emulators. */ enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - /** Whether the server-local Device panel setup flow has been completed. */ deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( @@ -1243,8 +1109,7 @@ export const ServerSettings = Schema.Struct({ ), sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, - // Legacy flat fields retained for old settings files and old clients. New - // consumers should resolve `backgroundActivity` instead. + automaticGitFetchInterval: Schema.DurationFromMillis.pipe( Schema.withDecodingDefault( Effect.succeed(Duration.toMillis(DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL)), @@ -1260,30 +1125,18 @@ export const ServerSettings = Schema.Struct({ ), defaultTheme: DefaultThemePreference.pipe(Schema.withDecodingDefault(Effect.succeed(""))), /** - * When the environment's theme was last set, so clients can tell a re-set - * of the same value from one they already applied: `t3 theme set` must act - * even when it names the theme it named before. Empty on environments - * provisioned by builds that predate it, where clients fall back to - * applying once per value. + * When the environment's theme was last set, so clients can tell a re-set of + * the same value from one they already applied: `t3 theme set` must act even + * when it names the theme it named before. */ defaultThemeSetAt: Schema.String.check(Schema.isMaxLength(64)).pipe( Schema.withDecodingDefault(Effect.succeed("")), ), - /** - * The icon clients draw for this environment. Null means "use what the - * server detected" (`environment.platform.machine`), falling back to a - * generic server. Lives on the server, not the client, so every device - * sees the same machine. A kind picked on a newer server decodes as null - * here rather than failing the whole settings snapshot for an older client. - */ environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), /** - * Null means inherit: the repository's t3.json, then "local". The old - * default "local" was never persisted (defaults are stripped on write), so - * it now decodes as inherit, which resolves the same way because the old - * chain also let t3.json outrank the environment. Null stays off the wire + * Null means inherit: the repository's t3.json, then "local". Null stays off the wire * so older clients, which require a literal here, keep decoding. */ defaultThreadEnvMode: OmittedWhenNull(ThreadEnvMode), @@ -1325,43 +1178,24 @@ export const ServerSettings = Schema.Struct({ sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), - /** - * Beta: review guides on this server. Off hides the Guide tab in every client and refuses to - * write guides, for people and agents alike. - */ reviewGuidesEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - /** - * The model that reads a pull request to write its review guide. It investigates a checkout - * over many turns, so it can want a stronger model than one-shot writing. `null` uses the - * source control writer model. - */ reviewGuideModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), - /** - * The maintainer's taste for review guides: what to dwell on, skip, or order first. Shapes the - * guide's emphasis only; the output format and coverage rules stay fixed. - */ reviewGuideInstructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - /** - * The merge method pull requests start with; `null` reuses the method - * last chosen on this device. Server-side so a project can override it - * like any other project setting. - */ + pullRequestMergeMethod: Schema.NullOr(PullRequestMergeMethod).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), - // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values - // are `ProviderInstanceConfig` envelopes. The driver-specific config blob - // is `Schema.Unknown` at this layer so envelopes with unknown drivers - // (forks, downgrades, in-flight PR branches) round-trip without loss. - // See providerInstance.ts for the forward/backward compatibility invariant. + // The driver-specific config blob is `Schema.Unknown` at this layer so + // envelopes with unknown drivers (forks, downgrades, in-flight PR branches) + // round-trip without loss. See providerInstance.ts for the forward/backward + // compatibility invariant. providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - /** Keyed by source control host kind, such as `gitcafe`; see `SourceControlHostSettings`. */ sourceControlHosts: Schema.Record(Schema.String, SourceControlHostSettings).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), @@ -1370,18 +1204,14 @@ export const ServerSettings = Schema.Struct({ usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), - /** Allows this server to read the Cursor CLI's macOS Keychain login for account usage. */ cursorKeychainUsageEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), - /** Exact model IDs, applied to past and future usage on this environment. */ + usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), - /** - * Exact model ID to the model its usage counts as, such as a preview slug to - * its released name. The mapped model is priced and reported as its target. - */ + usageModelAliases: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), @@ -1390,12 +1220,6 @@ export type ServerSettings = typeof ServerSettings.Type; export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); -/** - * Read the legacy `enabled` flag embedded in a provider instance config - * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single - * flag going forward; this reader exists for old settings files that still - * carry the flag in-config. - */ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => { if (config === null || typeof config !== "object" || Array.isArray(config)) { return undefined; @@ -1405,8 +1229,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un }; /** - * Built-in drivers that stay off until the user turns them on. Matches the - * `enabled` decoding default of each driver's settings schema. + * Matches the `enabled` decoding default of each driver's settings schema. */ const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( ["cursor", "grok", "muse", "pi", "opencode", "antigravity"].map((driver) => @@ -1414,32 +1237,16 @@ const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new S ), ); -/** Built-in drivers whose default instance runs before the user configures it. */ const DEFAULT_ENABLED_DEFAULT_INSTANCES: ReadonlySet = new Set( ["codex", "claudeAgent"].map((instanceId) => ProviderInstanceId.make(instanceId)), ); -/** - * Whether the built-in default instance at `instanceId` is enabled while - * settings have no `providerInstances` entry for it. Only Codex and Claude - * start on; any other id without an entry has no running instance. - */ export const isUnconfiguredDefaultInstanceEnabled = (instanceId: ProviderInstanceId): boolean => DEFAULT_ENABLED_DEFAULT_INSTANCES.has(instanceId); -/** - * Default enabled state for a driver when neither the envelope nor the config - * blob carries a flag. Unknown (fork) drivers default to enabled. - */ const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => !DEFAULT_DISABLED_PROVIDER_DRIVERS.has(driver); -/** - * Resolve whether a configured provider instance is enabled. An explicit - * false on either the envelope or the in-config flag wins (most - * restrictive), so a user's disable is never silently undone by the other - * flag. Otherwise: envelope, then config, then the driver's default. - */ export const resolveProviderInstanceEnabled = ( instance: Pick, ): boolean => { @@ -1450,12 +1257,6 @@ export const resolveProviderInstanceEnabled = ( return instance.enabled ?? configEnabled ?? defaultEnabledForDriver(instance.driver); }; -/** - * Effective enablement for a provider instance within a project: the - * project's `providerInstances[id].enabled` override when set, otherwise the - * machine-wide `resolveProviderInstanceEnabled` value. `projectId` of `null` - * (no project in scope) always resolves to the machine-wide value. - */ export const resolveProjectProviderInstanceEnabled = ( settings: Pick, projectId: ProjectId | null, @@ -1497,8 +1298,6 @@ export class ServerSettingsError extends Schema.TaggedError operation: ServerSettingsOperation, providerInstanceId: Schema.optional(Schema.String), environmentVariable: Schema.optional(Schema.String), - // Validation failures (e.g. a create colliding with an existing - // instance) originate without an upstream defect. cause: Schema.optional(Schema.Defect()), }, ) { @@ -1513,16 +1312,12 @@ export class ServerSettingsError extends Schema.TaggedError } } -// ── Unified type ───────────────────────────────────────────────────── - export type UnifiedSettings = Omit & ClientSettings; export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { ...DEFAULT_SERVER_SETTINGS, ...DEFAULT_CLIENT_SETTINGS, }; -// ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── - const ModelSelectionPatch = Schema.Struct({ instanceId: Schema.optionalKey(ProviderInstanceId), model: Schema.optionalKey(TrimmedNonEmptyString), @@ -1579,7 +1374,6 @@ export const ServerSettingsPatch = Schema.Struct({ }), ), worktreesDirectory: Schema.optionalKey(TrimmedString), - // Server settings responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), @@ -1599,15 +1393,11 @@ export const ServerSettingsPatch = Schema.Struct({ defaultRuntimeMode: Schema.optionalKey(RuntimeMode), /** * Per-project entry replacement: each entry replaces that project's whole - * override set and `null` removes it. Clearing one override means resending - * the entry without that key. Per-key null cannot express "clear" for the - * keys whose value type is itself nullable, and clients always hold the - * current entry from the last settings snapshot. + * override set and `null` removes it. * - * `providerInstances` is the one exception: an entry that omits the key - * keeps the stored value instead of clearing it, so a client built before - * that field existed cannot wipe it by editing another key. Clearing every - * provider override for a project means sending `providerInstances: {}`. + * `providerInstances` is the one exception: an entry that omits the key keeps + * the stored value instead of clearing it, so a client built before that + * field existed cannot wipe it by editing another key. */ projectSettingsOverrides: Schema.optionalKey( Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)), @@ -1670,7 +1460,6 @@ export const ServerSettingsPatch = Schema.Struct({ // Whole-map replacement for the new instance config. Patching individual // entries is intentionally out of scope: the map is small, and partial // patches risk leaving driver-specific config in a half-merged state. - // The web UI sends a fully-formed map every time it edits this field. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), // Per-entry, unlike `providerInstances`: a client only ever adds or removes // one source, and sending the whole map races another edit that has not @@ -1679,18 +1468,17 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), ), cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), - /** Each entry replaces one model's rates; `null` restores automatic pricing. */ + usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), ), - /** Each entry replaces one model's mapping; `null` removes it. */ + usageModelAliases: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)), ), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; -/** A mixed settings patch must be authorized for every configuration domain it changes. */ export function requiredScopesForServerSettingsPatch( patch: ServerSettingsPatch, ): ReadonlyArray { diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 4df2581ef93a..b9e7ec592700 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -3,11 +3,9 @@ import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { VcsDriverKind } from "./vcs.ts"; /** - * `SourceControlProviderKind` β€” open branded slug naming a source control host, such as `github`. - * * Open on purpose, like `ProviderDriverKind`: a server can report a host this client does not * ship a definition for, and clients fall back to a generic presentation instead of failing to - * decode. `unknown` is what the server reports for a remote it cannot attribute to any host. + * decode. */ export const SourceControlProviderKind = TrimmedNonEmptyString.check( Schema.isMaxLength(64), @@ -32,10 +30,8 @@ export const ChangeRequest = Schema.Struct({ url: Schema.String, baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, - /** The head commit, when the provider's read includes it. */ headSha: Schema.optional(TrimmedNonEmptyString), state: ChangeRequestState, - /** Present when the provider can tell that an open change request is still a draft. */ isDraft: Schema.optional(Schema.Boolean), closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), @@ -128,7 +124,6 @@ export const SourceControlProviderAuth = Schema.Struct({ account: Schema.Option(TrimmedNonEmptyString), host: Schema.Option(TrimmedNonEmptyString), detail: Schema.Option(TrimmedNonEmptyString), - /** Every login the provider CLI holds, across hosts. Only GitHub reports these today. */ accounts: Schema.optionalKey( Schema.Array( Schema.Struct({ diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index eef374043fee..c60ddcf8e09c 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -5,10 +5,8 @@ import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { ProjectScriptIcon } from "./project.ts"; import type { ProjectScopedServerSettingKey, ServerSettings } from "./settings.ts"; -/** File name of the checked-in T3 project file, resolved at the workspace root. */ export const T3_PROJECT_FILE_NAME = "t3.json"; -/** Public URL of the published JSON Schema for {@link T3ProjectFile}. */ export const T3_PROJECT_FILE_SCHEMA_URL = "https://t3.codes/schema/t3.json"; const T3_PROJECT_FILE_PATH_MAX_LENGTH = 512; @@ -114,13 +112,10 @@ export const T3ProjectFile = Schema.Struct({ export type T3ProjectFile = typeof T3ProjectFile.Type; /** - * Settings a repository can also declare in t3.json. A key here must be - * nullable on `ServerSettings` (null means inherit) so both the project - * override and the environment value can defer to the file; `field` names - * the t3.json field carrying the same value and `builtIn` is what applies - * when every tier is unset. `resolveProjectSettings` walks project override, - * environment value, file, built-in, so listing a key here is the whole - * change for a new file-backed setting. + * A key here must be nullable on `ServerSettings` (null means inherit) so both + * the project override and the environment value can defer to the file; `field` + * names the t3.json field carrying the same value and `builtIn` is what applies + * when every tier is unset. */ export const PROJECT_FILE_BACKED_SETTINGS = { defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, @@ -142,11 +137,6 @@ export const PROJECT_FILE_BACKED_SETTINGS = { }; export type ProjectFileBackedSettingKey = keyof typeof PROJECT_FILE_BACKED_SETTINGS; -/** - * `ServerSettings` with every file-backed key resolved to a concrete value. - * What `resolveProjectSettings(...).settings` produces once a t3.json (or - * its absence) has been accounted for. - */ export type ResolvedServerSettings = Omit & { readonly [K in ProjectFileBackedSettingKey]: Exclude; }; diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index e8772e4d1ae3..7e9edf3a99c2 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -2,11 +2,6 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -/** - * Client-side id for the first shell opened on a thread. Ids are uniformly - * `term-N`; there's no "default" intrinsic. Kept as a named constant so callers - * that want "the primary shell" don't hardcode `"term-1"`. - */ export const DEFAULT_TERMINAL_ID = "term-1"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -110,7 +105,6 @@ export const TerminalSessionSnapshot = Schema.Struct({ history: Schema.String, exitCode: Schema.NullOr(Schema.Int), exitSignal: Schema.NullOr(Schema.Int), - /** Server-computed display title (idle shell vs subprocess command). */ label: Schema.String.check(Schema.isMaxLength(128)), updatedAt: Schema.String, sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), @@ -127,7 +121,6 @@ export const TerminalSummary = Schema.Struct({ exitCode: Schema.NullOr(Schema.Int), exitSignal: Schema.NullOr(Schema.Int), hasRunningSubprocess: Schema.Boolean, - /** Server-computed display title (idle shell vs subprocess command). */ label: Schema.String.check(Schema.isMaxLength(128)), updatedAt: Schema.String, }); diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 2dfb532c7f2f..92e88386f601 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -16,11 +16,6 @@ import { PullRequestState, } from "./pullRequest.ts"; -/** - * Legacy single-PR link. Still emitted as the thread's derived current pull - * request (see `@t3tools/shared/threadPullRequests`) so clients from before - * `pullRequests` keep working independently of their release schedule. - */ export const ThreadLinkedPullRequest = Schema.Struct({ projectId: ProjectId, repository: TrimmedNonEmptyString, @@ -29,7 +24,7 @@ export const ThreadLinkedPullRequest = Schema.Struct({ }); export type ThreadLinkedPullRequest = typeof ThreadLinkedPullRequest.Type; -/** Who created a thread ↔ pull request link. `stack-dismissed` is a tombstone +/** `stack-dismissed` is a tombstone * for a native-stack member the user unlinked, so the sync reactor does not * re-add it; clients hide it. */ export const ThreadPullRequestLinkSource = Schema.Literals([ @@ -41,11 +36,6 @@ export const ThreadPullRequestLinkSource = Schema.Literals([ ]); export type ThreadPullRequestLinkSource = typeof ThreadPullRequestLinkSource.Type; -/** - * Host state persisted on a link by the sync reactor; null until first sync. The overview - * fields are optional: a host whose cheap read lacks them leaves them out, and snapshots - * written before they existed still decode. - */ export const ThreadPullRequestSnapshot = Schema.Struct({ state: PullRequestState, title: TrimmedNonEmptyString, @@ -73,7 +63,7 @@ export const ThreadPullRequestStackLayer = Schema.Struct({ }); export type ThreadPullRequestStackLayer = typeof ThreadPullRequestStackLayer.Type; -/** A host-native stack the pull request belongs to. Layers run bottom to top. */ +/** Layers run bottom to top. */ export const ThreadPullRequestStack = Schema.Struct({ kind: Schema.Literal("native"), id: TrimmedNonEmptyString, @@ -84,8 +74,6 @@ export const ThreadPullRequestStack = Schema.Struct({ }); export type ThreadPullRequestStack = typeof ThreadPullRequestStack.Type; -/** Identity of a pull request as a thread link sees it: host-level, so the - * same PR linked from two projects (or two environments) compares equal. */ export const ThreadPullRequestKey = Schema.Struct({ host: TrimmedNonEmptyString, repository: TrimmedNonEmptyString, @@ -93,28 +81,14 @@ export const ThreadPullRequestKey = Schema.Struct({ }); export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; -/** - * Present while the server watches the pull request for its thread. The server wakes the - * thread's agent when checks finish on the head commit, someone else comments, or the branch - * starts to conflict. The other fields record what the agent was last told, so each change is - * reported once. - */ export const ThreadPullRequestWatch = Schema.Struct({ startedAt: IsoDateTime, - /** Head commit at the last pass; null where the host does not report one. */ headSha: Schema.NullOr(TrimmedNonEmptyString), - /** Failed checks on that commit the agent was told about; a rerun that fails again is news. */ failedChecks: Schema.Array(TrimmedNonEmptyString), - /** The agent was told the required checks on that commit passed. */ passed: Schema.Boolean, - /** - * Names in the passing gate on that commit, so a required check that first shows up already - * passed is news. Empty on watches saved before this field existed. - */ passedChecks: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), - /** Remarks from others created up to this host time were reported. */ remarksThrough: IsoDateTime, /** Remarks created exactly at `remarksThrough` that were reported, so a late one still counts. */ remarkIds: Schema.Array(TrimmedNonEmptyString), diff --git a/packages/contracts/src/threadTitle.ts b/packages/contracts/src/threadTitle.ts index d2fa28dd7fcf..8f14f431ecff 100644 --- a/packages/contracts/src/threadTitle.ts +++ b/packages/contracts/src/threadTitle.ts @@ -2,7 +2,6 @@ import * as Schema from "effect/Schema"; import { CommandId, IsoDateTime } from "./baseSchemas.ts"; -/** An in-flight title regeneration, as MCP thread metadata reports it. */ export const ThreadTitleRegeneration = Schema.Struct({ requestId: CommandId, startedAt: IsoDateTime, diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 966acdaff771..29cddca8ce38 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -1,34 +1,14 @@ -/** - * Usage reporting contract. - * - * Each environment scans native session files and databases, including work - * driven outside T3 Code. Source status describes gaps in local coverage. - * - * Environments return pre-aggregated `(day, hourStart?, provider, model, sourcePath?)` - * buckets. Raw transcript records never cross the wire. - * - * @module usage - */ import * as Schema from "effect/Schema"; import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** - * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The - * client renders partial coverage when an environment reports an older version - * rather than failing the whole page. - * Adding providers or other array-element variants is additive: unknown - * entries are skipped on decode and do not require a version bump. So are - * optional bucket fields, which older clients ignore. + * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. + * Adding providers or other array-element variants is additive: unknown entries + * are skipped on decode and do not require a version bump. */ export const USAGE_CONTRACT_VERSION = 6 as const; -/** - * Oldest {@link UsageSummary} version a current client will still merge. - * - * v5/v6 add providers and optional source attribution; v4 Claude/Codex - * buckets remain valid in mixed-version environments. - */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; export const UsageProviderKind = Schema.Literals([ @@ -42,12 +22,6 @@ export const UsageProviderKind = Schema.Literals([ ]); export type UsageProviderKind = typeof UsageProviderKind.Type; -/** - * A calendar day in the reporting time zone, formatted `YYYY-MM-DD`. - * - * Days are bucketed server-side so that a turn always lands on the day the user - * experienced it, not the UTC day. - */ const USAGE_DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_PATTERN)).pipe( @@ -58,20 +32,10 @@ export type UsageDay = typeof UsageDay.Type; export const UsageResolution = Schema.Literals(["day", "hour"]); export type UsageResolution = typeof UsageResolution.Type; -/** - * Why a bucket's cost is what it is. - * - * - `providerReported` - the transcript carried an explicit cost figure. - * - `modelPriced` - we used a custom price override or the LiteLLM rate table. - * - `unpriced` - tokens are known, rates are not. Counted in totals, excluded - * from cost. - */ export const UsageCostSource = Schema.Literals(["providerReported", "modelPriced", "unpriced"]); export type UsageCostSource = typeof UsageCostSource.Type; /** - * Token counts for a bucket. - * * `cachedInputTokens` and `cacheCreationTokens` are disjoint from * `uncachedInputTokens`; summing all three gives total input. `reasoningTokens` * is a *subset* of `outputTokens` (Codex reports it that way, and Anthropic @@ -86,10 +50,6 @@ export const UsageTokenTotals = Schema.Struct({ }); export type UsageTokenTotals = typeof UsageTokenTotals.Type; -/** - * A bucket's cost split by token category, in USD. A provider-reported cost is - * split in proportion to the model's list rates. - */ export const UsageCategoryCost = Schema.Struct({ input: Schema.Number, cacheRead: Schema.Number, @@ -98,68 +58,35 @@ export const UsageCategoryCost = Schema.Struct({ }); export type UsageCategoryCost = typeof UsageCategoryCost.Type; -/** - * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start - * instant of a rolling bucket and is present only for hourly requests. - * - * `costUsd` is the raw API-equivalent cost of these tokens. It is not money - * spent: subscription plans bill separately. `unpricedRecords` counts records - * whose tokens are included in the token totals but which contributed nothing - * to `costUsd`. - */ export const UsageBucket = Schema.Struct({ day: UsageDay, hourStart: Schema.optional(TrimmedNonEmptyString), provider: UsageProviderKind, model: TrimmedNonEmptyString, - /** Source directory, so overlapping multi-home environments merge once per source. */ sourcePath: Schema.optional(TrimmedNonEmptyString), totals: UsageTokenTotals, costUsd: Schema.Number, - /** - * What the cached input would have cost at full input rates minus what it - * actually cost. Requires the rate table, so it is computed alongside cost - * rather than derived on the client. - */ cacheSavingsUsd: Schema.Number, - /** - * `costUsd` by token category. Cost with no known rates stays out of it, and - * it is absent when nothing could be split or the server predates it. - */ categoryCostUsd: Schema.optional(UsageCategoryCost), - /** Cost of fast and ultrafast requests. Absent when zero; the rest is standard. */ fastCostUsd: Schema.optional(Schema.Number), ultrafastCostUsd: Schema.optional(Schema.Number), - /** What fast and ultrafast requests cost above the standard rate. Absent when zero. */ speedPremiumUsd: Schema.optional(Schema.Number), costSource: UsageCostSource, - /** Distinct assistant responses, after de-duplication. */ records: NonNegativeInt, unpricedRecords: NonNegativeInt, - /** Distinct transcript sessions that contributed to this cell. */ sessions: NonNegativeInt, }); export type UsageBucket = typeof UsageBucket.Type; -/** - * Identifies the physical transcript directory a source read from. - * - * Two environments on the same machine (worktree servers, for example) resolve - * the same provider home and would otherwise double count. The client drops - * duplicate fingerprints before merging. - */ export const UsageSourceFingerprint = Schema.Struct({ hostId: TrimmedNonEmptyString, provider: UsageProviderKind, resolvedHomePath: TrimmedNonEmptyString, /** * Filesystem identity of the transcript directory, as `device:inode`. - * * Hostname and path alone are not enough: every Mac in a fleet resolves * `/Users//.claude`, so two machines that happen to share a hostname - * would look like one source and have their usage silently dropped. The - * device/inode pair is stable for two servers reading the same directory and - * effectively never collides across machines. Empty when it cannot be read. + * would look like one source and have their usage silently dropped. */ volumeId: Schema.String, }); @@ -173,16 +100,14 @@ export const UsageSource = Schema.Struct({ status: UsageSourceStatus, scannedFiles: NonNegativeInt, skippedFiles: NonNegativeInt, - /** Records that parsed but carried no recognisable usage payload. */ malformedRecords: NonNegativeInt, /** - * Distinct transcript sessions seen under this directory. Buckets also carry - * per-bucket session counts, but a session spans days and models, so summing - * those overcounts; this is the figure clients should total. + * Buckets also carry per-bucket session counts, but a session spans days and + * models, so summing those overcounts; this is the figure clients should + * total. */ distinctSessions: NonNegativeInt, message: Schema.NullOr(TrimmedNonEmptyString), - /** An action the client can offer to make this source available. */ action: Schema.optionalKey(Schema.Literal("enableCursorKeychain")), /** * Present when this source answered from its cache while a slow refresh (an @@ -196,10 +121,6 @@ export type UsageSource = typeof UsageSource.Type; export const UsagePricingStatus = Schema.Literals(["fresh", "cached", "unavailable"]); export type UsagePricingStatus = typeof UsagePricingStatus.Type; -/** - * Provenance for the rate table, so the UI can be honest about how good the - * cost figures are. - */ export const UsagePricing = Schema.Struct({ status: UsagePricingStatus, source: TrimmedNonEmptyString, @@ -209,26 +130,16 @@ export const UsagePricing = Schema.Struct({ export type UsagePricing = typeof UsagePricing.Type; export const UsageSummaryInput = Schema.Struct({ - /** Inclusive first day of the window, in `timeZone`. */ sinceDay: UsageDay, - /** Inclusive last day of the window, in `timeZone`. */ untilDay: UsageDay, /** * IANA zone the client wants days bucketed in. An offset would be wrong for * any window that crosses a DST boundary. */ timeZone: TrimmedNonEmptyString, - /** Defaults to daily for older clients. */ resolution: Schema.optional(UsageResolution), - /** Inclusive UTC instant for an hourly rolling window. */ sinceTime: Schema.optional(TrimmedNonEmptyString), - /** Exclusive UTC instant for an hourly rolling window. */ untilTime: Schema.optional(TrimmedNonEmptyString), - /** - * Wait for slow sources to finish refreshing instead of answering from their - * cache. Clients send it as the follow-up to a summary with a `refreshing` - * source. Older servers ignore it and always wait. - */ awaitRefresh: Schema.optional(Schema.Boolean), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; @@ -242,14 +153,12 @@ export const UsageSummary = Schema.Struct({ buckets: ForwardCompatibleArray(UsageBucket), sources: ForwardCompatibleArray(UsageSource), pricing: UsagePricing, - /** Wall-clock cost of the scan, surfaced in diagnostics. */ scanDurationMs: NonNegativeInt, }); export type UsageSummary = typeof UsageSummary.Type; export class UsageReadError extends Schema.TaggedError()("UsageReadError", { reason: Schema.Literals(["scanFailed", "invalidWindow"]), - /** Stable, bounded description. The underlying failure travels in `cause`. */ detail: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }) { diff --git a/packages/contracts/src/usageLimitSourceId.ts b/packages/contracts/src/usageLimitSourceId.ts index 1b323d311814..528b149f6eab 100644 --- a/packages/contracts/src/usageLimitSourceId.ts +++ b/packages/contracts/src/usageLimitSourceId.ts @@ -1,9 +1,8 @@ import * as Schema from "effect/Schema"; /** - * Key of one `settings.usageLimitSources` entry. Lives in its own module so - * both the settings and the usage-limit contracts can import it without - * importing each other. + * Lives in its own module so both the settings and the usage-limit contracts + * can import it without importing each other. */ export const UsageLimitSourceId = Schema.String.pipe(Schema.brand("UsageLimitSourceId")); export type UsageLimitSourceId = typeof UsageLimitSourceId.Type; diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index dec0e46b6eef..f92e4fc1d5c4 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -119,7 +119,6 @@ export class VcsProcessExitError extends Schema.TaggedError exitCode: Schema.Number, detail: Schema.String, failureKind: Schema.optional(VcsProcessExitFailureKind), - /** Process-boundary hint for a recognized transient failure; absence is not retryable. */ retryable: Schema.optional(Schema.Boolean), stderrLength: Schema.optional(NonNegativeInt), stderrTruncated: Schema.optional(Schema.Boolean), diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index 1ceafef55d9e..eff57bcb97ce 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -3,12 +3,9 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; /** - * Input for the `t3_worktree_handoff` MCP tool. - * - * Creates a git worktree for the calling agent thread and re-points the - * thread at it. Changing the thread's workspace detaches the live provider - * session, so the current turn ends shortly after the handoff is recorded; - * the conversation continues inside the worktree on the thread's next run. + * Changing the thread's workspace detaches the live provider session, so the + * current turn ends shortly after the handoff is recorded; the conversation + * continues inside the worktree on the thread's next run. */ export const WorktreeMcpHandoffInput = Schema.Struct({ branch: TrimmedNonEmptyString.annotate({ @@ -27,10 +24,7 @@ export const WorktreeMcpHandoffInput = Schema.Struct({ }), ), path: Schema.optional( - TrimmedNonEmptyString.check( - // Absolute POSIX (/...), Windows drive (C:\ or C:/), or UNC (\\host). - Schema.isPattern(/^(?:[A-Za-z]:[\\/]|[\\/])/), - ).annotate({ + TrimmedNonEmptyString.check(Schema.isPattern(/^(?:[A-Za-z]:[\\/]|[\\/])/)).annotate({ description: "Absolute filesystem path for the new worktree. Relative paths are rejected. Defaults to the server-managed worktrees directory.", }), diff --git a/packages/contracts/src/worktreeSetup.ts b/packages/contracts/src/worktreeSetup.ts index 203cfd6a4929..b2df5eb89cca 100644 --- a/packages/contracts/src/worktreeSetup.ts +++ b/packages/contracts/src/worktreeSetup.ts @@ -2,11 +2,6 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; -/** - * Live progress for a thread whose first turn is creating a worktree. The - * server keeps this in memory only; a client that reconnects mid-setup gets a - * fresh snapshot, and a finished setup is dropped once its turn starts. - */ /** Producers clamp free text to these before publishing so encoding never fails. */ export const WORKTREE_SETUP_DETAIL_MAX_LENGTH = 200; export const WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH = 400; @@ -36,11 +31,8 @@ export const WorktreeSetupStage = Schema.Struct({ status: WorktreeSetupStageStatus, startedAt: Schema.NullOr(IsoDateTime), endedAt: Schema.NullOr(IsoDateTime), - /** Only the checkout stage reports a real percentage, parsed from git's `Updating files` lines. */ percent: Schema.NullOr(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))), - /** Short trailing text for the row: a file count, an exit code, a submodule name. */ detail: Schema.NullOr(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_DETAIL_MAX_LENGTH))), - /** Last few output lines from the setup script, ANSI stripped, newest last. */ tail: Schema.Array(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_TAIL_LINE_MAX_LENGTH))), }); export type WorktreeSetupStage = typeof WorktreeSetupStage.Type; @@ -56,7 +48,6 @@ export const WorktreeSetupSnapshot = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), baseRef: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), - /** Display name plus command of the setup script from t3.json, when one runs. */ setupScript: Schema.NullOr( Schema.Struct({ name: TrimmedNonEmptyString, @@ -65,20 +56,11 @@ export const WorktreeSetupSnapshot = Schema.Struct({ }), ), stages: Schema.Array(WorktreeSetupStage), - /** Human readable reason when phase is failed. */ error: Schema.NullOr(Schema.String.check(Schema.isMaxLength(WORKTREE_SETUP_ERROR_MAX_LENGTH))), sequence: NonNegativeInt, }); export type WorktreeSetupSnapshot = typeof WorktreeSetupSnapshot.Type; -/** - * Thread activity that carries a `WorktreeSetupSnapshot` as its payload. The - * bootstrap writes it under a fixed id once the thread exists (phase running) - * and again when the setup settles, so the projection always holds the - * latest known state: a client attaches the live stream while it says - * running and renders the outcome from it afterwards, on any device or - * after a reload. - */ export const WORKTREE_SETUP_ACTIVITY_KIND = "worktree-setup"; export const WorktreeSetupSubscribeInput = Schema.Struct({ @@ -86,7 +68,6 @@ export const WorktreeSetupSubscribeInput = Schema.Struct({ }); export type WorktreeSetupSubscribeInput = typeof WorktreeSetupSubscribeInput.Type; -/** Null means no setup is tracked for that thread. Sent first, then after every change. */ export const WorktreeSetupStreamEvent = Schema.NullOr(WorktreeSetupSnapshot); export type WorktreeSetupStreamEvent = typeof WorktreeSetupStreamEvent.Type; diff --git a/packages/effect-acp/src/_generated/meta.gen.ts b/packages/effect-acp/src/_generated/meta.gen.ts index a491d90d845c..2641ff307a38 100644 --- a/packages/effect-acp/src/_generated/meta.gen.ts +++ b/packages/effect-acp/src/_generated/meta.gen.ts @@ -1,6 +1,3 @@ -// This file is generated by the effect-acp package. Do not edit manually. -// Current ACP schema release: schema-v2.0.0-alpha.3 - export const AGENT_METHODS = { initialize: "initialize", auth_login: "auth/login", diff --git a/packages/effect-acp/src/_generated/schema-v1.gen.ts b/packages/effect-acp/src/_generated/schema-v1.gen.ts index 6dd71bfc50bb..268118dab204 100644 --- a/packages/effect-acp/src/_generated/schema-v1.gen.ts +++ b/packages/effect-acp/src/_generated/schema-v1.gen.ts @@ -1,6 +1,3 @@ -// This file is generated by the effect-acp package. Do not edit manually. -// Current ACP schema release: schema-v1.21.0 - import * as Schema from "effect/Schema"; export type SessionId = string; diff --git a/packages/effect-acp/src/_generated/schema.gen.ts b/packages/effect-acp/src/_generated/schema.gen.ts index 12f652a45986..ad12b7e197e4 100644 --- a/packages/effect-acp/src/_generated/schema.gen.ts +++ b/packages/effect-acp/src/_generated/schema.gen.ts @@ -1,5 +1,4 @@ // This file is generated by the effect-acp package. Do not edit manually. -// Current ACP schema release: schema-v2.0.0-alpha.3 import * as Schema from "effect/Schema"; diff --git a/packages/effect-acp/src/_internal/shared.ts b/packages/effect-acp/src/_internal/shared.ts index 527fb99304c6..0a7c638b5bf2 100644 --- a/packages/effect-acp/src/_internal/shared.ts +++ b/packages/effect-acp/src/_internal/shared.ts @@ -30,10 +30,6 @@ export const callRpc = ( }), ); -/** - * Runs a notification handler so it cannot stop the caller: a typed failure is - * dropped, as notifications have no reply, and a defect is logged. - */ export const isolateNotificationHandler = (effect: Effect.Effect) => effect.pipe( Effect.catchCause((cause) => diff --git a/packages/effect-acp/src/agent.test.ts b/packages/effect-acp/src/agent.test.ts index 416c4808a3ed..5fec95118f64 100644 --- a/packages/effect-acp/src/agent.test.ts +++ b/packages/effect-acp/src/agent.test.ts @@ -35,7 +35,6 @@ const SessionCancelNotification = jsonRpcNotification( const ExtPingNotification = jsonRpcNotification("x/ping", Schema.Struct({ count: Schema.Number })); const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); -/** A response whose cause is a handler's defect, as RpcServer encodes it. */ const DieResponse = Schema.Struct({ id: Schema.Number, error: Schema.Struct({ diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 2ddf5cc8cc0d..829cc850e8df 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -35,50 +35,23 @@ export class AcpAgent extends Context.Service< AcpAgent, { readonly raw: { - /** - * Stream of inbound ACP notifications observed on the connection. - */ readonly notifications: Stream.Stream; - /** - * Sends a generic ACP extension request. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly request: ( method: string, payload: unknown, ) => Effect.Effect; - /** - * Sends a generic ACP extension notification. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly notify: (method: string, payload: unknown) => Effect.Effect; }; readonly client: { - /** - * Requests client permission for an operation. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ readonly requestPermission: ( payload: AcpSchema.RequestPermissionRequest, ) => Effect.Effect; - /** - * Requests structured user input from the client. - * @see https://agentclientprotocol.com/protocol/schema#elicitation/create - */ readonly elicit: ( payload: AcpSchema.CreateElicitationRequest, ) => Effect.Effect; - /** - * Sends a `session/update` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ readonly sessionUpdate: ( payload: AcpSchema.SessionNotification, ) => Effect.Effect; - /** - * Sends an `elicitation/complete` notification to the client. - * @see https://agentclientprotocol.com/protocol/schema#elicitation/complete - */ readonly elicitationComplete: ( payload: AcpSchema.CompleteElicitationNotification, ) => Effect.Effect; @@ -94,34 +67,18 @@ export class AcpAgent extends Context.Service< readonly notifyMcp: ( payload: AcpSchema.MessageMcpNotification, ) => Effect.Effect; - /** - * Sends an ACP extension request to the client. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly extRequest: ( method: string, payload: unknown, ) => Effect.Effect; - /** - * Sends an ACP extension notification to the client. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly extNotification: ( method: string, payload: unknown, ) => Effect.Effect; }; - /** - * Registers a handler for `initialize`. - * @see https://agentclientprotocol.com/protocol/schema#initialize - */ readonly handleInitialize: ( handler: AcpRequestHandler, ) => Effect.Effect; - /** - * Registers a handler for `authenticate`. - * @see https://agentclientprotocol.com/protocol/schema#authenticate - */ readonly handleAuthenticate: ( handler: AcpRequestHandler, ) => Effect.Effect; @@ -170,10 +127,6 @@ export class AcpAgent extends Context.Service< readonly handlePrompt: ( handler: AcpRequestHandler, ) => Effect.Effect; - /** - * Registers a handler for `session/cancel`. - * @see https://agentclientprotocol.com/protocol/schema#session/cancel - */ readonly handleCancel: ( handler: ( notification: AcpSchema.CancelNotification, @@ -428,8 +381,6 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( ); yield* RpcServer.make(AcpRpcs.AgentRpcs, { disableFatalDefects: true }).pipe( - // runHandler logs handler defects with their method. A reporter inherited - // from the caller (a WebSocket request, say) would log them again. Effect.provideService(ErrorReporter.CurrentErrorReporters, new Set()), Effect.provideService(RpcServer.Protocol, transport.serverProtocol), Effect.provide(layerAgentHandler), diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 6209dcffa41f..0a4393312280 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -52,9 +52,7 @@ const PermissionRequest = jsonRpcRequest( const PermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); const ElicitationRequest = jsonRpcRequest("elicitation/create", AcpSchema.CreateElicitationRequest); const ElicitationResponse = jsonRpcResponse(AcpSchema.CreateElicitationResponse); -/** A JSON-RPC error response; only its id and the presence of an error matter here. */ const ErrorResponse = Schema.Struct({ id: Schema.String, error: Schema.Unknown }); -/** A response whose cause is a handler's defect, as RpcServer encodes it. */ const DieResponse = Schema.Struct({ id: Schema.String, error: Schema.Struct({ @@ -1169,7 +1167,6 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { const send = (line: Effect.Effect) => Effect.flatMap(line, (bytes) => Queue.offer(input, bytes)); const decodeDie = Schema.decodeEffect(Schema.fromJsonString(DieResponse)); - // A stopped reader leaves these waiting; fail instead of hanging. const next = (queue: Queue.Dequeue) => TestClock.withLive(Queue.take(queue).pipe(Effect.timeout("2 seconds"))); @@ -1224,10 +1221,8 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); - // The next session handler still ran. assert.equal(yield* next(updates), "session-1"); - // The reader survived all three: a later request is still answered. yield* send( encodeJsonl(ExtRequest, { jsonrpc: "2.0", @@ -1242,7 +1237,6 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { ); assert.deepEqual([answered.id, answered.result], ["ext-b", { ok: true }]); - // Every defect was logged at Error, once. assert.deepEqual((yield* Queue.clear(errorLogs)).toSorted(), [ "ACP extension request handler failed for 'x/dies'", "ACP notification handler failed", diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 732e2d26650f..4b3af5881364 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -40,11 +40,9 @@ export interface AcpClientOptions { readonly onTermination?: AcpProtocol.AcpPatchedProtocolOptions["onTermination"]; readonly onOutgoingResponseFailure?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponseFailure"]; readonly onOutgoingResponse?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponse"]; - /** Transforms child output before protocol logging and parsing. */ readonly transformStdout?: ( stdout: ChildProcessSpawner.ChildProcessHandle["stdout"], ) => AcpProtocol.AcpStdio["stdin"]; - /** Transforms decoded session updates before buffering or delivery. */ readonly transformSessionUpdate?: ( notification: AcpSchema.SessionNotification, ) => AcpSchema.SessionNotification; @@ -63,68 +61,34 @@ export class AcpClient extends Context.Service< { readonly raw: AcpClientRaw; readonly agent: { - /** - * Initializes the ACP session and negotiates capabilities. - * @see https://agentclientprotocol.com/protocol/schema#initialize - */ readonly initialize: ( payload: AcpSchema.InitializeRequest, ) => Effect.Effect; - /** - * Performs authentication with the method negotiated for ACP v1 or v2. - * @see https://agentclientprotocol.com/protocol/v2/draft/authentication - */ readonly authenticate: ( payload: AcpSchema.AuthenticateRequest, ) => Effect.Effect; - /** - * Logs out the current ACP identity using the negotiated generation. - * @see https://agentclientprotocol.com/protocol/v2/draft/authentication - */ readonly logout: ( payload: AcpSchema.LogoutRequest, ) => Effect.Effect; - /** - * Starts a new ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/new - */ readonly createSession: ( payload: AcpSchema.NewSessionRequest, ) => Effect.Effect; /** - * Resumes a saved ACP session with replay from the beginning. - * * This compatibility name maps to ACP v2's `session/resume`; v2 removed * `session/load` and expresses replay with `replayFrom`. */ readonly loadSession: ( payload: AcpSchema.LoadSessionRequest, ) => Effect.Effect; - /** - * Lists available ACP sessions. - * @see https://agentclientprotocol.com/protocol/schema#session/list - */ readonly listSessions: ( payload: AcpSchema.ListSessionsRequest, ) => Effect.Effect; - /** - * Forks an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/fork - */ readonly forkSession: ( payload: AcpSchema.ForkSessionRequest, ) => Effect.Effect; - /** - * Resumes an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/resume - */ readonly resumeSession: ( payload: AcpSchema.ResumeSessionRequest, ) => Effect.Effect; - /** - * Closes an ACP session. - * @see https://agentclientprotocol.com/protocol/schema#session/close - */ readonly closeSession: ( payload: AcpSchema.CloseSessionRequest, ) => Effect.Effect; @@ -140,17 +104,12 @@ export class AcpClient extends Context.Service< readonly disableProvider: ( payload: AcpSchema.DisableProviderRequest, ) => Effect.Effect; - /** - * Updates a session configuration option. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option - */ readonly setSessionModel: ( payload: AcpSchema.SetSessionModelRequest, ) => Effect.Effect; /** * Selects a session mode on ACP v1 agents, which predate mode config * options. ACP v2 fails with method-not-found; use `setSessionConfigOption`. - * @see https://agentclientprotocol.com/protocol/schema#session/set_mode */ readonly setSessionMode: ( payload: AcpSchemaV1.SetSessionModeRequest, @@ -158,35 +117,19 @@ export class AcpClient extends Context.Service< readonly setSessionConfigOption: ( payload: AcpSchema.SetSessionConfigOptionRequest, ) => Effect.Effect; - /** - * Sends a prompt turn to the agent. - * @see https://agentclientprotocol.com/protocol/schema#session/prompt - */ readonly prompt: ( payload: AcpSchema.PromptRequest, ) => Effect.Effect; - /** - * Sends a real ACP `session/cancel` notification. - * @see https://agentclientprotocol.com/protocol/schema#session/cancel - */ readonly cancel: ( payload: AcpSchema.CancelNotification, ) => Effect.Effect; }; - /** - * Registers a handler for `session/request_permission`. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ readonly handleRequestPermission: ( handler: AcpRequestHandler< AcpSchema.RequestPermissionRequest, AcpSchema.RequestPermissionResponse >, ) => Effect.Effect; - /** - * Registers a handler for `elicitation/create` requests. - * @see https://agentclientprotocol.com/protocol/schema#elicitation/create - */ readonly handleElicitation: ( handler: AcpRequestHandler< AcpSchema.CreateElicitationRequest, @@ -207,7 +150,6 @@ export class AcpClient extends Context.Service< notification: AcpSchema.MessageMcpNotification, ) => Effect.Effect, ) => Effect.Effect; - /** Legacy ACP v1 hooks retained for policy compatibility; ACP v2 never advertises them. */ readonly handleReadTextFile: ( handler: AcpRequestHandler, ) => Effect.Effect; @@ -241,28 +183,16 @@ export class AcpClient extends Context.Service< AcpSchema.ReleaseTerminalResponse | void >, ) => Effect.Effect; - /** - * Registers a handler for `session/update`. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ readonly handleSessionUpdate: ( handler: ( notification: AcpSchema.SessionNotification, ) => Effect.Effect, ) => Effect.Effect; - /** - * Registers a handler for `elicitation/complete`. - * @see https://agentclientprotocol.com/protocol/schema#elicitation/complete - */ readonly handleElicitationComplete: ( handler: ( notification: AcpSchema.CompleteElicitationNotification, ) => Effect.Effect, ) => Effect.Effect; - /** - * Registers a fallback extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly handleUnknownExtRequest: ( handler: ( method: string, @@ -270,26 +200,14 @@ export class AcpClient extends Context.Service< context: AcpProtocol.AcpRequestContext, ) => Effect.Effect, ) => Effect.Effect; - /** - * Registers a fallback extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly handleUnknownExtNotification: ( handler: (method: string, params: unknown) => Effect.Effect, ) => Effect.Effect; - /** - * Registers a typed extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly handleExtRequest: ( method: string, payload: Schema.Codec, handler: AcpRequestHandler, ) => Effect.Effect; - /** - * Registers a typed extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly handleExtNotification: ( method: string, payload: Schema.Codec, @@ -840,7 +758,6 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( registration: BufferedNotificationHandler, notification: A, ) => - // One handler failing or dying does not stop the others, or the reader. Effect.forEach( registration.handlers, (handler) => isolateNotificationHandler(handler(notification)), @@ -1133,8 +1050,7 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( Effect.map((response) => { // ACP v1 protocolVersion values were not generation identifiers in // every shipped agent. Antigravity, for example, reports version 2 - // with the v1 agentInfo/agentCapabilities response shape. The - // response discriminator determines the wire generation reliably. + // with the v1 agentInfo/agentCapabilities response shape. if (isV2InitializeResponse(response)) { negotiatedProtocolGeneration = 2; return normalizeInitializeResponse(response); diff --git a/packages/effect-acp/src/compat.ts b/packages/effect-acp/src/compat.ts index a2be086de874..f0454f68cf0a 100644 --- a/packages/effect-acp/src/compat.ts +++ b/packages/effect-acp/src/compat.ts @@ -8,7 +8,6 @@ type KnownContentBlock = Extract< { readonly type: T } >; -/** Provider-neutral content accepted by T3 after ACP v1 or v2 negotiation. */ export type ContentBlock = | KnownContentBlock<"text"> | KnownContentBlock<"image"> @@ -380,8 +379,6 @@ export type RequestPermissionRequest = { }; export type RequestPermissionResponse = V2.RequestPermissionResponse; -// ACP v1 client-mediated filesystem and terminal shapes remain as internal -// types for the legacy policy helpers. ACP v2 never advertises or wires them. export interface ReadTextFileRequest { readonly sessionId: string; readonly path: string; diff --git a/packages/effect-acp/src/errors.test.ts b/packages/effect-acp/src/errors.test.ts index a43af1c537d0..fa64e9f5a30a 100644 --- a/packages/effect-acp/src/errors.test.ts +++ b/packages/effect-acp/src/errors.test.ts @@ -162,8 +162,6 @@ describe("effect-acp errors", () => { }), ); - // The summary goes into logs, so it names where a payload broke its schema - // and never copies a value, including a record key the agent chose. it.effect("summarizes schema issues by path without payload values", () => Effect.gen(function* () { const secret = "acp-summary-secret"; diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index 2bd1313ea0b7..506dced5e522 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -41,8 +41,6 @@ interface AcpSchemaIssueDiagnostics { const MAX_SUMMARY_ISSUES = 3; const MAX_SUMMARY_PATH_SEGMENTS = 8; -// Record keys come from the payload, so a key that is not a plain identifier -// is shown as `*` rather than copied. const summaryPathSegment = (segment: PropertyKey): string => typeof segment === "number" || /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(String(segment)) ? String(segment) @@ -71,11 +69,9 @@ const isCatchAllMember = (issue: SchemaIssue.Issue) => "ast" in issue && SchemaAST.resolveTitle(issue.ast) === "other"; /** - * Names where a payload broke its schema, such as `update.used missing`, - * without copying any value from it. ACP unions end in a member titled - * "other" that admits custom and future variants; it fails only because the - * discriminator names a known variant, so the known variant's issues are the - * ones reported. + * ACP unions end in a member titled "other" that admits custom and future + * variants; it fails only because the discriminator names a known variant, so + * the known variant's issues are the ones reported. */ const schemaIssueSummary = (root: SchemaIssue.Issue): string => { const entries = new Set(); @@ -192,7 +188,6 @@ export class AcpProtocolParseError extends Schema.TaggedError { method: "session/update", issueSummary: "update.used missing", }); - // The repeated bad update is dropped again but warned about once. assert.lengthOf( events.filter(({ stage }) => stage === "decode_failed"), 2, @@ -353,7 +352,6 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { ), ); - // Pending requests are failed through termination instead of hanging. const error = yield* TestClock.withLive( Deferred.await(termination).pipe(Effect.timeout("2 seconds")), ); @@ -441,8 +439,6 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { serverRequestMethods: new Set(), }); - // Notifications encode through Schema, so the cause is the schema failure rather - // than the raw TypeError JSON.stringify throws. The ACP error shape is what callers see. const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); assert.equal(bigintError.operation, "encode-message"); @@ -611,7 +607,6 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), - // The typed failure must not hide the defect from its cleanup. onExtRequest: () => Effect.fail(AcpError.AcpRequestError.invalidParams("bad params")).pipe( Effect.ensuring(Effect.die(new Error("cleanup bug"))), @@ -1132,7 +1127,6 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.isTrue(exitDetail.intentionalEnd); assert.equal(yield* Ref.get(terminations), 0); - // Tear down the forever input reader so the scoped test can finish. yield* Queue.end(input); }), ); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 7f510a17cde7..1be22e0af460 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -30,13 +30,11 @@ export interface AcpProtocolLogEvent { readonly payload: unknown; } -/** Stable transport identity for an inbound ACP JSON-RPC request. */ export interface AcpRequestContext { readonly requestId: string; readonly method: string; } -/** Lossless string identity for JSON-RPC request IDs used by callback maps. */ export function acpRequestIdentity(requestId: AcpError.AcpRequestId): string { const prefix = "$t3:jsonrpc:"; if (typeof requestId === "number") return `${prefix}number:${requestId}`; @@ -68,7 +66,6 @@ export type AcpIncomingNotification = readonly params: unknown; }; -/** Standard I/O whose input can report provider-specific ACP failures. */ export interface AcpStdio extends Omit { readonly stdin: Stream.Stream; } @@ -108,7 +105,6 @@ export interface AcpPatchedProtocolOptions { readonly onOutgoingResponse?: (requestId: string) => Effect.Effect; readonly testHooks?: { readonly onOutgoingWriteAdmitted?: () => Effect.Effect; - /** Invoked from the stdout writer exit path (after intentional/unexpected handling). */ readonly onOutgoingWriterExit?: (input: { readonly intentionalEnd: boolean; }) => Effect.Effect; @@ -167,7 +163,6 @@ const makeStrictNdJsonRpcParser = () => { }; const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; -/** A decode failure's structural diagnostics, without its cause or any wire payload. */ const decodeFailureDiagnostics = (error: AcpError.AcpProtocolParseError) => ({ operation: error.operation, ...(error.method === undefined ? {} : { method: error.method }), @@ -177,7 +172,6 @@ const decodeFailureDiagnostics = (error: AcpError.AcpProtocolParseError) => ({ ...(error.maximumPathDepth === undefined ? {} : { maximumPathDepth: error.maximumPathDepth }), ...(error.issueSummary === undefined ? {} : { issueSummary: error.issueSummary }), }); -// Outbound JSON-RPC notification: no `id`, so peers never treat it as a request. const encodeJsonRpcNotification = Schema.encodeUnknownExit( Schema.fromJsonString( Schema.Struct({ @@ -193,9 +187,7 @@ const isEffectRpcRequestId = (requestId: AcpError.AcpRequestId): boolean => /** * Effect RPC's JSON-RPC codec treats a standard JSON-RPC error object as a - * defect unless it carries Effect's private `_tag: "Cause"` envelope. ACP - * agents correctly send the standard `{ code, message, data? }` shape, so - * restore it to the typed failure channel before handing it to RpcClient. + * defect unless it carries Effect's private `_tag: "Cause"` envelope. */ function normalizeAcpJsonRpcError( message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, @@ -475,9 +467,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi }) .pipe( Effect.matchCauseEffect({ - // A dying handler answers its own request, like a core handler, and - // leaves the reader running. A defect wins over a typed failure in - // the same cause, so it is never hidden behind an expected error. onFailure: (cause) => { const failure = Cause.hasDies(cause) ? Option.none() : Cause.findErrorOption(cause); if (Option.isSome(failure)) { @@ -734,8 +723,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi // terminates the connection so pending requests fail instead of hanging. Effect.matchCauseEffect({ onFailure: (cause) => { - // The reader's own interruption never gets here: an interrupted fiber - // skips failure handlers. An interrupt raised inside it ends it too. const failure = Cause.findErrorOption(cause); const error = Option.isSome(failure) ? failure.value : Cause.squash(cause); const normalized: AcpError.AcpError = isAcpError(error) @@ -832,9 +819,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi onSuccess: () => Ref.get(outgoingWriterState).pipe( Effect.flatMap((state) => { - // Intentional Queue.end (serverProtocol.end) completes the writer - // successfully. Do not invent a transport failure or client protocol - // error for a clean RPC shutdown. const handled = state.intentionalEnd ? completeOutgoingWrites() : (() => { @@ -916,7 +900,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi // JSON-RPC notifications carry no `id`. Encoding a Request without `isNotification` // emits an `id`, which real agents (Grok CLI) parse as a malformed request and silently drop. - // That made `session/cancel` a no-op against Grok while the lenient mock agent accepted it. const sendNotification = Effect.fn("sendNotification")(function* ( method: string, payload: unknown, diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index e7efb97b5613..9d21c7cbe33c 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -141,9 +141,6 @@ export const AgentRpcs = RpcGroup.make( SetSessionConfigOptionRpc, ); -// Registry agents transition independently. The compatibility groups accept -// both protocol generations on the wire while the public client normalizes -// them into one provider-neutral surface after initialize negotiates v1 or v2. const CompatInitializeRpc = Rpc.make(AGENT_METHODS.initialize, { // The negotiation request intentionally carries both v1 and v2 capability // field names. A union encoder would select one branch and discard the @@ -308,7 +305,6 @@ const CompatRequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permi }); const CompatElicitationRpc = Rpc.make(CLIENT_METHODS.elicitation_create, { - // Decode in the handler so malformed requests receive ACP's invalid-params error. payload: Schema.Unknown, success: Schema.Union([ AcpSchema.CreateElicitationResponse, diff --git a/packages/effect-acp/src/schema.ts b/packages/effect-acp/src/schema.ts index 8397dfdc490c..e10872b29083 100644 --- a/packages/effect-acp/src/schema.ts +++ b/packages/effect-acp/src/schema.ts @@ -1,8 +1,6 @@ export * from "./_generated/schema.gen.ts"; export * from "./_generated/meta.gen.ts"; -// ACP v2 renamed these wire types. Keep the aliases while downstream adapters -// migrate their internal vocabulary; every runtime schema still comes from v2. export { CancelSessionNotification as CancelNotification, LoginAuthRequest as AuthenticateRequest, diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 2db05fcbb3eb..580e03ea5a96 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -140,9 +140,8 @@ const ManualSchemas: Record = { }, }; -// Codex adds plan slugs between our protocol refreshes (0.159 added `promax`). -// T3 Code only uses the plan for labels, so an unknown slug must not fail the -// whole `account/read` decode and take the provider down with it. +// T3 Code only uses the plan for labels, so an unknown slug must not fail the whole +// `account/read` decode and take the provider down with it. const DefinitionOverrides: Record = { PlanType: { type: "string" }, }; @@ -165,7 +164,6 @@ const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () { }); const fetchText = Effect.fn("fetchText")(function* (url: string) { - // Unauthenticated GitHub API calls are capped at 60/hour; set GITHUB_TOKEN to lift that. const token = process.env.GITHUB_TOKEN; return yield* HttpClientRequest.get(url).pipe( HttpClientRequest.setHeader("user-agent", USER_AGENT), @@ -377,7 +375,6 @@ function toPascalCaseMethod(method: string) { } function parseRequestEntries(fileContents: string): ReadonlyArray { - // Optional params render as `params?: Foo | undefined`; their JSON schema is `NullableFoo`. const entryPattern = /\{\s*"method":\s*"([^"]+)",\s*id:\s*RequestId,\s*params(\??):\s*([^,}|]+)/g; const entries: Array = []; let match: RegExpExecArray | null; @@ -455,9 +452,7 @@ function resolveResponseTypeName( const fromParams = paramsType.replace(/Params$/, "Response"); try { return resolveSchemaTypeName(fromParams, generatedSchemaNames); - } catch { - // Fall through to method-based lookup. - } + } catch {} } return resolveSchemaTypeName(`${toPascalCaseMethod(method)}Response`, generatedSchemaNames); diff --git a/packages/effect-codex-app-server/src/_generated/meta.gen.ts b/packages/effect-codex-app-server/src/_generated/meta.gen.ts index 1c9d47e58523..339bc326ac85 100644 --- a/packages/effect-codex-app-server/src/_generated/meta.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/meta.gen.ts @@ -1,6 +1,3 @@ -// This file is generated by the effect-codex-app-server package. Do not edit manually. -// Upstream protocol ref: 687a119f0fcaace47e1f1abcc77cec6c813fd6da - import * as CodexSchema from "./schema.gen.ts"; export const CLIENT_REQUEST_METHODS = { diff --git a/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts b/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts index 47474330d286..c707e6aac0fa 100644 --- a/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/namespaces.gen.ts @@ -1,6 +1,3 @@ -// This file is generated by the effect-codex-app-server package. Do not edit manually. -// Upstream protocol ref: 687a119f0fcaace47e1f1abcc77cec6c813fd6da - import * as CodexSchema from "./schema.gen.ts"; export const v1 = { diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index 38b46712257a..90006c016978 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -1,6 +1,3 @@ -// This file is generated by the effect-codex-app-server package. Do not edit manually. -// Upstream protocol ref: 687a119f0fcaace47e1f1abcc77cec6c813fd6da - import * as Schema from "effect/Schema"; export type ApplyPatchApprovalParams__ThreadId = string; diff --git a/packages/effect-codex-app-server/src/replay.ts b/packages/effect-codex-app-server/src/replay.ts index 1c98c7bc3b8a..e41bf6f73d06 100644 --- a/packages/effect-codex-app-server/src/replay.ts +++ b/packages/effect-codex-app-server/src/replay.ts @@ -244,20 +244,15 @@ function normalizeReplayFrame(value: unknown): unknown { return normalized; } -/** - * `config` keys a recorder sent on thread requests that the client under test - * never sends, such as `agents.max_depth`. Recorders list them in the - * transcript header as `metadata.recorderThreadConfigKeys`. - */ function recorderThreadConfigKeys(transcript: CodexAppServerReplayTranscript): ReadonlySet { const keys = transcript.metadata?.recorderThreadConfigKeys; return new Set(Array.isArray(keys) ? keys.filter((key) => typeof key === "string") : []); } /** - * Thread requests must match on the `config` the client owns. `cwd`, `model` - * and host-supplied MCP servers (machine-local URLs, short-lived authorization - * headers) vary per machine, and recorder-only keys are not the client's. + * `cwd`, `model` and host-supplied MCP servers (machine-local URLs, short-lived + * authorization headers) vary per machine, and recorder-only keys are not the + * client's. */ function normalizeThreadRequest(frame: unknown, ignoredConfigKeys: ReadonlySet): unknown { if (typeof frame !== "object" || frame === null) return frame; diff --git a/packages/provider-acp-registry/src/client.ts b/packages/provider-acp-registry/src/client.ts index 9d5cbd2fdce0..10f8c6e61cbe 100644 --- a/packages/provider-acp-registry/src/client.ts +++ b/packages/provider-acp-registry/src/client.ts @@ -1,10 +1,3 @@ -/** - * ACP Registry's client definition: settings form, label, and the registry - * glyph clients fall back to while an agent's own icon loads. - * Browser- and React Native-safe. - * - * @module provider-acp-registry/client - */ import { ProviderDriverKind } from "@t3tools/contracts"; import { defineProviderClient } from "@t3tools/provider-core/client"; @@ -14,7 +7,6 @@ export const acpRegistryClient = defineProviderClient({ driverKind: ProviderDriverKind.make("acpRegistry"), label: "ACP Registry", settingsSchema: AcpRegistrySettings, - // Each instance is an agent the user adds from the registry. hasDefaultInstance: false, icon: { viewBox: "0 0 576 220", diff --git a/packages/provider-acp-registry/src/server.ts b/packages/provider-acp-registry/src/server.ts index b8ca0bbe1872..636fe07230ad 100644 --- a/packages/provider-acp-registry/src/server.ts +++ b/packages/provider-acp-registry/src/server.ts @@ -1,13 +1,3 @@ -/** - * ACP Registry's server entry: the driver and adapter driver the server - * registers, the catalog layer it provides once, and the managed-binary - * directories the terminal manager keeps off agent PATHs. - * - * The catalog and runtime coordinator are services; import them from - * `./server/AcpRegistrySupport` and `./server/AcpRegistryRuntimeCoordinator`. - * - * @module provider-acp-registry/server - */ export { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./server/driver.ts"; export { AcpRegistryAdapterV2Driver, diff --git a/packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.ts b/packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.ts index ef86c87fa113..2ec4700ca060 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistryRuntimeCoordinator.ts @@ -32,7 +32,6 @@ interface UrlAuthActionUpdate { readonly action: AcpRegistryUrlAuthAction | null; } -/** Pending URL logins auto-decline after this window so stale prompts cannot be accepted later. */ const URL_AUTH_ACTION_TTL = Duration.minutes(10); interface PendingUrlAuthAction { @@ -41,7 +40,6 @@ interface PendingUrlAuthAction { readonly expiresAt: DateTime.Utc; } -/** Gives a user-started ACP process priority over disposable discovery for the same agent. */ export class AcpRegistryRuntimeCoordinator extends Context.Service< AcpRegistryRuntimeCoordinator, { @@ -53,7 +51,6 @@ export class AcpRegistryRuntimeCoordinator extends Context.Service< agentId: string, effect: Effect.Effect, ) => Effect.Effect, E, R>; - /** Serializes native-session imports and deletes across all client connections. */ readonly withSessionMutation: ( effect: Effect.Effect, ) => Effect.Effect; diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index e4a8126ce564..dafad5460f1a 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -632,7 +632,6 @@ describe("AcpRegistrySupport", () => { archive: archiveUrl, cmd: "./bin/example-agent", args: ["acp"], - // SHA-256 of binaryBytes. sha256: "97b29a636d7ddf7bf3567ae4d48c0f2a9b03943fd808b6efbf74f1dd3db131b7", }, }, @@ -692,7 +691,6 @@ describe("AcpRegistrySupport", () => { archive: archiveUrl, cmd: "./bin/example-agent", args: ["acp"], - // SHA-256 of binaryBytes. sha256: "e1eb7c905b91c400af2ec858628aec22866bfda1383b93ceb73ae01d9769fb31", }, }, diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts index b707c10a4438..519c4f45df86 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts @@ -314,12 +314,6 @@ function withPreferredPath( }; } -/** - * Directories exposing installed ACP Registry commands. The integrated - * terminal appends them to PATH so managed package commands and - * managed binary agents are both available by name. Best effort: unreadable - * directories resolve to no entries. - */ export const acpRegistryManagedBinaryDirectories = Effect.fn("acpRegistryManagedBinaryDirectories")( function* (input: { readonly cacheDir: string; @@ -378,7 +372,6 @@ export const acpRegistryManagedBinaryDirectories = Effect.fn("acpRegistryManaged Effect.orElseSucceed((): ReadonlyArray => []), ); const cachedAgentByInstall = new Map( - // Install directories are named by the URI-encoded version. cachedAgents.map( (agent) => [`${agent.id}\0${encodeURIComponent(agent.version)}`, agent] as const, ), @@ -528,12 +521,6 @@ export class AcpRegistryCatalog extends Context.Service< const layer = (options: AcpRegistryCatalogOptions) => Layer.effect(AcpRegistryCatalog, makeAcpRegistryCatalog(options)); -/** - * The server-lifetime catalog, with the runtime coordinator built alongside - * it so setup, snapshots, and turn launch share one of each. Its cache lives - * in the host's provider status cache and installed agents under the T3 - * home's `tools` directory. - */ export const layerFromHost = Layer.merge( Layer.unwrap( Effect.gen(function* () { @@ -1511,8 +1498,6 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }); yield* fileSystem.rename(archivePath, targetPath); } else { - // The new Windows ARM fallback must extract x64 ZIPs even when Git's GNU tar wins PATH. - // Keep archive selection for existing native distributions unchanged in this PR. const tarCommand = platformTarget === "windows-aarch64" && paths.platformTarget === "windows-x86_64" && @@ -1725,7 +1710,6 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct ? ({ status: "ready", agentId, - // The catalog version does not describe an overridden executable. version: null, distribution: distribution.kind, ...(documentationUrl ? { documentationUrl } : {}), diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 4cc847a9931e..63157b164624 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -74,17 +74,6 @@ export interface AcpRegistryAdapterV2Options { readonly assertComplete?: Effect.Effect; } -// ─── Per-agent exceptions ──────────────────────────────────────────────────── -// This adapter serves every ACP registry agent through the plain ACP spec. -// Agent-specific behavior does not belong here: an agent that needs it gets a -// dedicated driver (as Grok and Antigravity have). The few exceptions below -// predate that rule and are small presentation hooks, not permission or tool -// behavior. Copilot's subagent routing was approved later on the same terms. -// -// Agents changing this file: do NOT add another `agentId === "..."` branch, -// agent table, or agent-specific hook without explicit approval from the -// maintainer in the conversation. Propose a dedicated driver instead. - // Mistral Vibe: its application error code for rate limits (not an ACP code). const MISTRAL_VIBE_RATE_LIMITED = -31001; @@ -115,7 +104,6 @@ export function registerMistralVibeAcpExtensions(context: AcpAdapterV2ExtensionC }), ); } -// ─── End per-agent exceptions (Devin's gates are marked in makeAcpRegistryAdapterV2) ─── export function acpRegistryPromptFailure(agentId: string, cause: unknown) { return makeProviderFailure({ @@ -125,7 +113,6 @@ export function acpRegistryPromptFailure(agentId: string, cause: unknown) { message: cause.errorMessage, code: String(cause.code), class: - // Per-agent exception: see the note above registerMistralVibeAcpExtensions. agentId === "mistral-vibe" && cause.code === MISTRAL_VIBE_RATE_LIMITED ? ("usage_limit" as const) : ("provider_error" as const), @@ -201,8 +188,6 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu driver: ACP_REGISTRY_PROVIDER, capabilities: AcpProviderCapabilitiesV2, promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), - // Per-agent exceptions (Mistral Vibe, Devin, Copilot): see the note above - // registerMistralVibeAcpExtensions before adding any more. ...(registryAgentId === "mistral-vibe" ? { registerExtensions: registerMistralVibeAcpExtensions } : {}), @@ -259,9 +244,7 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu instanceId: options.instanceId, flavor, selfInvocation: options.selfInvocation, - // Per-agent exception (see the note above registerMistralVibeAcpExtensions): - // Devin runs commands through client terminals and has no ask mode over - // ACP to fall back on. Every other registry agent runs its own. + // Devin runs commands through client terminals and has no ask mode over ACP to fall back on. ...(isDevin ? { clientTerminals: { diff --git a/packages/provider-acp-registry/src/server/auth.ts b/packages/provider-acp-registry/src/server/auth.ts index ded5117c37b5..ac87bfc1b0c4 100644 --- a/packages/provider-acp-registry/src/server/auth.ts +++ b/packages/provider-acp-registry/src/server/auth.ts @@ -27,7 +27,6 @@ type Runtime = Pick< "initialize" | "authenticate" | "start" | "handleElicitation" | "logout" >; -/** ACP credentials stay in the agent's own store, using the same launch environment as chat. */ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (options: { readonly instanceId: ProviderInstanceId; readonly settings: AcpRegistrySettings; @@ -194,9 +193,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o detachExit(); try { process.kill(); - } catch { - /* The login process may already have exited. */ - } + } catch {} }), ); let transcript = ""; diff --git a/packages/provider-acp-registry/src/server/authenticationState.test.ts b/packages/provider-acp-registry/src/server/authenticationState.test.ts index ee3edcee4923..09229cc300e4 100644 --- a/packages/provider-acp-registry/src/server/authenticationState.test.ts +++ b/packages/provider-acp-registry/src/server/authenticationState.test.ts @@ -30,7 +30,6 @@ it.layer(NodeServices.layer)("ACP sign-in confirmation", (it) => { settings: { ...settings, customModels: ["new-model"] }, }); assert.isTrue(yield* recreated.get); - // Both logout and an explicit authentication failure revoke confirmation. yield* recreated.set(false); const restarted = yield* makeAcpRegistryAuthenticationState(input); assert.isFalse(yield* restarted.get); diff --git a/packages/provider-acp-registry/src/server/authenticationState.ts b/packages/provider-acp-registry/src/server/authenticationState.ts index f01f8f2a8dab..ede75c570104 100644 --- a/packages/provider-acp-registry/src/server/authenticationState.ts +++ b/packages/provider-acp-registry/src/server/authenticationState.ts @@ -22,7 +22,6 @@ const hash = Effect.fn("AcpRegistryAuthenticationState.hash")(function* (value: return Hex.encode(digest); }); -/** Remember explicit sign-in success, never discovery success or the agent's credentials. */ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")( function* (input: { readonly cacheDir: string; diff --git a/packages/provider-acp-registry/src/server/copilotAcp.ts b/packages/provider-acp-registry/src/server/copilotAcp.ts index 95b216e6a471..9cf8a6a7a155 100644 --- a/packages/provider-acp-registry/src/server/copilotAcp.ts +++ b/packages/provider-acp-registry/src/server/copilotAcp.ts @@ -7,16 +7,14 @@ import type { AcpAdapterV2SubagentUpdate, } from "@t3tools/provider-acp/server/adapter"; -// Copilot CLI streams subagent text as plain `agent_message_chunk`s on the -// root session. Only its opt-in raw event feed says who wrote a chunk: each -// chunk is preceded by an `assistant.*_delta` carrying the spawning `task` -// tool call id. Both arrive inline on the same reader, in wire order, so the -// delta just before a chunk is that chunk's author. +// Only its opt-in raw event feed says who wrote a chunk: each chunk is preceded +// by an `assistant.*_delta` carrying the spawning `task` tool call id. Both +// arrive inline on the same reader, in wire order, so the delta just before a +// chunk is that chunk's author. const COPILOT_META_KEY = "github.com/copilot"; const COPILOT_SESSION_EVENT = "github.com/copilot/sessionEvent"; -/** Subscribes to the raw events subagent routing needs, and nothing else. */ export const copilotClientCapabilitiesMeta = { [COPILOT_META_KEY]: { events: [ @@ -45,13 +43,10 @@ function record(value: unknown): Record | undefined { const childSessionId = (taskToolCallId: string) => `copilot-task:${taskToolCallId}`; interface CopilotSessionAttribution { - /** The last raw delta, consumed by the chunk that follows it. */ delta: { readonly kind: string; readonly text: string; readonly task?: string } | undefined; - /** Running subagent id to the `task` tool call that spawned it. */ readonly taskByAgentId: Map; } -/** Per adapter instance; entries are keyed by root session and drop once idle. */ export function makeCopilotSubagentRouting() { const sessions = new Map(); const session = (sessionId: string) => { @@ -110,7 +105,6 @@ export function makeCopilotSubagentRouting() { } }); - /** Moves a subagent's chunks and tool calls onto its own child session. */ const normalizeSessionUpdate = ( notification: AcpSchema.SessionNotification, ): AcpSchema.SessionNotification => { @@ -149,7 +143,6 @@ export function makeCopilotSubagentRouting() { return { registerExtensions, normalizeSessionUpdate }; } -/** Copilot's `task` tool launches a subagent; its id names the child session. */ export function extractCopilotSubagentUpdate( tool: AcpToolCallState, ): AcpAdapterV2SubagentUpdate | undefined { diff --git a/packages/provider-acp-registry/src/server/devinAcp.ts b/packages/provider-acp-registry/src/server/devinAcp.ts index 6f78d2506adc..4368a4cfaf43 100644 --- a/packages/provider-acp-registry/src/server/devinAcp.ts +++ b/packages/provider-acp-registry/src/server/devinAcp.ts @@ -8,7 +8,6 @@ function record(value: unknown): Record | undefined { : undefined; } -/** Devin's typed extension metadata, captured from its native ACP stream. */ export function normalizeDevinSessionUpdate( notification: AcpSchema.SessionNotification, ): AcpSchema.SessionNotification { diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index a480277acc73..dce555f0c5dc 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -94,8 +94,6 @@ function modelsFromDiscovery( customModels: ReadonlyArray, ): ReadonlyArray { const discovered = discovery?.models ?? []; - // Discovered session config options and modes ride on every model so the - // composer's generic option controls can drive them per thread. const capabilities = discovery === undefined || discovery.configOptions.length === 0 ? EMPTY_CAPABILITIES @@ -423,7 +421,6 @@ export const checkAcpRegistryProviderStatus = Effect.fn("AcpRegistryDriver.check }, ); -/** Publishes local executable readiness without starting an ACP process. */ export const checkAcpRegistryProviderReadiness = Effect.fn( "AcpRegistryDriver.checkProviderReadiness", )(function* ( @@ -458,7 +455,6 @@ export const checkAcpRegistryProviderReadiness = Effect.fn( export type AcpRegistryDriverEnv = AcpRegistryAdapterV2DriverEnv | ProviderHost.ProviderHost; -/** Canonical provider-instance wrapper for ACP Registry orchestration adapters. */ export const AcpRegistryDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { @@ -733,8 +729,6 @@ export const AcpRegistryDriver: ProviderDriver( effect: Effect.Effect< A, diff --git a/packages/provider-acp-registry/src/server/probe.ts b/packages/provider-acp-registry/src/server/probe.ts index e8cead21804f..f7ee9f802ec9 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -37,8 +37,6 @@ const MAX_NAME_LENGTH = 160; const MAX_DESCRIPTION_LENGTH = 1_024; const MAX_COMMANDS = 128; const MAX_COMMAND_LINE_LENGTH = 2_048; -// Covers a cold package command launch and ACP initialization after registry -// preparation globally installs the exact package. const PROBE_TIMEOUT_SECONDS = 60; const PROBE_TIMEOUT = `${PROBE_TIMEOUT_SECONDS} seconds`; const COMMAND_ADVERTISEMENT_GRACE = "500 millis"; @@ -95,7 +93,6 @@ function normalizeModels( return models; } -/** Agent spawn recipe used to compose runnable terminal-auth command lines. */ export interface AcpRegistryAuthSpawnContext { readonly command: string; readonly args: ReadonlyArray; @@ -173,7 +170,6 @@ export interface AcpRegistryLiveConfiguration { readonly configOptions: AcpRegistryProbeResult["configOptions"]; } -/** Normalizes volatile session configuration using the same bounds as discovery probes. */ export function normalizeAcpRegistryLiveConfiguration( configOptions: ReadonlyArray, modeState?: Parameters[0]["modeState"], @@ -200,7 +196,6 @@ const emptyAcpRegistryAvailableCommands = (): AcpRegistryAvailableCommands => ({ skills: [], }); -/** Splits the latest ACP command advertisement into T3's `/` and `$` menus. */ export function normalizeAcpRegistryCommands( commands: ReadonlyArray, ): AcpRegistryAvailableCommands { @@ -239,7 +234,6 @@ export function normalizeAcpRegistryCommands( return { slashCommands, skills }; } -/** Builds the bounded wire result from a successfully created disposable ACP session. */ export function acpRegistryProbeResult( instanceId: ProviderInstanceId, started: AcpSessionRuntime.AcpSessionRuntimeStartResult, @@ -306,7 +300,6 @@ export type AcpRegistryConfigurationProbe = ( input: AcpRegistryConfigurationProbeInput, ) => Effect.Effect; -/** Starts and immediately disposes an ACP session for one already-decoded configuration. */ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeConfiguration")( function* ( input: AcpRegistryConfigurationProbeInput, diff --git a/packages/provider-acp-registry/src/settings.ts b/packages/provider-acp-registry/src/settings.ts index bb9274a05981..7d5b4173d512 100644 --- a/packages/provider-acp-registry/src/settings.ts +++ b/packages/provider-acp-registry/src/settings.ts @@ -1,9 +1,3 @@ -/** - * ACP Registry instance settings. Shared by the server driver and the client - * settings form, so it holds only browser-safe schema code. - * - * @module provider-acp-registry/settings - */ import { TrimmedString, makeProviderSettingsSchema } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; diff --git a/packages/provider-acp-registry/src/testing.ts b/packages/provider-acp-registry/src/testing.ts index 83f9ba888085..4cc790443e5c 100644 --- a/packages/provider-acp-registry/src/testing.ts +++ b/packages/provider-acp-registry/src/testing.ts @@ -1,9 +1,3 @@ -/** - * ACP Registry internals the server's replay testkit and ACP adapter tests - * drive directly. - * - * @module provider-acp-registry/testing - */ export { ACP_REGISTRY_DEFAULT_INSTANCE_ID, ACP_REGISTRY_PROVIDER, diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index 25578f151b36..f0ff217a3a80 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -99,9 +99,7 @@ export interface AcpSessionRuntimeOptions { readonly sessionLoadTimeout?: Duration.Input; readonly sessionLoadReplayIdleGap?: Duration.Input; readonly interruptPromptOnCancel?: boolean; - /** Optional provider metadata forwarded on `session/cancel`. */ readonly cancelMeta?: EffectAcpSchema.CancelNotification["_meta"]; - /** Optional provider metadata forwarded on `initialize`. */ readonly initializeMeta?: EffectAcpSchema.InitializeRequest["_meta"]; readonly ownDetachedProcessGroup?: boolean; readonly ownDescendantProcessGroups?: boolean; @@ -112,7 +110,6 @@ export interface AcpSessionRuntimeOptions { ) => Effect.Effect; readonly posixProcessTreeController?: AcpPosixProcessTreeController; readonly linuxCgroupController?: AcpLinuxCgroupController | null; - /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */ readonly cancelBehavior?: "interrupt" | "wait-for-prompt"; readonly cancelTimeout?: Duration.Input; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; @@ -122,25 +119,22 @@ export interface AcpSessionRuntimeOptions { }; readonly authMethodId?: string; /** - * Call `authenticate` right after `initialize` instead of waiting for an - * auth-required error. Antigravity selects its credential source (Google - * account vs API key) from the method, so the session must not open first. + * Antigravity selects its credential source (Google account vs API key) from + * the method, so the session must not open first. */ readonly authenticateEagerly?: boolean; readonly mcpServers?: ReadonlyArray; - /** Extra workspace roots the agent may read and write besides `cwd`. */ readonly additionalDirectories?: ReadonlyArray; - /** Transforms provider stdout before protocol parsing and protocol logging. */ readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"]; - /** Normalizes provider-specific fields before notification queues or runtime state retain them. */ readonly transformSessionUpdate?: ( notification: EffectAcpSchema.SessionNotification, ) => EffectAcpSchema.SessionNotification; - /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */ + /** + * Redact secrets before logging. A failure closes the runtime. + */ readonly onStderr?: (text: string) => Effect.Effect; /** Disable only for non-interactive discovery that must surface auth-required immediately. */ readonly authenticateOnAuthRequired?: boolean; - /** Observes the normalized handshake before any session setup or authentication retry. */ readonly onInitialized?: ( result: EffectAcpSchema.InitializeResponse, ) => Effect.Effect; @@ -156,9 +150,8 @@ export interface AcpSessionRuntimeOptions { readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"]; readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"]; /** - * Model ids the user configured for this instance. The model config option - * accepts them even when the agent does not advertise them, and the agent - * validates them instead. + * The model config option accepts them even when the agent does not advertise + * them, and the agent validates them instead. */ readonly customModels?: ReadonlyArray; } @@ -193,11 +186,6 @@ export interface AcpPosixProcessIdentity { readonly pid: number; readonly ppid: number; readonly sid: number; - /** - * Linux `/proc//stat` state when known (`R`/`S`/`D`/`Z`/…). Omitted on - * platforms or fixtures that do not surface it. Zombies (`Z`) are already - * dead and must not count as residual teardown survivors. - */ readonly state?: string; readonly startTime: string; } @@ -336,9 +324,7 @@ export function resolveLinuxCgroupTargetCommand( if (!NodeFS.statSync(candidate).isFile()) continue; NodeFS.accessSync(candidate, NodeFS.constants.X_OK); return candidate; - } catch { - // Try the next PATH entry. - } + } catch {} } return undefined; } @@ -368,7 +354,6 @@ function defaultReadCgroupPopulated(siblingPath: string): "0" | "1" | undefined return state === "0" || state === "1" ? state : undefined; } -/** Best-effort removal of empty `t3-acp--*` sibling leases under a parent cgroup. */ export function sweepStaleLinuxCgroupSiblings( parentPath: string, options: SweepStaleLinuxCgroupSiblingsOptions = {}, @@ -390,13 +375,9 @@ export function sweepStaleLinuxCgroupSiblings( if (NodePath.dirname(siblingPath) !== parentPath) continue; if (readPopulated(siblingPath) !== "0") continue; remove(siblingPath); - } catch { - // Ignore unreadable or busy siblings; never fail lease creation. - } + } catch {} } - } catch { - // Ignore readdir failures on the parent path. - } + } catch {} } function tryCreateLinuxCgroupLease( @@ -438,9 +419,7 @@ function tryCreateLinuxCgroupLease( } catch { try { NodeFS.rmdirSync(childPath); - } catch { - // The unavailable cgroup was never used for a child process. - } + } catch {} return undefined; } return { @@ -489,7 +468,6 @@ export function makeLinuxCgroupController(): AcpLinuxCgroupController { return undefined; } if (!isSafeCgroupPath(currentRelative)) return undefined; - // Prefer the first mount that successfully creates a writable child cgroup. // Multi-mount hosts often list a read-only view before the delegated one. for (const mount of mounts) { const lease = tryCreateLinuxCgroupLease(currentRelative, mount); @@ -578,9 +556,7 @@ export function signalLinuxCgroupRootTerm(input: { const observed = input.controller.identity(root.pid); if (!samePosixProcessIdentity(root, observed) || !input.lease.contains(root.pid)) return; input.controller.signalProcess(root.pid, "SIGTERM"); - } catch { - // TERM is optional. cgroup.kill remains the authoritative teardown. - } + } catch {} } function readLinuxProcessIdentity(pid: number): AcpPosixProcessIdentity | undefined { @@ -643,9 +619,7 @@ function readLinuxChildPids(pid: number): ReadonlyArray { .map(Number)) { childPids.add(childPid); } - } catch { - // The task exited while its children were being read. - } + } catch {} } return [...childPids]; } @@ -737,7 +711,6 @@ export function observePosixOwnershipLedger(input: { if (child.ppid !== parent.pid) continue; let reservedPid = input.frontier.get(child.pid); if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) { - // PID reused under an owned parent; drop the stale reservation. input.frontier.delete(child.pid); for (const [key, owned] of input.ledger.entries()) { if (owned.pid === child.pid) input.ledger.delete(key); @@ -1054,7 +1027,6 @@ export function terminatePosixOwnedProcessTree(input: { if (!samePosixProcessIdentity(owned, observed) || observed === undefined) { return false; } - // Already-killed zombies still appear in /proc until reaped; not live. return !posixProcessIsZombie(observed); }); if (residual.length > 0) { @@ -1142,109 +1114,38 @@ export interface AcpSessionActivationOptions { export class AcpSessionRuntime extends Context.Service< AcpSessionRuntime, { - /** - * Registers a handler for `session/request_permission`. - * @see https://agentclientprotocol.com/protocol/schema#session/request_permission - */ readonly handleRequestPermission: EffectAcpClient.AcpClient["Service"]["handleRequestPermission"]; - /** - * Registers a handler for `session/elicitation`. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation - */ readonly handleElicitation: EffectAcpClient.AcpClient["Service"]["handleElicitation"]; readonly handleMcpConnect: EffectAcpClient.AcpClient["Service"]["handleMcpConnect"]; readonly handleMcpMessage: EffectAcpClient.AcpClient["Service"]["handleMcpMessage"]; readonly handleMcpDisconnect: EffectAcpClient.AcpClient["Service"]["handleMcpDisconnect"]; readonly handleMcpNotification: EffectAcpClient.AcpClient["Service"]["handleMcpNotification"]; - /** - * Registers a handler for `fs/read_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/read_text_file - */ readonly handleReadTextFile: EffectAcpClient.AcpClient["Service"]["handleReadTextFile"]; - /** - * Registers a handler for `fs/write_text_file`. - * @see https://agentclientprotocol.com/protocol/schema#fs/write_text_file - */ readonly handleWriteTextFile: EffectAcpClient.AcpClient["Service"]["handleWriteTextFile"]; - /** - * Registers a handler for `terminal/create`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/create - */ readonly handleCreateTerminal: EffectAcpClient.AcpClient["Service"]["handleCreateTerminal"]; - /** - * Registers a handler for `terminal/output`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/output - */ readonly handleTerminalOutput: EffectAcpClient.AcpClient["Service"]["handleTerminalOutput"]; - /** - * Registers a handler for `terminal/wait_for_exit`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/wait_for_exit - */ readonly handleTerminalWaitForExit: EffectAcpClient.AcpClient["Service"]["handleTerminalWaitForExit"]; - /** - * Registers a handler for `terminal/kill`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/kill - */ readonly handleTerminalKill: EffectAcpClient.AcpClient["Service"]["handleTerminalKill"]; - /** - * Registers a handler for `terminal/release`. - * @see https://agentclientprotocol.com/protocol/schema#terminal/release - */ readonly handleTerminalRelease: EffectAcpClient.AcpClient["Service"]["handleTerminalRelease"]; - /** - * Registers a handler for `session/update`. - * @see https://agentclientprotocol.com/protocol/schema#session/update - */ readonly handleSessionUpdate: EffectAcpClient.AcpClient["Service"]["handleSessionUpdate"]; - /** - * Registers a handler for `session/elicitation/complete`. - * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete - */ readonly handleElicitationComplete: EffectAcpClient.AcpClient["Service"]["handleElicitationComplete"]; - /** - * Registers a fallback extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly handleUnknownExtRequest: EffectAcpClient.AcpClient["Service"]["handleUnknownExtRequest"]; - /** - * Registers a fallback extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly handleUnknownExtNotification: EffectAcpClient.AcpClient["Service"]["handleUnknownExtNotification"]; - /** - * Registers a typed extension request handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly handleExtRequest: EffectAcpClient.AcpClient["Service"]["handleExtRequest"]; - /** - * Registers a typed extension notification handler. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; /** - * Sends only `initialize` and returns the agent's response. Health probes use this to read - * advertised capabilities without authenticating or opening a session, so a probe can never - * start an interactive login or boot MCP servers. - * @see https://agentclientprotocol.com/protocol/schema#initialize + * Health probes use this to read advertised capabilities without authenticating or opening + * a session, so a probe can never start an interactive login or boot MCP servers. */ readonly initialize: () => Effect.Effect< EffectAcpSchema.InitializeResponse, EffectAcpErrors.AcpError >; - /** Explicit login uses the negotiated v1 authenticate / v2 auth/login operation. */ readonly authenticate?: (methodId: string) => Effect.Effect; - /** - * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. - * Concurrent calls share the same in-flight startup and a failed startup may be retried. - */ readonly start: () => Effect.Effect; - /** Stream of parsed root-session events and connection failures. */ readonly getEvents: () => Stream.Stream; - /** Waits for queued events to be processed, or for the runtime scope to close. */ readonly drainEvents: Effect.Effect; - /** Latest mode state observed from session setup and `session/update` notifications. */ readonly getModeState: Effect.Effect; - /** Latest configuration options observed from session setup and configuration writes. */ readonly getConfigOptions: Effect.Effect>; readonly loadSession: ( sessionId: string, @@ -1277,42 +1178,26 @@ export class AcpSessionRuntime extends Context.Service< readonly disableProvider: ( providerId: string, ) => Effect.Effect; - /** Logs out the current ACP identity when the agent advertises support. */ readonly logout: Effect.Effect; /** - * Sends a prompt turn to the active session. `options.dispatched` settles once the - * `session/prompt` RPC is registered as the active prompt, so a caller that forks this - * effect knows when a later `cancel` will target this prompt. - * @see https://agentclientprotocol.com/protocol/schema#session/prompt + * `options.dispatched` settles once the `session/prompt` RPC is registered as the + * active prompt, so a caller that forks this effect knows when a later `cancel` will + * target this prompt. */ readonly prompt: ( payload: Omit, options?: { readonly dispatched?: Deferred.Deferred }, ) => Effect.Effect; - /** - * Sends a real ACP `session/cancel` notification for the active session. - * @see https://agentclientprotocol.com/protocol/schema#session/cancel - */ readonly cancel: Effect.Effect; readonly processContainment: | "cgroup-v2" | "process-ledger-reduced-guarantee" | "process-group" | "none"; - /** Terminates the opt-in detached ACP process group and all inherited work. */ readonly terminateProcessGroup?: Effect.Effect; - /** - * Selects the active mode through the negotiated `mode` configuration option. - * This is a no-op when the requested mode is already active. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option - */ readonly setMode: ( modeId: string, ) => Effect.Effect; - /** - * Updates a session configuration option and the runtime configuration snapshot. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option - */ readonly setSessionModel: ( modelId: string, meta?: EffectAcpSchema.SetSessionModelRequest["_meta"], @@ -1321,24 +1206,11 @@ export class AcpSessionRuntime extends Context.Service< configId: string, value: string | boolean, ) => Effect.Effect; - /** - * Selects the base model through the negotiated model configuration option. - * @see https://agentclientprotocol.com/protocol/schema#session/set_config_option - */ readonly setModel: (model: string) => Effect.Effect; - /** - - * Sends a generic ACP extension request and records it through the request logger. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly request: ( method: string, payload: unknown, ) => Effect.Effect; - /** - * Sends a generic ACP extension notification. - * @see https://agentclientprotocol.com/protocol/extensibility - */ readonly notify: ( method: string, payload: unknown, @@ -1392,13 +1264,10 @@ export const make = ( Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // A child of the caller's scope, so termination can close the runtime without closing the caller's. const runtimeScope = yield* Scope.fork(yield* Scope.Scope); const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); - // Recently shown tool calls. A late update to a finished call is not a new - // boundary in the answer, although its progress state is gone. const shownToolCallIds = new Set(); const assistantItemRuntimeId = yield* crypto.randomUUIDv4().pipe( Effect.mapError( @@ -1567,8 +1436,6 @@ export const make = ( cause: new Error("Contained ACP command was not found on PATH"), }); }); - // A cgroup lease is already its own leaf cgroup, so it only needs the - // raised OOM score (in its wrapper). Other agents get their own scope. const scopedSpawnCommand = linuxCgroupLease !== undefined || spawnCommand.shell ? spawnCommand @@ -2444,7 +2311,6 @@ export const make = ( const acknowledge = yield* Deferred.make(); yield* notificationSemaphore.withPermit( Effect.gen(function* () { - // Keep a provider's final flushed chunks together until the adapter settles the turn. if (Option.isNone(yield* Ref.get(activePromptRef))) { yield* Ref.set(assistantUpdatesOpenRef, false); yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); @@ -2797,8 +2663,6 @@ export const make = ( return {} satisfies EffectAcpSchema.SetSessionModeResponse; } const response = yield* setConfigOption(modeConfigOption?.id ?? "mode", modeId); - // The agent answers with its config options, so the mode it reports - // is the mode it runs in, even when it kept another one. const reported = parseSessionModeState({ configOptions: response.configOptions }); if (reported === undefined) { yield* updateCurrentModeId(modeId); @@ -2944,15 +2808,11 @@ const handleSessionUpdate = ({ if (!decision.emit) { continue; } - // A new tool call is a boundary in the prose. Progress on a call that - // is already shown, such as a background command finishing, is not. if (!shownToolCallIds.has(merged.toolCallId)) { shownToolCallIds.add(merged.toolCallId); - // Only recent calls get late updates; keep a long session bounded. if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) { shownToolCallIds.delete(shownToolCallIds.values().next().value!); } - // A call still running is already on screen, even if it aged out. if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); } yield* Queue.offer(queue, { diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 77ba680d9822..6bb8555bf99f 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -122,24 +122,17 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; /** - * Quiet window after a settled turn's last background rearm before it - * finalizes, so a slightly late post-hydration assistant chunk stays in the - * same turn. Grok commonly sends its final summary just over two seconds after - * the hydrated tool frame; two seconds split that tail into a second synthetic - * wake. Longer floors (4–20s) only prolonged Working. No per-model carveouts. + * Grok commonly sends its final summary just over two seconds after the + * hydrated tool frame; two seconds split that tail into a second synthetic + * wake. */ const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis"; export interface AcpAdapterV2RuntimeInput { readonly cwd: string; - /** - * Policy the session opened with. A runtime-mode change reopens the session, - * so flavors that encode permissions in the launch command (Grok) read it here. - */ readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly mcpServers: ReadonlyArray; readonly acpMcpServers?: ReadonlyArray; - /** Scoped credentials for terminal fallback when an ACP agent drops `mcpServers`. */ readonly processEnvironment?: NodeJS.ProcessEnv; readonly resumeSessionId?: string; readonly interruptPromptOnCancel?: boolean; @@ -171,30 +164,17 @@ export interface AcpAdapterV2ExtensionContext { readonly sessionId: string; readonly failure: OrchestrationV2ProviderFailure; }) => Effect.Effect; - /** - * A subagent's structured end on the root session (Grok `subagent_finished`), - * keyed by its child session id. Finishes the subagent row, in the turn that - * holds it or in the carryover of a settled one. - */ readonly finishSubagent: (notice: { readonly sessionId: string; readonly childSessionId: string; readonly status: "completed" | "failed" | "cancelled"; readonly result: string | null; }) => Effect.Effect; - /** - * Session-scoped background-task lifecycle reported via extension - * notifications (e.g. Grok `x.ai/task_backgrounded`; older builds use the - * underscore alias). Mutations for non-root sessions are ignored. A terminal - * mutation also finishes the tool that registered the task in a settled turn - * still held open for it, with `output` as its final text when given. - */ readonly applyBackgroundTaskMutation: (mutation: { readonly sessionId: string; readonly taskId: string; readonly status: "running" | "completed" | "failed"; readonly output?: string; - /** What the task is, so a wake it causes can name it. */ readonly report?: BackgroundWork; }) => Effect.Effect; readonly requestUserInput: ( @@ -207,14 +187,11 @@ export interface AcpAdapterV2ExtensionContext { }, EffectAcpErrors.AcpError >; - /** Surfaces plan markdown as this turn's proposed-plan card (#8358). */ readonly captureProposedPlan: (input: { readonly planMarkdown: string }) => Effect.Effect; - /** Last markdown captured for the active turn, as the exit-gate fallback. */ readonly lastProposedPlanMarkdown: Effect.Effect; } export interface AcpAdapterV2Flavor { - /** Interprets provider-specific prompt errors before they cross into orchestration. */ readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; readonly driver: ProviderDriverKind; readonly capabilities: OrchestrationV2ProviderCapabilities; @@ -245,28 +222,14 @@ export interface AcpAdapterV2Flavor { ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope >; readonly resolveModelId?: (selection: ModelSelection) => string | undefined; - /** - * Replaces the default model application on session setup. Returns the model - * the session now runs on. Antigravity resolves its provider-default alias - * against the account's catalog instead of sending it to the agent. - */ readonly applyModelSelection?: (input: { readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; readonly startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult; readonly modelSelection: ModelSelection; }) => Effect.Effect; - /** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */ readonly sessionModeForPolicy?: ( policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ) => string | undefined; - /** - * Opts the session into the ACP client `fs` capability. Agents read and write - * files themselves under their own permission model unless a flavor sets - * this. Requests pass the runtime policy guard, then these handlers, which - * receive the cwd of the policy active when the request arrives (null when - * the session has no workspace). Antigravity sets it and confines requests - * to that workspace. - */ readonly clientFileSystem?: { readonly readTextFile: ( request: EffectAcpSchema.ReadTextFileRequest, @@ -277,11 +240,6 @@ export interface AcpAdapterV2Flavor { cwd: string | null, ) => Effect.Effect; }; - /** - * Permission requests that are really questions (Antigravity `interaction_*` - * tool calls). Returns the question and a response builder; undefined routes - * the request through the normal approval card. - */ readonly extractPermissionQuestion?: (request: EffectAcpSchema.RequestPermissionRequest) => | { readonly question: OrchestrationV2UserInputQuestion; @@ -291,15 +249,13 @@ export interface AcpAdapterV2Flavor { } | undefined; /** - * Replaces T3's runtime-policy answer to a permission request. Grok's Auto - * mode only asks about what its own classifier refused, so those must reach - * the user instead of being approved by T3's policy. + * Grok's Auto mode only asks about what its own classifier refused, so those + * must reach the user instead of being approved by T3's policy. */ readonly permissionDisposition?: ( policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, request: EffectAcpSchema.RequestPermissionRequest, ) => AcpPermissionDisposition; - /** Approval choices to advertise on the approval card for a permission request. */ readonly approvalOptions?: ( request: EffectAcpSchema.RequestPermissionRequest, ) => ReadonlyArray; @@ -311,7 +267,6 @@ export interface AcpAdapterV2Flavor { readonly onSessionEvent?: ( event: AcpSessionRuntime.AcpSessionRuntimeEvent, ) => Effect.Effect; - /** Batch launches without child completion signals become idle when the root turn ends. */ readonly subagentsIdleOnTurnCompletion?: boolean; readonly supportsCompaction?: boolean; readonly runtimeHarness?: string; @@ -321,120 +276,49 @@ export interface AcpAdapterV2Flavor { readonly extractSubagentUpdate?: ( toolCall: AcpToolCallState, ) => AcpAdapterV2SubagentUpdate | undefined; - /** - * Optional Grok-style rewrite before tool projection (e.g. keep monitor start - * ACKs in the running state until stream end). - */ readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState; - /** - * Optional plan-file sniffing (#8358): providers that write their proposed - * plan to a file mid-turn (Grok plan.md) return its markdown from a tool - * call so T3 can show the proposed-plan card while plan mode is active. - */ readonly extractProposedPlanMarkdown?: (toolCall: AcpToolCallState) => string | undefined; - /** - * Optional mapping from a long-lived background tool start ACK to a task id - * (e.g. monitor task uuid) so later synthetic text events can update it. - */ readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined; - /** - * Optional parse of root-session synthetic text (monitor-event lines, monitor - * ended reminders). Returns every task mutation in the chunk so coalesced - * progress / end notices are not dropped. - */ readonly extractBackgroundToolMutation?: (text: string) => ReadonlyArray<{ readonly taskId: string; readonly status: "running" | "completed" | "failed"; readonly appendOutput: string; }>; - /** - * Optional parse of root-session synthetic text announcing a background - * subagent's end ("Background subagent "" ... completed successfully"). - * Older builds may never hydrate via get_command_or_subagent_output, so this - * remains a terminal fallback. Current Grok additionally emits structured - * `subagent_finished` session notifications. - */ readonly extractSubagentEndNotice?: (text: string) => | { readonly childSessionId: string; readonly status: "completed" | "failed"; } | undefined; - /** - * Optional hydration when a later tool (e.g. get_command TaskOutput) completes - * previously registered background task id(s). - */ readonly extractBackgroundTaskCompletion?: (toolCall: AcpToolCallState) => ReadonlyArray<{ readonly taskId: string; readonly status: "running" | "completed" | "failed"; readonly appendOutput: string; }>; - /** - * Persistent monitors (e.g. Grok `persistent: true`) should not hold root-turn - * deferred finalize open forever. Still tracked for post-settle wake. - */ readonly isPersistentBackgroundTool?: (toolCall: AcpToolCallState) => boolean; - /** - * Whether a root-session frame belongs to a turn the agent started itself - * after background work ended (Grok `task-completed-*`), not to T3's prompt. - * Such frames never project into a root turn held open for that work; they - * take the post-settle wake path once the held turn finalizes. - */ readonly isProviderWakeNotification?: ( notification: EffectAcpSchema.SessionNotification, ) => boolean; - /** - * When true, keep the active turn open after session/prompt returns while - * background tools/subagents are still running so later monitor/wake traffic - * can project (Grok monitors finish after the root prompt settles). - */ readonly deferFinalizeForBackgroundWork?: boolean; readonly assertComplete?: Effect.Effect; /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ readonly interruptPromptOnCancel?: boolean; /** - * Kill and respawn the ACP child process before the next `session/prompt` after a - * user interrupt. Grok can keep `task_already_running` state until the process exits. + * Grok can keep `task_already_running` state until the process exits. */ readonly restartRuntimeAfterInterrupt?: boolean; - /** - * When true, every interrupt restarts the runtime, not just those carrying - * `requestRuntimeRestart` (user Stop). Leave unset to keep non-Stop - * interrupts (steering, restart_active) soft: `session/cancel` plus session - * reuse in the same process. - */ readonly restartRuntimeOnEveryInterrupt?: boolean; readonly terminateRuntimeProcessGroupOnInterrupt?: boolean; - /** - * When true, an interrupt without `requestRuntimeRestart` (steering restart) - * on a turn whose native prompt already settled skips the hard process-group - * kill, the ACP cancel, and the runtime respawn entirely: the turn - * terminalizes locally while background subagents keep running in the same - * process and carry over into the replacement turn. Verified against the - * real Grok CLI (tmp/grok-acp-experiments E1): a new session/prompt is - * accepted concurrently while a fire-and-forget subagent is still running, - * with no task_already_running. User Stop (`requestRuntimeRestart: true`) - * keeps the hard teardown; mid-prompt non-Stop interrupts go soft - * (`session/cancel` plus same-session re-prompt) unless - * `restartRuntimeOnEveryInterrupt` is set. - */ readonly preserveRuntimeOnSettledInterrupt?: boolean; - /** - * When true (with continuationRequests), post-settle root session/update traffic - * buffers and requests a provider continuation run instead of being dropped or - * only appended to loaded history. - */ readonly enablePostSettleContinuation?: boolean; /** - * When true, send image attachment content blocks even if the ACP agent - * advertises `promptCapabilities.image: false`. Grok CLI currently accepts - * and vision-processes image blocks while still reporting the capability as - * false; without this override, screenshot turns fail before `session/prompt`. + * Grok CLI currently accepts and vision-processes image blocks while still + * reporting the capability as false; without this override, screenshot turns + * fail before `session/prompt`. */ readonly supportsImagePrompts?: boolean; } -/** Whether image attachment blocks may be included in session/prompt. */ export function acpSupportsImagePrompts(input: { readonly flavorSupportsImagePrompts?: boolean | undefined; readonly negotiatedImage?: boolean | undefined; @@ -442,12 +326,10 @@ export function acpSupportsImagePrompts(input: { return input.flavorSupportsImagePrompts === true || input.negotiatedImage === true; } -/** Keeps provider-derived ids distinct when two configured ACP instances reuse native ids. */ export function acpScopedNativeId(instanceId: ProviderInstanceId, nativeId: string): string { return `provider-instance:${encodeURIComponent(instanceId)}:${nativeId}`; } -/** Keeps pre-v2 persisted ids stable while scoping ids for newly created threads. */ export function acpProviderItemNativeId(input: { readonly instanceId: ProviderInstanceId; readonly itemIdentityVersion: 2 | undefined; @@ -473,45 +355,24 @@ export interface AcpAdapterV2SubagentUpdate { | "cancelled"; readonly childSessionId: string | null; readonly result: string | null; - /** - * When false, still project a normal tool turn item after the subagent update - * (hydration tools like get_command_or_subagent_output). Defaults to true. - */ readonly suppressNormalTool?: boolean; } export interface AcpAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly flavor: AcpAdapterV2Flavor; - /** How agents spawn this install's `acp-mcp-bridge`; see `resolveSelfInvocation`. */ readonly selfInvocation: SelfInvocation; - /** - * Opts the session into the ACP client `terminal` capability. Agents run - * commands themselves unless an adapter sets this; with it, sessions - * advertise `terminal: true` and run agent-created terminals through the - * adapter's spawner with the provider instance's environment. Devin sets it. - */ readonly clientTerminals?: { readonly environment?: NodeJS.ProcessEnv; readonly shellCommands?: boolean; }; readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging; - /** - * Shared with ProviderContinuationService so post-settle wake traffic can start - * a continuation run. Optional: adapters that omit it keep pre-continuation drop - * / history-only behavior for null-activeTurn updates. - */ readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, ) => Effect.Effect; }; readonly testHooks?: { - /** - * A settled turn with no background work left armed its finish debounce - * ({@link ACP_DEFERRED_FINALIZE_DEBOUNCE}); replay advances its test clock - * by exactly that on this receipt. - */ readonly onDeferredFinalizeScheduled?: (debounce: Duration.Input) => Effect.Effect; readonly afterPromptSettledWithBackgroundWork?: () => Effect.Effect; readonly afterNativeResponseTransportClosed?: () => Effect.Effect; @@ -630,8 +491,6 @@ export const AcpProviderCapabilitiesV2 = { nativeRequestIds: "weak", }, runtimePolicy: { - // ACP agents run their own tools; T3 only answers their permission - // requests by policy. enforcement: "client-boundary", }, } satisfies OrchestrationV2ProviderCapabilities; @@ -661,8 +520,6 @@ function negotiatedCapabilities( }, tools: { ...base.tools, - // The stdio bridge (`t3 acp-mcp-bridge`) makes the t3-code MCP toolkit - // available regardless of the agent's optional http/sse MCP support. supportsMcpTools: true, }, checkpointing: { @@ -687,12 +544,10 @@ function acpMcpContext( if (session === undefined) { return { servers: [], acpServers: [] }; } - // Stdio is ACP's required baseline MCP transport. Agents that advertise - // optional http support still routinely fail to wire injected http servers - // through to their backend (codex-acp 1.2.0 and pi-acp both drop them), so - // every ACP session gets the `t3 acp-mcp-bridge` stdio server, which - // forwards JSON-RPC to T3's authenticated MCP endpoint. The credential - // travels via environment variables, never the command line. + // Agents that advertise optional http support still routinely fail to wire + // injected http servers through to their backend (codex-acp 1.2.0 and pi-acp + // both drop them), so every ACP session gets the `t3 acp-mcp-bridge` stdio + // server, which forwards JSON-RPC to T3's authenticated MCP endpoint. return { servers: [ { @@ -782,11 +637,6 @@ function makeProviderThread(input: { }; } -/** - * Unwrap a codex-acp style MCP call result (`{ result, error }` around MCP - * `content`/`structuredContent`) the same way the native Codex adapter does, - * so recovered MCP items render identical output. Unknown shapes pass through. - */ function acpMcpToolCallOutput(rawOutput: unknown): unknown { const record = unknownRecord(rawOutput); if (record === undefined) return rawOutput; @@ -813,7 +663,6 @@ function decodeByteText(value: unknown): string | undefined { return undefined; } try { - // Preserve leading/trailing whitespace like the string path in textFromUnknown. const text = new TextDecoder().decode(Uint8Array.from(value as number[])); return text.length > 0 ? text : undefined; } catch { @@ -840,8 +689,6 @@ function textFromUnknown(value: unknown): string | undefined { if (record === undefined) { return undefined; } - // Prefer prompt-facing Grok fields before nested envelopes. - // Antigravity reports shell output as combinedOutput. for (const key of [ "output_for_prompt", "stdout", @@ -885,7 +732,6 @@ function commandExitCode(value: unknown): number | undefined { } /** - * Project an exit code only when the tool has a terminal native status. * Mid-stream Grok Bash re-reports carry exit_code 0 while still in progress; * interrupted tools must not retain that stale success code. */ @@ -931,12 +777,6 @@ function structuredFileChanges(toolCall: AcpToolCallState) { // file has one empty side and needs no search, so it is never capped. const ACP_V1_DIFF_MAX_EDITS = 1_000; -/** - * Patch text for a tool call's diff content. ACP v2 diffs carry it as - * `patch.text`. ACP v1 diffs carry `oldText`/`newText` instead (`oldText` - * null or absent for a new file), and agents that negotiate v1 still send - * that shape, so the patch is built from the two sides. - */ export function acpToolCallDiffPatch(content: unknown): string | undefined { if (!Array.isArray(content)) return undefined; const diffs = content.flatMap((entry) => { @@ -987,12 +827,6 @@ function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { return undefined; } -/** - * Grok runs X and web searches server-side as `search` tools whose rawInput is - * only `{ variant: "XSearch" | "WebSearch", backend: true }`. The query arrives - * with completion: web searches report `action: { query, sources }`, X searches - * the backend call `{ name, input }` with JSON-encoded arguments. - */ function acpBackendWebSearch( rawInput: Record | undefined, rawOutput: Record | undefined, @@ -1138,8 +972,6 @@ interface ActiveAcpTurn { readonly nativeTurnId: string; readonly startedAt: DateTime.Utc; readonly completed: Deferred.Deferred; - // Root item ordinals allocated in this turn. Subagent child items keep - // theirs on the subagent, which carries over into later turns. readonly itemOrdinals: Map; readonly user: ActiveTextStream; readonly assistant: ActiveTextStream; @@ -1155,31 +987,18 @@ interface ActiveAcpTurn { contextUsage: ThreadTokenUsageSnapshot | null; nativeMetadata: OrchestrationV2ProviderThreadNativeMetadata | null; readonly tools: Map; - /** Streamed tool updates skipped since the last persisted one; see `shouldPersistToolUpdate`. */ readonly toolUpdatesSkipped: Map; readonly toolStartedAt: Map; readonly subagents: Map; readonly subagentsBySessionId: Map; readonly pendingSubagentNotifications: Map>; - /** Background monitor/task id β†’ toolCallId for synthetic root text updates. */ readonly toolCallIdsByBackgroundTaskId: Map; - /** - * Persistent monitors registered this turn. Excluded from deferred-finalize - * holds so the root turn can settle while they keep streaming post-settle. - */ readonly persistentBackgroundTaskIds: Set; /** * Monitor end events often only say "use get_command…"; keep the turn open * until TaskOutput hydration arrives (or the safety timeout elapses). */ readonly awaitingBackgroundHydration: Set; - /** - * A monitor end notice landed after the prompt settled: the CLI runs an - * injected turn whose report never gets a turn_completed marker, so the - * report chunk races the deferred-finalize debounce (thread a8e8b0a9 run 5 - * dropped the listing this way). Hold finalize until the report streams or - * the safety timeout elapses. - */ readonly pendingInjectedReport: Set; /** * An injected assistant report can race ahead of its monitor end notice. @@ -1198,14 +1017,8 @@ interface ActiveAcpTurn { interrupted: boolean; finalized: boolean; finalizedStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; - /** session/prompt already returned; finalize deferred for background work. */ promptSettled: boolean; promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; - /** - * Completed the moment `runtime.prompt` resolves on the wire, before the - * completion callback requests `runtimeCallbackPermit`. Failure does not - * complete this; settled-soft classification ORs it with `promptSettled`. - */ readonly promptWireSettled: Deferred.Deferred; backgroundFinalizeGeneration: number; } @@ -1218,7 +1031,6 @@ type AcpRuntimeTeardownState = } | { readonly _tag: "Failed"; readonly error: ProviderAdapter.ProviderAdapterProtocolError }; -/** True when a root session/update carries ingestible turn output, not keepalive noise. */ function acpRootSessionUpdateIngestsOutput( notification: EffectAcpSchema.SessionNotification, ): boolean { @@ -1247,12 +1059,6 @@ function acpRootSessionUpdateIngestsOutput( } } -/** - * Post-settle traffic that should be *buffered* for a continuation attach. - * Excludes monitor end/event chatter that must not become ghost history. - * Broader than {@link acpPostSettleContinuationOfferEvidence}: incremental - * tool progress may still need replay once a real completion offers a run. - */ export function acpPostSettleWakeEvidence( notification: EffectAcpSchema.SessionNotification, flavor: Pick = {}, @@ -1275,11 +1081,9 @@ export function acpPostSettleWakeEvidence( } /** - * Whether buffered post-settle traffic should *offer* a continuation run now. * Still-running tool streams often land farther apart than the deferred-finalize * quiet window; treating every tool_call_update as offer evidence re-opens a - * synthetic "Background task completed." run on each chunk. Only completion-like - * frames (real agent text, or a terminal tool status) should open a new run. + * synthetic "Background task completed." run on each chunk. */ export function acpPostSettleContinuationOfferEvidence( notification: EffectAcpSchema.SessionNotification, @@ -1289,8 +1093,6 @@ export function acpPostSettleContinuationOfferEvidence( return false; } const update = notification.update; - // Assistant text only. Thought/reasoning bursts alone must not open synthetic - // "Background task completed." runs (duplicate-run spam after monitors). if (update.sessionUpdate === "agent_message_chunk") { return (acpContentBlockDisplayText(update.content)?.trim().length ?? 0) > 0; } @@ -1321,8 +1123,7 @@ export function acpPostSettleWakeShouldBuffer( * An app-owned wake (a delegated child finishing) is injected by the * orchestrator, not typed by the user. It reports on a sibling child and says * nothing about this session's own pending wake frames, so it must not discard - * them the way a real user turn does. ClaudeAdapterV2 already leaves its buffer - * alone on non-continuation turns; this keeps ACP consistent. + * them the way a real user turn does. */ export function acpIsAppOwnedWakeTurn(message: { readonly createdBy: string; @@ -1331,7 +1132,6 @@ export function acpIsAppOwnedWakeTurn(message: { return message.createdBy === "agent" && message.creationSource === "server"; } -/** A continuation run attaches to wake traffic the agent produced on its own. */ function acpIsProviderContinuationMessage(message: { readonly createdBy: string; readonly creationSource: string; @@ -1381,7 +1181,6 @@ interface ActiveAcpSubagent { readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; readonly turnItemId: OrchestrationV2TurnItem["id"]; readonly turnItemOrdinal: number; - /** Turn that spawned the subagent; carryover updates keep this lineage. */ readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; readonly parentProviderThreadId: ProviderThreadId; childSessionId: string | null; @@ -1389,14 +1188,6 @@ interface ActiveAcpSubagent { readonly assistantMessages: Map; readonly childItemOrdinals: Map; nextChildOrdinal: number; - /** - * Whether a terminal carryover status has been projected to events. - * Completed roots project post-settle terminals immediately while their - * subscriber remains observable. Non-completed roots retain terminals in - * memory until the next attach. A later project:true path for the same entry - * must still project once; this flag prevents double emission and pins - * hasPendingBackgroundWork until projection lands. - */ terminalStatusProjected: boolean; } @@ -1459,9 +1250,7 @@ const TOOL_UPDATE_PERSIST_EVERY = 10; /** * Some agents stream a tool's arguments (a file write's diff, `rawInput`) and - * resend the whole call each time. Persist every 10th of those. Status, title, - * and output the user watches live always persist, as does the agent's own - * completed/failed when a flavor normalizes it to a non-terminal status. + * resend the whole call each time. */ function shouldPersistToolUpdate( context: ActiveAcpTurn, @@ -1511,9 +1300,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( openSession: Effect.fn("AcpAdapterV2.openSession")( function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { const sessionScope = yield* Effect.scope; - // Persisted ACP threads from before item identity v2 retain their old - // deterministic ids. Fresh threads scope native ids by instance so - // separately configured agents cannot collide. let itemIdentityVersion: 2 | undefined = input.initialProviderItemIdentityVersion ?? (input.initialNativeThreadId === undefined ? 2 : undefined); @@ -1616,8 +1402,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (clientTerminals !== undefined) { yield* Scope.addFinalizer(sessionScope, clientTerminals.disposeAll); } - // Terminal ids embedded in raw tool_call updates, remembered before the - // content rewrite so emitTool can recover MCP-fallback command lines. const sessionScopedId = (sessionId: string, nativeId: string): string => JSON.stringify([sessionId, nativeId]); const embeddedTerminalsByToolCallId = new Map< @@ -1668,8 +1452,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( embeddedTerminalsByToolCallId.delete(oldest); } }; - // Command lines of the terminals embedded in a tool call, so MCP calls - // made through the acp-mcp-call terminal fallback keep their identity. const embeddedTerminalCommands = ( sessionId: string, toolCallId: string, @@ -1701,8 +1483,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const providerThreadByNativeSessionId = yield* Ref.make( new Map(), ); - // T3 only owns the temporary Plan override. Remember the agent's - // effective native configuration on entry and restore it on Build. const nativeBuildConfigurationBySessionId = new Map(); const initialSessionActivationFailure = yield* Ref.make<{ readonly sessionId: string; @@ -1959,18 +1739,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return acknowledgements.size > 0; }), ); - // Post-settle wake support (Grok async subagent/monitor follow-up). After - // the root turn finalizes, later root session/update traffic buffers here - // until a provider continuation run attaches and drains it. const lastTurnRoute = yield* Ref.make<{ readonly threadId: ThreadId; readonly providerThreadId: ProviderThreadId; } | null>(null); const wakeBuffer = yield* Ref.make>([]); - // Background work that ended after the prompt settled, keyed by task or - // child session id. The next continuation offer names it for the user; - // `offered` holds the keys that offer named. Work that ends while the - // offer waits for its turn is named by the offer after it. const noWakeReports = { reports: new Map(), offered: new Set(), @@ -1984,7 +1757,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( report: BackgroundWorkReport, ) { const context = yield* Ref.get(activeTurn); - // An open prompt reports the work itself; no continuation follows. if (context !== null && !context.promptSettled && !context.finalized) return; yield* Ref.update(wakeReports, ({ reports, offered }) => ({ reports: new Map(reports).set(key, report), @@ -2007,33 +1779,20 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make>( new Set(), ); - // Background tasks that reached a genuine terminal mutation while a root - // turn was still streaming and were not yet marked handled in-turn. The - // mid-turn offer is suppressed (active turn owns the work); on finalize - // these ids re-arm a single continuation when the agent never hydrated - // or reported them before settle (regression: mid-turn complete + no - // get_command must still wake after finalize). const midTurnUnreportedCompletedTaskIds = yield* Ref.make>(new Set()); // A monitor-event can arrive after its task and the user-facing provider // turn already completed. Grok starts another internal prompt for that // stale notification; suppress its agent output until a genuine terminal // mutation or the next app turn so it cannot create a redundant app - // continuation. Tool frames continue through normal hydration. + // continuation. const suppressPostSettleMonitorPrompt = yield* Ref.make(false); - // Background tasks (Grok monitors) known to still run at session level. - // Turn contexts are too short-lived to carry this: a continuation run - // finalizes between monitor events, and the next commentary burst must - // not reopen a run while the monitor is still streaming. const runningBackgroundTaskIds = yield* Ref.make>(new Set()); - // Task ids with a GENUINE end signal (monitor-ended reminder or - // TaskOutput completion). Normalized tool statuses are not genuine: - // Grok Bash re-reports carry exit_code 0 mid-stream. A straggler - // monitor-event can land after the real end (the CLI keeps streaming - // while the agent already consumed the output via - // get_command_or_subagent_output); without the tombstone it would - // resurrect the running set and pin offers/idle-release forever. - // A tool-level failed get_command tombstones too: failing open to a - // single continuation beats failing closed to a dead thread. + // Normalized tool statuses are not genuine: Grok Bash re-reports carry + // exit_code 0 mid-stream. A straggler monitor-event can land after the + // real end (the CLI keeps streaming while the agent already consumed + // the output via get_command_or_subagent_output); without the tombstone + // it would resurrect the running set and pin offers/idle-release + // forever. const endedBackgroundTaskIds = yield* Ref.make>(new Set()); const endedBackgroundTaskIdLimit = 128; @@ -2279,11 +2038,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( readonly markdown: string; readonly planId: OrchestrationV2PlanArtifact["id"]; } | null>(null); - /** - * Emits a completed proposed-plan artifact for the active turn. Same - * plan id per turn so repeated captures (plan.md rewrites, the exit - * gate) update one card; identical markdown within a turn is a no-op. - */ const captureProposedPlan = Effect.fnUntraced(function* (input: { readonly planMarkdown: string; }) { @@ -2717,10 +2471,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) { return; } - // get_command_or_subagent_output may target monitors/bash tasks. Only - // hydrate when we already have a matching subagent lineage. Spawn ACKs - // (non-empty prompt) may create a new lineage; empty-prompt hydration - // with suppressNormalTool must not invent a phantom subagent. + // Only hydrate when we already have a matching subagent lineage. Spawn + // ACKs (non-empty prompt) may create a new lineage; empty-prompt + // hydration with suppressNormalTool must not invent a phantom + // subagent. if (existing === undefined) { const isHydrationOnly = update.prompt === "" && update.title === null; if (isHydrationOnly || update.suppressNormalTool === false) { @@ -3008,7 +2762,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (context.awaitingBackgroundHydration.size > 0) return true; if (context.pendingInjectedReport.size > 0) return true; for (const [taskId, toolCallId] of context.toolCallIdsByBackgroundTaskId) { - // Persistent monitors stream after root settle; do not pin finalize. if (context.persistentBackgroundTaskIds.has(taskId)) continue; const tool = context.tools.get(toolCallId); if (tool === undefined) continue; @@ -3023,8 +2776,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return false; }; - // scheduleDeferredFinalize is declared above openSession body start and - // assigned after finalizeTurn so forked finalize Effect typing stays clean. const rearmDeferredFinalize = (context: ActiveAcpTurn) => Effect.gen(function* () { if (!flavor.deferFinalizeForBackgroundWork) return; @@ -3044,13 +2795,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.gen(function* () { if (context.awaitingBackgroundHydration.has(taskId)) return; context.awaitingBackgroundHydration.add(taskId); - // Safety: do not hold the root turn forever if the agent never hydrates. yield* Effect.gen(function* () { yield* Effect.sleep("60000 millis"); if (context.finalized || !context.awaitingBackgroundHydration.has(taskId)) return; context.awaitingBackgroundHydration.delete(taskId); - // End notices keep the tool running until hydration; force-complete so - // deferred finalize can proceed when get_command never arrives. const toolCallId = context.toolCallIdsByBackgroundTaskId.get(taskId); const tool = toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; if (tool !== undefined) { @@ -3065,12 +2813,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) => Effect.gen(function* () { - // Only the settled-and-held window has the race; mid-turn reports - // are protected by the prompt RPC still being open. if (!context.promptSettled) return; if (context.pendingInjectedReport.has(taskId)) return; context.pendingInjectedReport.add(taskId); - // Safety: the injected turn may end without a report chunk. yield* Effect.gen(function* () { yield* Effect.sleep("25000 millis"); if (context.finalized || !context.pendingInjectedReport.has(taskId)) return; @@ -3095,8 +2840,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( incoming: AcpToolCallState, projectedStatus?: ProjectedToolStatus, ) { - // An identified message can stream concurrently with tool updates. - // Keep its identity until the provider starts another message. if (context.assistant.current?.sourceMessageId == null) yield* closeTextStream(context, "assistant"); if (context.reasoning.current?.sourceMessageId == null) @@ -3117,26 +2860,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( backgroundTaskId, backgroundStatus === "pending" || backgroundStatus === "running", ); - // Background tool that reaches a terminal status while the root - // prompt is still open (completed, failed, or interrupted) was - // consumed in-turn. Mark handled so late CLI re-reports and residual - // agent chatter do not open synthetic "Background task completed." - // runs. get_command TaskOutput still marks handled below. - // Only before promptSettled: after STARTED the deferred-finalize - // hold can let a monitor finish while the turn is still active; - // marking then would suppress the legitimate post-settle TaskOutput - // continuation (live: grok-post-settle-continuation-poll). - // Terminal statuses only after normalizeToolCall (start ACKs stay - // inProgress/running). - // - // Tradeoff: a pre-settle false "completed" normalization (Bash- - // shaped re-report with exit_code 0, or the hydration safety - // force-complete firing pre-settle) would permanently mark the task - // handled and suppress its injected report chatter. The deleted - // handled-id removal on late monitor-event mutations used to rescue - // that case; likelihood is low because mid-turn monitor ends - // normally arrive as reminder mutations that force inProgress, and - // re-reports are documented post-settle traffic. if ( !context.promptSettled && backgroundStatus !== "pending" && @@ -3154,16 +2877,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } - // get_command TaskOutput for registered monitor(s): hydrate those tools. const backgroundCompletions = projectedStatus === undefined ? (flavor.extractBackgroundTaskCompletion?.(toolCall) ?? []) : []; let hydratedRegisteredMonitor = false; for (const backgroundCompletion of backgroundCompletions) { - // Genuine end signal when terminal: tombstone so straggler - // monitor-event chatter cannot resurrect the running set after the - // task truly ended. A still-running poll keeps the id running. yield* applyBackgroundTaskMutationRunning(backgroundCompletion); if (backgroundCompletion.status !== "running") { yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => @@ -3176,16 +2895,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return next; }); } - // A still-running fetch must keep the hydration hold (and its - // safety timer) alive until output actually lands. if (backgroundCompletion.status !== "running") { context.awaitingBackgroundHydration.delete(backgroundCompletion.taskId); } const targetToolCallId = context.toolCallIdsByBackgroundTaskId.get( backgroundCompletion.taskId, ); - // Known background-task id (monitor registration) β€” never open a - // phantom subagent for the same get_output poll. if (targetToolCallId !== undefined) { hydratedRegisteredMonitor = true; } @@ -3200,9 +2915,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( : ("completed" as const); const hydrated = backgroundCompletion.appendOutput.length > 0 - ? // TaskOutput is the real stdout; replace end-notice boilerplate - // so the timeline shows the listing, not only "Monitor ended…". - isMonitorEndNoticeText(toolOutputText(target)) || + ? isMonitorEndNoticeText(toolOutputText(target)) || toolOutputText(target).trim().length === 0 ? setToolOutputText( { ...target, status: nextStatus }, @@ -3320,14 +3033,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( typeof rawOutputRecord?.type === "string" && rawOutputRecord.type.trim().toLowerCase() === "bash" && commandExitCode(rawOutput) !== undefined; - // Grok Monitor tools arrive as generic kind + variant; project like shell - // so stdout is plain text in the timeline (not JSON {type:Text,text:...}). - // Post-settle wake re-reports of a finished monitor carry no rawInput at - // all, only a structured Bash result; project those as commands too. const projectAsCommandExecution = inputVariant === "monitor" || outputIsBashResult; - // ACP has no typed MCP item, so recover MCP identity from the - // agent-specific shape and project the same branded dynamic_tool - // item native providers produce (e.g. the T3 orchestration tools). const mcpIdentity = extractMcpToolCallIdentity(toolCall, { embeddedTerminalCommands: embeddedTerminalCommands( context.nativeThreadId, @@ -3404,7 +3110,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( case "search": { const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord); if (backendSearch !== undefined) { - // Grok titles these "X search:" / "Web search:" awaiting the query. const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace( /:\s*$/u, "", @@ -3772,15 +3477,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return Option.none(); } if ((yield* Ref.get(continuationGeneration)) !== generation) { - // Superseded by a newer offer; do not clear its flag. return Option.none(); } if (!(yield* Ref.get(continuationRequested))) return Option.none(); // A successful durable dispatch owns the sticky flag until its // continuation turn starts. Clearing here opens a dispatch-to- // start race where every late ACP frame can enqueue another - // synthetic continuation. Failures clear immediately because - // no turn will arrive to do so. + // synthetic continuation. const exit = yield* Effect.exit(effect); if (Exit.isFailure(exit)) { yield* clearIfOwner; @@ -3803,8 +3506,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const taskAlreadyEnded = (yield* Ref.get(endedBackgroundTaskIds)).has(mutation.taskId); yield* applyBackgroundTaskMutationRunning(mutation); if (mutation.status === "running") { - // Straggler running notice after a genuine end: suppress residual - // monitor-prompt agent chatter so it cannot open a synthetic wake. if (taskAlreadyEnded && acpPostSettleMonitorPromptShouldSuppress(mutation)) { yield* Ref.set(suppressPostSettleMonitorPrompt, true); } @@ -3816,29 +3517,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (!taskAlreadyEnded) { yield* Ref.set(suppressPostSettleMonitorPrompt, false); } - // Keep handledBackgroundTaskIdsInActiveTurn intact. Erasing the mark - // on a late monitor-event mutation defeated the in-turn chatter guard - // and let injected-turn acks arm wakeBuffer for a later spurious - // "Background task completed." run (multiturn live repro). Monitors - // the agent settled without reporting never enter that set, so their - // injected report still streams via pendingInjectedReport / hold. const activeContext = yield* Ref.get(activeTurn); - // Completions that land while a root turn is still streaming are - // owned by in-turn machinery (emitTool handled marks, deferred - // finalize, injected-report hold). Do not open a synthetic wake mid- - // turn; finalizeTurn re-checks wakeBuffer / midTurnUnreported once - // the turn leaves the active slot so legitimate unhandled evidence - // still offers after finalize. if (activeContext !== null && !activeContext.finalized) { const handled = yield* Ref.get(handledBackgroundTaskIdsInActiveTurn); - // Only arm deferred wake when this turn registered the task (monitor - // tool started in-turn) and the root prompt is still open. Completions - // that land in the settled-held window (promptSettled, deferred - // finalize holding for injected report) fall back to the pre-existing - // post-finalize injected-turn path; arming here would spuriously - // offer "Background task completed." after the report streams into - // the held turn. Residual cancel-backgrounded completions from a - // prior interrupt must not open a synthetic continuation. if ( !activeContext.promptSettled && !handled.has(mutation.taskId) && @@ -3863,10 +3544,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( // A structured task end (Grok `task_completed`) is authoritative for the // tool that registered the task: its own updates only ever say running. - // Finish the row while deferred finalize holds a settled root turn open - // for it, so the turn can settle. While the prompt is still open the - // agent reports the end itself (TaskOutput hydration), and an unreported - // end must keep its post-finalize continuation offer. const finishRegisteredBackgroundTool = Effect.fnUntraced(function* (mutation: { readonly taskId: string; readonly status: "completed" | "failed"; @@ -3899,8 +3576,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( stopProcessing: false, }; } - // Direct Stop quarantine: drop residual wake evidence instead of - // buffering it for a later continuation or follow-up run. if (yield* Ref.get(stoppedRunQuarantine)) { return { buffered: false, @@ -3930,11 +3605,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( // When the agent consumes the monitor output itself via // get_command_or_subagent_output (long timeout), the TaskOutput // completion is the ONLY end signal: no "Monitor ended" reminder - // follows. Without applying it here the running set never clears, - // every offer stays suppressed, and the wake buffer never drains - // (observed live 2026-07-12, thread 8dbe607f). The completion - // frame itself is offer evidence, so clearing the id before the - // gate below lets it open the single continuation run. + // follows. for (const event of parseSessionUpdateEvent(notification).events) { if (event._tag !== "ToolCallUpdated") continue; const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; @@ -3960,33 +3631,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } const backgroundWorkRunning = (yield* Ref.get(runningBackgroundTaskIds)).size > 0; - // Residual Grok agent/thought chatter after in-turn-handled background - // work must not be retained as wake evidence. Tool-path alreadyHandled - // covers re-reports with a task id; this covers agent_message_chunk / - // agent_thought_chunk frames that carry no task id (live: - // grok-in-turn-monitor-no-wake, multiturn stale-buffer arm). Check - // before buffering so the frames cannot dirty wakeBuffer and later - // arm a mid-turn offer when a second monitor completes. const handledInTurnCount = (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size; const isInTurnHandledAgentChatter = handledInTurnCount > 0 && (update.sessionUpdate === "agent_message_chunk" || update.sessionUpdate === "agent_thought_chunk"); // Grok prompts itself for every monitor event after the root turn - // settles. Its assistant/reasoning replies are progress chatter, not - // separate wake results. Retaining them would replay the entire burst - // into the single continuation once the monitor finishes. Keep tool - // state so the final command card still hydrates, then begin retaining - // agent output again after the genuine end signal clears the running - // set. With multiple tasks, text remains best-effort until every task - // ends; their retained tool cards are the authoritative results. - // - // Skip retaining frames for tasks already hydrated in the root turn - // (`handledBackgroundTaskIdsInActiveTurn`). Those re-reports must not - // pin `hasPendingBackgroundWork` via a wake buffer that never drains - // (the already-handled gate below intentionally skips - // `offerContinuationRun` to avoid synthetic "Background task - // completed." spam). + // settles. Retaining them would replay the entire burst into the + // single continuation once the monitor finishes. let buffered = false; if ( !alreadyHandledToolUpdate && @@ -3996,7 +3648,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* Ref.update(wakeBuffer, (current) => [...current, notification]); buffered = true; } - // Buffer progress without offering; only completion-like frames open a run. if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) { return { buffered, @@ -4004,15 +3655,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( stopProcessing: true, }; } - // While a monitor is still streaming, tool re-reports buffer without - // offering and per-event agent commentary is consumed without being - // retained. Grok re-reports a running monitor as Bash frames that - // already carry exit_code 0 mid-stream, so a "terminal" normalized - // status is not evidence the task ended; each burst would otherwise - // reopen a synthetic "Background task completed." run. Retained tool - // frames drain into the single continuation offered once the monitor - // actually ends (end-notice mutation below, or the first frame after - // it). if (backgroundWorkRunning) { return { buffered, @@ -4021,10 +3663,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }; } if (alreadyHandledToolUpdate) { - // Drop leftover wake noise for in-turn-handled work so idle release - // is not pinned forever. Leave the buffer alone when a continuation - // is already outstanding: its startTurn will drain legitimate frames - // from other tasks. if (!(yield* Ref.get(continuationRequested))) { yield* Ref.set(wakeBuffer, []); } @@ -4034,7 +3672,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( stopProcessing: true, }; } - // Same in-turn-handled agent chatter: do not open a synthetic wake. // Do not clear wakeBuffer here: frames for other still-tracked tasks // must remain drainable when a real (tool) completion later offers. if (isInTurnHandledAgentChatter) { @@ -4179,10 +3816,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) { return; } - // Only while a finalized turn is still the active context. When - // activeTurn is null, post-settle agent frames must reach - // bufferPostSettleWake so continuation can attach (context?.finalized - // !== false incorrectly treated null as finalized and dropped them). if ( context !== null && context.finalized && @@ -4213,14 +3846,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return; } if (context === null) { - // Direct Stop: quarantine residual events from the stopped run so - // they cannot become history, wake buffers, or a later run attach. if (yield* Ref.get(stoppedRunQuarantine)) { return; } const bufferOutcome = yield* bufferPostSettleWake(notification); - // Post-settle carryover sync: keep in-memory carryover accurate so - // hasPendingBackgroundWork reasons correctly after root settle. // A completed root keeps its subscriber open while background items // remain, so project its terminals immediately even when the wake // frame is buffered for a continuation. Non-completed roots stay @@ -4288,19 +3917,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( carryoverTerminalized = true; } } - // Synchronize carryover before an eager continuation can attach and - // drain the frame. const continuationOffered = bufferOutcome.offerContinuation ? yield* offerContinuationRun(notification.sessionId) : false; const wakeOutcome = { ...bufferOutcome, continuationOffered }; - // Frames that will not buffer never reach the continuation drain for - // this traffic. Once carryover is terminalized, skip history append / - // residual handling (and avoid double-projecting on child re-entry). if (carryoverTerminalized && !wakeOutcome.buffered) { - // Projected above. Never clear a non-empty wakeBuffer. A sticky - // continuationRequested with an empty buffer is safe to drop so - // idle release is not wed on a pin with nothing to deliver. if ( acpCarryoverTerminalShouldClearContinuation({ continuationOffered: wakeOutcome.continuationOffered, @@ -4311,8 +3932,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } return; } - // Prefer continuation buffering over history append so the same - // frames are not double-counted once a continuation run attaches. if (wakeOutcome.stopProcessing) { return; } @@ -4321,8 +3940,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( update.sessionUpdate === "agent_message_chunk" || update.sessionUpdate === "agent_thought_chunk" ) { - // Late monitor end/event reminders must not become ghost user/assistant - // history (or OS-facing chatter) after the root turn already finalized. const text = acpContentBlockDisplayText(update.content); if (text === undefined) return; const lateBackgroundMutations = @@ -4390,7 +4007,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (context.finalized) return; const rootSessionId = yield* Ref.get(activeSessionId); if (notification.sessionId !== rootSessionId) { - // Finalize may have completed during the activeSessionId yield. if (context.finalized) return; if (flavor.extractSubagentUpdate === undefined) return; const subagent = context.subagentsBySessionId.get(notification.sessionId); @@ -4420,9 +4036,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( context.tools.set(key, merged); if (!shouldPersistToolUpdate(context, key, previous, merged, merged.status)) continue; - // Terminals are remembered under the raw session id: the child's - // own session, or the root one when the flavor routes child - // updates out of it (Devin). const mcpIdentity = extractMcpToolCallIdentity(merged, { embeddedTerminalCommands: [ ...embeddedTerminalCommands(notification.sessionId, toolCall.toolCallId), @@ -4497,10 +4110,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( // Re-check after the activeSessionId yield: idle/prompt settle can // finalize the same context object while we waited. if (context.finalized) return; - // The agent started its own turn about finished background work while - // this settled root turn was still held for it. That turn is the - // agent's reply, not more of the root: settle the root first so the - // frame reaches the wake path and opens a continuation run. if ( context.promptSettled && !acpIsProviderContinuationMessage(context.input.message) && @@ -4517,8 +4126,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } return; } - // Only fresh model output proves a retry recovered; progress on - // tools and plans that started earlier can arrive mid-retry. if ( update.sessionUpdate !== "tool_call_update" && update.sessionUpdate !== "plan" && @@ -4553,10 +4160,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const text = acpContentBlockDisplayText(update.content); if (text !== undefined) { const startsNewAssistantSegment = context.assistant.current === null; - // The injected-turn report is streaming; the normal debounce - // after the last chunk takes over from here. Drop matching - // mid-turn armed ids so finalize does not open a duplicate - // wake after the report already projected into this turn. if (context.pendingInjectedReport.size > 0) { const reportedTaskIds = [...context.pendingInjectedReport]; context.pendingInjectedReport.clear(); @@ -4662,9 +4265,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( : mutation.status === "failed" ? ("failed" as const) : ("completed" as const); - // End notices typically omit full stdout (no get_command mention - // either). Keep the tool running and hold finalize until - // TaskOutput hydrates, or the safety timer force-completes. if (nextStatus !== "inProgress") { yield* markAwaitingBackgroundHydration(context, mutation.taskId); nextStatus = "inProgress"; @@ -4677,10 +4277,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ), ); } - // After emitTool: the hydration hold keeps the tool row at - // inProgress past an end notice, but the offer gate must see - // the mutation's semantic status. Also tracks monitors that - // never surfaced a tool_call row at all. yield* applyBackgroundTaskMutationRunning(mutation); if (mutation.status !== "running" && !reportArrivedBeforeNotice) { yield* markPendingInjectedReport(context, mutation.taskId); @@ -4713,9 +4309,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); } } - // startTurn already projects the submitted prompt. Active ACP - // user chunks are transport echoes or injected background - // notices, so only their semantic mutations belong in the turn. break; } default: { @@ -4737,7 +4330,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } } - // Keep deferred finalize quiet-window fresh while wake traffic lands. yield* rearmDeferredFinalize(context); }); @@ -5108,11 +4700,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ); }); - /** - * Mutate a carryover subagent's in-memory task status without projecting. - * Used after a non-completed root whose original run subscriber is no - * longer guaranteed to ingest the terminal event. - */ const mutateCarryoverSubagentStatus = Effect.fnUntraced(function* ( subagent: ActiveAcpSubagent, status: OrchestrationV2Subagent["status"], @@ -5133,11 +4720,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }; }); - /** - * Project a carryover subagent status change without an ActiveAcpTurn. - * Shared by completed-root post-settle completion, deferred attach, and - * Direct Stop terminalization. - */ const projectCarryoverSubagentStatus = Effect.fnUntraced(function* ( subagent: ActiveAcpSubagent, status: OrchestrationV2Subagent["status"], @@ -5234,15 +4816,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } }); - /** - * Update a carryover subagent matched by native task id or child session - * id. Post-settle completions must flip the pin in hasPendingBackgroundWork - * without waiting for a new user turn to consume carryover. - * When `project` is false, only the in-memory carryover status advances - * (the next attach projects the terminal state). - * If status already matches but projection was deferred, a later - * project:true caller still projects once via terminalStatusProjected. - */ const updateCarryoverSubagentStatus = Effect.fnUntraced(function* ( nativeIdOrChildSessionId: string, status: OrchestrationV2Subagent["status"], @@ -5261,14 +4834,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (match === undefined) return false; const shouldProject = options?.project !== false; if (match.task.status === status) { - // Already at this status: only project if requested and not yet done. if (!shouldProject || match.terminalStatusProjected) { return true; } yield* projectCarryoverSubagentStatus(match, status, result); return true; } - // Only advance nonterminal entries; do not resurrect a terminal one. if (match.task.status !== "running" && match.task.status !== "pending") { return false; } @@ -5318,8 +4889,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* rearmDeferredFinalize(context); return; } - // The root turn already settled: the subagent is carryover. Project - // its end while the completed root still owns the run. const carryover = yield* Ref.get(carryoverSubagents); yield* updateCarryoverSubagentStatus( notice.childSessionId, @@ -5440,11 +5009,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } }); - /** - * Direct Stop after a soft steer clears carryover without an active turn. - * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems - * would have, context-free (no ActiveAcpTurn). - */ const terminalizeCarryoverSubagents = Effect.fnUntraced(function* ( carryover: AcpCarryoverSubagents | null, ) { @@ -5628,11 +5192,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.succeed(request), requestContext.requestId, ); - // Without the capability no fs handler is registered, so a stray - // request (OpenCode and Kilo send one after approved edits) gets - // method-not-found and cannot touch the disk. A flavor that opts in - // serves requests itself, confined to the workspace of the policy - // active when the request arrives; the agent asks before its edits. const clientFileSystem = flavor.clientFileSystem; if (clientFileSystem !== undefined) { yield* targetRuntime.handleReadTextFile((request) => @@ -5874,8 +5433,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return admitted.value; } if (params.mode !== "form" || !("requestedSchema" in params)) { - // Future elicitation modes beyond form and url decline rather - // than guessing at their semantics. return { action: "decline" } as const; } const requestedSchema = unknownRecord(params.requestedSchema); @@ -5950,8 +5507,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( handlerGeneration, Effect.gen(function* () { if (yield* Ref.get(stoppedRunQuarantine)) return; - // Root-session notices only; nested subagents report to - // their own parent session. if ((yield* Ref.get(activeSessionId)) !== notice.sessionId) return; yield* finishSubagentFromNotice(notice); }), @@ -5960,11 +5515,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( runRuntimeCallbackAtGeneration( handlerGeneration, Effect.gen(function* () { - // Direct Stop quarantine: drop residual task lifecycle from - // the stopped run instead of mutating wake machinery. if (yield* Ref.get(stoppedRunQuarantine)) return; - // Root-session tasks only: a cancelled subagent's re-run in - // its child session must not gate root wake machinery. if ((yield* Ref.get(activeSessionId)) !== mutation.sessionId) return; if ( mutation.status !== "running" && @@ -5990,7 +5541,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } }); - /** Builds the MCP-over-ACP bridge for one runtime, disposed with that runtime's scope. */ const makeRuntimeMcpBridge = Effect.fnUntraced(function* ( mcpContext: AcpMcpContext, scope: Scope.Scope, @@ -6108,9 +5658,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.provideService(AgentScope, agentScope), Effect.provideService(AgentScopeThreadId, threadId ?? undefined), ); - // Session setup may publish commands before it returns. Buffer those - // notifications, but do not expose request or extension handlers - // until the candidate generation has committed. yield* replacementRuntime.handleSessionUpdate((notification) => Effect.suspend(() => handleCandidateSessionUpdate(notification)), ); @@ -6138,8 +5685,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.uninterruptible( Effect.gen(function* () { yield* awaitAdmittedNativeResponses; - // Session updates and MCP were wired during candidate startup - // (against its own bridge); terminals attach after commit below. yield* wireAcpRuntimeHandlers( replacementExit.value.replacementRuntime, replacementGeneration, @@ -6283,9 +5828,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( modelSelection: ModelSelection, runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, ) { - // A step failing below leaves the native config partly changed, so - // the previously applied selection no longer describes the session. - // Callers record the new selection only once this completes. yield* Ref.set(activeSelection, null); const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model; let appliedModel: string | undefined; @@ -6308,10 +5850,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* runtime.setModel(requestedModel); } } - // Same-runtime switches compare against this stored setup, so keep - // its model metadata in sync with what the session now runs on; - // otherwise switching A -> B -> A would see the stale setup-time A - // and skip the final switch. if (appliedModel !== undefined) { const applied = appliedModel; yield* Ref.update(activeSessionSetup, (setup) => { @@ -6331,8 +5869,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }; }); } - // Restore the agent's own empty value for choices T3 stores under a - // reserved ID (see ACP_EMPTY_OPTION_VALUE). const optionSelections = (modelSelection.options ?? []).map((selection) => selection.value === ACP_EMPTY_OPTION_VALUE ? { ...selection, value: "" } : selection, ); @@ -6371,10 +5907,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( // Tuning knobs degrade instead of failing the session open: agents // advertise the union of values across models but can reject a // per-model invalid one at set time (codex-acp advertises "ultra" - // reasoning effort and then rejects it for most models). Skip - // values the session does not currently offer and downgrade an - // agent-side set rejection to a warning; the agent's default - // applies for that option. + // reasoning effort and then rejects it for most models). const option = configOptions.find((candidate) => candidate.id === selection.id); if ( option !== undefined && @@ -6402,9 +5935,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* runtime.setMode(policyMode); } const modeState = yield* runtime.getModeState; - // The synthetic mode selection is skipped rather than failed when the - // agent no longer advertises it: mode sets are volatile across agent - // versions and a stale persisted mode should not block the turn. if ( modeSelection !== undefined && typeof modeSelection.value === "string" && @@ -6606,8 +6136,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (settledStatus === "completed") { yield* terminalizeOpenForegroundTools(context); } else if (settledStatus === "interrupted") { - // Direct Stop terminalizes every visible run-owned item. restart_active - // keeps live subagent lineages for in-process replacement carryover. yield* terminalizeOpenRunOwnedItems(context, { terminalizeSubagents: directStopQuarantine, }); @@ -6714,7 +6242,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const subagentsRequiringCarryover = [...context.subagents.values()].filter( acpSubagentHasPendingBackgroundWork, ); - // Direct Stop must not carry residual subagents into a later run. if (subagentsRequiringCarryover.length > 0 && !directStopQuarantine) { const sessionId = yield* Ref.get(activeSessionId); if (sessionId !== null) { @@ -6726,13 +6253,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } yield* Ref.set(activeTurn, null); - // A mid-turn background completion may have deferred its offer while - // this root turn was still streaming. Once the turn leaves the active - // slot, re-check: empty running set + residual wake evidence (or a - // mid-turn unreported completion) means a legitimate unhandled - // completion can open exactly one continuation. Sticky - // continuationRequested prevents double-offer if a later frame also - // races into offerContinuationRun. if ( postSettleContinuationEnabled && settledStatus === "completed" && @@ -6746,11 +6266,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* offerContinuationRun(sessionId); } } - // Clear mid-turn unreported marks unless a completed turn still has - // running background work: keep them so the post-finalize gate can - // offer once when the last task ends. Interrupted/failed turns must - // not leave marks that open a wake after interrupt (quarantine owns - // that path). Non-continuation turn start also clears (~user turn). if ( settledStatus !== "completed" || (yield* Ref.get(runningBackgroundTaskIds)).size === 0 @@ -6944,18 +6459,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const isContinuationTurn = postSettleContinuationEnabled && acpIsProviderContinuationMessage(turnInput.message); const isAppOwnedWakeTurn = acpIsAppOwnedWakeTurn(turnInput.message); - // An app-owned wake reports on a sibling delegated child and owns - // none of this session's background work, so it must not discard - // pending wake evidence: neither the mid-turn completion marks kept - // across a settle with work still running, nor the wake buffer - // below. Dropping the marks here would lose the offer for a task - // that completed unreported before the settle. if (!isAppOwnedWakeTurn) { yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set()); } - // A user turn supersedes an empty offer, but not an offer that owns - // buffered wake traffic. ProviderContinuationService queues that - // continuation behind the user run so it can drain afterwards. const continuationWasRequested = yield* continuationPermit.withPermit( Effect.gen(function* () { const wasRequested = yield* Ref.get(continuationRequested); @@ -6964,10 +6470,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( isContinuationTurn, wakeBufferLength: (yield* Ref.get(wakeBuffer)).length, }); - // User turns must not inherit prior-turn wake residue. Stale - // injected-turn ack chatter can otherwise arm a later offer. - // Preserve only a queued continuation that owns buffered wake - // traffic, and exempt app-owned sibling wakes entirely. if (!isContinuationTurn && !isAppOwnedWakeTurn && !preserveBufferedContinuation) { yield* Ref.set(wakeBuffer, []); yield* Ref.set(wakeReports, noWakeReports); @@ -7052,8 +6554,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } yield* Ref.set(activeTurn, context); - // Direct Stop closes and recreates the old runtime before reaching - // this reset. The quarantine remains session-scoped by design. yield* Ref.set(stoppedRunQuarantine, false); const runningTurn = providerTurnPayload(context, "running", null); yield* Ref.update(providerTurns, (current) => { @@ -7109,12 +6609,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* projectDeferredCarryoverTerminals(rehydratedCarryoverSubagents); // Keep projected terminals addressable through the wake drain so // emitSubagent's monotonic guard can reject replayed spawn frames. - // Finalize carries only entries with pending background work, so a - // terminal-and-projected lineage still expires with this turn. if (isContinuationTurn) { yield* Ref.set(continuationRequested, false); - // This turn delivers what its offer named. Work that ended after - // the offer keeps its report for the next one. yield* Ref.update(wakeReports, ({ reports, offered }) => ({ reports: new Map([...reports].filter(([key]) => !offered.has(key))), offered: noWakeReports.offered, @@ -7134,16 +6630,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ), Effect.map((drained) => drained.length), ); - // Treat attach mode as prompt-settled so deferred finalize / quiet - // windows can complete the continuation after wake traffic drains. context.promptSettled = true; context.promptSettledStatus = "completed"; if (drainedWakeCount === 0) { - // A requested continuation with only mid-turn evidence has no - // buffered frame yet. Wait the quiet window so late CLI frames - // can attach. A provider-authored attach without a matching - // request only exists to project deferred carryover terminals, - // so it can finish immediately once those terminals are visible. if (continuationWasRequested && flavor.deferFinalizeForBackgroundWork === true) { if (hasDeferredBackgroundWork(context)) { yield* rearmDeferredFinalize(context); @@ -7191,9 +6680,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? "interrupted" : "cancelled" : "completed"; - // Grok monitors (and async subagents) keep working after the root - // prompt RPC returns. Defer finalize so their later updates and - // wake-turn traffic still project onto this run. if ( flavor.deferFinalizeForBackgroundWork === true && !context.interrupted && @@ -7201,8 +6687,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) { context.promptSettled = true; context.promptSettledStatus = status; - // The agent finished this prompt's reply. Background work - // holds the run open, not the text it already sent. yield* closeTextStreams(context); yield* ( options.testHooks?.afterPromptSettledWithBackgroundWork?.() ?? Effect.void @@ -7213,8 +6697,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }), ).pipe(Effect.asVoid), ), - // Prompt failure is not wire-settled: only a successful resolve marks - // the signal. catchCause must not complete promptWireSettled. Effect.catchCause((cause) => runRuntimeCallbackAtGeneration( promptGeneration, @@ -7332,13 +6814,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if ((yield* Ref.get(wakeBuffer)).length > 0) return true; if (yield* Ref.get(continuationRequested)) return true; if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true; - // Projected post-settle Grok subagents can outlive the root - // turn via carryover; keep the ACP process pinned until they - // terminalize or teardown clears the carryover. - // Also pin while a terminal status is held only in memory - // (project:false) so idle release cannot drop the session - // before the continuation drain (or a later project:true path) - // delivers the turn_item terminal. const active = yield* Ref.get(activeTurn); if ( active !== null && @@ -7480,10 +6955,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return yield* Effect.gen(function* () { const transition = yield* Effect.gen(function* () { const interruptContext = yield* Ref.get(activeTurn); - // Settled steering restart: the native prompt already - // returned and this is not a user Stop, so keep the - // process (and its background subagents) alive instead - // of hard-killing it. The turn still terminalizes below. const softSteerCandidate = flavor.preserveRuntimeOnSettledInterrupt === true && turnInput.requestRuntimeRestart !== true && @@ -7521,8 +6992,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return yield* teardownState.error; } if (teardownState._tag === "InProgress") { - // Concurrent Stop/restart: wait for the in-flight hard - // teardown instead of failing the durable effect. yield* Effect.logInfo( "ACP interrupt awaiting in-progress hard teardown", { @@ -7536,19 +7005,15 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } const context = interruptContext; if (context?.providerTurnId !== turnInput.providerTurnId) { - // A soft steering interrupt can clear the turn while - // intentionally leaving the process alive. A queued user - // Stop must still contain that orphan runtime. With a - // different live turn active this stays a pure no-op - // success instead: quarantine and teardown are - // session-global and would maim the replacement turn. + // A queued user Stop must still contain that orphan + // runtime. With a different live turn active this stays + // a pure no-op success instead: quarantine and teardown + // are session-global and would maim the replacement + // turn. const containOrphanRuntime = context === null && hardRestart && turnInput.requestRuntimeRestart === true; - // Transport death or a prior Stop already cleared the - // turn. Failing here caused effect-worker retries while - // the process was already gone; treat as success. yield* Effect.logWarning( containOrphanRuntime ? "ACP Stop raced a soft interrupt that cleared the turn; containing the orphan runtime" @@ -7579,11 +7044,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* advanceRuntimeCallbackGeneration; }), ); - // Capture before quarantineStoppedRun clears carryover. const orphanCarryover = yield* Ref.getAndSet(carryoverSubagents, null); yield* quarantineStoppedRun(); - // Match the main hard-restart path: cancel pending - // approvals/elicitations after quarantine, before kill. yield* cancelPendingRuntimeRequests(); yield* terminalizeCarryoverSubagents(orphanCarryover); if (runtime.terminateProcessGroup === undefined) { @@ -7645,7 +7107,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( settledSoftInterrupt, }; }); - // Concurrent hard teardown already completed above. if (transition === undefined) return; const { context, @@ -7669,10 +7130,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); const runTransition = Effect.gen(function* () { context.interrupted = true; - // Quarantine only when this run is discarded (user Stop / - // hard process kill). Soft in-process restarts must keep - // carryoverSubagents so a still-running subagent can - // complete into the replacement turn. if (hardRestart || turnInput.requestRuntimeRestart === true) { yield* quarantineStoppedRun(); } @@ -7684,10 +7141,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( (hardRestart || context.promptSettled || settledSoftInterrupt) && !context.finalized ) { - // hardRestart: always terminalize locally. - // promptSettled / settledSoftInterrupt (incl. wire-settled): - // native prompt already returned; only deferred background - // work remains, so session/cancel has nothing to acknowledge. yield* finalizeTurn(context, "interrupted"); } if (hardRestart) { @@ -7705,9 +7158,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Cause.squash(teardownExit.cause), ).pipe(Effect.flatMap(Effect.fail)); } - // Process group is gone; the next turn must spawn a - // replacement even if the flavor only set hardRestart - // without restartRuntimeAfterInterrupt. yield* Ref.set(runtimeRestartRequired, true); yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore); @@ -7715,8 +7165,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( // Settled soft interrupt: the native prompt already // returned, so session/cancel has nothing to // acknowledge and would only threaten still-running - // background subagents. Skip it and leave the runtime - // untouched for the replacement turn. + // background subagents. if (!settledSoftInterrupt) { yield* runtime.cancel; } @@ -7902,10 +7351,6 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( detail: `Cannot roll back ACP provider thread ${rollbackInput.providerThread.id} while turn ${currentTurn.providerTurnId} is active`, }); } - // ACP defines no conversation truncation, so rollback stages - // a fresh native session before retiring the original. - // Returning its binding keeps the next turn runnable without - // loading any of the rolled-back conversation. yield* awaitRuntimeTeardown(); itemIdentityVersion = 2; prepareTerminalEnvironment( diff --git a/packages/provider-acp/src/server/clientPolicy.test.ts b/packages/provider-acp/src/server/clientPolicy.test.ts index fd2a9714c57c..630ebc979e75 100644 --- a/packages/provider-acp/src/server/clientPolicy.test.ts +++ b/packages/provider-acp/src/server/clientPolicy.test.ts @@ -90,7 +90,6 @@ describe("acpPermissionDisposition", () => { }); it("auto-accept-edits approves file changes without locations and asks for the rest", () => { - // ACP permission requests need not carry locations. const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd }; for (const kind of ["edit", "delete", "move"] as const) { assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "allow"); diff --git a/packages/provider-acp/src/server/clientPolicy.ts b/packages/provider-acp/src/server/clientPolicy.ts index 5a0d9355a1c1..b20420a43b9c 100644 --- a/packages/provider-acp/src/server/clientPolicy.ts +++ b/packages/provider-acp/src/server/clientPolicy.ts @@ -4,17 +4,6 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; -/** - * Runtime-policy decisions for ACP work that T3 mediates. - * - * ACP agents run their own tools under their own permission model and ask - * through `session/request_permission`, which T3 answers by policy. The one - * client-mediated path left is Devin's `terminal/*`, which runs with the T3 - * server's own privileges; it resolves through {@link acpOperationDisposition} - * so a client terminal can never do more than an execute permission request. - */ - -/** Structural subset of the adapter runtime policy that decisions read. */ export interface AcpRuntimePolicy { readonly runtimeMode: RuntimeMode; readonly cwd: string | null; @@ -49,13 +38,9 @@ function acpPathIsWithinRoot(path: string, root: string): boolean { } /** - * Canonicalize a path for an authorization containment check. - * - * `realpath` cannot resolve a file that has not been created yet, so walk up - * to the deepest existing ancestor and append the missing suffix to that - * ancestor's canonical path. This follows symlinked directories while still - * allowing normal writes to new files. If an existing entry cannot be - * canonicalized (for example, a broken symlink), fail closed. + * `realpath` cannot resolve a file that has not been created yet, so walk up to the deepest + * existing ancestor and append the missing suffix to that ancestor's canonical path. If an + * existing entry cannot be canonicalized (for example, a broken symlink), fail closed. */ function acpCanonicalPathForContainment(path: string): string | undefined { // Do not lexically normalize before realpath. For a path such as @@ -76,8 +61,6 @@ function acpCanonicalPathForContainment(path: string): string | undefined { try { NodeFS.lstatSync(candidate); - // The entry exists but realpath could not resolve it, as with a broken - // symlink. Treat it as untrusted rather than authorizing its lexical path. return undefined; } catch (cause) { if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { @@ -92,7 +75,6 @@ function acpCanonicalPathForContainment(path: string): string | undefined { } } -/** The parts of a native operation the disposition logic reads. */ interface AcpPolicyOperation { readonly kind: string | null | undefined; readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined; @@ -109,11 +91,8 @@ function isAcpReadKind(toolKind: string): boolean { } /** - * Reads follow the sandbox alone and never ask. Approval policy governs writes - * and commands, as it does for Codex and Claude, and every sandbox T3 knows - * lets the agent read. That includes no explicit sandbox, since the strictest - * runtime mode (approval-required) implies a read-only one. Unknown sandbox - * types still fail closed. + * Reads follow the sandbox alone and never ask. That includes no explicit sandbox, since the + * strictest runtime mode (approval-required) implies a read-only one. */ function acpReadDisposition(runtimePolicy: AcpRuntimePolicy): "allow" | "deny" { switch (unknownRecord(runtimePolicy.sandboxPolicy)?.type) { @@ -171,10 +150,6 @@ function acpWorkspaceWriteAllowsMutation( return true; } -/** - * Disposition of one native operation under the active runtime policy. Shared - * by provider permission requests and the client fs/terminal handlers. - */ function acpOperationDisposition( runtimePolicy: AcpRuntimePolicy, operation: AcpPolicyOperation, @@ -207,8 +182,6 @@ function acpOperationDisposition( return "allow"; case undefined: if (runtimePolicy.runtimeMode === "approval-required") return "deny"; - // Auto-accept edits approves file changes wherever the agent makes them - // (ACP prompts need not carry locations to confine); other actions still ask. if ( runtimePolicy.runtimeMode === "auto-accept-edits" && runtimePolicy.approvalPolicy === undefined && @@ -236,7 +209,6 @@ export function acpPermissionDisposition( }); } -/** Resolve explicitly tagged MCP approvals through the thread's normal policy. */ export function acpMcpToolApprovalElicitationDisposition( runtimePolicy: AcpRuntimePolicy, request: EffectAcpSchema.CreateElicitationRequest, @@ -255,7 +227,6 @@ export function acpMcpToolApprovalElicitationDisposition( return acpPolicyRequiresApproval(runtimePolicy) ? "ask" : "allow"; } -/** Disposition of a client-mediated `terminal/create` (Devin's client terminals). */ export function acpClientExecuteDisposition( runtimePolicy: AcpRuntimePolicy, ): AcpPermissionDisposition { @@ -274,11 +245,9 @@ export interface AcpClientPolicyGrants { } /** - * Command approvals recorded when the user accepts a `session/request_permission`, - * so an "ask" disposition at the client terminal boundary honors a command the - * user already approved. ACP does not link an approved tool call to the - * terminals the agent then creates, so an approved command authorizes client - * terminals for the approving turn, or for the session on accept-for-session. + * ACP does not link an approved tool call to the terminals the agent then creates, so an + * approved command authorizes client terminals for the approving turn, or for the session on + * accept-for-session. */ export function makeAcpClientPolicyGrants(): AcpClientPolicyGrants { let sessionExecute = false; diff --git a/packages/provider-acp/src/server/clientTerminals.test.ts b/packages/provider-acp/src/server/clientTerminals.test.ts index 9cd97876436c..5518b4a31ebd 100644 --- a/packages/provider-acp/src/server/clientTerminals.test.ts +++ b/packages/provider-acp/src/server/clientTerminals.test.ts @@ -79,7 +79,6 @@ describe("AcpClientTerminals", () => { environmentForSession: () => ({ T3_ACP_MCP_NODE: "mailbox-probe" }), }); yield* Effect.addFinalizer(() => terminals.disposeAll); - // Same command-only shape as the failed production commands and live capture. const terminal = yield* terminals.create({ sessionId: "devin", command: 'printf "%s\\n" "$T3_ACP_MCP_NODE" | tr a-z A-Z', @@ -140,7 +139,6 @@ describe("AcpClientTerminals", () => { ); expect(rejected.message).toContain("unknown terminal ID"); - // Embedded tool-call content still renders released terminals. expect(terminals.readOutputSnapshot("session", created.terminalId)).toBeDefined(); }), ), diff --git a/packages/provider-acp/src/server/clientTerminals.ts b/packages/provider-acp/src/server/clientTerminals.ts index ce6b3d0436ae..d4fb6d3bb323 100644 --- a/packages/provider-acp/src/server/clientTerminals.ts +++ b/packages/provider-acp/src/server/clientTerminals.ts @@ -12,16 +12,6 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; import * as NodeBuffer from "node:buffer"; -/** - * Client-side implementation of the ACP `terminal` capability. - * - * Agents create terminals to run commands through the client - * (`terminal/create`), poll buffered output, await exit, kill, and release - * them. Terminals also appear as embedded tool-call content, which - * {@link resolveEmbeddedTerminalContent} rewrites into plain text so the - * existing tool-call projection renders live command output. - */ - const MAX_LIVE_TERMINALS = 16; const MAX_UNRELEASED_TERMINALS = 64; const MAX_RETAINED_TERMINALS = 32; @@ -97,12 +87,10 @@ export interface AcpTerminalOutputSnapshot { interface ManagedAcpTerminal { readonly terminalId: string; readonly sessionId: string; - /** The spawned command with args, kept for MCP-fallback identification. */ readonly commandLine: string; readonly buffer: OutputBufferState; readonly exit: Deferred.Deferred; readonly kill: Effect.Effect; - /** Closes the terminal's scope, killing the process if still running. */ readonly dispose: Effect.Effect; exitStatus: EffectAcpSchema.TerminalExitStatus | undefined; released: boolean; @@ -129,9 +117,7 @@ export interface AcpClientTerminals { sessionId: string, terminalId: string, ) => AcpTerminalOutputSnapshot | undefined; - /** Command line a terminal was created with; presentation-only lookup. */ readonly readCommandLine: (terminalId: string) => string | undefined; - /** Kills every terminal that is still running. Safe to call repeatedly. */ readonly disposeAll: Effect.Effect; } @@ -237,8 +223,6 @@ export const makeAcpClientTerminals = Effect.fn("makeAcpClientTerminals")(functi (request.env?.length ?? 0) === 0 ? undefined : { ...options.environment, ...sessionEnvironment, ...requestEnvironment }; - // Each terminal owns a scope so the spawned process is reliably reaped: - // closing the scope kills a still-running command and frees the handle. const terminalScope = yield* Scope.make(); const child = yield* restore( spawner @@ -388,8 +372,6 @@ export const makeAcpClientTerminals = Effect.fn("makeAcpClientTerminals")(functi ), ); - // Terminal failures surface to the agent as JSON-RPC errors and to the - // user only as a failed tool item, so log them for server-side diagnosis. const logged = ( operation: string, @@ -417,10 +399,6 @@ export const makeAcpClientTerminals = Effect.fn("makeAcpClientTerminals")(functi }; }); -/** - * Rewrites embedded `terminal` tool-call content into plain text content so - * the provider-neutral tool-call projection can display live command output. - */ export function resolveEmbeddedTerminalContent( notification: EffectAcpSchema.SessionNotification, readOutputSnapshot: AcpClientTerminals["readOutputSnapshot"], diff --git a/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts index c9d7b2bbf234..cf266d464db4 100644 --- a/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts @@ -1,4 +1,3 @@ -// The bridge intentionally treats MCP JSON-RPC messages as opaque JSON. import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; diff --git a/packages/provider-acp/src/server/mcpOverAcpBridge.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.ts index 831bde8756be..29d5f852762e 100644 --- a/packages/provider-acp/src/server/mcpOverAcpBridge.ts +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.ts @@ -1,4 +1,3 @@ -// The bridge forwards opaque JSON-RPC values without interpreting their schemas. // @effect-diagnostics preferSchemaOverJson:off import * as Effect from "effect/Effect"; import * as Semaphore from "effect/Semaphore"; @@ -79,7 +78,6 @@ export interface AcpMcpOverAcpBridge { readonly dispose: Effect.Effect; } -/** Bridges the unstable ACP transport to T3's authenticated streamable-HTTP MCP endpoint. */ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(function* ( options: AcpMcpOverAcpBridgeOptions, ): Effect.fn.Return { diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index dab23a53fead..5ab3c17c5e0b 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -603,13 +603,10 @@ describe("AcpRuntimeModel", () => { expect(event.toolCall.detail).toBeDefined(); const detail = event.toolCall.detail!; - // 8000 chars of tail plus the truncation marker, regardless of input size. expect(detail.length).toBe(8_028); expect(detail.startsWith("[Earlier output truncated]")).toBe(true); expect(detail.endsWith(hugeText.slice(-100))).toBe(true); - // The raw payload threaded through for logging/persistence must not smuggle the full - // cumulative buffer back in either. const rawUpdate = ( event.rawPayload as { readonly update: { @@ -633,7 +630,6 @@ describe("AcpRuntimeModel", () => { let cumulativeBuffer = ""; for (let i = 0; i < 1_000; i += 1) { - // Grok resends the FULL accumulated buffer, not a delta, on every redraw. cumulativeBuffer += `frame ${i}: ${"#".repeat(50)}\n`; const isLast = i === 999; @@ -678,17 +674,11 @@ describe("AcpRuntimeModel", () => { } } - // The flood as the CLI sends it: 1000 cumulative redraws, ~31.6 MB of JSON. expect(notificationBytes).toBeGreaterThan(31_000_000); - // 1000 cumulative redraws collapse into a fixed, small number of runtime events... expect(emittedCount).toBe(114); - // ...each individually bounded, no matter how long the tool call runs... expect(largestEmittedEventBytes).toBeLessThan(25_000); - // ...so the whole flooding tool call costs ~2.5 MB of runtime events instead of ~31.6 MB. expect(emittedBytes).toBeLessThan(2_600_000); - // ...while the FINAL state (forced by the completed status) still reflects the real, - // latest output rather than a stale coalesced value. expect(finalDetail).toBeDefined(); expect(finalDetail?.endsWith(`frame 999: ${"#".repeat(50)}`)).toBe(true); }); @@ -782,9 +772,6 @@ describe("AcpRuntimeModel", () => { }); it("bounds oversized whitespace-only tool call content that has no trimmed text", () => { - // Whitespace-only entries are skipped when extracting display text (`chunks.length === 0`) - // and used to be returned unchanged, which let a redrawing terminal persist unbounded - // buffers on `toolCall.data.content` and `rawPayload`. const hugeWhitespace = " \n\t".repeat(30_000); expect(hugeWhitespace.length).toBeGreaterThan(60_000); @@ -982,8 +969,6 @@ describe("AcpRuntimeModel", () => { let previous: AcpToolCallState | undefined; for (let i = 1; i <= 12; i += 1) { - // Grows by 1 char per update β€” well under the 256-char growth threshold, so this - // exercises the coalesce-count fallback rather than the growth-based trigger. const next = toolCall("x".repeat(i), "inProgress"); const decision = decideToolCallUpdateEmission({ previous, @@ -999,8 +984,6 @@ describe("AcpRuntimeModel", () => { previous = next; } - // First update always emits (no previous state yet); after that, small per-update - // growth should be coalesced until the coalesce limit forces a periodic emission. const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : [])); expect(emittedIndices).toEqual([1, 11]); }); @@ -1407,8 +1390,6 @@ describe("extractMcpToolCallIdentity", () => { } it("recovers server and tool from codex-acp tagged execute calls", () => { - // Captured verbatim from codex-acp 2026-08-14: MCP calls arrive as kind - // "execute" with the identity only in rawInput. const toolCall = toolCallFromUpdate({ sessionUpdate: "tool_call", toolCallId: "exec-f4591587-0754-4bb4-990b-f2767894ba93", @@ -1444,9 +1425,6 @@ describe("extractMcpToolCallIdentity", () => { }); it("recovers T3 identity from pi-acp title-only fallback execs", () => { - // Captured verbatim from pi-acp 0.0.33 2026-08-14: rawInput is null and - // the command line only appears as the verbatim title, which the - // presentation layer summarizes into "Ran command". const toolCall = toolCallFromUpdate({ sessionUpdate: "tool_call", toolCallId: "call_JdxnvzjHHrbvyASTLVekLYWV|fc_08f5a805a7159aa6016a7ec4afad548191", @@ -1472,10 +1450,6 @@ describe("extractMcpToolCallIdentity", () => { }); it("recovers T3 identity from server-namespaced titles across titleless updates", () => { - // Captured verbatim from Kilo 7.4.22 2026-08-15: the initial tool_call - // titles the MCP function "_" with kind "other", and the - // completed update carries no title at all, so the merged presentation - // title regresses to "Tool" while data.title keeps the wire value. const created = toolCallFromUpdate({ sessionUpdate: "tool_call", toolCallId: "chatcmpl-tool-b2a6142ee1a510a5", @@ -1500,8 +1474,6 @@ describe("extractMcpToolCallIdentity", () => { }); it("recovers T3 identity from Gemini and qwen MCP-server title templates", () => { - // Gemini CLI 0.55.1: " ( MCP Server)"; qwen-code 0.21.12 - // appends ": " to the same template. const gemini = toolCallFromUpdate({ sessionUpdate: "tool_call", toolCallId: "gemini-1", @@ -1526,13 +1498,6 @@ describe("extractMcpToolCallIdentity", () => { }); it("recovers T3 identity across the registry agents' naming conventions", () => { - // One representative per surveyed convention (2026-08 registry builds): - // droid triple underscore, Copilot hyphen, Amp mangled server + detail - // tail, cline args tail, Auggie tool-first suffix. - // One representative per surveyed convention (2026-08 registry builds): - // droid triple underscore, Copilot hyphen, Amp mangled server + detail - // tail, cline args tail, Auggie tool-first suffix, fast-agent slash, - // Kimi bare name with args tail. for (const title of [ "t3-code___delegate_task", "t3-code-delegate_task", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index f4172d17089b..14095bb8ee40 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -21,7 +21,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -// Guards for the untyped `initialize._meta` states some agents (Grok) advertise. // Modes were removed from ACP v2, so the v1 wire schema is the source of truth. const isSessionModelState = Schema.is(EffectAcpSchema.SessionModelState); const isSessionModeState = Schema.is(EffectAcpSchemaV1.SessionModeState); @@ -52,7 +51,6 @@ export interface AcpAgentTerminalState { readonly cwd?: string; readonly output: string; readonly exitStatus?: EffectAcpSchema.TerminalExitStatus; - /** Incomplete trailing UTF-8 bytes retained until the next output update. */ readonly pendingOutputBytes?: Uint8Array; } @@ -74,11 +72,6 @@ function isBase64DataCode(code: number): boolean { ); } -/** - * Decodes one bounded base64 terminal frame, or `undefined` when the frame is - * malformed or oversized. Invalid frames are dropped rather than failed so one - * misbehaving agent frame cannot take down session-update handling. - */ function decodeBase64TerminalFrame(encoded: string): Uint8Array | undefined { if (encoded.length > Math.ceil(MAX_ACP_TERMINAL_FRAME_BYTES / 3) * 4) { return undefined; @@ -141,7 +134,6 @@ function truncateTerminalOutput(output: string): string { return bytes.subarray(start).toString("utf8"); } -/** Applies an ACP v2 agent-owned terminal snapshot or chunk to display state. */ export function applyAcpAgentTerminalUpdate( previous: AcpAgentTerminalState | undefined, update: AcpAgentTerminalUpdate, @@ -293,7 +285,6 @@ const boundedContentMetadata = (value: string | null | undefined, maximumLength: const MAX_ACP_CONTENT_TEXT_LENGTH = 65_536; -// Preserve whitespace in streamed model output while keeping one provider frame bounded. const boundedContentText = (value: string): string => value.slice(0, MAX_ACP_CONTENT_TEXT_LENGTH); const boundedContentUri = (value: string | null | undefined): string => { @@ -301,7 +292,6 @@ const boundedContentUri = (value: string | null | undefined): string => { return uri.toLowerCase().startsWith("data:") ? "" : uri; }; -/** Convert every ACP output block into bounded, displayable text without retaining base64 data. */ export function acpContentBlockDisplayText( content: EffectAcpSchema.ContentBlock, ): string | undefined { @@ -624,9 +614,7 @@ function locationsFromToolCallInput(input: { // output on every `tool_call_update` notification instead of a delta, so a redrawing // terminal progress bar can balloon a single tool call to hundreds of KB per update at // several updates per second. Cap what we retain/emit to a bounded tail so one busy tool -// call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update` -// deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal -// from another tool here, and the end is the useful part of any live-growing output. +// call cannot flood runtime event ingestion. const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; @@ -672,10 +660,6 @@ function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | u return entry.content.text; } -// Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never -// contribute to `chunks` and used to take the early returns with the original array. Bound -// each text entry independently so those paths cannot persist an unbounded terminal buffer -// on `toolCall.data.content` / `rawPayload`. function boundToolCallContentEntries( content: ReadonlyArray, ): ReadonlyArray { @@ -726,10 +710,6 @@ function extractTextContentFromToolCallContent( }; } -// Walk the original text entries from the joined tail window so a retained slice that -// spans entries around an image/diff stays on those entries. Non-text kinds keep their -// relative order; blank text entries are dropped; the truncation marker is prepended to -// the first remaining text entry. function distributeRetainedTailAcrossContent( content: ReadonlyArray, tail: string, @@ -786,10 +766,6 @@ function normalizeToolKind(kind: unknown): string | undefined { return typeof kind === "string" && kind.trim().length > 0 ? kind.trim() : undefined; } -/** - * Map an ACP tool kind onto the canonical runtime item type used by the - * thread activity model. Unknown kinds fall back to a generic tool call. - */ export function canonicalItemTypeFromAcpToolKind(kind: string | undefined): ToolLifecycleItemType { switch (kind) { case "read": @@ -945,10 +921,7 @@ export function mergeToolCallState( // Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal // can still shift its bounded tail window on nearly every notification, which would emit -// a runtime event per redraw. Coalesce those: only emit early when the tool call's detail -// has grown meaningfully since the last emission, otherwise batch up to a small number of -// skipped updates before emitting anyway, so the UI still gets periodic progress and the -// final (completed/failed) state is always emitted immediately. +// a runtime event per redraw. const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; @@ -1010,8 +983,6 @@ function toolCallContentTexts(state: AcpToolCallState): string { .join("\u0000"); } -// The output a user watches: detail, text content, and any `rawOutput`. A -// streamed diff or `rawInput` is not part of it. export function toolCallVisibleOutputChanged( previous: AcpToolCallState, next: AcpToolCallState, @@ -1052,7 +1023,6 @@ export interface AcpMcpToolCallIdentity { readonly input?: Record; } -/** Matches an invocation of T3's `acp-mcp-call` bridge fallback CLI. */ const ACP_MCP_FALLBACK_CALL = /(?:^|[\s"'=])acp-mcp-call[\s"']+([A-Za-z0-9_.-]+)(?:\s+(.+?))?\s*$/u; function acpMcpFallbackInput(value: string | undefined): Record | undefined { @@ -1076,13 +1046,8 @@ function acpMcpFallbackInput(value: string | undefined): Record } /** - * Agents flatten injected MCP tools into model-facing function names with no - * shared convention (survey of the 2026-08 registry builds): Kilo and - * opencode use `t3-code_`, claude-acp and qwen `mcp__t3-code__`, - * Amp `mcp__t3_code__` (hyphens mangled), droid `t3-code___`, - * Copilot `t3-code-`, cline appends `: `. T3 always injects - * its server as "t3-code", and matches are additionally gated on the known - * T3 tool inventory, so the separator match can stay loose. + * T3 always injects its server as "t3-code", and matches are additionally gated on the known T3 + * tool inventory, so the separator match can stay loose. */ const T3_MCP_TITLE_CALL = /^(?:mcp[-_]{1,2})?t3[-_ ]?code[-_.:/ ]{1,3}(?[A-Za-z0-9][A-Za-z0-9_.-]*)(?::.*)?$/i; @@ -1103,19 +1068,14 @@ const T3_MCP_TITLE_SUFFIX_CALL = const T3_MCP_BARE_TITLE_CALL = /^(?[A-Za-z0-9_]+)(?::\s|$)/; /** - * Best-effort recovery of MCP identity from a generic ACP tool call. - * - * ACP has no typed MCP tool-call item, so agents surface MCP calls in - * agent-specific shapes: codex-acp tags execute calls with - * `rawInput.server`/`rawInput.tool`, while agents on T3's terminal fallback - * run the `acp-mcp-call ` CLI through their command or an embedded - * client terminal. Recovered identity lets the projection render the same - * branded MCP item that native providers produce. + * ACP has no typed MCP tool-call item, so agents surface MCP calls in agent-specific shapes: + * codex-acp tags execute calls with `rawInput.server`/`rawInput.tool`, while agents on T3's + * terminal fallback run the `acp-mcp-call ` CLI through their command or an embedded client + * terminal. */ export function extractMcpToolCallIdentity( toolCall: AcpToolCallState, options?: { - /** Command lines of client terminals embedded in this tool call. */ readonly embeddedTerminalCommands?: ReadonlyArray; }, ): AcpMcpToolCallIdentity | undefined { @@ -1126,19 +1086,12 @@ export function extractMcpToolCallIdentity( if (meta?.is_mcp_tool_call === true && server.length > 0 && tool.length > 0) { return { server, tool }; } - // Agents without tagged rawInput identify their MCP calls through _meta - // (goose, qwen, claude-acp) or only through the namespaced function name - // in the title. The verbatim wire title survives merges even when a later - // titleless or LLM-enriched update replaces the presentation title, so - // match those rather than the summarized state title. const claudeCode = isRecord(meta?.claudeCode) ? meta.claudeCode : undefined; const gooseToolCall = isRecord(meta?.goose) ? isRecord(meta.goose.toolCall) ? meta.goose.toolCall : undefined : undefined; - // qwen asserts the origin server explicitly, so any known tool suffix in - // its toolName identifies the call even under future prefix formats. const metaServerId = typeof meta?.serverId === "string" ? meta.serverId.trim() : ""; const metaToolName = typeof meta?.toolName === "string" ? meta.toolName.trim() : ""; if (/^t3[-_ ]?code$/i.test(metaServerId) && metaToolName.length > 0) { @@ -1159,10 +1112,9 @@ export function extractMcpToolCallIdentity( const assertsForeignOrigin = (metaServerId.length > 0 && !/^t3[-_ ]?code$/i.test(metaServerId)) || (gooseExtension.length > 0 && !/^t3[-_ ]?code$/i.test(gooseExtension)); - // A foreign origin never brands as T3. qwen's serverId marks a real MCP - // server, but goose reports its built-in extensions (developer__shell, - // edits) the same way as user MCP servers, so goose stays unclassified and - // keeps its command and file-change projections. + // qwen's serverId marks a real MCP server, but goose reports its built-in + // extensions (developer__shell, edits) the same way as user MCP servers, so + // goose stays unclassified and keeps its command and file-change projections. if (assertsForeignOrigin) { if (metaServerId.length === 0 || metaToolName.length === 0) return undefined; const prefix = [`mcp__${metaServerId}__`, `mcp::${metaServerId}::`].find((prefix) => @@ -1200,8 +1152,6 @@ export function extractMcpToolCallIdentity( for (const command of commands) { const match = ACP_MCP_FALLBACK_CALL.exec(command); if (match?.[1] !== undefined) { - // The acp-mcp-call CLI exists only as T3's bridge fallback, so the - // server identity is T3's by construction. const input = acpMcpFallbackInput(match[2]); return { server: "t3-code", tool: match[1], ...(input === undefined ? {} : { input }) }; } @@ -1261,7 +1211,6 @@ export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotificatio return isRecord(meta) && meta.isReplay === true; } -/** Replay chunks and substantive updates during session/load; not Grok keepalives. */ export function sessionUpdateCountsAsLoadReplayActivity( params: EffectAcpSchema.SessionNotification, gatedSessionId?: string, @@ -1300,7 +1249,6 @@ export function sessionUpdateCountsAsLoadReplayActivity( export interface SessionLoadGate { readonly active: boolean; - /** Only notifications for this session refresh load-replay idle activity. */ readonly sessionId: string; readonly lastActivityAtMillis: number | undefined; readonly idleGap: Duration.Duration; @@ -1330,10 +1278,6 @@ export const waitForSessionLoadReplayIdle = (input: { } }); -/** - * Model state some agents (Grok) advertise in `initialize._meta.modelState`, before any - * session exists. Undefined when the agent does not advertise it or the shape is unknown. - */ export function sessionModelStateFromInitialize( initializeResult: EffectAcpSchema.InitializeResponse, ): EffectAcpSchema.SessionModelState | undefined { diff --git a/packages/provider-acp/src/server/sessionConfig.ts b/packages/provider-acp/src/server/sessionConfig.ts index c2ea4babd558..54609cfbed0e 100644 --- a/packages/provider-acp/src/server/sessionConfig.ts +++ b/packages/provider-acp/src/server/sessionConfig.ts @@ -3,24 +3,12 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import type { AcpSessionModeState } from "./runtimeModel.ts"; -/** - * Maps ACP session configuration and session modes onto T3's provider option - * descriptors so the existing model-options UI can drive them. - * - * Model selection stays on the dedicated model picker: `category: "model"` - * options are excluded here because they already surface as models. Session - * modes advertised through the modes API (rather than a config option) are - * folded into one synthetic descriptor that the ACP adapter routes to - * `session/set_mode`. - */ - -/** Synthetic descriptor ID for agents that expose modes outside config options. */ export const ACP_SESSION_MODE_OPTION_ID = "_t3/session-mode"; /** - * Choice ID for a select value that is exactly `""`. Agents use it for a real - * choice (GitHub Copilot's default agent), but T3 option IDs must be - * non-empty. The ACP adapter maps it back to `""` before applying a selection. + * Agents use it for a real choice (GitHub Copilot's default agent), but T3 + * option IDs must be non-empty. The ACP adapter maps it back to `""` before + * applying a selection. */ export const ACP_EMPTY_OPTION_VALUE = "_t3/empty"; diff --git a/packages/provider-acp/src/server/stderr.ts b/packages/provider-acp/src/server/stderr.ts index 01ed491d8732..5a3d486b2b88 100644 --- a/packages/provider-acp/src/server/stderr.ts +++ b/packages/provider-acp/src/server/stderr.ts @@ -1,4 +1,3 @@ -/** Last few KiB of ACP child stderr kept for startup / exit diagnostics. */ export const ACP_STDERR_TAIL_MAX_CHARS = 4_096; const PAIRING_URL_PATTERN = /https?:\/\/[^\s]*\/pair#[^\s]*/gi; @@ -13,7 +12,6 @@ export function appendAcpStderrTail(current: string, chunk: string): string { return next.length <= ACP_STDERR_TAIL_MAX_CHARS ? next : next.slice(-ACP_STDERR_TAIL_MAX_CHARS); } -/** Bounded, redacted excerpt safe to put on user-facing adapter errors. */ export function sanitizeAcpStderrExcerpt( text: string, homeDirectory: string, diff --git a/packages/provider-core/src/client.ts b/packages/provider-core/src/client.ts index feb4d0838dd9..b2974dce467a 100644 --- a/packages/provider-core/src/client.ts +++ b/packages/provider-core/src/client.ts @@ -1,28 +1,10 @@ -/** - * The client half of a provider package. - * - * Every provider package exports a `ProviderClientDefinition` from its - * `./client` entry point. Clients render settings, labels, and badges from - * these definitions plus the server's `ServerProvider` snapshots, so a new - * provider needs no per-driver branches in web or mobile. - * - * Definitions are browser- and React Native-safe: plain data and Effect - * schemas, no platform UI imports. - * - * @module provider-core/client - */ import type { ProviderDriverKind } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -/** - * A provider's instance settings schema. Fields carry `providerSettingsForm` - * annotations, which the settings form renders generically. - */ export type ProviderSettingsSchema = { readonly fields: Readonly>; } & Schema.Top; -/** An environment variable the settings UI offers for every instance of a driver. */ export interface ProviderEnvironmentField { readonly name: string; readonly label: string; @@ -31,11 +13,6 @@ export interface ProviderEnvironmentField { readonly sensitive?: boolean; } -/** - * A provider glyph as plain data, so web and mobile can each draw it with - * their own SVG renderer. Colors are per theme; a path without a fill or - * gradient uses the icon's `fill`. - */ export interface ProviderIcon { readonly viewBox: string; readonly fill: { readonly light: string; readonly dark: string }; @@ -43,7 +20,6 @@ export interface ProviderIcon { readonly d: string; readonly fillRule?: "evenodd" | "nonzero"; readonly fill?: { readonly light: string; readonly dark: string }; - /** A brand gradient, the same in both themes. Coordinates are in viewBox units. */ readonly gradient?: ProviderIconGradient; }>; } @@ -59,16 +35,10 @@ export interface ProviderIconGradient { export interface ProviderClientDefinition { readonly driverKind: ProviderDriverKind; readonly label: string; - /** Omitted for drivers whose glyph a client still draws itself. */ readonly icon?: ProviderIcon; readonly settingsSchema: ProviderSettingsSchema; readonly environmentFields?: ReadonlyArray; - /** Whether the driver has a default instance backed by legacy `providers.` settings. */ readonly hasDefaultInstance?: boolean; - /** - * Short warning badge beside every instance of the driver, such as "Beta". - * It marks the driver kind, so built-in and custom instances show it alike. - */ readonly badgeLabel?: string; } @@ -78,10 +48,8 @@ export function defineProviderClient; - /** `undefined` for drivers this client does not ship, such as a fork's driver. */ readonly get: ( driverKind: ProviderDriverKind | undefined, ) => ProviderClientDefinition | undefined; diff --git a/packages/provider-core/src/server/IdAllocator.ts b/packages/provider-core/src/server/IdAllocator.ts index 3382ca49a236..04c1e0396ada 100644 --- a/packages/provider-core/src/server/IdAllocator.ts +++ b/packages/provider-core/src/server/IdAllocator.ts @@ -251,7 +251,6 @@ const randomId = ), ); -/** Deterministic id derivations; pure, so callers that only derive need no allocator. */ export const derive: IdAllocatorV2Derive = { providerSession: (input) => ProviderSessionId.make( diff --git a/packages/provider-core/src/server/McpProviderSessions.ts b/packages/provider-core/src/server/McpProviderSessions.ts index 8f22c33d2149..7022328420af 100644 --- a/packages/provider-core/src/server/McpProviderSessions.ts +++ b/packages/provider-core/src/server/McpProviderSessions.ts @@ -1,10 +1,3 @@ -/** - * The T3 MCP credential each thread's provider session should expose to its - * agent. The session manager writes it before a session starts; adapters read - * it when they build provider launch options. - * - * @module provider-core/server/McpProviderSessions - */ import type { ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; diff --git a/packages/provider-core/src/server/ModelCatalog.test.ts b/packages/provider-core/src/server/ModelCatalog.test.ts index afa041196a86..b257fc5a8a1b 100644 --- a/packages/provider-core/src/server/ModelCatalog.test.ts +++ b/packages/provider-core/src/server/ModelCatalog.test.ts @@ -3,10 +3,6 @@ import type { ServerProviderModel } from "@t3tools/contracts"; import * as ModelCatalog from "./ModelCatalog.ts"; -/** - * Covers catalog semantics with synthetic models. Real model data lives in the - * server's model-manifest.json and is not asserted here. - */ const model = (overrides: Partial): ServerProviderModel => ({ slug: "model-test", name: "Model Test", @@ -27,7 +23,6 @@ const catalogModel = ( profileAdapter: undefined, }); -/** Strips a vendor prefix, the way a driver maps qualified slugs to catalog families. */ const prefixFamily: ModelCatalog.CatalogMatching = { family: (slug) => slug.replace(/^vendor\./, ""), }; @@ -40,10 +35,8 @@ describe("classifyCatalogModels", () => { }; const models = [ model({ slug: "current-a" }), - // Stale flag from a previous classification pass must be cleared. model({ slug: "current-b", isLegacy: true }), model({ slug: "old-model" }), - // Custom models are user-defined and never reclassified. model({ slug: "my-own-model", isCustom: true, isLegacy: true }), ]; assert.deepStrictEqual( @@ -100,7 +93,6 @@ describe("applyCatalogDefault", () => { model({ slug: "model-old" }), model({ slug: "model-new", isDefault: true, aliases: ["provider-default"] }), ]); - // The account does not offer the catalog default: keep the runtime's choice. assert.deepStrictEqual( ModelCatalog.applyCatalogDefault(models.slice(0, 1), catalog), models.slice(0, 1), diff --git a/packages/provider-core/src/server/ModelCatalog.ts b/packages/provider-core/src/server/ModelCatalog.ts index d241058258c8..a70a381bbdb5 100644 --- a/packages/provider-core/src/server/ModelCatalog.ts +++ b/packages/provider-core/src/server/ModelCatalog.ts @@ -1,13 +1,3 @@ -/** - * ModelCatalog β€” the model metadata a provider reads from T3's model manifest. - * - * The server owns the manifest itself (bundle, remote refresh, disk cache, - * compatibility policies). A provider only sees its own catalog entry, so a - * provider package never depends on the manifest file or how it is fetched. - * Reads never wait on the network. - * - * @module provider-core/server/ModelCatalog - */ import type { ModelCapabilities, ProviderDriverKind, @@ -18,7 +8,6 @@ import type * as Effect from "effect/Effect"; import type { ServerProviderDraft } from "./snapshotProbe.ts"; -/** One model entry in a provider's manifest catalog. */ export interface ProviderCatalogModel { readonly slug: string; readonly name: string; @@ -28,13 +17,10 @@ export interface ProviderCatalogModel { readonly status: "current" | "legacy"; readonly badge?: "new"; readonly capabilities: ModelCapabilities | null; - /** Provider-owned metadata for this model; the provider decodes it. */ readonly adapter: unknown; - /** Provider-owned metadata for the model's capability profile. */ readonly profileAdapter: unknown; } -/** A provider's catalog entry: its models and the chat default. */ export interface ProviderCatalog { readonly models: ReadonlyArray; readonly defaultChatModel: string | undefined; @@ -43,24 +29,14 @@ export interface ProviderCatalog { export class ModelCatalog extends Context.Service< ModelCatalog, { - /** - * The driver's catalog from the manifest in memory (disk cache or bundle), - * or `undefined` when the manifest has no entry for it. Never fetches. - */ readonly current: ( driverKind: ProviderDriverKind, ) => Effect.Effect; - /** The same catalog from the bundled manifest; for fallbacks when remote data is invalid. */ readonly bundled: (driverKind: ProviderDriverKind) => ProviderCatalog | undefined; - /** - * Starts a TTL-gated remote refresh in the background. Provider checks call - * this; it never fails and never blocks the check. - */ readonly refreshInBackground: Effect.Effect; } >()("@t3tools/provider-core/server/ModelCatalog") {} -/** A catalog model as the snapshot model it presents. */ export const catalogServerModel = ( catalog: ProviderCatalog, entry: ProviderCatalogModel, @@ -77,22 +53,13 @@ export const catalogServerModel = ( capabilities: entry.capabilities, }); -/** How a driver matches its discovered slugs to catalog entries. */ export interface CatalogMatching { - /** - * Maps a slug to the family the catalog names it by, such as a dated Codex - * build to its base model. Defaults to the slug itself. - */ readonly family?: (slug: string) => string; } const familyOf = (matching: CatalogMatching | undefined, slug: string) => matching?.family?.(slug) ?? slug; -/** - * Reclassifies built-in models as legacy or current from the catalog. Custom - * models are user-defined and never reclassified. - */ export function classifyCatalogModels( models: ReadonlyArray, catalog: ProviderCatalog | undefined, @@ -110,13 +77,6 @@ export function classifyCatalogModels( }); } -/** - * Moves `isDefault` to the catalog's chat default when the catalog names one - * the models include. Providers that learn their default from the runtime can - * be overridden here without a release. Aliases that pointed at the old - * default move with the flag so the shared "provider default" alias keeps - * resolving. - */ export function applyCatalogDefault( models: ReadonlyArray, catalog: ProviderCatalog | undefined, @@ -149,11 +109,6 @@ export function applyCatalogDefault( }); } -/** - * Reclassifies a snapshot draft's built-in models and moves its default from - * the catalog. Drivers that announce models a newer CLI unlocks add - * `updateRequiredModels` themselves; this clears a stale list. - */ export function applyModelCatalog( draft: ServerProviderDraft, catalog: ProviderCatalog | undefined, diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 4d7cc3136c62..1cc66c77f9cd 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -91,12 +91,6 @@ export const ProviderAdapterV2Event = Schema.Union([ type: Schema.Literal("provider_thread.updated"), driver: ProviderDriverKind, providerThread: OrchestrationV2ProviderThread, - /** - * Set when the provider rewound its own conversation outside T3, for - * example a Pi extension moving the session-tree leaf back. Lists every - * native turn id still on the active branch; the ingestor rolls back - * earlier runs whose turns fell off it. - */ retainedNativeTurnIds: Schema.optional(Schema.Array(Schema.String)), }), Schema.Struct({ @@ -399,15 +393,12 @@ export interface ProviderAdapterV2OpenSessionInput { readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; readonly resumeFromSession?: OrchestrationV2ProviderSession; - /** Native thread to activate while an eager adapter opens its provider process. */ readonly initialNativeThreadId?: string; - /** Preserves provider item identity across eager activation of a persisted thread. */ readonly initialProviderItemIdentityVersion?: 2; } export interface ProviderAdapterV2EnsureThreadInput { readonly threadId: ThreadId; - /** The app thread's title, for providers that name their native sessions. */ readonly title?: string; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; @@ -420,7 +411,6 @@ export interface ProviderAdapterV2TurnInput { readonly threadId: ThreadId; readonly runId: RunId; readonly runOrdinal: number; - /** Whether the current native session has an accepted turn; omitted when unknown. */ readonly nativeThreadHasTurns?: boolean; readonly providerTurnOrdinal: number; readonly restartContinuationOfRunId?: RunId; @@ -430,11 +420,6 @@ export interface ProviderAdapterV2TurnInput { readonly message: ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; - /** - * What the thread's MCP Apps want the agent to know (`ui/update-model-context`), - * latest per app, keyed stably per app. Adapters that host apps deliver it as - * application context; others never receive any. - */ readonly appContext?: ReadonlyArray<{ readonly key: string; readonly text: string }>; } @@ -449,7 +434,6 @@ export interface ProviderAdapterV2SteerInput { export interface ProviderAdapterV2InterruptInput { readonly providerThread: OrchestrationV2ProviderThread; readonly providerTurnId: ProviderTurnId; - /** When true, the next `startTurn` may respawn the provider runtime (Grok Stop recovery). */ readonly requestRuntimeRestart?: boolean; } @@ -511,14 +495,12 @@ export interface ProviderAdapterV2HistoricalContext { readonly context: string; } -/** An MCP tool as the provider's MCP client lists it. */ export interface ProviderAdapterV2McpTool { readonly name: string; readonly _meta?: unknown; readonly annotations?: unknown; } -/** MCP `CallToolResult` / `ReadResourceResult` shapes, passed through unchanged. */ export interface ProviderAdapterV2McpCallToolResult { readonly content: ReadonlyArray; readonly structuredContent?: unknown; @@ -544,7 +526,6 @@ export interface ProviderAdapterV2McpApps { }) => Effect.Effect<{ readonly contents: ReadonlyArray }, ProviderAdapterV2Error>; } -/** A terminal task outcome observed by the provider, not inferred from a stop timeout. */ export type ProviderAdapterV2SubagentOutcome = { readonly status: "completed" | "failed" | "cancelled"; readonly completedAt: NonNullable; @@ -563,41 +544,26 @@ export interface ProviderAdapterV2SessionRuntime { * Adapter runtimes may omit this and expose only their single-consumer event stream. */ readonly subscribeEvents?: Effect.Effect; - /** - * Adapters whose native runtime can hold pending work outside an active - * turn (for example Claude background tasks and their wake turns) report it - * here so the session manager defers idle release while it is pending. - */ readonly hasPendingBackgroundWork?: Effect.Effect; /** - * Per-provider-thread pending work for root-run ingestion stop gates. When - * present, RunExecutionService uses only this probe (never the session-wide - * hasPendingBackgroundWork) so sibling native threads cannot pin an - * unrelated root subscription open. + * When present, RunExecutionService uses only this probe (never the + * session-wide hasPendingBackgroundWork) so sibling native threads cannot pin + * an unrelated root subscription open. */ readonly hasPendingBackgroundWorkForThread?: ( providerThread: OrchestrationV2ProviderThread, ) => Effect.Effect; /** * Set when every background task this session lists on its provider threads - * runs inside the session's own process and ends when it closes. The session - * manager then clears those tasks when it releases the session (#15239). - * Leave unset when a task can outlive the session, for example on a shared - * server, so its thread keeps showing Waiting. + * runs inside the session's own process and ends when it closes. Leave unset + * when a task can outlive the session, for example on a shared server, so its + * thread keeps showing Waiting. */ readonly backgroundTasksEndWithProcess?: true; - /** - * Capacity for the requested model/options, independent of native thread usage. - * `cwd` is the thread's working directory, for providers whose project config - * can change a model's limits. - */ readonly getModelContextWindow?: ( modelSelection: ModelSelection, cwd?: string | null, ) => number | undefined; - /** Whether an option-only change preserves measured native usage and capacity. - * Compaction thresholds are still discarded. Unknown transitions invalidate usage. - */ readonly canReuseContextUsage?: (previous: ModelSelection, next: ModelSelection) => boolean; readonly ensureThread: ( input: ProviderAdapterV2EnsureThreadInput, @@ -630,18 +596,12 @@ export interface ProviderAdapterV2SessionRuntime { readonly interruptTurn: ( input: ProviderAdapterV2InterruptInput, ) => Effect.Effect; - /** Stops one native child through its owning runtime; the owner keeps running. */ readonly stopSubagent?: (input: { readonly providerThread: OrchestrationV2ProviderThread; readonly nativeTaskId: string; /** A delayed Stop must not interrupt a task resumed after this request. */ readonly requestedAt?: NonNullable; }) => Effect.Effect; - /** - * Lets a runtime shared by several app threads unload one provider thread's - * native state (and its MCP servers) when that app thread detaches, while - * the runtime keeps serving the others. A later resume reloads it. - */ readonly unloadThread?: (input: { readonly providerThread: OrchestrationV2ProviderThread; }) => Effect.Effect; @@ -651,18 +611,13 @@ export interface ProviderAdapterV2SessionRuntime { readonly readThreadSnapshot: ( input: ProviderAdapterV2ReadThreadSnapshotInput, ) => Effect.Effect; - /** - * Providers that accept product feedback for a thread (#7949, Codex β†’ OpenAI) - * expose it here; absent means the driver has no feedback channel. - */ readonly uploadFeedback?: (input: { readonly providerThread: OrchestrationV2ProviderThread; readonly reason?: string; }) => Effect.Effect<{ readonly feedbackId: string }, ProviderAdapterV2Error>; /** - * MCP Apps host operations through the provider's own MCP client, for - * drivers whose protocol exposes them (Codex). Absent means apps from this - * provider are not interactive and their tool calls stay plain tool rows. + * Absent means apps from this provider are not interactive and their tool + * calls stay plain tool rows. */ readonly mcpApps?: ProviderAdapterV2McpApps; readonly rollbackThread: ( diff --git a/packages/provider-core/src/server/ProviderContinuationRequests.ts b/packages/provider-core/src/server/ProviderContinuationRequests.ts index 5d08f22c13fb..ff14d5325db9 100644 --- a/packages/provider-core/src/server/ProviderContinuationRequests.ts +++ b/packages/provider-core/src/server/ProviderContinuationRequests.ts @@ -18,49 +18,27 @@ export interface ProviderContinuationRequest { readonly driver: ProviderDriverKind; readonly detail: string | null; readonly notification?: OrchestrationV2Notification; - /** - * Durable ownership for an app-owned delegated-task completion delivery. - * The continuation worker re-reads the cohort before dispatching so a later - * task can join a queued wake and a stopped or acknowledged cohort is dropped. - */ readonly delegatedCompletion?: { readonly parentRunId: RunId; readonly generation: number; readonly messageId: MessageId; }; /** - * How the continuation turn gets its content. - * - * `adapter_buffered` (default) is the provider-native wake: the adapter has - * already buffered the CLI's wake output, and the dispatched message only - * triggers ingestion. `ClaudeAdapterV2` deliberately discards the message - * text on that path. - * - * `message_text` is for app-owned work with no buffered provider output, such - * as a delegated child finishing. The text is the entire wake, so it must - * reach the provider as a real prompt. + * `adapter_buffered` (default) is the provider-native wake: the adapter has already buffered the + * CLI's wake output, and the dispatched message only triggers ingestion. `message_text` is for + * app-owned work with no buffered provider output, such as a delegated child finishing. */ readonly delivery?: "adapter_buffered" | "message_text"; - /** - * The message the continuation run starts from, when the adapter picks it: - * its turn input carries this id, so the adapter can tell which of its held - * wakes a run was dispatched for. Otherwise the service allocates one. - */ readonly messageId?: MessageId; readonly dispatchIfCurrent?: ( effect: Effect.Effect, ) => Effect.Effect, E, R>; - /** Clears a pending offer that the continuation worker intentionally drops. */ readonly clearIfCurrent?: () => Effect.Effect; } /** - * Adapters offer a continuation request when provider-native work completes - * outside an active turn (for example a Claude background task wake turn) so - * the orchestrator can start a run that ingests it. The default reference - * drops requests, keeping adapter construction dependency-free in tests; the - * live layer must be shared with the ProviderContinuationService worker that - * drains it. + * The default reference drops requests, keeping adapter construction dependency-free in tests; the + * live layer must be shared with the ProviderContinuationService worker that drains it. */ export class ProviderContinuationRequests extends Context.Reference<{ readonly offer: (request: ProviderContinuationRequest) => Effect.Effect; diff --git a/packages/provider-core/src/server/ProviderEventLoggers.ts b/packages/provider-core/src/server/ProviderEventLoggers.ts index 6341bd96af1b..d79a6804d0e9 100644 --- a/packages/provider-core/src/server/ProviderEventLoggers.ts +++ b/packages/provider-core/src/server/ProviderEventLoggers.ts @@ -1,10 +1,3 @@ -/** - * Provider event logging as the server offers it to drivers: one stream for - * the provider's native protocol frames and one for canonical orchestration - * events. Either logger is absent when diagnostics logging could not start. - * - * @module provider-core/server/ProviderEventLoggers - */ import type { ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -23,7 +16,6 @@ export class ProviderEventLoggers extends Context.Service< } >()("@t3tools/provider-core/server/ProviderEventLoggers") {} -/** Logging turned off: for tests and boot layers that skip diagnostics. */ export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { native: undefined, canonical: undefined, diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index 858a7c1e22d1..ad78126cc00a 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -1,14 +1,3 @@ -/** - * ProviderHost β€” what a provider package may ask of the server it runs in. - * - * Drivers and adapters run inside a T3 server but must not import it. The - * server provides this one service; everything a provider needs from its - * environment (directories, server settings, background demand, attachment - * files) goes through it, so a provider package depends only on - * `@t3tools/provider-core` and its own protocol code. - * - * @module provider-core/server/host - */ import type { BackgroundScope, ChatAttachment, @@ -24,19 +13,14 @@ import type * as Stream from "effect/Stream"; import type { ProviderCredentialError } from "./errors.ts"; export interface ProviderHostPaths { - /** Directory the server was started in; the fallback cwd for sessions without a project. */ readonly cwd: string; - /** T3 home. */ readonly baseDir: string; - /** Server-owned state directory under the T3 home. */ readonly stateDir: string; - /** Scratch space for provider probes and generated helper files. */ readonly providerStatusCacheDir: string; /** Where chat attachments are stored; agents that sandbox file reads must be granted it. */ readonly attachmentsDir: string; } -/** One opaque credential blob in the server's secret store. Only the provider decodes it. */ export interface ProviderCredentials { /** Instances whose bindings are equal share these credentials. */ readonly binding: { readonly owner: "t3"; readonly key: string }; @@ -55,19 +39,16 @@ export class ProviderHost extends Context.Service< readonly withSnapshot: ( use: (settings: ServerSettings) => Effect.Effect, ) => Effect.Effect; - /** Every settings change after subscription, starting with the next one. */ readonly changes: Stream.Stream; /** Changes buffered from the moment the scoped subscription is acquired. */ readonly subscribe: Effect.Effect, never, Scope.Scope>; }; - /** Whether background work for `scope` should run now (client demand and host power). */ readonly shouldRunBackgroundWork: (scope: BackgroundScope) => Effect.Effect; /** * Absolute path of a stored chat attachment, or `null` when its id does not * resolve inside the attachments directory. */ readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; - /** The credentials stored for `bindingId`, usually an instance id, under a provider-chosen namespace. */ readonly credentials: ( namespace: string, bindingId: string, diff --git a/packages/provider-core/src/server/ProviderLatestVersions.ts b/packages/provider-core/src/server/ProviderLatestVersions.ts index b26c0eac37ec..57b7129e4fa8 100644 --- a/packages/provider-core/src/server/ProviderLatestVersions.ts +++ b/packages/provider-core/src/server/ProviderLatestVersions.ts @@ -1,11 +1,3 @@ -/** - * Memoizes "latest available version" lookups (npm registry, release - * channels) for the version advisory. One instance is shared by every driver - * and the maintenance runner, so a provider update can invalidate the entry - * the next snapshot refresh reads. - * - * @module provider-core/server/ProviderLatestVersions - */ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -13,9 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; -/** A found version stays fresh this long. */ const FOUND_TTL = Duration.hours(1); -/** A failed lookup is retried sooner, so a flaky network does not hide an update for an hour. */ const MISSING_TTL = Duration.minutes(1); interface Entry { @@ -26,10 +16,6 @@ interface Entry { export class ProviderLatestVersions extends Context.Service< ProviderLatestVersions, { - /** - * Returns the cached version for `key`, running `lookup` when there is no - * fresh entry or `fresh` is set. `null` means the lookup found nothing. - */ readonly cached: ( key: string, lookup: Effect.Effect, @@ -39,7 +25,6 @@ export class ProviderLatestVersions extends Context.Service< } >()("@t3tools/provider-core/server/ProviderLatestVersions") {} -/** Starts from `seed`, for tests that need a cached entry. */ export const make = (seed: Iterable = []) => Effect.gen(function* () { const entries = yield* Ref.make( diff --git a/packages/provider-core/src/server/attachmentPrompt.test.ts b/packages/provider-core/src/server/attachmentPrompt.test.ts index e31c8417f202..a845b1aace1e 100644 --- a/packages/provider-core/src/server/attachmentPrompt.test.ts +++ b/packages/provider-core/src/server/attachmentPrompt.test.ts @@ -26,7 +26,6 @@ const image = ChatImageAttachment.make({ sizeBytes: 456, }); -// Stands in for the host's resolver, which maps stored attachments to their files. const resolveAttachmentPath = (attachment: { readonly id: string; readonly name: string }) => `/attachments/${attachment.id}${attachment.name.slice(attachment.name.lastIndexOf("."))}`; diff --git a/packages/provider-core/src/server/attachmentPrompt.ts b/packages/provider-core/src/server/attachmentPrompt.ts index 1b09911f50d8..c7c5e58a5a66 100644 --- a/packages/provider-core/src/server/attachmentPrompt.ts +++ b/packages/provider-core/src/server/attachmentPrompt.ts @@ -159,7 +159,6 @@ export function isProviderNativeImageAttachment(attachment: ChatAttachment): boo export function providerMessageTextWithAttachmentPaths(input: { readonly text: string; readonly attachments: ReadonlyArray; - /** Absolute path of a stored attachment, such as `ProviderHost.resolveAttachmentPath`. */ readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; }): string { let text = input.text; diff --git a/packages/provider-core/src/server/auth.ts b/packages/provider-core/src/server/auth.ts index 9954249b6796..25f30b8fd0c4 100644 --- a/packages/provider-core/src/server/auth.ts +++ b/packages/provider-core/src/server/auth.ts @@ -10,7 +10,6 @@ import type * as Scope from "effect/Scope"; import type * as Stream from "effect/Stream"; export interface ProviderAuthController { - /** Equal keys mean these instances share credentials on this environment. */ readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; readonly reconnectProfile?: ( methodId: string, @@ -48,7 +47,6 @@ export interface ProviderAuthController { ownerSessionId: string, input: ProviderAuthRespondInput, ) => Effect.Effect; - /** The controller closes process admission before it stops routed sessions. */ readonly logout: ( stopSessions: Effect.Effect, ) => Effect.Effect; diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index 1801fa316901..d64b1f7dcf87 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -1,25 +1,6 @@ /** - * ProviderDriver / ProviderInstance β€” driver SPI as plain values. - * - * `ProviderDriver` is a record, not a Context.Service. The thing it produces - * (`ProviderInstance`) is also a record of captured closures - * (`snapshot`, `orchestrationAdapter`, `textGeneration`), an id, and a driver kind. There - * are intentionally no per-driver Context tags because tags are + * There are intentionally no per-driver Context tags because tags are * singleton-per-runtime and we need many instances of the same driver. - * - * The only Effect service involved is `ProviderInstanceRegistry`, which - * owns the live `Map` and is itself a - * singleton. - * - * Driver factories are functions of `(typed config, env)` where: - * - `typed config` is decoded once by the registry via `configSchema`, - * so drivers never deal with raw `unknown`. - * - `env` flows through Effect's R channel. Each driver declares the - * subset of infrastructure services it needs (FileSystem, - * ChildProcessSpawner, …) on its `create` return type; the registry - * layer's R is the union of those, and the runtime layer satisfies it. - * - * @module provider-core/server/driver */ import type { ProviderConsumeResetCreditOutcome, @@ -44,41 +25,20 @@ import type { ManagedServerProvider } from "./snapshot.ts"; import type { ProviderTextGeneration } from "./textGeneration.ts"; import type { ProviderUsageReader } from "./usage.ts"; -/** - * Static metadata advertised by a driver. Used for default presentation - * and (later) settings UI. Doesn't need to be Effect-typed because nothing - * about it is dynamic β€” drivers are registered at startup. - */ export interface ProviderDriverMetadata { - /** Human-readable name for the driver itself (e.g. "Codex"). */ readonly displayName: string; /** - * Whether the driver may be instantiated more than once concurrently. * Defaults to `true`. Set to `false` for drivers that wrap a global * resource (e.g. a single desktop app socket) β€” the registry then * rejects multi-instance configurations with a clear error. */ readonly supportsMultipleInstances?: boolean; - /** - * Whether the server runs an instance at `defaultInstanceIdForDriver(kind)` - * even when settings have no entry for it. Defaults to `true`; drivers that - * need user input before they can run (such as the ACP Registry) opt out. - */ readonly hasDefaultInstance?: boolean; } export type ProviderWorkspaceSnapshot = ServerProvider & Pick; -/** - * One materialized provider instance. Held by the registry, looked up by - * `instanceId`, torn down by closing the scope it was created in. - * - * The three "shape" fields are captured closures owned by this instance β€” - * stopping one instance cannot affect another, and starting a second - * instance of the same driver does not reach into the first instance's - * state. - */ export interface ProviderInstance { readonly instanceId: ProviderInstanceId; readonly driverKind: ProviderDriverKind; @@ -91,13 +51,7 @@ export interface ProviderInstance { cwd: string, ) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; - /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ readonly invalidateCaches?: Effect.Effect; - /** - * Redeem one banked rate-limit reset credit on the signed-in account, then - * re-probe so the snapshot reflects the cleared windows. Account-level, - * not thread-level, which is why it lives here rather than on the adapter. - */ readonly consumeResetCredit?: () => Effect.Effect< ProviderConsumeResetCreditOutcome, ProviderDriverError @@ -145,12 +99,6 @@ export function defaultProviderContinuationIdentity(input: { }; } -/** - * Inputs the registry passes to a driver's `create` function. - * - * `config` is the typed payload β€” already decoded by the registry through - * `driver.configSchema`. Drivers never decode their own raw envelope. - */ export interface ProviderDriverCreateInput { readonly instanceId: ProviderInstanceId; readonly displayName: string | undefined; @@ -161,72 +109,31 @@ export interface ProviderDriverCreateInput { } /** - * Driver SPI β€” registered as a plain value, not a Layer. - * - * `Config` is whatever the driver decoded from - * `ProviderInstanceConfig.config`. `R` is the union of infrastructure - * services the driver depends on; the registry layer aggregates `R` across - * all registered drivers and the runtime supplies them. - * - * `create` is responsible for *all* per-instance state β€” process handles, - * pubsub topics, refs, file watchers β€” and must release them when its - * scope closes. Two calls to `create` with different `instanceId` / - * `config` MUST yield instances with no shared mutable state. + * Two calls to `create` with different `instanceId` / `config` MUST yield + * instances with no shared mutable state. */ export interface ProviderDriver { readonly driverKind: ProviderDriverKind; readonly metadata: ProviderDriverMetadata; /** - * Decoder for the opaque `ProviderInstanceConfig.config` envelope. The - * registry runs this exactly once per (re)load of an instance; a decode - * failure is surfaced as `ProviderDriverError` and downgraded to an - * unavailable shadow snapshot. - * - * The `Encoded` parameter is intentionally left as `unknown` (not - * `Config`) so schemas with `withDecodingDefault` / transformations β€” where - * the encoded shape differs from the decoded shape β€” satisfy the SPI - * without casts. The registry only ever decodes `unknown` envelopes here, - * so the precise encoded type is irrelevant at this boundary. - * * Using `Codec` rather than `Schema` pins `DecodingServices = never` β€” if * we used `Schema`, the erased `any` in `AnyProviderDriver` would * widen `DecodingServices` to `unknown` and poison the R channel of every * caller of `decodeUnknownEffect`. */ readonly configSchema: Schema.Codec; - /** - * Config used when an instance has no config blob, such as the default - * instance of a driver the user never configured. - */ readonly defaultConfig: () => Config; /** - * Materialize one instance. The returned effect runs in a scope owned - * by the registry; closing that scope releases every resource the - * driver opened. Failures become unavailable shadow snapshots β€” the - * driver MUST NOT throw defects. + * Failures become unavailable shadow snapshots β€” the driver MUST NOT throw + * defects. */ readonly create: ( input: ProviderDriverCreateInput, ) => Effect.Effect; - /** - * Where the usage page reads this provider's token history, when it keeps - * one. `UsageR` is what the reader needs, usually far less than `R`. - */ readonly usage?: ProviderUsageReader; } -/** - * Heterogeneous-array convenience: the registry stores drivers as - * `ReadonlyArray>` where `R` is the union of all - * registered drivers' env requirements. - */ -// `any` here intentionally erases the per-driver Config; the registry -// already decoded it before invoking `create`, so downstream code never -// needs the original `Config` type. Using `unknown` instead would force -// `create` callers into casts since `unknown` is not assignable to a -// concrete `Config` from inside the driver body. export type AnyProviderDriver = ProviderDriver; -/** The services a driver's usage reader needs. */ export type ProviderUsageReaderEnv = Driver extends ProviderDriver ? UsageR : never; diff --git a/packages/provider-core/src/server/errors.ts b/packages/provider-core/src/server/errors.ts index 46ca6e6541fe..0e3e231ad7a8 100644 --- a/packages/provider-core/src/server/errors.ts +++ b/packages/provider-core/src/server/errors.ts @@ -1,11 +1,6 @@ import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -/** - * ProviderDriverError - A driver `create` call failed before producing an - * instance. Surfaced to the registry, which marks the offending entry as - * an "unavailable" shadow snapshot rather than crashing the server. - */ export class ProviderDriverError extends Schema.TaggedError()( "ProviderDriverError", { @@ -20,7 +15,6 @@ export class ProviderDriverError extends Schema.TaggedError } } -/** Reading or writing a provider's stored credentials failed. */ export class ProviderCredentialError extends Schema.TaggedError()( "ProviderCredentialError", { diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index 45c90822aaf3..8921df5b5e8b 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -16,7 +16,6 @@ import * as Cause from "effect/Cause"; import * as IdAllocator from "./IdAllocator.ts"; import * as Schema from "effect/Schema"; -/** Raised when portable history does not fit the target provider's context window. */ export class ContextHandoffBudgetError extends Schema.TaggedError()( "ContextHandoffBudgetError", {}, @@ -31,7 +30,6 @@ export const MAX_PROVIDER_FAILURE_CODE_LENGTH = 128; const DEFAULT_PROVIDER_FAILURE_MESSAGE = "Provider turn failed."; -/** Translate known categories without exposing arbitrary provider defect text. */ function causeMessage(cause: unknown): string | undefined { const seen = new Set(); let message: string | undefined; @@ -116,7 +114,6 @@ function replaceUnsafeControlCharacters(value: string): string { return sanitized.join(""); } -/** Removes common credential forms before provider text crosses a transport boundary. */ function redactProviderFailureText(value: string): string { return replaceUnsafeControlCharacters(value) .replace(/\bhttps?:\/\/[^\s<>"']+/giu, redactUrl) diff --git a/packages/provider-core/src/server/handoffBudget.ts b/packages/provider-core/src/server/handoffBudget.ts index 042745f68826..07f7eb4a4b20 100644 --- a/packages/provider-core/src/server/handoffBudget.ts +++ b/packages/provider-core/src/server/handoffBudget.ts @@ -66,10 +66,10 @@ export function latestNativeContextUsage( } /** - * Occupancy of the native transcript survives a model or option change. - * The previous model's window and compaction threshold do not: a byte-length + * Occupancy of the native transcript survives a model or option change. The + * previous model's window and compaction threshold do not: a byte-length * stand-in for the missing measurement overstates the transcript and refuses - * switches that still fit. A new native thread has no occupancy to carry. + * switches that still fit. */ export function contextUsageForHandoff(input: { readonly sameNativeThread: boolean; @@ -92,12 +92,11 @@ export function contextUsageForHandoff(input: { } export function attachmentTokenAllowance(attachments: ReadonlyArray): number { - // Encoded image bytes are not model tokens. Without dimensions/detail metadata, - // reserve 8k tokens per image, above typical resized Codex/Claude image costs. - // This is a fallback estimate, not a bound for original-resolution/custom models. + // Without dimensions/detail metadata, reserve 8k tokens per image, above typical + // resized Codex/Claude image costs. This is a fallback estimate, not a bound for + // original-resolution/custom models. // https://developers.openai.com/api/docs/guides/image-cost-calculator // https://platform.claude.com/docs/en/build-with-claude/vision - // Other attachments are path references; reserve space for their descriptors. return attachments.reduce( (sum, attachment) => sum + (attachment.type === "image" ? 8_192 : 4_096), 0, @@ -106,8 +105,7 @@ export function attachmentTokenAllowance(attachments: ReadonlyArray, context: string, @@ -253,8 +249,6 @@ export function selectHistory(input: { selected.add(index); remaining -= cost; }; - // Prioritize the latest request and partial answer, then original constraints. - // Oversized items are omitted whole and remain available through thread_read. tryAdd(messages.findLastIndex((message) => message.role === "user")); tryAdd(messages.findLastIndex((message) => message.role === "assistant")); tryAdd(messages.findIndex((message) => message.role === "user")); diff --git a/packages/provider-core/src/server/instanceIdentity.ts b/packages/provider-core/src/server/instanceIdentity.ts index 757e6457786f..5c54617f9c51 100644 --- a/packages/provider-core/src/server/instanceIdentity.ts +++ b/packages/provider-core/src/server/instanceIdentity.ts @@ -3,13 +3,6 @@ import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import type { ServerProviderDraft } from "@t3tools/provider-core/server/snapshotProbe"; -/** - * Stamp instance identity onto a `ServerProvider` snapshot produced by the - * driver-kind-only snapshot helpers. Every driver builds its snapshot without - * knowing its own instance, so it pipes the draft through this stamper before - * publishing. Once `buildServerProvider` in `providerSnapshot.ts` is widened to - * accept `instanceId`/`driver`, this wrapper disappears. - */ export const withInstanceIdentity = (input: { readonly instanceId: ProviderInstance["instanceId"]; diff --git a/packages/provider-core/src/server/maintenance.ts b/packages/provider-core/src/server/maintenance.ts index 62f9a2ab63a6..b3b09412af3e 100644 --- a/packages/provider-core/src/server/maintenance.ts +++ b/packages/provider-core/src/server/maintenance.ts @@ -5,13 +5,11 @@ export interface ProviderMaintenanceCapabilities { readonly packageName: string | null; readonly update: ProviderMaintenanceCommandAction | null; /** - * Latest version reported by the installer that owns the executable. * `undefined` means the installer has no channel of its own and the npm * registry entry for `packageName` is authoritative; `null` means the * installer was asked and did not know. */ readonly latestVersion?: string | null; - /** Compare native release revisions when the provider does not use plain semver. */ readonly compareVersions?: (current: string, latest: string) => number; } @@ -21,7 +19,6 @@ export interface ProviderMaintenanceCommandAction { readonly args: ReadonlyArray; readonly lockKey: string; /** - * Extra environment for the spawned updater, on top of the server's own. * A native updater finds its install through the same variables the * provider runs with (e.g. `CODEX_HOME`), so an instance with a custom home * must update that home and not the default one. diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index c0e3a71b1451..37e1d10ca3af 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -85,7 +85,6 @@ function writeExecutable(path: string) { NodeFS.chmodSync(path, 0o755); } -/** Symlink `/bin/` into a package entry point, like npm/pnpm do. */ function linkIntoPackage(tempDir: string, name: string, packageSegments: ReadonlyArray) { const target = NodePath.join(tempDir, ...packageSegments, "bin", `${name}.js`); writeExecutable(target); @@ -284,7 +283,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "@openai/codex", ), ).toBe("/usr/local"); - // A copy nested inside another package is not a global install. expect( npmGlobalPrefixFromCommandPath( "/usr/local/lib/node_modules/other/node_modules/@openai/codex/bin/codex.js", @@ -297,7 +295,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { "@openai/codex", ), ).toBe("/"); - // Neither is a project-local dependency. expect( npmGlobalPrefixFromCommandPath( "/work/app/node_modules/@openai/codex/bin/codex.js", @@ -369,7 +366,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { args: ["install", "-g", "--prefix", tempDir, expect.any(String), expect.any(String)], }); - // The same layout on POSIX is a project checkout, not a global install. const script = NodePath.join(tempDir, "package-tool"); writeExecutable(script); const posix = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { @@ -471,8 +467,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - // Regression for #9850: an explicit native path outside PATH, with spaces, - // must be what actually gets spawned. it.effect.skipIf(windowsHost)("runs an explicit native updater outside PATH", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-native-update"); @@ -604,8 +598,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }); // A plain /usr/local/bin binary is not evidence of Homebrew (#8832). expect(homebrewOwnershipFromCommandPath("/usr/local/bin/codex")).toBeNull(); - // A keg elsewhere reports its prefix so the resolver can reject it against - // `brew --prefix`. expect(homebrewOwnershipFromCommandPath("/srv/Cellar/claude/1.0.0/bin/claude")).toMatchObject({ prefix: "/srv", }); @@ -786,7 +778,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { lockKey: "packageTool-native", }); - // A mise install is pinned in mise's config, so it stays manual. const misePath = NodePath.join(tempDir, "mise", "installs", "package-tool", "1.0.0", "bin"); writeExecutable(NodePath.join(misePath, "package-tool")); const mise = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, { @@ -910,12 +901,8 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - // An advisory read starts a resolution that is still running when the - // user clicks Update; the fresh read must not wait on or reuse it. const advisory = yield* Effect.forkChild(resolve(), { startImmediately: true }); const fresh = yield* Effect.forkChild(resolve({ fresh: true }), { startImmediately: true }); - // The fresh read runs its own resolution, so it finishes while the stale - // one is still waiting. expect(resolutions).toBe(2); expect(fresh.pollUnsafe()).toBeDefined(); yield* Deferred.succeed(releaseStale, undefined); @@ -940,8 +927,6 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { const first = yield* Effect.forkChild(resolve(), { startImmediately: true }); yield* Fiber.interrupt(first); - // The next read resolves again, and a reader that joined it keeps it - // running when the reader that started it goes away. const owner = yield* Effect.forkChild(resolve(), { startImmediately: true }); const waiter = yield* Effect.forkChild(resolve(), { startImmediately: true }); yield* Fiber.interrupt(owner); diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 0f084d47b92b..cd1f7ea58120 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -39,11 +39,6 @@ const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024; const MISE_WRAPPER_MAX_BYTES = 16 * 1_024; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; -/** - * Ownership is re-derived from the executable this often. Installs do not - * move on their own, so this mostly bounds how stale a Homebrew "latest" can - * get; the npm registry check keeps its own cache. - */ const MAINTENANCE_CAPABILITIES_CACHE_TTL = Duration.hours(1); const compactEnv = (input: Record>): NodeJS.ProcessEnv => @@ -65,13 +60,11 @@ const CommandLookupEnvConfig = Config.all({ const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); -/** Where the provider executable was found; every path is absolute. */ export interface ProviderMaintenanceResolutionContext { readonly binaryPath: string; readonly resolvedCommandPath: string; readonly realCommandPath: string; readonly env: NodeJS.ProcessEnv; - /** Host platform; decides how the copyable command quotes the executable. */ readonly platform: NodeJS.Platform; } @@ -89,16 +82,9 @@ export interface ProviderMaintenanceCapabilitiesResolver { export interface PackageManagedProviderMaintenanceDefinition { readonly provider: ProviderDriverKind; readonly npmPackageName: string; - /** - * The provider's own updater (`claude update`, `pi update --self`). It runs - * first for paths its installer owns, and otherwise only when no package - * manager is proven, because these updaters detect their installer too. - */ readonly nativeUpdate: { readonly args: ReadonlyArray; - /** Paths the provider's own installer owns; omit when it has none. */ readonly isCommandPath?: (commandPath: string) => boolean; - /** Environment the native updater needs to target this instance's install. */ readonly env?: NodeJS.ProcessEnv; } | null; } @@ -111,10 +97,6 @@ function nonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -/** - * The copyable command must paste into a shell as-is, so an executable path - * with spaces or quotes is quoted for the host's default shell. - */ function quoteShellWord(word: string, platform: NodeJS.Platform): string { const safeWord = platform === "win32" ? /^[\w./:\\@=-]+$/ : /^[\w./:@=-]+$/; if (safeWord.test(word)) return word; @@ -135,7 +117,6 @@ export function makeProviderMaintenanceCapabilities(input: { readonly updateExecutable: string | null; readonly updateArgs: ReadonlyArray; readonly updateLockKey: string | null; - /** Shown to the user instead of ` `; use for a bare tool name like `brew`. */ readonly updateCommand?: string; readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv; @@ -165,7 +146,6 @@ export function makeProviderMaintenanceCapabilities(input: { }; } -/** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */ export function makeTargetedProviderUpdateAction( capabilities: ProviderMaintenanceCapabilities, version: string, @@ -222,7 +202,6 @@ function isBunGlobalCommandPath(commandPath: string): boolean { } function isYarnGlobalCommandPath(commandPath: string): boolean { - // `~/.config/yarn/global/…` on POSIX, `%LOCALAPPDATA%\Yarn\Data\global\…` on Windows. return /\/yarn\/(?:data\/)?global\/node_modules\//.test(normalizeCommandPath(commandPath)); } @@ -243,13 +222,6 @@ function isPnpmGlobalCommandPath(commandPath: string): boolean { ); } -/** - * The npm global prefix that owns a package, derived from the real path of - * its entry point: `/lib/node_modules//…`. Windows global - * installs have no `lib` segment and are proven by the shim instead (see - * `resolveNpmGlobalPrefix`). A project-local `node_modules` is not a global - * install and yields null. - */ export function npmGlobalPrefixFromCommandPath( realCommandPath: string, packageName: string, @@ -270,22 +242,14 @@ export function npmGlobalPrefixFromCommandPath( return packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex); } -// `/Cellar///…` or `/Caskroom///…`. -// Homebrew always nests a version directory under the keg. const HOMEBREW_KEG_PATTERN = /^(.*)\/(cellar|caskroom)\/([^/]+)\/[^/]+\//i; export interface HomebrewOwnership { readonly kind: "formula" | "cask"; readonly name: string; - /** The Homebrew prefix the keg sits under; must match `brew --prefix`. */ readonly prefix: string; } -/** - * Homebrew looks like the owner when the real path runs through a versioned - * keg or cask. It is only proven once the prefix matches the `brew` that will - * run the upgrade (see `resolvePackageManagedProviderMaintenance`). - */ export function homebrewOwnershipFromCommandPath( realCommandPath: string, ): HomebrewOwnership | null { @@ -329,7 +293,6 @@ export function parseHomebrewLatestVersion( return nonEmptyString(raw); } -/** Run `brew ` and return stdout, or null on failure, timeout, or oversized output. */ const runHomebrew = Effect.fn("runHomebrew")(function* ( brewPath: string, args: ReadonlyArray, @@ -363,13 +326,6 @@ const runHomebrew = Effect.fn("runHomebrew")(function* ( ); }); -/** - * Derive update capabilities from where the executable actually lives. Every - * package-manager branch has evidence that the named tool owns that path, so - * T3 Code never runs a package manager against an install it did not create. - * An unproven install falls back to the provider's own updater, which detects - * its installer itself, and stays manual-only without one. - */ export const resolvePackageManagedProviderMaintenance = Effect.fn( "resolvePackageManagedProviderMaintenance", )(function* ( @@ -463,8 +419,8 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( // npm 12 blocks install scripts by default (empty allow-scripts allowlist) // and still exits 0, so a package whose postinstall finishes the install // (claude copies its native binary over a placeholder stub) is left broken - // while the update reports success. Allow this one package's scripts. - // Older npm warns about the unknown config and continues. + // while the update reports success. Older npm warns about the unknown + // config and continues. return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, @@ -502,8 +458,6 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( if (!brewPath) { return fallback; } - // A keg-shaped path is only Homebrew's if it sits under the prefix of the - // `brew` that would upgrade it; `brew --prefix` is a cheap shell script. const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env)); const realBrewPrefix = brewPrefix ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix)) @@ -533,7 +487,6 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( return fallback; }); -/** A launcher script that runs the provider through mise (`exec mise x codex -- codex`). */ const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* ( context: ProviderMaintenanceResolutionContext, ) { @@ -572,11 +525,6 @@ const isVoltaPackageInstall = Effect.fn("isVoltaPackageInstall")(function* ( return yield* fileSystem.exists(packageDir).pipe(Effect.orElseSucceed(() => false)); }); -/** - * POSIX npm links `/bin/` into the package, so the real path is - * proof. Windows npm writes `.cmd` shims beside `node_modules`, so the proof - * is the package manifest next to the shim. - */ const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* ( context: ProviderMaintenanceResolutionContext, packageName: string, @@ -623,11 +571,6 @@ function makeManualProviderMaintenanceCapabilities( }); } -/** - * Locate the configured provider executable, follow symlinks, and hand the - * result to the resolver. A binary that cannot be found yields the resolver's - * no-context answer. - */ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( "resolveProviderMaintenanceCapabilitiesEffect", )(function* ( @@ -643,9 +586,6 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( } const env = options?.env ?? (yield* readCommandLookupEnv); - // resolveCommandPath checks explicit paths for existence too, so a missing - // binary always lands in the no-context branch and never gets an update - // command it cannot run. const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env }); if (!resolvedCommandPath) { return yield* resolver.resolve(null); @@ -670,8 +610,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( /** * Turn a one-shot resolution into the shape drivers expose: a cached read for * advisories and a `fresh` read that update execution uses so it never trusts - * ownership derived before the user clicked. An interrupted resolution is not - * cached, so the next read resolves again. + * ownership derived before the user clicked. */ export const makeCachedProviderMaintenanceResolution = Effect.fn( "makeCachedProviderMaintenanceResolution", diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index cbca7e42a908..0cbb238cf1f8 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -96,7 +96,6 @@ const refreshedSnapshotSecond: ServerProvider = { message: "Refreshed provider availability again.", }; -/** A host whose settings never change and whose background demand is fixed. */ function layerProviderHost(input: { readonly runBackgroundWork: boolean; readonly settings?: Pick["settings"]; @@ -571,12 +570,10 @@ describe("makeManagedServerProvider", () => { ); yield* Effect.yieldNow; - // Percent-only weekly update: keeps the probe's reset time. yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:05:00.000Z", windows: [{ id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 25 }], }); - // No windows: nothing to merge, nothing published. yield* provider.applyUsageLimits({ checkedAt: "2026-04-10T00:06:00.000Z", windows: [] }); const [update] = Array.from(yield* Fiber.join(updatesFiber)); @@ -659,7 +656,6 @@ describe("makeManagedServerProvider", () => { windows: [liveWindow], }); - // Enrichment computed from the pre-update snapshot lands afterwards. yield* Deferred.succeed(releaseEnrichment, undefined); const enriched = yield* Stream.take(provider.streamChanges, 1).pipe( Stream.runCollect, @@ -668,14 +664,11 @@ describe("makeManagedServerProvider", () => { assert.deepStrictEqual(enriched.models, enrichedSnapshot.models); assert.deepStrictEqual(enriched.usageLimits?.windows, [liveWindow]); - // A probe that could not read usage keeps the last good windows. const refreshed = yield* provider.refresh; assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); - // ...and the workspace they were read for. assert.strictEqual(refreshed.auth.workspaceId, "ws-a"); - // A failed read for another workspace does not inherit those windows. const switched = yield* provider.refresh; assert.strictEqual(switched.auth.workspaceId, "ws-b"); assert.strictEqual(switched.usageLimits?.unavailable?.reason, "probeFailed"); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index bac7fbd3a52a..e90d5eefa04b 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -106,8 +106,6 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( yield* Fiber.interrupt(previousFiber).pipe(Effect.ignore); } - // Enrichment resolves binaries and package-manager state, which a disabled - // provider has no use for. if (!input.enrichSnapshot || !snapshot.enabled) { return; } @@ -162,7 +160,6 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const previous = state.snapshot.auth; const differs = (a: string | undefined, b: string | undefined) => a !== undefined && b !== undefined && a !== b; - // Limits read without a workspace cannot be attributed to a new one. const switchedAccount = differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || (probedSnapshot.auth.workspaceId !== undefined && @@ -195,11 +192,6 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); - /** - * Runtime usage updates arrive between probes. They patch only - * `usageLimits` on whatever snapshot is published and leave the enrichment - * generation alone, so an in-flight enrichment still lands. - */ const applyUsageLimits: ManagedServerProvider["applyUsageLimits"] = (update) => Effect.gen(function* () { const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { @@ -208,8 +200,6 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( update, checkedAt: update.checkedAt, }); - // `applyUsageLimitsUpdate` hands back the same object when nothing - // moved, which is the common case for Codex's per-tick notification. if (usageLimits === state.snapshot.usageLimits) { return [null, state] as const; } diff --git a/packages/provider-core/src/server/mcpSession.ts b/packages/provider-core/src/server/mcpSession.ts index bea9b3ac5f4a..8a42ced55768 100644 --- a/packages/provider-core/src/server/mcpSession.ts +++ b/packages/provider-core/src/server/mcpSession.ts @@ -8,23 +8,15 @@ export interface McpProviderSessionConfig { readonly endpoint: string; readonly authorizationHeader: string; /** - * Whether this credential includes the "preview" capability. Adapters read - * it to keep developer instructions truthful: when the user withholds agent - * browser access, the prompt must not advertise `preview_*` tools that every - * call would reject. + * Adapters read it to keep developer instructions truthful: when the user + * withholds agent browser access, the prompt must not advertise `preview_*` + * tools that every call would reject. */ readonly browserToolsAvailable: boolean; - /** Capabilities the credential grants ("preview", "device"). */ readonly capabilities?: ReadonlySet; - /** - * Set when the session may drive devices. Adapters spread this into the - * provider subprocess environment so the `agent-device` CLI is on PATH and - * already pointed at the server's daemon; the agent never handles a token. - */ readonly agentDeviceEnvironment?: Readonly>; } -/** Provider env with the device variables applied over `base`, or `base` untouched. */ export function withAgentDeviceEnvironment( base: NodeJS.ProcessEnv, config: Pick | undefined, diff --git a/packages/provider-core/src/server/nativeProtocolLogging.ts b/packages/provider-core/src/server/nativeProtocolLogging.ts index a85833b0e862..9d4896b936f5 100644 --- a/packages/provider-core/src/server/nativeProtocolLogging.ts +++ b/packages/provider-core/src/server/nativeProtocolLogging.ts @@ -5,7 +5,6 @@ export function structuralProtocolMethod(value: string): string { } /** - * Retain only bounded structural metadata for native protocol diagnostics. * Provider payload values may contain prompts, credentials, or arbitrarily * large tool output and must never be copied into the observability stream. */ diff --git a/packages/provider-core/src/server/notification.ts b/packages/provider-core/src/server/notification.ts index 87acef16f2ea..30f6826278c7 100644 --- a/packages/provider-core/src/server/notification.ts +++ b/packages/provider-core/src/server/notification.ts @@ -9,9 +9,7 @@ import type { type NotificationOutcome = OrchestrationV2Notification["outcome"]; -/** A piece of background work an adapter can name, by kind. */ export type BackgroundWork = { - /** Subagent title, command, or monitor description. */ readonly label?: string | undefined; } & ( | { readonly kind: "subagent"; readonly childThreadId?: ThreadId | undefined } @@ -20,7 +18,6 @@ export type BackgroundWork = { | { readonly kind: "background_task" } ); -/** Background work an adapter saw finish or report, as the user should read it. */ export type BackgroundWorkReport = BackgroundWork & { readonly outcome: NotificationOutcome }; const LABEL_MAX_LENGTH = 80; @@ -107,7 +104,6 @@ function reportsSummary( return `${joinNames(reports.map((report, index) => namedReport(report, index === 0)))} ${verb}`; } -/** Reports of one kind share it; mixed kinds are generic background work. */ function reportsSource( reports: readonly [BackgroundWorkReport, ...ReadonlyArray], ): OrchestrationV2NotificationSource { @@ -115,7 +111,6 @@ function reportsSource( if (rest.some((report) => report.kind !== first.kind)) return { kind: "background_task" }; switch (first.kind) { case "subagent": - // One subagent is the thing the row opens; several open nothing. return rest.length === 0 && first.childThreadId !== undefined ? { kind: "subagent", childThreadId: first.childThreadId } : { kind: "subagent" }; @@ -126,10 +121,6 @@ function reportsSource( } } -/** - * A notification that says which background work a provider reported. Null - * when nothing is known, so the caller keeps its generic notification. - */ export function backgroundWorkNotification( reports: readonly [BackgroundWorkReport, ...ReadonlyArray], ): OrchestrationV2Notification; @@ -162,14 +153,12 @@ function delegatedTaskReport( : "unknown"; return { kind: "subagent", - // Delegated tasks are titled by their prompt unless the caller named them. label: task?.title?.trim() || task?.prompt, outcome, ...(task?.childThreadId == null ? {} : { childThreadId: task.childThreadId }), }; } -/** Summarizes the delegated tasks one completion delivery reports, out of all its parent run delegated. */ function delegatedCompletionNotification( completion: NonNullable, tasks: ReadonlyArray, @@ -208,7 +197,6 @@ function delegatedCompletionNotification( }; } -/** Keep the delivery message intact while projecting its trigger as an activity. */ export function notificationTurnItem( item: OrchestrationV2TurnItem, message: Pick, diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 821ada6f952f..f654aae1f4df 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -94,9 +94,7 @@ export function t3AcpPromptWithInstructions(input: { } /** - * Providers without a system/developer-instruction channel receive this - * context in the first prompt. Keep the wrapper explicit so it cannot be - * mistaken for text authored by the user. + * Keep the wrapper explicit so it cannot be mistaken for text authored by the user. */ function prependT3OrchestrationInstructions(prompt: string): string { return `${T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()}\n\n\n${prompt}\n`; diff --git a/packages/provider-core/src/server/pathExpansion.ts b/packages/provider-core/src/server/pathExpansion.ts index 1cc52dd0e9d7..6dfa89fadc1b 100644 --- a/packages/provider-core/src/server/pathExpansion.ts +++ b/packages/provider-core/src/server/pathExpansion.ts @@ -2,17 +2,9 @@ import * as NodePath from "node:path"; /** - * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to `home`, - * the current user's home directory (`HostProcess.HomeDirectory`). Spawned - * processes don't get shell expansion, so env vars like + * Spawned processes don't get shell expansion, so env vars like * `CODEX_HOME=~/.codex-work` would be passed verbatim and treated as relative * paths by the receiver. - * - * Matches the behavior of the other `expandHomePath` helpers in the - * workspace layers and CLI bootstrap: `~` alone and both `~/` and `~\` - * separators are handled. Returns the input unchanged if it doesn't - * start with `~` or is empty. Does not handle `~user` (other-user) - * expansion. */ export function expandHomePath(value: string, home: string): string { if (!value) return value; diff --git a/packages/provider-core/src/server/processGroup.ts b/packages/provider-core/src/server/processGroup.ts index ee501e0fdd0b..f8b02650b95f 100644 --- a/packages/provider-core/src/server/processGroup.ts +++ b/packages/provider-core/src/server/processGroup.ts @@ -1,10 +1,6 @@ /** - * Signals a POSIX process group this server spawned, as `process.kill(-pid)`. - * * `kill(0)` signals this server's own group and `kill(-1)` every process the - * user owns, and a fake spawner in tests reports pid 1. So a pid of 0 or 1, or - * one that is not an integer, fails with ESRCH like a group that has already - * exited, and callers keep their existing handling. + * user owns, and a fake spawner in tests reports pid 1. */ export function signalProcessGroup(pid: number, signal: NodeJS.Signals | 0): void { if (!Number.isSafeInteger(pid) || pid <= 1) { diff --git a/packages/provider-core/src/server/providerAuthFlow.ts b/packages/provider-core/src/server/providerAuthFlow.ts index 9f4716a09cd1..c596f8ebc3e5 100644 --- a/packages/provider-core/src/server/providerAuthFlow.ts +++ b/packages/provider-core/src/server/providerAuthFlow.ts @@ -25,7 +25,6 @@ import type * as ProviderAuth from "./auth.ts"; export interface ProviderAuthFlowContext { readonly flowId: string; - /** The lifetime timeout interrupts authenticate before publishing its expired result. */ readonly expiresAt: number; readonly returnUrl?: string; readonly callbackMode?: "server" | "client"; @@ -58,13 +57,11 @@ interface Flow { | undefined; } -/** Adapters do login and credential handling; this owns client consent and flow lifetime. */ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { readonly instanceId: ProviderInstanceId; readonly credentialBinding: NonNullable; readonly methods: Effect.Effect, ProviderSetupError>; readonly defaultMethodId?: string; - /** Stored account profiles can change the advertised methods after auth/logout. */ readonly refreshMethodsAfterAuth?: boolean; /** Fail with ProviderSetupError containing safe text for the user, never native token data. */ readonly authenticate: ( @@ -125,7 +122,6 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { return active; }); - // Method discovery does not start a sign-in flow. const refreshMethods = options.methods.pipe( Effect.flatMap((methods) => SubscriptionRef.update(snapshot, (current) => ({ @@ -133,7 +129,6 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { state: { ...current.state, methods }, })), ), - // Keep the last known methods when a refresh fails or is interrupted. Effect.catch((error) => SubscriptionRef.update(snapshot, (current) => ({ ...current, diff --git a/packages/provider-core/src/server/runtimeInstructions.ts b/packages/provider-core/src/server/runtimeInstructions.ts index a7b1c5e4c2e0..ca31b32cd9bf 100644 --- a/packages/provider-core/src/server/runtimeInstructions.ts +++ b/packages/provider-core/src/server/runtimeInstructions.ts @@ -4,7 +4,6 @@ When the t3-code MCP server exposes link_pull_request, you must use it to regist /** * Shared runtime context; omit model and effort when the harness manages them dynamically. - * `modelName` is the display name users see in the model picker; `model` is the slug. */ export function buildRuntimeInstructions(runtime: { readonly harness: string; diff --git a/packages/provider-core/src/server/selectionTransition.ts b/packages/provider-core/src/server/selectionTransition.ts index d4ca82d314a4..0f07fc985a2d 100644 --- a/packages/provider-core/src/server/selectionTransition.ts +++ b/packages/provider-core/src/server/selectionTransition.ts @@ -6,10 +6,6 @@ export interface ProviderSelectionTransitionInput { readonly sessionCapabilities: OrchestrationV2ProviderCapabilities; } -/** - * Provider-owned classification of how a complete selection can be applied. - * The orchestrator remains responsible for attempts and resource lifecycle. - */ export type ProviderSelectionTransitionPlan = | { readonly type: "apply_on_next_turn" } | { readonly type: "restart_session" } @@ -20,7 +16,6 @@ export function turnScopedSelectionTransition(): ProviderSelectionTransitionPlan return { type: "apply_on_next_turn" }; } -/** ACP models require a negotiated model/config mutation capability. */ export function acpSelectionTransition( input: ProviderSelectionTransitionInput, ): ProviderSelectionTransitionPlan { diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index 7832e944410f..55e657672ee0 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -5,7 +5,7 @@ import type { ProviderMaintenanceCapabilities } from "./maintenance.ts"; export interface ManagedServerProvider { /** - * Ownership-derived update capabilities. Cached between reads; pass + * Cached between reads; pass * `{ fresh: true }` before executing an update so it never trusts a * resolution older than the click. */ @@ -16,8 +16,7 @@ export interface ManagedServerProvider { readonly refresh: Effect.Effect; readonly streamChanges: Stream.Stream; /** - * Fold a runtime rate-limit update into the published snapshot without - * waiting for the next status probe. Sparse: windows merge by id and an + * Sparse: windows merge by id and an * update with no usable window leaves the snapshot untouched. */ readonly applyUsageLimits: ( diff --git a/packages/provider-core/src/server/snapshotProbe.ts b/packages/provider-core/src/server/snapshotProbe.ts index 7ca44770f267..b3f1ee5a2836 100644 --- a/packages/provider-core/src/server/snapshotProbe.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -33,7 +33,6 @@ const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ /wird nicht als interner oder externer befehl/i, ] as const; -/** Whether a Windows exit means the command was not found rather than that it failed. */ export const isWindowsCommandNotFound = Effect.fn("isWindowsCommandNotFound")(function* ( code: number | null, stderr: string, @@ -45,7 +44,6 @@ export const isWindowsCommandNotFound = Effect.fn("isWindowsCommandNotFound")(fu }); export const DEFAULT_TIMEOUT_MS = 4_000; -// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) export const AUTH_PROBE_TIMEOUT_MS = 10_000; export const COMPACT_SLASH_COMMAND = { @@ -132,10 +130,6 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman return result; }).pipe(Effect.scoped); -/** - * Return the first semantic version found in CLI output, or null. Accepts a - * leading "v" (for example `opencode v2.0.3`). - */ export function parseGenericCliVersion(output: string): string | null { // "opencode v2.0.3"-style output: the optional "v" has to be consumed first, // since "v2" itself contains no word boundary. @@ -143,13 +137,6 @@ export function parseGenericCliVersion(output: string): string | null { return match?.[1] ?? null; } -/** - * Append the user's custom models after the built-ins. A custom entry that - * declares its own capabilities keeps them; a bare slug gets the driver's - * default set. A named entry whose slug matches a built-in renames that - * built-in instead (for example `{ "slug": "gpt-5.5", "name": "GPT" }` in - * `settings.json`); other fields on such an entry are ignored. - */ export function providerModelsFromSettings( builtInModels: ReadonlyArray, customModels: ReadonlyArray, @@ -164,7 +151,6 @@ export function providerModelsFromSettings( const resolvedBuiltInModels = builtInModels.map((model) => { const name = nameOverrides.get(model.slug); if (!name) return model; - // The built-in short name would undo the override in compact UI. const { shortName: _shortName, ...rest } = model; return { ...rest, name }; }); diff --git a/packages/provider-core/src/server/snapshotSettings.ts b/packages/provider-core/src/server/snapshotSettings.ts index bf0996d0a15a..aa2991b47d5c 100644 --- a/packages/provider-core/src/server/snapshotSettings.ts +++ b/packages/provider-core/src/server/snapshotSettings.ts @@ -27,7 +27,6 @@ export function haveProviderSnapshotSettingsChanged( return !Equal.equals(previous, next); } -/** The instance's settings paired with the host's server settings, as snapshots read them. */ export const makeProviderSnapshotSettingsSource = ( provider: Settings, ): Effect.Effect< diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index a3a00c5005e2..4ae4397251c7 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -209,7 +209,6 @@ export function subagentResultForRun( }; } -/** A finished turn can still own live children or queued completion follow-ups. */ export function delegatedTaskProgress(projection: { readonly runs: OrchestrationV2ThreadProjection["runs"]; readonly messages: ReadonlyArray< @@ -232,8 +231,6 @@ export function delegatedTaskProgress(projection: { const workRuns = projection.runs.filter( (run) => !monitorRuns.has(run.id) && run.status !== "rolled_back", ); - // A held queue waits for the user to resume it (after Stop, a restart, or a - // provider failure), so its runs are not work the task still owes. const active = workRuns.some( (run) => !terminal(run.status) && !(run.status === "queued" && run.queueHeld === true), ); @@ -261,7 +258,6 @@ export function delegatedTaskProgress(projection: { }; } -/** Ids that `emitted` already wrote with an event of `type`. */ function writtenIds( emitted: ReadonlyArray, type: "subagent.updated" | "node.updated" | "turn-item.updated", @@ -269,11 +265,6 @@ function writtenIds( return new Set(emitted.flatMap((event) => (event.type === type ? [event.payload.id] : []))); } -/** - * Ends a provider-native subagent, its node and its turn item once the provider - * process that ran it is gone, since no terminal event will come from it. Each - * record is skipped if already settled or already ended by `emitted`. - */ export const endOrphanedNativeSubagent = (input: { readonly projection: Pick; readonly subagentId: NodeId; @@ -351,11 +342,6 @@ export const endOrphanedNativeSubagent = (input: { return events; }); -/** - * Ends a provider-native subagent thread's work, a runless root turn plus the - * items under it (Claude's live progress), which only the provider process that - * ran it could settle. Skips records `emitted` already ended. - */ export const endRunlessRootTurns = (input: { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; @@ -384,8 +370,6 @@ export const endRunlessRootTurns = (input: { ) { continue; } - // Recovery may have ended this node through one of its items; its other - // items still need ending. if (!endedNodeIds.has(node.id)) { events.push({ ...scope, diff --git a/packages/provider-core/src/server/textGeneration.ts b/packages/provider-core/src/server/textGeneration.ts index 955fb2688c0d..7a02bbd30ef7 100644 --- a/packages/provider-core/src/server/textGeneration.ts +++ b/packages/provider-core/src/server/textGeneration.ts @@ -14,17 +14,14 @@ export interface CommitMessageGenerationInput { branch: string | null; stagedSummary: string; stagedPatch: string; - /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; policy?: TextGenerationPolicy | undefined; - /** What model and provider to use for generation. */ modelSelection: ModelSelection; } export interface CommitMessageGenerationResult { subject: string; body: string; - /** Only present when `includeBranch` was set on the input. */ branch?: string | undefined; } @@ -37,7 +34,6 @@ export interface PrContentGenerationInput { diffPatch: string; changeRequestTemplate?: string | undefined; policy?: TextGenerationPolicy | undefined; - /** What model and provider to use for generation. */ modelSelection: ModelSelection; } @@ -51,7 +47,6 @@ export interface BranchNameGenerationInput { cwd: string; message: string; attachments?: ReadonlyArray | undefined; - /** What model and provider to use for generation. */ modelSelection: ModelSelection; } @@ -63,10 +58,8 @@ export interface ThreadTitleGenerationInput { linkedContext?: string | undefined; cwd: string; message: string; - /** Present when replacing an existing title from the current thread history. */ previousTitle?: string | undefined; attachments?: ReadonlyArray | undefined; - /** What model and provider to use for generation. */ modelSelection: ModelSelection; } @@ -78,72 +71,45 @@ export interface ThreadTitleGenerationResult { export interface ReviewGuideStackLayer { readonly number: number; readonly title: string | null; - /** The layer's head in the checkout, null where it could not be fetched. */ readonly headSha: string | null; readonly state: "open" | "closed" | "merged"; } export interface ReviewGuideGenerationInput { - /** A disposable checkout of the change's head. The model investigates it read-only. */ cwd: string; number: number; title: string; - /** The author's own description, which states intent the diff cannot. */ description: string; baseBranch: string; - /** The commit the change is measured from, present in `cwd`. */ baseCommit: string; - /** - * What review already settled: resolved conversations, reduced to who raised what. The guide - * leaves these alone rather than raising them again. - */ settledReview: string; - /** The maintainer's preferences for guides (tone, emphasis, ordering). Empty for none. */ instructions?: string | undefined; - /** - * The stack the change is a layer of, bottom first, when it is in one. Another layer's head is - * fetched into `cwd` where the host served it, so the model can read across a seam. - */ stack?: ReadonlyArray | undefined; - /** What model and provider to use for generation. */ modelSelection: ModelSelection; } -/** A guide as the model wrote it, before the server holds it to the change. */ export type ReviewGuideGenerationResult = Omit< PullRequestReviewGuide, "headSha" | "generatedAt" | "modelSelection" >; -/** Commit, change request, branch, and title generation backed by one provider instance. */ export interface ProviderTextGeneration { - /** - * Generate a commit message from staged change context. - */ readonly generateCommitMessage: ( input: CommitMessageGenerationInput, ) => Effect.Effect; - /** - * Generate change request title/body from branch and diff context. - */ readonly generatePrContent: ( input: PrContentGenerationInput, ) => Effect.Effect; - /** - * Generate a concise branch name from a user message. - */ readonly generateBranchName: ( input: BranchNameGenerationInput, ) => Effect.Effect; - /** Generate a concise thread title from a first message or thread history. */ readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; - /** Explain a change request to the maintainer deciding whether it lands. */ readonly generateReviewGuide: ( input: ReviewGuideGenerationInput, ) => Effect.Effect; diff --git a/packages/provider-core/src/server/textGenerationOperations.ts b/packages/provider-core/src/server/textGenerationOperations.ts index 2debd5c4976a..414e9478fcbf 100644 --- a/packages/provider-core/src/server/textGenerationOperations.ts +++ b/packages/provider-core/src/server/textGenerationOperations.ts @@ -1,11 +1,3 @@ -/** - * The text generation operations every provider shares. A provider supplies a - * runner that sends one prompt and decodes the reply; prompts, linked source - * control context, and output sanitizing live here so providers cannot drift - * on them. - * - * @module textGeneration/TextGenerationOperations - */ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -29,43 +21,33 @@ import { export type Operation = keyof ProviderTextGeneration; -/** One prompt for a provider to run. */ export interface Request { readonly operation: Operation; readonly cwd: string; readonly prompt: string; readonly outputSchema: S; readonly modelSelection: ModelSelection; - /** The user's attachments, already listed in the prompt. Providers that accept images send them. */ readonly attachments?: ReadonlyArray | undefined; /** * Set when the model must investigate `cwd` before answering: it may read files and run - * read-only commands there, over as many turns as it needs, and must change nothing. Absent, - * the prompt carries everything and the model runs with no tools at all. A runner that cannot - * guarantee read-only tools fails rather than answering blind. + * read-only commands there, over as many turns as it needs, and must change nothing. A + * runner that cannot guarantee read-only tools fails rather than answering blind. */ readonly investigate?: { readonly timeoutMs: number } | undefined; } -/** How long an investigating run may take; a large change needs far longer than a title. */ const INVESTIGATION_TIMEOUT_MS = 15 * 60_000; -/** The refusal a runner gives when it has no read-only tool mode to investigate with. */ export const investigationUnsupported = (operation: Operation, provider: string) => new TextGenerationError({ operation, detail: `${provider} cannot investigate a checkout read-only. Choose another review guide model, or source control writer model when none is set.`, }); -/** Runs one request and decodes the reply as its `outputSchema`. */ export type Runner = ( request: Request, ) => Effect.Effect; -/** - * For a runner with no read-only tool mode: it answers prompts that carry everything, and - * refuses an investigation rather than answering one blind. - */ export const withoutInvestigation = (provider: string, run: Runner): Runner => (request) => @@ -73,13 +55,11 @@ export const withoutInvestigation = ? Effect.fail(investigationUnsupported(request.operation, provider)) : run(request); -/** Decodes the JSON object in a text reply, which models often wrap in prose. */ export const decodeJsonReply = ( request: Pick, "operation" | "outputSchema">, provider: string, text: string, ) => { - // Each request builds its own output schema, so the decoder cannot be hoisted. const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(request.outputSchema)); return decodeOutput(extractJsonObject(text)).pipe( Effect.mapError( @@ -93,7 +73,6 @@ export const decodeJsonReply = ( ); }; -/** The text generation service over `run`. `name` prefixes each operation's span. */ export function fromRunner(name: string, run: Runner): ProviderTextGeneration { const generateCommitMessage: ProviderTextGeneration["generateCommitMessage"] = Effect.fn( `${name}.generateCommitMessage`, diff --git a/packages/provider-core/src/server/textGenerationPrompts.test.ts b/packages/provider-core/src/server/textGenerationPrompts.test.ts index d908bc81a80e..3be8fb1bfd1c 100644 --- a/packages/provider-core/src/server/textGenerationPrompts.test.ts +++ b/packages/provider-core/src/server/textGenerationPrompts.test.ts @@ -28,7 +28,6 @@ describe("buildCommitMessagePrompt", () => { expect(result.prompt).toContain("Staged patch:"); expect(result.prompt).toContain("diff --git a/README.md b/README.md"); expect(result.prompt).toContain("Branch: main"); - // Should NOT include the branch generation instruction expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment"); }); diff --git a/packages/provider-core/src/server/textGenerationPrompts.ts b/packages/provider-core/src/server/textGenerationPrompts.ts index 9e4cb721db36..1ac6d3d1053f 100644 --- a/packages/provider-core/src/server/textGenerationPrompts.ts +++ b/packages/provider-core/src/server/textGenerationPrompts.ts @@ -1,11 +1,3 @@ -/** - * Shared prompt builders for text generation providers. - * - * Extracts the prompt construction logic that is identical across - * Codex, Claude, and any future CLI-based text generation backends. - * - * @module textGenerationPrompts - */ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import { limitTitleMessage } from "./threadTitleContext.ts"; @@ -22,10 +14,6 @@ function policyInstruction(instruction: string | undefined): ReadonlyArray | undefined; } -/** - * A layer is reviewed on its own, but its seams are where a reader gets lost: something used here - * that the layer below added, or something added here that only a layer above uses. The model may - * name the other layer to answer that, and never tours its code. - */ function reviewGuideStack( number: number, stack: ReadonlyArray | undefined, @@ -188,7 +160,6 @@ function reviewGuideStack( ]; } -/** The maintainer's taste, kept below the fixed format and coverage rules it cannot override. */ function reviewGuidePreferences(instructions: string | undefined): string[] { const trimmed = instructions?.trim(); return trimmed @@ -202,12 +173,6 @@ function reviewGuidePreferences(instructions: string | undefined): string[] { : []; } -/** - * The guide is a tour for a maintainer who trusts review agents with the lines and keeps the - * judgment for themselves: stops through the change in reading order. - * The model reads the change itself from the checkout, so no change is too large to brief. Line - * numbers it returns are only trusted after the server clamps them to the change's hunks. - */ export function buildReviewGuidePrompt(input: ReviewGuidePromptInput) { const base = input.baseCommit; const range = `${base}...HEAD`; @@ -288,10 +253,6 @@ export function buildReviewGuidePrompt(input: ReviewGuidePromptInput) { return { prompt, outputSchema }; } -// --------------------------------------------------------------------------- -// Branch name -// --------------------------------------------------------------------------- - export interface BranchNamePromptInput { naming?: BranchNamingOptions | undefined; message: string; @@ -371,10 +332,6 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { return { prompt, outputSchema }; } -// --------------------------------------------------------------------------- -// Thread title -// --------------------------------------------------------------------------- - export interface ThreadTitlePromptInput { linkedContext?: string | undefined; message: string; @@ -383,8 +340,7 @@ export interface ThreadTitlePromptInput { policy?: TextGenerationPolicy | undefined; } -// Keep shared editorial rules in these two prompts in sync. Regeneration -// intentionally adds guidance for thread history and the previous title. +// Keep shared editorial rules in these two prompts in sync. const INITIAL_THREAD_TITLE_PROMPT = `Generate a title that will help the user recognize this T3 Code thread weeks later. Return JSON with keys title and needsRefinement. Set needsRefinement to true only if the subject is still unknown, such as an unresolved link, "fix this", or an unexplained attachment. Otherwise set it to false. diff --git a/packages/provider-core/src/server/textGenerationUtils.ts b/packages/provider-core/src/server/textGenerationUtils.ts index 0f0873c58593..67abe1dc6a95 100644 --- a/packages/provider-core/src/server/textGenerationUtils.ts +++ b/packages/provider-core/src/server/textGenerationUtils.ts @@ -7,12 +7,7 @@ const decodeJsonThreadTitle = Schema.decodeOption( Schema.fromJsonString(Schema.Struct({ title: Schema.String })), ); -/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ export function toJsonSchemaObject(schema: Schema.Top): unknown { - // The type side, so decoding defaults do not turn required fields into - // optional ones, and closed objects (`additionalProperties: false`): - // structured-output modes require both, and closed was the generator - // default before effect rc.113. const document = Schema.toJsonSchemaDocument(Schema.toType(schema), { onExcessProperty: "error", }); @@ -22,14 +17,12 @@ export function toJsonSchemaObject(schema: Schema.Top): unknown { return document.schema; } -/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ export function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const truncated = value.slice(0, maxChars); return `${truncated}\n\n[truncated]`; } -/** Normalise a raw commit subject to imperative-mood, ≀72 chars, no trailing period. */ export function sanitizeCommitSubject(raw: string): string { const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); @@ -43,7 +36,6 @@ export function sanitizeCommitSubject(raw: string): string { return withoutTrailingPeriod.slice(0, 72).trimEnd(); } -/** Normalise a raw PR title to a single line with a sensible fallback. */ export function sanitizePrTitle(raw: string): string { const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; if (singleLine.length > 0) { @@ -52,11 +44,8 @@ export function sanitizePrTitle(raw: string): string { return "Update project changes"; } -// Prompts ask for under 40 characters. This cap only stops a runaway model -// from pushing a paragraph into the sidebar, header, and window title. const MAX_THREAD_TITLE_CHARS = 120; -/** Normalise a raw thread title to a single line. Clients truncate for display. */ export function sanitizeThreadTitle(raw: string): string { // Unwrap a JSON-formatted title before truncation can cut off the closing brace. const decoded = decodeJsonThreadTitle(raw); @@ -80,17 +69,11 @@ export function sanitizeThreadTitle(raw: string): string { return `${normalized.slice(0, MAX_THREAD_TITLE_CHARS - 3).trimEnd()}...`; } -/** CLI name to human-readable label, e.g. "codex" β†’ "Codex CLI (`codex`)" */ function cliLabel(cliName: string): string { const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); return `${capitalized} CLI (\`${cliName}\`)`; } -/** - * Normalize an unknown error from a CLI text generation process into a - * typed `TextGenerationError`. Parameterized by CLI name so both Codex - * and Claude (and future providers) can share the same logic. - */ export function normalizeCliError( cliName: string, operation: string, diff --git a/packages/provider-core/src/server/threadTitleContext.ts b/packages/provider-core/src/server/threadTitleContext.ts index aef9138c3710..cd938b4c5562 100644 --- a/packages/provider-core/src/server/threadTitleContext.ts +++ b/packages/provider-core/src/server/threadTitleContext.ts @@ -12,7 +12,6 @@ const MAX_MESSAGE = 2_000; const OMITTED = "[Earlier content truncated]\n\n"; const TRUNCATED = "\n[Content truncated]\n"; -/** Keep the request and its final constraints when a message is too long. */ export function limitTitleMessage(text: string, budget: number): string { if (text.length <= budget) return text; if (budget <= TRUNCATED.length) return ""; @@ -22,7 +21,6 @@ export function limitTitleMessage(text: string, budget: number): string { return `${text.slice(0, head)}${TRUNCATED}${tail > 0 ? text.slice(-tail) : ""}`; } -/** Reserve space for user intent before adding assistant findings, in conversation order. */ export function formatThreadTitleContext(messages: ReadonlyArray) { const sections = messages.flatMap((message, index) => { // Thinking traces are working notes, not what the thread is about, and they @@ -62,7 +60,6 @@ export function formatThreadTitleContext(messages: ReadonlyArray section.message.role === "user"); if (firstUser) add(firstUser, MAX_MESSAGE); - // Up to 6,000 characters go to user messages. Assistant output cannot evict them. for (const section of sections.toReversed()) { if (section.message.role === "user") { add(section, Math.min(MAX_MESSAGE, remaining - 2_000)); @@ -71,7 +68,6 @@ export function formatThreadTitleContext(messages: ReadonlyArray 0 ? Math.trunc(value) : 0; } -/** Epoch milliseconds of an ISO timestamp string, or `null`. */ export function parseTimestampMs(value: unknown): number | null { if (typeof value !== "string") return null; const parsed = Date.parse(value); return Number.isNaN(parsed) ? null : parsed; } -/** JSON fields a format reads. `true` keeps the whole subtree. */ export type SelectedFields = { readonly [key: string]: true | SelectedFields }; -/** - * How the server's JSONL engine reads one provider's transcript lines. - * - * Lines arrive one at a time. A line too large to parse whole is projected to - * `selectFields` and handed to `parseProjected` instead of `parseLine`. - */ export interface TranscriptUsageFormat { readonly selectFields: SelectedFields; /** * ASCII substrings, at least one of which every line that matters contains. - * Transcripts are mostly tool output, so the engine matches these against a - * line's raw bytes and neither decodes nor parses the rest, which is worth an - * order of magnitude. Lines that only update `State` must match too. + * Lines that only update `State` must match too. */ readonly usageMarkers: readonly [string, ...string[]]; parseLine(line: string, state: State): readonly UsageRecord[]; @@ -115,7 +79,6 @@ export interface TranscriptUsageFormat { readonly sharedSessionsAcrossFiles?: true; } -/** A configured instance of the driver, its config already decoded by `driver.configSchema`. */ export interface ProviderUsageInstance { readonly instanceId: ProviderInstanceId; /** @@ -123,39 +86,28 @@ export interface ProviderUsageInstance { * history on disk, so readers that need no config read it anyway. */ readonly config: Config | undefined; - /** The instance's environment merged over the host environment. */ readonly environment: NodeJS.ProcessEnv; - /** Whether settings list the instance, rather than it being the implicit default. */ readonly configured: boolean; } -/** One source a `scan` reader read. */ export interface ProviderUsageScan { readonly dir: string; - /** Identity of the source across hosts. Defaults to the filesystem identity of `dir`. */ readonly volumeId?: string; - /** Overrides the server's host name in the source fingerprint, for account-wide sources. */ readonly hostId?: string; readonly status?: UsageSource["status"]; readonly message?: string; readonly action?: UsageSource["action"]; - /** Parsed records per file, or `null` when the source does not exist. */ readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] | null; - /** Answered from a cache while a refresh runs. */ readonly refreshing?: true; } export interface ProviderUsageScanInput { readonly instances: ReadonlyArray>; - /** The settings snapshot the whole read runs against. */ readonly settings: ServerSettings; - /** Files last written before this cannot hold records inside the window. */ readonly windowStartMs: number; - /** The oldest usage the server keeps; account caches cover back to here. */ readonly retentionCutoffMs: number; - /** Wait for a slow refresh instead of answering from a cache. */ readonly awaitRefresh: boolean; } @@ -164,10 +116,6 @@ export type ProviderUsageReader = readonly kind: "transcripts"; readonly provider: UsageProviderKind; readonly format: TranscriptUsageFormat; - /** - * Transcript directories for one instance. The server canonicalizes, - * de-duplicates and scans them; `fileName` limits a walk to one basename. - */ readonly directories: ( instance: ProviderUsageInstance, ) => Effect.Effect< diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index ea8d0d1d029f..bcb234b9152b 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -47,16 +47,6 @@ export function makeUnavailableUsageLimits(input: { }; } -/** - * Fold a sparse runtime update into the limits a provider currently - * publishes. Windows upsert by `id`; a window the update omits keeps its - * previous values, and a window that arrives without `resetsAt` or - * `windowDurationMins` keeps whatever the last probe resolved for it. An - * update with no windows leaves `previous` untouched. - * - * An `unsupported` snapshot stays unsupported: an account that cannot have - * subscription windows will not start reporting them mid-turn. - */ export function applyUsageLimitsUpdate(input: { readonly previous: ServerProviderUsageLimits | undefined; readonly update: ProviderUsageLimitsUpdate; @@ -108,20 +98,6 @@ function usageWindowEquals(a: ServerProviderUsageWindow, b: ServerProviderUsageW ); } -/** - * Choose what to publish after a status probe finishes. A probe that failed - * this time must not wipe bars a previous probe or a turn already - * established, so the last good snapshot stays; `unsupported` is - * authoritative and replaces them. - * - * A successful probe replaces the published windows outright, including any - * runtime update that landed while it was running. That is a deliberate - * trade-off: the Codex and Claude reads take a few seconds at most, the - * probe is the fresher full read in every case except that window, and the - * per-window epoch bookkeeping needed to reconcile the two was more code - * than the sub-second regression it prevented. The next runtime event - * corrects it. - */ export function resolveUsageLimitsAfterProbe(input: { readonly published: ServerProviderUsageLimits | undefined; readonly probed: ServerProviderUsageLimits | undefined; diff --git a/packages/provider-cursor/src/client.ts b/packages/provider-cursor/src/client.ts index c76378ab1739..a701023a9169 100644 --- a/packages/provider-cursor/src/client.ts +++ b/packages/provider-cursor/src/client.ts @@ -1,9 +1,3 @@ -/** - * Cursor's client definition: settings form, label, sign-in environment - * field, and glyph. Browser- and React Native-safe. - * - * @module provider-cursor/client - */ import { ProviderDriverKind } from "@t3tools/contracts"; import { defineProviderClient } from "@t3tools/provider-core/client"; diff --git a/packages/provider-cursor/src/server.ts b/packages/provider-cursor/src/server.ts index cc56dd7978e1..e362e7ea9624 100644 --- a/packages/provider-cursor/src/server.ts +++ b/packages/provider-cursor/src/server.ts @@ -1,8 +1,2 @@ -/** - * Cursor's server entry: the driver the server registers and its adapter - * driver. - * - * @module provider-cursor/server - */ export { CursorDriver, type CursorDriverEnv } from "./server/driver.ts"; export { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/packages/provider-cursor/src/server/CursorAccountReader.ts b/packages/provider-cursor/src/server/CursorAccountReader.ts index 886bfe2f8737..79f391166f28 100644 --- a/packages/provider-cursor/src/server/CursorAccountReader.ts +++ b/packages/provider-cursor/src/server/CursorAccountReader.ts @@ -1,9 +1,3 @@ -/** - * CursorAccountReader - reads one range of Cursor account usage from its - * dashboard API. A service so tests can stand in for Cursor's API. - * - * @module provider-cursor/server/CursorAccountReader - */ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -26,7 +20,6 @@ export class CursorAccountReader extends Context.Service< } >()("@t3tools/provider-cursor/server/CursorAccountReader") {} -/** Reads Cursor's dashboard API with the saved CLI or Keychain login. */ export const layer = Layer.effect( CursorAccountReader, Effect.gen(function* () { @@ -35,8 +28,6 @@ export const layer = Layer.effect( FileSystem.FileSystem | Crypto.Crypto | HttpClient.HttpClient >(); return CursorAccountReader.of({ - // Only a Keychain login asks the Keychain. The dashboard reader turns - // its outcome, a failure included, into the source's message. read: (credentialSource, sinceMs, untilMs) => readCursorAccountUsage(credentialSource, sinceMs, untilMs, keychain.accessToken).pipe( Effect.provideContext(context), diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index e0162a4d062c..3cb27d09c91b 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -298,12 +298,6 @@ function makeCursorAgentSdkProtocolLogger(input: { .pipe(Effect.ignore); } -/** - * Runs agents through the Cursor SDK, logging every frame to the protocol - * logger chosen for each opened agent. The live layer writes the native - * provider event log; the replay recorder turns the same frames into a - * transcript. - */ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(function* ( protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined, ) { @@ -311,14 +305,10 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu /** * The Cursor SDK decides once per process whether local sandboxing works, and - * caches the answer the first time any run starts. Only sandboxed runs point - * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access) + * caches the answer the first time any run starts. Only sandboxed runs point it + * at its `cursorsandbox` helper first, so after an unsandboxed (Full access) * run it caches "unsupported" and rejects every later sandboxed run until the - * server restarts. Warming a bare sandboxed executor before the first - * unsandboxed agent opens lets the SDK find the helper and cache the real - * answer. Warming is best effort: on a machine without sandbox support it - * fails, the SDK caches "unsupported", and sandboxed runs report that as - * before. + * server restarts. */ const sandboxSupportPrimeStarted = yield* Ref.make(false); const sandboxSupportPrimed = yield* Deferred.make(); diff --git a/packages/provider-cursor/src/server/CursorKeychain.ts b/packages/provider-cursor/src/server/CursorKeychain.ts index 30beb6703434..d7bd133613a4 100644 --- a/packages/provider-cursor/src/server/CursorKeychain.ts +++ b/packages/provider-cursor/src/server/CursorKeychain.ts @@ -1,13 +1,7 @@ /** - * CursorKeychain - the Cursor CLI's macOS Keychain login, shared by usage - * history and usage limits in one server process. - * - * macOS shows the access prompt on the server's own screen, which a remote - * client cannot answer, so a read gives up after a timeout. The Keychain read - * stays in flight: the next read joins it instead of stacking a second prompt, - * and picks up the token once someone allows access. - * - * @module provider-cursor/server/CursorKeychain + * macOS shows the access prompt on the server's own screen, which a remote client cannot answer, so + * a read gives up after a timeout. The Keychain read stays in flight: the next read joins it + * instead of stacking a second prompt, and picks up the token once someone allows access. */ import * as NodeModule from "node:module"; @@ -25,7 +19,6 @@ const DEFAULT_TIMEOUT = Duration.seconds(30); const requireForKeyring = NodeModule.createRequire(import.meta.url); -/** Nobody answered the macOS Keychain prompt in time. */ export class CursorKeychainTimeoutError extends Schema.TaggedError()( "CursorKeychainTimeoutError", {}, @@ -37,7 +30,6 @@ export class CursorKeychainTimeoutError extends Schema.TaggedError()( "CursorKeychainReadError", { cause: Schema.Defect() }, @@ -50,7 +42,6 @@ export class CursorKeychainReadError extends Schema.TaggedError()("@t3tools/provider-cursor/server/CursorKeychain") {} -/** Reads the Cursor CLI's default macOS credential without invoking the shared security binary. */ const readKeychainEntry = Effect.tryPromise({ try: async () => { const { AsyncEntry } = requireForKeyring( @@ -69,7 +59,6 @@ const readKeychainEntry = Effect.tryPromise({ catch: (cause) => new CursorKeychainReadError({ cause }), }); -/** A Keychain backed by `read`, which tests replace. */ export const make = ( read: Effect.Effect = readKeychainEntry, timeout: Duration.Input = DEFAULT_TIMEOUT, @@ -77,7 +66,6 @@ export const make = ( let cached: { readonly token: string; readonly untilMs: number } | null = null; let pending: Deferred.Deferred | null = null; - /** Joins the read in flight, or starts one that outlives the caller. */ const startRead = Effect.uninterruptible( Effect.gen(function* () { if (pending !== null) return pending; diff --git a/packages/provider-cursor/src/server/CursorSdk.test.ts b/packages/provider-cursor/src/server/CursorSdk.test.ts index 53b9c6730140..5ab688396698 100644 --- a/packages/provider-cursor/src/server/CursorSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorSdk.test.ts @@ -6,9 +6,8 @@ import { isCursorShellSpawnFailure } from "./CursorSdk.ts"; const cursorSdkUrl = new URL("./CursorSdk.ts", import.meta.url).href; -// Builds the CursorSdk layer in a fresh process. @cursor/sdk is stubbed so the guard -// can be tested without the real package. The vitest worker already has its -// own unhandledRejection listener, which would hide the process-exit behavior. +// The vitest worker already has its own unhandledRejection listener, which +// would hide the process-exit behavior. const probeProgram = ` import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/packages/provider-cursor/src/server/CursorSdk.ts b/packages/provider-cursor/src/server/CursorSdk.ts index 2b9c02770d65..5de35e05eef6 100644 --- a/packages/provider-cursor/src/server/CursorSdk.ts +++ b/packages/provider-cursor/src/server/CursorSdk.ts @@ -6,17 +6,8 @@ import * as Layer from "effect/Layer"; import { Agent, createAgentPlatform, SqliteLocalAgentStore } from "./sdk.ts"; /** - * The Cursor Agent SDK runs in this process and spawns a shell for tool - * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`, - * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory - * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error, - * then rejects an internal promise nothing awaits. Node exits on that - * unhandled rejection and takes the server with it. - * - * The guard ignores only that rejection. Every other unhandled rejection - * still prints and exits when this is the process's only listener, matching - * Node's default. `child_process.spawn` itself is left alone, so git, - * terminals, and other providers keep their own error handling. + * The SDK listens for that error, then rejects an internal promise nothing awaits. + * Node exits on that unhandled rejection and takes the server with it. */ const CURSOR_SHELL_SPAWN_MARKERS = [ "dump_zsh_state", @@ -48,7 +39,6 @@ function onUnhandledRejection(reason: unknown): void { console.error("Cursor shell spawn failed. The server will keep running.", reason); return; } - // Another handler registered besides this one owns the decision. if (process.listenerCount("unhandledRejection") > 1) { return; } diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index 24f4ff87afff..7cfcc404470e 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -1,16 +1,3 @@ -/** - * CursorUsageAccounts - Cursor's account usage as a usage source, answered - * from a cache. - * - * Cursor's account API is slow, so a read answers from the cached account - * history and marks itself `refreshing` while a background refresh runs; - * `awaitRefresh` waits for that refresh instead. The caches persist in the - * server's state directory, so a restart only refetches the newest edge. See - * `accountCache`. - * - * @module provider-cursor/server/CursorUsageAccounts - */ - import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type { ProviderUsageScan } from "@t3tools/provider-core/server/usage"; import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; @@ -41,7 +28,6 @@ import * as CursorAccountReader from "./CursorAccountReader.ts"; const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; -/** The cache file is narrowed by hand in `accountCache`, so JSON is enough here. */ const decodeCacheFile = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec), ); @@ -49,15 +35,12 @@ const decodeCacheFile = Schema.decodeUnknownEffect( export class CursorUsageAccounts extends Context.Service< CursorUsageAccounts, { - /** This environment's Cursor account usage source, if it has one to report. */ readonly scan: (input: { - /** From the same settings snapshot as the rest of the usage read. */ readonly keychainUsageEnabled: boolean; readonly windowStartMs: number; readonly retentionCutoffMs: number; readonly awaitRefresh: boolean; }) => Effect.Effect>; - /** Waits for every cache write scheduled so far, as a restart would need. */ readonly awaitPersisted: Effect.Effect; } >()("@t3tools/provider-cursor/server/CursorUsageAccounts") {} @@ -71,7 +54,6 @@ const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const cachePath = path.join(host.paths.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); - /** Account caches by credential source. */ const caches = new Map(); let cacheDirty = false; /** @@ -80,10 +62,8 @@ const make = Effect.gen(function* () { * `null` error means there is no login: no source to report. */ const failures = new Map(); - /** The refresh in flight per credential source, which every read joins. */ const refreshes = new Map>(); - /** Loads the persisted caches once; concurrent first readers await the same load. */ const ensureLoaded = yield* Effect.cached( fileSystem.readFileString(cachePath).pipe( Effect.flatMap((raw) => decodeCacheFile(raw)), @@ -114,7 +94,6 @@ const make = Effect.gen(function* () { }).pipe(persistLock.withPermit, Effect.withSpan("CursorUsageAccounts.persist")); const pendingPersists = new Set>(); - /** Writes the caches in the background, after the read that dirtied them answers. */ const schedulePersist = Effect.forkDetach(persist).pipe( Effect.map((fiber) => { pendingPersists.add(fiber); @@ -124,10 +103,8 @@ const make = Effect.gen(function* () { const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe( Effect.asVoid, ); - // A write still running at shutdown finishes first, so the next start keeps it. yield* Effect.addFinalizer(() => awaitPersisted); - /** Fetches what one account cache is missing, then persists it if anything changed. */ const refreshAccount = Effect.fn("CursorUsageAccounts.refreshAccount")(function* ( credential: CursorCredentialSource, credentialKey: string, @@ -142,7 +119,6 @@ const make = Effect.gen(function* () { }; let base = caches.get(credentialKey); let fetched = yield* fetchMissing(base); - // Another login's history replaces the cached account's, even if reading it fails. if ( base !== undefined && fetched.result.accountKey !== null && @@ -172,14 +148,10 @@ const make = Effect.gen(function* () { ); caches.set(credentialKey, merged.cache); failures.delete(credentialKey); - // An unchanged edge is not worth rewriting the file for: after a restart - // the cache just refetches a slightly wider edge. Persisting anyway retries - // an earlier failed write; it is a no-op when nothing is dirty. if (merged.changed) cacheDirty = true; yield* schedulePersist; }); - /** Joins the refresh in flight, or starts one. */ const startRefresh = ( credential: CursorCredentialSource, credentialKey: string, @@ -214,11 +186,6 @@ const make = Effect.gen(function* () { }), ); - /** - * The account source. A cache inside its TTL, or a refresh that failed - * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh` - * waits for it, and anything else answers from the cache marked `refreshing`. - */ const accountSource = Effect.fn("CursorUsageAccounts.accountSource")(function* ( credential: CursorCredentialSource, authPath: string, @@ -226,7 +193,6 @@ const make = Effect.gen(function* () { retentionStartMs: number, awaitRefresh: boolean, ) { - // No saved login means there is no account source to report, not a setup error. if ( typeof credential === "string" && !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true))) diff --git a/packages/provider-cursor/src/server/accountCache.test.ts b/packages/provider-cursor/src/server/accountCache.test.ts index 56da1533ca0b..55b6ba5df3c6 100644 --- a/packages/provider-cursor/src/server/accountCache.test.ts +++ b/packages/provider-cursor/src/server/accountCache.test.ts @@ -47,7 +47,6 @@ describe("cursorAccountCache", () => { merged.records.map((entry) => entry.timestampMs), [50 * DAY_MS], ); - // Then only the newest edge. assert.deepStrictEqual(cursorFetchRange(merged, 11 * DAY_MS, nowMs + DAY_MS), { sinceMs: nowMs - 60 * 60 * 1000, untilMs: nowMs + DAY_MS, diff --git a/packages/provider-cursor/src/server/accountCache.ts b/packages/provider-cursor/src/server/accountCache.ts index a7322328746b..2b6acadd7177 100644 --- a/packages/provider-cursor/src/server/accountCache.ts +++ b/packages/provider-cursor/src/server/accountCache.ts @@ -1,23 +1,14 @@ /** - * Cursor account usage, cached per credential source. - * - * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched - * records are kept for the whole retention period, whatever window was asked - * for. After one full fetch, a refresh fetches only the newest edge. - * - * A record's dedupe key carries its timestamp and an occurrence index counted - * within one fetch. A refetched range therefore replaces every cached record in - * that range rather than adding to it, so each timestamp's records always come - * from a single fetch and their occurrence indexes cannot collide. - * - * @module provider-cursor/server/accountCache + * A record's dedupe key carries its timestamp and an occurrence index counted within one fetch. A + * refetched range therefore replaces every cached record in that range rather than adding to it, + * so each timestamp's records always come from a single fetch and their occurrence indexes cannot + * collide. */ import { cursorRateModel } from "./accountUsage.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; export type CursorCredentialSource = string | { readonly kind: "keychain" }; -/** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */ export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; /** Dashboard events can be finalized after they were first read. */ @@ -25,10 +16,8 @@ const REFETCH_OVERLAP_MS = 60 * 60 * 1000; export interface CursorAccountCache { readonly accountKey: string; - /** Inclusive range of event timestamps the records are complete for. */ readonly sinceMs: number; readonly untilMs: number; - /** When the newest edge was fetched. */ readonly fetchedAtMs: number; readonly records: readonly UsageRecord[]; } @@ -38,7 +27,6 @@ interface CursorFetchRange { readonly untilMs: number; } -/** Whether the cache answers without a refresh. */ export function isCursorCacheFresh( cache: CursorAccountCache | undefined, nowMs: number, @@ -46,10 +34,6 @@ export function isCursorCacheFresh( return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS; } -/** - * The range to fetch so the cache covers `[retentionStartMs, nowMs]`: the - * newest edge, or everything when the cache does not reach back that far. - */ export function cursorFetchRange( cache: CursorAccountCache | undefined, retentionStartMs: number, @@ -62,12 +46,6 @@ export function cursorFetchRange( : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs }; } -/** - * Applies a fetched range to the cache, replacing the cached records it covers - * and dropping those before the retention start. Pass `undefined` for a cache - * that belongs to another account. `changed` is false when the fetch only - * confirmed the cached records, so there is nothing new to persist. - */ export function mergeCursorFetch( cache: CursorAccountCache | undefined, accountKey: string, @@ -107,7 +85,6 @@ export function mergeCursorFetch( export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json"; const CURSOR_ACCOUNT_CACHE_VERSION = 1; -/** Positional rows with interned strings, as in the scan cache. */ type SerializedRecord = readonly [ timestampMs: number, modelIndex: number, @@ -128,7 +105,6 @@ interface SerializedAccount { readonly records: readonly SerializedRecord[]; } -/** Serialises the caches by credential source. */ export function encodeCursorAccountCaches(caches: ReadonlyMap): string { const strings: string[] = []; const indexes = new Map(); diff --git a/packages/provider-cursor/src/server/accountUsage.test.ts b/packages/provider-cursor/src/server/accountUsage.test.ts index 3924576b43dc..b420ae9f2e62 100644 --- a/packages/provider-cursor/src/server/accountUsage.test.ts +++ b/packages/provider-cursor/src/server/accountUsage.test.ts @@ -13,7 +13,6 @@ import * as Path from "effect/Path"; import * as CursorKeychain from "./CursorKeychain.ts"; import { readCursorAccountUsage } from "./accountUsage.ts"; -/** A scoped temporary directory holding `auth.json` with `accessToken`. */ const writeAuth = (accessToken: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -24,7 +23,6 @@ const writeAuth = (accessToken: string) => return { dir, authPath }; }); -/** One dashboard request as Cursor sees it: the URL, the fetch options and the decoded page. */ interface DashboardRequest { readonly url: string; readonly init: RequestInit; @@ -32,7 +30,6 @@ interface DashboardRequest { readonly headers: Headers; } -/** Stands in for Cursor's dashboard: `answer` replies to each page request. */ const dashboard = (answer: (request: DashboardRequest) => Response | Promise) => HttpClient.make((request, url, signal, fiber) => Effect.tryPromise({ @@ -55,7 +52,6 @@ const dashboard = (answer: (request: DashboardRequest) => Response | Promise[0], sinceMs: number, @@ -345,7 +341,6 @@ describe("readCursorAccountUsage", () => { async ({ page }) => { inFlight++; mostInFlight = Math.max(mostInFlight, inFlight); - // Later pages answer first, so record order cannot come from arrival order. for (let turn = page; turn < 9; turn++) await Promise.resolve(); inFlight--; return Response.json({ @@ -375,7 +370,6 @@ describe("readCursorAccountUsage", () => { totalUsageEventsCount: 8001, usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })), }; - // Page 3 fails while the pages after it are still waiting on Cursor. for (const [secondPage, error] of [ [() => Response.json(fullPage), "Cursor account usage could not be read."], [ diff --git a/packages/provider-cursor/src/server/accountUsage.ts b/packages/provider-cursor/src/server/accountUsage.ts index 11976a2e0f1d..432bf60f94cb 100644 --- a/packages/provider-cursor/src/server/accountUsage.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -1,10 +1,3 @@ -/** - * Cursor account usage from its dashboard API, read with the saved CLI or - * Keychain login. The dashboard covers CLI, desktop and headless usage from - * every machine on the account. - * - * @module provider-cursor/server/accountUsage - */ import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; @@ -33,8 +26,6 @@ function tokens(value: unknown): number { } /** - * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`, - * `claude-fable-5-1-thinking-high`) to the base model's rate-table key. * Grok resolves through xAI's first-party entry, which has no bare alias. */ export function cursorRateModel(model: string): string { @@ -51,7 +42,6 @@ export interface CursorAccountUsageReadResult { readonly error: string | null; } -/** Why a dashboard read was abandoned; the caller only sees the generic message. */ class CursorAccountUsageInvalidError extends Schema.TaggedError()( "CursorAccountUsageInvalidError", { reason: Schema.String }, @@ -84,7 +74,6 @@ function canonicalJson(value: unknown): string { return JSON.stringify(value) ?? "null"; } -/** Find the longest exact suffix/prefix overlap in linear time. */ function boundaryOverlap(previous: readonly string[], current: readonly string[]): number { const sequence = [...current, "", ...previous]; const lengths = Array.from({ length: sequence.length }, () => 0); @@ -102,10 +91,6 @@ const DASHBOARD_USAGE_URL = "https://cursor.com/api/dashboard/get-filtered-usage /** The session cookie must never follow a redirect off cursor.com. */ const NO_REDIRECT: RequestInit = { redirect: "error" }; -/** - * Dashboard usage includes headless agents and reports fresh input separately - * from cache reads. `keychainToken` is only run for a Keychain login. - */ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* ( credentialSource: string | { readonly kind: "keychain" }, sinceMs: number, @@ -266,7 +251,6 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi ); // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages // the first one's count implies are requested up to six ahead and consumed in page order. - // Pages still in flight when the read ends are interrupted with its scope. const ahead: Fiber.Fiber< Effect.Success>, CursorAccountUsageInvalidError @@ -277,7 +261,6 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi } const next = ahead.shift(); const events = yield* next === undefined ? readPage(page) : Fiber.join(next); - // A rejected login comes back as the finished result instead of a page. if (!Array.isArray(events)) return events; pages.push(events); if (events.length < pageSize) { diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index b0bd3303755c..43fb080eec38 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -196,7 +196,6 @@ describe("CursorAdapterV2", () => { Stream.takeUntil((event) => event.type === "turn.terminal"), Stream.runCollect, ); - // Keep injected MCP at agent creation so sends preserve Cursor's ambient servers. assert.isFalse(sentWithMcpOverride); const rows = events.filter((event) => event.type === "subagent.updated"); assert.equal(rows[0]?.subagent.status, "running"); @@ -872,8 +871,6 @@ describe("CursorAdapterV2", () => { }); it("loads the user's Cursor settings layers in every runtime mode", () => { - // The SDK loads no rules, skills, hooks, or MCP config from disk unless - // settingSources names them, so an omitted list silently drops AGENTS.md. for (const runtimeMode of ["full-access", "auto-accept-edits", "approval-required"] as const) { const options = makeCursorAgentOptions({ modelSelection: { diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 0d3815c0cfce..1786a0115c82 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -290,9 +290,6 @@ function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { * Every Cursor settings layer the Cursor CLI loads: project and user rules, * skills, hooks, and MCP servers, team and MDM admin policy, and account * plugins. The SDK loads none of them when `settingSources` is omitted. - * Sandbox policy files are read either way, and hooks can only deny or ask - * (which local SDK runs reject), so these layers do not loosen the sandbox or - * approval mode T3 sets. */ const CURSOR_AGENT_SETTING_SOURCES = [ "project", @@ -830,7 +827,6 @@ interface ActiveCursorTurn { readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; readonly startedAt: DateTime.Utc; readonly completed: Deferred.Deferred; - // Item ordinals allocated in this turn. No later turn looks items up here. readonly itemOrdinals: Map; readonly tools: Map; readonly subagents: Map; @@ -1126,7 +1122,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const emitToolArtifacts = Effect.fnUntraced(function* (input: { readonly active: ActiveCursorToolCall; readonly completed: boolean; - /** Terminal status for a tool the turn ended before Cursor completed it. */ readonly unfinishedStatus?: "interrupted" | "failed" | "cancelled"; }) { const { active } = input; @@ -1234,8 +1229,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( toolCall.result?.status === "success" && toolCall.result.value.diffString !== undefined ? { diffStr: toolCall.result.value.diffString } - : // A failed change keeps its error where the diff would be. - toolCall.result?.status === "error" && outputText.trim().length > 0 + : toolCall.result?.status === "error" && outputText.trim().length > 0 ? { diffStr: outputText } : {}), ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), @@ -1267,7 +1261,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( : toolCall.type === "semSearch" ? toolCall.args.targetDirectories?.join(", ") : pattern; - // A failed search keeps its error as one row under the searched path. const results = toolCall.result?.status === "error" && outputText.trim().length > 0 ? [{ fileName: searchPath?.trim() || ".", preview: outputText }] @@ -1985,8 +1978,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( } input.context.finalized = true; const completedAt = yield* DateTime.now; - // Tools still here never got a tool-call-completed. A stopped or - // failed turn cut them short, so they end with the turn's status. for (const tool of input.context.tools.values()) { yield* emitToolArtifacts({ active: tool, @@ -1995,9 +1986,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); } input.context.tools.clear(); - // This interaction stops delivering updates at finalization. Tasks - // without a completion have an unknown outcome. Background launch - // acknowledgements have already settled their rows. for (const subagent of input.context.subagents.values()) { if (!isOrchestrationV2WorkActive(subagent.task.status)) continue; yield* emitSubagent({ @@ -2472,8 +2460,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( interruptTurn: Effect.fn("CursorAdapterV2.interruptTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { const context = yield* Ref.get(activeTurn); - // Stop on a settled turn: finalization already ended its tools - // and subagents, so nothing of it is left running to stop. if (context === null && turnInput.requestRuntimeRestart === true) return; if (context?.providerTurnId !== turnInput.providerTurnId) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ diff --git a/packages/provider-cursor/src/server/auth.ts b/packages/provider-cursor/src/server/auth.ts index 514c4a392890..198bbeb37279 100644 --- a/packages/provider-cursor/src/server/auth.ts +++ b/packages/provider-cursor/src/server/auth.ts @@ -48,7 +48,6 @@ export interface CursorAuthOptions { readonly login?: (options: SdkLoginOptions) => Promise; } -/** Each instance owns its browser credential; explicit API keys always take precedence. */ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: CursorAuthOptions) { const scope = yield* Scope.Scope; const crypto = yield* Crypto.Crypto; @@ -111,7 +110,6 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu return apiKey; }); - // Keep SDK sessions and one-shot text generation inside instance-owned scopes. // Closing admission before draining these scopes prevents cached credentials surviving sign-out. const withAccess: CursorAuth["withAccess"] = (task) => Effect.uninterruptibleMask((restore) => diff --git a/packages/provider-cursor/src/server/credentialStore.test.ts b/packages/provider-cursor/src/server/credentialStore.test.ts index 10e5a6b8d06f..501b91ed6e1c 100644 --- a/packages/provider-cursor/src/server/credentialStore.test.ts +++ b/packages/provider-cursor/src/server/credentialStore.test.ts @@ -18,7 +18,6 @@ const legacyCredentials = { apiKey: "test-only-legacy-key", createdAtMs: 100, }; -// The SDK's FileCredentialStore writes pretty-printed JSON. const legacyFileText = `{ "version": 1, "backendUrl": "https://api.cursor.com", diff --git a/packages/provider-cursor/src/server/credentialStore.ts b/packages/provider-cursor/src/server/credentialStore.ts index 70277a54e216..0fcf53af5b33 100644 --- a/packages/provider-cursor/src/server/credentialStore.ts +++ b/packages/provider-cursor/src/server/credentialStore.ts @@ -21,11 +21,6 @@ const Credentials = Schema.fromJsonString( const decodeCredentials = Schema.decodeUnknownEffect(Credentials); const encodeCredentials = Schema.encodeEffect(Credentials); -/** - * The SDK owns the credential format; persistence uses the environment's secret store. - * `legacyFile` is the SDK file store earlier versions used; its sign-in moves into - * the secret store once and the file is deleted. - */ export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")(function* ( instanceId: ProviderInstanceId, legacyFile?: string, @@ -36,7 +31,6 @@ export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")( yield* Effect.gen(function* () { const legacy = yield* fileSystem.readFileString(legacyFile).pipe(Effect.option); if (Option.isNone(legacy)) return; - // A stored sign-in wins unless it is damaged; the SDK would ignore it anyway. const stored = yield* credentials.get; const storedIsValid = Option.isSome(stored) && diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 2c748bb70d5e..e938024ffc58 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -1,11 +1,3 @@ -/** - * CursorDriver β€” `ProviderDriver` for the Cursor Agent SDK runtime. - * - * Provider status, model discovery, orchestration, and text generation use the - * official Cursor SDK with an instance browser login or CURSOR_API_KEY. - * - * @module provider/Drivers/CursorDriver - */ import { ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; import { CursorSettings } from "../settings.ts"; import * as Effect from "effect/Effect"; @@ -75,7 +67,6 @@ export const CursorDriver: ProviderDriver< }, configSchema: CursorSettings, defaultConfig: (): CursorSettings => decodeCursorSettings({}), - // One account source per environment: the host's Cursor CLI login. usage: { kind: "scan", provider: "cursor", diff --git a/packages/provider-cursor/src/server/skills.test.ts b/packages/provider-cursor/src/server/skills.test.ts index 7b6df4dcc0ae..43124404abf9 100644 --- a/packages/provider-cursor/src/server/skills.test.ts +++ b/packages/provider-cursor/src/server/skills.test.ts @@ -118,7 +118,6 @@ describe("Cursor skills", () => { yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), "---\n---\n"); }); const userRoot = path.join(userHome, ".claude", "skills"); - // The root is depth 0, so `deep-skill` sits at the depth limit and `below` past it. const atLimit = path.join( userRoot, "deep", @@ -214,9 +213,6 @@ describe("Cursor skills", () => { yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents); }); - // A skill package managed in a config repo and installed by symlink. - // Its own SKILL.md must be discovered under the link name, but nothing - // below the target may be walked. yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n"); yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n"); const root = path.join(workspace, ".cursor", "skills"); diff --git a/packages/provider-cursor/src/server/skills.ts b/packages/provider-cursor/src/server/skills.ts index 9f119f28088d..fc2e9bbb87ab 100644 --- a/packages/provider-cursor/src/server/skills.ts +++ b/packages/provider-cursor/src/server/skills.ts @@ -1,12 +1,8 @@ /** - * CursorSkills β€” workspace-aware discovery and native invocation for Cursor. - * * Cursor discovers Agent Skills recursively from user and project roots but * its ACP command catalog only appears after opening a real session. Scanning * the same roots avoids starting an agent and its MCP servers just to populate * a composer menu. - * - * @module provider/Drivers/CursorSkills */ import type { ServerProviderSkill } from "@t3tools/contracts"; @@ -204,8 +200,6 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( const child = path.join(directory, entry); const info = yield* orUndefined(fileSystem.stat(child), input.budget); if (info?.type !== "Directory") continue; - // A tree deeper than the limit is skipped rather than fatal: the same tree would fail - // every refresh and hide every other skill with it. if (depth >= MAX_SKILL_DEPTH) continue; yield* visit(child, depth + 1); } diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts index 10650abb9021..1797e5d38522 100644 --- a/packages/provider-cursor/src/server/textGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -47,10 +47,6 @@ function cursorSandboxUnsupported(cause: unknown): boolean { return false; } -/** - * Build a Cursor text-generation closure bound to a specific `CursorSettings` - * payload. See `makeCodexAdapter` for the overall per-instance rationale. - */ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, @@ -83,7 +79,6 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu return apiKey; }); - // Ignores `cwd`: the agent runs in an empty temp directory, away from the project. const runCursorJson: TextGenerationOperations.Runner = (input) => { const { operation, prompt, modelSelection } = input; return Effect.gen(function* () { @@ -138,8 +133,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu ); const request = Effect.gen(function* () { - // Prefer the sandbox. When the SDK refuses it, the empty temp directory - // and empty setting sources still keep this run off the user's project. + // When the SDK refuses it, the empty temp directory and empty setting + // sources still keep this run off the user's project. const agent = yield* Effect.acquireRelease( createCursorAgent(true).pipe( Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)), diff --git a/packages/provider-cursor/src/server/transportFailure.ts b/packages/provider-cursor/src/server/transportFailure.ts index e6cadb23dc0d..2f2224320686 100644 --- a/packages/provider-cursor/src/server/transportFailure.ts +++ b/packages/provider-cursor/src/server/transportFailure.ts @@ -22,7 +22,6 @@ function consumeLine(state: ReplyState, line: string) { } } -/** Tracks a standalone Cursor diagnostic without retaining an entire streamed answer. */ export class CursorTransportFailure { private state: ReplyState = { disqualified: false, failure: undefined }; private line = ""; diff --git a/packages/provider-cursor/src/settings.ts b/packages/provider-cursor/src/settings.ts index d4d5017d4f89..ea91ab50bd74 100644 --- a/packages/provider-cursor/src/settings.ts +++ b/packages/provider-cursor/src/settings.ts @@ -1,16 +1,9 @@ -/** - * Cursor instance settings. Shared by the server driver and the client - * settings form, so it holds only browser-safe schema code. - * - * @module provider-cursor/settings - */ import { CustomModelSetting, makeProviderSettingsSchema, TrimmedString } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; export const CursorSettings = makeProviderSettingsSchema( { - // Off by default like Grok and OpenCode. Users opt in from Settings. enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), diff --git a/packages/provider-cursor/src/testing.ts b/packages/provider-cursor/src/testing.ts index 1357074782f5..1ac0ad7984db 100644 --- a/packages/provider-cursor/src/testing.ts +++ b/packages/provider-cursor/src/testing.ts @@ -1,9 +1,3 @@ -/** - * Cursor internals the server's replay testkit, fixture recorder, and - * orchestration tests use directly. - * - * @module provider-cursor/testing - */ export { CURSOR_DEFAULT_INSTANCE_ID, CURSOR_DRIVER_KIND, diff --git a/packages/provider-grok/src/client.ts b/packages/provider-grok/src/client.ts index d2ff95424f8c..e227d8a4a6e3 100644 --- a/packages/provider-grok/src/client.ts +++ b/packages/provider-grok/src/client.ts @@ -1,9 +1,3 @@ -/** - * Grok's client definition: settings form, label, and glyph. - * Browser- and React Native-safe. - * - * @module provider-grok/client - */ import { ProviderDriverKind } from "@t3tools/contracts"; import { defineProviderClient } from "@t3tools/provider-core/client"; diff --git a/packages/provider-grok/src/server.ts b/packages/provider-grok/src/server.ts index 4fe180316a84..2654b00c3ad9 100644 --- a/packages/provider-grok/src/server.ts +++ b/packages/provider-grok/src/server.ts @@ -1,7 +1,2 @@ -/** - * Grok's server entry: the driver and adapter driver the server registers. - * - * @module provider-grok/server - */ export { GrokDriver, type GrokDriverEnv } from "./server/driver.ts"; export { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/packages/provider-grok/src/server/acpCliProbe.test.ts b/packages/provider-grok/src/server/acpCliProbe.test.ts index eed0c1159342..1c2fd347aa39 100644 --- a/packages/provider-grok/src/server/acpCliProbe.test.ts +++ b/packages/provider-grok/src/server/acpCliProbe.test.ts @@ -1,13 +1,3 @@ -/** - * Optional integration check against a real `grok agent stdio` install. - * Enable with: T3_GROK_ACP_PROBE=1 vp test run GrokAcpCliProbe - * Set T3_GROK_LIVE_TURN=1 to also send a small prompt to the real model. - * - * The probe assumes either `XAI_API_KEY` is set in the environment or - * the user has previously run `grok login`. Without credentials the - * agent's `authenticate` request will fail and the test will surface - * the error. - */ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; diff --git a/packages/provider-grok/src/server/acpSupport.ts b/packages/provider-grok/src/server/acpSupport.ts index 60d395284648..726e28cd6ff6 100644 --- a/packages/provider-grok/src/server/acpSupport.ts +++ b/packages/provider-grok/src/server/acpSupport.ts @@ -48,9 +48,8 @@ export const GROK_SUPPORTED_RUNTIME_MODES = [ ] as const satisfies ReadonlyArray; /** - * Launch argv for a runtime mode. `--permission-mode` on the argv beats the - * user's Grok config, so Supervised cannot inherit a configured always-approve. - * A mode Grok does not offer launches asking. + * `--permission-mode` on the argv beats the user's Grok config, so Supervised + * cannot inherit a configured always-approve. */ export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray { switch (runtimeMode) { @@ -105,8 +104,7 @@ export function grokAcpRuntimeProcessOwnership( /** * Current Grok treats Ctrl+C cancellation as a barrier against stale - * background-task wake prompts until the next genuine user turn. Replay sends - * the same metadata so recorded cancels match. + * background-task wake prompts until the next genuine user turn. */ export const GROK_ACP_CANCEL_META = { cancelTrigger: "ctrl_c" } as const; @@ -129,10 +127,6 @@ export const GROK_ACP_INITIALIZE_META = { clientType: "extension" } as const; */ const GROK_ALLOW_EDITS_SESSION_OPTION_ID = "allow-edits-session"; -/** - * The approval choices a Grok permission prompt can honor. The session choice - * appears only where Grok's answer lasts for the session. - */ export function grokApprovalOptions( request: EffectAcpSchema.RequestPermissionRequest, ): ReadonlyArray { @@ -184,8 +178,8 @@ export const makeGrokAcpRuntime = ( }); /** - * T3's built-in Grok slug. It is the CLI's product name, not a model id the ACP accepts, - * so selecting it means "use whatever model the Grok session currently runs on". + * It is the CLI's product name, not a model id the ACP accepts, so selecting it + * means "use whatever model the Grok session currently runs on". */ export const GROK_DEFAULT_MODEL_SLUG = "grok-build"; @@ -246,7 +240,6 @@ export function applyGrokAcpModelSelection(input: { readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { - // The product slug is never sent over the wire; it keeps the session's current model. const requestedModelId = input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId; const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId; @@ -262,11 +255,6 @@ export function applyGrokAcpModelSelection(input: { } const reasoningMeta = reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined; - // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately - // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not - // provided at all, we also send no meta, but we only reach this call when the model itself - // changed - an omitted reasoning preference must not be treated as an explicit clear of the - // CLI-advertised default (e.g. Extra High) on same-model reselections. return input.runtime .setSessionModel(targetModelId, reasoningMeta) .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId)); diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index 224654585625..bb78c1584867 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -150,10 +150,8 @@ const registerGrokAcpExtensions: NonNullable extractGrokPlanMarkdownFromToolCallData(toolCall.data, { platform: options.hostPlatform, diff --git a/packages/provider-grok/src/server/driver.test.ts b/packages/provider-grok/src/server/driver.test.ts index b3e96bad0d04..1962f0b6cd1b 100644 --- a/packages/provider-grok/src/server/driver.test.ts +++ b/packages/provider-grok/src/server/driver.test.ts @@ -70,7 +70,6 @@ it.layer(layerTest)("GrokDriver", (it) => { executable: binaryPath, args: ["update"], }); - // `grok update` installs under GROK_HOME, so it must target this instance's home. expect(capabilities.update?.env?.GROK_HOME).toBe(grokHome); }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner), diff --git a/packages/provider-grok/src/server/driver.ts b/packages/provider-grok/src/server/driver.ts index fe5b8705471a..9be2dd85b9e9 100644 --- a/packages/provider-grok/src/server/driver.ts +++ b/packages/provider-grok/src/server/driver.ts @@ -50,8 +50,7 @@ const DRIVER_KIND = ProviderDriverKind.make("grok"); const GROK_NPM_PACKAGE = "@xai-official/grok"; // `grok update` finds the installer that owns the binary itself, so the // resolved executable is its own updater. It installs under `GROK_HOME`, so it -// runs with the instance's environment. No executable means nothing to update, -// not "whatever is on PATH". +// runs with the instance's environment. const UPDATE: ProviderMaintenanceCapabilitiesResolver = { resolve: (context) => Effect.succeed( diff --git a/packages/provider-grok/src/server/skills.ts b/packages/provider-grok/src/server/skills.ts index 01a937995331..f39d005f7321 100644 --- a/packages/provider-grok/src/server/skills.ts +++ b/packages/provider-grok/src/server/skills.ts @@ -1,19 +1,7 @@ /** - * GrokSkills β€” skill discovery for the `$` picker via `grok inspect --json`. - * - * Unlike Claude Code, the Grok CLI reports its full skill catalog itself: - * `grok inspect --json` returns `skills[]` with `name`, `description`, - * `source.type` (`user` / `project` / `bundled` / `plugin`), `source.path` - * (the absolute `SKILL.md` path), and `userInvocable`. Asking the CLI beats - * scanning the filesystem because the catalog honors Grok's own skill config - * (ignore lists, disabled skills) and includes plugin skills, which live - * three levels deep under `~/.grok/installed-plugins/` where a flat scan - * cannot see them. This mirrors how the Codex app-server reports skills over - * `skills/list`. Probe failures stay typed so workspace snapshots do not - * cache an empty catalog; machine-level discovery recovers them to an empty - * list without degrading the provider. - * - * @module provider/Drivers/GrokSkills + * Asking the CLI beats scanning the filesystem because the catalog honors Grok's own skill + * config (ignore lists, disabled skills) and includes plugin skills, which live three levels + * deep under `~/.grok/installed-plugins/` where a flat scan cannot see them. */ import type { ServerProviderSkill } from "@t3tools/contracts"; import type { GrokSettings } from "../settings.ts"; @@ -44,11 +32,6 @@ class GrokSkillsProbeError extends Schema.TaggedError()( } } -/** - * Map `grok inspect --json` output onto provider skills. Entries without a - * name or a filesystem path are skipped; `userInvocable: false` skills are - * kept but disabled so pickers that filter on `enabled` hide them. - */ function decodeGrokInspectSkills(stdout: string): ReadonlyArray | undefined { let parsed: unknown; try { @@ -93,11 +76,6 @@ function decodeGrokInspectSkills(stdout: string): ReadonlyArray left.name.localeCompare(right.name)); } -/** - * Run `grok inspect --json` and map the reported catalog onto provider - * skills. Callers that need best-effort discovery can recover this effect to - * an empty list; workspace callers leave failures typed so they are not cached. - */ export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* ( grokSettings: Pick, environment: NodeJS.ProcessEnv = process.env, diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index 4e45bb9b0f06..65322c04a38f 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -59,7 +59,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ }); const VERSION_PROBE_TIMEOUT_MS = 4_000; -// `initialize` is a single local round trip, so this is generous even on slow machines. const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; const GROK_API_KEY_ENV = "XAI_API_KEY"; @@ -216,7 +215,6 @@ export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): Mo : EMPTY_CAPABILITIES; } -/** Models advertised by the ACP agent, with the session's current model marked as default. */ export function buildGrokModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, ): ReadonlyArray { @@ -244,20 +242,13 @@ export function buildGrokModelsFromSessionModelState( } export interface GrokModelsCliOutput { - /** True or false when the CLI printed a login line, null when it printed neither. */ readonly authenticated: boolean | null; readonly models: ReadonlyArray; } /** - * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the - * text is the only signal. Current output looks like: - * - * You are logged in with grok.com. - * Default model: grok-4.6 - * Available models: - * * grok-4.6 (default) - * - grok-4.5 + * The command exits 0 whether or not the user is logged in, so the text is the + * only signal. */ export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput { const authenticated = /you are logged in/i.test(output) @@ -334,7 +325,6 @@ export function grokSlashCommandsFromInitialize( if (Option.isNone(decoded)) continue; const command = decoded.value; const name = command.name.trim(); - // Permission changes must go through T3 so the client and provider agree. if (!name || name.toLowerCase() === "always-approve") continue; // Grok advertises /context, but its ACP handler completes without emitting output. if (name.toLowerCase() === "context") continue; @@ -350,8 +340,8 @@ export function grokSlashCommandsFromInitialize( } /** - * Reads model and command metadata from `initialize._meta`. This never calls `authenticate` - * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers. + * This never calls `authenticate` or `session/new`, so it cannot open a browser login or + * boot the workspace's MCP servers. */ const discoverGrokMetadataViaAcpInitialize = ( grokSettings: GrokSettings, @@ -554,7 +544,6 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func probe: { installed: true, version, - // A failed metadata probe degrades the model picker, it does not make chats fail. status: acpFailed ? "warning" : "ready", auth, ...(acpFailed diff --git a/packages/provider-grok/src/server/usage.test.ts b/packages/provider-grok/src/server/usage.test.ts index d4b6122dbafb..fb73e837805b 100644 --- a/packages/provider-grok/src/server/usage.test.ts +++ b/packages/provider-grok/src/server/usage.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "@effect/vitest"; import { GROK_COST_USD_TICKS_PER_DOLLAR, parseGrokLine } from "./usage.ts"; describe("parseGrokLine", () => { - /** Shaped after a real Grok Build `turn_completed` session update. */ function turnCompleted(overrides?: { sessionId?: string; promptId?: string; diff --git a/packages/provider-grok/src/server/usage.ts b/packages/provider-grok/src/server/usage.ts index be78318753d6..1eda1f34b86e 100644 --- a/packages/provider-grok/src/server/usage.ts +++ b/packages/provider-grok/src/server/usage.ts @@ -1,10 +1,3 @@ -/** - * Usage history for Grok Build: the format of its per-session - * `updates.jsonl` logs and where each instance's `GROK_HOME` keeps them. - * - * @module provider-grok/server/usage - */ - import type { UsageTokenTotals } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; @@ -23,7 +16,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; /** * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok - * headless `total_cost_usd_ticks`. Convert to dollars for pricing. + * headless `total_cost_usd_ticks`. */ export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; @@ -75,15 +68,6 @@ function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals { }; } -/** - * Parses one line of a Grok Build `updates.jsonl` session log. - * - * Usage lands on `turn_completed` session updates. Per-model breakdowns live - * under `usage.modelUsage`; when present each model becomes its own record. - * - * Returns every record for the line (0 or more). Callers stream line-by-line - * and flatten. - */ export function parseGrokLine(line: string): readonly UsageRecord[] { let parsed: unknown; try { @@ -114,7 +98,6 @@ function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : ""; const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null; - // Prefer the high-resolution agent clock; fall back to the outer unix seconds. const meta = paramsRecord["_meta"]; let timestampMs: number | null = null; if (typeof meta === "object" && meta !== null) { @@ -162,14 +145,6 @@ function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { ]; } - // Cost allocation: - // 1. Emitted models with their own costUsdTicks keep those values. - // 2. Remaining aggregate cost (top-level minus those per-model ticks, - // clamped at 0) is pro-rated across emitted models that lack ticks, - // by token share among the unticked models only. - // 3. When no model has per-model ticks, remaining equals the full - // aggregate and every emitted model gets a token-share slice. - // Zero-token rows are never emitted and never count toward used ticks. const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks); let usedTickedCostUsd = 0; let untickedTokenDenominator = 0; diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index d4a855883a6c..2d7a16fbb70a 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -47,11 +47,7 @@ export function grokUsageResponseToLimits( // A billing read that succeeded but carries no percentage is an account // with nothing metered yet, not one that can never report: xAI omits the // field entirely (rather than sending 0) until usage registers, then fills - // it in. Calling that `unsupported` would strand the account β€” the Limits - // view drops unsupported entries and deliberately mutes their notice, so a - // freshly signed-in Grok account would vanish with no explanation until it - // happened to be used, and `applyUsageLimitsUpdate` would refuse the - // mid-turn windows that could have recovered it. + // it in. return makeUsageLimits({ checkedAt, windows: [] }); } const period = response.config?.currentPeriod; @@ -68,10 +64,6 @@ export function grokUsageResponseToLimits( return makeUsageLimits({ checkedAt, windows: [window] }); } -/** - * The grok.com login the CLI uses by default, or undefined when the CLI is - * configured to pick another account, endpoint, or an API key. - */ const readGrokCredential = Effect.fn("readGrokCredential")(function* ( environment: NodeJS.ProcessEnv, ) { @@ -138,10 +130,6 @@ const readGrokCredential = Effect.fn("readGrokCredential")(function* ( return credential?.auth_mode === "api_key" ? undefined : credential; }); -/** - * Reads the default grok.com login once and reports its usage limits along with - * its email, so the email always names the account whose quota was read. - */ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( environment: NodeJS.ProcessEnv = process.env, ) { @@ -160,7 +148,6 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( if (!token) { return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; } - // A failed quota request still knows which account it asked about. const usageLimits = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const response = yield* client.execute( diff --git a/packages/provider-grok/src/server/xaiAcpExtension.test.ts b/packages/provider-grok/src/server/xaiAcpExtension.test.ts index 18803e0f39e4..c31d9438972c 100644 --- a/packages/provider-grok/src/server/xaiAcpExtension.test.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.test.ts @@ -85,7 +85,6 @@ describe("xAiPromptCompleteFromSessionUpdate", () => { }); }); -// Runs a recorded Grok tool_call_update through the adapter's parse path. function parseRecordedToolCallUpdate(update: Record): AcpToolCallState { const [event] = parseSessionUpdateEvent({ sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2", @@ -464,7 +463,6 @@ describe("XAiAcpExtension", () => { }); it("finishes monitors from the recorded task_completed snapshot", () => { - // Fields from the grok_monitor recording (Grok 1.0.41). const completed = { sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2", update: { @@ -522,9 +520,6 @@ describe("XAiAcpExtension", () => { }); it("completes status-less Bash results despite generic titles", () => { - // A Bash result that carries no status (empty rawInput, generic title) is - // terminalized from its exit_code. An explicit in_progress status wins: - // that is how Grok streams a still-running monitor. const toolCall = { toolCallId: "call-wake-1", title: "Tool", @@ -627,7 +622,6 @@ describe("XAiAcpExtension", () => { }); expect(normalized.title).toBe("Monitor: Wait 30s then list directory"); expect(normalized.status).toBe("inProgress"); - // Non-generic titles from the CLI are preserved. expect( resolveXAiAcpToolTitle({ toolCallId: "call-read", @@ -818,7 +812,6 @@ describe("XAiAcpExtension", () => { { taskId: "call-bg-1", status: "completed", appendOutput: "" }, { taskId: "call-bg-2", status: "completed", appendOutput: "" }, ]); - // Singular task_id shape, matched by variant when the title is generic. expect( extractXAiKilledBackgroundTasks({ toolCallId: "call-kill-2", @@ -827,7 +820,6 @@ describe("XAiAcpExtension", () => { data: { rawInput: { variant: "kill", task_id: "call-bg-3" } }, }), ).toEqual([]); - // In-flight kill calls and unrelated tools contribute nothing. expect( extractXAiKilledBackgroundTasks({ toolCallId: "call-kill-3", @@ -847,7 +839,6 @@ describe("XAiAcpExtension", () => { }); it("maps task lifecycle notifications to background mutations", () => { - // task_backgrounded carries the cancelled call's tool_call_id as task_id. expect( xAiBackgroundTaskLifecycleMutation( { @@ -861,7 +852,6 @@ describe("XAiAcpExtension", () => { "running", ), ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "running" }); - // task_completed nests the id inside task_snapshot. expect( xAiBackgroundTaskLifecycleMutation( { @@ -969,8 +959,6 @@ describe("XAiAcpExtension", () => { }); it("keeps a standalone TaskOutput completion terminal through normalize", () => { - // Must remain offer evidence post-settle: normalize must not demote it to - // inProgress the way monitor start ACKs are. expect( normalizeXAiAcpToolCallState({ toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1", @@ -1018,7 +1006,6 @@ describe("XAiAcpExtension", () => { status: "completed", }, ]); - // Description/output may contain "error" without meaning the monitor failed. expect( extractXAiAcpBackgroundToolMutation( [ @@ -1491,7 +1478,6 @@ describe("XAiAcpExtension", () => { expect( finished({ status: "cancelled", error: "interrupted by process restart" }), ).toMatchObject({ status: "cancelled", result: "interrupted by process restart" }); - // A failed subagent's error is its result; `output` only accompanies success. expect(finished({ status: "failed", output: "partial" })).toMatchObject({ result: null }); expect(finished({ status: "timed_out" })).toBeNull(); expect(finished({})).toBeNull(); @@ -1527,8 +1513,6 @@ describe("XAiAcpExtension", () => { } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); const notices: Array = []; - // Registered after the wrapper, as the adapter does: it must not replace - // the wrapper's prompt completion on the shared method. yield* registerXAiSubagentFinished(runtime, (notice) => Effect.sync(() => notices.push(notice)), ); @@ -1940,7 +1924,6 @@ describe("Grok exit_plan_mode capture (#8358)", () => { expect(isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", grokHomeHost)).toBe( false, ); - // Workspace plan.md must not be treated as the session plan file. expect(isGrokPlanMarkdownPath("plan.md", linuxHost)).toBe(false); expect(isGrokPlanMarkdownPath("/repo/docs/plan.md", linuxHost)).toBe(false); expect(isGrokPlanMarkdownPath("/tmp/other.md", linuxHost)).toBe(false); diff --git a/packages/provider-grok/src/server/xaiAcpExtension.ts b/packages/provider-grok/src/server/xaiAcpExtension.ts index 3fe2c2886dea..c392b88cb0db 100644 --- a/packages/provider-grok/src/server/xaiAcpExtension.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.ts @@ -24,16 +24,6 @@ const XAiPromptCompleteNotification = Schema.Struct({ type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; -/** - * Grok persists root turn completion as an extension session update: - * `{ params: { sessionId, update: { - * sessionUpdate: "turn_completed", prompt_id, stop_reason } } }`. - * The open-source runtime forwards it as `x.ai/session_notification` and - * stores/replays it as `_x.ai/session/update`; released 0.2.106 forwards the - * underscore alias `_x.ai/session_notification`. Current source also emits - * the fire-and-forget canonical `x.ai/session/prompt_complete`, while released - * builds use the underscore alias. - */ const XAiSessionUpdateNotification = Schema.Struct({ sessionId: Schema.String, update: Schema.Struct({ @@ -43,7 +33,6 @@ const XAiSessionUpdateNotification = Schema.Struct({ stop_reason: Schema.optional(Schema.String), stopReason: Schema.optional(Schema.String), agent_result: Schema.optional(Schema.NullOr(Schema.Unknown)), - // subagent_finished child_session_id: Schema.optional(Schema.String), status: Schema.optional(Schema.String), output: Schema.optional(Schema.NullOr(Schema.String)), @@ -64,11 +53,6 @@ const xAiSessionNotificationMethods = [ const XAI_TASK_COMPLETED_PROMPT_ID_PREFIX = "task-completed-"; -/** - * Map a Grok `_x.ai/session/update` payload to a prompt-complete shape when it - * is a root `turn_completed` for a real prompt id. Returns null for hooks, - * background task completions, and other update kinds. - */ export function xAiPromptCompleteFromSessionUpdate( notification: XAiSessionUpdateNotification, ): XAiPromptCompleteNotification | null { @@ -261,7 +245,6 @@ function isXAiGetSubagentOutputTool( const rawOutput = unknownRecord(toolCall.data.rawOutput); const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase(); if (outputType === "taskoutput" || outputType === "task_output") return true; - // ACP often titles this tool with the finished subagent label. if (title.startsWith("[subagent:") && Array.isArray(rawInput?.task_ids)) return true; return false; } @@ -304,7 +287,6 @@ export function extractXAiMonitorTaskId(toolCall: AcpToolCallState): string | un if (backgroundShellTaskId !== undefined) return backgroundShellTaskId; if (!isXAiMonitorTool(toolCall)) return undefined; const rawOutput = unknownRecord(toolCall.data.rawOutput); - // Live ACP start ACK: { type: "Monitor", taskId, timeoutMs, persistent } if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") { const structured = nonEmptyString(rawOutput.taskId) ?? nonEmptyString(rawOutput.task_id); if (structured !== undefined) return structured; @@ -339,10 +321,6 @@ export function isXAiPersistentMonitor(toolCall: AcpToolCallState): boolean { return rawInput?.persistent === true; } -/** - * When get_command_or_subagent_output / TaskOutput completes registered monitor - * task(s), return hydration for each completed background tool id. - */ export function extractXAiBackgroundTaskCompletion(toolCall: AcpToolCallState): ReadonlyArray<{ readonly taskId: string; readonly status: "running" | "completed" | "failed"; @@ -350,8 +328,6 @@ export function extractXAiBackgroundTaskCompletion(toolCall: AcpToolCallState): }> { const rawInput = unknownRecord(toolCall.data.rawInput); if (!isXAiGetSubagentOutputTool(toolCall, rawInput)) return []; - // Caller matches taskId against registered background tools (monitors). - // Subagent hydration stays on extractXAiAcpSubagentUpdate. const text = xAiToolOutputText(toolCall); const status = statusFromGetOutputTool(toolCall, text); const appendOutput = resultFromGetOutputTool(toolCall, text) ?? ""; @@ -427,7 +403,6 @@ const XAiTaskLifecycleNotification = Schema.Struct({ command: Schema.optional(Schema.String), display_command: Schema.optional(Schema.NullOr(Schema.String)), description: Schema.optional(Schema.NullOr(Schema.String)), - // `TaskKind` in grok-build crates/common/xai-tool-runtime/src/notification.rs. kind: Schema.optional(Schema.String), }), ), @@ -441,9 +416,7 @@ export interface XAiBackgroundTaskLifecycleMutation { readonly sessionId: string; readonly taskId: string; readonly status: "running" | "completed" | "failed"; - /** Final output from `task_completed.task_snapshot`, when Grok sent one. */ readonly output?: string; - /** What the task is and how to name it, when Grok said. */ readonly report?: | { readonly kind: "command"; readonly label?: string; readonly exitCode?: number } | { readonly kind: "monitor"; readonly label?: string }; @@ -504,9 +477,7 @@ export function xAiBackgroundTaskLifecycleMutation( * current builds; older 0.2.x builds used `_x.ai/task_*`. Interactive cancel * preserves tasks that were already backgrounded (and some older builds could * background the cancelled foreground command), so both spellings must be - * tracked. Residual frames then buffer instead of waking synthetic - * continuations, and pending-background-work stays truthful until the task - * ends (or the model kills it via kill_command_or_subagent). + * tracked. */ export const registerXAiBackgroundTaskTracking = ( runtime: Pick, @@ -528,14 +499,10 @@ export const registerXAiBackgroundTaskTracking = ( ); /** - * A background subagent's end, sent on its PARENT session as - * `{ sessionUpdate: "subagent_finished", child_session_id, status, output, error }` - * (grok-build `crates/codegen/xai-grok-shell/src/extensions/notification.rs` - * `SessionUpdate::SubagentFinished`). `status` is exactly "completed", - * "failed" or "cancelled" (`SubagentResult::status()` in - * `crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs`); - * `output` is the final text of a completed subagent and `error` the message - * of a failed one. Grok follows it with its own `subagent-completed-` wake + * A background subagent's end, sent on its PARENT session as `{ sessionUpdate: + * "subagent_finished", child_session_id, status, output, error }` (grok-build + * `crates/codegen/xai-grok-shell/src/extensions/notification.rs` + * `SessionUpdate::SubagentFinished`). Grok follows it with its own `subagent-completed-` wake * turn when `will_wake` is true. */ export interface XAiSubagentFinishedNotice { @@ -545,7 +512,6 @@ export interface XAiSubagentFinishedNotice { readonly result: string | null; } -/** Null for other session updates, and for a status Grok does not define. */ export function xAiSubagentFinishedNotice( notification: XAiSessionUpdateNotification, ): XAiSubagentFinishedNotice | null { @@ -563,12 +529,6 @@ export function xAiSubagentFinishedNotice( }; } -/** - * Finishes background subagents from Grok's `subagent_finished`. These arrive - * on the session notification methods that also carry turn completion; a - * runtime from {@link makeXAiPromptCompletionRuntime} settles its prompt from - * each notification before passing it to this handler. - */ export const registerXAiSubagentFinished = ( runtime: Pick, finish: (notice: XAiSubagentFinishedNotice) => Effect.Effect, @@ -583,11 +543,6 @@ export const registerXAiSubagentFinished = ( { discard: true }, ); -/** - * Grok ACP often sends `title: "Tool"` even when rawInput has a useful - * description or variant (especially Monitor). Prefer description/variant so - * the timeline matches Claude-style tool names rather than a generic label. - */ export function isGenericAcpToolTitle(title: string | undefined): boolean { const key = (title ?? "").trim().toLowerCase(); return key.length === 0 || key === "tool" || key === "tool call" || key === "terminal"; @@ -624,15 +579,9 @@ export function resolveXAiAcpToolTitle(toolCall: AcpToolCallState): string | und } /** - * Normalize Grok ACP tool presentation and monitor lifecycle: - * - replace generic titles ("Tool") with description / variant labels - * - structured Monitor start ACK stays running - * - text start ACK stays running - * - structured Bash results with exit_code are terminal, unless Grok itself - * still reports the tool running. Grok 1.0.41 streams every monitor tick as - * `status: "in_progress"` with a Bash-shaped rawOutput whose exit_code is - * already 0, and never sends a completed status for the monitor; its end - * arrives as `_x.ai/task_completed`. + * Grok 1.0.41 streams every monitor tick as `status: "in_progress"` with a Bash-shaped rawOutput + * whose exit_code is already 0, and never sends a completed status for the monitor; its end + * arrives as `_x.ai/task_completed`. */ export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToolCallState { const resolvedTitle = resolveXAiAcpToolTitle(toolCall); @@ -660,14 +609,12 @@ export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToo } } if (xAiBackgroundShellTaskId(withTitle) !== undefined) { - // Start acknowledgement only; the command still runs in the background. return { ...withTitle, status: "inProgress" }; } if (!isXAiMonitorTool(withTitle)) { return withTitle; } if (outputType === "monitor") { - // Start registration only; process still running in the background. return { ...withTitle, status: "inProgress" }; } const output = xAiToolOutputText(withTitle); @@ -677,12 +624,6 @@ export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToo return withTitle; } -/** - * Parse Grok synthetic monitor traffic that arrives as root text chunks - * (`` lines, batched `` event blocks, and - * "Monitor ... ended" reminders). Coalesced chunks may contain several events; - * return every match so later progress / end notices are not dropped. - */ export function extractXAiAcpBackgroundToolMutation( text: string, ): ReadonlyArray { @@ -702,9 +643,6 @@ export function extractXAiAcpBackgroundToolMutation( }); } - // Batched form: "N monitor events from 1 monitor ...:\n\n[1] line\n". Still a running - // monitor; must track and filter like single-event chatter. const batchedRe = new RegExp( `]*task_id=["']?(${XAI_UUID_RE})["']?[^>]*>\\s*([\\s\\S]*?)\\s*`, "gi", @@ -745,11 +683,9 @@ export function extractXAiAcpBackgroundToolMutation( } /** - * Parse Grok's root-session subagent end reminder ("Background subagent - * "" (general-purpose: "...") completed successfully."). The agent is - * free to skip get_command_or_subagent_output hydration, so this reminder can - * be the only terminal signal for the subagent row; a row stuck on running - * holds deferred finalize (and the whole run) open until harness timeout. + * The agent is free to skip get_command_or_subagent_output hydration, so this reminder can be the + * only terminal signal for the subagent row; a row stuck on running holds deferred finalize (and + * the whole run) open until harness timeout. */ function stripLeadingBalancedParenGroup(text: string): string { const trimmed = text.replace(/^\s+/, ""); @@ -806,7 +742,6 @@ function taskIdsFromGetOutputTool( return [...new Set(ids)]; } -/** Identity of the task the get_output envelope actually reports (not the request list). */ function resultTaskIdFromGetOutputTool( toolCall: AcpToolCallState, output: string | undefined, @@ -840,7 +775,6 @@ function resultFromGetOutputTool( nonEmptyString(result?.text) ?? decodeByteText(result?.output); if (structured !== undefined) { - // Drop trailing machine meta blocks when present. const cleaned = structured .replace(/[\s\S]*?<\/subagent_meta>/gi, "") .replace(/[\s\S]*?<\/subagent_result>/gi, "") @@ -886,12 +820,9 @@ function statusFromGetOutputTool( } /** - * Recognizes Grok Task / spawn_subagent envelopes and get_command hydration - * without teaching the generic ACP adapter about xAI tool names. - * - * Current Grok CLI returns spawn_subagent immediately with a background ACK - * (`subagent_id: ...`). That must stay `running` until child work finishes or - * get_command_or_subagent_output reports a terminal status. + * Current Grok CLI returns spawn_subagent immediately with a background ACK (`subagent_id: ...`). + * That must stay `running` until child work finishes or get_command_or_subagent_output reports a + * terminal status. */ export function extractXAiAcpSubagentUpdate( toolCall: AcpToolCallState, @@ -901,16 +832,11 @@ export function extractXAiAcpSubagentUpdate( if (isXAiGetSubagentOutputTool(toolCall, rawInput)) { const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput); - // Prefer the completed task identity from the result/header over the first - // requested task_ids entry (one call can poll multiple task_ids). const childSessionId = resultTaskIdFromGetOutputTool(toolCall, output) ?? taskIds[0] ?? extractXAiChildSessionId(output); if (childSessionId === null) return undefined; - // Prefer the durable subagent row as the completion surface (Claude/Codex - // style). Suppress the noisy TaskOutput tool card; monitor hydration is - // handled separately via extractBackgroundTaskCompletion. return { nativeTaskId: toolCall.toolCallId, prompt: "", @@ -940,7 +866,6 @@ export function extractXAiAcpSubagentUpdate( if (toolCall.status === "failed") { status = "failed"; } else if (asyncSpawnAck) { - // Spawn RPC completed, but the child is still running. status = "running"; } else if (toolCall.status === "completed") { status = "completed"; @@ -994,10 +919,6 @@ export const XAiAskUserQuestionRequest = Schema.Union([ type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type; type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type; -// --------------------------------------------------------------------------- -// x.ai/exit_plan_mode β€” plan approval gate (mirrors Grok Build TUI plan window) -// --------------------------------------------------------------------------- - const XAiExitPlanModeParams = Schema.Struct({ sessionId: Schema.String, toolCallId: Schema.String, @@ -1021,7 +942,6 @@ function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanMo return "params" in params ? params.params : params; } -/** Empty-state copy when Grok exits plan mode without a plan file. */ export const XAI_EMPTY_PLAN_MARKDOWN = "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; @@ -1081,7 +1001,6 @@ function addGrokSessionPrefix( prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`); } -/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */ export interface GrokPlanPathHost { readonly platform: NodeJS.Platform; readonly environment: NodeJS.ProcessEnv; @@ -1110,12 +1029,6 @@ const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp( "i", ); -/** - * True when a path is Grok's session plan file under a Grok home - * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`). - * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md - * or a repo-local `.grok/sessions/.../plan.md`). - */ export function isGrokPlanMarkdownPath( path: string | undefined | null, host: GrokPlanPathHost, @@ -1139,7 +1052,6 @@ export function isGrokPlanMarkdownPath( continue; } const rest = haystack.slice(needle.length); - // Session layout: /.grok/sessions///plan.md if (rest !== "plan.md" && rest.endsWith("plan.md")) { return true; } @@ -1149,10 +1061,6 @@ export function isGrokPlanMarkdownPath( ).test(haystack); } -/** - * Extract plan markdown from a Grok write/edit tool call targeting plan.md. - * Used so T3 can show the plan while plan mode is still active (before exit). - */ export function extractGrokPlanMarkdownFromToolCallData( data: Record | undefined, host: GrokPlanPathHost, @@ -1378,8 +1286,6 @@ function promptResponseFromXAi( notification: XAiPromptCompleteNotification, ): EffectAcpSchema.PromptResponse { const stopReason = normalizeXAiStopReason(notification.stopReason); - // Values originate from a decoded JSON-RPC payload, so they are JSON even - // though the x.ai schema types agentResult as unknown. const meta: Record = { sessionId: notification.sessionId, }; @@ -1414,7 +1320,6 @@ function xAiPromptFailure( }); } if (notification.stopReason === "error") { - // Grok's raw result is unbounded provider text: keep it only as the cause. const agentResult = nonEmptyString(notification.agentResult); return new EffectAcpErrors.AcpRequestError({ code: -32603, @@ -1427,9 +1332,8 @@ function xAiPromptFailure( } /** - * Grok's own text for a failed prompt. A plain Error rather than a schema - * error so the unbounded provider text never becomes a structured attribute; - * it is read back only at the presentation boundary. + * A plain Error rather than a schema error so the unbounded provider text never becomes a + * structured attribute; it is read back only at the presentation boundary. */ export class XAiPromptFailureText extends Error {} @@ -1556,22 +1460,11 @@ const rememberCompletedXAiPromptId = ( }); }; -/** - * Grok-specific ACP runtime wrapper. Races `session/prompt` against root-matched - * terminal notifications: - * - session notification/update + `turn_completed` + matching `prompt_id` - * - `x.ai/session/prompt_complete` (open-source fire-and-forget signal) - * - `_x.ai/session/prompt_complete` (released-build alias) - * - * Pending entries are keyed by root sessionId + T3-injected promptId, so - * foreign/child sessions and `task-completed-*` ids do not settle the root turn. - */ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")( function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) { // Grok sends turn completion and other session updates (subagent_finished) // on the same extension methods, and the client keeps one handler per - // method. This wrapper owns those methods: it settles prompts itself, then - // passes each notification to the handler registered on the wrapped runtime. + // method. const sessionNotificationHandlers = new Map< string, (params: unknown) => Effect.Effect @@ -1697,8 +1590,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion }), cancel: Effect.gen(function* () { // Do not call start here: cancellation must be able to reach the - // underlying runtime when startup is stalled. Pending entries already - // carry their session ids, and this runtime owns all of them. + // underlying runtime when startup is stalled. yield* abortPendingPromptCompletions(pendingXAiPromptCompletionsRef); yield* runtime.cancel; }), diff --git a/packages/provider-grok/src/server/xaiBackgroundTasks.ts b/packages/provider-grok/src/server/xaiBackgroundTasks.ts index beca3472255a..d526a13ab7b7 100644 --- a/packages/provider-grok/src/server/xaiBackgroundTasks.ts +++ b/packages/provider-grok/src/server/xaiBackgroundTasks.ts @@ -57,7 +57,6 @@ function lifecycle(status: unknown, exitCode: unknown) { } } -/** Map Grok's discriminated tool results, including notifications after the turn ends. */ export function buildGrokBackgroundTaskEvents(input: { readonly tasks: Map; readonly toolCallId: string; diff --git a/packages/provider-grok/src/settings.ts b/packages/provider-grok/src/settings.ts index ab26b2f2148d..3796a5578ab6 100644 --- a/packages/provider-grok/src/settings.ts +++ b/packages/provider-grok/src/settings.ts @@ -1,9 +1,3 @@ -/** - * Grok instance settings. Shared by the server driver and the client - * settings form, so it holds only browser-safe schema code. - * - * @module provider-grok/settings - */ import { CustomModelSetting, makeBinaryPathSetting, @@ -14,8 +8,6 @@ import * as Schema from "effect/Schema"; export const GrokSettings = makeProviderSettingsSchema( { - // Off by default (like Cursor and OpenCode): the binding is not yet - // stable enough to probe on every install. Users opt in from Settings. enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), diff --git a/packages/provider-grok/src/testing.ts b/packages/provider-grok/src/testing.ts index 519d76a6d62d..ecc92326917f 100644 --- a/packages/provider-grok/src/testing.ts +++ b/packages/provider-grok/src/testing.ts @@ -1,9 +1,3 @@ -/** - * Grok internals the server's replay testkit, fixture recorder, and - * orchestration tests drive directly. - * - * @module provider-grok/testing - */ export { GROK_DEFAULT_INSTANCE_ID, GROK_PROVIDER, diff --git a/packages/provider-muse/src/client.ts b/packages/provider-muse/src/client.ts index 9a29533036dc..b26e7458d0c1 100644 --- a/packages/provider-muse/src/client.ts +++ b/packages/provider-muse/src/client.ts @@ -1,9 +1,3 @@ -/** - * Muse Code's client definition: settings form, label, badge, and glyph. - * Browser- and React Native-safe. - * - * @module provider-muse/client - */ import { ProviderDriverKind } from "@t3tools/contracts"; import { defineProviderClient } from "@t3tools/provider-core/client"; @@ -14,7 +8,6 @@ export const museClient = defineProviderClient({ label: "Muse Code", settingsSchema: MuseSettings, badgeLabel: "Beta", - // Meta's symbol (https://commons.wikimedia.org/wiki/File:Meta_Platforms_Inc._logo_(cropped).svg). icon: { viewBox: "0 0 287.56 191", fill: { light: "#0081fb", dark: "#0081fb" }, diff --git a/packages/provider-muse/src/server.ts b/packages/provider-muse/src/server.ts index 7100802d7e13..eab3a275e598 100644 --- a/packages/provider-muse/src/server.ts +++ b/packages/provider-muse/src/server.ts @@ -1,8 +1,2 @@ -/** - * Muse Code's server entry: the driver the server registers, plus the - * adapter factory the replay testkit and fixture recorder build on. - * - * @module provider-muse/server - */ export { MuseDriver, type MuseDriverEnv } from "./server/driver.ts"; export { makeMuseAdapterV2, type MuseAdapterV2Options } from "./server/adapter.ts"; diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 6cd989669f00..7571c20995b7 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -577,7 +577,6 @@ describe("MuseAdapterV2", () => { it.effect("moves a resumed session on another model back to the catalog default", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); - // The saved session last ran MODEL; the catalog's default is another model. const harness = yield* makeHarness( fake, INSTANCE_ID, @@ -681,7 +680,6 @@ describe("MuseAdapterV2", () => { }); const result = yield* makeHarness(fake).pipe(Effect.exit); assert.strictEqual(result._tag, "Failure"); - // A failed host closes in the background so the failure shows at once. yield* Effect.promise(() => fake.host.exited); assert.strictEqual(fake.closeCount(), 1); assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 0); @@ -804,7 +802,6 @@ describe("MuseAdapterV2", () => { .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "decline" }) .pipe(Effect.forkScoped); yield* fake.takeCall("approval/decide"); - // Muse's native decision for this choice maps to decline too; "cancel" proves it was ours. yield* fake.emit("approval/resolved", { approvalId: "approval-1", turnId: nativeId, @@ -833,11 +830,9 @@ describe("MuseAdapterV2", () => { runtimePolicy, { continuationRequests: { offer: (request) => Effect.sync(() => offers.push(request)) } }, ); - // A finished workflow's report turn starts while T3 runs nothing: it is held. yield* fake.emit("turn/started", { turnId: "report-1" }); yield* Effect.yieldNow; assert.lengthOf(offers, 1); - // The user sends a message first; Muse runs the report before it. const { nativeId } = yield* startConversation(harness, fake); yield* fake.emit("approval/requested", approval("report-1")); const asked = yield* harness.takeEvent( @@ -854,7 +849,6 @@ describe("MuseAdapterV2", () => { yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); const terminal = yield* harness.takeEvent("turn.terminal"); assert.strictEqual(terminal.status, "completed"); - // The user turn took it, so the continuation is not dispatched. const dispatched = yield* offers[0]!.dispatchIfCurrent!(Effect.succeed("run")); assert.isTrue(Option.isNone(dispatched)); }).pipe(Effect.scoped, Effect.provide(testLayer)), @@ -867,7 +861,6 @@ describe("MuseAdapterV2", () => { const { nativeId } = yield* startConversation(harness, fake); yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); yield* harness.takeEvent("turn.terminal"); - // Muse runs workflow children without the session's allowAll mode. const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } }; yield* fake.emit("approval/requested", childApproval); const asked = yield* harness.takeEvent("runtime_request.updated"); @@ -1079,8 +1072,6 @@ describe("MuseAdapterV2", () => { const fake = yield* makeFakeMuse(); const harness = yield* makeHarness(fake); const { nativeId } = yield* startConversation(harness, fake); - // Neither the tool call that wrote the list nor Muse's per-step reminder - // child may show as a generic tool row. yield* fake.emit("item/completed", { turnId: nativeId, item: { @@ -1194,7 +1185,6 @@ describe("MuseAdapterV2", () => { result: { summary: "All done" }, }, }); - // The revision without a role keeps the title the child thread opened with. const completed = yield* harness.takeEvent( "subagent.updated", (event) => event.subagent.status === "completed", diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index 7413ef85bc7f..bad108c6c352 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -106,8 +106,6 @@ const MuseProviderCapabilitiesV2 = { threads: { canCreateEmptyThread: true, canReadThreadSnapshot: true, - // Muse 1.2.1/1.3.0 reject tested completed-turn fork boundaries with InvalidCut. - // Public forks use portable context handoff until native boundaries are validated. canRollbackThread: false, canForkThread: false, canForkFromTurn: false, @@ -196,7 +194,6 @@ export interface MuseAdapterV2Options { readonly createHost?: typeof createMuseSdkHost; readonly requestTimeoutMs?: number; readonly nativeEventLogger?: ProviderEventLoggers.EventNdjsonLogger; - /** Where a turn Muse starts on its own (a finished workflow's report) asks for its run. */ readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -208,7 +205,6 @@ interface ActiveTurn { readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; providerTurn: OrchestrationV2ProviderTurn; readonly nativeId: string; - /** Turns Muse started on its own while this run was active, which this run shows. */ readonly joined: Set; readonly items: Map; readonly ordinals: Map; @@ -230,14 +226,13 @@ interface PendingRequest { request: OrchestrationV2RuntimeRequest; node: OrchestrationV2ExecutionNode; item: OrchestrationV2TurnItem; - /** What T3 answered, recorded on the request once Muse confirms it. */ response?: Pick; } /** - * One native subagent's child thread. Muse 1.4.4 cannot address an inline - * child session (`session/read` and `view/subscribe` answer sessionNotFound), - * so the thread carries the prompt, live status (on its root node) and result. + * Muse 1.4.4 cannot address an inline child session (`session/read` and + * `view/subscribe` answer sessionNotFound), so the thread carries the prompt, + * live status (on its root node) and result. */ interface ChildContext { readonly childSessionId: string; @@ -246,7 +241,6 @@ interface ChildContext { readonly rootNodeId: NodeId; readonly parentThreadId: ThreadId; status: OrchestrationV2TurnItemStatus; - /** The result shown in the child thread, once the subagent settled. */ result?: string; } @@ -256,19 +250,15 @@ const nativeRef = (nativeId: string) => ({ strength: "strong" as const, }); const recordSchema = Schema.Record(Schema.String, Schema.Unknown); -// Notifications that only add detail. A malformed one is skipped instead of ending the session. const INFORMATIONAL_NOTIFICATIONS = new Set([ "session/contextUsage", "session/tokenUsage", "session/todoListChanged", "turn/retryScheduled", ]); -// Tools whose results already show as their own rows: T3's todo list and question -// rows, and the workflow item a `workflow` call launches. const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "workflow"]); const responseAnswerSchema = Schema.Union([Schema.String, Schema.Array(Schema.String)]); -/** One scoped Muse host owns one native session; the orchestrator owns app runs and queuing. */ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( options: MuseAdapterV2Options, ) { @@ -334,11 +324,8 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( let nativeSessionId: string | undefined; let thread: OrchestrationV2ProviderThread | undefined; let active: ActiveTurn | undefined; - // The last finished turn, so context usage reported after it still lands on it. let lastProviderTurn: OrchestrationV2ProviderTurn | undefined; const agentEndedAt = new Map(); - // "default" is the catalog's default model, from the provider snapshot. Without - // one, Muse keeps whatever model the session already runs. const resolveModel = Effect.fnUntraced(function* (selection: ModelSelection) { if (selection.model !== MUSE_DEFAULT_MODEL) return selection.model; const catalog = options.modelCatalog ? yield* options.modelCatalog : []; @@ -346,15 +333,10 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( }); const pending = new Map(); const seenTerminals = new Set(); - // Workflows and subagents that outlive their turn, by item id, with the turn that owns them. const observedChildren = new Map(); - // Native subagents' child threads, by subagent item id. const childrenByItemId = new Map(); - // Titles per native subagent and workflow child attempt; later revisions drop the label. const agentTitles = new Map(); - // Background work that finished since the last turn, reported to the turn Muse starts for it. const finishedBackground: Array = []; - // A turn Muse started on its own, held until the continuation run T3 opens for it takes it. let wake: | { readonly nativeId: string; readonly events: Array<[string, unknown]> } | undefined; @@ -452,7 +434,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( }; const childItemIdentity = (context: ChildContext, nativeId: string) => `${options.instanceId}:child:${context.childSessionId}:${nativeId}`; - /** A subagent with a ready result has finished its work even while Muse keeps it open. */ const itemStatus = (item: MuseItem): OrchestrationV2TurnItemStatus => { const status = museItemStatus(item); return status === "running" && @@ -489,7 +470,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( }, }); }); - /** One synthesized conversation row in a child thread: its prompt or its result. */ const publishChildMessage = Effect.fnUntraced(function* ( context: ChildContext, role: "user" | "assistant", @@ -525,7 +505,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( turnItem: artifacts.turnItem, }); }); - /** Creates the child thread for a native subagent the first time its session id is known. */ const ensureChildContext = Effect.fnUntraced(function* ( turn: ActiveTurn, item: MuseItem, @@ -572,10 +551,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( if (prompt) yield* publishChildMessage(context, "user", prompt, "prompt", time); return context; }); - /** - * Shows a native subagent on its parent thread and mirrors it into a child - * thread the user can open, like Claude's and Codex's subagents. - */ const publishNativeSubagent = Effect.fnUntraced(function* ( turn: ActiveTurn, item: MuseItem, @@ -691,7 +666,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( } } }); - /** Lists running workflows and subagents on the thread, so it shows it still waits on them. */ const syncBackground = Effect.fnUntraced(function* () { yield* updateThread({ pendingBackgroundTasks: [...observedChildren].flatMap(([itemId, owner]) => { @@ -715,7 +689,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( ) { // Muse's skill-reminder child runs on every model step; it is housekeeping, not work. if (item.kind === "userMessage" || item.kind === "reminderChild") return; - // A failed call keeps its row: it may have no native row to show its error. if ( item.kind === "toolCall" && item.tool && @@ -816,11 +789,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem }); if (item.kind === "workflow") yield* publishWorkflowAgents(turn, item, base, status); }); - /** - * Shows each workflow child as a native subagent under the workflow row. Muse - * reports only their status, label and outcome, not their conversations, so - * they get no child thread. - */ const publishWorkflowAgents = Effect.fnUntraced(function* ( turn: ActiveTurn, item: MuseItem, @@ -926,7 +894,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( } observedChildren.clear(); if (thread?.pendingBackgroundTasks?.length) yield* syncBackground(); - // Only workflow children's requests outlive a turn; they end with the workflow. for (const entry of pending.values()) yield* resolvePending(entry, "cancelled"); }); const resolvePending = Effect.fnUntraced(function* ( @@ -972,8 +939,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( seenTerminals.add(turn.nativeId); const completedAt = yield* DateTime.now; for (const item of turn.items.values()) { - // Workflows and subagents outlive a completed turn. A stopped or failed - // turn ends them in T3 too, as the orchestrator stops following them. if ( (item.kind === "subagent" || item.kind === "workflow") && item.status === "inProgress" && @@ -985,7 +950,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( yield* publishItem(turn, item, item.status === "inProgress" ? status : undefined); } turn.dirty.clear(); - // A workflow child's request is runless and keeps waiting after the turn ends. for (const entry of pending.values()) if (entry.request.providerTurnId !== null) yield* resolvePending(entry, "cancelled"); turn.providerTurn = { ...turn.providerTurn, status, completedAt }; @@ -1064,13 +1028,11 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( } yield* settleObservedChildren("failed"); yield* Queue.fail(events, protocolError(detail, cause)); - // Report the failure first; a hung host can take a while to close. const failed = host; yield* Effect.tryPromise(() => failed.close()).pipe(Effect.ignore, Effect.forkIn(scope)); }); const owns = (turn: ActiveTurn, turnId: string) => turnId === turn.nativeId || turn.joined.has(turnId); - /** Sends T3's decision for a pending approval to Muse. */ const decideApproval = Effect.fnUntraced(function* ( entry: PendingRequest, decision: ProviderApprovalDecision | undefined, @@ -1088,9 +1050,9 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); }); /** - * Shows a Muse approval or question as a T3 request. A workflow child's approval - * is runless: the turn that started the workflow may end long before the child - * asks, and T3 keeps a runless request on the thread until Muse settles it. + * A workflow child's approval is runless: the turn that started the workflow + * may end long before the child asks, and T3 keeps a runless request on the + * thread until Muse settles it. */ const publishRequest = Effect.fnUntraced(function* (native: PendingRequest["native"]) { const child = native.type === "approval" && native.value.subagentOrigin !== undefined; @@ -1174,7 +1136,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( providerTurnId: null, nativeItemRef: nativeRef(nativeId), parentItemId: null, - // Sorts after the latest turn's items. ordinal: previous?.item.ordinal ?? ((latestTurn?.ordinal ?? 0) + 1) * 100, startedAt: runtimeRequest.createdAt, updatedAt: time, @@ -1234,7 +1195,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( if (child && input.runtimePolicy.runtimeMode === "full-access") yield* decideApproval(entry, "accept").pipe(Effect.ignore, Effect.forkIn(scope)); }); - /** Approval and question events; `turn` is undefined for a workflow child's approval. */ const handleRequestEvent = Effect.fnUntraced(function* ( method: string, params: Record, @@ -1272,7 +1232,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( (candidate) => candidate.request.nativeRequestRef?.nativeId === id, ); if (entry) { - // An approval settled outside T3 still shows Muse's own decision. const native = method === "approval/resolved" && typeof params.decision === "string" ? museApprovalDecision({ decision: params.decision, scope: "once" }) @@ -1330,9 +1289,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( // or after it, so it skips turn routing. if (params.subagentOrigin !== undefined && method.startsWith("approval/")) return yield* handleRequestEvent(method, params, undefined); - // Muse starts a turn on its own when a workflow finishes, to report its result. - // During a run, that run shows it. Otherwise hold it and ask the orchestrator for - // a run; that run takes the held events, unless a user turn takes them first. if ( method === "turn/started" && typeof params.turnId === "string" && @@ -1703,8 +1659,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( created.connection.onProtocolError((cause) => { Queue.offerUnsafe(inbox, { type: "failure", cause, epoch }); }); - // These are presentation receipts only. The matching notifications drive the UI, - // and decisions travel as `approval/decide` and `userInput/answer` commands. created.connection.onServerRequest(async (request) => { if (request.method === "approval/request" || request.method === "userInput/request") return {}; @@ -1792,14 +1746,11 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( } : undefined; const startModel = yield* resolveModel(args.modelSelection); - // Resume selects it too, since the thread may predate it or its mode changed. - // `session/setPermissionProfile` would need Muse's experimental API. const permissionProfile = musePermissionProfile(args.runtimePolicy.runtimeMode); const result = yield* request( requestedId ? "session/resume" : "session/start", requestedId - ? // T3 already has the transcript; skip Muse's history payload. - { excludeItems: true, ...(config ? { config } : {}), permissionProfile } + ? { excludeItems: true, ...(config ? { config } : {}), permissionProfile } : { workspaceRoot: cwd, ...(config ? { config } : {}), @@ -1824,8 +1775,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( yield* request("session/setApprovalMode", { mode: museApprovalMode(args.runtimePolicy.runtimeMode), }); - // A resumed session keeps the model it last ran with; the next turn - // switches it when the selection differs. const createdAt = yield* DateTime.now; thread = existing ? { @@ -1910,7 +1859,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( const catalog = options.modelCatalog ? yield* options.modelCatalog : []; const model = yield* resolveModel(selection); const selected = getModelSelectionStringOptionValue(selection, "reasoningEffort"); - // With no saved choice, the model's own default applies. return resolveMuseReasoningEffort( catalog.find((entry) => entry.slug === model)?.capabilities ?? museModelCapabilities(), selected, @@ -1930,7 +1878,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( return yield* protocolError("Muse does not support dedicated Plan mode"); if (turnInput.runtimePolicy.runtimeMode !== input.runtimePolicy.runtimeMode) return yield* protocolError("Runtime policy changes require a fresh Muse host"); - // A continuation run takes the turn Muse already started; it sends no prompt. const continuation = turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; @@ -1995,8 +1942,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( lastRunOrdinal: turnInput.runOrdinal, }); yield* updateSession("running"); - // A user turn takes a held Muse turn: Muse runs it first, so this run shows it - // (and its approvals), and the continuation asked for it is no longer needed. const held = wake; wake = undefined; if (continuation && !held) return yield* finish(turn, "completed"); @@ -2062,7 +2007,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( }), ); }); - // Only messages are read back; T3 owns turn and item history. const snapshot = Effect.fnUntraced(function* ( current: OrchestrationV2ProviderThread, ): Effect.fn.Return< @@ -2112,7 +2056,6 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( providerSessionId: input.providerSessionId, providerSession: session, events: Stream.fromQueue(events), - // Workflows and subagents run in this session's own `muse` host. backgroundTasksEndWithProcess: true, // A running workflow or a held Muse turn keeps the host: idle release must wait. hasPendingBackgroundWork: Effect.sync( diff --git a/packages/provider-muse/src/server/itemPresentation.ts b/packages/provider-muse/src/server/itemPresentation.ts index 3a59ddc089bf..d74bc7143ea7 100644 --- a/packages/provider-muse/src/server/itemPresentation.ts +++ b/packages/provider-muse/src/server/itemPresentation.ts @@ -7,7 +7,6 @@ import type { MuseItem } from "./protocol.ts"; const decodeToolArgs = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); -/** Muse's `web_search` tool output: the query and its hits, as JSON. */ const decodeWebSearchOutput = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ @@ -36,7 +35,6 @@ type MuseToolPresentation = { | Fields<"dynamic_tool", "type" | "toolName" | "input" | "output"> ); -/** Unrecognized native terminal states must not appear as successful work. */ export function museItemStatus(item: MuseItem): OrchestrationV2TurnItemStatus { if (item.status === "inProgress") return "running"; if (item.status === "cancelled") return "cancelled"; @@ -48,7 +46,6 @@ export function museItemStatus(item: MuseItem): OrchestrationV2TurnItemStatus { return "failed"; } -/** Converts native tool details into the shared timeline fields used by every client. */ export function museToolPresentation( item: MuseItem, status = museItemStatus(item), @@ -117,7 +114,6 @@ export function museToolPresentation( if (item.kind === "toolCall" && /web|browse|fetch/i.test(toolName)) { const query = stringArg("query", "url", "pattern")?.trim(); const pageUrl = stringArg("url")?.trim(); - // A search lists its hits; a page fetch (or a failure) shows its text under the page's link. const hits = item.tool === "web_search" && !detail ? Option.getOrUndefined(decodeWebSearchOutput(text))?.results.map( diff --git a/packages/provider-muse/src/server/maintenance.test.ts b/packages/provider-muse/src/server/maintenance.test.ts index 869aa6dca2fe..51550fe9f143 100644 --- a/packages/provider-muse/src/server/maintenance.test.ts +++ b/packages/provider-muse/src/server/maintenance.test.ts @@ -76,7 +76,6 @@ it.layer(NodeServices.layer)("Muse maintenance", (it) => { expect(compareMuseVersions("1.1.1-R9.1", "1.1.1-R10.1")).toBeLessThan(0); expect(compareMuseVersions("1.1.1-R2514.1", "1.1.1-R2514.2")).toBeLessThan(0); expect(compareMuseVersions("1.2.0-R1.1", "1.1.1-R2514.2")).toBeGreaterThan(0); - // A CLI that prints no revision compares by release, never as an older prerelease. expect(compareMuseVersions("1.4.3", "1.4.3-R5018.1")).toBe(0); expect(compareMuseVersions("1.4.2", "1.4.3-R5018.1")).toBeLessThan(0); }), diff --git a/packages/provider-muse/src/server/maintenance.ts b/packages/provider-muse/src/server/maintenance.ts index 776e9b0f3c23..8295fedd9083 100644 --- a/packages/provider-muse/src/server/maintenance.ts +++ b/packages/provider-muse/src/server/maintenance.ts @@ -17,25 +17,21 @@ import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderL import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; const DRIVER = ProviderDriverKind.make("muse"); -/** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */ const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]); const ChannelManifest = Schema.Struct({ version: Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+-R\d+(?:\.\d+)?$/)), }); -/** Muse prints both a display version and a release revision; prefer the latter. */ export function parseMuseVersion(output: string): string | null { return output.match(/\b\d+\.\d+\.\d+-R\d+(?:\.\d+)?\b/)?.[0] ?? parseGenericCliVersion(output); } function comparableMuseVersion(version: string): string { - // Make the release sequence numeric for semver comparison (R9 precedes R10). return version.replace(/-R(\d+)(?:\.(\d+))?$/, "-release.$1.$2").replace(/\.$/, ".0"); } const MUSE_REVISION = /-R\d+(?:\.\d+)?$/; -/** A version printed without a revision is compared by its release alone. */ export const compareMuseVersions = (current: string, latest: string) => MUSE_REVISION.test(current) === MUSE_REVISION.test(latest) ? compareSemverVersions(comparableMuseVersion(current), comparableMuseVersion(latest)) @@ -117,7 +113,6 @@ export const latestMuseVersion = Effect.fn("latestMuseVersion")(function* ( ); }); -/** Feed Muse's release channel into the same advisory and notification flow as other drivers. */ export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (input: { readonly snapshot: ServerProvider; readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; diff --git a/packages/provider-muse/src/server/modelCatalog.test.ts b/packages/provider-muse/src/server/modelCatalog.test.ts index 628628a161bd..fe89b6b57d60 100644 --- a/packages/provider-muse/src/server/modelCatalog.test.ts +++ b/packages/provider-muse/src/server/modelCatalog.test.ts @@ -76,7 +76,6 @@ describe("Muse model catalog", () => { ], }, ]); - // Older hosts and "unknown" fall back to Muse's documented tiers. expect(models[1]?.capabilities).toEqual(museModelCapabilities()); expect(models[2]?.capabilities).toEqual(museModelCapabilities()); }), diff --git a/packages/provider-muse/src/server/modelCatalog.ts b/packages/provider-muse/src/server/modelCatalog.ts index f390d062803c..29a319d92fec 100644 --- a/packages/provider-muse/src/server/modelCatalog.ts +++ b/packages/provider-muse/src/server/modelCatalog.ts @@ -22,9 +22,7 @@ const REASONING_EFFORT_LABELS = { const isMuseReasoningEffort = (tier: string): tier is MuseReasoningEffort => Object.hasOwn(REASONING_EFFORT_LABELS, tier); -/** Used when Muse does not report a model's efforts (custom slugs, older hosts, "unknown"). */ const FALLBACK_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -/** Muse's own CLI default. */ const FALLBACK_DEFAULT_EFFORT = "high"; const ModelCatalogEntry = Schema.Struct({ @@ -44,7 +42,6 @@ const decodeModelList = Schema.decodeUnknownEffect( Schema.Struct({ providerId: Schema.String, models: Schema.Array(ModelCatalogEntry) }), ); -/** Builds the reasoning picker from a `model/list` row, or the fallback when called without one. */ export function museModelCapabilities( model?: Pick< typeof ModelCatalogEntry.Type, @@ -79,7 +76,6 @@ export function museModelCapabilities( }); } -/** Apply the advertised model choices to saved, remembered and implicit efforts at dispatch. */ export function resolveMuseReasoningEffort( capabilities: ModelCapabilities | null | undefined, effort: string | undefined, diff --git a/packages/provider-muse/src/server/protocol.ts b/packages/provider-muse/src/server/protocol.ts index e7413a181a04..08d1507de91f 100644 --- a/packages/provider-muse/src/server/protocol.ts +++ b/packages/provider-muse/src/server/protocol.ts @@ -13,9 +13,7 @@ const MuseUsage = Schema.Struct({ }); export const MuseItem = Schema.Struct({ - /** `workflow`: the launched entry's name. */ entryId: Schema.optional(Schema.String), - /** `workflow`: each child agent's latest attempt, re-sent whole on every change. */ children: Schema.optional( Schema.Array( Schema.Struct({ @@ -31,7 +29,6 @@ export const MuseItem = Schema.Struct({ }), ), ), - /** `workflow`: the run's result, once it settles. */ finalSummary: Schema.optional(Schema.String), itemId: NonEmptyString, kind: NonEmptyString, @@ -177,7 +174,6 @@ export const MuseDelta = Schema.Struct({ field: Schema.optional(Schema.String), }); -/** The T3 decision for one native Muse choice, or none when T3 has no equivalent. */ export function museApprovalDecision( choice: Pick, ): ProviderApprovalDecision | undefined { @@ -203,8 +199,8 @@ export function museApprovalDecision( } /** - * Maps each canonical decision to one native choice. Muse shell approvals offer - * no `denied` choice, only `abort` ("Reject"), so `decline` falls back to it. + * Muse shell approvals offer no `denied` choice, only `abort` ("Reject"), so + * `decline` falls back to it. */ export function museApprovalChoices(approval: Pick) { const choices = new Map(); @@ -222,7 +218,6 @@ export function museApprovalChoices(approval: Pick) { const choices = museApprovalChoices(approval); const declineChoiceId = choices.get("decline")?.choiceId; diff --git a/packages/provider-muse/src/server/sdk.test.ts b/packages/provider-muse/src/server/sdk.test.ts index 676fa474a2e9..2ef50731dbf8 100644 --- a/packages/provider-muse/src/server/sdk.test.ts +++ b/packages/provider-muse/src/server/sdk.test.ts @@ -294,14 +294,12 @@ describe("Muse launch", () => { ), ); - // Node spawns neither a bare `muse` nor a `.cmd` file without a shell on Windows. it.effect("runs an exe directly and a launcher script under cmd.exe on Windows", () => Effect.gen(function* () { expect(yield* onWindows("C:\\Muse\\muse.exe")).toEqual({ command: "C:\\Muse\\muse.exe", args: [], }); - // Node quotes a path with spaces, and the `@` before it keeps cmd from dropping the quotes. const spaced = "C:\\Users\\Jane Doe\\AppData\\Local\\Programs\\muse\\muse.cmd"; expect(yield* onWindows(spaced)).toEqual({ command: CMD, args: ["/d", "/c", "@", spaced] }); const spacedAmpersand = "C:\\Users\\R&D Team\\AppData\\Local\\Programs\\muse\\muse.cmd"; @@ -309,7 +307,6 @@ describe("Muse launch", () => { command: CMD, args: ["/d", "/c", "@", spacedAmpersand], }); - // Unquoted, cmd would split the command at `&`. expect(yield* onWindows("C:\\Users\\R&D(1)\\muse.cmd")).toEqual({ command: CMD, args: ["/d", "/c", "C:\\Users\\R^&D^(1^)\\muse.cmd"], @@ -317,7 +314,6 @@ describe("Muse launch", () => { expect((yield* onWindows("C:\\Muse\\muse.bat", { SYSTEMROOT: "C:\\Windows" })).command).toBe( "C:\\Windows\\System32\\cmd.exe", ); - // A binary that cannot be found is spawned as given, so the failure reports it. expect(yield* onWindows(undefined)).toEqual({ command: "muse", args: [] }); }), ); @@ -435,7 +431,6 @@ describe("Muse workspace root", () => { expect(yield* museWorkspaceRoot("C:\\definitely-missing\\repo")).toBe( "\\\\?\\C:\\definitely-missing\\repo", ); - // `/` would be taken literally inside a verbatim path. expect(yield* museWorkspaceRoot("C:/definitely-missing/repo")).toBe( "\\\\?\\C:\\definitely-missing\\repo", ); diff --git a/packages/provider-muse/src/server/sdk.ts b/packages/provider-muse/src/server/sdk.ts index a73dd094c9db..0ae8c2b3ebd2 100644 --- a/packages/provider-muse/src/server/sdk.ts +++ b/packages/provider-muse/src/server/sdk.ts @@ -27,12 +27,10 @@ export interface MuseSdkHost { >; readonly initializeResult: Pick; readonly exited: Promise; - /** The last lines Muse wrote to stderr, for explaining an unexpected exit. */ readonly stderrTail?: () => ReadonlyArray; readonly close: () => Promise; } -/** The program that runs `binaryPath`, and the arguments that go before the binary's own. */ export interface MuseLaunch { readonly command: string; readonly args: ReadonlyArray; @@ -40,14 +38,11 @@ export interface MuseLaunch { export interface MuseSdkHostOptions { readonly binaryPath: string; - /** Arguments before the serve arguments, for a launcher in front of the binary. */ readonly launchArgs?: ReadonlyArray; - /** How to start `binaryPath`, from `museLaunch`; without it the binary path is run as is. */ readonly launch?: MuseLaunch; readonly cwd?: string; readonly environment?: NodeJS.ProcessEnv; readonly runtimeMode?: RuntimeMode; - /** Disables shell and filesystem writes and workspace trust. */ readonly readOnly?: boolean; /** Read-only generation needs durable logging for turn/item notifications (verified through 1.1.1). */ readonly sessionLogging?: boolean; @@ -55,7 +50,6 @@ export interface MuseSdkHostOptions { readonly startupTimeoutMs?: number; } -/** Preserve the host's CLI login while excluding the API-key override before copying values. */ export function makeMuseEnvironment( environment: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { @@ -69,16 +63,14 @@ export function makeMuseEnvironment( }; } -/** Windows' verbatim form of a canonical path: `\\?\C:\…`, or `\\?\UNC\server\share\…`. */ export function museVerbatimPath(path: string): string { if (path.startsWith("\\\\?\\")) return path; return path.startsWith("\\\\") ? `\\\\?\\UNC\\${path.slice(2)}` : `\\\\?\\${path}`; } /** - * A workspace root in the form `turn/start` accepts. On Windows Muse rejects - * anything but the verbatim canonical path, true case and long names - * included, so resolve with the native realpath, which unlike + * On Windows Muse rejects anything but the verbatim canonical path, true case + * and long names included, so resolve with the native realpath, which unlike * FileSystem.realPath fixes case and expands 8.3 names. Muse itself still * starts in the plain path: the `muse.cmd` launcher runs under cmd.exe, which * cannot use a verbatim directory. @@ -92,7 +84,6 @@ export const museWorkspaceRoot = Effect.fn("museWorkspaceRoot")(function* (path: return museVerbatimPath(canonical); }); -/** A Windows environment variable; their names are case-insensitive there. */ function windowsEnvironmentValue(environment: NodeJS.ProcessEnv, name: string) { const key = Object.keys(environment).find( (candidate) => candidate.toUpperCase() === name.toUpperCase(), @@ -101,13 +92,11 @@ function windowsEnvironmentValue(environment: NodeJS.ProcessEnv, name: string) { } /** - * How to start Muse. The SDK spawns without a shell, which finds the `muse` - * launcher on PATH everywhere but Windows. There Node neither looks a bare - * `muse` up with PATHEXT (ENOENT) nor runs a `.cmd` file such as Muse's own - * launcher (EINVAL) without a shell, so resolve the binary like any other - * command and run a `.cmd` or `.bat` under cmd.exe. Node quotes a path with - * spaces, and inside those quotes cmd takes every character literally. A path - * without spaces is not quoted, so its metacharacters are escaped instead. + * The SDK spawns without a shell, which finds the `muse` launcher on PATH + * everywhere but Windows. There Node neither looks a bare `muse` up with + * PATHEXT (ENOENT) nor runs a `.cmd` file such as Muse's own launcher (EINVAL) + * without a shell, so resolve the binary like any other command and run a + * `.cmd` or `.bat` under cmd.exe. */ export const museLaunch = Effect.fn("museLaunch")(function* ( binaryPath: string, @@ -147,7 +136,6 @@ export function musePermissionProfile(runtimeMode: RuntimeMode) { return runtimeMode === "full-access" ? ":unrestricted" : ":ask-me"; } -/** The `muse serve` arguments for a host: read-only hosts get no shell, writes, or workspace trust. */ export function museServeArgs( options: Pick, ) { @@ -162,7 +150,6 @@ export function museServeArgs( return args; } -/** What T3 sends in MSP `initialize`; only full hosts ask for session MCP servers. */ export function museInitializeParams(readOnly = false) { return { clientInfo: { name: "t3_code", title: "T3 Code", version: "1" }, @@ -170,7 +157,9 @@ export function museInitializeParams(readOnly = false) { }; } -/** One SDK-owned process. The pre-handshake handle owns cleanup even when initialize never replies. */ +/** + * The pre-handshake handle owns cleanup even when initialize never replies. + */ export async function createMuseSdkHost( options: MuseSdkHostOptions, spawn: typeof spawnMspConnection = spawnMspConnection, @@ -181,9 +170,7 @@ export async function createMuseSdkHost( command: launch.command, args: [...launch.args, ...(options.launchArgs ?? []), ...museServeArgs(options)], ...(options.cwd ? { cwd: options.cwd } : {}), - // Callers pass an environment already built with makeMuseEnvironment. env: options.environment ?? makeMuseEnvironment(), - // A healthy host exits at once; this only bounds a hung one on close. shutdownTimeoutMs: 10_000, }); let closePromise: Promise | undefined; diff --git a/packages/provider-muse/src/server/status.ts b/packages/provider-muse/src/server/status.ts index a8d3eaef0683..bb7fcc6adecb 100644 --- a/packages/provider-muse/src/server/status.ts +++ b/packages/provider-muse/src/server/status.ts @@ -60,7 +60,6 @@ export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(func ) { if (!settings.enabled) return yield* makePendingMuseProvider(settings); const checkedAt = DateTime.formatIso(yield* DateTime.now); - // The driver passes a prepared environment; direct callers get the host's without META_API_KEY. const museEnvironment = environment ?? makeMuseEnvironment(); const snapshot = (probe: ProviderProbeResult, models: ReadonlyArray = []) => buildServerProvider({ diff --git a/packages/provider-muse/src/server/textGeneration.ts b/packages/provider-muse/src/server/textGeneration.ts index 977a5eedf9c3..f122d1df756e 100644 --- a/packages/provider-muse/src/server/textGeneration.ts +++ b/packages/provider-muse/src/server/textGeneration.ts @@ -199,7 +199,6 @@ export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(functi options: { readonly environment?: NodeJS.ProcessEnv; readonly modelCatalog?: Effect.Effect>; - /** Test hook. */ readonly createHost?: typeof createMuseSdkHost; } = {}, ) { @@ -217,7 +216,6 @@ export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(functi } const selectedModel = input.modelSelection.model.trim(); const models = yield* modelCatalog; - // "default" is the catalog's default model, as in chat. Without one, Muse picks. const catalogModel = selectedModel && selectedModel !== MUSE_DEFAULT_MODEL ? models.find((model) => model.slug === selectedModel) @@ -342,7 +340,6 @@ export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(functi }); return { title: sanitizeThreadTitle(generated.title) }; }); - // Muse runs in an empty read-only directory and cannot be pointed at a checkout to read. const generateReviewGuide: ProviderTextGeneration["generateReviewGuide"] = () => Effect.fail(investigationUnsupported("generateReviewGuide", "Muse Code")); return { diff --git a/packages/provider-muse/src/settings.ts b/packages/provider-muse/src/settings.ts index 85a781dc6608..300320e8f63d 100644 --- a/packages/provider-muse/src/settings.ts +++ b/packages/provider-muse/src/settings.ts @@ -1,8 +1,6 @@ /** - * Muse Code instance settings. Shared by the server driver and the client - * settings form, so it holds only browser-safe schema code. - * - * @module provider-muse/settings + * Shared by the server driver and the client settings form, so it holds only browser-safe schema + * code. */ import { CustomModelSetting, diff --git a/packages/provider-muse/src/testing.ts b/packages/provider-muse/src/testing.ts index 56755167f366..6c55298b9eff 100644 --- a/packages/provider-muse/src/testing.ts +++ b/packages/provider-muse/src/testing.ts @@ -1,10 +1,3 @@ -/** - * Muse Code internals for replay tests and the fixture recorder: how Muse is - * started, the MSP launch arguments, initialize parameters, environment, the - * workspace root `turn/start` sends, and version parser. - * - * @module provider-muse/testing - */ export { makeMuseEnvironment, museInitializeParams, diff --git a/packages/provider-opencode/src/client.ts b/packages/provider-opencode/src/client.ts index 4c5e2d165db0..bac5cadb754a 100644 --- a/packages/provider-opencode/src/client.ts +++ b/packages/provider-opencode/src/client.ts @@ -1,9 +1,3 @@ -/** - * OpenCode's client definition: settings form, label, and glyph. - * Browser- and React Native-safe. - * - * @module provider-opencode/client - */ import { ProviderDriverKind } from "@t3tools/contracts"; import { defineProviderClient } from "@t3tools/provider-core/client"; diff --git a/packages/provider-opencode/src/server.ts b/packages/provider-opencode/src/server.ts index a9e0e2653cc0..65c6aeb9b874 100644 --- a/packages/provider-opencode/src/server.ts +++ b/packages/provider-opencode/src/server.ts @@ -1,8 +1,2 @@ -/** - * OpenCode's server entry: the driver the server registers, its adapter - * driver, and the runtime layer the driver needs from the server. - * - * @module provider-opencode/server - */ export { OpenCodeDriver, type OpenCodeDriverEnv } from "./server/driver.ts"; export { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts index 8394d8023d76..71cc09fa15c2 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.cliParsers.test.ts @@ -127,9 +127,6 @@ describe("parseModelsCliOutput", () => { }); it("keeps a model whose JSON body has a slash and no interior whitespace", () => { - // OpenRouter-style: the model id contains a `/` and no string value has a - // space, so the JSON body line itself matches the slug regex. It must still - // be treated as the body of the preceding slug, not a new slug. const stdout = [ "openrouter/qwen/qwen3-coder", JSON.stringify({ @@ -303,10 +300,10 @@ describe("toOpenCodeFileParts", () => { attachment("text/html"), attachment("text/markdown"), attachment("application/octet-stream"), - // Image formats the model APIs reject stay on the fallback path too. + attachment("image/bmp"), attachment("image/svg+xml"), - // Over the direct-attachment limit: path fallback even for a PDF. + attachment("application/pdf", 21 * 1024 * 1024), ], resolveAttachmentPath: () => "/tmp/attachment", diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts index afa6da63f94c..af0145218cf1 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.permissions.test.ts @@ -27,8 +27,6 @@ describe("buildOpenCodePermissionRules", () => { NodeAssert.equal(actionFor("approval-required", "edit"), "ask"); }); - // Documented in docs/user/permission-modes.md: providers without an AI - // reviewer, OpenCode among them, fall back to Supervised for "auto". it("leaves auto asking, as the docs say it does without a reviewer", () => { NodeAssert.equal(actionFor("auto", "edit"), "ask"); }); diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index d88a03218674..25a24c015d26 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -117,10 +117,6 @@ function encodeJsonStringForDiagnostics(input: unknown): string | undefined { return Exit.isSuccess(result) ? result.value : undefined; } -/** - * Formats OpenCode SDK and runtime failures, including errors with missing or - * non-string messages. - */ export function openCodeRuntimeErrorDetail(cause: unknown): string { if (isOpenCodeRuntimeError(cause)) return cause.detail; // Effect's TimeoutError can be constructed without a message, so `message` @@ -208,7 +204,6 @@ export interface OpenCodeInventory { export type OpenCodeSlashCommand = Pick; -/** Command templates stay in OpenCode, which expands arguments and runs MCP prompts. */ export const loadOpenCodeCommands = (client: OpencodeClient) => runOpenCodeSdk("command.list", (signal) => client.command.list(undefined, { signal })).pipe( Effect.map((result): ReadonlyArray => @@ -245,10 +240,8 @@ export class OpenCodeRuntime extends Context.Service< OpenCodeRuntime, { /** - * Spawns a local OpenCode server process. Its lifetime is bound to the caller's - * `Scope.Scope` β€” the child is killed automatically when that scope closes. - * Consumers that want a long-lived server must create and hold a scope explicitly - * (see {@link Scope.make}) and close it when done. + * Its lifetime is bound to the caller's `Scope.Scope` β€” the child is killed automatically + * when that scope closes. */ readonly startOpenCodeServerProcess: (input: { readonly binaryPath: string; @@ -258,14 +251,8 @@ export class OpenCodeRuntime extends Context.Service< readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; - /** Checks the listening server and returns its version. Defaults to the 1.x health check. */ readonly verify?: (url: string) => Effect.Effect; }) => Effect.Effect; - /** - * Returns a handle to either an externally-managed OpenCode server (when - * `serverUrl` is provided β€” no lifetime is attached to the caller's scope) or a - * freshly spawned local server whose lifetime is bound to the caller's scope. - */ readonly connectToOpenCodeServer: (input: { readonly binaryPath: string; readonly directory: string; @@ -356,9 +343,7 @@ export function parseModelsCliOutput(stdout: string): { } provider.models[modelID] = model; } - } catch { - // Skip unparseable model JSON - } + } catch {} } } currentSlug = null; @@ -405,9 +390,7 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { permission, options: {}, }); - } catch { - // Skip unparseable agent - } + } catch {} } } currentHeader = null; @@ -465,13 +448,8 @@ export function openCodeQuestionId( } /** - * Attachments OpenCode can hand to a model as a native file part. Anything - * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs - * reject, or files over the direct-attachment size limit) would make the turn - * fail before it starts, so those ride only as the file path ProviderService - * puts in the prompt. Text is native only as text/plain, which OpenCode reads - * itself; other text types (HTML, Markdown, CSV) reach the model API as raw - * file parts, which Anthropic and OpenAI reject. + * Text is native only as text/plain, which OpenCode reads itself; other text types (HTML, + * Markdown, CSV) reach the model API as raw file parts, which Anthropic and OpenAI reject. */ const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; @@ -532,14 +510,10 @@ export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): Permissi ]; } - // "Auto-accept edits" is documented as "auto-approve edits, ask before other - // actions", so prompting for every edit ignores the mode the user picked. // "auto" is left asking on purpose: the docs say providers without an AI // reviewer, OpenCode among them, fall back to Supervised for that mode. const editAction = runtimeMode === "auto-accept-edits" ? "allow" : "ask"; - // Session rules override OpenCode's agent defaults. Allow reads and task - // updates, but keep its default approval rules for environment files. return [ { permission: "*", pattern: "*", action: "ask" }, { permission: "read", pattern: "*", action: "allow" }, @@ -633,9 +607,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { : Effect.sync(() => { try { signalProcessGroup(Number(child.pid), "SIGKILL"); - } catch { - // The command and its process group may already have exited. - } + } catch {} }); yield* Effect.addFinalizer(() => terminateCommandGroup.pipe(Effect.ignore)); const collectOptions = @@ -689,9 +661,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { input, ) => Effect.gen(function* () { - // Bind this server's lifetime to the caller's scope. When the caller's - // scope closes, the spawned child is killed and all associated fibers - // are interrupted automatically β€” no `close()` method needed. const runtimeScope = yield* Scope.Scope; const hostname = input.hostname ?? DEFAULT_HOSTNAME; @@ -709,7 +678,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { )); const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; - // One server runs every session of the instance, so its scope has no thread. const launch = yield* agentScope.wrap({ command: input.binaryPath, args, @@ -734,13 +702,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { env: { ...input.environment, ...(serverPassword !== undefined ? { OPENCODE_SERVER_PASSWORD: serverPassword } : {}), - // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or - // the inherited process environment, only falling back to the - // empty config when neither is set. Setting it unconditionally - // previously clobbered the user's opencode config, hiding their - // providers/models. The value is set explicitly (rather than - // relying on inheritance) because `extendEnv` is false whenever - // `input.environment` is provided. + // The value is set explicitly (rather than relying on inheritance) because + // `extendEnv` is false whenever `input.environment` is provided. OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), }, extendEnv: input.environment === undefined, @@ -764,11 +727,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { : Effect.sync(() => { try { signalProcessGroup(Number(child.pid), signal); - } catch { - // The direct child may already have exited after starting the - // server; the process group kill is best-effort cleanup for - // any serve process left in that group. - } + } catch {} }); const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( Effect.andThen(Effect.sleep("1 second")), @@ -1036,7 +995,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { let agentsResult = initialAgentsResult; let skillsResult = initialSkillsResult; - // Retry once after 1s on transient failures (e.g. SQLite "database is locked") const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; @@ -1083,8 +1041,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }), ); - // Agent and skill metadata enrich the provider snapshot but are not required - // for an authoritative model inventory, so either may degrade to an empty list. let agents: ReadonlyArray = []; if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { agents = parseAgentListCliOutput(agentsResult.value.stdout); diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 45bb5bb5d7c1..040e4ead2b59 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -33,10 +33,6 @@ const killGroup = (pgid: number) => if (groupExists(pgid)) process.kill(-pgid, "SIGKILL"); }); -/** - * Spawns a throwaway process group shaped like `opencode serve`: by default a - * shell leader whose argv ends in the serve arguments, plus a `sleep` member. - */ const spawnGroup = (args: ReadonlyArray, script = "sleep 600 & wait") => Effect.acquireRelease( Effect.gen(function* () { @@ -53,7 +49,6 @@ const spawnGroup = (args: ReadonlyArray, script = "sleep 600 & wait") => ({ pid }) => killGroup(pid), ); -/** A T3 server that recorded its OpenCode server and then died without cleanup. */ const recordFromDeadServer = (stateDir: string, server: { readonly pid: number }) => Effect.gen(function* () { const previousServer = yield* spawnGroup([]); @@ -61,7 +56,6 @@ const recordFromDeadServer = (stateDir: string, server: { readonly pid: number } stateDir, ownerPid: previousServer.pid, }); - // The previous server never gets to forget its entry. yield* Effect.asVoid(previousLedger.track({ pid: server.pid, port: 4096, args: SERVE_ARGS })); process.kill(-previousServer.pid, "SIGKILL"); yield* previousServer.exited; @@ -102,7 +96,6 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); - // The wrapper starts the serve process in its group and exits. const wrapper = yield* spawnGroup( SERVE_ARGS, '/bin/sh -c "sleep 600 & wait" "$0" "$@" & exit 0', @@ -129,7 +122,6 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo const unrelated = yield* spawnGroup(SERVE_ARGS); yield* recordFromDeadServer(stateDir, unrelated); const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); - // Same pid, a different process: what a recycled pid looks like. const entry = yield* fs.readFileString(entryPath); yield* fs.writeFileString(entryPath, entry.replace(/"startTime":"[^"]+"/, '"startTime":"1"')); @@ -149,7 +141,6 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo const unrelated = yield* spawnGroup(SERVE_ARGS); yield* recordFromDeadServer(stateDir, unrelated); const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); - // Same pid and start second, but not the server T3 started. const entry = yield* fs.readFileString(entryPath); yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097")); @@ -186,8 +177,6 @@ describe.skipIf(observedPlatforms.length === 0)("OpenCode server startup", () => const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-startup-" }); - // A wrapper that leaves the serve process running in its group and exits 0, - // which the spawner's own cleanup does not stop. const binaryPath = path.join(tempDir, "opencode"); yield* fs.writeFileString(binaryPath, "#!/bin/sh\nsleep 600 &\nexit 0\n"); yield* fs.chmod(binaryPath, 0o755); @@ -210,7 +199,6 @@ describe.skipIf(observedPlatforms.length === 0)("OpenCode server startup", () => Layer.succeed( OpenCodeServerLedger.OpenCodeServerLedger, OpenCodeServerLedger.OpenCodeServerLedger.of({ - // Recording stalls until the scope is interrupted, after the wrapper exited. track: (server) => Effect.gen(function* () { yield* (yield* Deferred.await(spawned)).exitCode.pipe(Effect.ignore); diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts index cb6b2f92da41..9248f22cb877 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts @@ -17,21 +17,12 @@ type ProcessIdentity = typeof ProcessIdentity.Type; const OpenCodeServerEntry = Schema.Struct({ version: Schema.Literal(1), - /** The spawned group. Its id is the pid T3 spawned, which also names the entry file. */ pgid: Schema.Int, - /** - * A group member seen at spawn: the spawned process, or a process it left in - * the group if it had already exited. Its pid, start time, and command line - * must all still match before the group is signalled. - */ pid: Schema.Int, - /** Linux `/proc//stat` start ticks, or macOS `ps` lstart (whole seconds). */ startTime: Schema.String, command: Schema.String, port: Schema.Int, - /** Entries copied along with a state directory are dropped, never acted on. */ stateDir: Schema.String, - /** The T3 server that spawned it. Entries of a live owner are never touched. */ owner: ProcessIdentity, }); type OpenCodeServerEntry = typeof OpenCodeServerEntry.Type; @@ -39,21 +30,12 @@ const OpenCodeServerEntryJson = Schema.fromJsonString(OpenCodeServerEntry); const decodeEntry = Schema.decodeUnknownOption(OpenCodeServerEntryJson); const encodeEntry = Schema.encodeEffect(OpenCodeServerEntryJson); -/** - * Local `opencode serve` processes run in their own process group so T3 can - * stop the whole group, which also means they outlive a T3 server that is - * SIGKILLed or crashes. Each spawn is recorded under the state directory and - * removed on a graceful stop; the next server start stops whatever a dead - * server left behind. - */ export class OpenCodeServerLedger extends Context.Service< OpenCodeServerLedger, { - /** Records a spawned server group and returns the effect that forgets it after a graceful stop. */ readonly track: (server: { readonly pid: number; readonly port: number; - /** The argv after the binary, e.g. `["serve", "--hostname=127.0.0.1", "--port=4096"]`. */ readonly args: ReadonlyArray; }) => Effect.Effect>; } @@ -94,9 +76,7 @@ const parseDarwinPs = (output: string): ReadonlyArray => const signalGroup = (pgid: number, signal: NodeJS.Signals) => { try { signalProcessGroup(pgid, signal); - } catch { - // The group may already be gone. - } + } catch {} }; const groupExists = (pgid: number) => { @@ -108,10 +88,6 @@ const groupExists = (pgid: number) => { } }; -/** - * Builds a ledger for one state directory. `ownerPid` is the T3 server that - * owns the servers it tracks; it defaults to this process. - */ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { readonly stateDir: string; readonly ownerPid?: number; @@ -121,14 +97,11 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const platform = yield* HostProcess.Platform; const directory = path.join(input.stateDir, ENTRY_DIRECTORY); - // Windows servers are not detached and are never recorded. const recordable = platform === "linux" || platform === "darwin"; - /** Observes `pid`, or nothing when it is not in `group` (if given). */ const observeLinux = (pid: number, group?: number) => Effect.gen(function* () { const stat = yield* fs.readFileString(`/proc/${pid}/stat`); - // After the parenthesized comm: state, ppid, pgrp, session, … starttime (index 19). const fields = stat .slice(stat.lastIndexOf(")") + 2) .trim() @@ -174,7 +147,6 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { return Effect.succeed(undefined); }; - // Scans every process, so it is only used when the spawned process is gone. const observeGroup = (pgid: number): Effect.Effect> => { if (platform === "linux") { return fs.readDirectory("/proc").pipe( @@ -308,7 +280,6 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { ), ); - /** Stops recorded servers whose owning T3 server is gone and drops stale entries. */ const reapOrphans = Effect.gen(function* () { const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); yield* Effect.forEach( @@ -321,19 +292,16 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { return { track, reapOrphans }; }); -/** Tracks servers under `stateDir`, the server's own state directory. */ export const layer = (input: { readonly stateDir: string }) => Layer.effect( OpenCodeServerLedger, Effect.gen(function* () { const ledger = yield* make(input); - // Reaping waits for orphans to exit, so it must not hold up startup. yield* ledger.reapOrphans.pipe(Effect.forkScoped); return OpenCodeServerLedger.of({ track: ledger.track }); }), ); -/** Records nothing. For tests that start OpenCode servers without a state directory. */ export const layerTest = Layer.succeed( OpenCodeServerLedger, OpenCodeServerLedger.of({ track: () => Effect.succeed(Effect.void) }), diff --git a/packages/provider-opencode/src/server/OpenCodeServerOwner.ts b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts index 05d65dba377d..7ef2906683ab 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerOwner.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts @@ -26,7 +26,6 @@ export class OpenCodeServerOwner extends Context.Service< } >()("@t3tools/provider-opencode/server/OpenCodeServerOwner") {} -/** Owns the lazy local OpenCode server shared by one provider instance. */ export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { readonly binaryPath: string; readonly directory: string; diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index c2ec42bd16b5..7276d39cdd66 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -458,7 +458,6 @@ describe("OpenCodeAdapterV2", () => { assert.isTrue(Exit.isFailure(yield* Fiber.join(reply))); assert.isTrue(signal?.aborted); deliver = true; - // Failed delivery leaves the request available for an explicit retry. yield* harness.runtime.respondToRuntimeRequest(response); }).pipe( Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), @@ -1352,8 +1351,6 @@ describe("OpenCodeAdapterV2", () => { }, }, ); - // A forked run starts on its own row for the same native session, while - // the adapter tracks the session under the id it minted. const forkedRow = { ...harness.providerThread, id: ProviderThreadId.make("provider-thread:opencode-test:forked-run-row"), @@ -2485,7 +2482,6 @@ describe("OpenCodeAdapterV2", () => { const fakeClient = { event: { subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({ - // Emits nothing and ends when the pump's abort signal fires. stream: { [Symbol.asyncIterator]: () => ({ next: () => @@ -2538,9 +2534,6 @@ describe("OpenCodeAdapterV2", () => { runtimePolicy: runtimePolicy("full-access"), }); const now = yield* DateTime.now; - // The placeholder row the orchestrator creates for a first run: no - // native identity yet. The adapter must bind the created session to - // this row instead of minting a second session-keyed row. const placeholder: OrchestrationV2ProviderThread = { id: ProviderThreadId.make("thread:provider:opencode:native-thread:pending:run:adopt:1"), driver: OPENCODE_PROVIDER, @@ -2566,7 +2559,6 @@ describe("OpenCodeAdapterV2", () => { }); assert.equal(adopted.id, placeholder.id); assert.equal(adopted.nativeThreadRef?.nativeId, "ses_native_1"); - // Without a handed-over row the adapter still derives its own id. const minted = yield* session.ensureThread({ threadId, modelSelection, diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index b0b7f0ad29a3..0571de3009af 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -225,7 +225,6 @@ interface OpenCodeTurnTokenUsageAccumulator { readonly partIds: Set; readonly promptMessageIds: Set; readonly assistantOwnershipByMessageId: Map; - // Native removal does not undo usage. Keep unresolved counts until this turn settles. readonly unresolvedStepsByMessageId: Map>; inputTokens: number; cachedInputTokens: number; @@ -275,7 +274,6 @@ interface ActiveOpenCodeTurn { readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; - /** The provider thread the turn started on, which its terminal names. */ readonly providerThreadId: OrchestrationV2ProviderTurn["providerThreadId"]; readonly providerTurnOrdinal: number; readonly runOrdinal: number; @@ -395,14 +393,9 @@ interface OpenCodeThreadState { interface OpenCodeRequestOwner { readonly state: OpenCodeThreadState; readonly turn: ActiveOpenCodeTurn; - /** The subagent on the owner's thread that leads to the asking session. */ readonly subagent: OpenCodeSubagentContext | null; } -/** - * The top-level thread and active turn that own a session's requests, walking - * up through (possibly nested) subagents. - */ function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner | undefined { let owner = state; let subagent: OpenCodeSubagentContext | null = null; @@ -416,7 +409,6 @@ function topLevelRequestOwner(state: OpenCodeThreadState): OpenCodeRequestOwner interface PendingOpenCodeRequest { readonly requestId: RuntimeRequestId; readonly nativeRequestId: string; - /** The session that asked, which may be a subagent's. */ readonly nativeSessionId: string; readonly turn: ActiveOpenCodeTurn; readonly state: OpenCodeThreadState; @@ -609,10 +601,7 @@ const OPENCODE_RESTRICTED_PERMISSIONS = [ /** * OpenCode does not provide an OS sandbox, so permission rules are also the - * enforcement boundary for non-interactive policies. Read/planning tools are - * safe by default; edits are auto-approved only for workspace-write modes, - * while shell/network/external access remains gated unless policy explicitly - * allows it. + * enforcement boundary for non-interactive policies. */ export function openCodePermissionRules( runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, @@ -1007,10 +996,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const pendingRequestsByNativeId = new Map(); const subagentsByNativeItemId = new Map(); const subagentsByChildSessionId = new Map(); - // Permission and question requests can originate from child sessions - // (task subagents and their descendants). Related sessions map back - // to the root thread state whose active turn owns the request; asks - // arriving before the relation is known resolve it via session.get. const relatedSessionOwners = new Map(); // Sessions of this runtime that OpenCode reports busy. A background // task child keeps running after its parent turn settles, so idle @@ -1543,8 +1528,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function yield* emitSubagent(state, turn, part); return; } - // question.asked carries the respondable semantic item. Projecting - // the implementation tool as well would duplicate it in the UI. if (normalizedTool === "question") return; const now = yield* DateTime.now; const status = toolStatus(part); @@ -1829,7 +1812,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }); const nodeId = idAllocator.derive.approvalNode({ requestId }); const turnItemId = idAllocator.derive.approvalTurnItem({ requestId }); - // The tool call belongs to the asking session's turn, not the owner's. const permissionToolName = request.type === "permission" && request.value.tool !== undefined ? threads @@ -1970,10 +1952,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function if (!hasOtherPending) yield* updateProviderSession("running", null); }); - /** Resolve the thread state a session belongs to: its own, or for a - * child not registered yet, the nearest known ancestor found by walking - * the native parent chain. Registers every hop so later requests from - * the same child resolve without another lookup. */ const resolveSessionOwner = Effect.fnUntraced(function* (sessionId: string) { const known = threads.get(sessionId) ?? relatedSessionOwners.get(sessionId); if (known !== undefined) return known; @@ -1997,14 +1975,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function return undefined; }); - /** Every request is asked on the top-level thread and its active turn, - * under the subagent that leads to the asking session, because native - * subagent threads are hidden from the sidebar. A child's request can - * arrive before the task part or session.created event that reveals - * its relation to a thread. The first resolution attempt runs inline - * (the replayable path); if the relation or the owning turn is not - * established yet, a short forked backoff keeps trying instead of - * dropping the request. */ const routeRuntimeRequest = Effect.fnUntraced(function* ( nativeRequestId: string, sessionId: string, @@ -2483,7 +2453,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function return; } if (part.type === "tool") { - // Approval routing needs the tool name, without retaining its input and output. turn.toolNamesByCallId.set(part.callID, part.tool); } else { turn.parts.set(part.id, part); @@ -2732,7 +2701,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function Effect.exit, Effect.flatMap((exit) => Effect.gen(function* () { - // No further status can clear a busy session once the stream ends. busySessionIds.clear(); if (closing || abortController.signal.aborted) return; const detail = Exit.isSuccess(exit) @@ -3030,8 +2998,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function hasPendingBackgroundWork: Effect.sync(() => busySessionIds.size > 0), ensureThread: (threadInput) => Effect.gen(function* () { - // Only a row that already carries a native session can be - // resumed; a placeholder without one still needs session.create. if (threadInput.existingProviderThread?.nativeThreadRef != null) { return yield* runtimeSession.resumeThread({ providerThread: threadInput.existingProviderThread, @@ -3058,9 +3024,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function now: createdAt, }); const existing = threadInput.existingProviderThread; - // Bind the new native session to the caller's row when one was - // handed over: a second live row per app thread would make - // `activeProviderThreadId` flap between the two on every update. const providerThread = existing === undefined ? created @@ -3416,8 +3379,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const sessionId = nativeThreadId(interruptInput.providerThread); const state = threads.get(sessionId); const turn = state?.activeTurn; - // Stop on a settled turn: a background task child can outlive - // it (busySessionIds), and aborting the descendants ends it. if ( (turn === undefined || turn === null) && interruptInput.requestRuntimeRestart === true @@ -3442,7 +3403,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function cancelOpenCodePromptAdmission(turn, state!.nextAdmissionGeneration++); turn.admissionAbortController?.abort(); if (admissionWasPending) { - // Settle the cancelled request before sending the session abort. // A timed-out local request must not prevent the definitive // server-side abort below from running. yield* Deferred.await(turn.admissionSettled).pipe( @@ -3454,10 +3414,6 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function client.session.abort({ sessionID: sessionId }, { signal }), ).pipe( Effect.timeout("10 seconds"), - // The turn can settle while the abort is in flight, and - // aborting an already-idle session fails. That stop still - // succeeded; only surface failures for a turn that is - // genuinely still running. Effect.catch((cause) => turn.finalized ? Effect.void diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 64b18e0db88b..3b6419ad4a59 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -41,7 +41,6 @@ const reachedServer = (operation: string) => ), ), ); -// Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused. const openCode2Runtime = { runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), startOpenCodeServerProcess: () => reachedServer("start"), @@ -76,7 +75,6 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { Effect.gen(function* () { serverStarts.length = 0; const instance = yield* create({}, noHttp); - // Text generation goes to the instance's 2.x server, which this test refuses to start. yield* Effect.flip( instance.textGeneration.generateThreadTitle({ cwd: process.cwd(), @@ -121,7 +119,6 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { const hang = HttpClient.make(() => Effect.never); const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang); - // No probe has succeeded yet, and the server never answers: the 1.x default applies. const capabilities = yield* instance.orchestrationAdapter .getCapabilities() .pipe(Effect.forkChild); @@ -182,7 +179,6 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( npmGlobalInstall(v1Prefix, ["opencode-ai"], "opencode"), ); assert.strictEqual(v1.update?.args.at(-1), "opencode-ai@latest"); - // A 1.x install is never offered 2.x's package, or the reverse. assert.isNull( (yield* resolveUpdate("v2", NodePath.join(v1Prefix, "bin", "opencode"))).update, ); @@ -192,7 +188,6 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( }), ); -// The update the driver offers follows the version the binary reports now. const versionOutput: { current: string | undefined } = { current: undefined }; const versionProbes: Array = []; const changingRuntime = { @@ -259,7 +254,6 @@ it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { config: { ...OpenCodeDriver.defaultConfig(), binaryPath }, }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); - // The version could not be read: which package owns the binary is unknown. versionOutput.current = undefined; const unknown = yield* instance.snapshot.resolveMaintenance({ fresh: true }); assert.isNull(unknown.update); @@ -269,8 +263,6 @@ it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { const v1 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); assert.strictEqual(v1.packageName, "opencode-ai"); - // The same path now reports 2.x (reinstalled in place): a fresh read re-probes - // and never offers 1.x's package for it. versionOutput.current = "opencode v2.0.18\n"; const v2 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); assert.strictEqual(v2.packageName, "@opencode/cli"); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 56864f5592eb..cd1476d01688 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -1,17 +1,3 @@ -/** - * OpenCodeDriver β€” `ProviderDriver` for the OpenCode runtime. - * - * Mirrors the Codex / Claude drivers: a plain value whose `create()` - * bundles `snapshot` / `adapter` / `textGeneration` closures over the - * per-instance `OpenCodeSettings`. - * - * Two instances with different `serverUrl`s therefore talk to independent - * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation - * shares spin up their own scoped child processes, and those child - * processes are released when the registry scope closes. - * - * @module provider/Drivers/OpenCodeDriver - */ import { ProviderDriverKind } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; import * as Crypto from "effect/Crypto"; @@ -108,7 +94,6 @@ type OpenCodeRuntimeProbe = Effect.Success< ReturnType> >; -/** Runs `v2` for a 2.x instance and `v1` otherwise; a failed probe keeps 1.x, whose checks report it. */ function byOpenCodeRuntime( probed: Effect.Effect, paths: { readonly v1: Effect.Effect; readonly v2: Effect.Effect }, @@ -120,9 +105,9 @@ function byOpenCodeRuntime( } /** - * Routes each adapter call to the runtime the instance's probe detected. Capability and selection - * reads are hot, so they use the last successful probe (1.x before one lands) and never wait on a - * slow server. Opening a session waits for a probe, so each server is spoken to in its own protocol. + * Capability and selection reads are hot, so they use the last successful probe (1.x before one + * lands) and never wait on a slow server. Opening a session waits for a probe, so each server is + * spoken to in its own protocol. */ function selectOpenCodeRuntimeAdapter(input: { readonly probe: OpenCodeRuntimeProbe; @@ -143,7 +128,6 @@ function selectOpenCodeRuntimeAdapter(input: { }; } -/** Text generation runs on the server the instance's probe detected, each in its own protocol. */ function selectOpenCodeRuntimeTextGeneration( probe: OpenCodeRuntimeProbe, v1: ProviderTextGeneration, @@ -231,10 +215,6 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), ), ); - // Updates follow the installed package, which the version probe names. An - // unknown version offers no package update, since a guess could move a - // 2.x install onto 1.x's package or the reverse. A disabled instance never - // runs its binary, so it has no version and offers no update. const noUpdate = makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, packageName: null, @@ -258,8 +238,6 @@ export const OpenCodeDriver: ProviderDriver< const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( maintenanceFor(runtimeProbe.get), ); - // A fresh read (an update about to run, or a manual refresh) re-probes, so - // a binary replaced by the other major version gets its own package. const resolveMaintenance = (options?: { readonly fresh?: boolean }) => !effectiveConfig.enabled ? Effect.succeed(noUpdate) @@ -287,7 +265,6 @@ export const OpenCodeDriver: ProviderDriver< }), ), ); - // One OpenCode 2 server per instance, spawned on first use or reached at `serverUrl`. const openCode2Server = yield* OpenCode2Server.make({ binaryPath: effectiveConfig.binaryPath, serverUrl: effectiveConfig.serverUrl, @@ -315,9 +292,6 @@ export const OpenCodeDriver: ProviderDriver< loadOpenCode2Catalog(client, host.paths.cwd), ), ); - // A 2.x server lists skills and commands per directory, so one server - // answers every workspace. Its event stream says when a directory it had - // not served yet finished scanning. const listOpenCode2Workspace = (cwd: string) => openCode2Server.withConnection(({ client, events }) => Effect.gen(function* () { @@ -394,14 +368,10 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), ); - // NOTE: the local branch intentionally uses the shared SDK server - // instead of `opencode debug skill` (loadSkillsFromCli). The CLI writes - // its full JSON inventory to stdout, but the Bun-compiled binary does - // not flush more than one 64KB pipe buffer to a non-TTY stdout, so the - // piped output arrives truncated and unparseable β€” which degrades to an - // empty skill list and poisons the workspace snapshot the `$` picker - // reads. The SDK `app.skills` endpoint honors the per-request directory - // and returns complete results regardless of size. + // The CLI writes its full JSON inventory to stdout, but the Bun-compiled binary does not + // flush more than one 64KB pipe buffer to a non-TTY stdout, so the piped output arrives + // truncated and unparseable β€” which degrades to an empty skill list and poisons the workspace + // snapshot the `$` picker reads. const loadWorkspaceInventory = ( client: Parameters[0], ) => diff --git a/packages/provider-opencode/src/server/openCode2Catalog.test.ts b/packages/provider-opencode/src/server/openCode2Catalog.test.ts index 4bddbd88e633..326b25c0693d 100644 --- a/packages/provider-opencode/src/server/openCode2Catalog.test.ts +++ b/packages/provider-opencode/src/server/openCode2Catalog.test.ts @@ -9,14 +9,8 @@ import { describe } from "vite-plus/test"; import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; import * as OpenCode2Client from "./v2/OpenCode2Client.ts"; -/** - * `GET /api/model` from an `opencode serve` 2.0.26 with credentials for both - * gateways, cut to the one model each of them lists. Step 5 Preview Free is - * the whole point: both providers list it, with the same name. - */ const MODELS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode-go","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}},{"id":"step-5-preview-free","modelID":"step-5-preview-free","providerID":"opencode","name":"Step 5 Preview Free","compatibility":{"reasoningField":"reasoning_content"},"package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1","provider":"opencode"},"capabilities":{"tools":true,"input":["text","image","video"],"output":["text"]},"variants":[{"id":"low","settings":{"reasoningEffort":"low"}},{"id":"medium","settings":{"reasoningEffort":"medium"}},{"id":"high","settings":{"reasoningEffort":"high"}}],"time":{"released":1789516800000},"cost":[{"input":0,"output":0,"cache":{"read":0,"write":0}}],"status":"active","enabled":true,"limit":{"context":1000000,"input":1000000,"output":65536}}]}`; -/** `GET /api/provider` from the same server. */ const PROVIDERS_BODY = `{"location":{"directory":"/project"},"data":[{"id":"opencode-go","integrationID":"opencode-go","name":"OpenCode Go","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"sk-demo-not-real","baseURL":"http://127.0.0.1:48990/v1","provider":"opencode-go"}},{"id":"opencode","integrationID":"opencode","name":"OpenCode Zen","activation":"enabled","package":"@opencode/ai/providers/openai-compatible","settings":{"apiKey":"public","baseURL":"https://opencode.ai/zen/v1"}}]}`; const layerServerReplying = ( @@ -45,10 +39,6 @@ const layerServerReplying = ( ), ); -/** - * Reads a catalog the way the driver does, through the client and a - * connection. `hanging` names paths the server accepts but never answers. - */ const readCatalog = ( bodies: Readonly>, hanging: ReadonlyArray = [], @@ -99,8 +89,6 @@ describe("loadOpenCode2Catalog", () => { }), ); - // A server that cannot name its providers still lists its models, and the - // status check labels those with the provider id instead. it.effect("leaves a model unnamed when the provider list fails", () => Effect.gen(function* () { const models = yield* readCatalog({ "/api/model": MODELS_BODY }); @@ -115,8 +103,6 @@ describe("loadOpenCode2Catalog", () => { }), ); - // Names are decoration: a provider read that never answers must not hold the - // models back, because the models are what the snapshot needs. it.effect("keeps reading models while a provider list stays pending", () => Effect.gen(function* () { const reading = yield* readCatalog({ "/api/model": MODELS_BODY }, ["/api/provider"]).pipe( diff --git a/packages/provider-opencode/src/server/openCode2Catalog.ts b/packages/provider-opencode/src/server/openCode2Catalog.ts index ddf78f3684fc..6c6c30846e44 100644 --- a/packages/provider-opencode/src/server/openCode2Catalog.ts +++ b/packages/provider-opencode/src/server/openCode2Catalog.ts @@ -6,29 +6,14 @@ import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import type { OpenCode2Model } from "./status.ts"; /** - * How long a provider read may take before the models are listed without it. - * A server loads its provider list with the rest of its catalog, which takes a - * couple of seconds on a cold start, and the model loader retries while the - * catalog is empty anyway. Past that, the names are not worth waiting for. + * A server loads its provider list with the rest of its catalog, which takes a couple of seconds + * on a cold start, and the model loader retries while the catalog is empty anyway. */ const PROVIDER_NAME_TIMEOUT = "3 seconds"; /** - * Reads the model catalog of an OpenCode 2 server, and names the provider each - * model belongs to. - * - * One instance fronts several providers: OpenCode Zen, OpenCode Go, and - * whatever the user added themselves. Their catalogues overlap, so a model's - * name alone cannot say which provider listed it and two rows for it read the - * same. Reading the server's provider list in the same connection as its - * models is what tells them apart. - * - * A server that cannot name its providers still lists its models, so the - * provider read is optional: a model whose provider went unnamed keeps only - * its id, which the status check then uses as the label. The models carry the - * payload and the names are decoration, so a provider read is bounded as well - * as optional: one that never answers must not hold the models back. Both - * reads are issued at once, and only the slower of the two is waited on. + * The models carry the payload and the names are decoration, so a provider read is bounded as + * well as optional: one that never answers must not hold the models back. */ export const loadOpenCode2Catalog = ( client: OpenCodeClient, diff --git a/packages/provider-opencode/src/server/probeResponses.fixture.ts b/packages/provider-opencode/src/server/probeResponses.fixture.ts index 3dbf954b2f20..0da4938d4402 100644 --- a/packages/provider-opencode/src/server/probeResponses.fixture.ts +++ b/packages/provider-opencode/src/server/probeResponses.fixture.ts @@ -1,7 +1,3 @@ -/** - * Responses recorded from `opencode serve` 1.18.32 and 2.0.18 on 2026-09-29, replayed by an - * `HttpClient` so the version probe runs against real bytes. - */ import * as Effect from "effect/Effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -31,11 +27,6 @@ export const OPENCODE_2_RESPONSES = { }, } satisfies Record; -/** - * A 2.0.18 server's command and skill lists for a directory with one command - * (`.opencode/command/bee.md`) and one skill (`.opencode/skills/plum`), live on - * 2026-09-29. Skill contents are cut; machine-wide skills are dropped. - */ export const OPENCODE_2_WORKSPACE_RESPONSES = { "/api/command": { status: 200, @@ -60,7 +51,6 @@ export const OPENCODE_1_RESPONSES = { unauthorized: { status: 401, body: "" }, } satisfies Record; -/** Replays a recorded server; credentials other than `password` (none when empty) get its 401. */ export function replayOpenCodeServer( responses: | typeof OPENCODE_1_RESPONSES diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index 0ed3d648641e..49c13da9a44f 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -80,7 +80,6 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res NodeAssert.equal(limits.unavailable, undefined); NodeAssert.equal( limits.credentialFingerprint, - // SHA-256 of "opencode-go\0instance-key". "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37", ); NodeAssert.deepEqual( @@ -157,15 +156,6 @@ it.effect("keeps Go entitlement absence distinct from failed or malformed usage }), ); -/** - * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag - * are deleted. The snapshot-producing logic they wrapped now lives in the - * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which - * drivers call directly when building their per-instance snapshot - * `ManagedServerProvider`. Tests mirror that shape: build a settings payload, - * invoke the check, assert on the returned snapshot. - */ - const runtimeMock = { state: { runVersionError: null as Error | null, @@ -566,7 +556,6 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { "auto", "full-access", ]); - // Plan mode runs as OpenCode's plan agent, and every session can compact. NodeAssert.equal(snapshot.showInteractionModeToggle, true); NodeAssert.deepEqual( snapshot.slashCommands.map((command) => command.name), @@ -604,7 +593,6 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.message, "OpenCode could not load its model list."); - // A failed catalog read still leaves the session able to compact. NodeAssert.deepEqual( snapshot.slashCommands.map((command) => command.name), ["compact"], @@ -702,8 +690,6 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i it.effect("labels each model with the sub-provider that listed it", () => Effect.gen(function* () { - // Names come from the server's provider list; see openCode2Catalog.test.ts - // for reading that list itself. const snapshot = yield* checkProvider( makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", @@ -734,9 +720,7 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i const subProviderFor = (slug: string) => snapshot.models.find((model) => model.slug === slug)?.subProvider; NodeAssert.equal(subProviderFor("openai/gpt-5.4"), "OpenAI"); - // A provider called plain "OpenCode" would repeat the instance name. NodeAssert.equal(subProviderFor("custom/m1"), undefined); - // The id stands in until the server names its providers. NodeAssert.equal(subProviderFor("anthropic/claude"), "anthropic"); }), ); @@ -824,7 +808,6 @@ const bigPickle: OpenCode2Model = { it.effect("waits for a fresh OpenCode 2 server to list its models", () => Effect.gen(function* () { - // A fresh server lists nothing until its catalog loads. const replies: Array> = [[], [], [bigPickle]]; const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle])); const fiber = yield* load.pipe(Effect.forkChild); @@ -845,7 +828,6 @@ it.effect("keeps the last OpenCode 2 model list while a fresh server's stays emp }).pipe(Effect.provide(TestClock.layer())), ); -// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29). const builtinCommands = [ { name: "init", description: "guided AGENTS.md setup" }, { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" }, @@ -869,7 +851,6 @@ const scanned: OpenCode2Workspace = { it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () => Effect.gen(function* () { - // A directory the server has not served yet lists no commands until its scan ends. const reads: Array = [{ commands: [], skills: [] }, scanned]; const scanEnded = yield* Deferred.make(); const load = yield* loadOpenCode2Workspace( diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index cb0892c5d9d6..33c9052eece3 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -200,11 +200,6 @@ function openCodeCapabilitiesForModel(input: { readonly agents: ReadonlyArray; }): ModelCapabilities { const rawVariantValues = Object.keys(input.model.variants ?? {}); - // When a model advertises no variants, synthesize the standard reasoning - // levels so the composer still offers a Reasoning selector (mirrors the - // Codex/Grok experience where reasoning is always configurable). The set - // covers the common OpenCode variant spectrum; `inferDefaultVariant` - // picks the provider-appropriate default (e.g. medium for openai/opencode). const variantValues = rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; const defaultVariant = inferDefaultVariant(input.providerID, variantValues); @@ -378,27 +373,18 @@ export const makePendingOpenCodeProvider = ( }); }); -/** One model an OpenCode 2 server lists, as the status check reads it. */ export interface OpenCode2Model { readonly providerID: string; readonly id: string; readonly name: string; - /** - * Name of the provider the model belongs to, when the server names it. One - * OpenCode instance fronts several: OpenCode Zen, OpenCode Go, and whatever - * the user added themselves. - */ readonly providerName?: string; readonly variants: ReadonlyArray<{ readonly id: string }>; } /** - * One OpenCode instance fronts several providers, and their catalogues - * overlap: OpenCode Zen and OpenCode Go both list Step 5 Preview Free. Naming - * the sub-provider is what tells two such rows apart. The label only earns its - * place when it adds to the instance name the row already shows, which is why a - * provider called plain "OpenCode" keeps none, and the id stands in while the - * server has not named its providers yet. + * One OpenCode instance fronts several providers, and their catalogues overlap: + * OpenCode Zen and OpenCode Go both list Step 5 Preview Free. Naming the + * sub-provider is what tells two such rows apart. */ function subProviderLabelFor(input: { readonly providerID: string; @@ -434,7 +420,6 @@ export const makeOpenCode2ModelLoader = ( ); }); -/** A workspace's skills and commands, as an OpenCode 2 server lists them for its directory. */ export interface OpenCode2Workspace { readonly skills: ReadonlyArray<{ readonly id: string; @@ -449,11 +434,10 @@ export interface OpenCode2Workspace { } /** - * A server scans a directory it has not served yet in the background: its - * first read lists no commands at all (not even the built-ins), and - * `command.updated` plus `skill.updated` for the directory end the scan. A - * directory it already serves lists everything at once. `scanned` must be - * listening before `read` runs, since the read is what starts the scan. + * A server scans a directory it has not served yet in the background: its first read lists + * no commands at all (not even the built-ins), and `command.updated` plus `skill.updated` + * for the directory end the scan. `scanned` must be listening before `read` runs, since + * the read is what starts the scan. */ export const loadOpenCode2Workspace = ( read: Effect.Effect, @@ -499,9 +483,8 @@ export function openCode2CommandsToServerProviderSlashCommands( } /** - * Every mode maps onto OpenCode 2 session rules. Auto asks like Supervised: - * OpenCode has no reviewer that approves routine actions. Plan mode is its - * `plan` agent, so the composer's mode toggle drives it. + * Auto asks like Supervised: OpenCode has no reviewer that approves routine + * actions. */ const OPENCODE_2_PRESENTATION = { ...OPENCODE_PRESENTATION, @@ -547,7 +530,6 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( message, }); if (result._tag === "Failure") { - // The detail can carry a response body; it stays in the log, not the status. yield* Effect.logWarning("OpenCode 2 model list failed", result.cause); return buildServerProvider({ presentation: OPENCODE_2_PRESENTATION, @@ -558,7 +540,6 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( settings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ), - // Compaction does not depend on the model catalog. slashCommands: [COMPACT_SLASH_COMMAND], probe: probe("error", "OpenCode could not load its model list."), }); @@ -584,7 +565,6 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( enabled: true, checkedAt, models, - // Every session can compact; a workspace snapshot adds that directory's commands. slashCommands: [COMPACT_SLASH_COMMAND], probe: result.value.length > 0 @@ -596,11 +576,6 @@ const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* ( }); }); -/** - * `probeRuntime` is the driver's memoized version probe: `opencode --version` for a local binary, - * the version endpoints for a configured server. The status check refreshes it, so an in-place - * upgrade re-routes the instance. - */ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( openCodeSettings: OpenCodeSettings, cwd: string, diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index 48f85e60b6cb..0cc03755a5ce 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -53,8 +53,6 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { const index = runtimeMock.state.startCalls.length + 1; const url = `http://127.0.0.1:${4_300 + index}`; runtimeMock.state.startCalls.push(binaryPath); - // The production runtime binds server lifetime to the caller's scope. - // Mirror that here so the closeCalls probe observes scope close. yield* Effect.addFinalizer(() => Effect.sync(() => { runtimeMock.state.closeCalls.push(url); diff --git a/packages/provider-opencode/src/server/toolItems.ts b/packages/provider-opencode/src/server/toolItems.ts index 1b01b2b8007c..f4551cdd279c 100644 --- a/packages/provider-opencode/src/server/toolItems.ts +++ b/packages/provider-opencode/src/server/toolItems.ts @@ -1,8 +1,3 @@ -/** - * Turn-item bodies for OpenCode tool calls, shared by the 1.x and 2.x runtimes. - * Both report a tool as a name, a JSON input, text output, and free-form - * metadata; only how they deliver those differs. - */ import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; @@ -71,10 +66,6 @@ function stableJson(value: unknown): string { } } -/** - * The turn item for one tool call. `completedMetadata` is the tool's metadata - * once it succeeded: exit codes and diffs are only trusted from a finished call. - */ export function openCodeToolTurnItem( base: ToolItemBase, tool: { @@ -99,7 +90,6 @@ export function openCodeToolTurnItem( case "file_change": { const oldStr = recordString(input, "oldString", "oldText"); const newStr = recordString(input, "newString", "content", "newText"); - // A failed edit has no diff; keep its error where the diff would be. const diffStr = recordString(tool.completedMetadata, "diff", "patch") ?? (base.status === "failed" && output?.trim() ? output : undefined); @@ -114,8 +104,6 @@ export function openCodeToolTurnItem( } case "file_search": { const pattern = recordString(input, "pattern", "query", "path", "filePath"); - // OpenCode reports matches as plain text, so keep it as one result row - // under the searched path, like the ACP search projection. const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim(); return { ...base, diff --git a/packages/provider-opencode/src/server/usage.test.ts b/packages/provider-opencode/src/server/usage.test.ts index e2e80e42712c..61ffb25adbd7 100644 --- a/packages/provider-opencode/src/server/usage.test.ts +++ b/packages/provider-opencode/src/server/usage.test.ts @@ -11,7 +11,6 @@ import { vi } from "vite-plus/test"; import { readOpenCodeUsage } from "./usage.ts"; -/** A writable connection that stays open until the test's scope closes. */ const openDatabase = (filename: string) => Layer.build(NodeSqliteClient.layer({ filename })).pipe( Effect.map(Context.get(SqlClient.SqlClient)), @@ -33,7 +32,6 @@ describe("readOpenCodeUsage", () => { modelID: "claude-sonnet-4-5", time: { created: sinceMs }, tokens: { input: 100, output: 20 }, - // Transcript bodies are irrelevant to usage, but can be very large. body: "x".repeat(64 * 1024), }; const data = JSON.stringify(message); @@ -42,7 +40,6 @@ describe("readOpenCodeUsage", () => { "INSERT INTO message (rowid, id, session_id, data, time_created) VALUES (?, ?, ?, ?, ?)", [rowId, id, "session-1", payload, created], ); - // Sparse IDs, including negative rowids, must not skip records at page boundaries. for (let index = 0; index < 130; index++) { yield* insert(index * 2 - 10, `msg-${index}`, data); } @@ -81,7 +78,6 @@ describe("readOpenCodeUsage", () => { records.reduce((total, record) => total + record.totals.uncachedInputTokens, 0), 13000, ); - // The fixture exceeds 8 MiB. No query may retain its complete bodies. assert.isAbove(largestRawResult, 0); assert.isBelow(largestRawResult, 6 * 1024 * 1024); }).pipe(Effect.provide(NodeServices.layer)), @@ -102,7 +98,6 @@ describe("readOpenCodeUsage", () => { modelID: "claude-sonnet-4-5", tokens: { input: 100, output: 20 }, }); - // OpenCode appends, so the history before the window comes first. yield* db.unsafe("BEGIN"); for (let index = 0; index < 305; index++) { yield* db.unsafe("INSERT INTO message VALUES (?, ?, ?, ?)", [ @@ -211,7 +206,6 @@ describe("readOpenCodeUsage", () => { sessionID: "session-1", role: "assistant", modelID: "claude-sonnet-4-5", - // In range, so an old file that was read would produce a record. time: { created: sinceMs + 1000 }, tokens: { input: 100, output: 20 }, }), @@ -274,7 +268,6 @@ describe("readOpenCodeUsage", () => { yield* fileSystem.symlink(path.join(outside, "gone.json"), path.join(session, "gone.json")); yield* fileSystem.symlink(path.join(session, "loop.json"), path.join(session, "loop.json")); - // Links alone are not a store, even when one points at a stale message. const linksOnly = yield* readOpenCodeUsage(dir, sinceMs); assert.deepStrictEqual(linksOnly, { files: [], missing: true, error: false }); @@ -300,7 +293,6 @@ describe("readOpenCodeUsage", () => { const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-reader-test-" }); const session = path.join(dir, "storage", "message", "session-1"); yield* fileSystem.makeDirectory(session, { recursive: true }); - // More entries than the walk stats at once. yield* Effect.forEach( Array.from({ length: 600 }, (_, index) => `msg-${index}`), (id) => diff --git a/packages/provider-opencode/src/server/usage.ts b/packages/provider-opencode/src/server/usage.ts index 6ccfb688891e..f387c3d99efa 100644 --- a/packages/provider-opencode/src/server/usage.ts +++ b/packages/provider-opencode/src/server/usage.ts @@ -1,10 +1,3 @@ -/** - * Usage history for OpenCode, read from its SQLite databases and legacy JSON - * message store under each data directory. - * - * @module provider-opencode/server/usage - */ - import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { totalTokens, @@ -36,7 +29,6 @@ function text(value: unknown): string { return typeof value === "string" ? value : ""; } -/** OpenCode stores uncached input and reasoning separately from input/output. */ function parseOpenCodeMessage( source: string, fallback: { @@ -93,13 +85,11 @@ export interface OpenCodeUsageReadResult { const isNotFound = (cause: PlatformError.PlatformError) => cause.reason._tag === "NotFound"; const MESSAGE_PAGE_SIZE = 64; -/** Whether the path is itself a symlink, without following it. */ const isSymbolicLink = Effect.fnUntraced(function* (path: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* Effect.isSuccess(fileSystem.readLink(path)); }); -/** Stat of a regular file or directory; a symlink reports as itself and is never followed. */ const entryInfo = Effect.fn("entryInfo")(function* (path: string) { const fileSystem = yield* FileSystem.FileSystem; if (yield* isSymbolicLink(path)) @@ -116,7 +106,6 @@ const LEGACY_STAT_CONCURRENCY = 4; /** Entries stat'd before any is processed, so one huge directory is not held in memory at once. */ const LEGACY_STAT_BATCH = 256; -/** Reads current SQLite and pre-migration JSON stores without modifying either. */ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( root: string, sinceMs: number, @@ -140,7 +129,6 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( Effect.flatMap((names) => Effect.filter( names.filter((name) => /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(name)), - // One database removed between listing and stat must not hide the rest. (name) => entryInfo(path.join(root, name)).pipe( Effect.map((info) => info.type === "File"), @@ -243,8 +231,6 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( ); } - // Do not follow symlinks, including cycles. Database records win over their - // old JSON copies when OpenCode has migrated a store in place. const directories = [path.join(root, "storage", "message")]; while (directories.length > 0) { const directory = directories.pop()!; @@ -261,7 +247,6 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( (entry) => fileSystem.stat(entry).pipe( Effect.catchTags({ - // A symlink whose target cannot be stat'd is skipped, not an error. PlatformError: (cause) => isNotFound(cause) ? Effect.succeed(null) @@ -287,7 +272,6 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( const skip = seen.has(`opencode:${id}`) || Option.exists(info.mtime, (mtime) => mtime.getTime() < sinceMs); - // A skipped file only matters as proof that the store exists. if (found && skip) continue; if (yield* isSymbolicLink(entry)) continue; found = true; @@ -318,10 +302,6 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( return { files, missing: !found && !error, error }; }); -/** - * The data directories to read: `OPENCODE_DATA_DIR` (comma-separated) or the - * XDG default, canonicalized so aliases count once. - */ const resolveOpenCodeDataDirs = Effect.fn("resolveOpenCodeDataDirs")(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 6472bb71039c..52689da27480 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -103,9 +103,8 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( }, ]; const crypto = yield* Crypto.Crypto; - // Go's usage response has no account ID. An unkeyed hash matches across - // environments without a shared secret. It permits offline guesses, but - // Go keys are randomly generated. + // An unkeyed hash matches across environments without a shared secret. It + // permits offline guesses, but Go keys are randomly generated. const credentialFingerprint = yield* crypto .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`)) .pipe(Effect.map(Hex.encode), Effect.orDie); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts index 95d399de05df..e5a05c7f17c3 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.live.test.ts @@ -1,12 +1,3 @@ -/** - * Live contract check for `@opencode/client` against a real OpenCode 2 server. - * It re-proves that OpenCode's schemas decode under the workspace Effect - * version, which the pnpm overrides force. Run it after bumping either side: - * - * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Client.live.test.ts - * - * The turn uses the free `opencode/big-pickle` model. - */ import * as NodeServices from "@effect/platform-node/NodeServices"; import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect"; import { assert, it } from "@effect/vitest"; @@ -53,14 +44,11 @@ const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (bina }, }), ); - // Stop by the PID captured at spawn, before the temp directory is removed. yield* Effect.addFinalizer(() => Effect.sync(() => { try { process.kill(Number(child.pid), "SIGTERM"); - } catch { - // Already exited. - } + } catch {} }).pipe(Effect.andThen(child.exitCode), Effect.timeout("10 seconds"), Effect.ignore), ); const baseUrl = yield* child.stdout.pipe( @@ -124,7 +112,6 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => { ); assert.strictEqual(terminal.type, "session.execution.succeeded"); - // One message per page makes every request after the first send only a cursor. const history = yield* OpenCode2Client.paginate( { sessionID: session.id, order: "asc", limit: 1 }, client.message.list, diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts index dbc56a04c7ea..3b002194befe 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -10,7 +10,6 @@ import * as OpenCode2Client from "./OpenCode2Client.ts"; const INFO_BODY = '{"version":"2.0.18","pid":4242,"urls":[],"paths":{"tmp":"/tmp"}}'; -/** Sends one request through the client and returns the Authorization header it carried. */ const authorizationFor = (password: string | Redacted.Redacted) => { const seen: Array = []; const layerCapturing = Layer.succeed( diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts index fcfb87e41260..88a56ad00cd1 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -17,27 +17,17 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; /** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ const OPENCODE_USERNAME = "opencode"; -/** - * A session's execution ended, but this build could not decode the event (a - * newer server added a field or a value). Only the type and session survive, - * so the turn still ends. - */ export interface OpenCode2UnreadableTerminal { readonly type: "unreadable.execution.ended"; readonly executionType: (typeof EXECUTION_ENDS)[number]; readonly sessionID: string; } -/** - * A session's execution started, but this build could not decode the event. - * It marks where a new execution begins and never ends a turn. - */ export interface OpenCode2UnreadableStart { readonly type: "unreadable.execution.started"; readonly sessionID: string; } -/** The events that end an execution; any other undecodable event is skipped. */ const EXECUTION_ENDS = [ "session.execution.succeeded", "session.execution.failed", @@ -46,7 +36,6 @@ const EXECUTION_ENDS = [ const isExecutionEnd = (type: string): type is (typeof EXECUTION_ENDS)[number] => (EXECUTION_ENDS as ReadonlyArray).includes(type); -/** The server sent nothing, not even a heartbeat, for `SILENT_STREAM_TIMEOUT`. */ export class OpenCode2SilentStreamError extends Schema.TaggedError()( "OpenCode2SilentStreamError", {}, @@ -62,11 +51,6 @@ export type OpenCode2StreamEvent = | OpenCode2UnreadableStart; /** - * An OpenCode 2 client plus a forward-compatible `/api/event` stream. The - * client's own `event.subscribe` fails the whole stream on the first event a - * newer server adds, so `events` decodes each frame on its own and skips the - * ones this build does not know, except an execution's end or start, which it - * keeps as an {@link OpenCode2UnreadableTerminal} or {@link OpenCode2UnreadableStart}. * `events` succeeds once the server accepted the subscription: the stream is * volatile, so callers subscribe before they start work whose events they need. */ @@ -81,7 +65,6 @@ export interface OpenCode2Api { >; } -/** Builds clients for OpenCode 2 servers, one per base URL and password. */ export class OpenCode2Client extends Context.Service< OpenCode2Client, { @@ -99,7 +82,6 @@ export class OpenCode2Client extends Context.Service< const SILENT_STREAM_TIMEOUT = "45 seconds"; const decodeEvent = Schema.decodeUnknownResult(Schema.fromJsonString(OpenCodeEvent)); -// Just enough of an event to route it, for frames the full schema rejects. const decodeEnvelope = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ @@ -138,7 +120,6 @@ const undecodable = (data: string) => return Result.failVoid; }); -/** Subscribes to `/api/event`, then streams every frame this build can route. */ const readEvents = HttpClient.get("/api/event", { headers: { accept: "text/event-stream" }, }).pipe( @@ -198,9 +179,9 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(OpenCode2Client, make); /** - * Streams every item of a cursor-paged OpenCode 2 list. The first request - * carries the caller's input (including `order`); later requests send only - * the cursor, because OpenCode rejects a cursor combined with `order`. + * The first request carries the caller's input (including `order`); later + * requests send only the cursor, because OpenCode rejects a cursor combined + * with `order`. */ export const paginate = ( input: Input, diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts index 40e6e5568893..f1a7e7ac4534 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.live.test.ts @@ -1,10 +1,3 @@ -/** - * Live check of the OpenCode 2 server lifecycle against a real binary: - * - * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Server.live.test.ts - * - * No model is called. The server runs with isolated HOME and XDG directories. - */ import * as NodeServices from "@effect/platform-node/NodeServices"; import { AbsolutePath, Location } from "@opencode/client/effect"; import { assert, it } from "@effect/vitest"; @@ -31,7 +24,6 @@ import * as OpenCode2Server from "./OpenCode2Server.ts"; const binaryPath = process.env.OPENCODE2_BIN; -// A negative pid probes the whole process group, which T3 stops as one unit. const isAlive = (pid: number) => { try { process.kill(pid, 0); @@ -41,19 +33,12 @@ const isAlive = (pid: number) => { } }; -/** - * Stops a server the test spawned itself: SIGTERM, then SIGKILL if it is still - * alive after five seconds. Keyed on the PID captured at spawn, never on a - * pattern. Servers T3 spawns are stopped by T3, through the instance scope. - */ const stopByPid = (pid: number) => Effect.gen(function* () { const signal = (name: NodeJS.Signals) => { try { process.kill(pid, name); - } catch { - // Already exited. - } + } catch {} }; signal("SIGTERM"); for (let attempt = 0; attempt < 50 && isAlive(pid); attempt++) { @@ -62,10 +47,6 @@ const stopByPid = (pid: number) => if (isAlive(pid)) signal("SIGKILL"); }); -/** - * Starts `opencode serve` the way a user would run it themselves. It is stopped - * by its PID when the calling scope closes, whether or not the test passed. - */ const startExternalServer = Effect.fn("OpenCode2ServerLive.startExternalServer")(function* (input: { readonly environment: NodeJS.ProcessEnv; readonly directory: string; @@ -113,13 +94,9 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => { XDG_DATA_HOME: path.join(root, "data"), XDG_STATE_HOME: path.join(root, "state"), XDG_CACHE_HOME: path.join(root, "cache"), - // Ignored: the T3 password is the only one the server sees. OPENCODE_SERVER_PASSWORD: "ambient-password", }; - // The instance owns its server: building the layer spawns nothing, and - // closing its scope stops whatever it spawned. The scope is released - // even when an assertion below fails; it is closed early on purpose. const instanceScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); @@ -151,7 +128,6 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => { assert.isFalse(spawned.external); assert.isTrue(isAlive(spawned.pid)); - // A second location is served by the same process. const secondLocation = yield* server.withConnection((connection) => Effect.gen(function* () { const info = yield* connection.client.server.info(); @@ -167,7 +143,6 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => { assert.strictEqual(secondLocation.session.location.directory, second); assert.strictEqual(spawned.session.location.directory, first); - // External URL: the configured password works, a wrong one is a 401. const external = yield* OpenCode2Server.make({ binaryPath: binaryPath!, serverUrl: spawned.url, @@ -187,8 +162,6 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => { const ambientRejected = yield* ambient.withConnection(() => Effect.void).pipe(Effect.flip); assert.include(ambientRejected.detail, "rejected the server password"); - // An external server whose password is not ASCII: OpenCode decodes Basic - // credentials as UTF-8, so the configured password must be sent that way. const utf8Password = "pΓ€sswΓΆrd€"; const externalServer = yield* startExternalServer({ environment: OpenCode2Server.serverEnvironment(environment, Redacted.make(utf8Password)), @@ -216,7 +189,6 @@ describe.runIf(binaryPath !== undefined)("OpenCode2Server live", () => { const asciiRejected = yield* asciiOnly.withConnection(() => Effect.void).pipe(Effect.flip); assert.include(asciiRejected.detail, "rejected the server password"); - // Closing the instance stops the server it spawned. yield* Scope.close(instanceScope, Exit.void); for (let attempt = 0; attempt < 50 && isAlive(-spawned.pid); attempt++) { yield* Effect.sleep("100 millis"); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts index 9fab7f63420d..f32f26907dc1 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts @@ -94,7 +94,6 @@ describe("OpenCode2Server.verifyServer", () => { }), ); - // 1.x (and the 2.x web UI on unknown paths) answers /api/info with 200 HTML. it.effect("rejects a server that answers with the web UI's HTML", () => Effect.gen(function* () { const error = yield* verify( @@ -113,8 +112,6 @@ describe("OpenCode2Server.verifyServer", () => { }); describe("OpenCode2Server error details", () => { - // `detail` reaches clients through the provider status message, and a - // `serverUrl` can carry credentials in its userinfo or query. const serverUrl = "http://user:url-secret@127.0.0.1:1/?token=query-secret"; const detailFor = (httpClient: Layer.Layer) => Effect.gen(function* () { @@ -190,9 +187,6 @@ describe("OpenCode2Server passwords", () => { }); }); -// Serves /api/info only with the password from OPENCODE_PASSWORD, like 2.x, and -// prints the 2.x banner (plus the generated-password line 2.x prints when no -// password is set, which T3 must never need). const FAKE_SERVER = `import { createServer } from "node:http"; const expected = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_PASSWORD).toString("base64"); const server = createServer((request, response) => { diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts index ea50c22b37a5..5991d5758629 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts @@ -1,9 +1,6 @@ /** - * The OpenCode 2 server behind one provider instance. A 2.x server serves every - * location from one process, so an instance shares one server across all of its - * threads and directories. - * - * @module provider/opencode2/OpenCode2Server + * A 2.x server serves every location from one process, so an instance shares + * one server across all of its threads and directories. */ import type { OpenCodeClient } from "@opencode/client/effect"; import * as Context from "effect/Context"; @@ -30,7 +27,6 @@ export interface OpenCode2Connection extends OpenCode2Client.OpenCode2Api { export class OpenCode2Server extends Context.Service< OpenCode2Server, { - /** Runs `use` against the instance's server, spawning it first when T3 owns it. */ readonly withConnection: ( use: (connection: OpenCode2Connection) => Effect.Effect, ) => Effect.Effect; @@ -38,9 +34,8 @@ export class OpenCode2Server extends Context.Service< >()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {} /** - * A fresh password for a spawned server. OpenCode 2 always requires one and - * prints a generated one to stdout otherwise, so T3 supplies its own and keeps - * it in memory. + * OpenCode 2 always requires one and prints a generated one to stdout + * otherwise, so T3 supplies its own and keeps it in memory. */ export const generatePassword = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -49,9 +44,9 @@ export const generatePassword = Effect.gen(function* () { }); /** - * The environment for a spawned 2.x server. `OPENCODE_PASSWORD` wins over - * `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so the inherited 1.x variable is - * dropped to keep the T3 password the only one in play. + * `OPENCODE_PASSWORD` wins over `OPENCODE_SERVER_PASSWORD` in OpenCode 2, so + * the inherited 1.x variable is dropped to keep the T3 password the only one in + * play. */ export const serverEnvironment = ( environment: NodeJS.ProcessEnv, @@ -75,10 +70,10 @@ const httpFailureOf = (cause: unknown): HttpClientError.HttpClientError | undefi }; /** - * Describes a failed `/api/info` call. Only a transport failure means the - * server is unreachable; a 401 without the 2.x error body is still a rejected - * password (1.x sends it empty), and any other failed status is a server error. - * A 2xx the client cannot decode is not OpenCode 2 (1.x answers with HTML). + * Only a transport failure means the server is unreachable; a 401 without the + * 2.x error body is still a rejected password (1.x sends it empty), and any + * other failed status is a server error. A 2xx the client cannot decode is not + * OpenCode 2 (1.x answers with HTML). */ const describeInfoFailure = (cause: unknown) => { const failure = httpFailureOf(cause); @@ -94,11 +89,10 @@ const describeInfoFailure = (cause: unknown) => { }; /** - * Confirms a server is an authenticated OpenCode 2 server through `/api/info` - * and returns its version. `/health` and friends answer 200 with the web UI's - * HTML on every version, so only this endpoint proves readiness. Details are - * fixed text because they reach clients and a `serverUrl` can carry - * credentials; the underlying failure stays in `cause`. + * `/health` and friends answer 200 with the web UI's HTML on every version, so + * only this endpoint proves readiness. Details are fixed text because they + * reach clients and a `serverUrl` can carry credentials; the underlying failure + * stays in `cause`. */ export const verifyServer = (client: OpenCodeClient) => client.server.info().pipe( @@ -134,13 +128,6 @@ export const verifyServer = (client: OpenCodeClient) => Effect.map((info) => info.version), ); -/** - * One server per provider instance. With a `serverUrl` it connects to that - * server with the configured password; otherwise it spawns `binaryPath serve` - * with a generated password through {@link OpenCodeServerOwner}, which shares - * the process between borrowers and stops it after an idle period. Clients are - * built once per server; a failed check is not remembered. - */ export const make = Effect.fn("OpenCode2Server.make")(function* (input: { readonly binaryPath: string; readonly serverUrl: string; @@ -185,7 +172,6 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: { return OpenCode2Server.of({ withConnection: (use) => owner.withServer((server) => - // The owner verifies every server it starts before lending it out. latest?.url === server.url ? use(latest) : Effect.die(new Error("OpenCode 2 server was lent before verification.")), @@ -193,6 +179,5 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: { }); }); -/** Built once per provider instance from its settings; closing it stops a spawned server. */ export const layer = (input: Parameters[0]) => Layer.effect(OpenCode2Server, make(input)); diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 785bf1a06e70..a1ed0275f8a0 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -1,27 +1,3 @@ -/** - * The OpenCode 2 runtime behind the `opencode` driver. It talks to the - * instance's `opencode serve` process through the HTTP client and reads that - * server's `/api/event` stream, routed here by session id. - * - * A turn is one `session.prompt` (or `session.command`, or `session.compact` - * for `/compact`); the session's next `session.execution.*` terminal ends it. - * A steer is another prompt with `delivery: "steer"`, which OpenCode reads at - * the running execution's next step boundary, so it ends with the turn it - * joined. Fork and rollback cut the native history before the user message a - * turn prompted with (its `nativeTurnRef`). Each runtime mode is a set of - * session permission rules, plan mode is also OpenCode's `plan` agent - * (switched before the prompt like the model), and OpenCode's permission asks - * and question forms become runtime requests on the asking session's thread, a - * subagent's on its parent's. - * - * A `subagent` call runs in a child session shown as a subagent thread. A - * background one outlives its turn; when it ends, OpenCode starts a parent - * execution T3 did not ask for, which waits here for the continuation turn T3 - * opens for it. - * - * @module orchestration-v2/Adapters/OpenCode2AdapterV2 - */ - import { AbsolutePath, Agent, @@ -103,11 +79,9 @@ import { openCodeToolTurnItem } from "../toolItems.ts"; const OpenCode2ProviderCapabilities = { sessions: { - // One server serves every location, so one session runtime owns them all. supportsMultipleProviderThreadsPerSession: true, supportsModelSwitchInSession: true, supportsProviderSwitchingViaHandoff: true, - // A mode change rewrites the session's rules when its next turn resumes it. supportsRuntimeModeSwitchInSession: true, pendingRequestsSurviveRestart: false, }, @@ -127,8 +101,6 @@ const OpenCode2ProviderCapabilities = { supportsInterrupt: true, supportsActiveSteering: true, supportsSteeringByInterruptRestart: true, - // T3 holds queued messages and starts each as its own turn once the one - // before it ends, so OpenCode's own `queue` delivery is never used. supportsQueuedMessages: true, terminalStatusQuality: "strong", }, @@ -194,14 +166,12 @@ const OpenCode2ProviderCapabilities = { nativeItemIds: "strong", nativeRequestIds: "strong", }, - // OpenCode enforces each runtime mode through the session's permission rules. runtimePolicy: { enforcement: "native" }, } satisfies OrchestrationV2ProviderCapabilities; type EventOf = Extract; type Tokens = EventOf<"session.step.ended">["data"]["tokens"]; -/** What a turn reports against: a run's own turn, or a subagent session's runless one. */ interface TurnOwner { readonly appThread: OrchestrationV2AppThread; readonly threadId: OrchestrationV2AppThread["id"]; @@ -209,20 +179,17 @@ interface TurnOwner { readonly runOrdinal: number; readonly rootNodeId: ProviderAdapter.ProviderAdapterV2TurnInput["rootNodeId"]; readonly modelSelection: ModelSelection; - /** Where the turn runs: a subagent runs in its parent turn's directory. */ readonly runtimePolicy: Pick; } interface ActiveTurn { readonly input: TurnOwner; providerTurn: OrchestrationV2ProviderTurn; - /** Open text and reasoning blocks, keyed `::`. */ readonly texts: Map; readonly tools: Map }>; readonly startedAt: Map; readonly ordinals: Map; nextOrdinal: number; - /** Input includes cache reads and writes, as 1.x reports it; the parts are also kept apart. */ readonly usage: { input: number; cached: number; @@ -232,56 +199,26 @@ interface ActiveTurn { }; steps: number; lastStep: Tokens | undefined; - /** - * The history item this turn's execution follows, for a turn with no prompt - * id of T3's: the session's newest item before a `/name` command (null when - * the history was empty), since `session.command` takes no id and answers - * without one; the report a continuation turn's execution answers; or, on a - * subagent's session, the first item queued for its turn. A reconnect - * backfills that turn from everything after this. Other turns backfill from - * their own prompt's id (`nativeTurnRef`). - */ before: string | null | undefined; - /** The compaction running in this turn, `/compact` or OpenCode's own when the context fills. */ compaction: { readonly nativeId: string; readonly startedAt: DateTime.Utc } | undefined; compactions: number; interrupted: boolean; - /** - * Set until the turn's prompt, command or compaction is sent. A Stop before - * then has nothing on the server to stop, so it ends the turn here and the - * request is never sent. - */ unsent: boolean; - /** - * Steers sent into this turn that OpenCode has not delivered yet, by inbox - * id. Each one wakes the session, so an execution that ends before reading - * one is followed by another that does, and the turn spans both. - */ readonly steers: Set; - /** This turn's steers OpenCode delivered or cancelled: a retried one is not sent again. */ readonly settledInbox: Set; - /** How the last execution ended, while the turn waits for the one that delivers its steers. */ heldEnd: TurnTerminal | undefined; - /** - * Set on a turn started after a timed-out Stop's run left the server: that - * run's tail can still be on the stream, and everything before this turn's - * own `session.execution.started` belongs to it. - */ awaitingStart: boolean; /** * Prefixes a subagent session's tool ids in native item ids: the model * names tool calls, so a child's could repeat its parent's. */ readonly scope: string; - /** Set once the turn calls `subagent`: its usage then leaves out the subagents'. */ usedSubagents: boolean; } -/** A `subagent` tool call. A background one outlives the turn that made it. */ interface SubagentCall { readonly toolId: string; readonly nativeId: string; - /** The calling session and the turn whose item the call is. */ readonly state: ThreadState; readonly turn: ActiveTurn; readonly ordinal: number; @@ -290,9 +227,7 @@ interface SubagentCall { title: string | null; agent: string | undefined; model: string | null; - /** The tool returned while the subagent runs on; the report OpenCode gives its parent settles it. */ background: boolean; - /** The running call whose subagent run this call joined, so that run's one report settles both. */ joined: SubagentCall | undefined; child: ThreadState | undefined; status: OrchestrationV2Subagent["status"]; @@ -300,30 +235,15 @@ interface SubagentCall { completedAt: DateTime.Utc | null; } -/** - * An execution OpenCode ran on a thread's session without T3 asking: the - * parent's answer once a background subagent ended. Its events are held until - * the continuation turn T3 opens for it takes them. - */ interface Wake { - /** The message its continuation run starts from, which names the wake in that turn's input. */ readonly messageId: MessageId | undefined; readonly events: Array; running: boolean; - /** The background subagents whose end it answers, as its turn's notification names them. */ readonly reports: Array; - /** What OpenCode told the model, the continuation's prompt text. */ readonly detail: string | null; - /** Stopped, or taken by a user turn: the continuation it asked for is not needed. */ dropped: boolean; - /** The last report it delivered (inbox ids are history ids): its execution follows it. */ readonly after: string | undefined; - /** The first report it delivered, where fork and rollback cut before its turn. */ readonly first: string | undefined; - /** - * Subagent sessions OpenCode announced while it was held: their calls are - * in its events, so only its replay names them. - */ readonly children: Set; } @@ -339,7 +259,6 @@ interface ThreadState { providerThread: OrchestrationV2ProviderThread; readonly providerTurns: Map; active: ActiveTurn | undefined; - /** What the native session runs now, so a changed selection is switched before prompting. */ model: ModelRef | undefined; /** * Set when a turn ended here while OpenCode may still be running it: a Stop @@ -349,52 +268,27 @@ interface ThreadState { */ unsettled: boolean; /** - * Whether the run `unsettled` waits on had started when it was marked. A - * prompt whose request failed may start its run late; that start is the + * A prompt whose request failed may start its run late; that start is the * run's own, and only a start after it means its end was lost. */ unsettledStarted: boolean; - /** The session's location, where its agents' path rules are read. */ directory: string; - /** - * The agent the session runs (`build`, or `plan` in plan mode); its own path - * rules stay in force, and a changed mode switches it before prompting. - */ agent: string; - /** The native session's title as T3 last read or wrote it. */ title: string | undefined; - /** The native session's rules as T3 last read or wrote them, and the policy they are for. */ rules: ReadonlyArray | undefined; policy: RulesPolicy; - /** "Allow … this session" answers, kept in the session's rules while T3 has it open. */ readonly grants: Array; - /** The session's `subagent` calls still running, by tool call id. */ readonly calls: Map; - /** - * Set on a subagent's session: the call that runs it and the thread it - * shows in. Each of its executions is a runless turn there. - */ subagent: | { call: SubagentCall; readonly appThread: OrchestrationV2AppThread; turns: number; - /** The prompt its next execution answers, shown as that turn's user message. */ prompt: string | undefined; - /** - * The first item queued for the session since its last turn began (its - * prompt, or a report it answers): where the next turn's history - * begins, which a reconnect reads back. - */ queued: string | undefined; } | undefined; - /** Executions OpenCode started on its own, oldest first, each waiting for its turn. */ readonly wakes: Array; - /** - * Background subagents whose end OpenCode queued for this session and has - * not delivered yet, by inbox id. The wake that delivers them names them. - */ readonly reports: Map< string, { @@ -404,17 +298,8 @@ interface ThreadState { readonly text: string; } >; - /** - * Background subagent sessions T3 stopped. OpenCode wakes the parent to - * report them; a wake that reports only these is stopped as well. - */ readonly stoppedChildren: Set; - /** - * Steers a turn ended without (a Stop, a failure). They stay in OpenCode's - * inbox, where the next prompt would deliver them first, so it cancels them. - */ readonly strandedSteers: Set; - /** T3's MCP server as registered for this thread, and the instructions entry sent with it. */ mcp: | { readonly name: string; readonly directory: string; readonly credential: string } | undefined; @@ -432,17 +317,13 @@ type RulesPolicy = Pick< >; type NativeForm = EventOf<"form.created">["data"]["form"]; -/** A permission ask or question form shown to the user and not answered yet. */ interface PendingRequest { readonly request: OrchestrationV2RuntimeRequest; readonly item: OrchestrationV2TurnItem; readonly node: OrchestrationV2ExecutionNode; - /** The thread the request is shown on: the asking session's, or its parent's. */ readonly state: ThreadState; readonly turn: ActiveTurn; - /** The session that asked: the thread's own, or one of its subagents'. */ readonly sessionId: string; - /** Set once T3 sends its answer; the orchestrator has already recorded it. */ answering: boolean; readonly native: | { @@ -458,16 +339,6 @@ interface PendingRequest { const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resource: "*", effect }); /** - * A session's permission rules. OpenCode checks the agent's rules and then - * these, and the last rule that matches decides, so these override the - * agent's. `paths` are the agent's own allows for its directories (saved tool - * output, the plan agent's plan directory), which the blanket rules here would - * otherwise override; `grants` are "Always allow this session" answers. - */ -/** - * T3's MCP server is registered per directory, not per session, so each thread - * gets its own `t3-code-` entry with its own credential. OpenCode names - * an MCP tool's permission `_` (non-alphanumerics become `_`). * OpenCode skips every tool of a server whose name is over 64 characters (its * tool namespace limit), and its router rejects adding one over 100, so a * thread id that does not fit is replaced by a digest of it. @@ -485,8 +356,7 @@ export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: /** * The rules that keep T3's MCP servers to their own thread, after the mode's: * the last matching rule wins, so every thread's T3 server is denied and then - * this thread's own is allowed again, in every mode. A subagent's session - * inherits the thread's. + * this thread's own is allowed again, in every mode. */ const mcpRules = (mcpServerName: string | null): ReadonlyArray => mcpServerName === null @@ -527,8 +397,8 @@ const sameRules = (left: ReadonlyArray | undefined, right: ReadonlyArray) => `The user declined the ${action} request${resources.length === 0 ? "" : ` (${resources.join(", ")})`}. Do not retry it; continue without it or ask the user how to proceed.`; -/** The session-wide choice for a request whose `save` patterns OpenCode would remember. */ const sessionGrantLabel = (action: string, save: ReadonlyArray) => save.every((pattern) => pattern === "*") ? `Allow every ${action} request this session` @@ -547,11 +416,6 @@ const sessionGrantLabel = (action: string, save: ReadonlyArray) => const text = (value: string | undefined, fallback: string) => value?.trim() || fallback; -/** - * A form's fields as T3 questions, or why T3 cannot ask them: a link to open, - * a field shown only for another answer, a hidden field, or a number or yes/no - * value. OpenCode's question tool only asks text and multi-select fields. - */ const formQuestions = ( form: NativeForm, ): @@ -582,7 +446,6 @@ const formQuestions = ( return { questions }; }; -/** T3's answers in OpenCode's shape: a list for a multi-select, text otherwise. */ const formAnswer = (form: NativeForm, answers: Readonly>) => { const answer: Record> = {}; for (const field of form.fields) { @@ -600,10 +463,6 @@ const formAnswer = (form: NativeForm, answers: Readonly> const approves = (decision: ProviderApprovalDecision) => decision === "accept" || decision === "acceptForSession" || decision === "acceptAlways"; -/** - * An answer to a request OpenCode already dropped (its execution ended, or - * its session is gone): nothing waits on it, so it counts as delivered. - */ const permissionGone = { PermissionNotFoundError: () => Effect.void, SessionNotFoundError: () => Effect.void, @@ -614,7 +473,6 @@ const formGone = { SessionNotFoundError: () => Effect.void, }; -/** The session rules an agent keeps for its own directories, which T3's blanket rules would override. */ const agentPaths = (rules: ReadonlyArray) => rules.filter( (entry) => @@ -649,7 +507,6 @@ const stringField = (record: Readonly> | undefined, key: return typeof value === "string" && value.trim().length > 0 ? value : undefined; }; -/** How a background subagent ended, from the `state` of OpenCode's report to its parent. */ const reportOutcome = (state: string | undefined) => state === "completed" ? ("completed" as const) @@ -659,31 +516,24 @@ const reportOutcome = (state: string | undefined) => ? ("cancelled" as const) : ("unknown" as const); -/** A subagent's answer, without the `` wrapper OpenCode gives the model. */ const subagentOutput = (text: string) => /^]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text; const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; -/** The native turn id of a continuation turn, which answers a wake and has no prompt. */ const wakeTurnId = (sessionId: string, attemptId: string) => `${sessionId}:wake:${attemptId}`; const isWakeTurn = (turn: OrchestrationV2ProviderTurn) => turn.nativeTurnRef?.nativeId?.includes(":wake:") === true; const INTERRUPT_TIMEOUT = "10 seconds"; -/** The session instructions entry T3 writes its per-turn system prompt to. */ const INSTRUCTIONS_KEY = "t3-code"; -/** A lost event stream is resubscribed this many times, this far apart, before the session breaks. */ const RECONNECT_ATTEMPTS = 5; const RECONNECT_DELAY = "2 seconds"; const RECONCILE_TIMEOUT = "15 seconds"; -/** How long a new turn waits for a reconnect in progress. */ const RECONNECT_WAIT = "30 seconds"; -/** A background subagent's result when its end was lost with the event stream. */ const LOST_BACKGROUND = "T3 Code lost its connection to OpenCode while this subagent ran, so its result is not shown."; -/** How long a turn waits on the directory's commands or skills before sending the text as is. */ const INVENTORY_TIMEOUT = "5 seconds"; const ACTIVE_CHECK_TIMEOUT = "5 seconds"; /** Answers that mean the server refused a prompt; any other failure may have been accepted. */ @@ -699,7 +549,6 @@ export const OPENCODE_2_STILL_STOPPING = "OpenCode is still stopping the previous turn. Send the message again in a moment."; const REQUEST_REPLY_TIMEOUT = "10 seconds"; -/** Whether an answer to a paused request reached the server, trying twice. */ const deliver = (answer: Effect.Effect) => answer.pipe( Effect.retry({ times: 1 }), @@ -711,39 +560,23 @@ const deliver = (answer: Effect.Effect) => type ModelRef = ReturnType; /** - * The user message ids T3 prompts under. OpenCode takes a client id (it must - * start with `msg_`) and answers a repeat of one in the same session with the - * item it already has, so a retried request never queues a second message, - * and a turn knows the message fork and rollback cut at before OpenCode - * answers. The id is unique across the whole server, which refuses it in any - * other session (409), so it names the session: another T3 database or - * environment on the same server repeats thread ids and run ordinals, never - * session ids. A turn keeps its id in `nativeTurnRef`. + * OpenCode takes a client id (it must start with `msg_`) and answers a repeat of one in the same + * session with the item it already has, so a retried request never queues a second message, and a + * turn knows the message fork and rollback cut at before OpenCode answers. The id is unique across + * the whole server, which refuses it in any other session (409), so it names the session: another + * T3 database or environment on the same server repeats thread ids and run ordinals, never session + * ids. */ const turnPromptId = (sessionId: string, attemptId: string) => SessionMessage.ID.make(`msg_t3_turn_${sessionId}:${attemptId}`); const steerPromptId = (sessionId: string, messageId: string) => SessionMessage.ID.make(`msg_t3_steer_${sessionId}:${messageId}`); -/** - * The user message a turn prompted with. Turns from before T3 chose prompt ids - * recorded `:attempt:`, which is no message. - */ const promptOf = (turn: OrchestrationV2ProviderTurn) => { const nativeId = turn.nativeTurnRef?.nativeId; return nativeId?.startsWith("msg_") === true ? nativeId : undefined; }; -/** - * Where fork and rollback cut to keep the session's turns up to `kept` (all of - * them before the first turn when undefined): before the message that starts - * the next turn that reached OpenCode, which is in `prompts`. That is a turn's - * prompt, or for a continuation the report it answers. A turn refused before - * it prompted is not in the session and is passed over, and so is a - * continuation that took no report. `null` means nothing follows, so there is - * no cut; a later turn from before T3 chose prompt ids has no known message, - * so no cut is safe. - */ const boundaryAfter = ( turns: ReadonlyArray, providerThreadId: OrchestrationV2ProviderThread["id"], @@ -772,14 +605,8 @@ const boundaryAfter = ( ); }; -// Errors already in the adapter channel keep their tag; only lower-level ones are wrapped. const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error); -/** - * The model OpenCode should run for a `provider/model` slug and its reasoning - * variant, or undefined for any other slug: sending none would run OpenCode's - * default while T3 records the requested model. - */ const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => { const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(selection.model); if (parsed === null) return undefined; @@ -797,20 +624,16 @@ const sameModel = (left: ModelRef, right: ModelRef | undefined) => left.id === right?.id && (left.variant ?? "default") === (right?.variant ?? "default"); -/** OpenCode's own agents for T3's interaction modes; plan mode is its read-only `plan` agent. */ const agentFor = (input: ProviderAdapter.ProviderAdapterV2TurnInput) => input.runtimePolicy.interactionMode === "plan" ? "plan" : "build"; -/** `/name args` naming one of the workspace's commands, which OpenCode expands itself. */ const commandOf = (text: string) => { const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/.exec(text.trim()); return match === null ? undefined : { name: match[1]!, text: match[2] ?? "" }; }; -/** Whether a prompt names any skill at all, before the directory's skills are read. */ const SKILL_MENTION = new RegExp(SKILL_MENTION_PATTERN.source, "u"); -/** The workspace skills a prompt names with the composer's `$skill` tokens. */ const skillsNamed = (text: string, known: ReadonlySet) => [ ...new Set( [...text.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => @@ -819,10 +642,6 @@ const skillsNamed = (text: string, known: ReadonlySet) => [ ), ]; -/** - * The turn's own tokens: steps add up, and the last step's input is the live - * context size. A subagent's tokens are its own session's, never these. - */ const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["status"]) => turn.steps === 0 ? { @@ -841,10 +660,6 @@ const turnTokenUsage = (turn: ActiveTurn, status: OrchestrationV2ProviderTurn["s hasSubagents: turn.usedSubagents, }; -/** - * The adapter for one provider instance. It talks to the instance's - * {@link OpenCode2Server.OpenCode2Server}, which the driver builds from the instance's settings. - */ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: ProviderInstanceId) { const server = yield* OpenCode2Server.OpenCode2Server; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -856,10 +671,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const mcpServerNameFor = (threadId: string) => t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto)); - /** - * Lends the instance's server to a session until its scope closes. A spawned - * server that died is started again on the next borrow. - */ const borrow = Effect.gen(function* () { const lent = yield* Deferred.make< OpenCode2Server.OpenCode2Connection, @@ -884,14 +695,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, ) { let connection = initial.connection; - // Replaced when the session reconnects to a restarted server. let client = connection.client; const sessionScope = yield* Effect.scope; // Context windows by directory, then `provider/model`: a project's own // OpenCode config can change a model's limits, and this one runtime serves // the instance's threads in every directory. const contextWindows = new Map>(); - /** A thread without a worktree runs where T3 does, as its session is created. */ const directoryOf = (cwd: string | null | undefined) => cwd ?? host.paths.cwd; const windowOf = (cwd: string | null | undefined, model: string) => contextWindows.get(directoryOf(cwd))?.get(model); @@ -909,28 +718,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr lastError: null, }; const events = yield* Queue.unbounded(); - // Thread sessions, and the sessions of their subagents. const threads = new Map(); - // A subagent's session, by its id, to the thread whose session started it. const childOwners = new Map(); - // What OpenCode announced about a subagent's session, until its call names it. const announced = new Map["data"]>(); - // Subagent sessions a dropped held execution started, and the subagents - // they start, by the session the first of them was called from. Nothing - // names them, so they no longer route to its thread; they stay here until - // a Stop of that thread stops them or they are gone. const strays = new Map(); - // Sessions of these threads with an execution running, seen on the stream. const busy = new Set(); const pending = new Map(); - // Events and a wake's replay into its turn are handled one at a time, in order. const lock = yield* Semaphore.make(1); - // Sessions whose `revert.clear` has not had its empty execution yet. const clearing = new Map>(); - // Sessions a failed rollback may have left with a staged revert. const stagedReverts = new Set(); - // Starting a turn and cutting the history take turns on a session: each - // checks that the other is not running before its own requests yield. const sessionGates = yield* KeyedLock.make(); const exclusive = (providerThread: OrchestrationV2ProviderThread) => @@ -942,7 +738,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); const ownerOf = (sessionId: string) => childOwners.get(sessionId) ?? threads.get(sessionId); - /** Marks a run T3 ended while OpenCode may still run it (see `unsettled`). */ const markUnsettled = (state: ThreadState) => { state.unsettled = true; state.unsettledStarted = busy.has(state.sessionId); @@ -977,14 +772,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr instructions: undefined, }); - /** The thread whose session started this one, through any nesting. */ const rootOf = (state: ThreadState): ThreadState => { let current = state; while (current.subagent !== undefined) current = current.subagent.call.state; return current; }; - /** A thread's session and its subagents' sessions, through any nesting. */ const sessionsOf = (thread: ThreadState): ReadonlyArray => [ thread, ...[...threads.values()].filter((state) => { @@ -995,14 +788,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }), ]; - /** - * A thread's `subagent` calls still running, its subagents' included: a - * subagent's background call runs on after the call that made it ended. - */ const runningCalls = (thread: ThreadState): ReadonlyArray => sessionsOf(thread).flatMap((state) => [...state.calls.values()]); - /** The `subagent` calls that lead to a session, from its own up to the thread's. */ const callsAbove = (sessionId: string) => { const calls: Array = []; let current = threads.get(sessionId); @@ -1013,11 +801,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return calls; }; - /** - * The turn a request from `sessionId` is shown under. A background - * subagent's goes to the turn that started it, whose run waits on the - * subagent; anything else goes to the thread's running turn. - */ const requestTurn = (sessionId: string) => { const state = ownerOf(sessionId); if (state === undefined) return undefined; @@ -1030,19 +813,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return state.active === undefined ? undefined : { state, turn: state.active }; }; - /** - * What a session still owes its thread once the thread's turn ended: - * background subagents, held executions, reports OpenCode queued for the - * follow-up it will start, and anything a subagent's session still runs, - * such as its own follow-up to such a report. - */ const owesWork = (state: ThreadState) => state.wakes.length > 0 || state.reports.size > 0 || (state.subagent !== undefined && busy.has(state.sessionId)) || [...state.calls.values()].some((call) => call.background); - /** Work that outlives the thread's turn, on its own session or its subagents'. */ const hasBackground = (thread: ThreadState) => sessionsOf(thread).some(owesWork); const setSessionStatus = ( @@ -1118,7 +894,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }, }); - /** One text or reasoning block, re-emitted with its accumulated text on every change. */ const emitText = Effect.fnUntraced(function* ( state: ThreadState, turn: ActiveTurn, @@ -1217,7 +992,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); }); - /** The turn's compaction item; its summary is the text OpenCode carries forward. */ const emitCompaction = Effect.fnUntraced(function* ( state: ThreadState, turn: ActiveTurn, @@ -1300,7 +1074,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr usedSubagents: false, }); - /** A subagent call as its parent turn's item, node and subagent row. */ const emitSubagent = Effect.fnUntraced(function* (call: SubagentCall) { const updatedAt = yield* DateTime.now; const { turn, state } = call; @@ -1390,11 +1163,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* syncRoster(rootOf(call.state)); }); - /** - * Lists a thread's background subagents on its provider thread, as typed - * `subagent` work: the thread shows it waits on them after its turn ends, - * and a Stop reaches them. Emitted only when the list changes. - */ const syncRoster = Effect.fnUntraced(function* (state: ThreadState) { const roster = runningCalls(state) .filter((call) => call.background) @@ -1452,11 +1220,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; }; - /** - * Gives a subagent call its session once both are known: OpenCode names - * the session on the call's progress, and announces it just before. The - * session becomes a child thread under the call's item. - */ const attachChild = Effect.fnUntraced(function* (call: SubagentCall, childId: string) { if (call.child !== undefined) return; const info = announced.get(childId); @@ -1516,9 +1279,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr createdAt: now, updatedAt: now, }; - // A subagent called again keeps its session's state: how many turns it - // ran (each one's native id is its own), its grants and rules, and the - // background work an earlier call left running, which Stop must reach. const previous = threads.get(childId); // Called again while it runs, the subagent takes the prompt as a steer // and OpenCode reports that run once, for every call that joined it. @@ -1542,7 +1302,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr child.providerThread = withReportedModel(providerThread, child.model); child.directory = call.state.directory; child.agent = info?.agent ?? call.agent ?? child.agent; - // OpenCode gives a new session its parent's rules, which are the thread's. child.rules = info?.permissions ?? child.rules; threads.set(childId, child); childOwners.set(childId, rootOf(call.state)); @@ -1565,11 +1324,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }); - /** - * Ends a subagent call and every call under it that is still running. A - * background one outlives the call that made it, as it outlives a turn, - * unless that call failed. - */ const settleCall: ( call: SubagentCall, status: OrchestrationV2Subagent["status"], @@ -1582,7 +1336,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr call.state.calls.delete(call.toolId); const child = call.child; if (child !== undefined) { - // A snapshot: settling a call removes it from the map. for (const nested of Array.from(child.calls.values())) { if (nested.background && status !== "failed") continue; yield* settleCall(nested, status === "completed" ? "interrupted" : status); @@ -1604,10 +1357,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* emitSubagent(call); }); - /** - * Opens the runless turn a subagent's session runs each execution in, on - * its child thread, with the prompt it answers as the turn's user message. - */ const startChildTurn = Effect.fnUntraced(function* (child: ThreadState) { const subagent = child.subagent; if (subagent === undefined || child.active !== undefined) return; @@ -1693,11 +1442,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }); - /** - * Asks the orchestrator for the continuation turn a held wake needs. It - * names the subagents whose end woke the thread. A wake a Stop or an - * earlier turn already took needs no turn, so its offer is dropped. - */ const offerWake = Effect.fnUntraced(function* (state: ThreadState, wake: Wake) { const route = state.providerThread.appThreadId; if (route === null || wake.messageId === undefined) return; @@ -1723,9 +1467,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (turn === undefined) return; state.active = undefined; for (const inboxID of turn.steers) state.strandedSteers.add(inboxID); - // OpenCode drops a request when the asking session's execution ends. A - // subagent's request shown on this turn ends with it too, unless the - // subagent runs in the background past a turn that did not fail. for (const entry of pending.values()) { const background = callsAbove(entry.sessionId).some((call) => call.background); if ( @@ -1736,7 +1477,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } } const completedAt = yield* DateTime.now; - // Blocks still open when the execution ends are final as they stand. for (const open of turn.texts.values()) { yield* emitText(state, turn, open.block, open.kind, (text) => text, true); } @@ -1748,10 +1488,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr terminal.status === "completed" ? "completed" : "interrupted", ); } - // A foreground subagent ends with the turn that waits on it. A background - // one outlives a finished or interrupted turn (a user Stop has already - // stopped it), and a failed turn stops it. - // A snapshot: settling a call removes it from the map. for (const call of Array.from(state.calls.values())) { if (call.turn !== turn) continue; if (call.background && terminal.status !== "failed") continue; @@ -1829,9 +1565,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const base = { type: "turn.terminal" as const, driver, - // The provider thread the turn started on. After a native fork the - // session's own thread has a fresh id, and the terminal must name the - // thread the start was recorded against. + // After a native fork the session's own thread has a fresh id, and the + // terminal must name the thread the start was recorded against. providerThreadId: turn.providerTurn.providerThreadId, providerTurnId: turn.providerTurn.id, runOrdinal: turn.input.runOrdinal, @@ -1877,7 +1612,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr .pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); }); - /** Stops tracking a request; the session is waiting only while any remain. */ const forgetRequest = Effect.fnUntraced(function* (entry: PendingRequest) { if (!pending.delete(entry.request.id)) return false; if (pending.size === 0 && session.status === "waiting") { @@ -1887,8 +1621,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); /** - * Settles a request OpenCode answered or dropped on its own. T3's own - * answers are only forgotten: the orchestrator already recorded them. + * T3's own answers are only forgotten: the orchestrator already recorded them. */ const settleRequest = Effect.fnUntraced(function* ( entry: PendingRequest, @@ -1915,11 +1648,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); }); - /** - * Stops a run no turn of T3's waits on (one a Stop left running, or one - * found asking after a reconnect): a reject without a message and a - * cancelled form both end OpenCode's execution. Its end is not a turn's. - */ const stopStaleRequest = ( sessionId: string, native: { readonly type: "permission" | "form"; readonly id: string }, @@ -1960,7 +1688,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const [permissions, forms] = listed.value; if (permissions.length === 0 && forms.length === 0) return; - // The stopped run's end is not the next turn's. markUnsettled(state); for (const request of permissions) { yield* stopStaleRequest(state.sessionId, { type: "permission", id: request.id }); @@ -1970,11 +1697,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }); - /** - * Shows a permission ask or question form on the thread whose session (or - * subagent session) asked, under that thread's running turn. A request no - * turn of T3's is waiting on is left for OpenCode's own clients. - */ const showRequest = Effect.fnUntraced(function* ( sessionId: string, native: PendingRequest["native"], @@ -2090,7 +1812,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); const onPermissionAsked = Effect.fnUntraced(function* (data: Permission.Request) { - // The asking session's own turn knows the tool: a subagent's session runs its own. const turn = threads.get(data.sessionID)?.active; const toolName = data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name; @@ -2102,8 +1823,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr type: "approval_request", requestKind: openCodePermissionRequestKind(data.action, toolName), prompt: data.resources.length === 0 ? data.action : data.resources.join("\n"), - // "Always" in OpenCode saves a grant for the whole project, so the - // session-wide choice is T3's own rule on this session instead. options: [ { decision: "cancel", label: "Cancel" }, { decision: "decline", label: "Decline" }, @@ -2156,18 +1875,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); }); - /** - * A permission ask or question form, from the stream or read back after a - * reconnect: shown under the turn it blocks, or stopped when it is the run - * a Stop left behind, which nothing answers. - */ const onAsked = Effect.fnUntraced(function* ( asked: | { readonly type: "permission"; readonly request: Permission.Request } | { readonly type: "form"; readonly form: NativeForm }, ) { const asking = asked.type === "permission" ? asked.request.sessionID : asked.form.sessionID; - // A stray belongs to no turn: its request is refused, which ends its run. if (strays.has(asking)) { return yield* stopStaleRequest( asking, @@ -2205,7 +1918,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr type === "session.execution.failed" || type === "session.execution.interrupted"; - /** A `subagent` call's item, made when the model starts it. */ const startCall = Effect.fnUntraced(function* ( state: ThreadState, turn: ActiveTurn, @@ -2235,10 +1947,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* emitSubagent(call); }); - /** - * One event for a session's running turn: its text, tools, steps and end. - * A `subagent` call is its item and child thread rather than a tool. - */ const onTurnEvent = Effect.fnUntraced(function* ( state: ThreadState, turn: ActiveTurn, @@ -2258,7 +1966,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); } case "session.tool.input.started": - // Its form is the item the user answers; the tool call would repeat it. if (event.data.name === "question") return; if (event.data.name === "subagent") return yield* startCall(state, turn, event.data.id); turn.tools.set(event.data.id, { name: event.data.name, input: {} }); @@ -2310,7 +2017,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr case "session.tool.failed": { const aborted = event.data.error.type === "aborted"; const call = state.calls.get(event.data.id); - // A refused call (unknown agent, the nesting limit) is a failed subagent. if (call !== undefined) { return yield* settleCall( call, @@ -2325,8 +2031,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr turn.tools.delete(event.data.id); return; } - // `/compact` and OpenCode's own compaction when the context fills - // (`reason: "auto"`) both run inside a turn's execution. case "session.compaction.started": turn.compaction = { nativeId: `${turn.providerTurn.id}:compaction:${turn.compactions++}`, @@ -2351,7 +2055,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return; } case "session.execution.started": - // The execution that delivers steers the last one ended without. turn.heldEnd = undefined; return; case "session.execution.succeeded": @@ -2374,17 +2077,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }); - /** - * An execution OpenCode started on a thread's session with no turn of T3's - * running: the parent's answer to a background subagent's report. It is - * held for the continuation turn it asks for, or stopped when it only - * reports subagents a Stop ended. - */ - /** - * Takes the reports an execution OpenCode started on its own delivers. When - * they all report subagents a Stop ended, that execution only answers the - * Stop, so it is stopped and `stopped` is true. - */ const takeReports = Effect.fnUntraced(function* (state: ThreadState) { const delivered = [...state.reports.values()]; state.reports.clear(); @@ -2406,7 +2098,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (stopped) return; const route = state.providerThread.appThreadId; const wake: Wake = { - // Only a thread's wake is offered a continuation; the ordinal is display metadata. messageId: route === null ? undefined @@ -2427,7 +2118,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* offerWake(state, wake); }); - /** A background subagent's end, as OpenCode queues it for its parent. */ const onReport = Effect.fnUntraced(function* ( state: ThreadState, inboxId: string, @@ -2438,7 +2128,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ) { const childId = stringField(payload.metadata, "childID"); if (stringField(payload.metadata, "source") !== "subagent" || childId === undefined) return; - // A stray that reports has ended: no Stop needs to reach it. strays.delete(childId); const call = [...state.calls.values()].find( (candidate) => candidate.child?.sessionId === childId, @@ -2468,14 +2157,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr for (const each of calls) yield* settleCall(each, status, subagentOutput(payload.text)); }); - /** - * The thread whose held execution an event belongs to: the thread's own - * session, or a subagent session its held execution started. - */ const holderOf = (sessionId: string) => { const own = threads.get(sessionId); if (own !== undefined) return own.wakes.at(-1)?.running === true ? own : undefined; - // A subagent a held execution started is named only when that execution replays. const parent = announced.get(sessionId)?.parentID; const holder = parent === undefined ? undefined : threads.get(parent); return holder !== undefined && holder.active === undefined && holder.wakes.length > 0 @@ -2500,7 +2184,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* finishTurn(state, end); }); - /** Ends a turn held for its steers once none is left to deliver. */ const endIfSettled = (state: ThreadState, turn: ActiveTurn) => turn.heldEnd !== undefined && turn.steers.size === 0 ? finishTurn(state, turn.heldEnd) @@ -2508,9 +2191,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const handleEvent = Effect.fnUntraced(function* (event: OpenCode2Client.OpenCode2StreamEvent) { const sessionId = sessionOfEvent(event); - // `revert.clear` wakes the session into an empty execution of its own - // (2.0.18's `Session.revert.clear` ends with a wake). It is no run of - // T3's, and no follow-up to a subagent either. const cleared = sessionId === undefined ? undefined : clearing.get(sessionId); if (cleared !== undefined) { if (event.type === "unreadable.execution.ended" || executionEnd(event.type)) { @@ -2532,7 +2212,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // on this ordered stream. Once the run `unsettled` waits on has // started, a later start means that run is over: an end not seen by // now was lost with a dropped stream, and the next end is this - // execution's own. A run not yet started when it was marked starts here. + // execution's own. const state = threads.get(sessionId); if (state?.unsettled === true) { if (state.unsettledStarted) state.unsettled = false; @@ -2543,9 +2223,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr busy.delete(sessionId); } } - // A subagent's session: named on its parent's `subagent` call just after. if (event.type === "session.created" && event.data.parentID !== undefined) { - // A stray's subagent is a stray too: no call of the thread's names it. const strayCaller = strays.get(event.data.parentID); if (strayCaller !== undefined) { strays.set(event.data.sessionID, strayCaller); @@ -2559,8 +2237,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } return; } - // What a held execution does, and what the subagents it starts do, - // waits for the turn that takes it. const holder = sessionId === undefined ? undefined : holderOf(sessionId); const held = holder?.wakes.at(-1); if (holder !== undefined && held !== undefined) { @@ -2576,18 +2252,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* route(event, sessionId); }); - /** - * Handles one event for its session, as the turn running there sees it. - * `held` is set for a held execution's events as its turn replays them. - */ const route = Effect.fnUntraced(function* ( event: OpenCode2Client.OpenCode2StreamEvent, sessionId: string | undefined, held = false, ) { - // The end of the run a timed-out Stop left behind; no turn is its own. - // A held execution's end is its own: any such run was over, or was this - // execution, before it started. const ended = !held && (event.type === "unreadable.execution.ended" || executionEnd(event.type)) ? threads.get(sessionId ?? "") @@ -2596,8 +2265,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ended.unsettled = false; return; } - // Marks where a running turn's own execution begins; it never ends one. - // With no turn running it is a subagent's or a follow-up's start, below. if (event.type === "unreadable.execution.started") { const turn = threads.get(event.sessionID)?.active; if (turn !== undefined) { @@ -2631,9 +2298,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } if (event.type === "form.created") return yield* onAsked({ type: "form", form: event.data.form }); - // Answered in another OpenCode client, or dropped by OpenCode: a reject - // it sends on its own (a Stop, or another reject in the same session) - // cancels the request. T3's own answers are settled where they are sent. if ( event.type === "permission.replied" || event.type === "form.replied" || @@ -2672,19 +2336,15 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic") { return yield* onReport(state, event.data.inboxID, event.data.item.payload); } - // Cancelled, or delivered into a turn already running: no wake reports it. - // A steer of the running turn's is settled the same way. if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") { state.reports.delete(event.data.inboxID); const turn = state.active; if (turn !== undefined && turn.steers.delete(event.data.inboxID)) { turn.settledInbox.add(event.data.inboxID); - // A cancelled steer may leave a held turn nothing to wait for. if (event.type === "session.inbox.cancelled") yield* endIfSettled(state, turn); } return; } - // A subagent's later prompts (a resumed subagent) open its next turn's message. if ( event.type === "session.inbox.enqueued" && event.data.item.type === "user" && @@ -2697,8 +2357,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const started = event.type === "session.execution.started" || event.type === "unreadable.execution.started"; - // Each execution of a subagent's session is a turn on its child thread, - // unless it only answers the reports of nested subagents a Stop ended. if (state.subagent !== undefined && state.active === undefined && started) { if ((yield* takeReports(state)).stopped) { state.subagent.queued = undefined; @@ -2708,12 +2366,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const turn = state.active; if (turn === undefined) { - // OpenCode started the thread's session on its own. if (started && state.subagent === undefined) return yield* onWake(state); return; } - // A session runs one execution at a time, and each opens with `started` - // on this ordered stream, so what comes before it is the stopped run's. if (turn.awaitingStart) { if (event.type === "session.execution.started") turn.awaitingStart = false; return; @@ -2723,10 +2378,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // The stream is the only terminal signal, and it is volatile: events sent // while it is down are gone, and a restarted server never ends the - // execution it lost. So a lost stream reconnects, then reconciles each - // running turn from the server's own state (see `reconcile`). Only when - // reconnecting keeps failing are the turns failed and the session broken, - // so T3 reopens it. Set first, so a turn starting meanwhile waits or refuses. + // execution it lost. let streamFailure: string | undefined; let reconnected = yield* Deferred.make(); const failAll = Effect.fnUntraced(function* (message: string) { @@ -2736,7 +2388,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const failure = makeProviderFailure({ message, class: "transport_error" }); yield* finishTurn(state, { status: "failed", failure }, "broken"); } - // Nothing can report a background subagent's end any more. for (const state of threads.values()) { for (const call of runningCalls(state)) yield* settleCall(call, "failed"); for (const wake of state.wakes.splice(0)) wake.dropped = true; @@ -2745,17 +2396,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* setSessionStatus("error", message); yield* Queue.end(events); }); - /** - * Emits what a running turn missed while the stream was down, from the - * session's history since the turn began: text, reasoning and tools, each - * under the same native id its live events would have used, so nothing - * already shown is duplicated. A turn begins at its prompt, whose id T3 - * chose (`nativeTurnRef`), or after `before` for a `/name` command or a - * continuation. - * Returns how that history says the turn's execution ended: the `idle` item - * OpenCode appends after each one, or undefined when there is none after - * the turn's start. - */ const backfill = Effect.fnUntraced(function* (sessionId: string, state: ThreadState) { const turn = state.active; if (turn === undefined) return undefined; @@ -2771,8 +2411,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Stream.runCollect, ); // Without its start the history holds only earlier turns (the stream - // dropped before the prompt landed): none of it is this turn's. A null - // `before` is a command that started on an empty history. + // dropped before the prompt landed): none of it is this turn's. const found = start === null || read.at(-1)?.id === start; if (!found) return undefined; const recent = before !== undefined && before !== null ? read.slice(0, -1) : read; @@ -2816,24 +2455,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); /** - * Settles each thread turn that was running when the stream dropped, from - * the server: a session still running keeps its turn (its next events - * arrive on the new stream); one that stopped ends the turn with the - * outcome of the `idle` item after the turn's start. A restarted server - * writes no such item for the execution it lost, and `session.outcome` is - * still the previous execution's, so no `idle` means the turn was - * interrupted. A continuation turn is settled the same way, from the report - * its execution answers. A turn still holding for an undelivered steer only - * ends once the session is idle. Permission asks and question forms are - * read back from every session still running, so a run that asked during - * the gap can be answered. - * - * What the stream carried outside a turn is not read back: a background - * subagent's report, a follow-up OpenCode started on its own, or one held - * for its continuation turn. So background work is settled, not resumed: a - * subagent whose session stopped ends as interrupted, and a thread's held - * follow-ups and queued reports are dropped, so nothing waits on an event - * that already went by. + * A restarted server writes no such item for the execution it lost, and `session.outcome` is + * still the previous execution's, so no `idle` means the turn was interrupted. */ const reconcile = Effect.gen(function* () { // A subagent's turn ends before the turn that waits on it, so the @@ -2855,16 +2478,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return; } const active = yield* client.session.active(); - // An execution that ended while the stream was down never said so. for (const sessionId of busy) if (!(sessionId in active)) busy.delete(sessionId); - // A run `unsettled` waits on is over once its session is idle: any start - // from here is a new execution's, even if the run's own start was lost in - // the gap. A session still active may not have started that run yet, so - // its next start may still be the run's own; its end clears the mark. for (const state of threads.values()) { if (state.unsettled && !(state.sessionId in active)) state.unsettledStarted = true; } - // A stray that stopped meanwhile runs nothing to stop. for (const childId of strays.keys()) if (!(childId in active)) strays.delete(childId); for (const [sessionId, state] of running) { const turn = state.active; @@ -2888,14 +2505,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); } for (const state of background) { - // A subagent still running keeps its call; its end arrives on the new stream. for (const call of runningCalls(state)) { const child = call.child?.sessionId; if (child === undefined || !(child in active)) { yield* settleCall(call, "interrupted", LOST_BACKGROUND); } } - // A subagent's session holds reports of its own background subagents. for (const session of sessionsOf(state)) { discardWakes(session, session.wakes.splice(0)); session.reports.clear(); @@ -2905,10 +2520,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr for (const thread of new Set([...strays.values()].map(rootOf))) { yield* Effect.forkIn(stopStrays(thread), sessionScope); } - // A request asked while the stream was down was never shown, and the - // run waits on it; one T3 shows that OpenCode no longer lists was - // answered elsewhere or dropped with its execution. A session that - // stopped dropped its requests. const listed = new Map>(); for (const sessionId of Object.keys(active)) { if (ownerOf(sessionId) === undefined) continue; @@ -2926,7 +2537,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (!shown.has(form.id)) yield* onAsked({ type: "form", form }); } } - // A snapshot: settling a request removes it from the map. for (const entry of Array.from(pending.values())) { if (listed.get(entry.sessionId)?.has(entry.native.id) !== true) { yield* settleRequest(entry, "cancelled"); @@ -2934,10 +2544,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }); - /** - * Subscribes again, on a restarted server if the old one is gone, and - * reconciles. Retried a few times; the caller fails everything after that. - */ const reconnect = Effect.gen(function* () { // Returned on any failure, including the interrupt a closing session // sends a reconnect still in flight, so a spawned server can idle-stop. @@ -2960,8 +2566,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); let currentScope = initial.scope; - // The borrow in use when the session closes is returned with it, so a - // spawned server can still reach its idle shutdown. yield* Effect.addFinalizer(() => Scope.close(currentScope, Exit.void)); const follow = ( stream: Stream.Stream, @@ -3014,10 +2618,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); } - // Context windows come from the server's model list for a directory, read - // when the session opens, before a thread first runs in another directory, - // and again after a turn whose model had none yet. A directory counts as - // known once its read starts, so a failed read is retried only after a turn. const readModels = (directory: string) => Effect.suspend(() => { const windows = contextWindows.get(directory) ?? new Map(); @@ -3027,7 +2627,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.tap((models) => Effect.sync(() => { for (const model of models.data) { - // A model's input limit, when it has one, is its real headroom. windows.set( `${model.providerID}/${model.id}`, model.limit.input ?? model.limit.context, @@ -3042,7 +2641,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr contextWindows.has(directoryOf(cwd)) ? Effect.void : readModels(directoryOf(cwd)); yield* readModels(session.cwd); - // Each agent's own path allows, by the directory they were listed for. const agentRules = new Map>>(); const pathsFor = Effect.fnUntraced(function* ( directory: string, @@ -3057,8 +2655,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr schedule: Schedule.spaced("250 millis"), }), Effect.timeout("5 seconds"), - // The failure can carry the server's URL or response text, so only its - // tag is annotated; the full failure stays in the log's cause. Effect.tapCause((cause) => Effect.logWarning( "Could not list OpenCode agents; their path rules are skipped.", @@ -3082,11 +2678,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return [...paths.values()]; }); - /** - * The rules a thread's session runs `policy` with. Full access allows - * every path already, so the agent's own path rules are read only for the - * modes that narrow it. - */ const rulesFor = Effect.fnUntraced(function* ( thread: Pick, policy: RulesPolicy, @@ -3105,9 +2696,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); }); - /** Writes the session's rules for `policy` when they differ from what it has. */ const writeRules = Effect.fnUntraced(function* (state: ThreadState, policy: RulesPolicy) { - // A subagent's session may use its thread's T3 server, the root's. const rules = yield* rulesFor(state, policy, rootOf(state).providerThread.appThreadId); if (!sameRules(state.rules, rules)) { yield* client.session.update({ @@ -3119,7 +2708,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr state.policy = policy; }); - /** Names the session after its thread when they differ. */ const writeTitle = Effect.fnUntraced(function* (state: ThreadState, title: string) { const next = title.trim(); // An empty title asks OpenCode to generate one. @@ -3160,13 +2748,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; /** - * Refuses to cut or copy a session's history while something writes to - * it: a turn of T3's, an execution seen running on the stream (a held - * follow-up), or a run on the server this runtime does not own: one a - * timed-out Stop or an unanswered request left behind, or any run on a - * session loaded after the server outlived T3. Only that last case asks - * the server. A rollback also waits for background subagents, whose - * reports wake the session into the history it would cut. + * A rollback also waits for background subagents, whose reports wake the + * session into the history it would cut. */ const ensureQuiet = Effect.fnUntraced(function* ( sessionId: string, @@ -3190,13 +2773,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }); - /** - * Takes back steers a finished turn left in OpenCode's inbox. One whose - * cancel fails may still be there, so it is tried again before the next prompt. - */ const cancelStrandedSteers = Effect.fnUntraced(function* (state: ThreadState) { const sessionID = Session.ID.make(state.sessionId); - // A snapshot: a cancelled steer leaves the set. for (const inboxID of Array.from(state.strandedSteers)) { const cancelled = yield* client.session.inbox .cancel({ sessionID, inboxID: SessionMessage.ID.make(inboxID) }) @@ -3231,12 +2809,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); /** - * Truncates the session's history before `boundary`: OpenCode stages the - * revert, then commits it. If either fails or is interrupted the stage is - * cleared, since OpenCode commits a staged revert on the next prompt; a - * clear that fails too is tried again before that prompt. Files stay where - * they are: T3 restores its own checkpoint when the user asked for files, - * and a rewind without files must not touch them. + * If either fails or is interrupted the stage is cleared, since OpenCode commits a staged + * revert on the next prompt; a clear that fails too is tried again before that prompt. Files + * stay where they are: T3 restores its own checkpoint when the user asked for files, and a + * rewind without files must not touch them. */ const rollBackTo = Effect.fnUntraced(function* ( sessionId: string, @@ -3260,8 +2836,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); /** - * The ids fork and rollback can cut before: the session's prompts, and the - * reports continuations answer. The list takes one type, so it reads all. + * The list takes one type, so it reads all. */ const userMessages = (sessionId: string) => OpenCode2Client.paginate( @@ -3279,7 +2854,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ); - /** The session's history as V2 messages, read after any change T3 made to it. */ const snapshotOf = Effect.fnUntraced(function* ( providerThread: OrchestrationV2ProviderThread, sessionId: string, @@ -3348,8 +2922,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr client.mcp.remove({ server: mcp.name, location: { directory: mcp.directory } }), ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })); - // T3's MCP registrations outlive a session only on an external server; a - // spawned one forgets them when it stops. yield* Effect.addFinalizer(() => Effect.forEach( [...threads.values()].flatMap((state) => (state.mcp === undefined ? [] : [state.mcp])), @@ -3358,14 +2930,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ), ); - /** - * T3's MCP server for this thread, and the per-turn instructions. The MCP - * server is registered for the session's directory under the thread's own - * name and credential (the session rules allow only it), and removed when - * the thread unloads or the session closes. OpenCode 2 has no per-prompt - * system field, so the instructions are a session instructions entry, - * which applies from the next step; it is only rewritten when it changes. - */ const prepareTurn = Effect.fnUntraced(function* ( sessionId: string, state: ThreadState, @@ -3388,7 +2952,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* removeMcp(state.mcp); state.mcp = undefined; } - // T3's tools are an addition: a server that cannot add them still runs the turn. if (wanted !== undefined && state.mcp === undefined) { const added = yield* client.mcp .add({ @@ -3434,7 +2997,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (turn !== undefined) turn.before = before; }); - /** Installs a turn T3 started; every path after it ends the turn with a terminal. */ const beginTurn = ( state: ThreadState, turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, @@ -3448,9 +3010,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr providerThreadId: turnInput.providerThread.id, nodeId: turnInput.rootNodeId, runAttemptId: turnInput.attemptId, - // The user message it prompts with, where fork and rollback cut. A - // continuation prompts nothing; it records the report it answers - // once it takes its execution. nativeTurnRef: ref( isContinuation(turnInput) ? wakeTurnId(state.sessionId, turnInput.attemptId) @@ -3463,7 +3022,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr completedAt: null, }; const turn = makeTurn(turnInput, providerTurn, { scope: "", awaitingStart }); - // No stream is left to end this turn, so it must not start. if (streamFailure !== undefined) { return yield* new ProviderAdapter.ProviderAdapterEventStreamError({ driver, @@ -3489,18 +3047,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return turn; }); - /** Feeds a held execution's events to the turn now running on its session. */ const replay = Effect.fnUntraced(function* (wake: Wake) { wake.dropped = true; for (const event of wake.events) yield* route(event, sessionOfEvent(event), true); }); - /** - * Drops held executions no turn will take. The subagents one started are - * named only by its replay, so they stop routing to the thread at once and - * become strays for {@link stopStrays}. A subagent that registered its own - * call outside the held execution is the thread's, and is left alone. - */ const discardWakes = (caller: ThreadState, wakes: ReadonlyArray) => { const retired = new Set(); const retire = (childId: string) => { @@ -3510,17 +3061,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr childOwners.delete(childId); strays.set(childId, caller); retired.add(childId); - // OpenCode reports its end to the session that called it; that - // report answers no turn, so the execution it wakes is stopped. A - // deeper one reports to a stray, which T3 does not track. threads.get(info.parentID ?? "")?.stoppedChildren.add(childId); }; for (const wake of wakes) { wake.dropped = true; for (const childId of wake.children) retire(childId); } - // Their own subagents, at any depth, were announced live but no call - // of the thread's names them either. for (let grew = retired.size > 0; grew;) { grew = false; for (const [childId, info] of announced) { @@ -3532,10 +3078,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; /** - * Stops a thread's strays one by one, as a Stop stops background - * subagents, and never under `lock`: one that does not answer would - * otherwise hold up every event. A stray is forgotten once stopped or - * gone; one still running waits for the next Stop. + * Stops a thread's strays one by one, as a Stop stops background subagents, and never under + * `lock`: one that does not answer would otherwise hold up every event. */ const stopStrays = (thread: ThreadState) => Effect.forEach( @@ -3554,7 +3098,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr /** * A user turn that starts while OpenCode runs an execution on its own: the * prompt joins that execution, so the turn takes it, and its continuation - * turn is no longer needed. Executions that already ended stay for theirs. + * turn is no longer needed. */ const takeRunningWake = Effect.fnUntraced(function* (state: ThreadState) { const wake = state.wakes.at(-1); @@ -3563,14 +3107,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* replay(wake); }); - /** - * The continuation turn for an execution OpenCode started on its own: it - * takes the wake it was dispatched for, named by its message, and ends - * with it. Wakes held before that one had their continuation cancelled or - * dropped before it started, so nothing will replay them: they are - * dropped. One already taken (a user turn joined it) leaves nothing to - * run, so the turn ends at once. - */ const runWake = Effect.fnUntraced(function* ( state: ThreadState, turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, @@ -3594,25 +3130,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (turn !== undefined && wake.first !== undefined) { turn.providerTurn = { ...turn.providerTurn, nativeTurnRef: ref(wake.first, "weak") }; } - // The execution still runs, so the run a mark waits on is this one or - // was over before it started: its end, still to come, is this turn's. if (wake.running) state.unsettled = false; yield* replay(wake); }), ); }); - /** - * Stops a thread's background subagents and any execution OpenCode is - * running on its own. Their sessions end as interrupted; the reports - * OpenCode then queues wake the parent into an execution that is stopped - * as well, so no turn takes it. A subagent the Stop did not reach runs on, - * so its call stays tracked: the session is not released under it, and - * the next Stop tries it again. - */ const stopBackground = Effect.fnUntraced(function* (state: ThreadState) { const calls = runningCalls(state).filter((call) => call.background); - // Every session under the thread, which a stopped subagent may report to. const callers = sessionsOf(state); // A subagent whose call ended runs again when OpenCode wakes it to // answer a report of its own background subagent. @@ -3631,8 +3156,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); const childrenOf = (call: SubagentCall) => call.child === undefined ? announcedTo(call.state) : [call.child.sessionId]; - // Each child reports to the session that called it, so its marker goes - // on that caller's state: the thread's own, or a subagent's for a nested one. const children = new Map( calls.flatMap((call) => childrenOf(call).map((childId) => [childId, call.state] as const)), ); @@ -3642,7 +3165,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const reached = yield* client.session .interrupt({ sessionID: Session.ID.make(childId) }) .pipe( - // A session that is gone runs nothing. Effect.catchTags({ SessionNotFoundError: () => Effect.void }), Effect.timeout(INTERRUPT_TIMEOUT), Effect.tapCause((cause) => @@ -3653,7 +3175,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ); if (!reached) unreached.add(childId); } - // Its end closes its turn; one the Stop did not reach stays pending work. for (const followUp of followUps) { yield* client.session .interrupt({ sessionID: Session.ID.make(followUp.sessionId) }) @@ -3669,13 +3190,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (childrenOf(call).some((childId) => unreached.has(childId))) continue; yield* settleCall(call, "interrupted"); } - // A report already queued starts a follow-up the Stop must end too. for (const caller of callers) { for (const report of caller.reports.values()) { caller.stoppedChildren.add(report.childId); } } - // A held wake no turn will take: its execution is stopped, not replayed. const running = state.wakes.some((wake) => wake.running); discardWakes(state, state.wakes.splice(0)); if (running) { @@ -3686,17 +3205,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } }), ); - // Subagents of held executions this or an earlier Stop dropped. yield* stopStrays(state); }); - /** - * What a turn sends: `/compact` compacts, `/name args` naming a workspace - * command runs it, and anything else is a prompt with the `$skill`s it - * names attached. Commands and skills are read from the session's - * directory only when the text could use them. A turn stopped meanwhile - * sends nothing. - */ const submit = Effect.fnUntraced(function* ( sessionId: string, turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, @@ -3704,7 +3215,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr turn: ActiveTurn, ) { const sessionID = Session.ID.make(sessionId); - /** Whether the turn still sends; past this point a Stop goes to the server. */ const sending = () => { if (state.active !== turn || turn.interrupted) return false; turn.unsent = false; @@ -3712,7 +3222,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; const text = turnInput.message.text.trim(); const bare = turnInput.message.attachments.length === 0; - // The turn's own id, so fork and rollback cut before this turn's item. const id = turnPromptId(sessionId, turnInput.attemptId); if (bare && text === "/compact") { if (!sending()) return; @@ -3775,7 +3284,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const state = nativeId == null ? undefined : threads.get(nativeId); return state !== undefined && hasBackground(state); }), - // A caller that names no directory gets the one this session opened in. getModelContextWindow: (selection, cwd) => selection.instanceId === instanceId ? windowOf(cwd === undefined ? session.cwd : cwd, selection.model) @@ -3800,7 +3308,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const directory = threadInput.runtimePolicy.cwd ?? host.paths.cwd; const policy = threadInput.runtimePolicy; - // A new session runs OpenCode's default agent. const permissions = yield* rulesFor( { directory, agent: "build", grants: [] }, policy, @@ -3864,8 +3371,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (threadInput.runtimePolicy !== undefined) { yield* readModelsOnce(threadInput.runtimePolicy.cwd); } - // 1.x session ids survive the upgrade; a server without this session - // fails the resume, so T3 recreates the thread with a handoff. const native = yield* client.session.get({ sessionID: Session.ID.make(sessionId) }); const providerThread: OrchestrationV2ProviderThread = { ...threadInput.providerThread, @@ -3876,13 +3381,8 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const cwd = threadInput.runtimePolicy?.cwd; const loaded = threads.has(sessionId); const state = register(providerThread, native, cwd ?? native.location.directory); - // OpenCode keeps no request across its own restart, but a server that - // outlived T3 may still wait on one T3 no longer shows. if (!loaded) yield* stopLeftoverRequests(state); - // The session gets the rules for this thread's mode: it may have run - // another mode, or been made by 1.x or an earlier build. yield* writeRules(state, threadInput.runtimePolicy ?? state.policy); - // A thread moved to another worktree takes its session with it. if (cwd != null && native.location.directory !== cwd) { yield* client.session.move({ sessionID: Session.ID.make(sessionId), @@ -3902,13 +3402,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }), ), ), - // `/compact` is its own turn, which `startTurn` sends as `session.compact`. compactThread: (turnInput) => runtime.startTurn({ ...turnInput, message: { ...turnInput.message, text: "/compact" } }), startTurn: (turnInput) => Effect.gen(function* () { - // A lost stream is reconnecting: wait for it rather than prompt into - // a dead stream, and refuse the turn before any request if it gave up. // Waited out before the session's gate, so a rollback or fork is not // held behind a reconnect. if (streamFailure !== undefined) { @@ -3940,12 +3437,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr detail: `OpenCode session ${sessionId} already has an active turn`, }); } - // OpenCode already ran this turn on its own; it prompts nothing. if (isContinuation(turnInput)) return yield* runWake(state, turnInput); - // After a timed-out Stop the server says whether that run is gone. A - // run still going is stopped again and this turn fails so it can be - // sent again; a run that is gone may still have its end on the - // stream, which the turn skips. const afterUnsettled = state.unsettled; let stillStopping = false; if (state.unsettled) { @@ -3961,7 +3453,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr state.unsettled = false; } } - // Installs the turn; every path after it ends the turn with a terminal. const begin = beginTurn(state, turnInput, afterUnsettled); if (stillStopping) { yield* begin; @@ -3973,8 +3464,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }), }); } - // A turn T3 will not run still starts and fails, so the refusal is what - // the user reads. const model = modelRef(turnInput.modelSelection); if (model === undefined) { yield* begin; @@ -3987,8 +3476,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); } // Plan mode is OpenCode's `plan` agent, switched before the rules are - // written so they keep that agent's own path allows. Switching to or - // from `plan` queues OpenCode's own "Plan mode" reminder for the prompt. + // written so they keep that agent's own path allows. const agent = agentFor(turnInput); if (agent !== state.agent) { yield* client.session.switchAgent({ @@ -4001,7 +3489,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // and its subagents still running hold the rules they started with. // Those run on whether or not this turn starts, so theirs are best effort. yield* writeRules(state, turnInput.runtimePolicy); - // A thread renamed since the last turn, or titled after its first prompt. yield* writeTitle(state, turnInput.appThread.title).pipe( Effect.timeout(REQUEST_REPLY_TIMEOUT), Effect.ignore({ log: true }), @@ -4013,8 +3500,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.ignore({ log: true }), ); } - // A selection changed since the last turn applies now; OpenCode keeps - // the session's model otherwise. if (!sameModel(model, state.model)) { yield* client.session.switchModel({ sessionID: Session.ID.make(sessionId), model }); state.model = model; @@ -4026,8 +3511,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* prepareTurn(sessionId, state, turnInput); const turn = yield* begin; turn.unsent = true; - // An execution OpenCode is running on its own takes this prompt at - // its next step, so this turn is that execution from here on. yield* lock.withPermit(takeRunningWake(state)); return { state, sessionId, turn }; }), @@ -4055,8 +3538,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.tapError((cause) => state.active === turn ? Effect.gen(function* () { - // Without a clear rejection the server may have taken the - // prompt, so the next turn checks before it prompts again. if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) markUnsettled(state); yield* finishTurn(state, { status: "failed", @@ -4096,7 +3577,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); } const inboxID = steerPromptId(sessionId, steerInput.message.messageId); - // A retried steer that already reached the model is not sent again. if (turn.settledInbox.has(inboxID)) return; turn.steers.add(inboxID); state.strandedSteers.delete(inboxID); @@ -4116,7 +3596,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr lock.withPermit( Effect.gen(function* () { turn.steers.delete(inboxID); - // Without a clear refusal OpenCode may hold it; the next prompt takes it back. if (!CLEAR_PROMPT_REJECTIONS.has(cause._tag)) state.strandedSteers.add(inboxID); if (state.active === turn) yield* endIfSettled(state, turn); }), @@ -4149,15 +3628,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (turn === undefined || turn.providerTurn.id !== interruptInput.providerTurnId) { return; } - // The session answers with `session.execution.interrupted`, which ends - // the turn. A server that does not answer in time is stuck, so the turn - // ends here instead of waiting on it. turn.interrupted = true; const reply = yield* client.session .interrupt({ sessionID: Session.ID.make(sessionId) }) .pipe( Effect.timeoutOption(INTERRUPT_TIMEOUT), - // A Stop that never reached the server stopped nothing. Effect.tapError(() => Effect.sync(() => { turn.interrupted = false; @@ -4175,14 +3650,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); return yield* cancelStrandedSteers(state); } - // Nothing was running because the turn has not sent its request yet - // (it is reading the workspace's commands or skills): it ends here, - // and never sends it. if (!reply.value.interrupted && state.active === turn && turn.unsent) { return yield* lock.withPermit(finishTurn(state, { status: "interrupted" })); } - // Nothing was running. Unless the execution already ended (its event - // is on the way), the turn is still open and nothing stopped. if (!reply.value.interrupted && state.active === turn) { turn.interrupted = false; return yield* new ProviderAdapter.ProviderAdapterProtocolError({ @@ -4241,9 +3711,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } entry.answering = true; // OpenCode's own "always" saves a grant for the whole project, so a - // session-wide answer is a rule on this session instead. The grant - // is best effort: this request is answered either way. - // The session that asked waits on the answer and holds the grant: a // subagent's own, not its parent's. const asker = threads.get(entry.sessionId) ?? entry.state; if ( @@ -4260,11 +3727,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr state.grants.push({ action: native.action, resource, effect: "allow" }); } } - // A subagent runs under its thread's mode. yield* writeRules(state, rootOf(state).policy).pipe(Effect.ignore({ log: true })); } const sessionID = Session.ID.make(entry.sessionId); - // Best effort: the decline stands without the note. if (native.type === "permission" && decision === "decline") { yield* client.session .synthetic({ @@ -4282,7 +3747,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr sessionID, requestID: Permission.ID.make(native.id), decision: decision !== undefined && approves(decision) ? "once" : "reject", - // Without a message OpenCode ends the whole run, which is Cancel. ...(decision === "decline" ? { message: DECLINED } : {}), }) .pipe(Effect.catchTags(permissionGone)), @@ -4332,12 +3796,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr Effect.gen(function* () { const { providerThread, target } = rollbackInput; const sessionId = yield* sessionIdOf(providerThread); - // A stopped run's end may still be on the stream, so `unsettled` - // stays for the next turn to skip it. yield* ensureQuiet(sessionId, "roll back"); - // A session this runtime has not loaded yet is loaded the way a turn - // would load it, which stops anything it still waits on. Requests it - // stops end their run, so the server is asked again after that. if (!threads.has(sessionId)) { yield* runtime.resumeThread({ providerThread }); yield* ensureQuiet(sessionId, "roll back"); @@ -4351,9 +3810,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (boundary !== null) { yield* rollBackTo(sessionId, SessionMessage.ID.make(boundary)); } - // The turns after the target left the history with their messages. - // The snapshot lists the ones kept as T3 recorded them, which a - // runtime that loaded the session after they ran never saw. const keptOrdinal = target.type === "provider_turn" ? target.providerTurn.ordinal : 0; const loaded = threads.get(sessionId); for (const [id, turn] of loaded?.providerTurns ?? []) { @@ -4369,7 +3825,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr .map((turn) => [turn.id, turn] as const), ); for (const turn of snapshot.providerTurns) kept.set(turn.id, turn); - // The snapshot's head is the last user message OpenCode kept. return { ...snapshot, providerTurns: [...kept.values()] }; }).pipe( exclusive(rollbackInput.providerThread), @@ -4411,7 +3866,6 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr yield* userMessages(sourceId), ); // A fork copies the source's history, model, location and rules; its - // message ids are new, so it has no turns T3 could cut at yet. const forked = yield* client.session.fork({ sessionID: Session.ID.make(sourceId), ...(before === null ? {} : { before: SessionMessage.ID.make(before) }), @@ -4439,12 +3893,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr createdAt, updatedAt: createdAt, }; - // A fork keeps the source's location. The target thread usually // inherits that worktree too, but its first turn starts without a // resume, so a target in another worktree takes the session there now. const cwd = forkInput.runtimePolicy?.cwd; const state = register(providerThread, forked, cwd ?? forked.location.directory); - // The fork runs the target thread's mode, not whatever the source ran. yield* writeRules(state, forkInput.runtimePolicy ?? state.policy); if (cwd != null && forked.location.directory !== cwd) { yield* client.session.move({ diff --git a/packages/provider-opencode/src/server/v2/textGeneration.ts b/packages/provider-opencode/src/server/v2/textGeneration.ts index dd4cf4368a87..252431bc3594 100644 --- a/packages/provider-opencode/src/server/v2/textGeneration.ts +++ b/packages/provider-opencode/src/server/v2/textGeneration.ts @@ -1,10 +1,7 @@ /** - * Text generation on an OpenCode 2 server. Both of its `generate` endpoints - * answer 403 on OpenCode's free tier ("free tier can only be used from within - * OpenCode"), so each request runs as a prompt in a temporary session, as 1.x + * Both of its `generate` endpoints answer 403 on OpenCode's free tier ("free tier can only be + * used from within OpenCode"), so each request runs as a prompt in a temporary session, as 1.x * does, and the session is removed afterwards. - * - * @module textGeneration/OpenCode2TextGeneration */ import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect"; import { TextGenerationError } from "@t3tools/contracts"; @@ -21,18 +18,16 @@ import * as TextGenerationOperations from "@t3tools/provider-core/server/textGen const isTextGenerationError = Schema.is(TextGenerationError); -/** A generation that takes this long is stuck; its session is stopped and removed. */ const GENERATION_TIMEOUT = "3 minutes"; /** - * Nothing in a text generation needs a tool, so every tool asks and T3 is not - * there to answer. Denying `shell` or `read` outright gets the whole session - * refused on OpenCode's free models, and an ask that is never answered would - * hang, so asks are rejected as they arrive. + * Denying `shell` or `read` outright gets the whole session refused on + * OpenCode's free models, and an ask that is never answered would hang, so asks + * are rejected as they arrive. */ const GENERATION_PERMISSIONS = [{ action: "*", resource: "*", effect: "ask" }] as const; -/** How a generation ended. A failure's cause is the provider's own error, kept out of `detail`. */ +/** A failure's cause is the provider's own error, kept out of `detail`. */ type Outcome = | { readonly _tag: "text"; readonly text: string } | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown }; @@ -181,7 +176,6 @@ const runOnServer = ( return result.text; }).pipe(Effect.scoped); -/** Text generation for an instance whose server is OpenCode 2. */ export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () { const server = yield* OpenCode2Server.OpenCode2Server; const host = yield* ProviderHost.ProviderHost; diff --git a/packages/provider-opencode/src/server/versionProbe.test.ts b/packages/provider-opencode/src/server/versionProbe.test.ts index 6bf6763e9941..d698bbcb8785 100644 --- a/packages/provider-opencode/src/server/versionProbe.test.ts +++ b/packages/provider-opencode/src/server/versionProbe.test.ts @@ -70,7 +70,6 @@ describe("OpenCode version probe", () => { Effect.gen(function* () { const urls: Array = []; const replay = replayOpenCodeServer(OPENCODE_1_RESPONSES, "pw"); - // A reverse proxy mounting OpenCode under /opencode, reached with a routing query. const proxied = HttpClient.make((request, url) => { urls.push(url.href); return replay.execute( @@ -190,7 +189,6 @@ describe("OpenCode version probe", () => { assert.strictEqual((yield* probe.get).generation, "v2"); assert.strictEqual((yield* probe.get).generation, "v2"); assert.strictEqual(calls, 2); - // A status refresh re-probes, so an in-place downgrade or upgrade re-routes. assert.strictEqual((yield* probe.refresh).generation, "v1"); assert.strictEqual((yield* probe.get).generation, "v1"); assert.strictEqual(calls, 3); diff --git a/packages/provider-opencode/src/server/versionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts index 519144944a4f..5fff466e95ba 100644 --- a/packages/provider-opencode/src/server/versionProbe.ts +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -1,4 +1,3 @@ -/** Detects whether an OpenCode instance runs 1.x or 2.x, so the driver can pick its runtime. */ import { parseSemver } from "@t3tools/shared/semver"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; @@ -33,14 +32,13 @@ function probed(version: string | null | undefined): ProbedOpenCode | undefined return { generation: major >= 2 ? "v2" : "v1", version }; } -/** `opencode --version` prints `1.18.32` on 1.x and `opencode v2.0.18` on 2.x. */ export const classifyOpenCodeCliVersion = (output: string) => probed(parseGenericCliVersion(output)); /** - * 2.x answers `/api/info` and 1.x answers `/global/health`. Each serves its web UI's HTML with a - * 200 on the other's path, so only a JSON body counts. Both versions answer a wrong password with - * a 401 on either path, so a 401 says nothing about the version. + * Each serves its web UI's HTML with a 200 on the other's path, so only a JSON body counts. Both + * versions answer a wrong password with a 401 on either path, so a 401 says nothing about the + * version. */ function classifyOpenCodeProbeResponse( path: "/api/info" | "/global/health", @@ -110,7 +108,6 @@ const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( ? `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}` : undefined; for (const path of ["/api/info", "/global/health"] as const) { - // A path prefix and query on the configured URL are kept: `/base/?x=1` β†’ `/base/api/info?x=1`. const url = new URL(baseUrl); url.pathname = `${url.pathname.replace(/\/+$/, "")}${path}`; const request = HttpClientRequest.get(url.href); @@ -163,7 +160,6 @@ const probeOpenCodeServer = Effect.fn("probeOpenCodeServer")(function* ( }); }); -/** Probes a configured server when `serverUrl` is set, otherwise the local binary. */ export const probeOpenCodeRuntime = ( settings: { readonly binaryPath: string; @@ -180,11 +176,6 @@ export const probeOpenCodeRuntime = ( ? probeOpenCodeServer(settings.serverUrl, settings.serverPassword) : probeOpenCodeBinary(settings.binaryPath, environment); -/** - * One instance's runtime, remembered after the first successful probe. Settings changes rebuild - * the driver; `refresh` re-probes (status checks use it, so an in-place upgrade re-routes). A - * failed probe is never remembered. `lastSuccess` never probes, for calls too hot to wait on one. - */ export const makeOpenCodeRuntimeProbe = (probe: Effect.Effect) => Effect.map( Cache.makeWith(() => probe, { diff --git a/packages/provider-opencode/src/settings.ts b/packages/provider-opencode/src/settings.ts index 5881b75e00f9..8573cdb4f12d 100644 --- a/packages/provider-opencode/src/settings.ts +++ b/packages/provider-opencode/src/settings.ts @@ -1,8 +1,6 @@ /** - * OpenCode instance settings. Shared by the server driver and the client - * settings form, so it holds only browser-safe schema code. - * - * @module provider-opencode/settings + * Shared by the server driver and the client settings form, so it holds only + * browser-safe schema code. */ import { CustomModelSetting, @@ -15,8 +13,6 @@ import * as Schema from "effect/Schema"; export const OpenCodeSettings = makeProviderSettingsSchema( { - // Off by default (like Cursor and Grok): the binding is not yet stable - // enough to probe on every install. Users opt in from Settings. enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), diff --git a/packages/provider-opencode/src/testing.ts b/packages/provider-opencode/src/testing.ts index 5fa87cde4e50..d9d079c708d8 100644 --- a/packages/provider-opencode/src/testing.ts +++ b/packages/provider-opencode/src/testing.ts @@ -1,10 +1,3 @@ -/** - * OpenCode internals the server's replay testkits and live tests drive - * directly: the 2.x client, server, adapter, and text generation, plus the - * 1.x transcript identifiers. - * - * @module provider-opencode/testing - */ export { OPENCODE_DEFAULT_INSTANCE_ID, OPENCODE_PROVIDER, diff --git a/packages/provider-pi/src/client.ts b/packages/provider-pi/src/client.ts index 2b1acd8f2f88..44095bec0c60 100644 --- a/packages/provider-pi/src/client.ts +++ b/packages/provider-pi/src/client.ts @@ -1,9 +1,3 @@ -/** - * Pi's client definition: settings form, label, and glyph. Browser- and - * React Native-safe. - * - * @module provider-pi/client - */ import { ProviderDriverKind } from "@t3tools/contracts"; import { defineProviderClient } from "@t3tools/provider-core/client"; diff --git a/packages/provider-pi/src/server.ts b/packages/provider-pi/src/server.ts index 8205371dbbe2..a148be9a4e65 100644 --- a/packages/provider-pi/src/server.ts +++ b/packages/provider-pi/src/server.ts @@ -1,8 +1,2 @@ -/** - * Pi's server entry: the driver the server registers, plus the adapter - * driver used by replay tests and the fixture recorder. - * - * @module provider-pi/server - */ export { PiDriver, type PiDriverEnv } from "./server/driver.ts"; export { PI_PROVIDER, PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./server/adapter.ts"; diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 98a956cc5a08..78876fcfa18e 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -82,32 +82,20 @@ interface FakePi { readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly emit: (record: PiRpcRecord, chunkBytes?: number) => Effect.Effect; readonly takeRequest: (type: string) => Effect.Effect; - /** Data returned by the next `get_entries` acks, consumed in order. */ readonly queueEntries: (data: unknown) => void; - /** Data returned by the next active-branch `get_messages` acks. */ readonly queueMessages: (data: unknown) => void; - /** Make the next `switch_session` ack report an extension veto. */ readonly vetoNextSwitch: () => void; - /** Fields overriding the recorded idle state in the next `get_state` acks, in order. */ readonly queueState: (data: Record) => void; - /** Change Pi's streaming state before its agent_start event reaches T3. */ readonly setStreaming: (streaming: boolean) => void; - /** Hold the next `get_state` response until the test resolves it. */ readonly deferNextState: () => void; - /** Resolve the held `get_state` request. */ readonly resolveDeferredState: (data: unknown) => Effect.Effect; - /** Reject the next `get_state` request. */ readonly failNextState: () => void; readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; - /** Hold a model-select extension hook until its UI request is answered. */ readonly deferNextModelSelection: () => void; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; - /** Every request received by the fake process. */ readonly allRequests: () => ReadonlyArray; - /** Data returned by the next `get_session_stats` acks, consumed in order. */ readonly queueStats: (data: unknown) => void; - /** Close the fake process stdout stream. */ readonly closeStdout: Effect.Effect; readonly lastSpawn: () => { readonly args: ReadonlyArray; @@ -133,12 +121,6 @@ const recordedIdleState = (sessionFile: string) => ({ pendingMessageCount: 0, }); -/** - * In-process fake `pi --mode rpc` for races and failures a live Pi cannot - * produce on demand: captures every stdin record, auto-acks requests, and lets - * tests push protocol events to stdout. Behaviour a real Pi can show belongs - * in a replay fixture instead (see PiAdapterV2.testkit.ts). - */ const makeFakePi: Effect.Effect = Effect.gen(function* () { const stdout = yield* Queue.unbounded(); const requests = yield* Queue.unbounded(); @@ -183,8 +165,6 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { failState = false; return { ...base, success: false, error: "state unavailable" }; } - // Queued data overrides fields of the recorded idle state, so a test - // that only cares about the session file still gets a real shape. return { ...base, data: { @@ -356,8 +336,6 @@ const makeAdapter = Effect.fnUntraced(function* ( ? forkFake.spawner.spawn(command) : fake.spawner.spawn(command), ); - // Continuation cases go through the driver, which wires the offer from the - // environment the way production does. if (continuationRequests !== undefined) { return yield* PiAdapterV2Driver.create({ instanceId: PI_INSTANCE_ID, @@ -1676,7 +1654,6 @@ describe("PiAdapterV2", () => { model: "anthropic/large", }), ); - // New native sessions have their own default, even within one process. fake.queueState({ sessionFile: "/fake/replacement.jsonl", model: { provider: "anthropic", id: "small", contextWindow: 32_000 }, @@ -1696,10 +1673,6 @@ describe("PiAdapterV2", () => { const fake = yield* makeFakePi; const { runtime, takeEvent } = yield* openRuntime(fake); const now = yield* DateTime.now; - // The placeholder row the orchestrator creates for a first run: no - // native identity yet. The adapter must bind the pi session to this - // row instead of registering a second session-file-keyed row, or the - // projection ends up with two live rows per app thread. const placeholder: OrchestrationV2ProviderThread = { id: ProviderThreadId.make("thread:provider:pi:native-thread:pending:run:thread-pi-test:1"), driver: PI_PROVIDER, @@ -1749,7 +1722,6 @@ describe("PiAdapterV2", () => { runtimePolicy, }); - // An explicit effort on a concrete model. yield* startTurn(runtime, providerThread, "default", [], "Hello pi", { instanceId: PI_INSTANCE_ID, model: "xai/grok-4.6", @@ -1765,7 +1737,6 @@ describe("PiAdapterV2", () => { yield* fake.emit({ type: "agent_settled" }); yield* takeEvent((event) => event.type === "turn.terminal"); - // Back to Pi default with no explicit thinking choice of its own. yield* startTurn( runtime, providerThread, @@ -1867,7 +1838,6 @@ describe("PiAdapterV2", () => { assert.isFalse( fake.allRequests().some((request) => request.type === "fork" || request.type === "clone"), ); - // ProviderTurnStartService adopts the fork into its pending row. const adopted = { ...forked, id: ProviderThreadId.make("pending-fork-row") }; yield* startTurn(runtime, adopted, "default", [], "Continue", undefined, 1, target); yield* fake.emit({ type: "agent_start" }); @@ -2096,7 +2066,6 @@ describe("PiAdapterV2", () => { assert.deepEqual(Object.keys(event.turnItem.output as object), ["content"]); } } - // Distinct typed metadata remains stored even when readable text exists. yield* fake.emit({ type: "tool_execution_end", toolCallId: "distinct-metadata", @@ -2116,7 +2085,6 @@ describe("PiAdapterV2", () => { const note = "Read image file [image/png]"; const image = { type: "image", data: "AAAA", mimeType: "image/png" }; const imageContent = [{ type: "text", text: note }, image]; - // An extension can attach distinct typed metadata to a read result. for (const structuredContent of [ { ...image, note, width: 1200 }, { ...image, note: "A distinct note" }, @@ -2177,8 +2145,6 @@ describe("PiAdapterV2", () => { }, 64 * 1024, ); - // A following small completion proves a dropped screenshot immediately, - // without waiting for a timeout when framing loses the large event. yield* fake.emit({ type: "tool_execution_end", toolCallId: "after-screenshot", @@ -2367,7 +2333,6 @@ describe("PiAdapterV2", () => { write.turnItem.newStr === "export {};\n", ); - // A failed edit has no patch, so it keeps the error to show when expanded. yield* fake.emit({ type: "tool_execution_start", toolCallId: "call_edit_failed", @@ -2418,8 +2383,6 @@ describe("PiAdapterV2", () => { modelSelection: modelSelection("default"), runtimePolicy, }); - // Runs one turn to its terminal. `listings` answer the turn's - // get_entries requests in order. const settle = Effect.fnUntraced(function* ( text: string, runOrdinal: number, @@ -2428,8 +2391,6 @@ describe("PiAdapterV2", () => { yield* startTurn(runtime, providerThread, "default", [], text, undefined, runOrdinal); yield* fake.takeRequest("prompt"); for (const listing of listings) fake.queueEntries(listing); - // Pi acks every prompt; a command-only prompt then settles from - // an idle probe instead of agent events. yield* fake.emit({ type: "response", command: "prompt", success: true }); if (!text.startsWith("/")) { yield* fake.emit({ type: "agent_start" }); @@ -2467,8 +2428,6 @@ describe("PiAdapterV2", () => { assert.equal(promptB.turnRef?.nativeId, "uB"); assert.isUndefined(promptB.retained); - // The extension moves the leaf back to A's reply. With a summary, - // pi appends a branch_summary child of that reply as the new leaf. const rewoundLeaf = summarize ? "summary" : "aA"; const summary = summarize ? [{ type: "branch_summary", id: "summary", parentId: "aA" }] : []; const rewind = yield* settle( @@ -2490,16 +2449,11 @@ describe("PiAdapterV2", () => { assert.deepEqual(rewind.retained, ["uA"]); assert.equal(rewind.head, rewoundLeaf); if (summarize) { - // The summary is this turn's only entry, and fork cannot re-root - // before a non-user entry, so rollback may not pass it. assert.equal(rewind.turnRef?.strength, "weak"); } else { - // Nothing of this turn is on the branch; rollback can pass it. assert.isNull(rewind.turnRef); } - // get_entries is append-ordered: after a rewind to an older leaf, - // the abandoned branch still comes first in the next window. const promptC = yield* settle("prompt C", 4, { entries: [ ...(summarize ? [] : [user("uB", "aA"), reply("aB", "uB")]), @@ -2517,8 +2471,6 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); - // Corrupted session data can loop its parentId chain. Finalizing the turn - // must still finish, and must not report a branch to roll runs back to. it.effect.each(["turn window", "full tree"] as const)( "finishes a turn whose %s has a parentId cycle", (cycleIn) => @@ -2541,8 +2493,6 @@ describe("PiAdapterV2", () => { if (cycleIn === "turn window") { fake.queueEntries({ entries: cycle, leafId: "x" }); } else { - // The leaf is outside the `since` window, so this reads as a rewind - // and the full tree is walked for the surviving branch. fake.queueEntries({ entries: [], leafId: "x" }); fake.queueEntries({ entries: [root, ...cycle], leafId: "x" }); } @@ -2641,7 +2591,6 @@ describe("PiAdapterV2", () => { update.type === "provider_thread.updated" ? update.retainedNativeTurnIds : null, ); }); - // A failed cursor read is followed by a full listing from a new session. fake.queueEntries({ entries: [] }); yield* finish(); fake.queueEntries({ @@ -2697,10 +2646,8 @@ describe("PiAdapterV2", () => { .filter((request) => request.type === "fork") .map((request) => request.entryId); - // Only a turn that left nothing follows the target: already there. yield* rollBackToFirst([turn(1, "u1"), turn(2, null)]); assert.deepEqual(forkedEntries(), []); - // Pi re-roots before the first later turn that left entries. yield* rollBackToFirst([turn(1, "u1"), turn(2, null), turn(3, "u3")]); assert.deepEqual(forkedEntries(), ["u3"]); }).pipe(Effect.scoped, Effect.provide(layerTest)), @@ -2717,8 +2664,6 @@ describe("PiAdapterV2", () => { }); yield* startTurn(runtime, providerThread, "default", [], "/command-only"); yield* fake.takeRequest("prompt"); - // A pure extension command: dialog + notify, then the deferred ack β€” - // pi emits no agent_start/agent_settled at all. yield* fake.emit({ type: "extension_ui_request", id: "ui-cmd", @@ -2727,8 +2672,6 @@ describe("PiAdapterV2", () => { notifyType: "info", }); yield* fake.emit({ type: "response", command: "prompt", success: true }); - // The adapter probes get_state (auto-acked idle by the fake), then - // settles the turn as completed. const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); }).pipe(Effect.scoped, Effect.provide(layerTest)), @@ -2946,7 +2889,6 @@ describe("PiAdapterV2", () => { modelSelection: modelSelection("default"), runtimePolicy, }); - // Project-trust style prompt before any turn exists. yield* fake.emit({ type: "extension_ui_request", id: "ui-trust", @@ -3020,7 +2962,6 @@ describe("PiAdapterV2", () => { decision: "accept", }); assert.equal((yield* fake.takeRequest("extension_ui_response"))["id"], "fresh-dialog"); - // The old expiry must not cancel an already answered request, and zero disables expiry. yield* fake.emit({ ...dialog, id: "zero-timeout", timeout: 0 }); const zero = yield* takeEvent( (event) => @@ -3080,7 +3021,6 @@ describe("PiAdapterV2", () => { modelSelection: modelSelection("default"), runtimePolicy, }); - // Project-trust style prompt before any turn exists. yield* fake.emit({ type: "extension_ui_request", id: "ui-trust", @@ -3323,7 +3263,6 @@ describe("PiAdapterV2", () => { Effect.gen(function* () { const fake = yield* makeFakePi; const { runtime } = yield* openRuntime(fake); - // A --no-session Pi keeps its session in memory and reports no file. fake.queueState({ sessionFile: undefined }); const result = yield* runtime .ensureThread({ @@ -3345,7 +3284,6 @@ describe("PiAdapterV2", () => { modelSelection: modelSelection("default"), runtimePolicy, }); - // An extension command can hold its prompt ack open past settlement. yield* startTurn(runtime, providerThread, "default", [], "/my-command"); yield* fake.takeRequest("prompt"); yield* fake.emit({ type: "agent_start" }); @@ -3355,8 +3293,6 @@ describe("PiAdapterV2", () => { yield* startTurn(runtime, providerThread, "default", [], "Second turn", undefined, 2); yield* fake.takeRequest("prompt"); - // The rejection answers the first turn's prompt. It must not consume or - // fail the second turn's prompt acknowledgement. yield* fake.emit({ type: "response", command: "prompt", @@ -3880,8 +3816,6 @@ describe("PiAdapterV2", () => { yield* fake.takeRequest("prompt"); yield* fake.emit({ type: "agent_start" }); - // Extension ctx.compact() waits for this first settlement, then starts - // compaction in a detached continuation. fake.queueState({ isStreaming: false, isCompacting: true, pendingMessageCount: 0 }); yield* fake.emit({ type: "agent_settled" }); yield* fake.takeRequest("get_state"); @@ -4121,8 +4055,6 @@ describe("PiRpc framing", () => { ); }); -// This fails before a provider transcript exists, so a replay fixture is not -// an honest fit. The boundary is the stdio transport seeing stdout end. describe("PiRpc early process exit", () => { const makeHandle = (options: { readonly exitCode: Effect.Effect; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 722ac13b3989..25c3984246e9 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -1,28 +1,3 @@ -/** - * PiAdapterV2 β€” orchestrator-v2 adapter for the Pi coding agent - * (https://pi.dev), driving `pi --mode rpc` over stdio JSONL via `PiRpc.ts`. - * - * Design intent: honor the user's Pi customizations. The process is spawned - * with no `--no-*` flags, so the user's extensions, skills, prompt templates, - * AGENTS.md / SYSTEM.md context, settings.json, custom models, and auth all - * load exactly as they do in the `pi` TUI. Sessions are stored by Pi itself - * (default `~/.pi/agent/sessions/`), and the session file path is the durable - * `nativeThreadRef`, so a thread started in T3 can be resumed from the TUI - * and vice versa. - * - * Turn lifecycle: `agent_settled` is the only terminal signal. `agent_end` - * merely closes one low-level run β€” compaction retries, auto-retries, and - * queued continuations may still follow it, so the turn stays open until Pi - * reports the session settled. An extension can start detached compaction as - * that signal unwinds, so the adapter confirms Pi is idle before terminalizing. - * - * Extension UI: Pi extensions raise dialogs through `extension_ui_request`. - * Dialog methods become v2 runtime requests (`confirm` β†’ approval_request, - * `select`/`input`/`editor` β†’ user_input_request); answers travel back as - * `extension_ui_response`. `notify` becomes a completed activity item. - * Terminal-only decoration such as status, widget, title, and editor-text - * updates has no matching T3 surface and is ignored. - */ import * as HostProcess from "@t3tools/shared/HostProcess"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; @@ -111,7 +86,6 @@ const PI_INHERIT_MODEL_SLUG = "default"; const STREAM_FLUSH_MS = 50; const PI_REQUEST_TIMEOUT_MS = 15_000; -// Session lifecycle hooks reload extensions, MCP servers and language servers. const PI_SESSION_TIMEOUT_MS = 60_000; const PI_UNSOLICITED_ACTIVITY_ERROR = "Pi started agent work outside an active T3 turn. The session was stopped to prevent invisible tool execution."; @@ -164,9 +138,6 @@ const PiProviderCapabilitiesV2 = { supportsDynamicToolCallbacks: false, }, approvals: { - // Pi exposes a blocking tool_call extension hook. The T3 bridge uses it - // for supervised and auto-accept modes and forwards its confirmations - // through the same extension UI protocol as user-installed extensions. supportsCommandApproval: true, supportsFileReadApproval: false, supportsFileChangeApproval: true, @@ -183,9 +154,6 @@ const PiProviderCapabilitiesV2 = { planDeltasHaveItemIds: false, }, subagents: { - // T3 delegation uses the shared MCP `delegate_task` path. Installed Pi - // subagent extensions are observed best-effort, but their official tool - // runs children with --no-session and exposes no resumable child id. supportsSubagents: true, exposesSubagentThreadIds: false, emitsSubagentLifecycle: true, @@ -199,8 +167,6 @@ const PiProviderCapabilitiesV2 = { acceptsSyntheticUserContext: true, canGenerateSummaries: false, canConsumeHandoffSummaries: true, - // T3 delivers both full and delta handoffs through Pi's normal user-message - // input, so neither strategy depends on a Pi-specific context hook. supportsDeltaHandoff: true, supportsFullThreadHandoff: true, maxRecommendedHandoffChars: null, @@ -226,10 +192,6 @@ export interface PiAdapterV2Options { readonly instanceId: ProviderInstanceId; readonly settings: PiSettings; readonly environment: NodeJS.ProcessEnv; - /** - * Where to offer a wake turn for provider-initiated work. Without one, such - * work has no owner and the adapter stops it. - */ readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -237,7 +199,6 @@ export interface PiAdapterV2Options { }; } -/** Concatenate the `text` fields of a Pi content-block array. */ function contentText(content: unknown): string { if (!Array.isArray(content)) { return typeof content === "string" ? content : ""; @@ -252,7 +213,6 @@ function contentText(content: unknown): string { const MAX_STRUCTURED_TOOL_OUTPUT_BYTES = 64 * 1024; -/** Preserve complete structured JSON only when its size and traversal remain bounded. */ function isBoundedStructuredToolValue(value: unknown): boolean { let bytes = MAX_STRUCTURED_TOOL_OUTPUT_BYTES; let nodes = 4_096; @@ -279,7 +239,6 @@ function isBoundedStructuredToolValue(value: unknown): boolean { return visit(value, 0); } -/** Keep model content and distinct typed values; server ingestion owns image byte policy. */ function piToolOutput(result: unknown, toolName: string): unknown { const rawContent = recordField(result, "content"); const content = Array.isArray(rawContent) @@ -288,8 +247,6 @@ function piToolOutput(result: unknown, toolName: string): unknown { ? [{ type: "text", text: rawContent }] : []; let structured = recordField(result, "structuredContent"); - // Pi MCP tools return the full CallToolResult to scripts. The model content - // is already captured, so store only its distinct structured payload. const details = recordField(result, "details"); const isMcpResult = toolName.startsWith("mcp__") && @@ -357,8 +314,6 @@ function providerRef( const PI_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); -// ── per-session state ───────────────────────────────────────── - interface PiStreamItemState { readonly nativeItemId: string; readonly kind: "assistant_message" | "reasoning"; @@ -401,46 +356,26 @@ interface ActivePiTurn { readonly startedAt: DateTime.Utc; readonly itemOrdinals: Map; nextItemOrdinal: number; - /** Increments on assistant `message_start` so content indexes stay unique. */ messageOrdinal: number; readonly streamItems: Map; readonly toolArgs: Map; - /** - * First-seen time per `toolCallId`. Later update/end events reuse it so a - * tool keeps one start timestamp and reports a real duration. - */ readonly toolStartedAt: Map; interrupted: boolean; - /** Cancellation of the latest native run; new work can recover before settlement. */ nativeAborted: boolean; - /** - * Whether any agent run activity was observed. Command-only prompts (pure - * extension slash commands) never start an agent run and never emit - * `agent_settled`; their deferred prompt ack plus an idle probe settles - * the turn instead. - */ sawAgentActivity: boolean; - /** A user prompt joined provider-native work whose settlement may already be queued. */ adoptedWake: boolean; - /** Only slash-command prompts can complete without starting an agent run. */ readonly promptMayBeCommandOnly: boolean; - /** Pi reports context as unknown immediately after compaction; keep its estimate for the meter. */ latestCompactionAfterTokens: number | null; - /** Last streamed usage total already emitted on the running turn. */ lastLiveUsedTokens: number | null; - /** Invalidates idle snapshots when new work starts after a settle probe. */ settleProbeGeneration: number; /** An extension may start compaction immediately after Pi emits agent_settled. */ settleWhenIdle: boolean; sawCompaction: boolean; - /** RPC compact is in flight; Pi abort does not cancel it. */ manualCompactInFlight: boolean; activeCompaction: PiCompactionState | null; activeProviderRetry: PiProviderRetryState | null; failure: ReturnType | null; - /** Native retry can recover its work without accepting a rejected joining prompt. */ rejectedPromptFailure: ReturnType | null; - /** Session-tree refs read just before Stop terminates Pi, when no read is possible later. */ stopTreeRefs?: PiTurnTreeRefs | null; } @@ -448,7 +383,6 @@ interface PiTurnTreeRefs { /** Omitted keeps the synthetic ref; null means the turn left nothing on the active branch. */ readonly nativeTurnRef: OrchestrationV2ProviderRef | null | undefined; readonly leafId: string | null; - /** Set after a native rewind: every user entry still on the active branch. */ readonly retainedNativeTurnIds: ReadonlyArray | undefined; } @@ -464,11 +398,6 @@ interface PendingPiPrompt { readonly turnItem: OrchestrationV2TurnItem; } -/** - * The T3 bridge confirms tool calls as `Allow ?`. Edits surface as - * file-change approvals so clients render them like other providers' edits; - * every other confirmation, including ones from user extensions, is a command. - */ function piApprovalRequestKind(title: string): "command" | "file-change" { const toolName = /^Allow (\S+)\?$/.exec(title)?.[1]; return toolName !== undefined && @@ -487,8 +416,6 @@ interface PiWake { readonly events: Array; } -// ── adapter ─────────────────────────────────────────────────── - export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( options: PiAdapterV2Options, ) { @@ -597,12 +524,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( let threadState: PiThreadState | null = null; let registrationAttempted = false; let lastNativeThreadId: string | null = null; - // User Stop intentionally tears down this RPC process after aborting. - // Keep that intent beyond turn finalization so the later stdout close is - // not mistaken for an unexpected transport failure. let stopRequested = false; - // Native work is held until an orchestrator-owned continuation or user - // turn attaches. The guard remains for sessions with no routable owner. let unsolicitedActivityDetected = false; let pendingWake: PiWake | null = null; let rollbackBarrier: Deferred.Deferred< @@ -623,15 +545,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ); let appliedModel: string | null = null; let appliedThinking: string | null = null; - /** Last thread title synced into pi's session name (`/resume` listing). */ let appliedSessionName: string | null = null; - /** Extension failures raised during startup are attached to the next turn. */ const outOfTurnExtensionErrors: Array = []; - /** - * Leaf entry id of the pi session tree as of the last turn boundary. - * Turn-start user entries are located relative to it, giving each - * provider turn a durable native ref for session-tree rollback. - */ let lastKnownLeaf: string | null = null; /** * Set when a `get_entries` capture failed. Pi may have advanced past @@ -645,7 +560,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // explicitly. let baselineModel: { provider: string; modelId: string } | null = null; let baselineThinking: string | null = null; - /** Context window of the model Pi currently runs, from get_state and set_model. */ let contextWindow: number | null = null; const modelContextWindows = new Map(); let modelsDiscovered = false; @@ -769,13 +683,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }; }; - /** - * Pi only reports context usage through `get_session_stats`, so the - * settled turn carries it on the base's per-turn `tokenUsage` (#8144). - * Usage is secondary telemetry: the request is bounded and a provider - * version without stats simply leaves the turn without a report, which - * keeps the meter on the last turn that had one. - */ const readTokenUsage = (fallbackUsedTokens: number | null, updatedAt: DateTime.Utc) => request({ type: "get_session_stats" }, 2_000).pipe( Effect.map((stats) => tokenUsageFromStats(stats, fallbackUsedTokens, updatedAt)), @@ -783,9 +690,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ); /** - * Pi attaches the current message's cumulative usage to every streaming - * update (0.84.2+). Emit it on the running turn only when the total - * changes, so the meter moves live without a burst of no-op updates. + * Pi attaches the current message's cumulative usage to every streaming update (0.84.2+). */ const reportLiveUsage = (turn: ActivePiTurn, usage: unknown) => Effect.gen(function* () { @@ -944,8 +849,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); }); - // ── streaming text / reasoning ──────────────────────── - const emitStreamItem = (turn: ActivePiTurn, item: PiStreamItemState, streaming: boolean) => Effect.gen(function* () { const emittedAt = yield* DateTime.now; @@ -1048,7 +951,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( startedAt, }; turn.streamItems.set(nativeItemId, item); - // Ordinal reserved on first delta so items appear in stream order. itemOrdinal(turn, nativeItemId); return item; }); @@ -1068,8 +970,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( { discard: true }, ); - // ── tools ───────────────────────────────────────────── - const emitToolItem = Effect.fnUntraced(function* ( turn: ActivePiTurn, event: PiRpcRecord, @@ -1089,8 +989,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const isError = event["isError"] === true; const resultRecord = completed ? event["result"] : event["partialResult"]; const outputText = contentText(recordField(resultRecord, "content")); - // A Stop aborts in-flight tools, and pi reports those as error ends. - // Present them as interrupted (matching the run) rather than failed. const status = completed ? isError ? turn.interrupted @@ -1133,8 +1031,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (toolName === "edit" || toolName === "write") { const fileName = recordString(args, "path") ?? recordString(args, "file_path"); if (fileName !== undefined) { - // edit reports a unified patch in its result details; write only - // carries the new content in its args. A failed call keeps its error. const diffStr = recordString(recordField(resultRecord, "details"), "patch") ?? (isError && outputText.trim().length > 0 ? outputText : undefined); @@ -1173,12 +1069,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } }); - /** - * Observe the result shape from Pi's official example subagent extension. - * The extension runs children with --no-session, so these entries are - * visible in T3's shared subagent UI without inventing a child thread. - * Unknown or changed result shapes stay ordinary dynamic tool output. - */ const emitSubagentTasks = Effect.fnUntraced(function* ( turn: ActivePiTurn, toolCallId: string, @@ -1269,8 +1159,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } }); - // ── extension UI prompts ────────────────────────────── - const cancelPrompt = (pending: PendingPiPrompt, resolvedAt: DateTime.Utc) => Effect.gen(function* () { if (pending.expirationFiber !== null) yield* Fiber.interrupt(pending.expirationFiber); @@ -1354,7 +1242,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( method !== "input" && method !== "editor" ) { - // Terminal decoration has no matching T3 surface. yield* Effect.logDebug("Ignoring pi extension UI update.", { method }); return; } @@ -1426,8 +1313,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( providerTurnId, nativeItemRef: providerRef(nativeRequestId), parentItemId: null, - // Runless startup/session-switch requests are normalized into the - // thread-level ordinal range by TurnItemPositionStore. ordinal: turn === null ? 0 : itemOrdinal(turn, nativeRequestId), status: "waiting" as const, title, @@ -1526,9 +1411,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); }); - // ── turn lifecycle ──────────────────────────────────── - - /** One `get_entries` listing, or null when the request failed or named no leaf. */ const listSessionEntries = (since: string | null, timeoutMs: number) => request({ type: "get_entries", ...(since === null ? {} : { since }) }, timeoutMs).pipe( Effect.map((data) => { @@ -1542,16 +1424,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ); /** - * Locate this turn's first user entry and the new leaf in pi's session - * tree. The user-entry id becomes the provider turn's native ref (the - * point `fork` rolls back to); the leaf becomes the conversation head. - * `get_entries` is append-ordered and keeps abandoned branches, so both - * come from walking the leaf's `parentId` chain, never from list order. - * A leaf that no longer descends from the previous one means something - * outside T3 rewound the session, such as an extension calling - * `navigateTree`. The user entries left on the active branch are then - * returned so orchestration can roll back runs that fell off it. - * Pure bookkeeping: failures degrade to the synthetic refs. + * `get_entries` is append-ordered and keeps abandoned branches, so both come from walking + * the leaf's `parentId` chain, never from list order. */ const captureTurnTreeRefs = Effect.fnUntraced(function* ( timeoutMs = PI_REQUEST_TIMEOUT_MS, @@ -1567,9 +1441,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // continues past it, so its ancestry needs the whole tree. const tree = rewound && !fullListing ? yield* listSessionEntries(null, timeoutMs) : listing; if (listing === null || turnWalk === null || tree === null) { - // The read failed or its ancestry loops. Pi may have advanced past - // `lastKnownLeaf` meanwhile, so the cursor can no longer be trusted - // to bound a single turn. leafCursorStale = true; return null; } @@ -1585,12 +1456,9 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ? walkPiBranch(tree.entries, listing.leafId, null) : undefined; return { - // Without a trustworthy cursor the walk spans more than one turn, so - // its earliest user entry belongs to an earlier turn. Keep the - // synthetic ref rather than pointing rollback too far back; the next - // turn gets an accurate ref again. A turn that left nothing on the - // active branch (a command, or a rewind) gets no ref: a rollback - // past it needs no fork of its own. + // Without a trustworthy cursor the walk spans more than one turn, so its earliest user + // entry belongs to an earlier turn. Keep the synthetic ref rather than pointing + // rollback too far back; the next turn gets an accurate ref again. nativeTurnRef: cursorWasStale ? undefined : turnStartEntryId !== undefined @@ -1716,8 +1584,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } }); - // ── event pump ──────────────────────────────────────── - const scheduleSettleProbe = ( turn: ActivePiTurn, settleAfterAgentActivity = false, @@ -1992,7 +1858,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (turn === null) return; const emittedAt = yield* DateTime.now; if (event["success"] === true) { - // The retry recovered. Pi emits the erroring `message_end` // before retrying, so leaving that failure in place would make // `agent_settled` terminalize a successful turn as failed. if (turn.activeProviderRetry !== null) { @@ -2052,8 +1917,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( turn.nativeAborted = event["aborted"] === true; turn.settleWhenIdle = true; turn.settleProbeGeneration += 1; - // A wake can settle while the newly joined user prompt is still - // expanding. Its acknowledgement opens the idle-probe barrier. if ( turn.adoptedWake && pendingPromptResponses.some( @@ -2068,8 +1931,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return; } case "response": { - // Correlated responses never reach the pump; an id-less response - // is the deferred ack of a fire-and-forget prompt/steer/compact. const command = recordString(event, "command"); if (command === "compact") { const pendingCompact = pendingCompactResponses.shift(); @@ -2171,9 +2032,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return; } case "t3.flush_extension_errors": { - // Startup extension failures are informational and do not block - // Pi, so attach them to the next real turn instead of creating a - // standalone failed run. for (const extensionError of outOfTurnExtensionErrors.splice(0)) { yield* emitExtensionError(extensionError); } @@ -2185,8 +2043,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return; } case "t3.settle_probe": { - // New work increments the generation before the pump can consume - // a stale idle snapshot, so only a current snapshot may settle. const data = event["data"]; const probeFailed = event["probeFailed"] === true; const settleAfterAgentActivity = event["settleAfterAgentActivity"] === true; @@ -2240,9 +2096,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( Effect.catchCause((cause) => sessionEventPermit.withPermits(1)( Effect.gen(function* () { - // Transport death finalizes any live turn. Stop-with-restart - // closes the provider stream cleanly; only an unexpected death - // is surfaced as an event-stream failure. const state = threadState; if (rollbackBarrier !== null) yield* Deferred.fail( @@ -2289,8 +2142,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( Effect.forkIn(scope), ); - // ── session runtime ─────────────────────────────────── - const registerThread = Effect.fnUntraced(function* ( threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, publish = true, @@ -2407,15 +2258,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( updatedAt: createdAt, }; threadState = { providerThread, activeTurn: null }; - // Baseline the session-tree leaf so the first turn's user entry can - // be located with a `since` cursor instead of a full entry scan. const baselineEntries = yield* request({ type: "get_entries" }).pipe( Effect.orElseSucceed(() => undefined), ); lastKnownLeaf = recordString(baselineEntries, "leafId") ?? null; - // A successful full baseline makes the cursor trustworthy again. Only - // a failed one leaves it stale, so a recovered session does not keep - // skipping turn refs. An empty tree is a success with no leafId. leafCursorStale = baselineEntries === undefined; if (publish) yield* emit({ @@ -2429,8 +2275,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { - // Returning to "Pi default" after an explicit pick has to replay the - // captured baseline, otherwise Pi stays on the last model applied. if (appliedModel !== null && baselineModel !== null) { const restoredModel = yield* request({ type: "set_model", ...baselineModel }); contextWindow = rememberModelContextWindow(restoredModel); @@ -2443,9 +2287,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( providerSession: sessionEntity, }); } - // The "Pi default" model advertises no thinking choices of its own, - // so an unqualified return also restores Pi's configured level - // instead of silently keeping the effort a previous pick applied. if ( thinking === undefined && appliedThinking !== null && @@ -2563,8 +2404,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }), ), ), - // The run executor routes a bare `/compact` here instead of startTurn. - // Pi's start path already turns that text into the RPC compact call. compactThread: (turnInput) => runtime.startTurn({ ...turnInput, @@ -2595,9 +2434,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; if (!continuation) yield* applySelection(turnInput.modelSelection); - // Mirror the thread title into pi's session name so the session - // stays identifiable in pi's own /resume listing. Best-effort: - // naming must never block a turn. if (turnInput.appThread.title !== appliedSessionName) { yield* request({ type: "set_session_name", @@ -2697,8 +2533,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( kind: "turn_start", }); } else if (payload !== null) { - // Pi may already be streaming before its wake reaches T3. - // Let Pi atomically queue the prompt or start an idle run. yield* connection.send({ type: "prompt", message: payload.message, @@ -2732,7 +2566,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* handleSessionEvent(event); } } else if (continuation) { - // A user turn, teardown, or newer binding already took the wake. yield* finalizeTurn(state); } if (outOfTurnExtensionErrors.length > 0) { @@ -2824,8 +2657,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( interruptTurn: (interruptInput) => Effect.gen(function* () { const turn = threadState?.activeTurn ?? null; - // Stop on a settled turn: Pi runs nothing between prompts, so - // nothing of that turn is left to stop. if (turn === null && interruptInput.requestRuntimeRestart === true) { if (pendingWake !== null) yield* sessionEventPermit.withPermits(1)(stopPendingWake(pendingWake)); @@ -2900,8 +2731,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( id: pending.nativeRequestId, ...response, }); - // Dropped only once Pi has the answer, so a failed send leaves the - // request retryable and still cancellable during teardown. pendingPrompts.delete(String(requestInput.requestId)); if (pending.expirationFiber !== null) yield* Fiber.interrupt(pending.expirationFiber); if (pending.method === "confirm" && requestInput.decision === "acceptForSession") { @@ -2976,9 +2805,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( { id: idAllocator.derive.messageFromProviderItem({ driver: PI_PROVIDER, - // RPC messages do not expose session-tree entry ids. The - // active-branch index is stable for the lifetime of this - // snapshot and keeps abandoned branch ids out of it. nativeItemId: `${wantedNativeId}:snapshot-message:${index}`, }), threadId, @@ -3029,12 +2855,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (pendingWake !== null) { return yield* protocolError("Cannot roll back while native Pi work awaits a turn"); } - // `fork(entryId)` re-roots the active branch before that user - // message, so the rollback boundary is the first user entry of - // the earliest turn being discarded. const forkEntryId = piRollbackForkEntry(rollbackInput); if (forkEntryId === null) { - // Nothing after the target: the conversation is already there. return piThreadSnapshot(state.providerThread); } if (forkEntryId === undefined) { @@ -3165,9 +2987,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (beforeEntry === undefined) { return yield* protocolError("Pi fork boundary has no captured session-tree entry"); } - // CLI --fork uses Pi's own session format and sets the destination - // cwd. RPC switch_session/clone alone would retain the source cwd. - // This short-lived process never prompts or runs user extensions. + // CLI --fork uses Pi's own session format and sets the destination cwd. RPC + // switch_session/clone alone would retain the source cwd. const forkLaunch = buildPiRpcLaunch({ launchArgs: resolvedLaunchArgs.args, environment: options.environment, @@ -3261,7 +3082,6 @@ interface PiSessionEntry { readonly isUserMessage: boolean; } -/** Index a `get_entries` listing, rejecting ambiguous ancestry. */ function piSessionEntries(entries: unknown): ReadonlyMap | null { const byId = new Map(); if (!Array.isArray(entries)) return null; @@ -3280,14 +3100,6 @@ function piSessionEntries(entries: unknown): ReadonlyMap return byId; } -/** - * Walk pi's active branch from `leafId` towards the root through `entries`, - * stopping at `stopAt`. Returns how many entries it passed, the user entries - * among them (newest first), and whether the walk reached `stopAt`. Leaving a - * partial listing anywhere else means the branch does not descend from `stopAt`. - * Returns null when the `parentId` chain loops, which only corrupted session - * data can produce; callers treat that like a failed listing. - */ function walkPiBranch( entries: ReadonlyMap, leafId: string | null, @@ -3312,12 +3124,10 @@ function walkPiBranch( } /** - * Resolve the pi session-tree entry `fork` should re-root at for a rollback. - * Turns without a ref left nothing on the active branch, so the boundary is - * the first discarded turn that has one. Returns `null` when no such turn - * follows the target (nothing to discard) and `undefined` when the boundary - * turn has no captured entry ref (only turn-boundary refs recorded by - * `captureTurnTreeRefs` are strong). + * Turns without a ref left nothing on the active branch, so the boundary is the first discarded + * turn that has one. Returns `null` when no such turn follows the target (nothing to discard) and + * `undefined` when the boundary turn has no captured entry ref (only turn-boundary refs recorded + * by `captureTurnTreeRefs` are strong). */ function piRollbackForkEntry(input: { readonly target: @@ -3335,10 +3145,6 @@ function piRollbackForkEntry(input: { return ref.nativeId; } -/** - * Human-readable output for one subagent-extension task result: the last - * assistant text from its transcript, or the error/stderr when it failed. - */ function piSubagentOutput(result: unknown): string { const stopReason = recordString(result, "stopReason"); const failed = @@ -3346,8 +3152,6 @@ function piSubagentOutput(result: unknown): string { stopReason === "error" || stopReason === "aborted"; if (failed) { - // Falsy fallback, not `??`: an empty `errorMessage` must not suppress a - // non-empty `stderr`, which is often the only description of the failure. const failure = recordString(result, "errorMessage") || recordString(result, "stderr"); if (failure !== undefined && failure.length > 0) return failure; } @@ -3422,8 +3226,6 @@ function piUiResponse( return { cancelled: true }; } -// ── driver ──────────────────────────────────────────────────── - export type PiAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem diff --git a/packages/provider-pi/src/server/commands.ts b/packages/provider-pi/src/server/commands.ts index 07b992dd7c27..2013422f0d5e 100644 --- a/packages/provider-pi/src/server/commands.ts +++ b/packages/provider-pi/src/server/commands.ts @@ -42,7 +42,6 @@ function normalizePiSkillScope(scope: string | undefined): string | undefined { return scope; } -/** Maps Pi's `get_commands` payload to T3's shared command and skill surfaces. */ export function parsePiDiscoveredCommands(data: unknown): PiDiscoveredCommands { const commands = recordField(data, "commands"); if (!Array.isArray(commands)) return { slashCommands: [], skills: [] }; diff --git a/packages/provider-pi/src/server/driver.test.ts b/packages/provider-pi/src/server/driver.test.ts index 84e6d86b0ddf..2b09515be880 100644 --- a/packages/provider-pi/src/server/driver.test.ts +++ b/packages/provider-pi/src/server/driver.test.ts @@ -46,7 +46,6 @@ const personalSkill = { sourceInfo: { scope: "user", path: "/home/.pi/agent/skills/personal/SKILL.md" }, }; -// Respond through the real stdio transport, with a distinct command catalog for each cwd. const makePiSpawner = Effect.fnUntraced(function* ( discovery: { readonly state?: unknown; readonly models?: ReadonlyArray } = {}, ) { diff --git a/packages/provider-pi/src/server/driver.ts b/packages/provider-pi/src/server/driver.ts index e36909fa3990..eebdf7d864d6 100644 --- a/packages/provider-pi/src/server/driver.ts +++ b/packages/provider-pi/src/server/driver.ts @@ -1,8 +1,4 @@ /** - * PiDriver β€” v1 `ProviderDriver` for the Pi coding agent, composing the - * orchestrator-v2 adapter (`PiAdapterV2`), the snapshot/probe layer - * (`PiProvider`), and Pi-backed text generation. - * * Pi state (sessions, settings, extensions, auth) lives in the user's own * `~/.pi/agent`, so continuation identity uses the default instance grouping. */ @@ -52,7 +48,6 @@ const DRIVER_KIND = ProviderDriverKind.make("pi"); const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@earendil-works/pi-coding-agent", - // Pi's updater covers its own installer and npm, pnpm, yarn, and bun globals. nativeUpdate: { args: ["update", "--self"] }, }); diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index 8151878afafb..1205c7573ea4 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -27,7 +27,6 @@ type InputHook = ( async function loadHooks(commands: ReadonlyArray = []) { const handlers = new Map(); - // Execute the shipped extension with MCP disabled; this path needs no Typebox. const source = NodeModule.stripTypeScriptTypes( PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( "export default async function", @@ -38,7 +37,6 @@ async function loadHooks(commands: ReadonlyArray = []) { process: { env: {} }, NodeFSP, NodePath, - // Frontmatter parsing belongs to Pi. These fixtures contain only skill bodies. stripFrontmatter: (content: string) => content, pi: { on: (name: string, handler: unknown) => handlers.set(name, handler), @@ -314,7 +312,6 @@ describe("Pi tool discovery permissions", () => { (await hook({ toolName: prefix + "delegate_task", input: {} }, ctx))?.block, true, ); - // task_status acknowledges result delivery, and is intentionally not read-only. assert.equal( (await hook({ toolName: prefix + "task_status", input: {} }, ctx))?.block, true, @@ -387,7 +384,6 @@ describe("Pi tool discovery permissions", () => { assert.equal(result?.block, true); assert.deepEqual(confirmations, ["Allow mcp__t3-code__preview_snapshot?"]); - // An extension that replaces the search builtin is not known to be read-only. searchPath = "/extensions/custom-search.ts"; const replaced = await toolCall!({ toolName: "tool_search", input: {} }, ctx); assert.equal(replaced?.block, true); @@ -397,7 +393,6 @@ describe("Pi tool discovery permissions", () => { async function loadRequestHook(): Promise { const handlers = new Map(); - // Execute the shipped extension with MCP disabled; this path needs no Typebox. const source = NodeModule.stripTypeScriptTypes( PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( "export default async function", @@ -482,7 +477,6 @@ describe("Pi skill references", () => { const hook = await loadInputHook([ { name: "skill:alpha", source: "skill", sourceInfo: { path: "/unused" } }, ]); - // Punctuation-adjacent references are plain text in the composer, not selected chips. for (const text of [ "Explain $HOME", "/skill:alpha use $alpha", diff --git a/packages/provider-pi/src/server/mcpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts index 5ee4342e5bb9..5e473c700d0c 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.ts @@ -1,11 +1,4 @@ /** - * Source for the T3-owned Pi extension that consumes T3's HTTP MCP server. - * - * Pi 0.99+ discovers optional HTTP bridge tools on demand. OpenAI completion - * gateways need their schemas loaded up front. Older versions keep tools - * directly available. Pi loads this TypeScript via `--extension`; the - * server writes it to a cache so packaged builds need no sibling .ts file. - * * Do not import t3code modules from the string body. The Pi process resolves * `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install. */ diff --git a/packages/provider-pi/src/server/mcpInjection.ts b/packages/provider-pi/src/server/mcpInjection.ts index 260b37642e91..ccb9eecfde78 100644 --- a/packages/provider-pi/src/server/mcpInjection.ts +++ b/packages/provider-pi/src/server/mcpInjection.ts @@ -100,11 +100,6 @@ function normalizePiBuiltInEqualsArguments(args: ReadonlyArray): Readonl }); } -/** - * Pi launch arguments may configure resources, models, tools, trust, and - * storage. T3 owns RPC mode and session identity, so arguments that select a - * different execution mode or native session are rejected before spawn. - */ export function resolvePiLaunchArgs(launchArgs: string): PiLaunchArgsResolution { // Pi parses equals-form tokens only as extension flags, even when their name // matches a built-in option. Split known built-ins while leaving arbitrary @@ -161,9 +156,6 @@ export function resolvePiLaunchArgs(launchArgs: string): PiLaunchArgsResolution message: `Pi launch arguments cannot include positional prompt '${arg}'.`, }; } - // Pi 1.0 exits at startup on `--provider` without `--model`; older versions - // silently ran another provider's default model. Rejecting it here makes - // every spawn site report the reason instead of a bare exit code. if (hasProvider && !hasModel) { return { ok: false, message: "Pi launch argument '--provider' requires '--model'." }; } diff --git a/packages/provider-pi/src/server/rpc.ts b/packages/provider-pi/src/server/rpc.ts index 3f67291330ae..aabce9e8ca34 100644 --- a/packages/provider-pi/src/server/rpc.ts +++ b/packages/provider-pi/src/server/rpc.ts @@ -1,20 +1,7 @@ /** - * PiRpc β€” stdio JSONL transport for the Pi coding agent's RPC mode. - * - * Spawns `pi --mode rpc` and speaks Pi's line-delimited JSON protocol: - * requests go to stdin as `{"type": "...", "id": "..."}` records, responses - * come back as `{"type": "response", "id": ..., "success": ...}` and are - * correlated by `id`; every other stdout record is a session event and is - * surfaced on the `events` queue in arrival order. - * - * Framing follows Pi's spec: LF-delimited only, with a trailing `\r` - * stripped. Lines are split manually (never with `readline`, which also - * splits on U+2028/U+2029 and would corrupt frames). Records that fail to - * parse as JSON are dropped with a debug log rather than failing the - * transport, so a chatty extension cannot take the session down. - * - * Used by `PiAdapterV2` for sessions and by `PiTextGeneration` / - * `PiProvider` for ephemeral one-shot processes. + * Framing follows Pi's spec: LF-delimited only, with a trailing `\r` stripped. + * Lines are split manually (never with `readline`, which also splits on + * U+2028/U+2029 and would corrupt frames). */ import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; @@ -75,11 +62,6 @@ export function piRecordNumber(input: unknown, key: string): number | undefined return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; } -/** - * Splits a `provider/model` slug into the two fields `set_model` expects. - * Returns null for slugs without a usable separator so callers can reject the - * selection instead of silently leaving Pi on its configured default. - */ export function parsePiModelSlug(slug: string): { provider: string; modelId: string } | null { const separator = slug.indexOf("/"); if (separator <= 0 || separator === slug.length - 1) return null; @@ -94,31 +76,13 @@ export interface PiRpcSpawnOptions { } export interface PiRpcConnection { - /** Fire-and-forget write (used for `extension_ui_response`). */ readonly send: (record: PiRpcRecord) => Effect.Effect; - /** - * Correlated request: assigns an `id`, waits for the matching response - * record, and returns its `data` (undefined when the command carries none). - * Fails on `success: false`, transport death, or timeout. - */ readonly request: ( record: PiRpcRecord, timeoutMs?: number, ) => Effect.Effect; - /** - * Session events (every non-response stdout record) in arrival order. The - * full queue is exposed so consumers can append order-preserving synthetic - * records of their own (see PiAdapterV2's settle probe). - */ readonly events: Queue.Queue; - /** Resolves when the process has exited, with its exit code. */ readonly exited: Effect.Effect; - /** - * Kill the pi process group immediately (SIGTERM, grace, SIGKILL). Used by - * Stop-with-restart when the process may be wedged and `abort` cannot be - * trusted to land. The transport fails and the session manager respawns a - * fresh process on the next turn. - */ readonly terminate: Effect.Effect; } @@ -170,11 +134,6 @@ const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); const PI_ERROR_DETAIL_MAX_CHARS = 200; -/** - * Bounded, human-readable summary of a failed response's `error` payload. - * The untruncated value stays on the error's `cause`, so `message` never - * carries unbounded remote text while logs keep something diagnostic. - */ function summarizePiError(error: unknown): string { const text = typeof error === "string" ? error : JSON.stringify(error); if (text === undefined) return "unknown error"; @@ -184,10 +143,8 @@ function summarizePiError(error: unknown): string { } /** - * Stdout closure is not enough to diagnose an early crash: Pi 0.84+ can exit - * 1 on import before writing any protocol line. The numeric exit code is - * sanitized; stderr stays out of public details because it can carry - * credentials or prompt text. + * Stdout closure is not enough to diagnose an early crash: Pi 0.84+ can exit 1 + * on import before writing any protocol line. */ function describePiStdoutClosure(exitCode: number | undefined): string { if (exitCode === undefined || !Number.isFinite(exitCode)) { @@ -206,8 +163,6 @@ function parsePiRecord(line: string): PiRpcRecord | undefined { } /** - * Kill the pi process group: SIGTERM, short grace, then SIGKILL. - * * `hasExited` is consulted before each signal. Once the original child is * gone its pid/pgid can be recycled by the OS, so escalating blindly could * deliver SIGKILL to an unrelated process. @@ -257,7 +212,6 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp } }; - /** Signal 0 probes liveness without delivering anything. */ const hasExited = (): boolean => { if (childExited) return true; try { @@ -305,8 +259,6 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp pendingRequests.delete(key); yield* Deferred.fail(pending.deferred, error); } - // Closing `outgoing` is what makes `send` non-racy: once the writer is - // gone every later offer is refused rather than silently buffered. yield* Queue.fail(outgoing, error); yield* Queue.fail(events, error); }); @@ -350,7 +302,6 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp Effect.forkIn(scope), ); - // Reader: decode stdout into LF-delimited JSON records. yield* Effect.gen(function* () { const frame = makeJsonlFramer(); yield* child.stdout.pipe( @@ -410,7 +361,6 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp Effect.forkIn(scope), ); - // Surface stderr as debug logs; pi reserves stdout for the protocol. yield* child.stderr.pipe( Stream.decodeText(), Stream.runForEach((chunk) => @@ -442,9 +392,6 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp outgoing, new TextEncoder().encode(`${encodeJsonLine(record)}\n`), ); - // A refused offer means `failTransport` already closed the queue, so the - // write can never land; surface the transport error instead of - // reporting a success the caller cannot rely on. if (!accepted) { return yield* Deferred.await(transportDown); } @@ -461,10 +408,6 @@ export const makePiRpcConnection = Effect.fnUntraced(function* (options: PiRpcSp yield* send({ ...record, id }).pipe( Effect.tapError(() => Effect.sync(() => pendingRequests.delete(id))), ); - // Raced against the transport: a death that lands after this request was - // registered (or between `send`'s check and its enqueue) would otherwise - // leave the caller waiting out the full timeout for a reply that is - // never coming. return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( Effect.timeoutOrElse({ duration: Duration.millis(timeoutMs), diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 03240655ba58..ebfacf576049 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -1,13 +1,3 @@ -/** - * PiProvider β€” snapshot/probe layer for the Pi coding agent. - * - * Health is probed with `pi --version`. Models, the user's default model, and - * the user's commands (extension slash commands, prompt templates, skills) - * are discovered through a short-lived ephemeral RPC session - * (`pi --mode rpc --no-session`), so everything the user configured in - * `~/.pi/agent` β€” custom providers, models.json entries, extensions, skills β€” - * shows up in T3 without any hardcoded catalog. - */ import { type CustomModelSetting, type ServerProvider, @@ -62,8 +52,6 @@ const PI_PRESENTATION = { displayName: "Pi", showInteractionModeToggle: false, supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], - // The adapter reports context usage from Pi's streaming usage while a - // turn runs, so clients can reserve the meter before the first settle. reportsContextWindow: true, requiresNewThreadForModelChange: false, } as const; @@ -72,12 +60,10 @@ const VERSION_PROBE_TIMEOUT_MS = 4_000; const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; /** * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. - * Version 0.80.5 was the first published package containing both hooks. T3 - * needs them for rollback boundaries and reliable turn terminalization. + * Version 0.80.5 was the first published package containing both hooks. */ export const MINIMUM_PI_VERSION = "0.80.5"; -/** Deferring to the user's own settings.json default model. */ const PI_DEFAULT_MODEL: ServerProviderModel = { slug: "default", name: "Pi default", @@ -182,7 +168,6 @@ const discoverPiViaRpc = ( } satisfies PiDiscovery; }).pipe(Effect.scoped); -/** Probe the full command catalog Pi exposes in a workspace without changing machine health. */ export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( function* (piSettings: PiSettings, environment: NodeJS.ProcessEnv, cwd: string) { const launchArgs = resolvePiLaunchArgs(piSettings.launchArgs); diff --git a/packages/provider-pi/src/server/textGeneration.test.ts b/packages/provider-pi/src/server/textGeneration.test.ts index d7b5630d6388..30e178d71c53 100644 --- a/packages/provider-pi/src/server/textGeneration.test.ts +++ b/packages/provider-pi/src/server/textGeneration.test.ts @@ -14,10 +14,6 @@ import { makePiTextGeneration } from "./textGeneration.ts"; const decodeJsonLine = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); -/** - * In-process `pi --mode rpc` that answers one prompt with `reply`. Every - * record written to its stdin lands in `received`. - */ const makeFakePi = (reply: string) => Effect.gen(function* () { const stdout = yield* Queue.unbounded(); diff --git a/packages/provider-pi/src/server/textGeneration.ts b/packages/provider-pi/src/server/textGeneration.ts index d844a040f3ef..d36770a40d7d 100644 --- a/packages/provider-pi/src/server/textGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -1,9 +1,3 @@ -/** - * PiTextGeneration β€” commit messages, PR content, branch names, and thread - * titles generated through an ephemeral `pi --mode rpc --no-session` process. - * No session file is written; the user's Pi configuration (default model, - * auth, custom providers) still applies. - */ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -43,16 +37,12 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* mcpSession: undefined, extensionPath: undefined, ephemeral: true, - // No user is present to answer a text-generation extension dialog. disableExtensions: true, // Background naming/content helpers must never mutate the workspace. disableTools: true, }); const connection = yield* makePiRpcConnection({ command: piSettings.binaryPath || "pi", - // Extensions and tools are disabled because no user is present to - // answer a dialog and background text generation is read-only. User - // model config and auth still apply. args: launch.args, cwd, env: launch.env, diff --git a/packages/provider-pi/src/server/thinkingCapabilities.ts b/packages/provider-pi/src/server/thinkingCapabilities.ts index 0df30c7fe99c..3ae1f2110981 100644 --- a/packages/provider-pi/src/server/thinkingCapabilities.ts +++ b/packages/provider-pi/src/server/thinkingCapabilities.ts @@ -48,7 +48,6 @@ export function thinkingCapabilitiesForPiModel( }); } -/** Mirrors `@earendil-works/pi-ai` `clampThinkingLevel`. */ function clampPiThinkingLevel( input: unknown, availableLevels: ReadonlyArray, @@ -69,12 +68,6 @@ function clampPiThinkingLevel( return availableLevels[0]; } -/** - * Mirror of `@earendil-works/pi-ai` `getSupportedThinkingLevels`. - * - * A reasoning model always exposes off through high unless a map entry is - * `null`. Extra High and Max appear only when the map has a non-null entry. - */ function supportedPiThinkingLevelsFromModel(model: unknown): ReadonlyArray { if (recordField(model, "reasoning") !== true) return []; const thinkingLevelMap = thinkingLevelMapFromModel(model); diff --git a/packages/provider-pi/src/server/usage.ts b/packages/provider-pi/src/server/usage.ts index bce75c298750..f0899c57a692 100644 --- a/packages/provider-pi/src/server/usage.ts +++ b/packages/provider-pi/src/server/usage.ts @@ -1,4 +1,3 @@ -/** Native Pi session usage, including calls billed outside the main conversation. */ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -99,7 +98,7 @@ function parseRecord(parsed: unknown, state: typeof PiScanState.Type): readonly speed: "standard", // Forks copy entries verbatim, including ID/timestamp. Entry IDs are only // eight random hex digits, so retain timestamp/model to avoid collisions - // across unrelated sessions. Parent IDs may change when Pi branches. + // across unrelated sessions. dedupeKey: JSON.stringify(["pi", entry.id, entry.timestamp, model]), }, ]; diff --git a/packages/provider-pi/src/settings.ts b/packages/provider-pi/src/settings.ts index 8d19e29ac6a2..25d807fcccbb 100644 --- a/packages/provider-pi/src/settings.ts +++ b/packages/provider-pi/src/settings.ts @@ -1,8 +1,6 @@ /** - * Pi instance settings. Shared by the server driver and the client settings - * form, so it holds only browser-safe schema code. - * - * @module provider-pi/settings + * Shared by the server driver and the client settings form, so it holds only + * browser-safe schema code. */ import { CustomModelSetting, @@ -15,7 +13,6 @@ import * as Schema from "effect/Schema"; export const PiSettings = makeProviderSettingsSchema( { - // Off by default like Cursor and Grok. Users opt in from Settings. enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), diff --git a/packages/provider-testing/src/TestProviderHost.ts b/packages/provider-testing/src/TestProviderHost.ts index 4f38ddf02cba..cd2cbbfe0af9 100644 --- a/packages/provider-testing/src/TestProviderHost.ts +++ b/packages/provider-testing/src/TestProviderHost.ts @@ -1,10 +1,3 @@ -/** - * A `ProviderHost.ProviderHost` for driver and adapter tests. Its directories live in a - * scoped temp directory, settings are fixed, and background work always runs - * unless the test says otherwise. - * - * @module provider-testing/TestProviderHost - */ import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as Context from "effect/Context"; @@ -17,15 +10,11 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; export interface TestProviderHostOptions { - /** Session fallback cwd. Defaults to the test process cwd. */ readonly cwd?: string; - /** Initial settings. A test changes them through `TestProviderHostSettings`. */ readonly settings?: ServerSettings; - /** Whether background work such as status probes may run. Defaults to `true`. */ readonly runBackgroundWork?: boolean; } -/** Lets a test change the settings its `layer` reports. */ export class TestProviderHostSettings extends Context.Service< TestProviderHostSettings, { readonly set: (settings: ServerSettings) => Effect.Effect } @@ -68,9 +57,7 @@ export const layer = ( subscribe: Effect.succeed(Stream.empty), }, shouldRunBackgroundWork: () => Effect.succeed(options.runBackgroundWork ?? true), - // Tests store attachments flat under the attachments directory by id. resolveAttachmentPath: (attachment) => path.join(attachmentsDir, attachment.id), - // Credentials live in memory for the layer's lifetime. credentials: (namespace, bindingId) => Effect.sync(() => { const key = `${namespace}:${bindingId}`; diff --git a/packages/provider-testing/src/fakeCli.ts b/packages/provider-testing/src/fakeCli.ts index 650f2fd0d5e2..9aa78fe6b814 100644 --- a/packages/provider-testing/src/fakeCli.ts +++ b/packages/provider-testing/src/fakeCli.ts @@ -5,28 +5,16 @@ import * as NodePath from "node:path"; import * as HostProcess from "@t3tools/shared/HostProcess"; export interface FakeCliOptions { - /** Directory the launcher and its stub are written into. */ readonly directory: string; - /** Command name as callers spawn it, e.g. `codex`. */ readonly name: string; - /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */ readonly source: string; - /** Environment set for the stub before it runs, on top of the inherited environment. */ readonly env?: Readonly>; - /** - * Platform the launcher is shaped for. Effect callers should pass the value - * they read from `HostProcess.Platform` so an injected override is honoured; - * defaults to the real host for plain tests. - */ readonly platform?: NodeJS.Platform; } /** - * Writes a fake CLI whose behaviour lives in a Node stub. On posix the - * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a - * shebang file is not executable there and `resolveSpawnCommand` routes - * `.cmd` through a shell. Returns the launcher path to hand to the code under - * test, which on Windows carries the `.cmd` extension. + * On posix the launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a + * shebang file is not executable there and `resolveSpawnCommand` routes `.cmd` through a shell. */ export function writeFakeCli(options: FakeCliOptions): string { NodeFS.mkdirSync(options.directory, { recursive: true }); @@ -67,20 +55,10 @@ export function writeFakeCli(options: FakeCliOptions): string { return launcherPath; } -/** - * Stub source that becomes `scriptPath`: after optionally requiring a leading - * argv prefix, it imports the script into its own process so the stub is the - * agent. Nothing sits between the code under test and the mock, so a kill or - * a closed stdin reaches it directly and its exit log is faithful on every - * host. The script must not read `process.argv`; the mock agents are driven - * by environment and stdin. - */ export function execScriptSource(options: { readonly scriptPath: string; readonly expectedArgs?: ReadonlyArray; - /** Tab-separated argv appended here per invocation, for launch-flag assertions. */ readonly argvLogPath?: string; - /** Wait before handing over, for tests that race a slow startup. */ readonly delayMs?: number; }): string { return [ diff --git a/packages/provider-testing/src/replayGate.ts b/packages/provider-testing/src/replayGate.ts index 249427750642..f28861d5240e 100644 --- a/packages/provider-testing/src/replayGate.ts +++ b/packages/provider-testing/src/replayGate.ts @@ -7,13 +7,11 @@ export interface ProviderReplayGate { readonly release: (label: string) => boolean; readonly releaseAll: () => void; /** - * Receipts from adapters that hold a settled turn open and finalize it after - * a debounce: each arming records the debounce it waits. Replay runs on a - * test clock, so a scenario advances it by exactly that on the receipt. + * Replay runs on a test clock, so a scenario advances it by exactly that on + * the receipt. */ readonly recordFinishArmed: (debounce: Duration.Input) => void; readonly finishArmedCount: () => number; - /** Resolves with the latest armed debounce once more than `seen` were recorded. */ readonly waitForFinishArmed: (seen: number) => Promise; } diff --git a/packages/provider-testing/src/replayTranscript.ts b/packages/provider-testing/src/replayTranscript.ts index 67bd754f5d2d..bc309eb0f523 100644 --- a/packages/provider-testing/src/replayTranscript.ts +++ b/packages/provider-testing/src/replayTranscript.ts @@ -69,10 +69,6 @@ function materializeWorkspacePlaceholder(value: unknown, workspace: string): unk ); } -/** - * Resolves portable fixture placeholders before a replay driver sees the transcript. - * The resulting outbound frames still use exact structural equality during replay. - */ export function materializeReplayTranscriptWorkspace( transcript: ProviderReplayTranscript, workspace: string, @@ -164,7 +160,6 @@ export function decodeProviderReplayNdjson( } /** - * Reads a provider replay transcript from a `file:` URL and decodes it. * Conversion goes through the `Path` service so drive-letter and UNC fixture * URLs resolve to native paths on Windows instead of `/C:/...` pathname strings. */ diff --git a/packages/provider-testing/src/replayWorkspace.ts b/packages/provider-testing/src/replayWorkspace.ts index a9fdcdee9154..4216ebaedc53 100644 --- a/packages/provider-testing/src/replayWorkspace.ts +++ b/packages/provider-testing/src/replayWorkspace.ts @@ -39,7 +39,6 @@ function runGit( }); } -/** Workspace-relative path to file contents, committed with the initial README. */ export type ReplayWorkspaceFiles = Readonly>; const makeCheckpointWorkspaceEffect = Effect.fn("makeCheckpointWorkspace")(function* ( diff --git a/packages/shared/src/AgentScope.test.ts b/packages/shared/src/AgentScope.test.ts index 85f454f7ec68..cf3ca64acce0 100644 --- a/packages/shared/src/AgentScope.test.ts +++ b/packages/shared/src/AgentScope.test.ts @@ -22,7 +22,6 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("agentScopeComm encoding: "utf8", }); const [pid, score, ...args] = child.stdout.trimEnd().split("\n"); - // The command runs as the spawned process, so signals and PIDs still reach it. expect(Number(pid)).toBe(child.pid); expect(Number(score)).toBe(AGENT_OOM_SCORE_ADJ); expect(args).toEqual(["a b", "$HOME"]); diff --git a/packages/shared/src/AgentScope.ts b/packages/shared/src/AgentScope.ts index 61255e5a4e29..08af8f0ba23a 100644 --- a/packages/shared/src/AgentScope.ts +++ b/packages/shared/src/AgentScope.ts @@ -1,24 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -/** - * Isolates long-lived agent and terminal processes from the server on Linux. - * - * Each wrapped command runs with a high `oom_score_adj`, so the kernel OOM - * killer picks it before the server. Under a systemd user manager it also runs - * in its own transient scope, so systemd-oomd (which kills whole leaf cgroups) - * kills one agent instead of the server and every other agent. - * - * The server provides the real implementation. The default leaves commands - * unchanged, which is also the behavior on macOS, Windows, and in tests. - */ export interface AgentScopeShape { - /** - * Wraps the command that starts an agent or terminal. `name` labels the - * scope unit, for example "claude" or "terminal". `env` is the environment - * the command will run with, used to resolve it on PATH. A command that does - * not resolve stays unwrapped, so a missing binary still fails at spawn. - */ readonly wrap: (input: { readonly command: string; readonly args: ReadonlyArray; @@ -32,11 +15,6 @@ export interface AgentScopeShape { */ readonly survivesRestart?: boolean | undefined; }) => Effect.Effect; - /** - * Whether the latest agent scope of this thread was killed because the - * machine or the agents slice ran out of memory. The answer holds until the - * thread launches its next agent, so a retried failure reads the same. - */ readonly oomKilled: (threadId: string) => Effect.Effect; /** * Forgets the thread's scope. The session manager calls it before it opens @@ -59,22 +37,13 @@ export const AgentScope = Context.Reference("@t3tools/shared/Ag }), }); -/** - * The thread an ACP runtime is spawned for. ACP runtimes are shared by several - * provider flavors and do not take a thread id, so the ACP adapter provides it. - */ export const AgentScopeThreadId = Context.Reference( "@t3tools/shared/AgentScope/ThreadId", { defaultValue: () => undefined }, ); -/** - * Agents get this `oom_score_adj` so the kernel kills them before the server. - * An unprivileged process may raise its own score, never lower it. - */ export const AGENT_OOM_SCORE_ADJ = 800; -/** Shell line that raises the score of the current process, ignoring failure. */ export const RAISE_OOM_SCORE_LINE = `{ echo ${AGENT_OOM_SCORE_ADJ} >/proc/self/oom_score_adj; } 2>/dev/null`; const LAUNCH_SCRIPT = [RAISE_OOM_SCORE_LINE, 'exec "$@"'].join("\n"); @@ -94,15 +63,13 @@ export interface AgentSystemdScope { readonly slice: string; readonly unit: string; readonly runtimeDir: string; - /** Extra `systemd-run` options, such as `--property=OOMPolicy=stop`. */ readonly options: ReadonlyArray; } /** - * Builds the Linux launch for an agent command. Every step `exec`s, so the - * spawned PID, stdio, and signals all belong to the agent itself. - * `systemd-run --scope` registers its own PID as the scope and then runs the - * command, so the agent and all of its children start inside the scope. + * Every step `exec`s, so the spawned PID, stdio, and signals all belong to the agent itself. + * `systemd-run --scope` registers its own PID as the scope and then runs the command, so the + * agent and all of its children start inside the scope. */ export function agentScopeCommand(input: { readonly command: string; diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index bda85810fa6f..d6bad8114c5a 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -41,11 +41,9 @@ describe("makeDrainableWorker", () => { yield* worker.enqueue("interrupt"); yield* worker.enqueue("ok"); - // A stopped worker never drains; its exit settles the race instead of a hang. yield* Effect.raceFirst(worker.drain, Fiber.await(yield* Deferred.await(workerFiber))); expect(processed).toEqual(["ok"]); - // The item that interrupted itself was cancelled, not failed. expect(loggedErrors).toEqual(["typed failure", "defect"]); }), ).pipe(Effect.provide(loggerLayer)); @@ -75,7 +73,6 @@ describe("makeDrainableWorker", () => { expect(processed).toEqual(["block"]); expect(loggedErrors).toEqual([]); - // Queued and late items are dropped with the queue, so drain resolves at once. yield* worker.enqueue("after close"); const drained = yield* Effect.forkChild(worker.drain, { startImmediately: true }); expect(drained.pollUnsafe()).toEqual(Exit.void); diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index 5ec2a3f9db44..4515e5ca626d 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -1,13 +1,3 @@ -/** - * DrainableWorker - A queue-based worker that exposes a `drain()` effect. - * - * Wraps the common `Queue.unbounded` + `Effect.forever` pattern and adds - * a signal that resolves when the queue is empty **and** the current item - * has finished processing. This lets tests replace timing-sensitive - * `Effect.sleep` calls with deterministic `drain()`. - * - * @module DrainableWorker - */ import * as Cause from "effect/Cause"; import * as Scope from "effect/Scope"; import * as Effect from "effect/Effect"; @@ -15,32 +5,15 @@ import * as TxQueue from "effect/TxQueue"; import * as TxRef from "effect/TxRef"; export interface DrainableWorker { - /** - * Enqueue a work item and track it for `drain()`. - * - * This wraps `Queue.offer` so drain state is updated atomically with the - * enqueue path instead of inferring it from queue internals. - */ readonly enqueue: (item: A) => Effect.Effect; - /** - * Resolves when the queue is empty and the worker is idle (not processing). - */ readonly drain: Effect.Effect; } /** - * Create a drainable worker that processes items from an unbounded queue. - * - * The worker is forked into the current scope and will be interrupted when - * the scope closes. A finalizer shuts down the queue and drops queued items, - * so `drain` resolves after the scope closes instead of waiting on them. - * - * An item that fails or dies is logged and skipped; the worker keeps - * processing later items and `drain` still resolves. - * - * @param process - The effect to run for each queued item. - * @returns A `DrainableWorker` with `enqueue` and `drain`. + * A finalizer shuts down the queue and drops queued items, so `drain` resolves after the scope + * closes instead of waiting on them. An item that fails or dies is logged and skipped; the worker + * keeps processing later items and `drain` still resolves. */ export const makeDrainableWorker = ( process: (item: A) => Effect.Effect, @@ -64,8 +37,6 @@ export const makeDrainableWorker = ( Effect.suspend(() => process(a)).pipe( // Only the item's own failure, defect, or interruption lands here and // the loop continues; interrupting the worker fiber still stops it. - // Callers treat an item that only interrupted itself as cancelled, - // not failed. Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void @@ -85,7 +56,6 @@ export const makeDrainableWorker = ( const enqueue = (element: A): Effect.Effect => TxQueue.offer(queue, element).pipe( - // A shut-down queue refuses the item, so it is never processed. Effect.tap((offered) => (offered ? TxRef.update(outstanding, (n) => n + 1) : Effect.void)), Effect.tx, ); diff --git a/packages/shared/src/HostProcess.ts b/packages/shared/src/HostProcess.ts index f30b83234d69..79f6dfef15d7 100644 --- a/packages/shared/src/HostProcess.ts +++ b/packages/shared/src/HostProcess.ts @@ -63,12 +63,6 @@ export const InvokedAs = Context.Reference("@t3tools/shared/HostProcess/ defaultValue: () => process.argv0, }); -/** - * Whether this process is a Node single-executable rather than a script run - * by a Node on the machine. Code that needs a sibling file or a Node to run - * one branches on this: an executable hosts such things as hidden - * subcommands of itself. - */ export const IsExecutable = Context.Reference("@t3tools/shared/HostProcess/IsExecutable", { defaultValue: () => NodeSea.isSea(), }); @@ -78,10 +72,8 @@ export const IsExecutable = Context.Reference("@t3tools/shared/HostProc * whatever the resolver returns for the machine's own hostname. The latter * matters because a hostname can map to an address no interface carries β€” * Debian-style hosts put `127.0.1.1` in `/etc/hosts` β€” and a program that - * records "its" address by resolving its hostname (Firefox's profile lock - * does) will write that one. "Is this address ours" has to accept both. - * - * Best effort: a failed lookup just leaves the interface set. + * records "its" address by resolving its hostname (Firefox's profile lock does) + * will write that one. */ export const Addresses = Context.Reference>>( "@t3tools/shared/HostProcess/Addresses", diff --git a/packages/shared/src/KeyedCoalescingWorker.ts b/packages/shared/src/KeyedCoalescingWorker.ts index ce1d89a41169..733a3b3c537f 100644 --- a/packages/shared/src/KeyedCoalescingWorker.ts +++ b/packages/shared/src/KeyedCoalescingWorker.ts @@ -1,13 +1,3 @@ -/** - * KeyedCoalescingWorker - A keyed worker that keeps only the latest value per key. - * - * Enqueues for an active or already-queued key are merged atomically instead of - * creating duplicate queued items. Each batch yields to other queued keys before - * processing newer updates. `drainKey()` resolves only when that key has no - * queued, pending, or active work left. - * - * @module KeyedCoalescingWorker - */ import * as Scope from "effect/Scope"; import * as Effect from "effect/Effect"; import * as TxQueue from "effect/TxQueue"; @@ -36,8 +26,6 @@ export const makeKeyedCoalescingWorker = (options: { activeKeys: new Set(), }); - // Return dirty keys to the tail on success or failure. Keep pending values - // available for merging until the next batch starts. const completeKey = (key: K): Effect.Effect => TxRef.modify(stateRef, (state) => { const activeKeys = new Set(state.activeKeys); diff --git a/packages/shared/src/KeyedLock.test.ts b/packages/shared/src/KeyedLock.test.ts index 046729af5568..d58df7b5be39 100644 --- a/packages/shared/src/KeyedLock.test.ts +++ b/packages/shared/src/KeyedLock.test.ts @@ -56,7 +56,6 @@ describe("KeyedLock", () => { const holders = yield* Effect.forEach(["a", "b"], hold, { concurrency: "unbounded" }).pipe( Effect.forkChild, ); - // Both enter while the other still holds its key. const both = new Set([yield* Queue.take(entered), yield* Queue.take(entered)]); assert.deepStrictEqual(both, new Set(["a", "b"])); yield* Deferred.succeed(release, undefined); @@ -77,18 +76,15 @@ describe("KeyedLock", () => { ) .pipe(Effect.forkChild); yield* Deferred.await(entered); - // A waiter that gives up and one that runs. const abandoned = yield* lock.withLock("key", Effect.void).pipe(Effect.forkChild); const waiting = yield* lock.withLock("key", Effect.void).pipe(Effect.forkChild); yield* Effect.yieldNow; assert.deepStrictEqual(yield* lock.activeKeys, ["key"]); yield* Fiber.interrupt(abandoned); - // The holder and the other waiter still need the lock. assert.deepStrictEqual(yield* lock.activeKeys, ["key"]); yield* Deferred.succeed(release, undefined); yield* Fiber.joinAll([holder, waiting]); - // A holder that fails releases its key too. assert.strictEqual( yield* lock.withLock("other", Effect.fail("boom")).pipe(Effect.flip), "boom", @@ -100,7 +96,6 @@ describe("KeyedLock", () => { it.effect("keeps working after the scope that made it closes", () => Effect.gen(function* () { - // Services built per request hand their lock to work that outlives the build. const lock = yield* Effect.scoped(KeyedLock.make()); assert.strictEqual(yield* lock.withLock("key", Effect.succeed("ran")), "ran"); }), @@ -118,7 +113,6 @@ describe("KeyedLock", () => { Effect.addFinalizer(() => Ref.set(finalized, true)), ) .pipe(Scope.provide(callerScope)); - // The finalizer belongs to the caller, so releasing the lock leaves it. assert.isFalse(yield* Ref.get(finalized)); yield* Scope.close(callerScope, Exit.void); assert.isTrue(yield* Ref.get(finalized)); diff --git a/packages/shared/src/KeyedLock.ts b/packages/shared/src/KeyedLock.ts index 780ee3a9bbca..0411d7ef7e76 100644 --- a/packages/shared/src/KeyedLock.ts +++ b/packages/shared/src/KeyedLock.ts @@ -3,9 +3,7 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; export interface KeyedLock { - /** Runs `effect` once no other holder of `key` is running. Queued waiters go first-in, first-out. */ readonly withLock: (key: Key, effect: Effect.Effect) => Effect.Effect; - /** The keys someone holds or waits on right now. */ readonly activeKeys: Effect.Effect>; } @@ -15,12 +13,7 @@ interface LockEntry { } /** - * Mutual exclusion per key. A key's lock exists only while someone holds or - * waits on it, so locks for keys that come and go never accumulate. Not - * reentrant: taking a key while already holding it deadlocks. - * - * Keys compare like `Map` keys (by value for strings and numbers). The lock is - * not tied to a scope, so it keeps working for as long as anyone references it. + * Not reentrant: taking a key while already holding it deadlocks. */ export const make = (): Effect.Effect> => Effect.gen(function* () { diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 93a1649b25ba..15c78f72ba35 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -9,9 +9,7 @@ const closeServer = (server: NodeNet.Server) => Effect.sync(() => { try { server.close(); - } catch { - // Ignore cleanup failures in tests. - } + } catch {} }); const getPort = (server: NodeNet.Server): number => { diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index e3b653692880..a4d5955a5970 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -23,42 +23,21 @@ const isErrnoExceptionWithCode = ( const closeServer = (server: NodeNet.Server) => { try { server.close(); - } catch { - // Ignore close failures during cleanup. - } + } catch {} }; export interface NetServiceShape { - /** - * Returns true when a TCP server can bind to {host, port}. - */ readonly canListenOnHost: (port: number, host: string) => Effect.Effect; - /** - * Checks loopback availability on both IPv4 and IPv6 localhost addresses. - */ readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect; - /** - * Returns true when something accepts TCP connections on {host, port}. - * Unlike the bind-side checks this works for privileged ports (<1024). - */ readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect; - /** - * Reserve an ephemeral loopback port and release it immediately. - */ readonly reserveLoopbackPort: (host?: string) => Effect.Effect; - /** - * Resolve an available listening port, preferring the provided port first. - */ readonly findAvailablePort: (preferred: number) => Effect.Effect; } -/** - * NetService - Service tag for startup networking helpers. - */ export class NetService extends Context.Service()( "@t3tools/shared/Net/NetService", ) {} @@ -66,7 +45,6 @@ export class NetService extends Context.Service()( /** @public Service construction is part of the canonical Effect module API. */ export const make = () => { /** - * Returns true when a TCP server can bind to {host, port}. * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail * loopback availability checks. */ @@ -151,10 +129,6 @@ export const make = () => { ); }); - /** - * Reserve an ephemeral loopback port and release it immediately. - * Returns the reserved port number. - */ const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => Effect.callback((resume) => { const probe = NodeNet.createServer(); diff --git a/packages/shared/src/PtyAdapter.ts b/packages/shared/src/PtyAdapter.ts index 7abb51cbfe6d..83bed9824048 100644 --- a/packages/shared/src/PtyAdapter.ts +++ b/packages/shared/src/PtyAdapter.ts @@ -1,18 +1,7 @@ -/** - * PtyAdapter - Terminal PTY adapter service contract. - * - * Defines the process primitives required by terminal session management - * without binding to a specific PTY implementation. - * - * @module PtyAdapter - */ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -/** - * PtySpawnError - Error type for PTY spawn failures. - */ export class PtySpawnError extends Schema.TaggedError()("PtySpawnError", { adapter: Schema.String, shell: Schema.optional(Schema.String), @@ -52,15 +41,9 @@ export interface PtySpawnInput { env: NodeJS.ProcessEnv; } -/** - * PtyAdapter - Service tag for PTY process integration. - */ export class PtyAdapter extends Context.Service< PtyAdapter, { - /** - * Spawn a PTY process for a terminal session. - */ readonly spawn: (input: PtySpawnInput) => Effect.Effect; } >()("@t3tools/shared/PtyAdapter") {} diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index bc4aaa781c56..b8bd43766de5 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -54,7 +54,6 @@ export interface ProjectThreadAwarenessV2Input { >; } -/** Build relay activity directly from the V2 shell projection. */ export function projectThreadAwarenessV2( input: ProjectThreadAwarenessV2Input, ): AgentAwarenessState | null { @@ -104,7 +103,6 @@ function resolveThreadAwarenessPhaseV2( case "waiting": return "running"; case "completed": - // Work that will wake the agent keeps the run going; a dev server does not. return backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) ? "running" : "completed"; diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts index 53edc79e47fd..9af6a2970672 100644 --- a/packages/shared/src/assistantCitations.ts +++ b/packages/shared/src/assistantCitations.ts @@ -24,7 +24,6 @@ function encodePathPart(value: string): string { ); } -/** Edits only the user comment, leaving the quote and its source selector unchanged. */ export function withAssistantCitationComment( citation: AssistantCitation, comment: string, @@ -34,7 +33,6 @@ export function withAssistantCitationComment( return trimmedComment ? { ...source, comment: trimmedComment } : source; } -/** Self-contained and origin-independent, so draft, clipboard, and sent-message copies agree. */ export function formatAssistantCitationHref(citation: AssistantCitation): string { const path = [citation.environmentId, citation.threadId, citation.messageId] .map(encodePathPart) @@ -98,7 +96,6 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu } } -/** Visible chip text: the comment, else the quoted text, collapsed and capped. */ export function assistantCitationLabel(citation: AssistantCitation): string { const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); return preview.length > 64 ? `${preview.slice(0, 64)}…` : preview; @@ -124,7 +121,6 @@ export function collectAssistantCitations(text: string) { return citations; } -/** Titles and previews include the selected text and user comment without Markdown escaping. */ export function assistantCitationsToPlainText(prompt: string): string { return prompt.replace(CITATION_LINK, (source: string, href: string) => { const citation = parseAssistantCitationHref(href); @@ -135,7 +131,6 @@ export function assistantCitationsToPlainText(prompt: string): string { }); } -/** Provider adapters receive readable quote data; the persisted message keeps its clickable links. */ export function expandAssistantCitationsForProvider(prompt: string): string { const matches = collectAssistantCitations(prompt); if (matches.length === 0) return prompt; @@ -172,7 +167,6 @@ function escapeMarkdownText(text: string): string { .replace(/[\\`*_[\]{}()#+.!|~-]/g, "\\$&"); } -/** Native clients display the complete quote and keep the user's comment outside the quote block. */ export function renderAssistantCitationsAsText(prompt: string): string { const matches = collectAssistantCitations(prompt); let text = ""; diff --git a/packages/shared/src/atomicWrite.test.ts b/packages/shared/src/atomicWrite.test.ts index 28cc1e3ca8c5..1ff882963837 100644 --- a/packages/shared/src/atomicWrite.test.ts +++ b/packages/shared/src/atomicWrite.test.ts @@ -143,7 +143,6 @@ it.effect("succeeds when the write lands but its temp directory cannot be remove const renamed: Array = []; const removed: Array = []; const fileSystem = FileSystem.layerNoop({ - // The target does not exist yet, so it is written in place. readLink: (path) => Effect.fail( PlatformError.systemError({ diff --git a/packages/shared/src/atomicWrite.ts b/packages/shared/src/atomicWrite.ts index bdceff501d6a..23a84429a330 100644 --- a/packages/shared/src/atomicWrite.ts +++ b/packages/shared/src/atomicWrite.ts @@ -4,8 +4,7 @@ import * as Path from "effect/Path"; import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; /** - * Replaces a file's contents via a sibling temp file and rename. A symlinked - * target is resolved first so the link survives and its destination is + * A symlinked target is resolved first so the link survives and its destination is * rewritten, since renaming over the link itself would swap it for a regular file. */ export const writeFileStringAtomically = (input: { diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 265c31c157fb..3c24ed750195 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -2,9 +2,6 @@ import type { RepositoryIdentity, ThreadLinkedPullRequest } from "@t3tools/contr import { canonicalRepositoryKey } from "./sourceControl.ts"; /** - * A change request named the way a thread link names one: the host below which the repository - * is addressed, the repository path as that host writes it, and the number. - * * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce * from a git remote β€” lower case, no port, the full path below the host β€” because links are * matched against those. Anything else matches nothing. @@ -13,30 +10,14 @@ export interface ChangeRequestLink { readonly host: string; readonly repository: string; readonly number: number; - /** Forgejo's HTTP host and port, separate from the portless repository identity. */ readonly authority?: string; } -/** The host itself, one of its subdomains, or an install named after the provider. */ function isHostOf(hostname: string, apex: string, label?: string): boolean { if (hostname === apex || hostname.endsWith(`.${apex}`)) return true; return label !== undefined && hostname.split(".").includes(label); } -/** - * The repository and number behind a change request URL on a host this can read, or null for - * anything else β€” an issue, a commit, a repository root, a host this cannot tell apart from an - * ordinary link. A doubtful match is worse than no match, so nothing here guesses. - * - * Each host is recognised by the path shape it alone uses, guarded by a hostname it could - * plausibly be served from, since self-hosted installs are named whatever their admin chose: - * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic - * enough that it is only believed from a GitHub-ish host. - * - * Nothing here tries to tell a lookalike hostname from a real one β€” `github.com.evil.test` and - * the rest are an open set, and blocking spellings of it costs real hosts (`gitlab.com.br` is a - * registrable domain). What a claim is worth is decided where it is used. - */ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null { let url: URL; try { @@ -44,37 +25,28 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu } catch { return null; } - // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open. if (url.protocol !== "https:" && url.protocol !== "http:") return null; const host = url.hostname.toLowerCase(); - // GitCafe, only on its own two hosts: /{owner}/{repo}/pulls/{n} if (host === "git.cafe" || host === "staging.git.cafe") { const match = /^\/([^/]+\/[^/]+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); return claim(host, match); } - // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); if (match) return claim(host, match); } - // Forgejo and Gitea use /pulls/ on arbitrary self-hosted domains. const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); if (forgejo) { const link = claim(host, forgejo); return link === null ? null : { ...link, authority: url.host.toLowerCase() }; } - // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/` - // separator is GitLab's own, so the hostname is not asked about. const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); if (gitlab) return claim(host, gitlab); - // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n} if (isHostOf(host, "bitbucket.org", "bitbucket")) { const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); return claim(host, match); } - // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part - // of the repository path there, as it is in the remote URL the identity is read from. if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) { const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); return claim(host, match); @@ -90,7 +62,6 @@ function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | : null; } -/** The web URL a host writes for a change request; null when the host shape is unknown. */ export function changeRequestUrlFor( kind: string | null | undefined, host: string, @@ -129,7 +100,6 @@ export function changeRequestUrlFor( } } -/** Builds a GitHub URL that remains available when the pull request API cannot be read. */ export function gitHubPullRequestBrowserUrl( identity: RepositoryIdentity | null | undefined, repository: string, @@ -150,9 +120,7 @@ export function gitHubPullRequestBrowserUrl( if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { origin = remoteUrl.origin; } - } catch { - // SCP-style remotes are read from their normalized identity below. - } + } catch {} const hostname = identity.canonicalKey.split("/")[0]; if (origin === null && !hostname) return null; @@ -193,7 +161,6 @@ export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): return url.toString(); } -/** Match a stored PR without requiring its project to remain available. */ export function matchesLinkedPullRequestUrl( linkedPullRequest: ThreadLinkedPullRequest, targetUrl: string, @@ -210,7 +177,6 @@ export function matchesLinkedPullRequestUrl( ); } -/** The repository root behind a recognised change-request URL, without PR-specific state. */ export function changeRequestRepositoryUrl(targetUrl: string): string | null { const changeRequest = parseChangeRequestUrl(targetUrl); if (changeRequest === null) return null; diff --git a/packages/shared/src/claudeCompaction.test.ts b/packages/shared/src/claudeCompaction.test.ts index 9eef52520719..9c05629c7cc8 100644 --- a/packages/shared/src/claudeCompaction.test.ts +++ b/packages/shared/src/claudeCompaction.test.ts @@ -6,8 +6,6 @@ import { } from "./claudeCompaction.ts"; describe("claude resume compaction copy", () => { - // The matcher must recognize every question the formatter can produce. - // This is the drift guard: rewording one side fails here. it.each([ { ageMinutes: 145, estimatedTokens: 275_123 }, { ageMinutes: 70, estimatedTokens: 100_000 }, diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts index 69851f2f3fb3..1bba545d6618 100644 --- a/packages/shared/src/cliArgs.ts +++ b/packages/shared/src/cliArgs.ts @@ -64,27 +64,6 @@ export function tokenizeCliArgs(args?: string): ReadonlyArray { return tokens; } -/** - * Parse CLI-style arguments into flags and positionals. - * - * Accepts a string (quote-aware tokenized) or a pre-split argv array. - * Supports `--key value`, `--key=value`, and `--flag` (boolean) syntax. - * - * parseCliArgs("") - * β†’ { flags: {}, positionals: [] } - * - * parseCliArgs("--chrome") - * β†’ { flags: { chrome: null }, positionals: [] } - * - * parseCliArgs("--chrome --effort high") - * β†’ { flags: { chrome: null, effort: "high" }, positionals: [] } - * - * parseCliArgs("--effort=high") - * β†’ { flags: { effort: "high" }, positionals: [] } - * - * parseCliArgs(["1.2.3", "--root", "/path", "--github-output"], { booleanFlags: ["github-output"] }) - * β†’ { flags: { root: "/path", "github-output": null }, positionals: ["1.2.3"] } - */ export function parseCliArgs( args: string | readonly string[], options?: ParseCliArgsOptions, @@ -102,20 +81,17 @@ export function parseCliArgs( const rest = token.slice(2); if (!rest) continue; - // Handle --key=value syntax const eqIndex = rest.indexOf("="); if (eqIndex !== -1) { flags[rest.slice(0, eqIndex)] = rest.slice(eqIndex + 1); continue; } - // Known boolean flag β€” never consumes next token if (booleanSet?.has(rest)) { flags[rest] = null; continue; } - // Handle --key value or --flag (boolean) const next = tokens[i + 1]; if (next !== undefined && !next.startsWith("--")) { flags[rest] = next; diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index c92421db5ec2..953d8999d750 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -23,7 +23,6 @@ describe("cliRelease", () => { expect(cliArchivePlatformKey("darwin", "arm64")).toBe("darwin-arm64"); expect(cliArchivePlatformKey("linux", "x64")).toBe("linux-x64"); expect(cliArchivePlatformKey("win32", "x64")).toBe("win32-x64"); - // Node single-executables are unsupported on x64 macOS. expect(cliArchivePlatformKey("darwin", "x64")).toBeUndefined(); expect(cliArchivePlatformKey("linux", "arm64")).toBe("linux-arm64"); expect(cliArchivePlatformKey("win32", "arm64")).toBe("win32-arm64"); @@ -66,7 +65,6 @@ describe("cliRelease", () => { expect(cliReleaseChannelOf("1.2.3")).toBe("stable"); expect(cliReleaseChannelOf("1.2.3-nightly.20260911.4")).toBe("nightly"); expect(cliReleaseChannelOf("1.2.3-preview.20260911.4")).toBe("preview"); - // A prerelease that is not one of our trains is not silently a nightly. expect(cliReleaseChannelOf("1.2.3-rc.1")).toBe("stable"); }); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 28f0d530bb29..4a8a535da0c8 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -1,21 +1,12 @@ -/** - * Naming shared by the release workflow, the runtime installers, and - * install scripts for the per-platform CLI archives attached to GitHub - * Releases. Every consumer derives the same file names from a version and a - * platform key, so a rename here is a release-breaking change. - */ - const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; -/** Overrides the download origin for mirrors and air-gapped installs. */ export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; /** - * The archives a release attaches. Kept in step with the build_linux_cli - * matrix, build_windows_arm64_cli, and the `cli_archive` rows in - * .github/workflows/release.yml: a key here without a build there produces - * download URLs that 404, and a build there without a key here is - * unreachable from every installer. + * Kept in step with the build_linux_cli matrix, build_windows_arm64_cli, and + * the `cli_archive` rows in .github/workflows/release.yml: a key here without a + * build there produces download URLs that 404, and a build there without a key + * here is unreachable from every installer. */ // No darwin-x64: Node single-executables are unsupported on x64 macOS (the // SEA docs list macOS as arm64 only) and the binary segfaults on start. @@ -37,9 +28,9 @@ export function cliArchivePlatformKey( } /** - * The tar to extract a release archive with. Windows ships bsdtar in - * System32, which reads both formats; a Git-for-Windows GNU tar earlier on - * PATH cannot open the zip, so the system copy is named by absolute path. + * Windows ships bsdtar in System32, which reads both formats; a Git-for-Windows + * GNU tar earlier on PATH cannot open the zip, so the system copy is named by + * absolute path. */ export function cliArchiveTarCommand( platform: NodeJS.Platform, @@ -56,7 +47,6 @@ export function cliArchiveFileName(version: string, platformKey: CliArchivePlatf const CLI_RELEASE_DEFAULT_BASE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases/download`; -/** Directory that `releases/download//` lives under. */ export function cliReleaseDownloadBaseUrl( version: string, baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL, @@ -64,10 +54,6 @@ export function cliReleaseDownloadBaseUrl( return `${(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL).replace(/\/+$/, "")}/v${version}`; } -/** - * Parses the `sha256sum` style checksum file attached to each release. - * Lines are ` `; a leading `*` marks binary mode and is ignored. - */ export function parseChecksums(text: string): ReadonlyMap { const checksums = new Map(); for (const line of text.split(/\r?\n/)) { @@ -86,15 +72,13 @@ export const CLI_RELEASE_CHANNELS: ReadonlyArray = [ "preview", ]; -/** The release train a version was published on, derived from its prerelease tag. */ export function cliReleaseChannelOf(version: string): CliReleaseChannel { const channel = /^[^-+]+-(nightly|preview)\.\d{8}\.\d+$/.exec(version)?.[1]; return channel === "nightly" || channel === "preview" ? channel : "stable"; } /** - * One page of GitHub's list-releases endpoint, newest first. Callers walk pages - * until a channel match turns up; a busy nightly train can push the newest + * Callers walk pages until a channel match turns up; a busy nightly train can push the newest * preview or stable release past any single page. */ export function cliReleaseIndexPageUrl(page: number): string { @@ -102,10 +86,7 @@ export function cliReleaseIndexPageUrl(page: number): string { } /** - * Picks the newest version on a channel from the release index. Tags are - * `v`; the channel is decided by the same rule the runtime uses, so - * a preview tag never satisfies a nightly lookup and vice versa. Drafts are - * skipped because their assets are not downloadable. + * Drafts are skipped because their assets are not downloadable. */ export function newestCliReleaseVersion( releases: ReadonlyArray<{ diff --git a/packages/shared/src/codexArtifactTemplates.ts b/packages/shared/src/codexArtifactTemplates.ts index 052a60f11d58..08fa5a28e3e5 100644 --- a/packages/shared/src/codexArtifactTemplates.ts +++ b/packages/shared/src/codexArtifactTemplates.ts @@ -62,7 +62,6 @@ function isAbsoluteSkillDirectory(value: string): boolean { ); } -/** Mirrors the Codex result-card schema so malformed directives remain literal Markdown. */ export function resolveCodexArtifactTemplate( attributes: CodexArtifactTemplateAttributes | null | undefined, ): CodexArtifactTemplate | null { diff --git a/packages/shared/src/codexAuthHandoff.ts b/packages/shared/src/codexAuthHandoff.ts index d6632355e363..7f0e4ccd183a 100644 --- a/packages/shared/src/codexAuthHandoff.ts +++ b/packages/shared/src/codexAuthHandoff.ts @@ -23,7 +23,6 @@ const decodeHandoff = Schema.decodeUnknownSync(Schema.fromJsonString(CodexAuthHa const encodeDelivery = Schema.encodeSync(Schema.fromJsonString(Delivery)); const decodeDelivery = Schema.decodeUnknownSync(Schema.fromJsonString(Delivery)); -/** The helper only opens OpenAI's authorize endpoint and receives a loopback callback. */ export function codexAuthorizationRequest(value: string) { const url = new URL(value); if ( @@ -56,7 +55,6 @@ export function codexAuthorizationRequest(value: string) { return { authorizationUrl: url.toString(), redirectUri, state }; } -/** Validation is shared by the desktop listener and the environment receiving the code. */ export function codexCallbackUrl(value: string, redirectUri: string, state: string) { const callback = new URL(value); const expected = new URL(redirectUri); diff --git a/packages/shared/src/codexMarkdownDirectives.ts b/packages/shared/src/codexMarkdownDirectives.ts index 6a01aeb27b8c..6ff396eddb12 100644 --- a/packages/shared/src/codexMarkdownDirectives.ts +++ b/packages/shared/src/codexMarkdownDirectives.ts @@ -209,7 +209,6 @@ function renderFileCitation(node: MarkdownAstNode, source: string, insideLink: b delete node.value; } -/** Shows only the label. T3 has no suggestion UI, so the hidden prompt is not shown. */ function renderFollowUp(node: MarkdownAstNode, source: string): void { if (!node.attributes?.prompt?.trim() || !textContent(node).trim()) { restoreTextDirective(node, source); @@ -267,7 +266,6 @@ function transformCodexDirectives(node: MarkdownAstNode, source: string, insideL } } -/** Adds grammar only for the directives emitted by Codex, then renders them as mdast. */ function attachCodexDirectives(this: Processor) { const data = this.data(); const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []); @@ -338,7 +336,6 @@ function renderDirectiveMatches( return rendered; } -/** Native Markdown renderers use this adapter because they cannot consume a Remark tree. */ export function renderCodexInlineDirectivesAsMarkdown(markdown: string): string { if ( !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) && @@ -350,7 +347,6 @@ export function renderCodexInlineDirectivesAsMarkdown(markdown: string): string return renderDirectiveMatches(markdown, (match) => match.markdown); } -/** Matches the Markdown emitted when users copy rendered Codex directive UI. */ export function renderCodexDirectivesForCopy(markdown: string): string { if ( !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) && @@ -367,7 +363,6 @@ export function renderCodexDirectivesForCopy(markdown: string): string { }); } -/** Native renderers split cards out because they cannot host a view inside Markdown text. */ export function splitCodexArtifactTemplateMarkdown( markdown: string, ): ReadonlyArray { diff --git a/packages/shared/src/composerContextClipboard.test.ts b/packages/shared/src/composerContextClipboard.test.ts index 5eb98b21bcdd..6786eec7d5fc 100644 --- a/packages/shared/src/composerContextClipboard.test.ts +++ b/packages/shared/src/composerContextClipboard.test.ts @@ -98,9 +98,6 @@ describe("composerContextClipboard", () => { }); const html = encodeComposerContextClipboardHtml("output", raw); - // The attribute runs to nearly nine characters per code unit. A bound derived from the - // fragment limit has to allow that, or a fragment that encoded cleanly is rejected on the - // way back in. This ratio is what the decoder's own bound is sized against. expect(html.length).toBeGreaterThan(raw.length * 8); expect(decodeComposerContextClipboardHtml(html)).toEqual(JSON.parse(raw)); }); diff --git a/packages/shared/src/composerContextClipboard.ts b/packages/shared/src/composerContextClipboard.ts index 654ea2f28681..a8d4157df22a 100644 --- a/packages/shared/src/composerContextClipboard.ts +++ b/packages/shared/src/composerContextClipboard.ts @@ -17,8 +17,6 @@ export function encodeComposerContextFragment( return encoded.length <= MAX_FRAGMENT_CHARS ? encoded : null; } -/** HTML is the portable flavor shared by browsers and native system clipboards. */ -/** Room for the wrapper element, its attribute name, and the copied HTML itself. */ const HTML_WRAPPER_SLACK_CHARS = 4096; export function encodeComposerContextClipboardHtml( @@ -47,7 +45,6 @@ export function decodeComposerContextClipboardHtml( } } -/** Clipboard data is untrusted: anything that is not a valid version-1 fragment is ignored. */ export function decodeComposerContextFragment( raw: string | null | undefined, ): ComposerContextClipboardFragment | null { diff --git a/packages/shared/src/composerContextLegacy.test.ts b/packages/shared/src/composerContextLegacy.test.ts index ee52498cb3f4..7b250495bfc8 100644 --- a/packages/shared/src/composerContextLegacy.test.ts +++ b/packages/shared/src/composerContextLegacy.test.ts @@ -355,9 +355,6 @@ describe("upgradeLegacyContextMessage", () => { }); it("keeps every line of a multiline legacy annotation comment", () => { - // Older clients wrote the comment verbatim, newlines and all. Reading only the first line - // silently drops the rest while the original block is replaced by a chip, so the dropped - // instructions are gone from what the reader sees and copies. const text = [ "", "Preview annotation:", @@ -391,8 +388,6 @@ describe("upgradeLegacyContextMessage", () => { }); it("keeps markup inside a legacy annotation comment", () => { - // Only a real block delimiter ends the comment. A line that merely starts with `<` is - // something the author typed. const text = [ "", "Preview annotation:", diff --git a/packages/shared/src/composerContextLegacy.ts b/packages/shared/src/composerContextLegacy.ts index b13caa51e3e9..8bea672a06be 100644 --- a/packages/shared/src/composerContextLegacy.ts +++ b/packages/shared/src/composerContextLegacy.ts @@ -11,10 +11,6 @@ import * as Schema from "effect/Schema"; import { formatComposerContextReference } from "./composerContextReferences.ts"; /** - * Upgrades a message written before inline context references: trailing - * ``, `` and `` blocks, inline or - * trailing `` blocks, and U+FFFC terminal placeholders. Produces canonical - * reference links plus records so old messages render and copy through the new path. * Event history is never rewritten; this runs in memory on read. */ @@ -70,7 +66,6 @@ function parseEntries(block: string): ParsedEntry[] | null { return entries; } -/** The inline label the old send path wrote for a terminal excerpt: `@terminal-1:509-514`. */ function inlineTerminalLabel(record: TerminalContextRecord): string { const slug = record.terminalLabel.trim().toLowerCase().replace(/\s+/g, "-"); const range = @@ -329,11 +324,8 @@ export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext const reviewTokenPattern = new RegExp(`${reviewToken}(\\d+)${reviewToken}`, "g"); const trailingReviewTokenPattern = new RegExp(`(?:\\s*${reviewToken}\\d+${reviewToken})+\\s*$`); - // Review blocks become tokens in place first so they neither hide the trailing blocks - // behind them nor lose their position. Unparseable blocks stay as text. let rest = replaceReviewBlocks(text, (whole, attributes, rawBody) => { const record = reviewRecord(attributes, rawBody, reviews.length + 1); - // Keep the original prose if converting it would produce a record the wire drops. if (!record || !isReviewCommentContextRecord(record)) return whole; reviews.push(record); return `${reviewToken}${reviews.length - 1}${reviewToken}`; @@ -404,15 +396,13 @@ export function upgradeLegacyContextMessage(text: string): UpgradedLegacyContext formatComposerContextReference(reviews[Number(index)]!), ); - // Placeholders bind to terminal entries in order, like the old materialize step did. let placeholderIndex = 0; body = body.replace(new RegExp(PLACEHOLDER, "g"), () => { const record = terminals[placeholderIndex]; placeholderIndex += 1; return record ? formatComposerContextReference(record) : ""; }); - // Sent messages carry the materialized `@terminal-1:509-514` label instead of the - // placeholder; each such label becomes the chip in place. + const placedTerminals = new Set(terminals.slice(0, placeholderIndex)); for (const record of terminals) { if (placedTerminals.has(record)) continue; diff --git a/packages/shared/src/composerContextLegacySend.test.ts b/packages/shared/src/composerContextLegacySend.test.ts index 41df52042c61..7541ee0bbfbb 100644 --- a/packages/shared/src/composerContextLegacySend.test.ts +++ b/packages/shared/src/composerContextLegacySend.test.ts @@ -68,11 +68,9 @@ describe("serializeLegacyContextMessage", () => { const text = `Look at ${formatComposerContextReference(terminal)} please`; const legacy = serializeLegacyContextMessage({ text, records: [terminal] }); - // An older server forwards text verbatim, so the payload has to be in it. expect(legacy).not.toContain("t3-context://"); expect(legacy).toContain("boom"); - // A newer client reading that message reconstructs the same excerpt. const upgraded = upgradeLegacyContextMessage(legacy); expect(upgraded.records).toHaveLength(1); expect(upgraded.records[0]).toMatchObject({ diff --git a/packages/shared/src/composerContextLegacySend.ts b/packages/shared/src/composerContextLegacySend.ts index de80d242705b..51e8f1cabb5e 100644 --- a/packages/shared/src/composerContextLegacySend.ts +++ b/packages/shared/src/composerContextLegacySend.ts @@ -6,9 +6,6 @@ import { collectComposerContextReferences } from "./composerContextReferences.ts * Serializes a canonical message (inline reference links plus records) into the pre-inline-context * wire shape, for servers that do not advertise `inlineMessageContext`. Those servers drop the * records and forward the links as literal text, so the payload has to travel inside the message. - * - * The output is the format `upgradeLegacyContextMessage` parses, so a newer client reading the - * resulting message reconstructs the same records. */ export function serializeLegacyContextMessage(input: { text: string; @@ -17,8 +14,6 @@ export function serializeLegacyContextMessage(input: { const recordsById = new Map(input.records.map((record) => [record.contextId, record])); const used = new Set(); - // Inline references become the plain label the old composer wrote; the payload follows in a - // trailing block, which is where the legacy format carried it. let text = input.text; const occurrences = collectComposerContextReferences(text); for (const occurrence of [...occurrences].reverse()) { @@ -47,8 +42,6 @@ export function serializeLegacyContextMessage(input: { .map(renderReviewComment), ].filter((block) => block.length > 0); - // Review comments already inlined their payload above; anything else unreferenced still ships - // its block so no context is silently dropped. return [text.trimEnd(), ...blocks].filter((part) => part.length > 0).join("\n\n"); } diff --git a/packages/shared/src/composerContextReferences.test.ts b/packages/shared/src/composerContextReferences.test.ts index f79cd8b2bc58..b1a41b2eac3f 100644 --- a/packages/shared/src/composerContextReferences.test.ts +++ b/packages/shared/src/composerContextReferences.test.ts @@ -315,7 +315,6 @@ describe("toComposerContextId", () => { expect(toComposerContextId("::")).toMatch(/^ctx-[0-9a-f]{16}$/); }); it("tells apart producer ids that agree past the slug's truncation point", () => { - // The slug keeps 48 characters, so only the digest distinguishes these two. const shared = `pull-request-finding:${"a".repeat(60)}`; const first = toComposerContextId(`${shared}:1`); const second = toComposerContextId(`${shared}:2`); diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index ef83dac8c9f1..8bad6dcd3f73 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -7,12 +7,6 @@ import { type KnownComposerContextRecord, } from "@t3tools/contracts"; -/** - * Canonical inline reference: `[label](t3-context://v1//)`, or the image - * form `![label](...)`. The link carries position and identity only; the payload lives in the - * message's context records. Labels are display text and never identity. - */ - const CONTEXT_PROTOCOL = "t3-context:"; const COMPOSER_CONTEXT_HREF_PREFIX = `${CONTEXT_PROTOCOL}//v1/`; const CONTEXT_KIND_PATTERN = /^[a-z][a-z0-9-]{0,39}$/; @@ -24,9 +18,8 @@ const CONTEXT_LINK = new RegExp( ); /** - * Two independent FNV-1a passes, one forward and one with a different offset basis over the - * reversed input. 64 bits of digest, because the slug in front of it is truncated: two long - * producer ids that agree on their first 48 characters are told apart by this alone. + * 64 bits of digest, because the slug in front of it is truncated: two long producer + * ids that agree on their first 48 characters are told apart by this alone. */ function fnv1a64(value: string): string { let forward = 0x811c9dc5; @@ -40,12 +33,6 @@ function fnv1a64(value: string): string { return `${forward.toString(16).padStart(8, "0")}${reverse.toString(16).padStart(8, "0")}`; } -/** - * Producers mint ids in their own grammars (`pull-request-finding:42`, - * `file-comment--`). A context id must survive a Markdown link and the wire - * schema, so anything outside `[a-z0-9_-]` is folded into a readable slug plus a hash of - * the original. Deterministic, so the same producer id always maps to the same context id. - */ export function toComposerContextId(producerId: string): ComposerContextId { if (CONTEXT_ID_PATTERN.test(producerId)) return producerId as ComposerContextId; const slug = producerId @@ -55,7 +42,6 @@ export function toComposerContextId(producerId: string): ComposerContextId { return `${slug || "ctx"}-${fnv1a64(producerId)}` as ComposerContextId; } -/** Raw producer IDs are always scoped, even when they already start with the kind name. */ export function toKindScopedComposerContextId( kind: ComposerContextKind, producerId: string, @@ -80,7 +66,6 @@ export function parseComposerContextHref( return { kind, contextId: contextId as ComposerContextId }; } -/** Labels must survive a Markdown link: no brackets or line breaks, bounded, never empty. */ export function sanitizeComposerContextLabel(label: string, kind: ComposerContextKind): string { const cleaned = label .replace(/[[\]\\\r\n]/g, " ") @@ -104,7 +89,6 @@ export interface ComposerContextReferenceOccurrence { kind: ComposerContextKind; contextId: ComposerContextId; label: string; - /** Whether the occurrence used the `![...]` image form. */ image: boolean; source: string; start: number; @@ -115,8 +99,6 @@ export function collectComposerContextReferences( text: string, ): ComposerContextReferenceOccurrence[] { const occurrences: ComposerContextReferenceOccurrence[] = []; - // No link can match without the protocol prefix; skip the scan entirely on - // plain prose so long messages never pay for a regex walk per `[`. if (!text.includes("](t3-context:")) return occurrences; for (const match of text.matchAll(CONTEXT_LINK)) { const parsed = parseComposerContextHref(match[3]!); @@ -133,11 +115,9 @@ export function collectComposerContextReferences( return occurrences; } -/** Text sent in place of an empty prompt when a message is attachments only. */ export const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; -/** Whether `text` is image references and nothing else, which is how an image-only prompt reads. */ export function isImageReferencesOnly(text: string): boolean { const references = collectComposerContextReferences(text); return ( @@ -160,10 +140,6 @@ export function replaceComposerContextReferences( return result + text.slice(cursor); } -// --------------------------------------------------------------------------- -// Provider projection -// --------------------------------------------------------------------------- - const CONTEXT_ENVELOPE_TAG = "t3_context"; const CONTEXT_ENTRY_TAG = "context"; @@ -172,7 +148,6 @@ function kindDisplayName(kind: ComposerContextKind): string { return spaced.charAt(0).toUpperCase() + spaced.slice(1); } -/** `[Image: shot.png; ref=ctx_1]` β€” readable in place, with the id the payload is keyed by. */ export function formatComposerContextProviderMarker( kind: ComposerContextKind, label: string, @@ -186,8 +161,8 @@ export function formatComposerContextProviderMarker( } /** - * Captured text is data. A terminal line or PR comment that contains `` or - * `` must not be able to close the envelope and forge a record. + * A terminal line or PR comment that contains `` or `` + * must not be able to close the envelope and forge a record. */ function escapeComposerContextPayloadText(text: string): string { return text.replace( @@ -233,7 +208,6 @@ function formatElementDetails(element: ElementContextDetails): string[] { return lines; } -/** Body lines for one payload, including authoritative paths and names behind display labels. */ function formatComposerContextProviderPayload(record: KnownComposerContextRecord): string { switch (record.kind) { case "image": @@ -312,11 +286,6 @@ function formatEnvelopeEntry( return `${open}>\n${escapeComposerContextPayloadText(body)}\n`; } -/** - * What the provider reads: every reference becomes an in-place marker, and each unique - * referenced payload appears once in a trailing envelope, in first-reference order. - * Unreferenced records are not emitted. Binary attachments travel on their own channel. - */ export function projectComposerContextForProvider(input: { text: string; records: ReadonlyArray; @@ -325,7 +294,6 @@ export function projectComposerContextForProvider(input: { if (occurrences.length === 0) return input.text; const recordsById = new Map(); for (const record of input.records) { - // Even callers that bypass the wire schema must not silently select an ambiguous payload. recordsById.set(record.contextId, recordsById.has(record.contextId) ? undefined : record); } const body = replaceComposerContextReferences(input.text, (occurrence) => @@ -352,7 +320,6 @@ export function projectComposerContextForProvider(input: { return `${body}\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n${entries.join("\n")}\n`; } -/** Preserve context bindings when uploads become thread-owned attachments. */ export function remapComposerContextAttachments( context: import("@t3tools/contracts").OrchestrationMessageContext | undefined, before: ReadonlyArray<{ readonly id?: string | undefined }>, diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 5f19fb2a9989..245157a3a303 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -184,8 +184,6 @@ describe("collectComposerInlineTokens", () => { }); it("stays fast on unterminated bracket runs", () => { - // Unbounded, the label body rescanned the rest of the text from every - // whitespace: this input took seconds. const started = performance.now(); expect(collectComposerInlineTokens(" [[".repeat(40_000))).toEqual([]); expect(performance.now() - started).toBeLessThan(1_000); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index 0e3a48f35d27..90051d18e8f7 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -18,35 +18,18 @@ export interface CollectComposerInlineTokensOptions { readonly preserveTrailingFrom?: ReadonlyArray; } -/** - * A skill name may start with a digit, but compact monetary amounts and - * numeric expressions like "$20", "$20k", "$100M", and "$1e6" must stay prose: - * the composer chips any matched `$name` token, known or not. Tokens beginning - * with digits must not match numbers with currency/exponent suffixes, and must - * contain at least one letter. Any currency symbol is accepted as the sigil. - */ const SKILL_MENTION_SOURCE = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)/u .source; -// While typing, a token only becomes a chip once a delimiter follows it, so a -// half-typed name at the end of the text stays plain. const SKILL_TOKEN_REGEX = new RegExp(`${SKILL_MENTION_SOURCE}(?=\\s)`, "gu"); -/** - * Skill mentions in a sent prompt, which may also end at the end of the text. - * Group 1 is the leading delimiter and group 2 the skill name. The pattern is - * global, so use it with `matchAll` or `replace`, not `test` or `exec`. - */ export const SKILL_MENTION_PATTERN = new RegExp(`${SKILL_MENTION_SOURCE}(?=\\s|$)`, "gu"); const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; /** - * The label body is bounded rather than `*`. Unbounded, every whitespace in - * the composer is a candidate start: the engine scans the rest of the text for - * a closing `]`, fails, and rescans from the next whitespace β€” quadratic on - * input like " [[[[[…". A cap makes each attempt constant-bounded. - * - * Only a basename ever survives the `label !== basename` check below, so this - * cannot reject a link a user could meaningfully write; the longest filename - * any common filesystem allows is 255. + * Unbounded, every whitespace in the composer is a candidate start: the engine scans the rest + * of the text for a closing `]`, fails, and rescans from the next whitespace β€” quadratic on + * input like " [[[[[…". Only a basename ever survives the `label !== basename` check below, so + * this cannot reject a link a user could meaningfully write; the longest filename any common + * filesystem allows is 255. */ const MAX_FILE_LINK_LABEL_LENGTH = 512; const FILE_LINK_TOKEN_REGEX = new RegExp( @@ -55,7 +38,6 @@ const FILE_LINK_TOKEN_REGEX = new RegExp( ); const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; -// Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package. const SCOPED_PACKAGE_REFERENCE_REGEX = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/; @@ -70,9 +52,7 @@ function collectMentionTokens(text: string): ComposerInlineToken[] { let path = encodedPath; try { path = decodeURIComponent(encodedPath); - } catch { - // Preserve malformed source rather than dropping a user-authored token. - } + } catch {} const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); diff --git a/packages/shared/src/composerPullRequestMatches.test.ts b/packages/shared/src/composerPullRequestMatches.test.ts index 5e2eb3657c27..0463fc51789c 100644 --- a/packages/shared/src/composerPullRequestMatches.test.ts +++ b/packages/shared/src/composerPullRequestMatches.test.ts @@ -27,7 +27,6 @@ describe("filterComposerPullRequestMatches", () => { }); it("leaves the caller's array untouched", () => { - // The sort runs on a copy; mutating the input would reorder whatever the caller holds. const entries = [entry(2, "2026-01-01"), entry(1, "2026-01-02")]; const snapshot = entries.map((match) => match.number); filterComposerPullRequestMatches({ diff --git a/packages/shared/src/composerPullRequestMatches.ts b/packages/shared/src/composerPullRequestMatches.ts index b844a2739edf..9e5ed4da610a 100644 --- a/packages/shared/src/composerPullRequestMatches.ts +++ b/packages/shared/src/composerPullRequestMatches.ts @@ -9,10 +9,6 @@ export interface ComposerPullRequestMatch { readonly updatedAt: string; } -/** - * Suggestion rows for the pull requests a thread links, from any repository. Built from the - * synced snapshot, so a link the server has not read yet is left out until it has one. - */ export function composerPullRequestsFromLinks( links: ReadonlyArray, projectId: string, @@ -37,12 +33,6 @@ export function composerPullRequestsFromLinks( ); } -/** - * Pull requests matching the numeric fragment typed after `#`, de-duplicated. `entries` are kept - * to the project's repository; `linked` rows (the thread's own pull requests) may come from any - * repository. An exact number match always outranks a substring match so the result limit can - * never drop the pull request the user typed in full; then linked rows lead, newest first. - */ export function filterComposerPullRequestMatches< Entry extends ComposerPullRequestMatch, Linked extends ComposerPullRequestMatch = never, @@ -65,7 +55,6 @@ export function filterComposerPullRequestMatches< entry.repository.trim().toLowerCase() === repository && String(entry.number).includes(input.query), ); - // Project rows go first so a linked pull request the listing also has keeps the fresher row. const uniqueEntries = new Map(); for (const entry of [...matchingEntries, ...linked]) { if (!uniqueEntries.has(keyOf(entry))) { diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 9143f27172e1..c5d26b66ef68 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -45,13 +45,6 @@ function isWhitespace(char: string): boolean { return char === " " || char === "\n" || char === "\t" || char === "\r"; } -/** - * Detect an active trigger (@path, $skill, /command) at the cursor position. - * - * Accepts an optional `isWhitespaceChar` override so callers with inline - * placeholder characters (e.g. terminal context chips on web) can treat - * those as token boundaries. - */ export function detectComposerTrigger( text: string, cursorInput: number, diff --git a/packages/shared/src/connectAuth.ts b/packages/shared/src/connectAuth.ts index 0d9b089c34d4..22b6190d68c2 100644 --- a/packages/shared/src/connectAuth.ts +++ b/packages/shared/src/connectAuth.ts @@ -7,42 +7,22 @@ const CONNECT_LOOPBACK_CALLBACK_PATH = "/callback"; const CONNECT_AUTHORIZE_PATH = "/connect"; -/** - * The CLI prints URLs against this origin and the web bundle uses it to - * decide whether it is the hosted deployment β€” the two must agree, so the - * default lives here. - */ export const DEFAULT_HOSTED_APP_URL = "https://app.t3.codes"; -/** - * Requested at authorize time by the hosted page and by the CLI's device - * authorization request; keep both sides on this single definition. - * `offline_access` asks Clerk for the refresh token the CLI relies on. - */ export const CONNECT_OAUTH_SCOPES = ["openid", "profile", "email", "offline_access"] as const; export interface ConnectAuthorizeRequest { readonly state: string; readonly challenge: string; - /** - * The hosted /connect page asks Clerk to redirect the authorization code - * straight to `http://127.0.0.1:/callback` on the waiting CLI. - */ readonly loopbackPort: number; } /** - * The URL the CLI prints for the user to open in a browser. `state` and - * `code_challenge` ride the fragment so they never reach the hosted app's - * server or CDN logs; neither is a secret. - * - * The CLI routes through the hosted /connect page rather than hitting - * Clerk's /oauth/authorize directly: a signed-out browser sent straight to + * The CLI routes through the hosted /connect page rather than hitting Clerk's + * /oauth/authorize directly: a signed-out browser sent straight to * /oauth/authorize goes through Clerk's sign-in redirect, which does not * reliably preserve the authorize query parameters (state, response_type, - * code_challenge). The hosted page waits for a Clerk session first, then - * forwards the request with the parameters intact. Headless hosts use the - * OAuth device authorization grant instead and never involve this page. + * code_challenge). */ export function buildConnectAuthorizeRequestUrl(input: { readonly hostedAppUrl: string; diff --git a/packages/shared/src/dateTime.ts b/packages/shared/src/dateTime.ts index 544dd2d0d73b..67bdd01e465e 100644 --- a/packages/shared/src/dateTime.ts +++ b/packages/shared/src/dateTime.ts @@ -25,7 +25,6 @@ function parseTimestamp(value: string): number { return Date.parse(value); } -/** Compare date-time strings by absolute time, with stable handling for malformed stored values. */ export function compareDateTimeStrings(left: string, right: string): number { const leftTimestamp = parseTimestamp(left); const rightTimestamp = parseTimestamp(right); diff --git a/packages/shared/src/delimitedPreview.ts b/packages/shared/src/delimitedPreview.ts index 1cfbd41a7812..6420b5d72f30 100644 --- a/packages/shared/src/delimitedPreview.ts +++ b/packages/shared/src/delimitedPreview.ts @@ -1,4 +1,3 @@ -/** A table preview is bounded independently of the source preview's byte limit. */ export function filePreviewDelimiter(file: { name: string; mimeType?: string }): "," | "\t" | null { const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase(); if (mime === "text/csv") return ","; @@ -9,7 +8,6 @@ export function filePreviewDelimiter(file: { name: string; mimeType?: string }): return null; } -/** Preserve quoted delimiters, escaped quotes and multiline cells; raw mode retains all text. */ export function parseDelimitedPreview(text: string, delimiter: "," | "\t") { const rows: string[][] = []; let row: string[] = []; diff --git a/packages/shared/src/desktopAppControl.ts b/packages/shared/src/desktopAppControl.ts index 42fc391d8bdb..63efbbd86055 100644 --- a/packages/shared/src/desktopAppControl.ts +++ b/packages/shared/src/desktopAppControl.ts @@ -12,7 +12,6 @@ function shortHash(value: string): string { } /** - * Returns the local-only socket address shared by the desktop shell and CLI. * The state directory is hashed so custom T3 homes cannot exceed Unix socket * path limits. */ diff --git a/packages/shared/src/desktopBootstrapToken.test.ts b/packages/shared/src/desktopBootstrapToken.test.ts index bdf0199850d4..c097e43ed52c 100644 --- a/packages/shared/src/desktopBootstrapToken.test.ts +++ b/packages/shared/src/desktopBootstrapToken.test.ts @@ -21,7 +21,6 @@ describe("desktop bootstrap token", () => { }); it("accepts a token from a desktop clock slightly ahead of the backend", () => { - // Windows crossed the boundary, the WSL clock has not yet. const desktopNow = 10 * WINDOW + 500; const backendNow = 10 * WINDOW - 500; diff --git a/packages/shared/src/desktopBootstrapToken.ts b/packages/shared/src/desktopBootstrapToken.ts index f18a0f6aa91f..4e254e106222 100644 --- a/packages/shared/src/desktopBootstrapToken.ts +++ b/packages/shared/src/desktopBootstrapToken.ts @@ -2,16 +2,11 @@ import * as NodeCrypto from "node:crypto"; /** - * The desktop app and the backends it launches share one secret, delivered - * over the backend's bootstrap channel and never sent to the renderer. Both - * sides derive the bootstrap token for a time window from it, so the token the - * renderer holds rotates every window without the desktop having to reach a - * running backend. A backend accepts the previous, current and next window's - * token: the previous one so a token works for between one and two windows, - * and the next one because a WSL backend's clock can trail the Windows clock - * that derived the token, which would otherwise reject every fresh token for - * a moment after each boundary. A token is still dead two windows after it - * was issued. + * A backend accepts the previous, current and next window's token: the previous + * one so a token works for between one and two windows, and the next one + * because a WSL backend's clock can trail the Windows clock that derived the + * token, which would otherwise reject every fresh token for a moment after each + * boundary. */ export const DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS = 12 * 60 * 60 * 1000; @@ -25,12 +20,10 @@ function deriveToken(secret: string, window: number): string { .digest("hex"); } -/** The token the desktop hands out at `nowMs`. */ export function currentDesktopBootstrapToken(secret: string, nowMs: number): string { return deriveToken(secret, windowIndex(nowMs)); } -/** Whether `token` is the previous, current or next window's token at `nowMs`. */ export function isValidDesktopBootstrapToken( secret: string, token: string, diff --git a/packages/shared/src/devHome.test.ts b/packages/shared/src/devHome.test.ts index 812c34e3ccee..b21bcd1cb1b0 100644 --- a/packages/shared/src/devHome.test.ts +++ b/packages/shared/src/devHome.test.ts @@ -24,10 +24,8 @@ const makeRepo = ( if (kind === "worktree") { NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /elsewhere/.git/worktrees/x\n"); } else if (kind === "bare-repo-worktree") { - // `git worktree add` from a bare repo: the common dir is `.git`. NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /srv/myrepo.git/worktrees/x\n"); } else if (kind === "custom-common-dir-worktree") { - // $GIT_COMMON_DIR need not be named `.git` at all. NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /srv/store/worktrees/x\n"); } else if (kind === "submodule") { NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: ../.git/modules/sub\n"); diff --git a/packages/shared/src/devHome.ts b/packages/shared/src/devHome.ts index 1fde5409f5cd..e35e63681b01 100644 --- a/packages/shared/src/devHome.ts +++ b/packages/shared/src/devHome.ts @@ -1,7 +1,4 @@ /** - * Where development state lives, and how to keep it away from the shared - * `~/.t3` that a user's installed T3 Code runs against. - * * A linked git worktree gets its own (gitignored) `.t3`: feature work in a * throwaway branch must not share a database with the real app, and an ambient * `T3CODE_HOME` counts as an explicit base dir β€” flipping the state directory @@ -14,11 +11,6 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; /** - * A `.git` file points at the real git directory. A linked worktree's lives at - * `/worktrees/`; a submodule's at - * `/modules/`. Both are files, so the pointer β€” not the - * file-vs-directory distinction alone β€” is what identifies a worktree. - * * The common dir is not necessarily named `.git`: a worktree of a bare repo * points at `.git/worktrees/`, and `$GIT_COMMON_DIR` can be * anything. So match on the `worktrees/` tail, which git always uses, @@ -34,25 +26,13 @@ const pointsAtLinkedWorktree = (gitFileContents: string, path: Path.Path): boole if (gitdir === undefined || gitdir.length === 0) { return false; } - // Compare as path segments so a directory merely named `…worktrees…` cannot - // match as a substring. Trailing separators normalize away first. const segments = path .normalize(gitdir.replaceAll("\\", "/")) .split(/[/\\]/) .filter((segment) => segment.length > 0); - // `/worktrees/`: `worktrees` is the penultimate segment, - // and something must precede it. This excludes `/modules/`. return segments.length >= 3 && segments.at(-2) === "worktrees"; }; -/** - * The path of the linked git worktree containing `cwd`, or undefined when - * `cwd` is not inside one. Git marks a linked worktree by making `.git` a file - * whose `gitdir:` points into the repository's `.git/worktrees`. - * - * Walks up to the repository root, so running from a subdirectory resolves the - * same worktree as running from the top. - */ export const resolveGitWorktreePath = ( cwd: string, ): Effect.Effect => @@ -65,13 +45,9 @@ export const resolveGitWorktreePath = ( const gitPath = path.join(directory, ".git"); const info = yield* fileSystem.stat(gitPath).pipe(Effect.option); if (Option.isSome(info)) { - // A directory means the main checkout. Stop either way: nesting one - // repository inside another does not make the outer one this root. if (info.value.type !== "File") { return undefined; } - // A submodule also has a `.git` file, but it is not a worktree of this - // repository and gets no worktree-local home. const contents = yield* fileSystem .readFileString(gitPath) .pipe(Effect.orElseSucceed(() => "")); @@ -86,9 +62,8 @@ export const resolveGitWorktreePath = ( }); /** - * The worktree-local data directory for `cwd`, or undefined outside a linked - * worktree. Deliberately does not require the directory to exist yet: falling - * back because it is missing would send callers at the shared home. + * Deliberately does not require the directory to exist yet: falling back + * because it is missing would send callers at the shared home. */ export const resolveWorktreeT3Home = ( cwd: string, diff --git a/packages/shared/src/devProxy.ts b/packages/shared/src/devProxy.ts index 709a5449c22e..a6c8039148e6 100644 --- a/packages/shared/src/devProxy.ts +++ b/packages/shared/src/devProxy.ts @@ -1,6 +1,4 @@ /** - * Backend paths the web dev server proxies in single-origin browser dev. - * * Two consumers must agree on this list: the Vite proxy map * (apps/web/vite.config.ts) that forwards these to the backend, and the * server's dev catch-all (apps/server/src/http.ts) that 404s them instead of @@ -11,8 +9,7 @@ export const DEV_PROXIED_PATH_PREFIXES = ["/api", "/oauth", "/.well-known", "/ws", "/mcp"] as const; /** - * Prefixes the proxy must forward with the browser's own Host. MCP OAuth - * derives its issuer and resource URLs from the request, and a client + * MCP OAuth derives its issuer and resource URLs from the request, and a client * rejects metadata naming a different origin than the one it fetched. The * server accepts a cookie session on `/ws` only when the page's Origin matches * the Host it sees, so whatever origin the page was opened on keeps working. diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts index 13279bd726af..4a263d0b10e8 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -19,7 +19,7 @@ const installNames: Partial>> = { rider: ["Rider", "JetBrains Rider"], }; -/** True for ` .app`, where the version is digits and dots (`IntelliJ IDEA 2026.1.4.app`). +/** * A plain prefix match would let one editor steal another's bundle, e.g. stable VS Code resolving to * `Visual Studio Code - Insiders.app`. */ const isVersionedBundle = (entry: string, name: string) => { diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts index 877f178d6072..214214f86ac5 100644 --- a/packages/shared/src/favicon.ts +++ b/packages/shared/src/favicon.ts @@ -1,10 +1,5 @@ import { isPublicFaviconHost } from "./hostClassification.ts"; -/** - * Mirrors Codex's generic Browser Use fallback: ask the page origin for its - * conventional favicon and let the image element fall back to a browser glyph. - * Chrome-backed tools can pass their tab's explicit favicon URL separately. - */ function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { if (!rawUrl || rawUrl.length > 4096) return null; try { @@ -52,12 +47,6 @@ function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { } } -/** - * Chooses a website icon for the app's resolved theme. Provider-supplied - * variants mirror Codex's Chrome-selected favicon path. A small site-owned - * fallback table covers websites whose conventional favicon is illegible in - * one appearance without recoloring full-color icons. - */ export function toolActivityFaviconUrl( icon: { readonly pageUrl: string; @@ -82,11 +71,6 @@ export function toolActivityFaviconUrl( ); } -/** - * Return a public favicon URL that discloses only a public hostname, never the - * port, path, or a private or internal-looking host. Callers show a generic - * icon when this returns null. - */ export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { if (!rawUrl) return null; try { diff --git a/packages/shared/src/fileLinks.ts b/packages/shared/src/fileLinks.ts index 16e1ca567ce7..65328be0ae6a 100644 --- a/packages/shared/src/fileLinks.ts +++ b/packages/shared/src/fileLinks.ts @@ -167,7 +167,6 @@ export function buildFileLinkParentSuffixByPath( return suffixByPath; } -/** Visible text of a file chip, shared by Markdown rendering and thread search. */ export function fileLinkLabel( file: FilePathPosition, parentSuffixByPath: ReadonlyMap, diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index b35b8a7ecd59..85a783b59544 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -14,8 +14,6 @@ const ICON_EXTENSION_BY_MIME_TYPE: Readonly> = { "text/x-shellscript": "sh", }; -/** A detected type fills in an unknown filename's icon; callers keep any - * specific icon already supplied by the filename. */ export function fileIconPathForMimeType(path: string, mimeType?: string): string { const extension = ICON_EXTENSION_BY_MIME_TYPE[mimeType ?? ""]; return extension ? `file.${extension}` : path; @@ -31,7 +29,6 @@ export type FilePreviewKind = | "text" | "unsupported"; -/** Content classification is identical for captured attachments and workspace references. */ export function filePreviewKind(file: { readonly name: string; readonly mimeType?: string; @@ -76,7 +73,6 @@ export function filePreviewKind(file: { export const FILE_TEXT_PREVIEW_MAX_BYTES = 1024 * 1024; -/** Reject binary data rather than displaying replacement characters as a document. */ export function decodeFilePreviewText(bytes: Uint8Array, truncated = false) { const bounded = bytes.subarray(0, FILE_TEXT_PREVIEW_MAX_BYTES); if (bounded.some((byte) => byte === 0)) @@ -134,7 +130,6 @@ const AUDIO_MIME_TYPE_BY_EXTENSION = new Map([ [".aiff", "audio/aiff"], ]); -/** Audio a player can request inline; the server serves these with byte ranges like video. */ export function audioMimeTypeFromExtension(extension: string): string | null { if (!/^\.[a-z0-9]+$/i.test(extension)) return null; return AUDIO_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? null; @@ -149,7 +144,6 @@ export function mediaMimeTypeFromExtension(extension: string): string | null { ); } -/** Files the server serves in place from anywhere on its host: media, audio and browser documents. */ export function hostPreviewMimeTypeFromExtension(extension: string): string | null { if (!/^\.[a-z0-9]+$/i.test(extension)) return null; return ( @@ -177,9 +171,7 @@ export function mediaMimeType(path: string): string | null { } try { sourcePath = decodeURIComponent(sourcePath); - } catch { - // A literal percent character is valid in a filename. - } + } catch {} const basename = sourcePath.split(/[\\/]/).at(-1) ?? ""; const extensionIndex = basename.lastIndexOf("."); return extensionIndex < 0 ? null : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 7ca2678d4c42..7ff050fbb847 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -78,8 +78,6 @@ describe("normalizeGitRemoteUrl", () => { }); it("leaves an Azure SSH host it cannot read as the path it was given", () => { - // Not `v3`, and not four segments: rewriting either would invent a repository that the web - // spelling has no name for, so the remote stands as it arrived. expect(normalizeGitRemoteUrl("git@ssh.dev.azure.com:v4/T3Tools/Platform/T3Code")).toBe( "ssh.dev.azure.com/v4/t3tools/platform/t3code", ); @@ -120,7 +118,6 @@ describe("parseOriginUrlFromGitConfig", () => { expect(parseOriginUrlFromGitConfig("[remote.origin]\n\turl = git@github.com:a/b.git\n")).toBe( "git@github.com:a/b.git", ); - // Git folds the dotted form to lowercase but keeps quoted names as written. expect(parseOriginUrlFromGitConfig("[remote.Origin]\n\turl = git@github.com:a/b.git\n")).toBe( "git@github.com:a/b.git", ); @@ -203,9 +200,7 @@ describe("isTemporaryWorktreeBranch", () => { }); it("rejects UUID-shaped refs that are not RFC 4122 v4", () => { - // version nibble is not 4 expect(isTemporaryWorktreeBranch("t3code/f4ae4e0e-f971-1d48-b4f2-9cf0aa54ab12")).toBe(false); - // variant nibble is not [89ab] expect(isTemporaryWorktreeBranch("t3code/f4ae4e0e-f971-4d48-c4f2-9cf0aa54ab12")).toBe(false); }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 3d9af4b7ed8f..14af818f4c21 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -12,12 +12,6 @@ import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3"; -// Canonical form is `t3/<8 hex>`. `t3-<8 hex>` is the fallback when a plain `t3` -// branch blocks the namespace. The matcher also accepts every legacy shape, so -// existing threads stay eligible for branch regeneration: `t3code/<8 hex>` and -// `t3code-<8 hex>` from before the prefix was shortened, and `t3code/` from -// older mobile builds that used Crypto.randomUUID() (always RFC 4122 v4, so version -// nibble `4` and variant nibble `[89ab]`). Nothing looser than what was generated. const TEMP_WORKTREE_HEX_TOKEN = "[0-9a-f]{8}"; const TEMP_WORKTREE_UUID_V4_TOKEN = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"; @@ -25,10 +19,6 @@ const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( `^(?:${WORKTREE_BRANCH_PREFIX}[-/]${TEMP_WORKTREE_HEX_TOKEN}|t3code(?:[-/]${TEMP_WORKTREE_HEX_TOKEN}|\\/${TEMP_WORKTREE_UUID_V4_TOKEN}))$`, ); -/** - * Sanitize an arbitrary string into a valid, lowercase git refName fragment. - * Strips quotes, collapses separators, limits to 64 chars. - */ export function sanitizeBranchFragment(raw: string): string { const normalized = raw .trim() @@ -47,7 +37,6 @@ export function sanitizeBranchFragment(raw: string): string { return branchFragment.length > 0 ? branchFragment : "update"; } -/** Custom naming preserves the model's complete ref; Git validates it on rename. */ export function formatGeneratedBranchName(raw: string, naming?: BranchNamingOptions): string { if (naming?.mode === "custom") return raw.trim(); const branch = sanitizeBranchFragment(raw); @@ -65,10 +54,6 @@ export function formatGeneratedBranchName(raw: string, naming?: BranchNamingOpti return prefix ? `${prefix}/${branch}` : branch; } -/** - * Sanitize a generated branch name, preserving any slash-separated namespace. - * Unprefixed fragments use the `feature/` namespace. - */ export function sanitizeFeatureBranchName(raw: string): string { const sanitized = sanitizeBranchFragment(raw); return sanitized.includes("/") ? sanitized : `feature/${sanitized}`; @@ -76,10 +61,6 @@ export function sanitizeFeatureBranchName(raw: string): string { const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; -/** - * Resolve a unique generated refName, suffixing only the path component - * blocked by an existing ref or, for the final component, its descendants. - */ export function resolveAutoFeatureBranchName( existingBranchNames: readonly string[], preferredBranch?: string, @@ -111,9 +92,6 @@ export function resolveAutoFeatureBranchName( return resolved; } -/** - * Strip the remote prefix from a remote ref such as `origin/feature/demo`. - */ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { const firstSeparatorIndex = branchName.indexOf("/"); if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { @@ -125,8 +103,6 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { export function buildTemporaryWorktreeBranchName( randomHex: (byteLength: number) => string, ): string { - // Normalize to exactly 8 lowercase hex chars so a UUID-shaped callback - // still produces the canonical temporary branch form. const token = randomHex(4) .toLowerCase() .replace(/[^0-9a-f]/g, "") @@ -139,7 +115,6 @@ export function buildTemporaryWorktreeBranchName( * ref impossible. This moves a temporary name to the flat `t3-` sibling. */ export function flattenTemporaryWorktreeBranchName(refName: string): string { - // Keep only the canonical 8-hex token so legacy `t3code/` and UUID names map cleanly. const normalized = refName.trim().toLowerCase(); const tokenStart = normalized.search(/[-/]/) + 1; const token = normalized.slice(tokenStart, tokenStart + 8); @@ -151,13 +126,10 @@ export function isTemporaryWorktreeBranch(refName: string): boolean { } /** - * The web spelling of an Azure DevOps repository reached over SSH, or null for anything else. - * * Azure alone addresses one repository under two names that share no part: `ssh.dev.azure.com` and * `v3/{org}/{project}/{repo}` over SSH, against `dev.azure.com` and `{org}/{project}/_git/{repo}` * everywhere a person sees it. A project cloned over SSH would otherwise be a different repository - * to every comparison made against a pull request URL, which arrives in the web spelling. So the - * web spelling is the one both are keyed by. + * to every comparison made against a pull request URL, which arrives in the web spelling. */ function azureDevOpsRepositoryKey(host: string, segments: ReadonlyArray): string | null { if (host !== "ssh.dev.azure.com" && host !== "vs-ssh.visualstudio.com") return null; @@ -169,9 +141,6 @@ function azureDevOpsRepositoryKey(host: string, segments: ReadonlyArray) : `${organization}.visualstudio.com/${project}/_git/${repository}`; } -/** - * Normalize a git remote URL into a stable comparison key. - */ export function normalizeGitRemoteUrl(value: string): string { const normalized = value .trim() @@ -206,10 +175,6 @@ export function normalizeGitRemoteUrl(value: string): string { return normalized; } -/** - * Unquote a git config value: strip an inline `#` or `;` comment outside - * quotes, then drop surrounding quotes and backslash escapes. - */ function parseGitConfigValue(raw: string): string { let out = ""; let quoted = false; @@ -230,11 +195,6 @@ function parseGitConfigValue(raw: string): string { return out.trim(); } -/** - * Read the primary remote URL from raw `.git/config` text without spawning - * git. Prefers `remote.origin.url` and falls back to the first remote so - * clones made with `git clone --origin ` still resolve. - */ export function parseOriginUrlFromGitConfig(configText: string): string | null { let section: string | null = null; let originUrl: string | null = null; @@ -270,9 +230,6 @@ export function parseOriginUrlFromGitConfig(configText: string): string | null { return originUrl ?? firstRemoteUrl; } -/** - * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. - */ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { @@ -307,22 +264,13 @@ function deriveLocalBranchNameCandidatesFromRemoteRef( return [...candidates]; } -// Git rejects ASCII space and the ASCII control characters (tab, newline and -// friends) in ref names, so the picker's "Create new ref" entry can only fail -// for a typed name like "new branch". Replacing runs of those with a dash makes -// the name usable without reimplementing check-ref-format: names invalid for -// other reasons still surface the git error. Only the whitespace git actually -// rejects is replaced β€” git accepts U+00A0 and friends, and rewriting those -// would silently create a ref the user never asked for. Case and existing -// dashes are left alone, since ref names are case sensitive and consecutive -// dashes are valid. +// Only the whitespace git actually rejects is replaced β€” git accepts U+00A0 and +// friends, and rewriting those would silently create a ref the user never asked +// for. export function sanitizeNewRefName(rawName: string): string { return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-"); } -/** - * Hide `origin/*` remote refs when a matching local refName already exists. - */ export function dedupeRemoteBranchesWithLocalMatches( refs: ReadonlyArray, ): ReadonlyArray { diff --git a/packages/shared/src/gitPatchPath.test.ts b/packages/shared/src/gitPatchPath.test.ts index c1592a92acf7..06559edd83f3 100644 --- a/packages/shared/src/gitPatchPath.test.ts +++ b/packages/shared/src/gitPatchPath.test.ts @@ -10,7 +10,6 @@ describe("quoteGitPatchPath", () => { it("leaves a name a header can carry as itself", () => { expect(quoteGitPatchPath("src/app.ts")).toBe("src/app.ts"); expect(quoteGitPatchPath("with space.txt")).toBe("with space.txt"); - // Outside ASCII is only quoted for what a terminal can show, and a diff viewer is not one. expect(quoteGitPatchPath("cafΓ©/rΓ©sumΓ©.ts")).toBe("cafΓ©/rΓ©sumΓ©.ts"); expect(quoteGitPatchPath("πŸš€.ts")).toBe("πŸš€.ts"); }); @@ -49,7 +48,6 @@ describe("a name written into a header and read back out", () => { it.each(names)("is the name that went in: %j", (name) => { const written = quoteGitPatchPath(name); expect(unquoteGitPatchPath(written)).toBe(name); - // A parser that takes the quotes off itself, as the clients' one does, gets there too. const unwrapped = written.startsWith('"') ? written.slice(1, -1) : written; expect(unquoteGitPatchPath(unwrapped)).toBe(name); }); @@ -57,8 +55,6 @@ describe("a name written into a header and read back out", () => { it("carries the whole name past the first thing a header stops at", () => { const written = quoteGitPatchPath("tab\tfile.txt"); - // The bug this guards: a header line ends at a tab and splits on a space, so the name written - // as itself would be read as `tab` and marked viewed under a path no host has. expect(written).not.toContain("\t"); expect(written).not.toContain("\n"); expect(unquoteGitPatchPath(written)).not.toBe("tab"); diff --git a/packages/shared/src/gitPatchPath.ts b/packages/shared/src/gitPatchPath.ts index 767600c4a4b2..316d72f8b977 100644 --- a/packages/shared/src/gitPatchPath.ts +++ b/packages/shared/src/gitPatchPath.ts @@ -1,17 +1,3 @@ -/** - * How a file's name travels in a unified patch, which is not as itself. A reader finds the name in - * a header by where it stops: `diff --git a/ b/` splits on a space, `--- a/` stops - * at a tab. So a name holding a tab or a newline reads back as the part of itself before that - * byte, or fabricates a header line of its own, and git's answer is to write such a name quoted - * with C-style escapes. Both halves live here so what one writes is what the other reads. - */ - -/** - * What git escapes by name, as the character written and the escape written for it. Not every byte - * git would escape: `core.quotePath` also escapes anything outside ASCII, which is a setting for - * what a terminal can show rather than anything the format needs, and a patch here is read by a - * diff viewer. - */ const ESCAPE_BY_CHARACTER = new Map([ ['"', '\\"'], ["\\", "\\\\"], @@ -24,7 +10,6 @@ const ESCAPE_BY_CHARACTER = new Map([ ["\r", "\\r"], ]); -/** The escape written for a character, as the byte it stands for. */ const CHARACTER_BY_ESCAPE: Record = { '"': 0x22, "\\": 0x5c, @@ -46,10 +31,8 @@ const utf8 = new TextEncoder(); const fromUtf8 = new TextDecoder(); /** - * A name as a patch header can carry it: itself where that is unambiguous, and git's quoted form - * where it is not, which is any name holding a quote, a backslash or a control character. A header - * side's `a/` or `b/` belongs inside the quoting, so pass it in along with the name: what git - * quotes is the whole token a reader takes off the line, side letter and all. + * A header side's `a/` or `b/` belongs inside the quoting, so pass it in along with the name: + * what git quotes is the whole token a reader takes off the line, side letter and all. */ export function quoteGitPatchPath(path: string): string { let body = ""; @@ -62,8 +45,6 @@ export function quoteGitPatchPath(path: string): string { continue; } const code = character.codePointAt(0) ?? 0; - // Every character git has no name for is written as the octal of its byte, and a control - // character is one byte in UTF-8, so the character's own code point is that byte. if (code < LOWEST_PRINTABLE || code === DELETE_CHARACTER) { body += `\\${code.toString(8).padStart(3, "0")}`; quoting = true; @@ -75,9 +56,7 @@ export function quoteGitPatchPath(path: string): string { } /** - * The escapes inside a quoted form undone, whether or not the quotes are still around them. A name - * holding no backslash is already itself, and an escape git would never write reads the way C - * reads it. The escapes are per byte, so a name in another alphabet arrives as a run of octal and + * The escapes are per byte, so a name in another alphabet arrives as a run of octal and * only reads back as itself once those bytes are rejoined and decoded together. */ function unescapeBody(body: string): string { @@ -127,10 +106,9 @@ function unescapeBody(body: string): string { } /** - * One header's name token as the name it stands for. The escapes are undone whether the quotes are - * still there or not, because patch parsers disagree about how much of the quoting they hand back: - * the one the clients read diffs with takes the quotes off the `diff --git` line's names and - * leaves them on a rename's. + * The escapes are undone whether the quotes are still there or not, because patch parsers disagree + * about how much of the quoting they hand back: the one the clients read diffs with takes the + * quotes off the `diff --git` line's names and leaves them on a rename's. */ export function unquoteGitPatchPath(token: string): string { if (token.length >= 2 && token.startsWith(QUOTE) && token.endsWith(QUOTE)) { diff --git a/packages/shared/src/githubMarkdownLinks.ts b/packages/shared/src/githubMarkdownLinks.ts index 4fb31502130b..21777947b678 100644 --- a/packages/shared/src/githubMarkdownLinks.ts +++ b/packages/shared/src/githubMarkdownLinks.ts @@ -17,7 +17,6 @@ const parser = unified().use(remarkParse).use(remarkGfm); const reference = /(?`, and a file committed alongside the code - * becomes a `raw.githubusercontent.com` path β€” so the renderer, which carries no GitHub session, - * draws a broken image where the reviewer expects the evidence. The server can fetch these with - * the `gh` credential and hand the bytes back. - * * Only hosts where a credential is what decides the answer belong here. `objects.githubusercontent.com` * and the `private-user-images` links GitHub's own HTML carries are already signed and load on their * own, and a token does nothing for them once that signature expires. */ const RAW_HOST = "raw.githubusercontent.com"; -/** Git LFS pointers resolve here, which is where an LFS-tracked screenshot's bytes live. */ const LFS_HOST = "media.githubusercontent.com"; const ATTACHMENT_PATH_PATTERN = /^\/user-attachments\/assets\/[\w-]+$/u; -/** What GitHub wrote into a body before `user-attachments`; older descriptions still carry it. */ const LEGACY_ATTACHMENT_PATH_PATTERN = /^\/[^/]+\/[^/]+\/assets\/\d+\/[\w-]+$/u; /** `blob` and `raw` both address file bytes; `raw` is the one the token is honoured on. */ const REPOSITORY_FILE_PATTERN = /^\/([^/]+)\/([^/]+)\/(?:raw|blob)\/(.*[^/])$/u; -/** Port, userinfo, and fragment say nothing about which bytes GitHub will serve. */ function canonicalUrl(host: string, url: URL): string { return `https://${host}${url.pathname}${url.search}`; } -/** - * The URL to fetch with a GitHub credential for `source`, or null when the source is not - * GitHub-hosted media β€” those keep loading directly, exactly as they do today. - */ export function githubMediaFetchUrl(source: string): string | null { let url: URL; try { @@ -47,16 +33,11 @@ export function githubMediaFetchUrl(source: string): string | null { return `https://github.com${url.pathname}`; } const repositoryFile = REPOSITORY_FILE_PATTERN.exec(url.pathname); - // `?raw=true` is how the web UI spells "the bytes, not the page"; the raw host needs no query. return repositoryFile ? `https://${RAW_HOST}/${repositoryFile[1]}/${repositoryFile[2]}/${repositoryFile[3]}` : null; } -/** - * Last path segment, for the signed URL's display name. A percent sequence GitHub accepts but - * `decodeURIComponent` rejects is left encoded rather than failing the whole asset. - */ export function githubMediaFileName(fetchUrl: string): string { const segment = new URL(fetchUrl).pathname.split("/").pop() ?? ""; let decoded: string; diff --git a/packages/shared/src/hostClassification.test.ts b/packages/shared/src/hostClassification.test.ts index 9fcb1a7667cb..4110c9f207a5 100644 --- a/packages/shared/src/hostClassification.test.ts +++ b/packages/shared/src/hostClassification.test.ts @@ -71,7 +71,6 @@ describe("isPublicFaviconHost", () => { "::ffff:10.0.0.1", "::ffff:100.126.17.15", "[::ffff:192.168.1.10]", - // c0a8:010a is 192.168.1.10, 0a00:0001 is 10.0.0.1. "::ffff:c0a8:010a", "::ffff:a00:1", ]) { diff --git a/packages/shared/src/hostClassification.ts b/packages/shared/src/hostClassification.ts index 2f0d8a119c8f..b569b58e6c36 100644 --- a/packages/shared/src/hostClassification.ts +++ b/packages/shared/src/hostClassification.ts @@ -93,10 +93,6 @@ export const isTailnetHost = (host: string): boolean => { return ipv6 !== null && ipv6PrefixMatches(ipv6, [0xfd7a, 0x115c, 0xa1e0, 0, 0, 0, 0, 0], 48); }; -/** - * An IPv4 address in 100.64.0.0/10. Tailscale, Cloudflare WARP and Mesh, other - * VPNs, and carrier-grade NAT all assign from this range. - */ export const isSharedAddressSpaceHost = (host: string): boolean => { const parts = parseIpv4Address(host); return parts !== null && parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127; @@ -144,7 +140,6 @@ const PRIVATE_FAVICON_TLDS = [ ]; const INTERNAL_HOST_LABELS: ReadonlySet = new Set(["corp", "internal", "intranet"]); -/** Whether a hostname is eligible to be disclosed to a public favicon provider. */ export const isPublicFaviconHost = (host: string): boolean => { // A single trailing dot is a valid absolute DNS name. Repeated trailing // dots are malformed and can conceal legacy numeric forms such as 127.1. diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index 5bfdd782e360..270fc50add61 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -253,7 +253,6 @@ describe("htmlRenderFrameHeight", () => { }); it("takes the taller layout when a breakpoint falls between measured widths", () => { - // 900px tall below a 600px media query, 450px above it. const responsive = readHtmlRenderReference({ ...reference, height: 2000, @@ -267,7 +266,6 @@ describe("htmlRenderFrameHeight", () => { }); it("fits a page the client lays out taller than the server measured", () => { - // The agent passed contentHeight at the column width, so the page should never scroll. const fitted = { ...measured, height: 1403 }; expect(htmlRenderFrameHeight(fitted, 728, 1415)).toBe(1415); expect(htmlRenderFrameHeight(fitted, 1400)).toBe(1660); @@ -309,7 +307,6 @@ describe("htmlRenderOutlineBoxes", () => { })!; it("round-trips through a tool result and stretches to the client's width and height", () => { - // The empty box is dropped when encoded. expect(outline).toBe("t0,0,75,10,2;m0,12,182,75,1"); expect(htmlRenderOutlineBoxes(outlined, 728)).toEqual([ { kind: "text", x: 0, y: 0, width: 300, height: 40, lines: 2 }, diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index 8f2323ee1787..8d0dabefed37 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -6,45 +6,21 @@ import { } from "./themePalettes.ts"; import { EVENT_CODE_SHORTCUT_KEYS } from "./keybindings.ts"; -/** - * Agent-authored HTML pages ("HTML renders") are self-contained documents an - * agent publishes into a thread with T3's `html_render` MCP tool. The server - * stores each one as a thread attachment with a small bootstrap injected into - * its head; clients show it in a sandboxed iframe (web, desktop) or WebView - * (mobile) and hand it the active theme as CSS custom properties. - */ - export const HTML_RENDER_TOOL_NAME = "html_render"; export const HTML_RENDER_MIN_HEIGHT = 80; export const HTML_RENDER_MAX_HEIGHT = 2000; export const HTML_RENDER_MAX_TITLE_LENGTH = 200; -/** What the `html_render` tool result carries so clients can show the page. */ export interface HtmlRenderReference { readonly attachmentId: string; readonly title: string; - /** The agent's frame height in CSS pixels, and the cap on any measured height. */ readonly height: number; - /** - * `[width, contentHeight]` pairs the server measured at publish, ascending by - * width. Absent when the preview browser was not installed yet. - */ readonly heights?: ReadonlyArray; - /** - * The page's main blocks at the column width, measured with `heights`, so a - * client can draw its loading skeleton in the page's shape. See - * `encodeHtmlRenderOutline`. - */ readonly outline?: string; } -/** Frame widths the server measures a page at, from phones to the wide chat setting. */ export const HTML_RENDER_MEASURE_WIDTHS = [320, 375, 430, 520, 640, 728, 860, 1000, 1144] as const; -/** - * The reply column's frame width at the default chat width. Agents preview at - * it, and it picks the measured height when a client cannot know its width. - */ export const HTML_RENDER_COLUMN_WIDTH = 728; const MAX_MEASURED_HEIGHTS = 24; @@ -94,7 +70,6 @@ export function readHtmlRenderReference(value: unknown): HtmlRenderReference | u }; } -/** Whether two references show the same page at the same sizes. */ export function htmlRenderReferencesEqual(left: HtmlRenderReference, right: HtmlRenderReference) { return ( left.attachmentId === right.attachmentId && @@ -119,12 +94,8 @@ function measuredHeight(heights: NonNullable, wi } /** - * The frame height for a page at a frame width. It is the page's own reported - * `contentHeight` when the client has one, else the server's measurement for - * that width. A page even a few pixels taller than its frame scrolls inside it - * and takes the reader's scroll, so the frame fits the page. The agent's height - * caps it only when it is below the page's height at the column width (the - * agent asked for a scrolling frame) or when the page was never measured. + * A page even a few pixels taller than its frame scrolls inside it and takes + * the reader's scroll, so the frame fits the page. */ export function htmlRenderFrameHeight( reference: HtmlRenderReference, @@ -142,10 +113,6 @@ export function htmlRenderFrameHeight( return clampHtmlRenderHeight(Math.min(cap, contentHeight ?? measuredHeight(heights, width))); } -/** - * One block of a page's outline: `text` with its line count, a `media` element - * or solid shape, or a `table` with its row count. - */ export interface HtmlRenderOutlineBox { readonly kind: "text" | "media" | "table"; readonly x: number; @@ -157,16 +124,13 @@ export interface HtmlRenderOutlineBox { const OUTLINE_KINDS = { t: "text", m: "media", g: "table" } as const; const OUTLINE_LETTERS = { text: "t", media: "m", table: "g" } as const; -// Coordinates travel in 4px units, which keeps the tool result an agent reads short. const OUTLINE_UNIT = 4; export const HTML_RENDER_OUTLINE_MAX_BOXES = 48; -// Sizes and line counts are never zero, so a client never divides by one. const OUTLINE_BOX = "[tmg]\\d{1,4},\\d{1,4},[1-9]\\d{0,3},[1-9]\\d{0,3},[1-9]\\d{0,2}"; const OUTLINE_PATTERN = new RegExp( `^${OUTLINE_BOX}(?:;${OUTLINE_BOX}){0,${HTML_RENDER_OUTLINE_MAX_BOXES - 1}}$`, ); -/** Packs measured boxes, in CSS pixels at the column width, into an outline string. */ export function encodeHtmlRenderOutline( boxes: ReadonlyArray, ): string | undefined { @@ -184,10 +148,6 @@ export function encodeHtmlRenderOutline( return encoded === "" ? undefined : encoded; } -/** - * The outline's boxes in CSS pixels for a frame `width` wide, stretched to the - * page's measured height there. Empty without an outline. - */ export function htmlRenderOutlineBoxes( reference: HtmlRenderReference, width: number, @@ -212,7 +172,6 @@ export function htmlRenderOutlineBoxes( }); } -/** A readable download name: the title without characters file systems reject. */ export function htmlRenderFileName(title: string) { const name = title .replace(/[\\/:*?"<>|\p{Cc}]+/gu, " ") @@ -239,23 +198,19 @@ export const HTML_RENDER_DEFAULT_FONTS: HtmlRenderFonts = { }; /** - * Fonts the server's headless browser lays pages out with. Arial-metric faces - * stand in for the system UI fonts readers see, so measured heights and - * preview screenshots wrap text close to how a client will. + * Arial-metric faces stand in for the system UI fonts readers see, so measured + * heights and preview screenshots wrap text close to how a client will. */ export const HTML_RENDER_MEASURE_FONTS: HtmlRenderFonts = { sans: '"Liberation Sans", Arimo, Arial, Helvetica, sans-serif', mono: '"Liberation Mono", Cousine, Menlo, Consolas, monospace', }; -/** The resolved theme a client hands an HTML render. */ export interface HtmlRenderTheme { readonly appearance: ThemeAppearance; readonly variables: Readonly>; } -// Roles themes cannot change (index.css keeps info/success fixed), plus a -// categorical chart series after the theme's own accent. const FIXED_COLORS = { light: { success: "#10b981", @@ -274,9 +229,8 @@ const FIXED_COLORS = { } as const; /** - * Maps a theme palette to the variables HTML renders style against. Names - * follow the app's own tokens, except `--accent`, which is the theme's brand - * color here rather than the app's neutral hover surface. + * Names follow the app's own tokens, except `--accent`, which is the theme's + * brand color here rather than the app's neutral hover surface. */ export function htmlRenderTheme( colors: ThemeColors, @@ -327,7 +281,6 @@ export function htmlRenderTheme( }; } -/** Agent-facing reference for the injected variables, used in tool descriptions. */ export const HTML_RENDER_THEME_GUIDE = [ "T3 injects its active theme as CSS custom properties on :root, and they follow the user's theme and light/dark mode live:", "--background (page background, identical to the thread around the frame), --foreground, --muted, --muted-foreground,", @@ -339,7 +292,6 @@ export const HTML_RENDER_THEME_GUIDE = [ "The base stylesheet sets html background/color/font from these, body margin to 0, and hides the page's scrollbar; your own CSS overrides it.", ].join(" "); -/** Agent-facing layout rules for a page that sits inside a reply. */ export const HTML_RENDER_LAYOUT_GUIDE = [ `The frame is borderless on the thread's background, as wide as the reply column (${HTML_RENDER_COLUMN_WIDTH}px on desktop by default, wider if the reader widens chat, about 360px on phones), and its left edge lines up with your reply text.`, "The page sits on the thread's own background, so by default leave html, body, and the outermost element with no background color. This overrides general style preferences such as a fixed black page background.", @@ -349,9 +301,6 @@ export const HTML_RENDER_LAYOUT_GUIDE = [ "Let content set the page's height. Avoid viewport-based heights such as 100vh or height:100% on html or body; the frame grows to fit the page, so they can make it grow again and again.", ].join(" "); -// The bridge between a render and its client speaks the MCP Apps protocol -// (JSON-RPC over postMessage), so the same host code can later drive upstream -// MCP apps: https://github.com/modelcontextprotocol/ext-apps const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed"; const OPEN_LINK_METHOD = "ui/open-link"; const SIZE_CHANGED_METHOD = "ui/notifications/size-changed"; @@ -363,7 +312,6 @@ const KEY_DOWN_METHOD = "t3/notifications/keydown"; // Not part of MCP Apps: a click on a page image opens it in the app's image viewer. const OPEN_IMAGE_METHOD = "t3/notifications/open-image"; -/** The content height in a framed render's `ui/notifications/size-changed` notification. */ export function readHtmlRenderContentHeight(data: unknown): number | undefined { if (typeof data !== "object" || data === null) return undefined; const { jsonrpc, method, params } = data as Record; @@ -375,11 +323,6 @@ export function readHtmlRenderContentHeight(data: unknown): number | undefined { return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined; } -/** - * A render's `ui/open-link` request, if `data` is one with an http(s) URL. The - * bootstrap adds the click's Cmd/Ctrl state, so a modifier click can leave the - * app the way it does from a chat link. - */ export function readHtmlRenderLinkRequest(data: unknown): | { readonly id: string | number; @@ -449,9 +392,7 @@ export function readHtmlRenderKeyDown(data: unknown): }; } -/** A chord as the page sees it: `mod` already resolved to Meta or Control. */ export interface HtmlRenderShortcut { - /** Lowercase key name, as in a keybinding: `k`, `[`, `enter`. */ readonly key: string; readonly metaKey: boolean; readonly ctrlKey: boolean; @@ -459,11 +400,6 @@ export interface HtmlRenderShortcut { readonly altKey: boolean; } -/** - * The `t3/notifications/shortcuts` notification a client posts into a render: - * the chords it may pass on, and chords of other commands that the page keeps - * even when one also matches an allowed chord. - */ export function htmlRenderShortcutsMessage( shortcuts: ReadonlyArray, blocked: ReadonlyArray, @@ -471,19 +407,16 @@ export function htmlRenderShortcutsMessage( return { jsonrpc: "2.0", method: SHORTCUTS_METHOD, params: { shortcuts, blocked } } as const; } -/** The empty result a client sends back for a render's request. */ export function htmlRenderResult(id: string | number) { return { jsonrpc: "2.0", id, result: {} } as const; } const THEME_FRAGMENT_KEY = "t3-theme"; -/** URL fragment that hands a render its theme before first paint. */ export function htmlRenderThemeFragment(theme: HtmlRenderTheme): string { return `#${THEME_FRAGMENT_KEY}=${encodeURIComponent(JSON.stringify(theme))}`; } -/** The `host-context-changed` notification a client posts into a mounted render when the theme changes. */ export function htmlRenderThemeMessage(theme: HtmlRenderTheme) { return { jsonrpc: "2.0", @@ -492,8 +425,6 @@ export function htmlRenderThemeMessage(theme: HtmlRenderTheme) { } as const; } -// The frame scrolls a page taller than itself, but a scrollbar inside the -// reply reads as a box within the thread, so it stays hidden. const BASE_CSS = "html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}" + @@ -529,8 +460,6 @@ const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"), function bootstrapMarkup(markup: string): string { const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); const light = htmlRenderTheme(T3_CODE_LIGHT_THEME_COLORS, "light"); - // Without a client-provided theme (a direct download, the headless preview - // without a fragment) the page follows the OS appearance. const defaultCss = `${rootRule(dark)}@media (prefers-color-scheme: light){${rootRule(light)}}${BASE_CSS}`; return [ /]*charset/i.test(markup.slice(0, 4096)) ? "" : '', @@ -570,10 +499,6 @@ const blankNonMarkup = (html: string) => { return parts.join(""); }; -/** - * Inserts the theme bootstrap at the start of the document head, so a page's - * own styles and scripts come after it. - */ export function injectHtmlRenderBootstrap(html: string): string { const scan = blankNonMarkup(html); const markup = bootstrapMarkup(scan); diff --git a/packages/shared/src/httpReadiness.ts b/packages/shared/src/httpReadiness.ts index 17a4a1f8a904..313aa9099522 100644 --- a/packages/shared/src/httpReadiness.ts +++ b/packages/shared/src/httpReadiness.ts @@ -7,12 +7,6 @@ import { HttpClient, HttpClientRequest } from "effect/http"; const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000; -/** - * Normalizes an arbitrary readiness probe failure into a plain, structured value - * suitable for diagnostic logging. Preserves the tagged-error `_tag` (and - * message/cause) shape for Effect tagged errors while recursing through nested - * `cause`/`reason` chains. - */ export function describeReadinessCause(cause: unknown): unknown { if (cause instanceof Error) { const tag = (cause as { readonly _tag?: unknown })._tag; @@ -36,18 +30,6 @@ export function describeReadinessCause(cause: unknown): unknown { }; } -/** - * Generic HTTP readiness probe shared by the SSH tunnel and the desktop backend - * manager. Polls `baseUrl + path` until it returns a 2xx response or the overall - * `timeoutMs` elapses. Each individual probe is bounded by `probeTimeoutMs` so a - * single hung request cannot stall the retry loop, and the retry cadence is - * `intervalMs` bounded to roughly `timeoutMs / intervalMs` attempts. - * - * The error type is left to the caller via `makeError`, so each consumer keeps - * its own tagged error. `makeError` is called at every failure site; callers can - * inspect `cause` (which carries a `kind` discriminator for the probe-timeout and - * overall-timeout cases) to reproduce phase-specific messages, or ignore it. - */ export const waitForHttpReady = Effect.fn("shared.httpReadiness.waitForHttpReady")(function* < E, >(input: { @@ -74,9 +56,6 @@ export const waitForHttpReady = Effect.fn("shared.httpReadiness.waitForHttpReady const lastProbeFailure = yield* Ref.make(null); let attempt = 0; - // Tracks errors this function itself produced via `makeError`, so the - // pass-through guards below never double-wrap an already-constructed error - // (mirrors the SSH original's `cause instanceof SshReadinessError` checks). const makeError = input.makeError; const madeErrors = new WeakSet(); const fail = (cause: unknown): E => { diff --git a/packages/shared/src/image.test.ts b/packages/shared/src/image.test.ts index 4d41431d300a..1699c4e65345 100644 --- a/packages/shared/src/image.test.ts +++ b/packages/shared/src/image.test.ts @@ -4,7 +4,6 @@ import { imageMimeType } from "./image.ts"; describe("imageMimeType", () => { it("recognizes a picture the file picker left untyped", () => { - // The document picker types every pick as a plain file, often with no mime at all. expect(imageMimeType({ name: "IMG_4997.PNG", mimeType: "" })).toBe("image/png"); expect(imageMimeType({ name: "shot.jpg", mimeType: "application/octet-stream" })).toBe( "image/jpeg", @@ -19,7 +18,6 @@ describe("imageMimeType", () => { }); it("leaves images the provider cannot accept as files", () => { - // Promoting these would put an attachment on the image path that cannot be sent. expect(imageMimeType({ name: "scan.heic", mimeType: "image/heic" })).toBeNull(); expect(imageMimeType({ name: "logo.svg", mimeType: "image/svg+xml" })).toBeNull(); }); @@ -33,8 +31,6 @@ describe("imageMimeType", () => { describe("legacy attachments", () => { it("recognizes a picture that was sent before pictures were typed by content", () => { - // Older messages recorded these as plain files; the bytes are still a picture, so the - // chat view can render a thumbnail rather than a download row. expect(imageMimeType({ name: "IMG_4996.PNG", mimeType: "application/octet-stream" })).toBe( "image/png", ); @@ -44,8 +40,6 @@ describe("legacy attachments", () => { describe("a declared non-image type", () => { it("wins over a misleading picture extension", () => { - // The filename fallback exists for attachments whose type was never recorded. A definite - // type is what the file actually is, so a `.png` name cannot promote a PDF to a picture. expect(imageMimeType({ name: "report.png", mimeType: "application/pdf" })).toBeNull(); expect(imageMimeType({ name: "archive.jpg", mimeType: "application/zip" })).toBeNull(); expect(imageMimeType({ name: "clip.png", mimeType: "video/mp4" })).toBeNull(); diff --git a/packages/shared/src/image.ts b/packages/shared/src/image.ts index 8d3f8cb8c9ec..753c956118e1 100644 --- a/packages/shared/src/image.ts +++ b/packages/shared/src/image.ts @@ -1,7 +1,7 @@ /** - * Only the types a provider turn accepts. A picture the provider would reject is still a - * file, so widening this map would promote attachments the send path cannot carry. - * Mirrors `PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES`. + * A picture the provider would reject is still a file, so widening this map would + * promote attachments the send path cannot carry. Mirrors + * `PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES`. */ const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([ ["gif", "image/gif"], @@ -13,25 +13,18 @@ const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([ const SUPPORTED_IMAGE_MIME_TYPES = new Set(IMAGE_MIME_TYPE_BY_EXTENSION.values()); -/** What a picker writes when it did not recognize the file; the name is better evidence. */ export const GENERIC_MIME_TYPES = new Set([ "application/octet-stream", "binary/octet-stream", "application/unknown", ]); -/** - * Recognizes pictures even when the picker omitted their MIME type. A picture chosen through - * the document picker arrives typed as a plain file, so what it *is* has to come from its own - * name and type rather than from which picker produced it. - */ export function imageMimeType(attachment: { readonly name: string; readonly mimeType: string; }): string | null { const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; if (SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) return mimeType; - // A declared but unsupported image type stays a file: the send path cannot carry it. if (mimeType.startsWith("image/")) return null; // The name is only evidence when nothing recorded what this is. A definite type already // answers the question, and a `.png` on a PDF must not override it. diff --git a/packages/shared/src/imageDimensions.test.ts b/packages/shared/src/imageDimensions.test.ts index 1b86bb97493d..74c0f6f6d743 100644 --- a/packages/shared/src/imageDimensions.test.ts +++ b/packages/shared/src/imageDimensions.test.ts @@ -47,7 +47,6 @@ describe("readImageDimensions", () => { }); it("swaps the axes for a JPEG whose EXIF orientation rotates it 90 degrees", () => { - // Big-endian TIFF with one IFD0 entry: tag 0x0112 (orientation), SHORT, count 1, value 6. const tiff = [ 0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -59,13 +58,11 @@ describe("readImageDimensions", () => { width: 3024, height: 4032, }); - // A later XMP APP1 segment must not clear the rotation. const xmp = bytes([0xff, 0xe1], u16(2 + 29), "http://ns.adobe.com/xap/1.0/\0"); expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...xmp], [...sof0]))).toEqual({ width: 3024, height: 4032, }); - // Orientation 1 leaves the frame size alone. const upright = [...tiff]; upright[19] = 0x01; const app1Upright = bytes([0xff, 0xe1], u16(2 + 6 + upright.length), "Exif\0\0", upright); @@ -94,11 +91,9 @@ describe("readImageDimensions", () => { it("reads each WebP container flavour", () => { const riff = (chunk: string, body: ReadonlyArray) => bytes("RIFF", u32(0), "WEBP", chunk, u32(body.length), body); - // VP8: frame tag (3), start code (3), then 14-bit width and height. expect( readImageDimensions(riff("VP8 ", [0, 0, 0, 0x9d, 0x01, 0x2a, ...u16le(800), ...u16le(600)])), ).toEqual({ width: 800, height: 600 }); - // VP8L: signature 0x2f, then width-1 (14 bits) and height-1 (14 bits) packed LE. const packed = (800 - 1) | ((600 - 1) << 14); expect( readImageDimensions( @@ -111,7 +106,6 @@ describe("readImageDimensions", () => { ]), ), ).toEqual({ width: 800, height: 600 }); - // VP8X: flags (4), then 24-bit width-1 and height-1. expect( readImageDimensions( riff("VP8X", [ diff --git a/packages/shared/src/imageDimensions.ts b/packages/shared/src/imageDimensions.ts index 8eceb305b764..91ee8165dd16 100644 --- a/packages/shared/src/imageDimensions.ts +++ b/packages/shared/src/imageDimensions.ts @@ -1,9 +1,3 @@ -/** - * Reads pixel dimensions from the header bytes of a PNG, JPEG, GIF, or WebP - * file so a client can reserve the exact box before the bytes arrive. Any - * other format, a truncated header, or a malformed file yields null; callers - * fall back to measuring after decode. - */ export interface ImageDimensions { readonly width: number; readonly height: number; @@ -67,21 +61,17 @@ function readWebp(bytes: Uint8Array): ImageDimensions | null { switch (chunk) { case "VP8 ": if (bytes.length < 30) return null; - // Lossy: 14-bit dimensions after the 3-byte frame tag and 3-byte start code. return { width: data.getUint16(26, true) & 0x3fff, height: data.getUint16(28, true) & 0x3fff, }; case "VP8L": { if (bytes.length < 25) return null; - // Lossless: width-1 in bits 0-13 and height-1 in bits 14-27 of the - // 32 bits after the signature byte. const packed = data.getUint32(21, true); return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 }; } case "VP8X": if (bytes.length < 30) return null; - // Extended: 24-bit canvas dimensions minus one. return { width: (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1, height: (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1, @@ -99,7 +89,6 @@ function readJpeg(bytes: Uint8Array): ImageDimensions | null { while (offset + 9 <= bytes.length) { if (bytes[offset] !== 0xff) return null; const marker = bytes[offset + 1]!; - // Padding bytes between segments. if (marker === 0xff) { offset += 1; continue; @@ -128,10 +117,8 @@ function readJpeg(bytes: Uint8Array): ImageDimensions | null { return null; } -/** Whether EXIF orientation 5-8 (a 90Β° rotation) applies. `start` is the APP1 payload. */ function exifOrientationSwapsAxes(bytes: Uint8Array, start: number, end: number): boolean { end = Math.min(end, bytes.length); - // "Exif\0\0" then a TIFF header: byte order, 0x2a, and the IFD0 offset. if (end - start < 14 || String.fromCharCode(...bytes.subarray(start, start + 4)) !== "Exif") { return false; } diff --git a/packages/shared/src/legacyCliLauncher.test.ts b/packages/shared/src/legacyCliLauncher.test.ts index b6630f468a83..447c5a12e846 100644 --- a/packages/shared/src/legacyCliLauncher.test.ts +++ b/packages/shared/src/legacyCliLauncher.test.ts @@ -13,7 +13,7 @@ const hostPlatform = NodeOS.platform(); // oxlint-disable-next-line t3code/no-global-process-runtime -- Match the real executable used by the subprocess. const hostArch = NodeOS.arch(); -// The fixture executable uses a POSIX shebang. The wrapper itself also runs on Windows. +// The fixture executable uses a POSIX shebang. it.skipIf(hostPlatform === "win32")( "keeps service IPC, arguments, and termination connected", async () => { diff --git a/packages/shared/src/legacyCliLauncher.ts b/packages/shared/src/legacyCliLauncher.ts index b852e2310dba..5be30d923b12 100644 --- a/packages/shared/src/legacyCliLauncher.ts +++ b/packages/shared/src/legacyCliLauncher.ts @@ -1,14 +1,7 @@ /** * `dist/bin.mjs` of the `t3` npm package: the entry point boot-service * launchers installed before 0.0.41 run with Node to start a new version they - * just npm-installed. It forwards everything (arguments, stdio, the IPC - * channel the launcher talks over, signals, exit status) to the platform - * executable in the sibling `@t3code/t3--` package. - * - * The first server started this way rewrites the service unit to run the - * executable directly, so nothing depends on this file after one update. - * Remove it once no supported release predates the executable (after the - * first stable release that ships it). + * just npm-installed. */ export function legacyCliLauncherScript(): string { return `import { spawn } from "node:child_process"; diff --git a/packages/shared/src/logging.ts b/packages/shared/src/logging.ts index a0ab1c16fd8e..ce0d03ad6d25 100644 --- a/packages/shared/src/logging.ts +++ b/packages/shared/src/logging.ts @@ -185,7 +185,6 @@ export class RotatingFileSink { } } -/** Serialized asynchronous writes and rotation without blocking the event loop. */ export const makeAsyncRotatingFileSink = Effect.fnUntraced(function* ( options: Omit, ) { diff --git a/packages/shared/src/markdownGithubAlerts.ts b/packages/shared/src/markdownGithubAlerts.ts index a4650bda2770..ccd37a70c4ca 100644 --- a/packages/shared/src/markdownGithubAlerts.ts +++ b/packages/shared/src/markdownGithubAlerts.ts @@ -1,14 +1,3 @@ -/** - * GitHub's blockquote alerts: a quote whose first line is `[!NOTE]` β€” or TIP, IMPORTANT, - * WARNING, CAUTION β€” renders as a titled callout. They are GitHub's own extension rather than - * GFM, so remark-gfm leaves the marker as literal text in the quote. This lifts the marker off - * the mdast into a `data-alert` attribute for the blockquote renderer to style, and removes - * the marker line itself. - * - * Only a marker with nothing after it on its own line counts, which is GitHub's rule: - * `> [!NOTE] aside` is an ordinary quote. - */ - interface MarkdownAstNode { type?: string; value?: unknown; @@ -36,19 +25,13 @@ function readGithubAlert(node: MarkdownAstNode): void { // in a sibling β€” while `[!NOTE]*aside*` parses the same way minus the newline. const markerEndsItsLine = match[0].endsWith("\n"); if (remainder.length > 0) { - // The quote continues on the next line inside the same text node: keep the paragraph, - // shed the marker line. text.value = remainder; } else if (markerEndsItsLine || paragraph.children?.length === 1) { - // The marker was the whole text node β€” a marker-only paragraph, or a marker line whose - // next line starts as a sibling inline. Drop the node, and the paragraph if it empties. paragraph.children?.shift(); if (paragraph.children?.length === 0) { node.children?.shift(); } } else { - // Something else shares the marker's line β€” `[!NOTE]*aside*` β€” which GitHub does not - // read as an alert. return; } diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index 94d8945b3fb5..3a0def815d21 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -105,8 +105,6 @@ describe("splitFilePathPosition", () => { }); describe("parseMarkdownFileLink", () => { - // Both clients consume this table, so a path the web app recognizes is one - // the mobile app recognizes too. it.each([ ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"], ["/home/me/notes.md", "/home/me/notes.md"], diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index bb518d1f4420..941f26f55c44 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -26,13 +26,9 @@ const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; -// A final dot between digits marks a version or model id (`glm-5.3`, -// `Qwen2.5-Coder`), not an extension. `ls.1` and `libfoo.so.1` stay files. const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; -// Standard OS and dev-container roots; deliberately excludes app-route-ish -// prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ "/Users/", "/home/", @@ -60,7 +56,6 @@ const POSIX_FILE_ROOT_PREFIXES = [ "/workspaces/", ] as const; -// `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. const EXTENSIONLESS_FILE_NAMES = new Set([ "Makefile", "makefile", @@ -93,8 +88,6 @@ const EXTENSIONLESS_FILE_NAMES = new Set([ const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); -// These allowlists avoid classifying dotted directories such as `conf.d/` -// or filenames such as `Makefile.in:12` as hosts. const GENERIC_HOSTNAME_TLDS = new Set([ "com", "net", @@ -123,8 +116,6 @@ const GENERIC_HOSTNAME_TLDS = new Set([ "link", ]); -// Country codes also name file extensions. A :line suffix makes `.pl` -// and `.pt` files more likely than hostnames. const COUNTRY_HOSTNAME_TLDS = new Set([ "uk", "de", @@ -171,10 +162,6 @@ function looksLikeHostname(segment: string, hasPosition: boolean): boolean { return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); } -/** - * Picks path-shaped inline code for the client's markdown file-link resolver. - * It does not resolve paths or turn plain prose and fenced code into links. - */ export function inlineCodeFilePathCandidate(codeText: string): string | null { const trimmed = codeText.trim(); if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; @@ -229,7 +216,6 @@ export function splitMarkdownLinkSearchAndHash(value: string): { }; } -/** Keeps filename and destination-path labels compact without discarding prose. */ export function isMarkdownFileLinkLabel(label: string, href: string): boolean { const destination = parseMarkdownFileLink(href); if (!destination) return false; @@ -267,11 +253,6 @@ function looksLikePosixFilesystemPath(path: string): boolean { return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename); } -/** - * Decides whether a decoded link destination is a file path rather than a route - * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L` - * anchor never turns `/chat/settings` into a file. - */ function looksLikeFilePath(path: string, authoredPath: string): boolean { if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath); @@ -297,7 +278,6 @@ export function parseMarkdownFileLink(href: string): FilePathPosition | null { const source = (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ?? splitMarkdownLinkSearchAndHash(normalized); - // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded. const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim())); const hash = safeDecodeURIComponent(source.hash.trim()); if (path.length === 0 || hasExternalScheme(path)) return null; @@ -322,7 +302,6 @@ export function extractInlineCodeSpans(text: string): string[] { return spans; } -/** Reuses delimiter searches when successive candidates fall in the same run. */ function markdownRunEndFinder(markdown: string, delimiter: RegExp) { let runStart = -1; let runEnd = -1; @@ -397,11 +376,6 @@ export function extractMarkdownLinkHrefs(markdown: string): string[] { return hrefs; } -/** - * `baseDir` anchors relative links; it defaults to the workspace root and is the - * file's own directory when rendering a markdown file. `cwd` stays the workspace - * root so the result still knows whether the target is inside it. - */ export function resolveMarkdownFileLinkTarget( href: string | undefined, cwd?: string, diff --git a/packages/shared/src/markdownListIndentation.ts b/packages/shared/src/markdownListIndentation.ts index bd1728e4d932..cbf2090547ed 100644 --- a/packages/shared/src/markdownListIndentation.ts +++ b/packages/shared/src/markdownListIndentation.ts @@ -105,12 +105,8 @@ function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): } /** - * CommonMark treats four or more spaces after a list marker as an indented - * code block. In chat output, excessive spacing is commonly accidental - * alignment such as `- text`, which otherwise produces a full code card - * for every bullet. Only normalize blocks that retain excess indentation and - * start on the marker's own line; explicit fences and conventional indented - * blocks remain code. + * CommonMark treats four or more spaces after a list marker as an indented code + * block. */ function attachListItemIndentationNormalizer(this: MarkdownParser) { return (tree: MarkdownAstNode, file: MarkdownFile) => { diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 7317dcc5b9ff..ac1850499f34 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -28,14 +28,6 @@ function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastN ); } -/** - * An image that is the only content of its block (optionally wrapped in a - * link) is almost always a screenshot or figure, so it gets a reserved slot - * while it loads. Images mixed with text or other images β€” badge rows, icons - * in a sentence β€” stay inline at their natural size, since a placeholder taller - * than the image would move the page more than the image itself does. - */ -/** Containers whose sole child image reads as a figure rather than part of a sentence. */ const STANDALONE_IMAGE_BLOCKS = new Set([ "p", "div", @@ -53,15 +45,12 @@ function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode const only = children[0]; if (only?.type !== "element") return undefined; if (only.tagName === "img") return only; - // A link, emphasis, or similar inline wrapper around the image still counts - // as long as nothing else shares the block. return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" ? soleImageDescendant(only) : undefined; } function markStandaloneImages(node: MarkdownImageHastNode) { - // A raw `` on its own line reaches the root without a paragraph. if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { const image = soleImageDescendant(node); if (image) image.properties = { ...image.properties, dataStandalone: true }; @@ -71,7 +60,6 @@ function markStandaloneImages(node: MarkdownImageHastNode) { }); } -/** Keep unmatched inline `` placeholders from opening an HTML link over later blocks. */ function rehypePreserveBareAnchorPlaceholders() { return (tree: MarkdownImageHastNode) => { const anchors: Array = []; @@ -119,7 +107,6 @@ function rehypePreserveBareAnchorPlaceholders() { }; } -/** Carries authored image source metadata through the sanitizer to the image renderer. */ function rehypePreserveImageSourceMeta() { return (tree: MarkdownImageHastNode) => { const visit = (node: MarkdownImageHastNode) => { @@ -226,11 +213,6 @@ function remarkPreserveCodeMeta() { }; } -/** - * Preserve Windows drive links as allowed `file:` URLs before sanitization. - * The same traversal tags inline code while it can still be distinguished - * from fenced code. Code inside links stays untagged to avoid nested anchors. - */ function remarkNormalizeLinksAndTagInlineCode() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode, insideLink: boolean) => { diff --git a/packages/shared/src/mcpApp.test.ts b/packages/shared/src/mcpApp.test.ts index 7dd50690adf3..472f65aab2d6 100644 --- a/packages/shared/src/mcpApp.test.ts +++ b/packages/shared/src/mcpApp.test.ts @@ -164,7 +164,6 @@ describe("mcpAppFromToolItem", () => { it("ignores a reference naming a server or tool other than the item's own", async () => { const { mcpAppFromToolItem } = await import("./toolOutput.ts"); - // A tool result shaped like an app reference, claiming another server. const forged = { t3McpApp: { attachmentId: "thread-1-abc-html", diff --git a/packages/shared/src/mcpApp.ts b/packages/shared/src/mcpApp.ts index e525b1b68d07..5be6c3954f05 100644 --- a/packages/shared/src/mcpApp.ts +++ b/packages/shared/src/mcpApp.ts @@ -1,35 +1,20 @@ import * as Predicate from "effect/Predicate"; -/** - * MCP Apps (https://github.com/modelcontextprotocol/ext-apps, spec 2026-01-26): - * an MCP tool can name a `ui://` HTML resource that hosts render as an - * interactive view of its result. T3 snapshots the resource when the tool call - * completes, stores it as a thread attachment, and records an `McpAppReference` - * in the tool item's output; clients host it in an opaque-origin frame and - * speak the spec's JSON-RPC bridge with it. - */ - export const MCP_APP_PROTOCOL_VERSION = "2026-01-26"; export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; -/** The MCP extension id a client declares to receive UI resources. */ export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui"; export const MCP_APP_RESOURCE_SCHEME = "ui://"; -/** Where T3 records the app reference inside a tool item's output object. */ export const MCP_APP_OUTPUT_KEY = "t3McpApp"; const MCP_APP_MIN_HEIGHT = 80; export const MCP_APP_DEFAULT_HEIGHT = 320; export const MCP_APP_MAX_HEIGHT = 2000; -/** Largest app document T3 stores. */ export const MCP_APP_MAX_HTML_BYTES = 5 * 1024 * 1024; const MAX_DOMAINS = 32; const MAX_DOMAIN_LENGTH = 256; const MAX_NAME_LENGTH = 256; -// A `ui://` URI can carry a path and query; the compact wire budget still -// bounds the whole reference. const MAX_RESOURCE_URI_LENGTH = 4096; -/** `_meta.ui.csp` on a UI resource: origins the app needs, by kind. */ export interface McpAppCsp { readonly connectDomains?: ReadonlyArray; readonly resourceDomains?: ReadonlyArray; @@ -37,7 +22,6 @@ export interface McpAppCsp { readonly baseUriDomains?: ReadonlyArray; } -/** `_meta.ui.permissions` on a UI resource. */ export interface McpAppPermissions { readonly camera?: Record; readonly microphone?: Record; @@ -45,10 +29,8 @@ export interface McpAppPermissions { readonly clipboardWrite?: Record; } -/** What a tool item's output carries so clients can host the app. */ export interface McpAppReference { readonly attachmentId: string; - /** The MCP server the tool belongs to, as the provider names it. */ readonly server: string; readonly tool: string; readonly resourceUri: string; @@ -75,7 +57,6 @@ function readDomains(value: unknown): ReadonlyArray | undefined { return domains.length === 0 ? undefined : domains; } -/** The well-formed part of a resource's declared `_meta.ui.csp`. */ export function readMcpAppCsp(value: unknown): McpAppCsp | undefined { if (!Predicate.isObject(value)) return undefined; const csp: { @@ -152,7 +133,6 @@ export function mcpAppReferencesEqual(left: McpAppReference, right: McpAppRefere ); } -/** The tool's `_meta.ui.resourceUri` (or the deprecated flat key), if it names a UI resource. */ export function readMcpAppResourceUri(meta: unknown): string | undefined { if (!Predicate.isObject(meta)) return undefined; const nested = Predicate.isObject(meta.ui) ? meta.ui.resourceUri : undefined; @@ -160,10 +140,6 @@ export function readMcpAppResourceUri(meta: unknown): string | undefined { return typeof uri === "string" && uri.startsWith(MCP_APP_RESOURCE_SCHEME) ? uri : undefined; } -/** - * Whether the tool's `_meta.ui.visibility` lets an app call it. The spec - * defaults to visible to both the model and apps. - */ export function mcpAppToolCallableByApp(meta: unknown): boolean { const visibility = Predicate.isObject(meta) && Predicate.isObject(meta.ui) ? meta.ui.visibility : undefined; @@ -198,10 +174,9 @@ const escapeAttribute = (value: string) => value.replace(/&/g, "&").replace(/"/g, """).replace(/]*>)?/i.exec( html, @@ -252,7 +223,6 @@ export function injectMcpAppThemeBootstrap(html: string): string { /** * Analytics libraries often read cookies during startup. An opaque document * has none, but browsers throw instead of returning an empty cookie string. - * Keep the sandbox and make that document-local case behave like no cookies. */ export function injectMcpAppCookieBootstrap(html: string): string { const bootstrap = ``; @@ -265,7 +235,6 @@ export function injectMcpAppCookieBootstrap(html: string): string { return `${prefix}${bootstrap}${html.slice(prefix.length)}`; } -/** The iframe `allow` attribute for the permissions an app declared. */ export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { const features: Array = []; if (permissions?.camera) features.push("camera"); diff --git a/packages/shared/src/mcpResponsePayloads.ts b/packages/shared/src/mcpResponsePayloads.ts index d46ef8e73fe9..fe5a89153d6e 100644 --- a/packages/shared/src/mcpResponsePayloads.ts +++ b/packages/shared/src/mcpResponsePayloads.ts @@ -1,11 +1,4 @@ -// MCP responses carry opaque JSON-RPC that callers relay verbatim. // @effect-diagnostics preferSchemaOverJson:off -/** - * Reads the JSON-RPC payloads of a response from an MCP streamable-HTTP - * endpoint. Used on the ACP bridge fast path, so it imports only effect core. - * - * @module mcpResponsePayloads - */ import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; @@ -33,11 +26,6 @@ async function* sseDataLines(response: Response): AsyncGenerator { export const discardResponseBody = (response: Response): Effect.Effect => Effect.promise(() => response.body?.cancel().catch(() => undefined) ?? Promise.resolve()); -/** - * Every JSON-RPC payload carried by a response, in arrival order: nothing for - * notification acknowledgements, each SSE `data:` event as it streams in, or - * the single JSON body. - */ export function responsePayloads( response: Response, onError: (cause: unknown) => E, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 32691d242e6e..7595ef47560d 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -63,8 +63,6 @@ const codexCaps: ModelCapabilities = createModelCapabilities({ describe("model slug normalization", () => { it("preserves exact custom slugs instead of expanding provider aliases", () => { - // Claude aliases now resolve through the model catalog (#9084), so the - // provider alias table passes unknown slugs through unchanged. const claude = ProviderDriverKind.make("claudeAgent"); expect(normalizeModelSlug("opus", claude)).toBe("opus"); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 0e05fb6926cf..c4f5fe61322a 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -14,7 +14,6 @@ import { copySorted } from "./Array.ts"; const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); -/** Choose the command for a model change against the thread's current provider instance. */ export function modelSelectionCommandType( currentInstanceId: ProviderInstanceId, selection: ModelSelection, @@ -103,10 +102,6 @@ function canonicalModelSelectionOptions( ); } -/** - * Compares the complete provider selection while treating option ordering and - * an omitted empty option list as presentation details. - */ export function modelSelectionsEqual(left: ModelSelection, right: ModelSelection): boolean { if (left.instanceId !== right.instanceId || left.model !== right.model) { return false; @@ -303,7 +298,6 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole return typeof text === "string" && /\bultrathink\b/i.test(text); } -/** Compare Codex model families without changing provider-owned dispatch identifiers. */ export function codexModelFamily(slug: string): string { return slug.startsWith("openai.gpt-") ? slug.slice("openai.".length) : slug; } @@ -353,7 +347,6 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri return model.trim() || null; } -/** A custom model setting with its optional fields resolved. */ export interface CustomModelDefinition { readonly slug: string; readonly name: string; @@ -362,13 +355,6 @@ export interface CustomModelDefinition { const decodeCustomModelCapabilities = Schema.decodeUnknownOption(ModelCapabilities); -/** - * Read a `customModels` setting into resolved definitions. Accepts the typed - * union as well as the opaque `providerInstances[id].config` blob clients see, - * so it tolerates bare slugs, malformed rows, and unparseable capabilities - * (dropped rather than failing the whole list). Slugs are trimmed and - * deduplicated, first occurrence wins; `name` falls back to the slug. - */ export function readCustomModelEntries(value: unknown): CustomModelDefinition[] { if (!Array.isArray(value)) return []; const entries: CustomModelDefinition[] = []; @@ -401,10 +387,6 @@ export function readCustomModelEntries(value: unknown): CustomModelDefinition[] return entries; } -/** - * Write a definition back to the compact stored shape: a bare slug when it - * carries nothing custom, otherwise an entry with only the set fields. - */ export function toCustomModelSetting(entry: CustomModelDefinition): CustomModelSetting { const descriptors = entry.capabilities?.optionDescriptors ?? []; const name = entry.name !== entry.slug ? entry.name : undefined; @@ -458,7 +440,6 @@ export function resolveSelectableModel( return resolved ? resolved.slug : null; } -/** Trim a string, returning null for empty/missing values. */ function trimOrNull(value: T | null | undefined): T | null { if (typeof value !== "string") return null; const trimmed = value.trim() as T; @@ -484,14 +465,6 @@ export function createModelSelection( return selections.length > 0 ? { ...base, options: selections } : base; } -/** - * Returns the effort value if it is a prompt-injected value according to - * any select descriptor in the given capabilities, or null otherwise. - * - * Unlike a single `find`, this checks every descriptor so that the - * correct descriptor's `promptInjectedValues` list is consulted even when - * multiple select descriptors exist. - */ export function resolvePromptInjectedEffort( caps: ModelCapabilities, rawEffort: string | null | undefined, diff --git a/packages/shared/src/nodeRuntime.ts b/packages/shared/src/nodeRuntime.ts index bfc7881c6ec7..9bc70b7f25e1 100644 --- a/packages/shared/src/nodeRuntime.ts +++ b/packages/shared/src/nodeRuntime.ts @@ -27,7 +27,6 @@ export class NodeRuntimeUnavailableError extends Schema.TaggedError { readonly warning?: string; } -/** - * Reads one variable. Blank reads as unset, and a value `parse` rejects warns - * without echoing it, since these variables carry credentials. - */ const readOrWarn = ( name: string, parse: (raw: string) => Option.Option, @@ -188,12 +157,6 @@ const EXPORTERS: ReadonlySet = new Set(["otlp", "none"]); const isExporter = (entry: string): entry is Exporter => EXPORTERS.has(entry); -/** - * `OTEL__EXPORTER`, a case-insensitive list whose default is `otlp`. - * Entries T3 Code has no exporter for are named in a warning and dropped, and - * a list left with nothing to honor reads as unset, as the specification asks - * of any enum value an implementation does not recognize. - */ const exporter = (name: string): Config.Config> => Config.String(name).pipe( Config.option, @@ -243,16 +206,9 @@ const withSignalPath = (signal: OtlpSignalName, base: URL) => { interface ResolvedSignal { readonly signal: OtelSignal; - /** The settings this signal read, whose warnings are the signal's to report. */ readonly used: ReadonlyArray>; } -/** - * A signal whose endpoint, protocol, or headers do not read is not exported - * rather than sent somewhere, in a format, or without the credentials its - * collector expects. `none` turns the signal off before any of those are read, - * whether or not an endpoint was named. - */ const signal = ( name: OtlpSignalName, exporter: Setting, @@ -320,7 +276,6 @@ export const load: Effect.Effect = Config.all({ }).pipe( Effect.map(({ t3, spec, resource, generic, exporters, ...own }) => { const disabled = t3.value ?? spec.value ?? false; - // The kill switch wins outright, so the signals say nothing once it is set. const signals = disabled ? undefined : { @@ -328,7 +283,6 @@ export const load: Effect.Effect = Config.all({ metrics: signal("METRICS", exporters.metrics, own.metrics, generic), logs: signal("LOGS", exporters.logs, own.logs, generic), }; - // A generic variable read by several signals warns once. const used = new Set( signals === undefined ? [] : Object.values(signals).flatMap((resolved) => resolved.used), ); @@ -354,7 +308,6 @@ export const load: Effect.Effect = Config.all({ logs: signals?.logs.signal ?? OtelSignal.Unset(), }; }), - // Every read above falls back instead of failing, so this cannot happen. Effect.orDie, ); @@ -365,12 +318,6 @@ export interface SignalEndpoint { readonly export: SignalExport; } -/** - * Where one signal exports and how. `T3CODE_OTLP_*_URL` wins outright with - * T3 Code's own export, then an OTEL endpoint with its own headers and - * protocol, since `T3CODE_OTLP_HEADERS` was written for a different - * collector, then the first of `fallbackUrls` with T3 Code's own export. - */ export const resolveSignalEndpoint = ( otel: OtelEnvironment, signal: SignalName, @@ -410,13 +357,11 @@ export const layerResourceAttributes = (attributes: Readonly `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join(","), }, - // Keeps an emptied list from falling through to the raw value. preserveEmptyStrings: true, }), { asPrimary: true }, ); -/** An environment that asked for nothing, for tests and for the pairing CLI. */ export const none: OtelEnvironment = { disabled: false, warnings: [], diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 9dee78c9d403..f020e16770dd 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -51,12 +51,10 @@ describe("path helpers", () => { }); it("normalizes a bare Windows drive root the same as one with a trailing separator", () => { - // `C:`, `C:\` and `C:/` all refer to the drive root and must compare equal. expect(normalizeProjectPathForDispatch("C:")).toBe("C:\\"); expect(normalizeProjectPathForComparison("C:")).toBe("c:\\"); expect(normalizeProjectPathForComparison("C:")).toBe(normalizeProjectPathForComparison("C:\\")); expect(normalizeProjectPathForComparison("C:")).toBe(normalizeProjectPathForComparison("C:/")); - // Non-root drive paths keep their trailing separator trimmed as before. expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); }); @@ -64,10 +62,8 @@ describe("path helpers", () => { expect(newProjectFolderName("Pinball Stats")).toBe("pinball-stats"); expect(newProjectFolderName(" CafΓ© & CrΓ¨me! ")).toBe("cafe-creme"); expect(newProjectFolderName("../../etc")).toBe("etc"); - // Nothing usable left, so the server falls back to a fixed name. expect(newProjectFolderName("🎱🎱")).toBe("project"); expect(newProjectFolderName(`${"a".repeat(63)} b`)).toBe("a".repeat(63)); - // Windows cannot make folders with device names. expect(newProjectFolderName("Con")).toBe("con-project"); expect(newProjectFolderName("LPT1")).toBe("lpt1-project"); expect(newProjectFolderName("console")).toBe("console"); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index 86e6d26d6583..d887262c08ba 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -57,11 +57,6 @@ export function normalizeProjectPathForComparison(value: string): string { // Windows refuses these as file names, with or without an extension. const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/; -/** - * Folder name for a project started from just a name ("Pinball Stats" becomes - * "pinball-stats"). The server uses it for `projects.createNew`, and clients - * use it to show the path before the server makes it. - */ export function newProjectFolderName(name: string): string { const slug = name .normalize("NFKD") @@ -83,8 +78,6 @@ export function stripSlashPrefixedWindowsDrive(path: string): string { } export function fileBasename(path: string): string { - // A trailing separator is a valid way to write a directory. Trim it before - // taking the final segment so the label is never empty. const trimmed = path.replace(/[/\\]+$/, ""); if (trimmed.length === 0) return path; const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); @@ -112,8 +105,6 @@ export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } -/** Resolve a literal filesystem path without interpreting file-link positions. - * Home-relative paths stay intact for the server to expand. */ export function resolveWorkspaceFilePath(path: string, workspaceRoot: string): string { if (isAbsolutePath(path) || path.startsWith("~/") || path.startsWith("~\\")) return path; const base = workspaceRoot.replace(/[/\\]+$/, ""); diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index e228b785927a..afa4f531c17c 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -49,7 +49,6 @@ describe("resolveAddressBarInput", () => { expect(() => resolveAddressBarInput("data:text/plain,hello")).toThrow( PreviewUrlNormalizationError, ); - // A known scheme with a numeric payload is not a host and port. for (const input of ["ftp:21", "tel:5551234"]) { expect(() => resolveAddressBarInput(input)).toThrow(PreviewUrlNormalizationError); } diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 393fba0a35aa..15f423a06186 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -1,18 +1,8 @@ -/** - * Pure URL helpers shared between the preview server, desktop main process, - * and web renderer. Centralising these guarantees the four call sites agree - * on what counts as "loopback" and how to normalise a free-form URL string. - */ - import * as Schema from "effect/Schema"; const TAB_ID_PREFIX = "tab_"; let nextPreviewTabSequence = 0; -/** - * Generate a fresh preview tab id. Lives in shared (not contracts) because - * the contracts package is schema-only β€” runtime helpers belong here. - */ export function newPreviewTabId(): string { nextPreviewTabSequence += 1; return `${TAB_ID_PREFIX}${nextPreviewTabSequence.toString(36)}`; @@ -20,7 +10,6 @@ export function newPreviewTabId(): string { const LOOPBACK_HOSTS: ReadonlySet = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); -/** Internal β€” used by `lsof` parsing where the host string is wire-formatted. */ export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet = new Set([ ...LOOPBACK_HOSTS, "*", @@ -58,13 +47,8 @@ function previewUrlProtocol(rawUrl: string): string | undefined { } const SEARCH_URL = "https://duckduckgo.com/?q="; -/** A bare host that is always an address: localhost, an IPv4 literal, or a bracketed IPv6 one. */ const ADDRESS_HOST_PATTERN = /^(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f:.]+\])$/i; const BARE_HOST_PORT_PATTERN = /^[^/?#:@]+:\d+(?:[/?#]|$)/; -/** - * Registered schemes that are never a bare host, so `ftp:21` or `tel:5551234` - * is rejected rather than read as host and port. - */ const KNOWN_NON_WEB_SCHEMES: ReadonlySet = new Set([ "about:", "blob:", @@ -82,13 +66,6 @@ const KNOWN_NON_WEB_SCHEMES: ReadonlySet = new Set([ "wss:", ]); -/** - * Turns what a user typed in an address bar into the URL to open: an address - * as `normalizePreviewUrl` reads it, or else a web search for the text. Text - * is an address when it has a scheme, or no spaces and a host that has a dot, - * a port, or is localhost or an IP. Throws only for empty input or an - * explicit unsupported scheme (`ftp://`, `mailto:`, `data:`). - */ export function resolveAddressBarInput(rawInput: string): string { const trimmed = rawInput.trim(); if (/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) || trimmed.length === 0) @@ -117,24 +94,12 @@ export function resolveAddressBarInput(rawInput: string): string { if (hasPort || ADDRESS_HOST_PATTERN.test(host) || /^[^.]+(?:\.[^.]+)+\.?$/.test(host)) { try { return normalizePreviewUrl(trimmed); - } catch { - // Not a usable address after all; search for it. - } + } catch {} } } return `${SEARCH_URL}${encodeURIComponent(trimmed)}`; } -/** - * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. - * - * - Bare loopback hosts (`localhost`, `localhost:5173`) become `http://...`. - * - Bare public hosts (`example.com`) become `https://...`. - * - Already-qualified URLs are validated and returned as `URL.href`. - * - * Throws `PreviewUrlNormalizationError` for empty, unparseable, or - * unsupported-protocol inputs. - */ export function normalizePreviewUrl(rawUrl: string): string { const trimmed = rawUrl.trim(); if (trimmed.length === 0) { diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index 8dbceb5504bc..d07ec3a087ef 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -27,9 +27,7 @@ export function getProjectFaviconCacheKey( try { const pathname = new URL(url, "https://t3.invalid").pathname; revision = pathname.slice(pathname.lastIndexOf("/") + 1); - } catch { - // Keep the full value as a safe fallback for malformed URLs. - } + } catch {} return JSON.stringify([environmentId, workspaceRoot, revision]); } diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 691376e72367..0312252d3554 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -8,12 +8,6 @@ type ProjectScriptSettings = Pick< | "projectSettingsFolded" >; -/** - * The project's override wins, then environment defaults. Until the legacy - * fields have been folded into `projectSettingsOverrides`, the old map (null - * there meant "reset to machine defaults") and the aggregate's own scripts - * still count, so a server that has not run the fold yet behaves as before. - */ export function resolveProjectScripts( settings: ProjectScriptSettings, project: { id: ProjectId; scripts: readonly ProjectScript[] }, @@ -74,7 +68,6 @@ export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectSc return scripts.find((script) => script.runOnWorktreeCreate) ?? null; } -/** Menu label naming the lifecycle roles a script runs in, e.g. "Clean (on settle)". */ export function projectScriptMenuLabel(script: ProjectScript): string { const roles = [ ...(script.runOnWorktreeCreate ? ["setup"] : []), diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 50bd96c1879a..3cebbe9f57ee 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -52,8 +52,6 @@ describe("resolveProjectSettings", () => { }); it("treats a null project like an absent one before the shell snapshot arrives", () => { - // The mobile new-task flow resolves settings while its selected project is - // still null; reading the aggregate's legacy fields off null crashed launch. expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null, null).settings).toBe( DEFAULT_SERVER_SETTINGS, ); @@ -111,7 +109,6 @@ describe("resolveProjectSettings", () => { expect(unfolded.settings.defaultModelSelection).toEqual(aggregateModel); expect(unfolded.settings.defaultThreadEnvMode).toBe("local"); expect(unfolded.sources.defaultModelSelection).toBe("project"); - // A stored override still beats the aggregate before the fold. const overridden = resolveProjectSettings( { ...DEFAULT_SERVER_SETTINGS, @@ -122,7 +119,6 @@ describe("resolveProjectSettings", () => { project, ); expect(overridden.settings.defaultThreadEnvMode).toBe("worktree"); - // After the fold a reset in the record wins over the stale aggregate. const folded = resolveProjectSettings( { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }, projectId, @@ -218,8 +214,6 @@ describe("resolveProjectSettings with a t3.json", () => { const builtIn = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, null); expect(builtIn.settings.defaultThreadEnvMode).toBe("local"); expect(builtIn.sources.defaultThreadEnvMode).toBe("environment"); - // A stored null override defers like an unset one and is not reported - // as the project's value. const nullOverride = resolveProjectSettings( { ...DEFAULT_SERVER_SETTINGS, @@ -231,7 +225,6 @@ describe("resolveProjectSettings with a t3.json", () => { ); expect(nullOverride.settings.defaultThreadEnvMode).toBe("worktree"); expect(nullOverride.sources.defaultThreadEnvMode).toBe("t3.json"); - // A file that does not mention the key leaves the source alone too. expect( resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, {}).sources .defaultThreadEnvMode, @@ -381,8 +374,6 @@ describe("projectSettingsOverrides patches", () => { const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } }, }); - // The canonical entry omits defaultAutoPull to clear it; the stale legacy - // map in the same patch must not put it back. const next = applyServerSettingsPatch(current, { projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "local" } }, projectAutoPullOverrides: { [projectId]: true, [otherProjectId]: false }, diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 445b216bac1e..76bc70fcc0a6 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -16,13 +16,6 @@ import { import { normalizeProjectPathForComparison } from "./path.ts"; import { fallbackTextGenerationModelSelection } from "./serverSettings.ts"; -/** - * Where a project-scoped value came from. The order is the priority order: - * a project override, then the environment value, then the repository's - * t3.json for keys in `PROJECT_FILE_BACKED_SETTINGS`, then the built-in - * default (reported as "environment", since that is what the environment - * value is when nothing set it). - */ export type ProjectSettingSource = "environment" | "project" | "t3.json"; export type ProjectSettingSources = Readonly< @@ -30,11 +23,8 @@ export type ProjectSettingSources = Readonly< >; export interface ResolvedProjectSettings { - /** Environment settings with the project's overrides applied. */ readonly settings: Settings; - /** Where each scopable key's effective value came from. */ readonly sources: ProjectSettingSources; - /** The project's raw override entry; `{}` when it has none. */ readonly overrides: ProjectSettingsOverrides; } @@ -44,7 +34,6 @@ const ENVIRONMENT_SOURCES: ProjectSettingSources = Object.fromEntries( PROJECT_SCOPED_SERVER_SETTING_KEYS.map((key) => [key, "environment"]), ) as Record; -/** Cheap check so hot paths skip the projectId lookup when nothing is overridden. */ export function hasProjectSettingsOverrides( settings: Pick, ): boolean { @@ -67,24 +56,11 @@ export interface LegacyProjectSettingsFields { readonly defaultThreadEnvMode?: ThreadEnvMode | null | undefined; } -/** - * Apply one project's overrides on top of environment settings. A model - * override whose provider is disabled on this environment falls back to the - * environment value, the same guard the environment-level selection gets. - */ export function resolveProjectSettings( settings: ServerSettings, projectId: ProjectId | null, - // Nullable, not just optional: the mobile new-task flow passes its selected - // project straight through, and that is null until the shell snapshot lands. project?: LegacyProjectSettingsFields | null, ): ResolvedProjectSettings; -/** - * With the checkout's decoded t3.json (or null for a missing or invalid - * one), every file-backed key resolves to a concrete value: the file fills - * keys whose project and environment tiers are both unset, and the built-in - * default fills what is left. - */ export function resolveProjectSettings( settings: ServerSettings, projectId: ProjectId | null, @@ -113,8 +89,6 @@ function applyProjectFile( effective ??= { ...resolved.settings }; sources ??= { ...resolved.sources }; effective[key] = value; - // A project override of null defers like an unset one, so the value did - // not come from the project either way. sources[key] = source; } return effective === null || sources === null @@ -122,12 +96,6 @@ function applyProjectFile( : { ...resolved, settings: effective as ServerSettings, sources }; } -/** - * The file and built-in tiers for one key, given the project-over-environment - * value (`null` when neither is set). For callers that hold the settings tier - * but only see the file later, such as the git driver reading the t3.json of - * the checkout it just created. Same chain as `resolveProjectSettings`. - */ export function resolveProjectFileBackedSetting( key: K, setting: ServerSettings[K], @@ -174,9 +142,6 @@ function resolveProjectOverrides( // A forward-compatible decode leaves an unknown value as a present // undefined; that is not an override. if (value === undefined) continue; - // A model on a disabled provider falls back to the environment, like the - // environment-level guards do for these keys. An instance this project - // enabled via `providerInstances[id].enabled` still counts as enabled here. if ( (key === "textGenerationModelSelection" || key === "defaultModelSelection") && value !== undefined && @@ -192,8 +157,6 @@ function resolveProjectOverrides( effective[key] = value; sources[key] = "project"; } - // Like the environment guard, text generation never runs on an instance - // this project turned off. const textGeneration = effective.textGenerationModelSelection as ModelSelection; if (!resolveProjectProviderInstanceEnabled(settings, projectId, textGeneration.instanceId)) { const fallback = fallbackTextGenerationModelSelection( @@ -210,13 +173,10 @@ function resolveProjectOverrides( } /** - * The workspace mode a new thread starts in. A project the environment made - * from just a name (a folder directly inside `ServerConfig.newProjectsRoot`) - * has only its first commit, so until it has a thread, a new thread works in - * the project checkout even when the environment default is a new worktree. - * Only that environment default is replaced: a project override still wins, - * and so does a t3.json when the environment sets no default (the usual - * precedence; the scaffold writes no t3.json). + * A project the environment made from just a name (a folder directly inside + * `ServerConfig.newProjectsRoot`) has only its first commit, so until it has a + * thread, a new thread works in the project checkout even when the environment + * default is a new worktree. */ export function resolveNewThreadEnvMode(input: { readonly projectSettings: ResolvedProjectSettings; @@ -245,7 +205,6 @@ export function resolveNewThreadEnvMode(input: { return isNewProjectFolder ? "local" : configured; } -/** Replace the project's entry, dropping it entirely when nothing is overridden. */ export function withProjectSettingsOverrides( settings: Pick, projectId: ProjectId, @@ -255,7 +214,6 @@ export function withProjectSettingsOverrides( return next === null || Object.keys(next).length === 0 ? rest : { ...rest, [projectId]: next }; } -/** The project's entry with `keys` removed; `null` when that leaves it empty. */ export function clearProjectSettingsOverrides( settings: Pick, projectId: ProjectId, @@ -268,7 +226,6 @@ export function clearProjectSettingsOverrides( return Object.keys(next).length === 0 ? null : next; } -/** Worktree rules are project-scoped; artifact and log retention stays environment-wide. */ export function resolveWorktreeCleanup( settings: ServerSettings, projectId: ProjectId | null, diff --git a/packages/shared/src/promptLinks.ts b/packages/shared/src/promptLinks.ts index a867e5b2c9a0..76c7b8f20a82 100644 --- a/packages/shared/src/promptLinks.ts +++ b/packages/shared/src/promptLinks.ts @@ -1,20 +1,5 @@ import { DEFAULT_HOSTED_APP_URL } from "./connectAuth.ts"; -/** - * Prompt links open T3 Code from outside it with a prompt ready to send. They never send it: - * a link from a stranger fills a draft, and the user reads it and presses Send. A link also - * cannot pick a runtime mode, branch, or worktree. Those stay the user's own defaults. - * - * Shared links are https on the hosted app, because that is what Twitter and GitHub make - * clickable. The `t3code://` scheme carries the same paths into desktop and mobile: - * - * https://app.t3.codes/new?prompt=…&repo=owner/name&provider=codex&model=gpt-5 - * https://app.t3.codes/new?pr=owner/name/123&prompt=… - * https://app.t3.codes//?prompt=… - * t3code://new?… t3code://threads//?… - */ - -/** URLs stay well under the limits browsers and link shorteners apply. */ export const PROMPT_LINK_MAX_PROMPT_LENGTH = 8_000; export interface PromptLinkRepository { @@ -26,10 +11,8 @@ export interface PromptLinkPullRequest extends PromptLinkRepository { readonly number: number; } -/** A prompt as it arrived, after hidden characters were removed. */ export interface LinkedPrompt { readonly text: string; - /** How many invisible characters were removed. Any count above zero is a warning sign. */ readonly hiddenCharacters: number; } @@ -38,7 +21,6 @@ export type NewThreadLink = readonly kind: "new"; readonly prompt: LinkedPrompt; readonly repository: PromptLinkRepository | null; - /** A provider driver kind such as `codex`. Users map it to one of their own instances. */ readonly provider: string | null; readonly model: string | null; } @@ -54,8 +36,7 @@ export type PromptLinkResult = // Zero-width and bidi controls, tag and variation-selector blocks, and other characters that // render as nothing. A model reads them; the person checking the draft cannot see them, which -// makes them the usual way to hide instructions in a prompt. U+FE0E/U+FE0F stay: emoji use them. -// Line and paragraph separators become plain newlines, so the draft shows the break the model reads. +// makes them the usual way to hide instructions in a prompt. const HIDDEN_CHARACTERS = // oxlint-disable-next-line no-control-regex, no-misleading-character-class -- these are the characters it removes. /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B\u200C\u200E\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0D\uFEFF\uFFA0\uFFF0-\uFFFB\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E0FFF}]/gu; @@ -67,7 +48,6 @@ const isEmojiPart = (codePoint: number | undefined) => (codePoint >= 0x2190 && codePoint <= 0x2bff) || codePoint === 0xfe0f); -/** Removes characters a reader cannot see, and counts them. */ export function sanitizeLinkedPrompt(raw: string): LinkedPrompt { let hiddenCharacters = 0; const visible = Array.from( @@ -76,7 +56,6 @@ export function sanitizeLinkedPrompt(raw: string): LinkedPrompt { return ""; }), ); - // A zero-width joiner is visible work only inside an emoji sequence, such as a family. const text = visible .filter((character, index) => { if (character !== "\u200D") return true; @@ -99,7 +78,6 @@ function repositoryOf(owner: string, name: string): PromptLinkRepository | null return { owner, name: trimmedName }; } -/** `owner/name`, or a GitHub repository URL. */ export function parsePromptLinkRepository(value: string): PromptLinkRepository | null { const trimmed = value.trim(); const url = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/?$/i.exec(trimmed); @@ -108,7 +86,6 @@ export function parsePromptLinkRepository(value: string): PromptLinkRepository | return match?.[1] && match[2] ? repositoryOf(match[1], match[2]) : null; } -/** `owner/name/123`, `owner/name#123`, or a GitHub pull request URL. */ function parsePromptLinkPullRequest(value: string): PromptLinkPullRequest | null { const trimmed = value.trim(); const match = @@ -123,7 +100,6 @@ function parsePromptLinkPullRequest(value: string): PromptLinkPullRequest | null : null; } -/** The pull request's page on GitHub, the reference `preparePullRequestThread` takes. */ export function promptLinkPullRequestUrl(pullRequest: PromptLinkPullRequest): string { return `https://github.com/${pullRequest.owner}/${pullRequest.name}/pull/${pullRequest.number}`; } @@ -150,7 +126,6 @@ function readPrompt(params: URLSearchParams): PromptLinkResult params.get(key)?.trim() || null; -/** Reads the query of a `/new` link. */ export function parseNewThreadLink(search: string): PromptLinkResult { const params = new URLSearchParams(search); const prompt = readPrompt(params); @@ -181,12 +156,10 @@ export function parseNewThreadLink(search: string): PromptLinkResult { return readPrompt(new URLSearchParams(search)); } -/** Drops the link's own parameters from a query, keeping anything else the route uses. */ export function withoutPromptLinkParams(search: string): string { const params = new URLSearchParams(search); for (const key of ["prompt", "repo", "pr", "provider", "model"]) params.delete(key); @@ -194,7 +167,6 @@ export function withoutPromptLinkParams(search: string): string { return rest ? `?${rest}` : ""; } -/** A shareable `/new` link on the hosted app. */ export function buildNewThreadLink(input: { readonly prompt: string; readonly repository?: PromptLinkRepository | null; @@ -215,11 +187,6 @@ export function buildNewThreadLink(input: { const APP_SCHEME = /^t3code(?:-[a-z]+)?:$/; const THREAD_PATH = /^\/([^/]+)\/([^/]+)\/?$/; -/** - * The in-app path a prompt link or thread deep link opens, or null for any other URL. - * Desktop uses it for links the OS hands over, both `t3code://` ones and, as the default - * browser on macOS, hosted-app ones. - */ export function promptLinkAppPath( value: string, hostedAppUrl: string = DEFAULT_HOSTED_APP_URL, @@ -235,13 +202,10 @@ export function promptLinkAppPath( } if (!URL.canParse(hostedAppUrl) || url.origin !== new URL(hostedAppUrl).origin) return null; if (url.pathname === "/new") return `/new${url.search}`; - // Hosted thread links only open in the app when they carry a prompt. A plain one is just a - // page on the hosted app. const thread = url.searchParams.has("prompt") ? THREAD_PATH.exec(url.pathname) : null; return thread ? `/${thread[1]}/${thread[2]}${url.search}` : null; } -/** The `t3code://` link that hands an in-app path to an installed desktop or mobile app. */ export function promptLinkDeepLink(appPath: string, scheme = "t3code"): string { const url = new URL(appPath, "https://app.invalid"); const host = url.pathname === "/new" ? "new" : `threads${url.pathname}`; diff --git a/packages/shared/src/providerAuthReturnUrl.ts b/packages/shared/src/providerAuthReturnUrl.ts index 5a0e825117ee..1adc969fa1f5 100644 --- a/packages/shared/src/providerAuthReturnUrl.ts +++ b/packages/shared/src/providerAuthReturnUrl.ts @@ -1,6 +1,5 @@ import { isLoopbackHost } from "./preview.ts"; -/** Only return to a local client or the hosted T3 client, never an arbitrary OAuth-supplied URL. */ export function providerAuthReturnUrl(value: string | undefined): string | undefined { if (!value) return undefined; try { diff --git a/packages/shared/src/qrCode.ts b/packages/shared/src/qrCode.ts index 6c83d09df332..6677357273aa 100644 --- a/packages/shared/src/qrCode.ts +++ b/packages/shared/src/qrCode.ts @@ -28,59 +28,19 @@ type bit = number; type byte = number; type int = number; -/*---- QR Code symbol class ----*/ - -/* - * A QR Code symbol, which is a type of two-dimension barcode. - * Invented by Denso Wave and described in the ISO/IEC 18004 standard. - * Instances of this class represent an immutable square grid of dark and light cells. - * The class provides static factory functions to create a QR Code from text or binary data. - * The class covers the QR Code Model 2 specification, supporting all versions (sizes) - * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. - * - * Ways to create a QR Code object: - * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). - * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). - * - Low level: Custom-make the array of data codeword bytes (including - * segment headers and final padding, excluding error correction codewords), - * supply the appropriate version number, and call the QrCode() constructor. - * (Note that all ways require supplying the desired error correction level.) - */ export class QrCode { public static Ecc: typeof QrCodeEcc; - /*-- Static factory functions (high level) --*/ - - // Returns a QR Code representing the given Unicode text string at the given error correction level. - // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer - // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible - // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the - // ecl argument if it can be done without increasing the version. public static encodeText(text: string, ecl: QrCodeEcc): QrCode { const segs: Array = QrSegment.makeSegments(text); return QrCode.encodeSegments(segs, ecl); } - // Returns a QR Code representing the given binary data at the given error correction level. - // This function always encodes using the binary segment mode, not any text mode. The maximum number of - // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. - // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. public static encodeBinary(data: Readonly>, ecl: QrCodeEcc): QrCode { const seg: QrSegment = QrSegment.makeBytes(data); return QrCode.encodeSegments([seg], ecl); } - /*-- Static factory functions (mid level) --*/ - - // Returns a QR Code representing the given segments with the given encoding parameters. - // The smallest possible QR Code version within the given range is automatically - // chosen for the output. Iff boostEcl is true, then the ECC level of the result - // may be higher than the ecl argument if it can be done without increasing the - // version. The mask number is either between 0 to 7 (inclusive) to force that - // mask, or -1 to automatically choose an appropriate mask (which may be slow). - // This function allows the user to create a custom sequence of segments that switches - // between modes (such as alphanumeric and byte) to encode text in less space. - // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). public static encodeSegments( segs: Readonly>, ecl: QrCodeEcc, @@ -100,28 +60,22 @@ export class QrCode { ) throw new RangeError("Invalid value"); - // Find the minimal version number to use let version: int; let dataUsedBits: int; for (version = minVersion; ; version++) { - const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available + const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; const usedBits: number = QrSegment.getTotalBits(segs, version); if (usedBits <= dataCapacityBits) { dataUsedBits = usedBits; - break; // This version number is found to be suitable + break; } - if (version >= maxVersion) - // All versions in the range could not fit the given data - throw new RangeError("Data too long"); + if (version >= maxVersion) throw new RangeError("Data too long"); } - // Increase the error correction level while the data still fits in the current version number for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { - // From low to high if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) ecl = newEcl; } - // Concatenate all segments to create the data bit string let bb: Array = []; for (const seg of segs) { appendBits(seg.mode.modeBits, 4, bb); @@ -130,58 +84,35 @@ export class QrCode { } assert(bb.length == dataUsedBits); - // Add terminator and pad up to a byte if applicable const dataCapacityBits: int = QrCode.getNumDataCodewords(version, ecl) * 8; assert(bb.length <= dataCapacityBits); appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); appendBits(0, (8 - (bb.length % 8)) % 8, bb); assert(bb.length % 8 == 0); - // Pad with alternating bytes until data capacity is reached for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11) appendBits(padByte, 8, bb); - // Pack bits into bytes in big endian let dataCodewords: Array = []; while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0); bb.forEach((b: bit, i: int) => (dataCodewords[i >>> 3]! |= b << (7 - (i & 7)))); - // Create the QR Code object return new QrCode(version, ecl, dataCodewords, mask); } - /*-- Fields --*/ - - // The width and height of this QR Code, measured in modules, between - // 21 and 177 (inclusive). This is equal to version * 4 + 17. public readonly version: int; public readonly errorCorrectionLevel: QrCodeEcc; public readonly size: int; - // The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). - // Even if a QR Code is created with automatic masking requested (mask = -1), - // the resulting object still has a mask value between 0 and 7. public readonly mask: int; - // The modules of this QR Code (false = light, true = dark). - // Immutable after constructor finishes. Accessed through getModule(). private readonly modules: Array> = []; - // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. private readonly isFunction: Array> = []; - /*-- Constructor (low level) and fields --*/ - - // Creates a new QR Code with the given version number, - // error correction level, data codeword bytes, and mask number. - // This is a low-level API that most users should not use directly. - // A mid-level API is the encodeSegments() function. public constructor( - // The version number of this QR Code, which is between 1 and 40 (inclusive). - // This determines the size of this barcode. version: int, - // The error correction level used in this QR Code. errorCorrectionLevel: QrCodeEcc, dataCodewords: Readonly>, @@ -191,28 +122,23 @@ export class QrCode { this.version = version; this.errorCorrectionLevel = errorCorrectionLevel; - // Check scalar arguments if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) throw new RangeError("Version value out of range"); if (msk < -1 || msk > 7) throw new RangeError("Mask value out of range"); this.size = version * 4 + 17; - // Initialize both grids to be size*size arrays of Boolean false let row: Array = []; for (let i = 0; i < this.size; i++) row.push(false); for (let i = 0; i < this.size; i++) { - this.modules.push(row.slice()); // Initially all light + this.modules.push(row.slice()); this.isFunction.push(row.slice()); } - // Compute ECC, draw modules this.drawFunctionPatterns(); const allCodewords: Array = this.addEccAndInterleave(dataCodewords); this.drawCodewords(allCodewords); - // Do masking if (msk == -1) { - // Automatically choose best mask let minPenalty: int = 1000000000; for (let i = 0; i < 8; i++) { this.applyMask(i); @@ -222,92 +148,70 @@ export class QrCode { msk = i; minPenalty = penalty; } - this.applyMask(i); // Undoes the mask due to XOR + this.applyMask(i); } } assert(0 <= msk && msk <= 7); this.mask = msk; - this.applyMask(msk); // Apply the final choice of mask - this.drawFormatBits(msk); // Overwrite old format bits + this.applyMask(msk); + this.drawFormatBits(msk); this.isFunction = []; } - /*-- Accessor methods --*/ - - // Returns the color of the module (pixel) at the given coordinates, which is false - // for light or true for dark. The top left corner has the coordinates (x=0, y=0). - // If the given coordinates are out of bounds, then false (light) is returned. public getModule(x: int, y: int): boolean { return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y]![x]!; } - /*-- Private helper methods for constructor: Drawing function modules --*/ - - // Reads this object's version field, and draws and marks all function modules. private drawFunctionPatterns(): void { - // Draw horizontal and vertical timing patterns for (let i = 0; i < this.size; i++) { this.setFunctionModule(6, i, i % 2 == 0); this.setFunctionModule(i, 6, i % 2 == 0); } - // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) this.drawFinderPattern(3, 3); this.drawFinderPattern(this.size - 4, 3); this.drawFinderPattern(3, this.size - 4); - // Draw numerous alignment patterns const alignPatPos: Array = this.getAlignmentPatternPositions(); const numAlign: int = alignPatPos.length; for (let i = 0; i < numAlign; i++) { for (let j = 0; j < numAlign; j++) { - // Don't draw on the three finder corners if (!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))) this.drawAlignmentPattern(alignPatPos[i]!, alignPatPos[j]!); } } - // Draw configuration data - this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawFormatBits(0); this.drawVersion(); } - // Draws two copies of the format bits (with its own error correction code) - // based on the given mask and this object's error correction level field. private drawFormatBits(mask: int): void { - // Calculate error correction code and pack bits - const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; // errCorrLvl is uint2, mask is uint3 + const data: int = (this.errorCorrectionLevel.formatBits << 3) | mask; let rem: int = data; for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); - const bits = ((data << 10) | rem) ^ 0x5412; // uint15 + const bits = ((data << 10) | rem) ^ 0x5412; assert(bits >>> 15 == 0); - // Draw first copy for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i)); this.setFunctionModule(8, 7, getBit(bits, 6)); this.setFunctionModule(8, 8, getBit(bits, 7)); this.setFunctionModule(7, 8, getBit(bits, 8)); for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i)); - // Draw second copy for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); - this.setFunctionModule(8, this.size - 8, true); // Always dark + this.setFunctionModule(8, this.size - 8, true); } - // Draws two copies of the version bits (with its own error correction code), - // based on this object's version field, iff 7 <= version <= 40. private drawVersion(): void { if (this.version < 7) return; - // Calculate error correction code and pack bits - let rem: int = this.version; // version is uint6, in the range [7, 40] + let rem: int = this.version; for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); - const bits: int = (this.version << 12) | rem; // uint18 + const bits: int = (this.version << 12) | rem; assert(bits >>> 18 == 0); - // Draw two copies for (let i = 0; i < 18; i++) { const color: boolean = getBit(bits, i); const a: int = this.size - 11 + (i % 3); @@ -317,12 +221,10 @@ export class QrCode { } } - // Draws a 9*9 finder pattern including the border separator, - // with the center module at (x, y). Modules can be out of bounds. private drawFinderPattern(x: int, y: int): void { for (let dy = -4; dy <= 4; dy++) { for (let dx = -4; dx <= 4; dx++) { - const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const dist: int = Math.max(Math.abs(dx), Math.abs(dy)); const xx: int = x + dx; const yy: int = y + dy; if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) @@ -331,8 +233,6 @@ export class QrCode { } } - // Draws a 5*5 alignment pattern, with the center module - // at (x, y). All modules must be in bounds. private drawAlignmentPattern(x: int, y: int): void { for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) @@ -340,31 +240,23 @@ export class QrCode { } } - // Sets the color of a module and marks it as a function module. - // Only used by the constructor. Coordinates must be in bounds. private setFunctionModule(x: int, y: int, isDark: boolean): void { this.modules[y]![x] = isDark; this.isFunction[y]![x] = true; } - /*-- Private helper methods for constructor: Codewords and masking --*/ - - // Returns a new byte string representing the given data with the appropriate error correction - // codewords appended to it, based on this object's version and error correction level. private addEccAndInterleave(data: Readonly>): Array { const ver: int = this.version; const ecl: QrCodeEcc = this.errorCorrectionLevel; if (data.length != QrCode.getNumDataCodewords(ver, ecl)) throw new RangeError("Invalid argument"); - // Calculate parameter numbers const numBlocks: int = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal]![ver]!; const blockEccLen: int = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal]![ver]!; const rawCodewords: int = Math.floor(QrCode.getNumRawDataModules(ver) / 8); const numShortBlocks: int = numBlocks - (rawCodewords % numBlocks); const shortBlockLen: int = Math.floor(rawCodewords / numBlocks); - // Split data into blocks and append ECC to each block let blocks: Array> = []; const rsDiv: Array = QrCode.reedSolomonComputeDivisor(blockEccLen); for (let i = 0, k = 0; i < numBlocks; i++) { @@ -378,11 +270,9 @@ export class QrCode { blocks.push(dat.concat(ecc)); } - // Interleave (not concatenate) the bytes from every block into a single sequence let result: Array = []; for (let i = 0; i < blocks[0]!.length; i++) { blocks.forEach((block, j) => { - // Skip the padding byte in short blocks if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]!); }); } @@ -390,39 +280,27 @@ export class QrCode { return result; } - // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire - // data area of this QR Code. Function modules need to be marked off before this is called. private drawCodewords(data: Readonly>): void { if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) throw new RangeError("Invalid argument"); - let i: int = 0; // Bit index into the data - // Do the funny zigzag scan + let i: int = 0; for (let right = this.size - 1; right >= 1; right -= 2) { - // Index of right column in each column pair if (right == 6) right = 5; for (let vert = 0; vert < this.size; vert++) { - // Vertical counter for (let j = 0; j < 2; j++) { - const x: int = right - j; // Actual x coordinate + const x: int = right - j; const upward: boolean = ((right + 1) & 2) == 0; - const y: int = upward ? this.size - 1 - vert : vert; // Actual y coordinate + const y: int = upward ? this.size - 1 - vert : vert; if (!this.isFunction[y]![x]! && i < data.length * 8) { this.modules[y]![x] = getBit(data[i >>> 3]!, 7 - (i & 7)); i++; } - // If this QR Code has any remainder bits (0 to 7), they were assigned as - // 0/false/light by the constructor and are left unchanged by this method } } } assert(i == data.length * 8); } - // XORs the codeword modules in this QR Code with the given mask pattern. - // The function modules must be marked and the codeword bits must be drawn - // before masking. Due to the arithmetic of XOR, calling applyMask() with - // the same mask value a second time will undo the mask. A final well-formed - // QR Code needs exactly one (not zero, two, etc.) mask applied. private applyMask(mask: int): void { if (mask < 0 || mask > 7) throw new RangeError("Mask value out of range"); for (let y = 0; y < this.size; y++) { @@ -461,12 +339,9 @@ export class QrCode { } } - // Calculates and returns the penalty score based on state of this QR Code's current modules. - // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. private getPenaltyScore(): int { let result: int = 0; - // Adjacent modules in row having same color, and finder-like patterns for (let y = 0; y < this.size; y++) { let runColor = false; let runX = 0; @@ -485,7 +360,6 @@ export class QrCode { } result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; } - // Adjacent modules in column having same color, and finder-like patterns for (let x = 0; x < this.size; x++) { let runColor = false; let runY = 0; @@ -505,7 +379,6 @@ export class QrCode { result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; } - // 2*2 blocks of modules having same color for (let y = 0; y < this.size - 1; y++) { for (let x = 0; x < this.size - 1; x++) { const color: boolean = this.modules[y]![x]!; @@ -518,23 +391,16 @@ export class QrCode { } } - // Balance of dark and light modules let dark: int = 0; for (const row of this.modules) dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); - const total: int = this.size * this.size; // Note that size is odd, so dark/total != 1/2 - // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const total: int = this.size * this.size; const k: int = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; assert(0 <= k && k <= 9); result += k * QrCode.PENALTY_N4; - assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + assert(0 <= result && result <= 2568888); return result; } - /*-- Private helper functions --*/ - - // Returns an ascending list of positions of alignment patterns for this version number. - // Each position is in the range [0,177), and are used on both the x and y axes. - // This could be implemented as lookup table of 40 variable-length lists of integers. private getAlignmentPatternPositions(): Array { if (this.version == 1) return []; else { @@ -547,9 +413,6 @@ export class QrCode { } } - // Returns the number of data bits that can be stored in a QR Code of the given version number, after - // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. - // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. private static getNumRawDataModules(ver: int): int { if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) throw new RangeError("Version number out of range"); @@ -563,9 +426,6 @@ export class QrCode { return result; } - // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any - // QR Code of the given version number and error correction level, with remainder bits discarded. - // This stateless pure function could be implemented as a (40*4)-cell lookup table. private static getNumDataCodewords(ver: int, ecl: QrCodeEcc): int { return ( Math.floor(QrCode.getNumRawDataModules(ver) / 8) - @@ -574,22 +434,14 @@ export class QrCode { ); } - // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be - // implemented as a lookup table over all possible parameter values, instead of as an algorithm. private static reedSolomonComputeDivisor(degree: int): Array { if (degree < 1 || degree > 255) throw new RangeError("Degree out of range"); - // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. - // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. let result: Array = []; for (let i = 0; i < degree - 1; i++) result.push(0); - result.push(1); // Start off with the monomial x^0 + result.push(1); - // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), - // and drop the highest monomial term which is always 1x^degree. - // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). let root = 1; for (let i = 0; i < degree; i++) { - // Multiply the current product by (x - r^i) for (let j = 0; j < result.length; j++) { result[j] = QrCode.reedSolomonMultiply(result[j]!, root); if (j + 1 < result.length) result[j]! ^= result[j + 1]!; @@ -599,14 +451,12 @@ export class QrCode { return result; } - // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. private static reedSolomonComputeRemainder( data: Readonly>, divisor: Readonly>, ): Array { let result: Array = divisor.map((_) => 0); for (const b of data) { - // Polynomial division const factor: byte = b ^ (result.shift() as byte); result.push(0); divisor.forEach((coef, i) => (result[i]! ^= QrCode.reedSolomonMultiply(coef, factor))); @@ -614,11 +464,8 @@ export class QrCode { return result; } - // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result - // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. private static reedSolomonMultiply(x: byte, y: byte): byte { if (x >>> 8 != 0 || y >>> 8 != 0) throw new RangeError("Byte out of range"); - // Russian peasant multiplication let z: int = 0; for (let i = 7; i >= 0; i--) { z = (z << 1) ^ ((z >>> 7) * 0x11d); @@ -628,8 +475,6 @@ export class QrCode { return z as byte; } - // Can only be called immediately after a light run is added, and - // returns either 0, 1, or 2. A helper function for getPenaltyScore(). private finderPenaltyCountPatterns(runHistory: Readonly>): int { const n: int = runHistory[1]!; assert(n <= this.size * 3); @@ -645,141 +490,100 @@ export class QrCode { ); } - // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). private finderPenaltyTerminateAndCount( currentRunColor: boolean, currentRunLength: int, runHistory: Array, ): int { if (currentRunColor) { - // Terminate dark run this.finderPenaltyAddHistory(currentRunLength, runHistory); currentRunLength = 0; } - currentRunLength += this.size; // Add light border to final run + currentRunLength += this.size; this.finderPenaltyAddHistory(currentRunLength, runHistory); return this.finderPenaltyCountPatterns(runHistory); } - // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). private finderPenaltyAddHistory(currentRunLength: int, runHistory: Array): void { - if (runHistory[0] === 0) currentRunLength += this.size; // Add light border to initial run + if (runHistory[0] === 0) currentRunLength += this.size; runHistory.pop(); runHistory.unshift(currentRunLength); } - /*-- Constants and tables --*/ - - // The minimum version number supported in the QR Code Model 2 standard. public static readonly MIN_VERSION: int = 1; - // The maximum version number supported in the QR Code Model 2 standard. public static readonly MAX_VERSION: int = 40; - // For use in getPenaltyScore(), when evaluating which mask is best. private static readonly PENALTY_N1: int = 3; private static readonly PENALTY_N2: int = 3; private static readonly PENALTY_N3: int = 40; private static readonly PENALTY_N4: int = 10; private static readonly ECC_CODEWORDS_PER_BLOCK: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level [ -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // Low + ], [ -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, - ], // Medium + ], [ -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // Quartile + ], [ -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, - ], // High + ], ]; private static readonly NUM_ERROR_CORRECTION_BLOCKS: Array> = [ - // Version: (note that index 0 is for padding, and is set to an illegal value) - //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level [ -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25, - ], // Low + ], [ -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, - ], // Medium + ], [ -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68, - ], // Quartile + ], [ -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81, - ], // High + ], ]; } -// Appends the given number of low-order bits of the given value -// to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. function appendBits(val: int, len: int, bb: Array): void { if (len < 0 || len > 31 || val >>> len != 0) throw new RangeError("Value out of range"); - for ( - let i = len - 1; - i >= 0; - i-- // Append bit by bit - ) - bb.push((val >>> i) & 1); + for (let i = len - 1; i >= 0; i--) bb.push((val >>> i) & 1); } -// Returns true iff the i'th bit of x is set to 1. function getBit(x: int, i: int): boolean { return ((x >>> i) & 1) != 0; } -// Throws an exception if the given condition is false. function assert(cond: boolean): void { if (!cond) throw new Error("Assertion error"); } -/*---- Data segment class ----*/ - -/* - * A segment of character/binary/control data in a QR Code symbol. - * Instances of this class are immutable. - * The mid-level way to create a segment is to take the payload data - * and call a static factory function such as QrSegment.makeNumeric(). - * The low-level way to create a segment is to custom-make the bit buffer - * and call the QrSegment() constructor with appropriate values. - * This segment class imposes no length restrictions, but QR Codes have restrictions. - * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. - * Any segment longer than this is meaningless for the purpose of generating QR Codes. - */ export class QrSegment { public static Mode: typeof QrSegmentMode; - /*-- Static factory functions (mid level) --*/ - - // Returns a segment representing the given binary data encoded in - // byte mode. All input byte arrays are acceptable. Any text string - // can be converted to UTF-8 bytes and encoded as a byte mode segment. public static makeBytes(data: Readonly>): QrSegment { let bb: Array = []; for (const b of data) appendBits(b, 8, bb); return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); } - // Returns a segment representing the given string of decimal digits encoded in numeric mode. public static makeNumeric(digits: string): QrSegment { if (!QrSegment.isNumeric(digits)) throw new RangeError("String contains non-numeric characters"); let bb: Array = []; for (let i = 0; i < digits.length;) { - // Consume up to 3 digits per iteration const n: int = Math.min(digits.length - i, 3); appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); i += n; @@ -787,38 +591,27 @@ export class QrSegment { return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); } - // Returns a segment representing the given text string encoded in alphanumeric mode. - // The characters allowed are: 0 to 9, A to Z (uppercase only), space, - // dollar, percent, asterisk, plus, hyphen, period, slash, colon. public static makeAlphanumeric(text: string): QrSegment { if (!QrSegment.isAlphanumeric(text)) throw new RangeError("String contains unencodable characters in alphanumeric mode"); let bb: Array = []; let i: int; for (i = 0; i + 2 <= text.length; i += 2) { - // Process groups of 2 let temp: int = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); appendBits(temp, 11, bb); } - if (i < text.length) - // 1 character remaining - appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + if (i < text.length) appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); } - // Returns a new mutable list of zero or more segments to represent the given Unicode text string. - // The result may use various segment modes and switch modes to optimize the length of the bit stream. public static makeSegments(text: string): Array { - // Select the most efficient segment encoding automatically if (text == "") return []; else if (QrSegment.isNumeric(text)) return [QrSegment.makeNumeric(text)]; else if (QrSegment.isAlphanumeric(text)) return [QrSegment.makeAlphanumeric(text)]; else return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; } - // Returns a segment representing an Extended Channel Interpretation - // (ECI) designator with the given assignment value. public static makeEci(assignVal: int): QrSegment { let bb: Array = []; if (assignVal < 0) throw new RangeError("ECI assignment value out of range"); @@ -833,66 +626,47 @@ export class QrSegment { return new QrSegment(QrSegment.Mode.ECI, 0, bb); } - // Tests whether the given string can be encoded as a segment in numeric mode. - // A string is encodable iff each character is in the range 0 to 9. public static isNumeric(text: string): boolean { return QrSegment.NUMERIC_REGEX.test(text); } - // Tests whether the given string can be encoded as a segment in alphanumeric mode. - // A string is encodable iff each character is in the following set: 0 to 9, A to Z - // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. public static isAlphanumeric(text: string): boolean { return QrSegment.ALPHANUMERIC_REGEX.test(text); } - /*-- Constructor (low level) and fields --*/ - public readonly mode: QrSegmentMode; public readonly numChars: int; private readonly bitData: Array; - // Creates a new QR Code segment with the given attributes and data. // The character count (numChars) must agree with the mode and the bit buffer length, - // but the constraint isn't checked. The given bit buffer is cloned and stored. + // but the constraint isn't checked. public constructor( - // The mode indicator of this segment. mode: QrSegmentMode, - // The length of this segment's unencoded data. Measured in characters for - // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. - // Always zero or positive. Not the same as the data's bit length. numChars: int, - // The data bits of this segment. Accessed through getData(). bitData: Array, ) { this.mode = mode; this.numChars = numChars; if (numChars < 0) throw new RangeError("Invalid argument"); - this.bitData = bitData.slice(); // Make defensive copy + this.bitData = bitData.slice(); } - /*-- Methods --*/ - - // Returns a new copy of the data bits of this segment. public getData(): Array { - return this.bitData.slice(); // Make defensive copy + return this.bitData.slice(); } - // (Package-private) Calculates and returns the number of bits needed to encode the given segments at - // the given version. The result is infinity if a segment has too many characters to fit its length field. public static getTotalBits(segs: Readonly>, version: int): number { let result: number = 0; for (const seg of segs) { const ccbits: int = seg.mode.numCharCountBits(version); - if (seg.numChars >= 1 << ccbits) return Infinity; // The segment's length doesn't fit the field's bit width + if (seg.numChars >= 1 << ccbits) return Infinity; result += 4 + ccbits + seg.bitData.length; } return result; } - // Returns a new array of bytes representing the given string encoded in UTF-8. private static toUtf8ByteArray(str: string): Array { str = encodeURI(str); let result: Array = []; @@ -906,51 +680,30 @@ export class QrSegment { return result; } - /*-- Constants --*/ - - // Describes precisely all strings that are encodable in numeric mode. private static readonly NUMERIC_REGEX: RegExp = /^[0-9]*$/; - // Describes precisely all strings that are encodable in alphanumeric mode. private static readonly ALPHANUMERIC_REGEX: RegExp = /^[A-Z0-9 $%*+.\/:-]*$/; - // The set of all legal characters in alphanumeric mode, - // where each character value maps to the index in the string. private static readonly ALPHANUMERIC_CHARSET: string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; } -/*---- Public helper enumeration ----*/ - class QrCodeEcc { - /*-- Constants --*/ - - public static readonly LOW = new QrCodeEcc(0, 1); // The QR Code can tolerate about 7% erroneous codewords - public static readonly MEDIUM = new QrCodeEcc(1, 0); // The QR Code can tolerate about 15% erroneous codewords - public static readonly QUARTILE = new QrCodeEcc(2, 3); // The QR Code can tolerate about 25% erroneous codewords - public static readonly HIGH = new QrCodeEcc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + public static readonly LOW = new QrCodeEcc(0, 1); + public static readonly MEDIUM = new QrCodeEcc(1, 0); + public static readonly QUARTILE = new QrCodeEcc(2, 3); + public static readonly HIGH = new QrCodeEcc(3, 2); public readonly ordinal: int; public readonly formatBits: int; - /*-- Constructor and fields --*/ - - private constructor( - // In the range 0 to 3 (unsigned 2-bit integer). - ordinal: int, - // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). - formatBits: int, - ) { + private constructor(ordinal: int, formatBits: int) { this.ordinal = ordinal; this.formatBits = formatBits; } } -/*---- Public helper enumeration ----*/ - class QrSegmentMode { - /*-- Constants --*/ - public static readonly NUMERIC = new QrSegmentMode(0x1, [10, 12, 14]); public static readonly ALPHANUMERIC = new QrSegmentMode(0x2, [9, 11, 13]); public static readonly BYTE = new QrSegmentMode(0x4, [8, 16, 16]); @@ -960,22 +713,11 @@ class QrSegmentMode { public readonly modeBits: int; private readonly numBitsCharCount: [int, int, int]; - /*-- Constructor and fields --*/ - - private constructor( - // The mode indicator bits, which is a uint4 value (range 0 to 15). - modeBits: int, - // Number of character count bits for three different version ranges. - numBitsCharCount: [int, int, int], - ) { + private constructor(modeBits: int, numBitsCharCount: [int, int, int]) { this.modeBits = modeBits; this.numBitsCharCount = numBitsCharCount; } - /*-- Method --*/ - - // (Package-private) Returns the bit width of the character count field for a segment in - // this mode in a QR Code at the given version number. The result is in the range [0, 16]. public numCharCountBits(ver: int): int { return this.numBitsCharCount[Math.floor((ver + 7) / 17)]!; } diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index d743067b6209..18cb67c7e077 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -56,7 +56,6 @@ const layerHttpClient = (bytes: Uint8Array) => ), ); -// Records each request and never responds, simulating a wedged endpoint. const layerStalledHttpClient = (requests: Array) => Layer.succeed( HttpClient.HttpClient, @@ -66,8 +65,6 @@ const layerStalledHttpClient = (requests: Array) => }), ); -// Answers `cloudflared version` with the version recorded for that path, which -// defaults to the pinned release; tests change it to simulate other binaries. const layerSpawner = (commands: Array, versions: Record = {}) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -116,10 +113,6 @@ const renameError = (code: string, path: string) => type InstallRename = "staging" | "activation"; -/** - * Fails the first `failures` staging or activation renames with `code`, as Windows does while - * another process holds the file. Staging moves the binary to a `.tmp` name beside its final path. - */ const makeLockedRenames = ( fileSystem: FileSystem.FileSystem, input: { readonly rename: InstallRename; readonly failures: number; readonly code: string }, @@ -450,8 +443,6 @@ describe("RelayClient", () => { concurrency: "unbounded", }); expect(second).toEqual(first); - // The first install validates the download once; the second, after waiting - // its turn, probes the activated binary and reuses it instead of downloading. expect(commands).toHaveLength(2); }).pipe( Effect.scoped, @@ -602,7 +593,6 @@ describe("RelayClient", () => { const older = managedPathFor(baseDir, "2025.9.0"); const newer = managedPathFor(baseDir, "2026.1.0"); const pinned = managedPathFor(baseDir, RelayClient.CLOUDFLARED_VERSION); - // A newer server sharing this base dir owns this folder. const newerServer = managedPathFor(baseDir, "2099.1.0"); versions[newerServer] = "2099.1.0"; yield* writeExecutable(newerServer); @@ -634,7 +624,6 @@ describe("RelayClient", () => { (yield* fileSystem.readDirectory(`${baseDir}/tools/cloudflared`)).sort(), ).toEqual(["2026.1.0", RelayClient.CLOUDFLARED_VERSION, "2099.1.0"]); - // The kept older release still outranks PATH; PATH is the last resort. yield* fileSystem.remove(pinned); expect(yield* resolveWith).toMatchObject({ source: "managed", version: "2026.1.0" }); yield* fileSystem.remove(`${baseDir}/tools/cloudflared/2026.1.0`, { @@ -746,7 +735,6 @@ describe("RelayClient", () => { expect(error.message).toBe(message); expect(renames.attempts).toBe(41); - // Nothing is installed, and the staged copy, download folder, and install lock are gone. expect(yield* fileSystem.readDirectory(managedDirectory(baseDir, "win32"))).toEqual([]); }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("win32"))), ); @@ -799,7 +787,6 @@ describe("RelayClient", () => { while (requests.length === 0) { yield* Effect.yieldNow; } - // The download timeout is 10 minutes; advance past it. yield* TestClock.adjust("11 minutes"); const error = yield* Fiber.join(child).pipe(Effect.flip); expect(error).toBeInstanceOf(RelayClient.RelayClientInstallError); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 8dd0a6e3eaec..151155dd2d85 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -30,9 +30,6 @@ export const CLOUDFLARED_VERSION = "2026.10.0"; export const CLOUDFLARED_MIN_VERSION = "2025.6.1"; const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; -// Generous bound for a ~40MB binary download; without it a stalled -// connection parks the install forever (same unbounded-wait family as the -// other relay/cloud network calls). const CLOUDFLARED_DOWNLOAD_TIMEOUT = "10 minutes"; export type RelayClientExecutableSource = "override" | "managed" | "path"; @@ -147,14 +144,7 @@ export interface CloudflaredRelayClientOptions { } export interface RelayClientShape { - /** - * Finds the relay client to run, without downloading anything: the override, - * else the pinned managed folder, else the newest older managed folder, else - * `cloudflared` on PATH. Every candidate must report a compatible version, and - * the status carries the version the binary reports. - */ readonly resolve: Effect.Effect; - /** Installs the pinned managed release unless it, or a valid override, is already present. */ readonly install: Effect.Effect; readonly installWithProgress: ( report: (event: RelayClientInstallProgressEvent) => Effect.Effect, @@ -163,7 +153,6 @@ export interface RelayClientShape { readonly pruneManagedVersions: Effect.Effect; } -/** True when this is the pinned managed release, which needs no update. */ export function isPinnedManagedRelayClient(client: AvailableRelayClient): boolean { return client.source === "managed" && client.version === CLOUDFLARED_VERSION; } @@ -190,20 +179,17 @@ function parseVersion(value: string): readonly [number, number, number] | null { return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; } -/** Orders `YYYY.M.P` cloudflared versions; unparseable versions sort first. */ export function compareCloudflaredVersions(left: string, right: string): number { const a = parseVersion(left) ?? [0, 0, 0]; const b = parseVersion(right) ?? [0, 0, 0]; return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; } -/** Reads the version from `cloudflared version` output, e.g. `cloudflared version 2026.5.2 (built ...)`. */ export function parseCloudflaredVersionOutput(output: string): string | null { const version = parseVersion(output); return version ? version.join(".") : null; } -// A changed file is a new key, so a replaced binary is probed again. class VersionProbeKey extends Data.Class<{ readonly executablePath: string; readonly size: number; @@ -275,10 +261,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }); // `cloudflared version` costs a process spawn, so answers are cached per file - // revision. Before `--no-autoupdate`, a managed binary could replace itself in - // place, so the version is read from the binary, never from its folder name. - // A failed or timed-out probe is not cached, so a slow spawn under an AV scan - // does not hide a good binary until it changes on disk. + // revision. A failed or timed-out probe is not cached, so a slow spawn under + // an AV scan does not hide a good binary until it changes on disk. const versionCache = yield* Cache.makeWith( (key: VersionProbeKey) => spawner @@ -384,9 +368,6 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ); return missingStatus; } - // A managed binary reports the version it actually runs. One that replaced - // itself in place still serves as a fallback; the runtime sees it is not the - // pinned release and installs that. const pinned = yield* compatibleCandidate(managedPath, "managed"); if (pinned) return pinned; for (const version of yield* olderManagedVersions) { @@ -413,7 +394,6 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions")); - /** Moves the new binary toward its final path, waiting out a brief Windows lock. */ const renameWhenUnlocked = (from: string, to: string) => fileSystem.rename(from, to).pipe( Effect.retry({ diff --git a/packages/shared/src/relayJwt.ts b/packages/shared/src/relayJwt.ts index 0b06c00d4b5d..ba309b028e31 100644 --- a/packages/shared/src/relayJwt.ts +++ b/packages/shared/src/relayJwt.ts @@ -12,7 +12,6 @@ export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt"; export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt"; export const RELAY_MANAGED_TUNNEL_RECOVERY_TYP = "t3-env-managed-tunnel-recovery+jwt"; export const RELAY_HOOK_DELIVERY_TYP = "t3-relay-hook-delivery+jwt"; -/** Header carrying the signed proof that a webhook request came from the relay. */ export const RELAY_HOOK_DELIVERY_HEADER = "x-t3-relay-delivery"; export class RelayJwtError extends Schema.TaggedError()("RelayJwtError", { diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 8f7aa3bc9c24..2d726760d103 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -29,10 +29,6 @@ export class RelayClientTracer extends Context.Reference( }, ) {} -/** - * The tracer that was active before relay tracing took over, so work nested - * inside a relay span can return to it. - */ class LocalTracer extends Context.Reference("@t3tools/shared/relayTracing/LocalTracer", { defaultValue: () => Option.none(), }) {} @@ -63,9 +59,8 @@ export const withRelayClientTracing = ( ); /** - * Runs `effect` on the tracer that was active outside relay tracing. Its spans - * stay in the local trace and are never exported, so a relay span can time the - * work without shipping what the user's machine did to answer it. + * Its spans stay in the local trace and are never exported, so a relay span can + * time the work without shipping what the user's machine did to answer it. */ export const withLocalTracing = (effect: Effect.Effect): Effect.Effect => LocalTracer.pipe( @@ -150,9 +145,7 @@ function nonInterferingTracer(delegate: Tracer.Tracer): Tracer.Tracer { span.end = (endTime, exit) => { try { end(endTime, traceSafeExit(exit)); - } catch { - // Telemetry is best-effort and must never change application behavior. - } + } catch {} }; return span; }, diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts index 4a4d16da0b30..a653ad712f27 100644 --- a/packages/shared/src/schemaJson.test.ts +++ b/packages/shared/src/schemaJson.test.ts @@ -58,11 +58,8 @@ Done.`), }); it("preserves commas before brackets inside string values", () => { - // A comma inside a string value that happens to precede `}`/`]` must not - // be stripped as if it were a trailing comma. expect(decodeLenientJson('{"note":"a,]"}')).toEqual({ note: "a,]" }); expect(decodeLenientJson('{"list":["x,}"]}')).toEqual({ list: ["x,}"] }); - // Genuine trailing commas are still removed. expect(decodeLenientJson('{"values":[1, 2,],}')).toEqual({ values: [1, 2] }); }); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 64dd48575028..a65ad62573e6 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -155,30 +155,19 @@ export const formatSchemaError = (cause: Cause.Cause) => { return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH - suffix.length) + suffix; }; -/** - * A `Getter` that parses a lenient JSON string (tolerating trailing commas - * and JS-style comments) into an unknown value. - * - * Mirrors `SchemaGetter.parseJson()` but strips JSONC syntax before parsing. - */ const decodeJsonString = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const parseLenientJsonGetter = SchemaGetter.transformEffect((input: string) => { - // Strip single-line comments - alternation preserves quoted strings. let stripped = input.replace( /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), ); - // Strip multi-line comments. stripped = stripped.replace( /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), ); - // Strip trailing commas before `}` or `]`. The alternation preserves quoted - // strings so a comma inside a string value (e.g. `{"note":"a,]"}`) is not - // mistaken for a trailing comma and removed. stripped = stripped.replace( /("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g, (match, stringLiteral: string | undefined, bracket: string | undefined) => @@ -188,13 +177,6 @@ const parseLenientJsonGetter = SchemaGetter.transformEffect((input: string) => { return decodeJsonString(stripped).pipe(Effect.mapError((error) => error.issue)); }); -/** - * Schema transformation: lenient JSONC string ↔ unknown. - * - * Same API as `SchemaTransformation.fromJsonString`, but the decode side - * strips trailing commas and JS-style comments before parsing. - * Encoding produces strict JSON via `JSON.stringify`. - */ const fromLenientJsonString = new SchemaTransformation.Transformation( parseLenientJsonGetter, SchemaGetter.stringifyJson(), @@ -204,12 +186,6 @@ const prettyJsonString = SchemaGetter.parseJson().pipe( SchemaGetter.compose(SchemaGetter.stringifyJson({ space: 2 })), ); -/** - * Build a schema that decodes a lenient JSON string into `A`. - * - * Drop-in replacement for `Schema.fromJsonString(schema)` that tolerates - * trailing commas and comments in the input. - */ export const fromLenientJson = (schema: S) => Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString)); @@ -261,12 +237,6 @@ export function extractJsonObject(raw: string): string { return trimmed.slice(start); } -/** - * Build a JSON string schema that encodes with stable 2-space formatting. - * - * Decode behavior matches `Schema.fromJsonString(schema)`. Encode behavior - * keeps the transformation schema-based while preserving human-readable JSON. - */ export const fromJsonStringPretty = (schema: S) => Schema.fromJsonString(schema).pipe( Schema.encode({ diff --git a/packages/shared/src/schemaYaml.ts b/packages/shared/src/schemaYaml.ts index 867efdacbe2e..58e23a849698 100644 --- a/packages/shared/src/schemaYaml.ts +++ b/packages/shared/src/schemaYaml.ts @@ -32,7 +32,6 @@ function formatYamlParseError(error: unknown): string { return `Invalid YAML (code=${error.code}${location}).`; } -/** Parses YAML during decoding, reporting parse failures as InvalidValue issues. */ function parseYaml(options?: YamlParseOptions): SchemaGetter.Getter { return SchemaGetter.transformEffect((input: E) => Effect.try({ @@ -42,7 +41,6 @@ function parseYaml(options?: YamlParseOptions): SchemaGetter.G ); } -/** Serializes YAML during encoding, reporting stringify failures as InvalidValue issues. */ function stringifyYaml(options?: YamlStringifyOptions): SchemaGetter.Getter { return SchemaGetter.transformEffect((input: unknown) => Effect.try({ @@ -52,37 +50,10 @@ function stringifyYaml(options?: YamlStringifyOptions): SchemaGetter.Getter( parseYaml(), stringifyYaml(), ); -/** - * Build a schema that decodes a YAML string into `A`. - * - * Decode parses the input as YAML before validating the parsed value with the - * provided schema. Encode validates the value and serializes it as YAML text. - */ export const fromYaml = (schema: S) => Schema.String.pipe(Schema.decodeTo(schema, fromYamlString)); diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index 6c5ee4b89a91..7bcda99bb3dd 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -85,8 +85,6 @@ function findBoundaryMatchIndex( } /** - * Scores how well `value` matches `query` using tiered match strategies. - * * **Expects pre-normalized inputs**: both `value` and `query` must already be * trimmed and lowercased (e.g. via {@link normalizeSearchQuery}). */ @@ -99,10 +97,6 @@ export function scoreQueryMatch(input: { includesBase?: number; fuzzyBase?: number; boundaryMarkers?: readonly string[]; - /** - * Score by match tier alone, ignoring where the match lands and how long the - * value is, so callers can keep their own order among equal matches. - */ ignorePosition?: boolean; }): number | null { const { value, query } = input; diff --git a/packages/shared/src/semver.test.ts b/packages/shared/src/semver.test.ts index ed3e1896aaf6..71e8da310c0f 100644 --- a/packages/shared/src/semver.test.ts +++ b/packages/shared/src/semver.test.ts @@ -31,8 +31,6 @@ describe("semver helpers", () => { }); it("compares shorthand versions numerically instead of lexically", () => { - // Regression: "20" vs "9" previously fell back to string comparison, which - // ordered "20" before "9" ("2" < "9"). expect(compareSemverVersions("20", "9")).toBeGreaterThan(0); expect(compareSemverVersions("18", "18.0.0")).toBe(0); }); diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts index a765b065fc6d..ee8a61e52d27 100644 --- a/packages/shared/src/semver.ts +++ b/packages/shared/src/semver.ts @@ -17,11 +17,6 @@ export function normalizeSemverVersion(version: string): string { } } - // Pad shorthand versions ("20" or "20.1") up to three segments so major-only - // and minor-only inputs parse and compare numerically. This matches - // satisfiesSemverRange, which already treats a missing minor/patch as 0. The - // length > 0 guard keeps empty/garbage input empty (parseSemver still - // rejects it), and inputs with more than three segments are left untouched. while (segments.length > 0 && segments.length < 3) { segments.push("0"); } @@ -141,14 +136,8 @@ export function compareSemverVersions(left: string, right: string): number { } /** - * Small semver range checker for CLI/runtime gates. - * * Keep the function body valid plain JavaScript: SSH startup stringifies this * function and runs it on remote Node versions before TypeScript support is known. - * - * @param rawVersion Version string, with or without a leading `v`. - * @param range Space-separated comparators, with `||` range groups. - * @returns Whether `rawVersion` satisfies the supported range syntax. */ export const satisfiesSemverRange: (rawVersion: string, range: string) => boolean = function satisfiesSemverRange(rawVersion, range) { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 84db4b8c656a..5d1154e0fc9d 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -23,7 +23,6 @@ import { resolveProjectAutoPull, } from "./serverSettings.ts"; -/** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */ const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { @@ -106,7 +105,6 @@ describe("serverSettings helpers", () => { runOnWorktreeCreate: false, }; const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; - // Before the one-time fold, scripts stored on the project aggregate still apply. const unfolded = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultProjectScripts: [action], }); @@ -655,9 +653,6 @@ describe("serverSettings helpers", () => { }); it("keeps a project's providerInstances override when a patch entry omits the key", () => { - // An older client that predates per-project provider overrides reads and - // resends a project's whole entry without knowing about the field. A - // naive whole-entry replace would silently wipe it. const instanceId = ProviderInstanceId.make("codex_work"); const projectId = ProjectId.make("project-a"); const current = { @@ -683,9 +678,6 @@ describe("serverSettings helpers", () => { }); it("clears a project's providerInstances override when a patch entry sends an empty map", () => { - // A client that knows the field expresses "clear every provider override - // for this project" by sending providerInstances explicitly, not by - // omitting it. const instanceId = ProviderInstanceId.make("codex_work"); const projectId = ProjectId.make("project-a"); const current = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index c4c9e389527b..5371cf76e8a7 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -34,7 +34,6 @@ import { const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); -/** @deprecated Read `resolveProjectSettings(...).settings.enableAgentBrowserAccess`. */ export function resolveProjectAgentBrowserAccess( settings: Pick< ServerSettings, @@ -49,7 +48,6 @@ export function resolveProjectAgentBrowserAccess( ); } -/** @deprecated Read `resolveProjectSettings(...).settings.defaultAutoPull`. */ export function resolveProjectAutoPull( settings: Pick< ServerSettings, @@ -58,7 +56,6 @@ export function resolveProjectAutoPull( projectId: ProjectId, legacyAutoPull: boolean | undefined, ): boolean { - // Existing opt-ins stay enabled until explicitly overridden or reset. return ( settings.projectSettingsOverrides[projectId]?.defaultAutoPull ?? settings.projectAutoPullOverrides[projectId] ?? @@ -90,16 +87,6 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ ].map((driver) => ProviderDriverKind.make(driver)); /** - * The text generation model on the first instance `isEnabled` accepts: the - * built-in default instances in preference order, then, when - * `includeConfiguredInstances` is set, configured instances of those - * drivers. `null` when none is. - * - * The model is the driver's cheap default, unless `defaultModelSelection` - * (the thread default) is one of that instance's custom models. A custom - * model usually means a backend that may refuse the built-in default, and - * the thread default, with its options, is known to work there. - * * `includeConfiguredInstances` is for the project-scoped caller only: a * project that disabled every built-in instance should still be able to * fall back to one it configured itself. The machine-level caller leaves it @@ -146,12 +133,6 @@ export function fallbackTextGenerationModelSelection( }; } -/** - * `projectId` has no default: a caller that forgets it would otherwise fall - * back to the machine's own writer choice, silently ignoring a project that - * turned the writer's instance off. Pass `null` explicitly outside a - * project's scope. - */ export function resolveSourceControlWriterModelSelection( settings: ServerSettings, providers: ReadonlyArray | undefined, @@ -176,10 +157,6 @@ export function resolveSourceControlWriterModelSelection( : settings.textGenerationModelSelection; } -/** - * The model that writes review guides: its own setting when that provider can still run it, - * otherwise whatever writes source control text. - */ export function resolveReviewGuideModelSelection( settings: ServerSettings, providers: ReadonlyArray | undefined, @@ -264,7 +241,6 @@ function mergeModelSelectionOptionsById(input: { return [...merged.entries()].map(([id, value]) => ({ id, value })); } -/** Upsert each patched entry; `null` removes it. Entries the patch omits are untouched. */ function mergeSettingsEntries( current: Readonly>, patch: Readonly>, @@ -284,10 +260,8 @@ function mergeSettingsEntries( * `projectSettingsOverrides` entries otherwise replace whole (see the field's * doc comment in contracts), but `providerInstances` was added after clients * already round-trip a project's entry on every edit. A client built before - * that addition sends an entry that never mentions the key, and a whole - * replace would silently wipe it. When an incoming entry omits the key, keep - * what is stored; a client that means to clear every provider override for - * the project says so explicitly with `providerInstances: {}`. + * that addition sends an entry that never mentions the key, and a whole replace + * would silently wipe it. */ function mergeProjectSettingsOverridesPatch( current: Readonly>, @@ -310,11 +284,6 @@ function mergeProjectSettingsOverridesPatch( return Object.fromEntries(next); } -/** - * Derived views of `projectSettingsOverrides` for clients that still read - * the legacy per-key maps. Recomputed on every patch and load so they - * cannot drift from the generic record. - */ export function deriveLegacyProjectOverrides( settings: Pick, ): Pick< @@ -338,12 +307,6 @@ export function deriveLegacyProjectOverrides( return { projectAgentBrowserAccessOverrides, projectAutoPullOverrides, projectScriptOverrides }; } -/** - * Rewrite a patch that still uses the legacy per-key project maps into - * entries of `projectSettingsOverrides`, so older clients keep editing the - * values the server actually reads. `null` in a legacy map clears that one - * override. - */ function translateLegacyProjectOverridePatch( current: Pick, patch: ServerSettingsPatch, @@ -420,8 +383,6 @@ export function applyServerSettingsPatch( usageModelAliases: usageModelAliasesPatch, // Entry replacement: deepMerge would keep keys the client meant to clear. projectSettingsOverrides: projectSettingsOverridesPatch, - // Already translated into `projectSettingsOverrides` above; the legacy - // maps are derived views and must never be merged directly. projectAgentBrowserAccessOverrides: _legacyBrowserAccess, projectAutoPullOverrides: _legacyAutoPull, projectScriptOverrides: _legacyScripts, @@ -663,7 +624,6 @@ export function applyServerSettingsPatch( }; } -/** Resolve legacy local preferences only until the environment has a saved value. */ export function resolveModelPreferences( settings: Pick, legacy: Partial = {}, diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index d401824cee42..8634ebc39662 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -452,10 +452,6 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { }), ); - // Records every path the scan stats, without ever reporting a match, so the - // walk runs to exhaustion and the probe set can be inspected. Assertions - // below count probes rather than naming paths: `Path` is the host's, so the - // separator differs between a Windows and a Linux CI runner. const recordProbes = (env: NodeJS.ProcessEnv) => Effect.gen(function* () { const probed: Array = []; @@ -484,7 +480,6 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { PATHEXT: ".COM;.EXE", }); - // Two directories, two extensions, upper and lowercase spellings. expect(probed).toHaveLength(8); expect(new Set(probed).size).toBe(probed.length); }), @@ -497,9 +492,9 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { PATHEXT: ".COM;.EXE", }); - // Deliberately not folded together. Windows 10+ can mark a directory - // case-sensitive, so the two spellings are not provably one directory and - // skipping the second could hide a command that is really there. + // Windows 10+ can mark a directory case-sensitive, so the two spellings + // are not provably one directory and skipping the second could hide a + // command that is really there. expect(probed).toHaveLength(8); }), ); @@ -521,7 +516,6 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { Effect.provideService(CommandResolutionCache, new Map()), Effect.provideService(FileSystem.FileSystem, { ...fs, - // Keep this case-sensitive fixture portable to case-insensitive hosts. stat: (filePath) => fs.stat(filePath === executable ? filePath : path.join(cwd, "missing")), }), @@ -578,14 +572,13 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { expect(yield* resolveCommandPath("cursor", { env })).toBe(path.join(first, "cursor.CMD")); expect(yield* isCommandAvailable("absent", { env })).toBe(false); yield* fs.writeFileString(path.join(second, "late.EXE"), ""); - yield* fs.utimes(second, 4_102_444_800, 4_102_444_800); // seconds: 2100-01-01 + yield* fs.utimes(second, 4_102_444_800, 4_102_444_800); expect(yield* resolveCommandPath("late", { env })).toBe(path.join(second, "late.EXE")); }).pipe( withPathDirectoryListings, Effect.provideService(FileSystem.FileSystem, { ...fs, stat: (file) => { - // Record candidate probes, not the per-lookup directory mtime checks. if (file !== first && file !== second) probed.push(file); return fs.stat(file); }, @@ -606,7 +599,6 @@ describe("preferGitForWindowsBinary", () => { paths.includes(filePath); it("runs the git.exe Git for Windows' launcher would start", () => { - // 2.56+ on x64. The PATHEXT scan returns the extension in PATHEXT's case. expect( preferGitForWindowsBinary( "C:\\Program Files\\Git\\cmd\\git.EXE", @@ -617,7 +609,6 @@ describe("preferGitForWindowsBinary", () => { ), ), ).toBe("C:\\Program Files\\Git\\ucrt64\\bin\\git.exe"); - // Before 2.56, and the portable build's bin launcher. expect( preferGitForWindowsBinary( "D:\\PortableGit\\bin\\git.exe", @@ -764,12 +755,9 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe"); expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe"); - // A failed spawn is how a provider reports "not installed", so a miss - // must clear the moment the binary appears. yield* resolve("missing", "C:\\one"); yield* resolve("missing", "C:\\one"); expect((yield* resolve("git", "C:\\two")).command).toBe("C:\\two\\git.exe"); - // Callers probe explicit paths they may have just written. yield* resolve("C:\\tools\\git.exe", "C:\\one"); yield* resolve("C:\\tools\\git.exe", "C:\\one"); expect(scans).toEqual([ @@ -785,7 +773,6 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { yield* resolve("git", "C:\\one"); expect(scans).toHaveLength(7); - // Another resolver sharing the cache gets its own answer, not the cached one. const elsewhere = yield* resolve("git", "C:\\one", () => "D:\\elsewhere\\git.exe"); expect(elsewhere.command).toBe("D:\\elsewhere\\git.exe"); expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe"); @@ -811,7 +798,6 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { yield* resolve({}); yield* resolve({}); expect(scans).toBe(1); - // A swap made without MSYSTEM must not reach a child that has it set. yield* resolve({ MSYSTEM: "MINGW64" }); expect(scans).toBe(2); }).pipe(Effect.provideService(CommandResolutionCache, new Map())), diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index fe376cb183ab..e8a9cc2ba77c 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -45,21 +45,15 @@ export type CommandAvailabilityChecker = ( const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; /** - * Escapes a single argument for `cmd.exe` shell mode (`spawn(..., { shell: true })` - * on Windows). Node hands the command string to `cmd.exe /d /s /c ""` - * without any quoting, so every dynamic argument must be escaped to survive both - * cmd.exe parsing and the target program's `CommandLineToArgvW` parsing. - * Mirrors cross-spawn's argument escaping. + * Node hands the command string to `cmd.exe /d /s /c ""` without any + * quoting, so every dynamic argument must be escaped to survive both cmd.exe + * parsing and the target program's `CommandLineToArgvW` parsing. */ function escapeWindowsShellArg(arg: string): string { - // Double up backslashes that precede a double quote, then escape the quote - // itself so it survives CommandLineToArgvW. let escaped = arg.replace(/(\\*)"/g, '$1$1\\"'); // Double up trailing backslashes so the closing quote is not escaped away. escaped = escaped.replace(/(\\*)$/, "$1$1"); - // Quote the whole argument so embedded whitespace is preserved. escaped = `"${escaped}"`; - // Escape cmd.exe metacharacters so cmd passes them through verbatim. return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1"); } @@ -458,17 +452,10 @@ function resolveCommandCandidates( return Array.from(new Set(candidates)); } -// Session bootstrap resolves the same commands over and over, each PATH scan -// costing hundreds of 'shell.isExecutableFile' filesystem probes (tens of -// thousands per connect). Memoize the scan outcome per -// (platform, PATH, PATHEXT, command) for a short window: repeat scans hit the -// cache while any change to the search environment invalidates immediately. // Explicit-path resolution is never cached - callers probe paths they have // just written (e.g. managed binary installs). A "not-found" outcome is also // cached for the TTL, so a just-installed binary can stay invisible for up to // 30s unless resolved by explicit path. -// TTL expiry uses the monotonic clock (Clock.monotonicTimeNanos) so backward -// wall-clock adjustments cannot keep expired entries alive. const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n; const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512; const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0); @@ -478,9 +465,6 @@ interface CommandResolutionCacheEntry { readonly expiresAtNanos: bigint; } -// The cache lives in the Effect environment (like HostProcess.Platform above) -// so tests and embedders can provide an isolated instance; the default is a -// single process-wide map shared by all consumers. export const CommandResolutionCache = Context.Reference>( "@t3tools/shared/shell/CommandResolutionCache", { @@ -493,7 +477,6 @@ interface PathDirectoryListing { readonly names: ReadonlySet | undefined; } -// mtime of a PATH directory; null when missing, undefined when unreadable. const directoryMtime = (directory: string) => FileSystem.FileSystem.use((fileSystem) => fileSystem.stat(directory)).pipe( Effect.map((info) => @@ -519,11 +502,6 @@ const PathDirectoryListings = Context.Reference< Cache.Cache | undefined >("@t3tools/shared/shell/PathDirectoryListings", { defaultValue: () => undefined }); -/** - * Run a batch of command lookups (e.g. editor discovery) that lists each PATH - * directory once, relisting it if its mtime changes, and probes only listed - * names instead of every PATH x PATHEXT candidate per command. - */ export const withPathDirectoryListings = (effect: Effect.Effect) => Effect.gen(function* () { const listings = yield* Cache.make({ @@ -564,7 +542,6 @@ function cacheCommandResolution( }); } -// Trace each command lookup, not every candidate file it probes. const isExecutableFile = Effect.fnUntraced(function* ( filePath: string, platform: NodeJS.Platform, @@ -642,7 +619,6 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat listing = yield* Cache.get(listings, pathEntry); } for (const candidate of commandCandidates) { - // The stat below still checks exact case and rejects non-files. if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue; const candidatePath = path.join(pathEntry, candidate); if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { @@ -678,14 +654,11 @@ function isFileSync(filePath: string): boolean { } /** - * Swaps Git for Windows' launcher (`\cmd\git.exe`, the only git its - * installer puts on PATH, or the portable build's `\bin\git.exe`) for the - * git.exe it starts. The launcher costs a second process on every git command, - * and each launch leaks a kernel token reference that slows process creation - * machine-wide until reboot. The real binary sets HOME itself, but adds its own - * folders to PATH for hooks, ssh and credential helpers only when MSYSTEM is - * unset, so the launcher stays when MSYSTEM is set. Anything else is returned - * as is. + * The launcher costs a second process on every git command, and each launch + * leaks a kernel token reference that slows process creation machine-wide until + * reboot. The real binary sets HOME itself, but adds its own folders to PATH + * for hooks, ssh and credential helpers only when MSYSTEM is unset, so the + * launcher stays when MSYSTEM is set. */ export function preferGitForWindowsBinary( executable: string, @@ -706,7 +679,6 @@ export function preferGitForWindowsBinary( return executable; } -// Untraced because it runs before most spawns and returns at once off Windows. export const resolveSpawnCommand = Effect.fnUntraced(function* ( command: string, args: ReadonlyArray, @@ -746,9 +718,6 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( if (cached !== undefined && cached.expiresAtNanos > nowNanos) { resolvedExecutable = cached.resolvedPath; } else { - // Cached with the scan: its file checks would otherwise run before every - // git launch. A Git upgrade that moves the real binary can fail git for up - // to the cache lifetime. const found = resolveExecutable(command, platform, env); resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env); if (!explicitPath && resolvedExecutable !== null) { @@ -842,8 +811,6 @@ export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironm }).PATH; const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); - // Preserve the order a user's shell uses. These directories fill gaps when - // desktop apps launch without the full interactive-shell PATH. const baselinePath = mergePathValues(mergedPath, knownCliPath, "win32"); const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 46d0f70fea5c..7395c4b6ad2d 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -85,7 +85,6 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { }); it("detects Azure DevOps SSH remotes", () => { - // The default Azure DevOps SSH clone URL uses the ssh.dev.azure.com host. expect( detectSourceControlProviderFromRemoteUrl("git@ssh.dev.azure.com:v3/org/project/repo")?.kind, ).toBe("azure-devops"); @@ -93,7 +92,6 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { detectSourceControlProviderFromRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/org/project/repo") ?.kind, ).toBe("azure-devops"); - // Legacy visualstudio.com SSH host stays classified too. expect( detectSourceControlProviderFromRemoteUrl("git@vs-ssh.visualstudio.com:v3/org/project/repo") ?.kind, diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index eee0c817cb07..fcda71b64f8f 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -144,10 +144,6 @@ export function isSshRemoteUrl(remoteUrl: string): boolean { return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); } -/** - * Extracts the normalized host used for provider detection. SCP-style and SSH remotes return the - * hostname only, while other URL schemes retain explicit ports for non-default web endpoints. - */ function parseRemoteHost(remoteUrl: string): string | null { const trimmed = remoteUrl.trim(); if (trimmed.length === 0) { @@ -203,7 +199,6 @@ function isAzureDevOpsHost(host: string): boolean { ); } -/** GitCafe has no self-hosted installs: production and staging are the only hosts. */ function isGitCafeHost(host: string): boolean { return host === "git.cafe" || host === "staging.git.cafe"; } @@ -283,17 +278,10 @@ export function detectSourceControlProviderFromRemoteUrl( } /** - * The provider-native repository selector. `displayName` is the full path below the host, which - * is what nested GitLab groups need; owner/name is the two-segment fallback for identities - * recorded before that field existed. - * * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and * takes the organisation and project from the checkout it detects β€” so the recorded * `org/project/_git/repo` path is refused outright and the whole repository reads as * unavailable. Its name is the last segment, which is what this hands over. - * - * One function because everything downstream is keyed by what it answers: the rows' own - * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. */ export function sourceControlRepositorySelector( identity: diff --git a/packages/shared/src/symlink.ts b/packages/shared/src/symlink.ts index f1e3ffed0fdb..bc947b0596a8 100644 --- a/packages/shared/src/symlink.ts +++ b/packages/shared/src/symlink.ts @@ -13,11 +13,9 @@ const isNotASymlink = (error: PlatformError.PlatformError) => (error.cause instanceof Error && "code" in error.cause && error.cause.code === "EINVAL"); /** - * Follows a chain of symlinks to the file it finally names, which may not exist - * yet. Any path that is not a symlink resolves to itself. Atomic writers rename - * onto this path so a linked file keeps its link. Fails on a cycle, an overly - * long chain, or an unreadable link rather than handing back a link that a - * rename would replace. + * Atomic writers rename onto this path so a linked file keeps its link. Fails + * on a cycle, an overly long chain, or an unreadable link rather than handing + * back a link that a rename would replace. */ export const resolveSymlinkTarget = (filePath: string) => Effect.gen(function* () { diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 32a5b6fa65ed..b18351cdd2c4 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -84,7 +84,6 @@ function tool( const T3_MCP_SERVER_ALIASES = new Set(["t3-code", "t3_code", "t3code"]); -// Cards, activity rows, summaries, and provider identity recovery share this inventory. const T3_MCP_TOOLS: Readonly> = { link_pull_request: tool( ["Link", "Linking", "Linked", "a pull request"], @@ -334,10 +333,6 @@ const T3_MCP_TOOLS: Readonly> = { html_render: tool(["Render", "Rendering", "Rendered", "an HTML page"], "html-render"), }; -/** - * The T3 orchestration tool inventory, used to gate loose name matching on - * both the server (ACP MCP identity recovery) and the client (logo branding). - */ export const T3_MCP_TOOL_NAMES: ReadonlySet = new Set(Object.keys(T3_MCP_TOOLS)); function normalizeT3McpToolLabel(value: string): string { @@ -384,7 +379,6 @@ function resolveT3McpToolName(value: string): string | null { return longest; } -/** The bare T3 tool name (`html_render`) for any provider's spelling of it. */ export function resolveT3McpToolId(toolName: string | null | undefined): string | null { const name = toolName == null ? null : resolveT3McpToolName(toolName); return name !== null && Object.hasOwn(T3_MCP_TOOLS, name) ? name : null; diff --git a/packages/shared/src/t3ProjectFile.ts b/packages/shared/src/t3ProjectFile.ts index 8f93dbe6ee6a..910e78420f46 100644 --- a/packages/shared/src/t3ProjectFile.ts +++ b/packages/shared/src/t3ProjectFile.ts @@ -5,33 +5,16 @@ import { T3ProjectFile, T3_PROJECT_FILE_SCHEMA_URL } from "@t3tools/contracts"; import { fromLenientJson } from "./schemaJson.ts"; -/** - * Codec between the raw `t3.json` file contents (lenient JSONC string) and the - * decoded {@link T3ProjectFile}. - */ export const T3ProjectFileFromJson = fromLenientJson(T3ProjectFile); const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); -/** - * Decode raw `t3.json` contents, treating invalid or malformed files as - * absent. Clients use this to read optional defaults (scripts, thread env - * mode) without surfacing decode errors to the user. - */ export function parseT3ProjectFile(contents: string): T3ProjectFile | null { const decoded = decodeT3ProjectFile(contents); return Exit.isSuccess(decoded) ? decoded.value : null; } -/** - * Build the publishable JSON Schema document for `t3.json` (draft 2020-12). - * - * Served from the marketing site at {@link T3_PROJECT_FILE_SCHEMA_URL} so - * editors get LSP support via a `$schema` reference. - */ export function buildT3ProjectFileJsonSchema(): Record { - // Closed objects, as before effect rc.113 changed the generator default; - // editors then flag unknown keys in t3.json. const document = Schema.toJsonSchemaDocument(T3ProjectFile, { onExcessProperty: "error" }); const jsonSchema: Record = { $schema: "https://json-schema.org/draft/2020-12/schema", diff --git a/packages/shared/src/terminalLabels.ts b/packages/shared/src/terminalLabels.ts index 9873c32c4cb2..d020e46fade1 100644 --- a/packages/shared/src/terminalLabels.ts +++ b/packages/shared/src/terminalLabels.ts @@ -6,7 +6,6 @@ function terminalNumber(terminalId: string): string | undefined { )?.[1]; } -/** Human-readable label for a terminal tab; matches mobile and web sidebars. */ export function getTerminalLabel(terminalId: string): string { const numericSuffix = terminalNumber(terminalId); if (numericSuffix) { @@ -16,7 +15,6 @@ export function getTerminalLabel(terminalId: string): string { return terminalId; } -/** Prefer server summary label when present; otherwise fall back to `getTerminalLabel`. */ export function resolveTerminalSessionLabel( terminalId: string, summary: Pick | null | undefined, @@ -29,11 +27,8 @@ export function resolveTerminalSessionLabel( } /** - * Client-side terminal id allocator. Ids are ALWAYS chosen by the client and sent explicitly - * on every `terminal.open` / `terminal.attach` call β€” the server never allocates. - * - * Returns the lowest unused `term-N` number. When metadata is unavailable, - * callers append a UUID so an unseen host session cannot be reused accidentally. + * When metadata is unavailable, callers append a UUID so an unseen host session + * cannot be reused accidentally. */ export function nextTerminalId( existingTerminalIds: ReadonlyArray, diff --git a/packages/shared/src/testing/TraceRetention.fixture.mjs b/packages/shared/src/testing/TraceRetention.fixture.mjs index 38c4ddf5b50c..a8dcb781a94e 100644 --- a/packages/shared/src/testing/TraceRetention.fixture.mjs +++ b/packages/shared/src/testing/TraceRetention.fixture.mjs @@ -31,7 +31,6 @@ const tracer = await Effect.runPromise( }, }), ); -// Like the SQLite prepared statement cache, lookup fibers remain alive after use. const cache = await Effect.runPromise( Cache.make({ capacity: count, timeToLive: "10 minutes", lookup: (key) => Effect.succeed(key) }), ); diff --git a/packages/shared/src/testing/longTempDir.ts b/packages/shared/src/testing/longTempDir.ts index 669d6da36971..e542695fd7de 100644 --- a/packages/shared/src/testing/longTempDir.ts +++ b/packages/shared/src/testing/longTempDir.ts @@ -6,14 +6,11 @@ import * as HostProcess from "../HostProcess.ts"; // GitHub's Windows runners hand out the temp directory by its 8.3 short name // (C:\Users\RUNNER~1\...). Anything that canonicalises a path, such as git or // realpath, reports the long form, so equality checks between a temp path and -// its canonical form fail. Node reads TEMP/TMP on every os.tmpdir() call, so -// pointing them at the long form fixes every temp directory the suite makes. +// its canonical form fail. if (HostProcess.Platform.defaultValue() === "win32") { try { const longForm = NodeFS.realpathSync.native(NodeOS.tmpdir()); process.env.TEMP = longForm; process.env.TMP = longForm; - } catch { - // Leave the host's value alone if it cannot be resolved. - } + } catch {} } diff --git a/packages/shared/src/testing/symlinks.ts b/packages/shared/src/testing/symlinks.ts index 4e76984ca210..00091311f75a 100644 --- a/packages/shared/src/testing/symlinks.ts +++ b/packages/shared/src/testing/symlinks.ts @@ -4,10 +4,9 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; /** - * Whether this host lets an unprivileged process create symlinks. Windows - * refuses without Developer Mode or elevation, so tests that plant a symlink - * to prove containment or escape detection should `skipIf(!symlinksSupported)` - * rather than fail at setup. Probed once per process. + * Windows refuses without Developer Mode or elevation, so tests that plant a + * symlink to prove containment or escape detection should + * `skipIf(!symlinksSupported)` rather than fail at setup. */ export const symlinksSupported: boolean = (() => { let directory: string | undefined; @@ -22,8 +21,6 @@ export const symlinksSupported: boolean = (() => { // probe directory would otherwise throw out of module initialisation. try { if (directory !== undefined) NodeFS.rmSync(directory, { recursive: true, force: true }); - } catch { - // Leave the probe directory behind rather than fail every importer. - } + } catch {} } })(); diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index d6d7979de550..bff06383aadc 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -1,13 +1,7 @@ export const BUILT_IN_THEME_IDS = ["t3-chat", "grove", "ocean", "ember", "iris"] as const; -/** The standard T3 Code palette, kept separate from the optional built-in theme library. */ export const MOBILE_DEFAULT_THEME_ID = "t3-code"; -/** - * Every palette the mobile app can render. Declared here so host-side tooling - * (the app-store screenshot harness) can validate a requested theme without - * importing React Native application code. - */ export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const; /** @@ -43,7 +37,6 @@ export type BuiltInThemeId = (typeof BUILT_IN_THEME_IDS)[number]; export type MobileThemeId = (typeof MOBILE_THEME_IDS)[number]; export type ThemeAppearance = "light" | "dark"; -/** Product roles shared by web CSS, React Native tokens, and native surfaces. */ export const THEME_COLOR_ROLES = [ "canvas", "chrome", @@ -117,23 +110,11 @@ export type ThemeDefinition = Readonly<{ appearance: ThemeAppearance; colors: ThemeColors; variants?: ThemeVariants; - /** Groups related imported variants into one library card. */ collection?: Readonly<{ id: string; label: string }>; - /** Allows reviewed built-ins to render product artwork over their sidebar. */ sidebarArtwork?: boolean; - /** Generated from the guided editor's canvas and accent roles. */ managed?: boolean; }>; -/** - * The palette T3 Code wears with no theme installed, captured from the app's - * stock tokens (index.css) so a draft seeded from the default look paints the - * pixels the user is already seeing. Alpha-bearing tokens are flattened over - * their real backdrops (canvas, or the sidebar for its rows) because theme - * colors are stored as opaque OKLCH tokens. - * Search uses amber across built-ins to separate matches from accent-tinted - * inline code. The current match uses a stronger gold with dark text. - */ export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { searchMatchBackground: "#ffde8b", searchMatchForeground: "#21180a", diff --git a/packages/shared/src/themePreview.ts b/packages/shared/src/themePreview.ts index 62ac593d4c28..39ba431d88eb 100644 --- a/packages/shared/src/themePreview.ts +++ b/packages/shared/src/themePreview.ts @@ -6,7 +6,6 @@ export type ThemePreviewColors = Readonly<{ messageAction: string; }>; -/** The standard T3 Code artwork is not a built-in theme, so its preview colors live here. */ export const STANDARD_THEME_PREVIEW_COLORS: Readonly> = { light: { @@ -39,7 +38,6 @@ export type ThemePreviewRenderSpec = Readonly<{ blurAt56Px: number; }>; -/** Shared geometry and falloff for the web and native theme preview orbs. */ export const THEME_PREVIEW_RENDER_SPECS: Readonly> = { light: { diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index d4019bf28cd4..a2e5b4dbb982 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -102,7 +102,6 @@ interface TextTree { readonly children?: ReadonlyArray; } -/** Uses the renderer's Markdown transforms, without mounting folded/virtualized rows. */ function markdownThreadFindText( markdown: string, userMessage = false, @@ -139,7 +138,6 @@ function markdownThreadFindText( const citation = node.tagName === "a" && typeof href === "string" ? parseAssistantCitationHref(href) : null; if (citation) { - // Matches the citation chip, which shows its label instead of the link text. text += assistantCitationLabel(citation); return; } @@ -169,7 +167,6 @@ function markdownThreadFindText( } const template = artifactTemplateFromHastProperties(node.properties); if (template) { - // Matches the card's visible text; its action button is not indexed. flush(); segments.push( template.displayName, diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index 087aa8894f6a..e6855ea9cf26 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -29,7 +29,6 @@ describe("thread links", () => { ).toBe( "[Decoded](t3-thread://v1/thread:project:1) [Literal escape](t3-thread://v1/provider%3A1)", ); - // A thread with an empty title still exists, so its link is not redirected. const untitled = new Map([ ["a%3A1", ""], ["a:1", "Other"], diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts index 15384aeea4d4..0b17846db147 100644 --- a/packages/shared/src/threadLinks.ts +++ b/packages/shared/src/threadLinks.ts @@ -2,11 +2,6 @@ import { ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -/** - * Agents mention another thread in Markdown as `[title](t3-thread://v1/)`. The id is - * the reference and resolves in the environment of the message that holds it. Titles change, so - * clients show the thread's current title; the label only stands in for a thread they cannot see. - */ export const THREAD_LINK_PROTOCOL = "t3-thread"; const THREAD_LINK_HREF_PREFIX = `${THREAD_LINK_PROTOCOL}://v1/`; // Code comes first in the alternation so a link written inside a code span or fence is skipped. @@ -21,10 +16,6 @@ export function parseThreadLinkHref(href: string): ThreadId | null { return Option.getOrNull(decodeThreadId(href.slice(THREAD_LINK_HREF_PREFIX.length))); } -/** - * Agents often percent-encode the id anyway. When the id as written names no thread, clients try - * this decoded form. Null when decoding changes nothing or fails. - */ export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null { try { const decoded = decodeURIComponent(threadId); @@ -34,7 +25,6 @@ export function percentDecodedThreadLinkId(threadId: ThreadId): ThreadId | null } } -/** A thread link whose label survives Markdown: no brackets, backslashes, or line breaks. */ export function formatThreadLink(threadId: string, label: string): string { const cleaned = label .replace(/[[\]\\\r\n]/g, " ") @@ -47,10 +37,6 @@ export function hasThreadLinks(markdown: string): boolean { return markdown.includes(`](${THREAD_LINK_HREF_PREFIX}`); } -/** - * Relabels each thread link with `title(threadId)`, pointing it at the thread that title came from. - * A link it returns nothing for keeps its label. - */ export function relabelThreadLinks( markdown: string, title: (threadId: ThreadId) => string | undefined, @@ -61,7 +47,6 @@ export function relabelThreadLinks( if (href === undefined) return source; const written = parseThreadLinkHref(href); if (written === null) return source; - // The decoded id only stands in when the id as written names no thread. const decoded = percentDecodedThreadLinkId(written); const threadId = title(written) === undefined && decoded !== null && title(decoded) !== undefined diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index e926e10d3b43..3fb8f27a3258 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -16,7 +16,6 @@ type ThreadPullRequestKeySource = ThreadPullRequestKey & { readonly url?: string; }; -/** Normalize stored links, recovering Forgejo HTTP ports from old links' URLs. */ export function normalizeThreadPullRequestKey( key: ThreadPullRequestKeySource, ): ThreadPullRequestKey { @@ -37,7 +36,6 @@ export function normalizeThreadPullRequestKey( }; } -/** Legacy Azure selectors omit the organization and project; recover those from the PR URL. */ export function legacyThreadPullRequestKey( linked: Pick, fallbackHost?: string, @@ -64,7 +62,6 @@ export function legacyThreadPullRequestKey( }; } -/** Identity comparison for links: host-level, case-insensitive on host and repository. */ export function threadPullRequestKeysEqual( left: ThreadPullRequestKeySource, right: ThreadPullRequestKeySource, @@ -77,7 +74,7 @@ export function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string return `${normalized.host}/${normalized.repository}#${normalized.number}`; } -/** Links a user should see. Tombstoned stack members stay in the array only so the +/** Tombstoned stack members stay in the array only so the * sync reactor does not re-add them. */ export function visibleThreadPullRequests( links: ReadonlyArray, @@ -86,8 +83,6 @@ export function visibleThreadPullRequests( } function isOpen(link: ThreadPullRequestLink): boolean { - // Unsynced links are treated as open: they were just linked, and hiding them - // behind a terminal PR until the first sync would make the link look lost. return link.snapshot === null || link.snapshot.state === "open"; } @@ -97,20 +92,14 @@ function latestUpdatedAt(link: ThreadPullRequestLink): number { return Number.isNaN(ms) ? 0 : ms; } -/** The single pull request a one-slot surface (sidebar badge, tab icon, copy link) shows. */ export type ThreadCurrentPullRequest = | { readonly kind: "single"; readonly link: ThreadPullRequestLink } | { readonly kind: "stack"; readonly open: ReadonlyArray; - /** Highest layer of the open set; the one "View PR" and copy-link target. */ readonly top: ThreadPullRequestLink; }; -/** - * Prefer open work and the highest open layer within a chain. A completed chain still - * points at its top; unrelated terminal links use the most recently updated request. - */ export function resolveThreadCurrentPullRequest( links: ReadonlyArray, ): ThreadCurrentPullRequest | null { @@ -142,7 +131,6 @@ export function resolveThreadCurrentPullRequest( return { kind: "single", link: terminal[0]! }; } -/** The one link a legacy `linkedPullRequest` consumer should see, or null. */ export function resolveThreadCurrentPullRequestLink( links: ReadonlyArray, ): ThreadPullRequestLink | null { @@ -151,14 +139,12 @@ export function resolveThreadCurrentPullRequestLink( return current.kind === "single" ? current.link : current.top; } -/** Legacy clients can only route links belonging to the thread's own repository. */ export function legacyLinkedPullRequestOf( links: ReadonlyArray, projectId: ThreadLinkedPullRequest["projectId"], identity: RepositoryIdentity | null | undefined, ): ThreadLinkedPullRequest | null { if (!identity) return null; - // A local-path remote has no host segment and no provider, so there is no host to match. const host = pullRequestHostOf(identity, identity.provider as SourceControlProviderKind); if (typeof host !== "string") return null; const repository = sourceControlRepositorySelector(identity); @@ -182,9 +168,7 @@ export function legacyLinkedPullRequestOf( parsed.authority === remote.host && parsed.repository === repository.toLowerCase() ); } - } catch { - // SSH web ports are resolved by the provider's configured login. - } + } catch {} return parsed.host === host && parsed.repository === repository.toLowerCase(); } return ( @@ -204,15 +188,9 @@ export function legacyLinkedPullRequestOf( export interface ThreadPullRequestChain { readonly kind: "native" | "derived"; - /** Bottom to top. */ readonly layers: ReadonlyArray; } -/** - * Groups a thread's links into stacks. Native stacks come from the host and win; the rest - * are chained by matching one link's base branch to another's head branch within the same - * repository. A link that chains to nothing is a one-layer chain. - */ export function resolveThreadPullRequestChains( links: ReadonlyArray, ): ReadonlyArray { @@ -241,7 +219,6 @@ export function resolveThreadPullRequestChains( const key = normalizeThreadPullRequestKey(link); return `${key.host}/${key.repository}:${branch}`; }; - // Reused head names cannot identify a parent unambiguously. const byHead = new Map(); for (const link of remaining) { if (link.snapshot === null) continue; @@ -254,7 +231,6 @@ export function resolveThreadPullRequestChains( const parent = byHead.get(branchKey(link, link.snapshot.baseBranch)); if (parent != null && parent !== link) hasChild.add(threadPullRequestKeyOf(parent)); } - // Walk from each top (a link nothing builds on) down its base chain. for (const top of remaining) { if (hasChild.has(threadPullRequestKeyOf(top))) continue; const layers: Array = []; @@ -269,7 +245,6 @@ export function resolveThreadPullRequestChains( } if (layers.length > 0) chains.push({ kind: "derived", layers }); } - // Cycles have no top. Keep those links visible without inventing a stack order. for (const link of remaining) { if (!placed.has(threadPullRequestKeyOf(link))) { chains.push({ kind: "derived", layers: [link] }); @@ -288,7 +263,6 @@ export type ThreadPullRequestBadge = { | { readonly kind: "pull-request"; readonly others: number } ); -/** Aggregate visible links' state for both stacks and unrelated linked counts. */ export function resolveThreadPullRequestBadge( pullRequests: ReadonlyArray | undefined, ): ThreadPullRequestBadge | null { @@ -309,7 +283,6 @@ export function resolveThreadPullRequestBadge( return { kind: "pull-request", others: visible.length - 1, state }; } -/** Search terms for visible PR links, the legacy projection, and the branch PR. */ export function threadPullRequestSearchTerms(thread: { readonly pullRequests?: ReadonlyArray | undefined; readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; @@ -356,7 +329,6 @@ export function threadPullRequestsOf(thread: { ]; } -/** Apply a single-link sync without copying unrelated PRs into the event history. */ export function applyThreadPullRequestLinkUpdate( thread: OrchestrationV2AppThread, updated: ThreadPullRequestLink, diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index d93c5fd3e1bc..c70720a5ac99 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -14,7 +14,6 @@ function asTrimmedString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -/** A Claude `Skill` call: the skill it loads and the arguments it passes, if any. */ export function claudeSkillInvocation( toolName: string | null | undefined, input: unknown, @@ -35,7 +34,6 @@ export const COMPUTER_USE_TOOL_SOURCE = { kind: "computer", } as const; -/** Computer-use calls may fail or still be running before result metadata is available. */ export function dynamicToolSurface(toolName: string | null | undefined, input: unknown) { if (toolName === "cua_repl.js") return "computer" as const; const code = asRecord(input)?.code; @@ -46,10 +44,6 @@ export function dynamicToolSurface(toolName: string | null | undefined, input: u : undefined; } -/** - * Activity log heading a dynamic tool derives from its input: a REPL's `title`, - * or the skill a Claude `Skill` call loads. - */ export function dynamicToolTitle( toolName: string | null | undefined, input: unknown, @@ -190,7 +184,6 @@ function collectPaths(value: unknown, paths: string[], seen: Set, depth: } } -/** Structured paths from tool input, never file-body text. */ export function collectToolFilePaths(data: unknown): string[] { const paths: string[] = []; collectPaths(data, paths, new Set(), 0); @@ -252,7 +245,6 @@ export type ToolActivityAction = "command" | "read" | "file_change" | "search" | function toolNameToken(value: string | undefined): string | undefined { const trimmed = asTrimmedString(value); - // Server-prefixed MCP names (`github.read_file`, `mcp__db__find`) are not local reads or searches. if (!trimmed || /__|[./]/u.test(trimmed)) { return undefined; } @@ -369,7 +361,6 @@ function searchTargetName(value: string | undefined): string | undefined { return value.split(/[\\/]/u).findLast((part) => part.length > 0 && part !== "."); } -/** Cursor-style row: "Searched files *.{ts,tsx} in t3chat-new". */ export function formatSearchToolLabel( data: Record | undefined, ): string | undefined { @@ -395,7 +386,6 @@ export function formatSearchToolLabel( return undefined; } -/** Work-log heading for a file read: verb plus the structured path, never the path alone. */ export function formatReadToolLabel(path: string, extraCount = 0): string { const trimmed = path.trim(); const suffix = extraCount > 0 ? ` +${extraCount} more` : ""; diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts index 5cfb1463aaea..be788033d0fb 100644 --- a/packages/shared/src/toolOutput.ts +++ b/packages/shared/src/toolOutput.ts @@ -108,7 +108,6 @@ function boundedId(value: unknown): string | undefined { return Array.from(value).join(""); } -/** Keeps only IDs and failure metadata used by T3's grouped tool summaries. */ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | undefined { const budget: ResultReadBudget = { remainingBytes: MAX_PARSED_BYTES, @@ -127,7 +126,6 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un const htmlRender = readHtmlRenderReference(data.htmlRender); if (htmlRender !== undefined) output.htmlRender = htmlRender; const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]); - // Kept under its stored key, so the compact wire output reads back the same way. if (mcpApp !== undefined) output[MCP_APP_OUTPUT_KEY] = mcpApp; const nestedThreadId = Predicate.isObject(data.thread) ? boundedId(data.thread.threadId) @@ -170,9 +168,6 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un delete output.threadId; delete output.status; } - // An app's declared origins are the only unbounded-ish part of its - // reference; hosting works without them (the stored document carries its - // own policy), so they go before the app does. const app = output[MCP_APP_OUTPUT_KEY]; if (app?.csp !== undefined && oversized()) { const { csp: _csp, ...rest } = app; @@ -182,7 +177,6 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un return Object.keys(output).length === 0 ? undefined : output; } -/** The page a completed `html_render` tool call published, if this item is one. */ export function htmlRenderFromToolItem(item: { readonly toolName: string | null | undefined; readonly output?: unknown; @@ -221,15 +215,12 @@ export function toolOutputIndicatesFailure(text: string): boolean { ); } -/** An image a tool returned inline. `data` is base64; a detail read omits it. */ export interface ToolOutputImage { readonly mimeType: string; readonly data?: string; } /** - * Reads one image block in the MCP `{ data, mimeType }` shape or the Anthropic - * `{ source: { type: "base64", media_type, data } }` shape Claude stores. * Only raster types are recognized, so the server never serves an agent's SVG * or HTML inline. */ @@ -246,25 +237,17 @@ export function readToolOutputImage(block: unknown): ToolOutputImage | null { return typeof data === "string" ? { ...image, data } : image; } -/** Tools return a block, a list of blocks, or an MCP result with a `content` list. */ function outputBlocks(value: unknown): ReadonlyArray { if (Array.isArray(value)) return value; if (Predicate.isObject(value) && Array.isArray(value.content)) return value.content; return [value]; } -/** A tool returns one screenshot or a few frames; more would only flood the timeline. */ export const MAX_TOOL_OUTPUT_IMAGES = 8; -/** The largest image a `tool-output-image` asset serves: a provider turn's limit, as base64. */ export const MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH = Math.ceil(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / 3) * 4; -/** - * The blocks `toolOutputImages` reads, by reference. These hold the only image - * bytes in a tool output that a `tool-output-image` asset can serve, up to - * `MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH`. - */ export function toolOutputImageBlocks(value: unknown): ReadonlyArray { const blocks: unknown[] = []; for (const block of outputBlocks(value)) { @@ -276,17 +259,13 @@ export function toolOutputImageBlocks(value: unknown): ReadonlyArray { } /** - * The first images in a tool output, in order. The order is the - * `tool-output-image` asset index, so servers and clients agree on it. + * The order is the `tool-output-image` asset index, so servers and clients + * agree on it. */ export function toolOutputImages(value: unknown): ReadonlyArray { return toolOutputImageBlocks(value).flatMap((block) => readToolOutputImage(block) ?? []); } -/** - * Replaces each image's bytes with `{ type: "image", mimeType }` and keeps its - * position, so detail reads stay small and clients load the bytes as assets. - */ export function omitToolOutputImageData(value: unknown): unknown { const omit = (block: unknown) => { const image = readToolOutputImage(block); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 83b2d53fb99f..3b13d09e0a95 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -1,9 +1,4 @@ // @effect-diagnostics globalDate:off -- Usage windows are calendar days in the viewer's zone, derived from wall-clock "now" via Intl. -/** - * Display formatting for the usage page. - * - * @module usageFormat - */ import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; import type { UsageContractMismatch } from "./usageMerge.ts"; @@ -25,10 +20,6 @@ export function formatCount(value: number): string { return INTEGER.format(Math.round(value)); } -/** - * Compacts a token count to three significant figures with a unit suffix, so - * columns of numbers line up at a glance (`19.9B`, `76.7M`, `804K`). - */ export function formatTokens(value: number): string { const abs = Math.abs(value); if (abs >= 1e12) return `${trim(value / 1e12)}T`; @@ -60,7 +51,6 @@ export function formatUsageContractMismatch( : `This client is older than the server on ${environmentLabel}; its usage is excluded from totals.`; } -/** `2026-08-07` to `Aug 7`. */ export function formatDayShort(day: string): string { const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part)); if (year === undefined || month === undefined || dayOfMonth === undefined) return day; @@ -81,7 +71,6 @@ export function formatDayShort(day: string): string { return `${MONTHS[month - 1] ?? ""} ${dayOfMonth}`; } -/** Inclusive day list between two `YYYY-MM-DD` bounds. */ export function enumerateDays(sinceDay: string, untilDay: string): readonly string[] { const days: string[] = []; const start = Date.parse(`${sinceDay}T00:00:00Z`); @@ -113,7 +102,6 @@ function dateTimeFormatter( return formatter; } -/** Every fixed-duration bucket start in an hourly rolling window. */ export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] { const starts: string[] = []; const start = Date.parse(sinceTime); @@ -127,10 +115,8 @@ export function enumerateHourStarts(sinceTime: string, untilTime: string): reado } /** - * A rolling bucket start rendered in the viewer's requested time zone. - * - * Repeated wall-clock hours during a fall-back transition include their short - * zone name so the two distinct buckets remain distinguishable. + * Repeated wall-clock hours during a fall-back transition include their short zone name so the + * two distinct buckets remain distinguishable. */ export function formatHourShort(hourStart: string, timeZone?: string): string { const instant = new Date(hourStart); @@ -161,7 +147,6 @@ export function formatHourShort(hourStart: string, timeZone?: string): string { }).format(instant); } -/** `2026-08-11T14:37:00Z` to `Aug 11, 2 PM` in the requested zone. */ export function formatDateTimeShort(instant: string, timeZone?: string): string { const date = new Date(instant); if (Number.isNaN(date.getTime())) return instant; @@ -173,7 +158,6 @@ export function formatDateTimeShort(instant: string, timeZone?: string): string }).format(date); } -/** An hourly tooltip label relative to the rolling window's end date. */ export function formatRelativeHourShort( hourStart: string, relativeTo: string, @@ -201,10 +185,6 @@ export function formatRelativeHourShort( return formatDateTimeShort(hourStart, timeZone); } -/** - * The window the page requests, expressed in the viewer's own time zone so days - * line up with what they actually experienced. - */ export function makeWindow( days: number, now = new Date(), @@ -220,7 +200,6 @@ export function makeWindow( day: "2-digit", }); } catch { - // An unknown zone should degrade to UTC rather than crash the page. timeZone = "UTC"; format = new Intl.DateTimeFormat("en-CA", { timeZone: "UTC", @@ -231,9 +210,6 @@ export function makeWindow( } const untilDay = format.format(now); if (resolution === "hour") { - // Minute-aligned bounds keep labels readable while still representing an - // exact rolling 24-hour duration. Fixed-duration buckets remain correct - // across offset changes and daylight-saving transitions. const untilTimeMs = Math.floor(now.getTime() / 60_000) * 60_000; const sinceTimeMs = untilTimeMs - 24 * HOUR_MS; const sinceTime = new Date(sinceTimeMs); diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 657b84fd7314..e161d62530a8 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -207,14 +207,12 @@ describe("pools", () => { expect(accounts[0]).toMatchObject({ key: "env-a:claude", sourceLabel: null, - // Desktop's read is fresher, so its credits and its redeem are the ones on show. redeem: { environmentId: "env-b", input: { instanceId: "claude" } }, environments: [ { environmentId: "env-a", label: "Laptop" }, { environmentId: "env-b", label: "Desktop" }, ], }); - // The fresher native snapshot wins; the hub row is pre-filtered by email. expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); }); @@ -371,12 +369,10 @@ describe("pools", () => { ], ]); const [account] = collectLimitAccounts(input); - // Only the hub path clears the routing cooldown it holds for this account. expect(account?.redeem).toEqual({ environmentId: "env-a", input: { sourceId: "hub", accountId: "claude-same@example.com.json", creditId: "hub-credit" }, }); - // The fresher native balance is still the one shown. expect(account?.limits.resetCredits?.availableCount).toBe(3); }); @@ -532,8 +528,6 @@ describe("pools", () => { serverConfig: { providers: [login("business", "ws-business", 8), login("personal", "ws-plus", 26)], usageLimitSources: [ - // A hub that names the workspace joins it; one that does not - // cannot say which of the two it read, so it keeps its own row. { ...source, accounts: [hubAccount("plus.json", "ws-plus"), hubAccount("x.json")] }, ], }, @@ -554,8 +548,6 @@ describe("pools", () => { "hub:x.json", ]); expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(8); - // One hub file name in two workspaces stays two accounts, even without an - // email to tell them apart. const withoutEmail = (id: string, workspaceId: string) => { const { email: _email, ...account } = hubAccount(id, workspaceId); return account; @@ -619,7 +611,6 @@ describe("pools", () => { expect(collectLimitAccounts(withOther(unrelated)).map((account) => account.key)).toEqual([ "env-a:codex", ]); - // A second org whose limits could not be read still makes the hub ambiguous. const unreadable = provider({ ...native, instanceId: ProviderInstanceId.make("team"), @@ -693,8 +684,6 @@ describe("pools", () => { ), now, )[0]?.windows ?? []; - // A weekly-only plan reports weekly as `primary`. The pool keeps one id - // whichever account sorts first. for (const order of [ [codexAccount("business", ["primary"]), codexAccount("plus", ["secondary"])], [codexAccount("plus", ["secondary"]), codexAccount("business", ["primary"])], @@ -703,7 +692,6 @@ describe("pools", () => { expect(windows.map((window) => window.id)).toEqual(["weekly"]); expect(windows[0]?.members).toHaveLength(2); } - // A second window of the same kind on one account stays apart, with its own id. expect( pooled([codexAccount("a", ["secondary"]), codexAccount("b", ["primary", "secondary"])]).map( (window) => window.id, @@ -769,7 +757,6 @@ describe("pools", () => { ["codex", 1], ]); const [session, week] = pools[0]!.windows; - // A member with no reset has no clock, so it does not vote on pace. const untimed = collectLimitPools( collectLimitAccounts(input).map((account) => account.key === "hub:b" @@ -784,9 +771,7 @@ describe("pools", () => { ), now, ); - // Only a votes: 80% used, 80% elapsed. expect(untimed[0]?.windows[0]?.pace).toBe("on"); - // a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used. expect(session).toMatchObject({ id: "five_hour", remainingPercent: 40, @@ -836,7 +821,6 @@ describe("pools", () => { ["weekly", 1], ["monthly", 1], ]); - // Session resets determine the account order for every row. expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]); expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]); }); @@ -1143,14 +1127,11 @@ describe("/usage-limits", () => { }, ]; const report = collectProviderUsageLimits(fresher.instanceId, [fresher], stale, now); - // Only redeeming through the hub clears the routing cooldown it holds for - // this account, so the hub wins the path even with a staler balance. expect(report?.accounts[0]?.resetCreditInput).toEqual({ sourceId: "hub", accountId: "duplicate", creditId: "hub-credit", }); - // The fresher native balance is still the one shown. expect(report?.accounts[0]?.limits.resetCredits?.availableCount).toBe(3); }); @@ -1227,12 +1208,10 @@ describe("/usage-limits", () => { expect( collectProviderUsageLimits(selected.instanceId, [selected], [claudeOnly], now)?.notices, ).toEqual([]); - // A read failure clears the accounts, so the error must not depend on a match. const unreadable = { ...failing, accounts: [] }; expect( collectProviderUsageLimits(selected.instanceId, [selected], [unreadable], now)?.notices, ).toEqual(["Accounts: token expired"]); - // A source-only provider still gets the report, carrying only the error. const sourceOnly = collectProviderUsageLimits( selected.instanceId, [provider({})], @@ -1255,7 +1234,6 @@ describe("/usage-limits", () => { supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), ).toEqual(["usage-limits"]); expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); - // A provider's own command of the same name is left alone without coverage. const ownCommand = provider({ slashCommands: [{ name: "usage-limits", description: "Provider's own" }], }); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index dd24b20c7e76..a4445bcfe41b 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1,10 +1,3 @@ -/** - * Selection and pace maths for the provider limits view, shared by web and - * mobile so both agree on which providers show, what "ahead of pace" means, - * and how a reset is phrased. - * - * @module usageLimits - */ import { type EnvironmentId, type UsageLimitsReport, @@ -57,11 +50,6 @@ function cursorUsageWindowRank(id: string): number { return rank < 0 ? CURSOR_USAGE_WINDOWS.length : rank; } -/** - * Providers that belong on the Limits view: enabled, installed, and one whose - * driver reports subscription usage at all. A driver with no notion of usage - * never sets `usageLimits`, so it has no row rather than an empty one. - */ export function providersWithLimits( providers: readonly ServerProvider[], ): readonly ServerProvider[] { @@ -85,7 +73,6 @@ export type LimitPresentations = ReadonlyMap< } >; -/** One destination per service, even when several accounts or environments use it. */ export function collectExternalUsageLinks(presentations: LimitPresentations) { const links = new Map< string, @@ -112,7 +99,6 @@ export function collectExternalUsageLinks(presentations: LimitPresentations) { return [...links.values()]; } -/** Prefer the reported email; use an identical credential when no email is available. */ function accountKey( driver: ServerProvider["driver"], email: string | undefined, @@ -140,12 +126,8 @@ const nativeReport = (provider: ServerProvider): AccountReport => ({ }); /** - * Keys each report by the account it draws on. One email can belong to - * several workspaces, each with its own quota, so a report that names its - * workspace is keyed by it too. One that does not (a hub, an older server) - * joins the only workspace known for its email, and gets no key when there - * are several, since it cannot say which one it read. Pass every enabled - * login, read or not, so an unreadable workspace still counts as one. + * Pass every enabled login, read or not, so an unreadable workspace still + * counts as one. */ function accountKeyResolver(reports: Iterable) { const workspaces = new Map>(); @@ -165,28 +147,18 @@ function accountKeyResolver(reports: Iterable) { }; } -/** - * One subscription account as the pooled views see it, whichever way it was - * reported. Matching emails or credentials across environments name one - * account, unless they name different workspaces. Its quota is one bucket, so - * counting it twice would misstate what is left. - */ export interface LimitAccount { readonly key: string; readonly driver: ServerProvider["driver"]; - /** The instance's configured name, which is not sensitive; null for hub accounts. */ readonly displayName: string | null; readonly email: string | undefined; readonly plan: string | undefined; readonly accentColor: string | undefined; - /** Environments the account is signed in on; empty when only a hub reports it. */ readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string; }>; - /** The hub that reported it, when no environment has it natively. */ readonly sourceLabel: string | null; - /** Where the displayed reset credit can be redeemed. */ readonly redeem: { readonly environmentId: EnvironmentId; readonly input: ProviderConsumeResetCreditInput; @@ -194,11 +166,6 @@ export interface LimitAccount { readonly limits: ServerProviderUsageLimits; } -/** - * Every account with usable windows across the connected environments, one - * entry per distinct account. The freshest reads supply windows and credits; - * native instances supply names and environment labels. - */ export function collectLimitAccounts(presentations: LimitPresentations): readonly LimitAccount[] { const accounts = new Map(); const creditSources = new Map(); @@ -231,7 +198,6 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl return; } const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt); - // Two instances on one machine sharing an account still name it once. const environments = [ ...previous.environments, ...next.environments.filter( @@ -240,8 +206,6 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl ), ]; const winner = fresher ? next : previous; - // Credits and their redemption target travel together. A failed credit - // probe must not erase a successful read from another environment. const creditSource = creditSources.get(key); accounts.set(key, { ...previous, @@ -249,7 +213,6 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl plan: previous.plan ?? next.plan, accentColor: previous.accentColor ?? next.accentColor, environments, - // A hub only names the account when no environment has it natively. sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), redeem: hubRedeems.get(key)?.redeem ?? @@ -288,9 +251,6 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl }); } } - // Every hub account, including those a native instance also knows: the hub - // may hold a fresher read of the same subscription, and the merge above - // keeps the redeem target consistent with whichever snapshot wins. const labelEnvironment = presentations.size > 1; for (const [environmentId, presentation] of presentations) { for (const source of presentation.serverConfig?.usageLimitSources ?? []) { @@ -327,12 +287,6 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl return [...accounts.values()]; } -/** - * What the pooled views cannot draw as a bar: a hub that failed to read, a - * provider whose probe failed. Accounts that can never report (API keys) - * are left out; there is nothing for the user to act on. The environment - * is named only when more than one is connected. - */ export function collectLimitNotices(presentations: LimitPresentations): readonly string[] { const label = (environmentLabel: string, subject: string) => presentations.size > 1 ? `${environmentLabel} Β· ${subject}` : subject; @@ -340,8 +294,6 @@ export function collectLimitNotices(presentations: LimitPresentations): readonly for (const presentation of presentations.values()) { const environmentLabel = presentation.entry.target.label; for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { - // An account that can never report (API key) is left out; one that - // failed, or reported nothing at all, is worth a line. if (provider.usageLimits?.unavailable?.reason === "unsupported") continue; const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; const name = provider.displayName?.trim() || String(provider.driver); @@ -363,17 +315,11 @@ export interface LimitPoolMember { readonly window: ServerProviderUsageWindow; } -/** - * One window id across every account that reports it: the pooled share left, - * pace against the clock, and the resets in the order they will land, each - * with the share of the pool it hands back. - */ export interface LimitPoolWindow { readonly id: string; readonly kind: ServerProviderUsageWindow["kind"]; readonly label: string; readonly members: readonly LimitPoolMember[]; - /** Fixed account positions across rows; a null window leaves a gap. */ readonly columns: ReadonlyArray<{ readonly account: LimitAccount; readonly window: ServerProviderUsageWindow | null; @@ -384,7 +330,6 @@ export interface LimitPoolWindow { readonly resets: ReadonlyArray<{ readonly member: LimitPoolMember; readonly at: number; - /** Points of the pool the reset restores: the member's used share over the member count. */ readonly restoresPercent: number; }>; } @@ -395,10 +340,8 @@ export interface LimitPool { readonly windows: readonly LimitPoolWindow[]; } -/** Show Cursor's two usable pools instead of a combined percentage when both are available. */ export function displayLimitWindows(pool: LimitPool) { if (pool.driver === "antigravity") { - // Keep each model group's five-hour and weekly buckets next to one another. return [...pool.windows].sort( (left, right) => Number(!left.id.startsWith("gemini-")) - Number(!right.id.startsWith("gemini-")) || @@ -424,19 +367,6 @@ const WINDOW_KIND_ORDER: Record = { other: 3, }; -/** - * Accounts grouped by driver, each with its windows pooled by kind and id. - * Window ids are stable per provider, so a hub row and a native row for the - * same window land in the same pool; the kind is part of the key because - * Codex's `primary` is a position, not a duration (five hours on paid plans, - * a month on Free/Go), and a monthly allowance must not average into a - * five-hour pool. Pools order by kind, then first appearance. - * - * Accounts and columns share the session reset order, soonest first. When - * no account reports a session window, use the first window by kind instead. - * Missing reset times sort last, with account names and keys breaking ties. - * Each window's reset list still follows its own clock. - */ export function collectLimitPools( accounts: readonly LimitAccount[], now: number, @@ -485,7 +415,6 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L for (const account of accounts) { for (const window of account.limits.windows) { const pooled = poolKey(account.driver, window); - // An account with two windows of one kind keeps the second apart. const key = byKey.get(pooled)?.some((member) => member.account === account) ? `${window.kind}:${window.id}` : pooled; @@ -497,8 +426,6 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L const pools = [...byKey].map(([key, members]): LimitPoolWindow => { const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; - // Pooled Codex slots take the pool's own key, so the id stays the same - // whichever account sorts first. const id = members[0]!.account.driver === "codex" ? key : first.id; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; // Pace compares spend against the clock, so it is judged only over the @@ -542,7 +469,6 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]); } -/** The one-line status under a provider heading when there are no bars to draw. */ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { if (limits.unavailable?.reason === "unsupported") { return limits.unavailable.message ?? "This account has no subscription limits."; @@ -553,7 +479,6 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { return limits.windows.length === 0 ? "No limits reported." : null; } -/** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */ export function remainingPercent(window: ServerProviderUsageWindow): number { return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); } @@ -564,7 +489,6 @@ function resetMillis(window: ServerProviderUsageWindow): number | null { return Number.isFinite(at) ? at : null; } -/** Elapsed share of the window, 0..1, or null when its length or reset is unknown. */ export function elapsedShare(window: ServerProviderUsageWindow, now: number): number | null { const resetsAt = resetMillis(window); if (resetsAt === null || window.windowDurationMins === undefined) return null; @@ -575,11 +499,6 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu export type LimitPace = "ahead" | "on" | "under"; -/** - * Usage against the clock. Spending evenly leaves the same share of quota as - * there is time left in the window; within five points of that counts as on - * pace, further ahead means the window may run dry first. - */ export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { const elapsed = elapsedShare(window, now); return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed); @@ -592,7 +511,6 @@ function paceOfShares(usedPercent: number, elapsed: number): LimitPace { return "on"; } -/** `2h 13m`, `3d 4h`, `12m`. */ export function formatDuration(ms: number): string { const remaining = Math.max(0, ms); const days = Math.floor(remaining / DAY); @@ -603,7 +521,6 @@ export function formatDuration(ms: number): string { return `${minutes}m`; } -/** `resets in 2h 13m`, or null when the window has no reset. */ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): string | null { const resetsAt = resetMillis(window); if (resetsAt === null) return null; @@ -612,22 +529,15 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): return `resets in ${formatDuration(resetsAt - now)}`; } -/** Limit commands are served by T3 from the same snapshots as Usage β†’ Limits. */ export const USAGE_LIMITS_COMMAND = { name: "usage-limits", description: "Show this provider's usage limits", } satisfies ServerProviderSlashCommand; -/** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */ export function isUsageLimitsCommand(prompt: string): boolean { return prompt.trim().toLowerCase() === "/usage-limits"; } -/** - * Whether Limits has anything to say about this driver. A source that failed to - * read keeps no accounts, so its error counts for every driver rather than - * disappearing until the next successful refresh. - */ export function hasProviderUsageLimits( driver: ServerProvider["driver"], providers: readonly ServerProvider[], @@ -643,11 +553,6 @@ export function hasProviderUsageLimits( ); } -/** - * The drivers a set of sources would offer the command to, where a source that - * failed to read counts for every driver. Two snapshots with the same coverage - * need no catalog republish, however much their quotas moved. - */ export function sameUsageLimitCommandCoverage( previous: UsageLimitSourceSnapshots, next: UsageLimitSourceSnapshots, @@ -691,7 +596,6 @@ export function withUsageLimitsCommands( }); } -/** A point-in-time report; never refreshes or guesses which pooled account serves a turn. */ export function collectProviderUsageLimits( instanceId: ProviderInstanceId, providers: readonly ServerProvider[], @@ -733,14 +637,6 @@ export function collectProviderUsageLimits( (a, b) => Date.parse(b.account.usageLimits.checkedAt) - Date.parse(a.account.usageLimits.checkedAt), )[0]; - // Two independent decisions. Which balance to *display* follows whichever - // snapshot is fresher. Which path to *redeem through* always prefers the - // hub, because only the hub path clears the routing cooldown it holds for - // that account; redeeming natively against the same account resets the - // subscription upstream but leaves the hub refusing to route to it until - // its own cooldown expires. A hub credit id that a fresher native redeem - // already spent comes back as `alreadyRedeemed`, which still clears the - // cooldown, so preferring it is safe even when the hub snapshot is stale. const hubCreditId = hubCredits?.account.usageLimits.resetCredits?.nextCreditId; const showHubCredits = hubCredits && @@ -793,8 +689,6 @@ export function collectProviderUsageLimits( limits: account.usageLimits, }); } - // A source that failed to read has no accounts left to match on, so its - // error is reported to every provider rather than silently dropped. if (source.error && (matching.length > 0 || source.accounts.length === 0)) { notices.push(`${source.label}: ${source.error}`); } diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 8208e5b7ed1c..c90fa5301a1d 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -135,7 +135,6 @@ describe("mergeUsage", () => { }); it("counts a shared transcript directory once", () => { - // Two worktree servers on one machine resolve the same provider home. const shared = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; const merged = mergeUsage( [ @@ -172,7 +171,6 @@ describe("mergeUsage", () => { USAGE_CONTRACT_VERSION, ); - // env-b's claude bucket is dropped, its codex bucket survives. expect(merged.costUsd).toBe(14); expect(merged.providers.map((provider) => provider.provider).sort()).toEqual([ "claude", @@ -604,7 +602,6 @@ describe("mergeUsage", () => { costSource: "unpriced", unpricedRecords: 5, }), - // Reported cost on one record, no rates for the other four. bucket({ provider: "codex", model: "partly-reported", @@ -669,8 +666,6 @@ describe("mergeUsage", () => { }); it("keeps two machines apart when hostname and home path collide", () => { - // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make - // one machine's usage vanish. Filesystem identity separates them. const shape = { provider: "claude" as const, hostId: "mac", homePath: "/Users/theo/.claude" }; const merged = mergeUsage( [ @@ -704,8 +699,6 @@ describe("mergeUsage", () => { }); it("totals sessions from per-directory distinct counts, not per-bucket sums", () => { - // One session that spans two days appears in two buckets. Summing bucket - // sessions would say 2; the source's distinct count says 1. const merged = mergeUsage( [ environment( diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 07718c099bef..796f4c49146a 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -1,11 +1,3 @@ -/** - * Merges per-environment usage summaries into the single view the page renders. - * - * Pure, so the de-duplication and derivation rules can be tested without a - * connected environment. - * - * @module usageMerge - */ import { USAGE_MERGE_COMPATIBLE_SINCE, type EnvironmentId, @@ -40,15 +32,7 @@ export interface ModelTotals { readonly totalTokens: number; readonly tokens: UsageTokenTotals; readonly records: number; - /** - * Records whose tokens are counted here but which contributed nothing to - * `costUsd`. When it equals `records` the cost is unknown, not zero. - */ readonly unpricedRecords: number; - /** - * Tokens with no known rates, which a custom price would cover. A cell that - * mixes these with reported costs counts its tokens by record share. - */ readonly unpricedTokens: number; readonly costShare: number; readonly tokenShare: number; @@ -84,10 +68,6 @@ export interface CostQuality { readonly cacheSavingsUsd: number; } -/** - * `costUsd` by token category. `unsplit` is cost no rates could split, - * including all cost from servers that predate the split. - */ export interface CategoryCost { readonly input: number; readonly cacheRead: number; @@ -96,12 +76,10 @@ export interface CategoryCost { readonly unsplit: number; } -/** `costUsd` by request speed. Servers that predate speeds count as standard. */ export interface SpeedCost { readonly standard: number; readonly fast: number; readonly ultrafast: number; - /** What fast and ultrafast requests cost above the standard rate. */ readonly premium: number; } @@ -128,19 +106,14 @@ export interface MergedUsage { readonly costQuality: CostQuality; readonly categoryCost: CategoryCost; readonly speedCost: SpeedCost; - /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; readonly contractMismatches: readonly UsageContractMismatch[]; } /** - * Two sources are the same physical transcript directory only when host, - * provider, path and filesystem identity all agree. - * - * `volumeId` is what stops two machines that happen to share a hostname and a - * home path, which is every Mac in a fleet, from collapsing into one source and - * having one of them silently dropped. + * `volumeId` is what stops two machines that happen to share a hostname and a home path, which is + * every Mac in a fleet, from collapsing into one source and having one of them silently dropped. */ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { return [ @@ -167,16 +140,6 @@ function bucketKey(bucket: UsageBucket): string { return JSON.stringify([bucket.day, bucket.hourStart ?? null, bucket.provider, bucket.model]); } -/** - * Decides which environment owns each physical transcript directory. - * - * Several environments on one machine (worktree servers, for instance) resolve - * the same provider home and would otherwise double count every token. The - * Complete scans claim a fingerprint ahead of partial scans, then the most - * recently read scan wins within each status. A newer partial scan can still - * contribute cells absent from an older complete scan. Environment ids break - * ties so the result is stable when summaries have the same read time. - */ function claimSources(environments: readonly EnvironmentUsage[]): { readonly ownerByFingerprint: ReadonlyMap; readonly supplementalBucketsByEnvironment: ReadonlyMap>; @@ -199,8 +162,6 @@ function claimSources(environments: readonly EnvironmentUsage[]): { a.environmentId.localeCompare(b.environmentId), ); - // A complete scan takes precedence over a newer partial scan of the same - // directory. Partial history still contributes when no complete copy exists. for (const status of ["ok", "partial", "failed"] as const) { for (const environment of ordered) { for (const source of environment.summary.sources) { @@ -263,7 +224,6 @@ function claimSources(environments: readonly EnvironmentUsage[]): { }; } -/** Sources this environment owns after fingerprint claims, plus their buckets. */ function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, @@ -345,15 +305,6 @@ const EMPTY_MERGED: MergedUsage = { contractMismatches: [], }; -/** - * Merges every connected environment's summary. - * - * `expectedContractVersion` guards against incompatible server code: rather - * than blocking the page, its data is excluded and the mismatch direction is - * reported so the UI can identify which side needs updating. Versions in - * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive - * provider expansion does not drop Claude/Codex totals from older servers. - */ export function mergeUsage( environments: readonly EnvironmentUsage[], expectedContractVersion: number, @@ -622,7 +573,6 @@ export function mergeUsage( records === 0 ? 0 : (records - providerReportedRecords - unpricedRecords) / records, cacheSavingsUsd, }, - // Clamped so float error never shows as a negative remainder. categoryCost: { ...categoryCost, unsplit: Math.max( diff --git a/packages/shared/src/video.ts b/packages/shared/src/video.ts index 4e976da5dbad..7979f73e6d53 100644 --- a/packages/shared/src/video.ts +++ b/packages/shared/src/video.ts @@ -12,15 +12,12 @@ const VIDEO_MIME_TYPE_BY_EXTENSION = new Map([ export const VIDEO_FILE_EXTENSIONS = Object.freeze([...VIDEO_MIME_TYPE_BY_EXTENSION.keys()]); -/** Recognizes videos even when the file picker omitted their MIME type. */ export function videoMimeType(attachment: { readonly name: string; readonly mimeType: string; }): string | null { const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; if (mimeType.startsWith("video/")) return mimeType; - // The name is only evidence when nothing recorded what this is. A definite type already - // answers the question, and a `.mp4` on a PDF must not override it. if (mimeType !== "" && !GENERIC_MIME_TYPES.has(mimeType)) return null; const dotIndex = attachment.name.lastIndexOf("."); return dotIndex < 0 diff --git a/packages/source-control-azure-devops/src/client/definition.ts b/packages/source-control-azure-devops/src/client/definition.ts index f53e4c3e7cd7..0149e9fa928a 100644 --- a/packages/source-control-azure-devops/src/client/definition.ts +++ b/packages/source-control-azure-devops/src/client/definition.ts @@ -1,8 +1,3 @@ -/** - * Azure DevOps's client definition. Browser- and React Native-safe. - * - * @module source-control-azure-devops/client/definition - */ import { SourceControlProviderKind } from "@t3tools/contracts"; import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; import { @@ -15,7 +10,6 @@ const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; -/** `az repos pr checkout` names the pull request by `--id`, `-i`, or its first plain argument. */ function checkoutCommandId(args: string): string | null { const parts = args.trim().split(/\s+/).filter(Boolean); for (const [index, part] of parts.entries()) { @@ -37,7 +31,6 @@ export const definition = defineSourceControlClient({ publicHost: null, publishDescription: "dev.azure.com", publishHost: () => "dev.azure.com", - // A new repository belongs to a project, which the account does not name. newRepositoryOwner: () => null, defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number }) => @@ -62,12 +55,10 @@ export const definition = defineSourceControlClient({ isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), changeRequestUrlHost: (url) => url.hostname, checkoutChangeRequestHost: () => null, - // The legacy and SSH spellings of one repository all canonicalise to dev.azure.com. isChangeRequestInRepository: (identity, link) => isChangeRequestAuthorityOfProject(identity, link) && canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()), - // `az repos` reads use the checkout's organization and project, not host-wide credentials. canReadChangeRequestOnHost: () => false, isChangeRequestUrl: (url) => isChangeRequestPath(url, "/pullrequest/"), }); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts index 8ad71188e187..cae12ad870f5 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -29,14 +29,8 @@ function output(stdout: string) { }; } -/** What VcsProcess allows a read that asked for no ceiling of its own. */ const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; -/** - * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts - * it at the process default when the caller asked for none. A read whose response is larger than - * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling. - */ function outputWithin(maxOutputBytes: number | undefined, response: string) { const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES; return ceiling >= Buffer.byteLength(response) @@ -44,7 +38,6 @@ function outputWithin(maxOutputBytes: number | undefined, response: string) { : { ...output(response.slice(0, ceiling)), stdoutTruncated: true }; } -/** A fixture's own shape, spelled the way `az` would answer with it. */ const json = (value: Record) => JSON.stringify(value); const pullRequestRow = { @@ -88,21 +81,18 @@ function pullRequests(count: number, firstNumber: number): string { return JSON.stringify(pullRequestRows(count, firstNumber)); } -/** The arguments of the nth az invocation. */ function argsOfCall(index: number): ReadonlyArray { const call = mockedExecute.mock.calls[index]; assert.isDefined(call); return call[0].args; } -/** The output ceiling the nth az invocation asked for, if it asked for one at all. */ function maxOutputBytesOfCall(index: number) { const call = mockedExecute.mock.calls[index]; assert.isDefined(call); return call[0].maxOutputBytes; } -/** A page of change entries the size Azure really answers with, url and object ids and all. */ function changeEntries(count: number): ReadonlyArray> { const commit = "c".repeat(40); return Array.from({ length: count }, (_, index) => { @@ -121,7 +111,6 @@ function changeEntries(count: number): ReadonlyArray> { }); } -/** An Azure identity, which rides along with every comment and every push Azure answers with. */ function identity(name: string) { const id = "6f9c9b7f-0000-0000-0000-000000000000"; return { @@ -139,7 +128,6 @@ function identity(name: string) { }; } -/** A review's threads the shape Azure answers with, system threads and identities and all. */ function threadRows(count: number): ReadonlyArray> { return Array.from({ length: count }, (_, index) => ({ id: index + 1, @@ -164,7 +152,6 @@ function threadRows(count: number): ReadonlyArray> { })); } -/** A review's iterations the shape Azure answers with, one per push. */ function iterationRows(count: number): ReadonlyArray> { return Array.from({ length: count }, (_, index) => ({ id: index + 1, @@ -266,9 +253,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { query: "page", }); - // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at - // all. The rows come back as they would have without a search, for the caller to narrow; - // nothing of the search reaches the command, where it could only mean the wrong thing. assert.strictEqual(page.items.length, 3); expect(argsOfCall(0)).toEqual([ "repos", @@ -302,8 +286,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { involvement: "all", viewer: "bilal@acme.dev", limit: 10, - // The instant is the same cursor every other host reads; Azure has no filter for it and - // takes the count instead. cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 }, }); @@ -362,7 +344,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { expect(batch.items.map((item) => item.number)).toEqual([1, 2]); assert.isTrue(batch.truncated); - // Three raw rows from the first request and one from the second produced this page. assert.strictEqual(batch.cursorAdvance, 4); const secondArgs = argsOfCall(1); assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3"); @@ -386,7 +367,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { expect(argsOfCall(0)).toContain("--creator"); expect(argsOfCall(0)).toContain("bilal@acme.dev"); - // Azure calls a closed pull request abandoned. expect(argsOfCall(0)).toContain("abandoned"); }), ); @@ -430,7 +410,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { it.effect("reads the signed-in account, which az reports as a bare value", () => Effect.gen(function* () { - // `--query user` unwraps the object, so the wrapper has to put it back. mockedExecute.mockReturnValueOnce( Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))), ); @@ -475,7 +454,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { assert.strictEqual(yield* cli.getViewer({ cwd: "/w" }), "bilal@acme.dev"); expect(argsOfCall(1)).toEqual(expect.arrayContaining(["--creator", "me", "--top", "1"])); - // Someone who has opened nothing has no name to compare rows against. const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); }), @@ -637,8 +615,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { body: "- rewrote the page\n- kept the rest", }); - // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag, - // and the whole text stays together where `--description` would otherwise take several. expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest"); }), ); @@ -648,7 +624,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); const provider = yield* AzureDevOpsPullRequestProvider.make; - // False for a remark because nothing here can post one, so there is none to rewrite. expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false }); assert.isDefined(provider.updateChangeRequest); yield* provider.updateChangeRequest({ @@ -717,9 +692,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { ); const provider = yield* AzureDevOpsPullRequestProvider.make; - // Kept here rather than on Azure: its own record of what a reader has read is behind an - // undocumented endpoint, so the marks belong to this environment and need a revision of - // their own to tell a re-push from a file still as it was read. assert.strictEqual(provider.capabilities.viewedFiles, "environment"); assert.isDefined(provider.getFileRevisions); const answer = yield* provider.getFileRevisions({ @@ -730,18 +702,13 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { paths: ["README.md"], }); - // The latest push, since an iteration's changes are reported against the merge base rather - // than against the push before it. expect(argsOfCall(2)).toContain("iterationId=2"); - // Only what was asked for. DEMO.md changed too, and nobody has marked it. expect([...answer.revisions]).toEqual([["README.md", "8f80"]]); }), ); it.effect("reads where a pull request lives once, however often it is asked about", () => Effect.gen(function* () { - // A pull request cannot move repositories, and the marks would otherwise pay for a whole - // pull request read every time they checked whether a file had been pushed to. const pullRequest = Effect.succeed( output( JSON.stringify({ @@ -802,7 +769,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { const again = yield* ask(); assert.strictEqual(mockedExecute.mock.calls.length, 5); - // The second read goes straight to the pushes, and still answers with the head's blob. expect(argsOfCall(3)).toContain("pullRequestIterations"); expect([...again.revisions]).toEqual([["README.md", "8f80"]]); }), @@ -812,8 +778,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { "answers for a marked file the pull request no longer changes as the empty version", () => Effect.gen(function* () { - // Which is what was stored for it when it was ticked with nothing on the head, so a file - // the pull request deletes is cleared once and stays cleared. mockedExecute .mockReturnValueOnce( Effect.succeed( @@ -864,8 +828,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { it.effect("says nothing about the files past the end of a change it gave up following", () => Effect.gen(function* () { - // Every page is an `az` process of its own, so a change past the ceiling stops being - // followed. A path nobody looked at must not be answered for as deleted. const entries = (from: number, count: number) => Array.from({ length: count }, (_, index) => ({ changeType: "edit", @@ -920,7 +882,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"], }); - // The second page picks up where the first said it ended. expect(argsOfCall(3)).toContain("$skip=5000"); expect([...answer.revisions]).toEqual([ ["src/f1.ts", "blob-1"], @@ -963,8 +924,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { ), ), ) - // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted - // in files would never be reached however long the walk went on. .mockImplementation((command) => { const skip = command.args.find((arg) => arg.startsWith("$skip=")); const from = Number(skip?.slice("$skip=".length) ?? 0); @@ -988,10 +947,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { paths: ["src/page.ts"], }); - // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset - // rather than spending an `az` process a page for as long as Azure keeps paging. assert.strictEqual(mockedExecute.mock.calls.length, 7); - // And it read part of a change, so it says nothing about the file it never saw. assert.strictEqual(answer.revisions.size, 0); }), ); @@ -1036,16 +992,12 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { assert.isTrue(slice.truncated); expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin"); - // And the file behind it still renders, which is the point of giving up on one file. expect(slice.patch).toContain("+hello"); }), ); it.effect("fails the whole read when it is the connection that would not answer", () => Effect.gen(function* () { - // A rate limit is not this file's problem, and answering with a change full of files listed - // without their hunks would read as a change nobody can see rather than as a host to wait - // for. mockedExecute .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow)))) .mockReturnValueOnce(Effect.succeed(output(json(oneIteration)))) @@ -1140,8 +1092,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { number: 42, }); - // Azure leaves the metadata out unless it is asked for, and without it every file reads as - // text however it was stored. expect(argsOfCall(3)).toContain("includeContentMetadata=true"); expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ"); assert.isTrue(slice.truncated); @@ -1213,10 +1163,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { paths: ["a.ts", "b.ts", "unlisted.ts"], }); - // Four reads and no more: a page pointing at where it already is would be read forever. assert.strictEqual(mockedExecute.mock.calls.length, 4); - // And what was read is not the whole change, so the file nobody listed is left unanswered - // rather than reported as gone from the change request. expect([...answer.revisions]).toEqual([ ["a.ts", "8f80"], ["b.ts", "0ca4"], @@ -1239,7 +1186,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }); expect(answer.revisions.size).toBe(0); - // Nothing was marked, so Azure was not asked at all. assert.strictEqual(mockedExecute.mock.calls.length, 0); }), ); @@ -1271,15 +1217,11 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }); assert.strictEqual(comments.length, 1); - // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension - // does, and `az rest` mints its own token against whichever tenant `az` defaults to. expect(argsOfCall(0)).toContain("invoke"); expect(argsOfCall(0)).toContain("pullRequestThreads"); expect(argsOfCall(0)).toContain("project=platform"); expect(argsOfCall(0)).toContain("repositoryId=web"); expect(argsOfCall(0)).toContain("pullRequestId=42"); - // A review's threads grow with how long it ran, and this route does not page, so the read - // asks for more than the process default rather than taking whatever it is given. expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); }), ); @@ -1287,8 +1229,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { it.effect("reads a long review's threads, which are past the default output limit", () => Effect.gen(function* () { const response = json({ value: threadRows(800) }); - // Azure opens a thread per vote and per ref update beside the ones people wrote, and every - // comment carries a full identity, so a review argued over for weeks outgrows the default. expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); mockedExecute.mockImplementationOnce((input) => Effect.succeed(outputWithin(input.maxOutputBytes, response)), @@ -1308,9 +1248,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { it.effect("reads a long review's iterations, which are past the default output limit", () => Effect.gen(function* () { const response = json({ value: iterationRows(1_200) }); - // This route does not page, so the whole history arrives at once. Cut at the default it is - // JSON stopping mid-string, and every diff and file revision read on this host fails with - // it, since each of them starts by asking which iteration is the latest. expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); mockedExecute.mockImplementationOnce((input) => Effect.succeed(outputWithin(input.maxOutputBytes, response)), @@ -1331,8 +1268,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { it.effect("reads a full page of change entries, which is past the default output limit", () => Effect.gen(function* () { const response = json({ changeEntries: changeEntries(2_000) }); - // Azure's own maximum for this route, and every entry carries a path, a url and three - // object ids, so an ordinary page of a large change already outgrows the default. expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); mockedExecute.mockImplementationOnce((input) => Effect.succeed(outputWithin(input.maxOutputBytes, response)), @@ -1346,8 +1281,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { iterationId: 1, }); - // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly - // ordinary page would be reported as a host answering with nonsense. assert.strictEqual(page.changes.length, 2_000); assert.isFalse(page.truncated); }), @@ -1355,11 +1288,9 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { it.effect("reads a file whose JSON envelope is past the default output limit", () => Effect.gen(function* () { - // Under a megabyte as bytes on the host, so this is a file the other hosts hand over. const file = "const value = 1;\n".repeat(57_000); const response = json({ content: file }); expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); - // And past it once Azure wraps it, because there is no route here that serves the bytes. expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES); mockedExecute.mockImplementationOnce((input) => Effect.succeed(outputWithin(input.maxOutputBytes, response)), @@ -1383,9 +1314,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" })))); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - // The path carried around here has had Azure's leading slash taken off so it matches the - // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes - // back on the way out rather than being sent as the shorter name. yield* cli.readItemContent({ cwd: "/w", location: { project: "platform", repository: "web" }, @@ -1402,7 +1330,6 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed( output( - // Well-formed, but with nothing to build a link from: not a decode failure. JSON.stringify({ pullRequestId: 42, title: "Add the page", diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index c2fbbfbe8073..d0b1a74dc72b 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -28,10 +28,6 @@ import { } from "./azureDevOpsPullRequestJson.ts"; import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; -/** - * Names the read that produced unusable output, so a failure reports the call it came from - * rather than borrowing another operation's message. - */ export class AzureDevOpsPullRequestReadError extends Schema.TaggedError()( "AzureDevOpsPullRequestReadError", { @@ -50,7 +46,6 @@ export class AzureDevOpsPullRequestReadError extends Schema.TaggedError()( "AzureDevOpsViewerUnavailableError", { @@ -67,10 +62,6 @@ export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError()( "AzureDevOpsPullRequestIncompleteError", { @@ -88,11 +79,6 @@ export class AzureDevOpsPullRequestIncompleteError extends Schema.TaggedError()( "AzureDevOpsReviewerNameError", { @@ -116,14 +102,11 @@ export type AzureDevOpsPullRequestCliError = | AzureDevOpsReviewerNameError | AzureDevOpsViewerUnavailableError; -/** The version every REST call below is pinned to, so a new default cannot reshape a response. */ const REST_API_VERSION = "7.1"; const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; /** * A full page of change entries is two thousand files, each carrying its path, its url and - * several object ids, which is past the megabyte a read is given by default. Output cut at that - * ceiling arrives here as JSON that will not parse, so a change large enough to be paged would - * report itself as a host returning nonsense rather than as the ordinary page it is. + * several object ids, which is past the megabyte a read is given by default. */ const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; /** @@ -133,10 +116,8 @@ const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; */ const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; /** - * What a review's own history is given. Neither route pages, so each answers with the whole of it - * at once and grows with how long the review ran rather than with how large the change is. Cut at - * the default, both arrive as JSON that stops mid-string, and a long review would report its host - * as answering with nonsense. + * Neither route pages, so each answers with the whole of it at once and grows with how + * long the review ran rather than with how large the change is. */ const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; @@ -146,13 +127,10 @@ const CHANGE_ENTRIES_PER_PAGE = 2000; /** * Where following the pages stops, counted in the entries Azure was asked to skip rather than in * the files that survived decoding: a page can be entirely folders and other entries a review has - * nothing to show for, and bounding on what was kept would follow such a change forever. Every - * page is an `az` process of its own, so the read gives up and reports itself incomplete rather - * than presenting five pages as the whole change. + * nothing to show for, and bounding on what was kept would follow such a change forever. */ const MAX_CHANGE_ENTRIES = 10_000; -/** What an iteration changed, and whether following its pages reached the end of it. */ export interface AzureDevOpsIterationChanges { readonly changes: ReadonlyArray; readonly truncated: boolean; @@ -181,7 +159,6 @@ export class AzureDevOpsPullRequestCli extends Context.Service< { readonly items: ReadonlyArray; readonly truncated: boolean; - /** Raw Azure rows consumed to produce this page, including malformed rows. */ readonly cursorAdvance: number; }, AzureDevOpsPullRequestCliError @@ -200,8 +177,8 @@ export class AzureDevOpsPullRequestCli extends Context.Service< }) => Effect.Effect, AzureDevOpsPullRequestCliError>; /** - * The pushes a pull request has had, oldest first. Azure hangs the changed files off an - * iteration rather than off the pull request, so reading a diff starts here. + * Azure hangs the changed files off an iteration rather than off the pull + * request, so reading a diff starts here. */ readonly listIterations: (input: { readonly cwd: string; @@ -238,7 +215,6 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly mergeMethod?: PullRequestMergeMethod; }) => Effect.Effect; - /** Rewrites the pull request's own words, through the same command that moves it. */ readonly updatePullRequest: (input: { readonly cwd: string; readonly number: number; @@ -247,9 +223,8 @@ export class AzureDevOpsPullRequestCli extends Context.Service< }) => Effect.Effect; /** - * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole - * of what Azure offers here: it adds and removes named identities, and has no counterpart that - * says who could be named. + * `az repos pr reviewer` is the whole of what Azure offers here: it adds and removes named + * identities, and has no counterpart that says who could be named. */ readonly setPullRequestReviewers: (input: { readonly cwd: string; @@ -287,11 +262,6 @@ function involvementArgs(input: { } } -/** - * Azure moves a pull request by setting its state rather than by named commands: completing it - * is the merge, abandoning it is the close, and reactivating it is the reopen. Squashing is a - * completion option rather than a strategy of its own. - */ function actionArgs( action: PullRequestAction, mergeMethod: PullRequestMergeMethod | undefined, @@ -299,8 +269,6 @@ function actionArgs( switch (action) { case "merge": return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"]; - // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes - // it once its policies pass. The squash choice is stored with it, as it is for a merge now. case "enable-auto-merge": return [ "--auto-complete", @@ -317,12 +285,10 @@ function actionArgs( return ["--draft", "true"]; case "close": return ["--status", "abandoned"]; - // Never reached: this host does not declare the action, so nothing offers it. case "update-branch": return []; case "reopen": return ["--status", "active"]; - // Never reached: this host does not declare the action, so the service refuses it first. case "revert": case "approve-workflows": case "delete-branch": @@ -331,9 +297,8 @@ function actionArgs( } /** - * A reviewer Azure could be given: an email address, a display name or an identity guid, and - * nothing that starts with a dash. The dash is the whole point β€” these are argv, and a value that - * looks like a flag stops being a value. + * The dash is the whole point β€” these are argv, and a value that looks like a + * flag stops being a value. */ function isReviewerName(value: string): boolean { const name = value.trim(); @@ -344,9 +309,7 @@ function isReviewerName(value: string): boolean { export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; - // Every command resolves the organization, project and repository from the checkout, which is - // what the rest of the Azure wrapper does. The remote takes three shapes and only `az` knows - // how to read all of them. + // The remote takes three shapes and only `az` knows how to read all of them. const detectArgs = ["--detect", "true"] as const; const executeJson = (input: { @@ -361,11 +324,10 @@ export const make = Effect.gen(function* () { }); /** - * A REST route reached through `az devops invoke`, which addresses it by area, resource and - * route parameters rather than by URL. Used in place of `az rest` because it signs in the way - * the azure-devops extension does: `az rest` mints its own token against the tenant `az` - * defaults to, and an organisation in any other tenant answers that with a sign-in page, which - * arrives here as unreadable output rather than as a failure. + * Used in place of `az rest` because it signs in the way the azure-devops extension does: `az + * rest` mints its own token against the tenant `az` defaults to, and an organisation in any + * other tenant answers that with a sign-in page, which arrives here as unreadable output + * rather than as a failure. */ const invoke = (input: { readonly cwd: string; @@ -467,7 +429,6 @@ export const make = Effect.gen(function* () { input.repository, ...statusArgs(input.state), ...involvementArgs(input), - // A web link per row, which is the only url that needs no assembling. "--include-links", ...(input.skip === 0 ? [] : ["--skip", String(input.skip)]), "--top", @@ -500,7 +461,6 @@ export const make = Effect.gen(function* () { const consumed = lastItemIndex + 1; return Effect.succeed({ items: [...input.items, ...decoded.success.items.slice(0, remaining)], - // A full raw response may have more rows even when malformed entries used the probe. truncated: consumed < decoded.success.rawCount || decoded.success.rawCount === top, cursorAdvance: input.cursorAdvance + consumed, }); @@ -528,8 +488,6 @@ export const make = Effect.gen(function* () { getViewer: (input) => executeJson({ cwd: input.cwd, args: ["account", "show", "--query", "user"] }).pipe( Effect.flatMap((result): Effect.Effect => { - // `--query user` narrows the payload to the account, so it is nested back under the - // key the decoder reads to keep one shape for the signed-in user. const decoded = decodeViewerJson(`{"user":${result.stdout.trim() || "null"}}`); if (!Result.isSuccess(decoded)) { return Effect.fail( @@ -547,8 +505,7 @@ export const make = Effect.gen(function* () { }), // `az devops login` signs in with a personal access token, which `az account show` knows // nothing about, and nothing in `az` prints whose token it is. `--creator me` is resolved - // by the extension itself, so the viewer's own pull request names them. Someone with none - // stays unnamed: `me` would read as nobody everywhere a row is compared with the viewer. + // by the extension itself, so the viewer's own pull request names them. Effect.catchIf( (error) => error._tag !== "AzureDevOpsCliUnavailableError", () => @@ -590,10 +547,6 @@ export const make = Effect.gen(function* () { involvement: input.involvement, viewer: input.viewer, limit: input.limit, - // Azure counts rather than filters, so a slice carries on by stepping over every raw row - // the prior slice consumed. That is an offset into a list that can shift underneath it: - // a pull request opened between two slices moves everything down one, and the row on the - // seam is the one that pays for it. skip: input.cursor?.delivered ?? 0, cursorAdvance: 0, items: [], @@ -617,8 +570,6 @@ export const make = Effect.gen(function* () { }), ); } - // Null means Azure answered with too little to place the pull request. Nothing - // failed underneath it, so it is its own outcome rather than a decode failure. return decoded.success === null ? Effect.fail( new AzureDevOpsPullRequestIncompleteError({ @@ -659,8 +610,6 @@ export const make = Effect.gen(function* () { operation: "listIterationChanges", resource: "pullRequestIterationChanges", routeParameters: [...pullRequestRoute(input), `iterationId=${input.iterationId}`], - // Azure pages this route at 1000 entries by default; this is its own maximum per page, - // and it names where the next page starts rather than answering with the whole change. queryParameters: [`$top=${CHANGE_ENTRIES_PER_PAGE}`, `$skip=${skip}`], maxOutputBytes: CHANGE_ENTRIES_MAX_OUTPUT_BYTES, decode: decodeIterationChangesJson, @@ -672,11 +621,7 @@ export const make = Effect.gen(function* () { page(skip).pipe( Effect.flatMap((answer) => { const changes = [...collected, ...answer.changes]; - // The last page names no page after it, and only that is the end of the change. if (answer.nextSkip === null) return Effect.succeed({ changes, truncated: false }); - // A page pointing at where the read already is would be followed forever, and one - // past the ceiling is a change nobody will read to the end of. Both stop the read - // and both say so, rather than presenting part of a change as the whole of it. return answer.nextSkip <= skip || answer.nextSkip >= MAX_CHANGE_ENTRIES ? Effect.succeed({ changes, truncated: true }) : from(answer.nextSkip, changes); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts index 1b96984cf76b..636b7b27263e 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts @@ -46,12 +46,6 @@ function change( return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" }; } -/** - * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of - * `width` characters each: the diff work and the patch bytes one file costs are both dialled from - * here, and they are what a slice is bounded by. Each line carries its own number and a prefix - * as well, so `width` is a floor on how long a line is rather than its byte count. - */ function side(prefix: string, lines: number, width: number): string { const pad = "z".repeat(width); return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`; @@ -61,9 +55,7 @@ const readSlice = (input: { readonly paths: ReadonlyArray; readonly lines: number; readonly width: number; - /** Paths the host refuses, which is one file's problem rather than the read's. */ readonly refused?: ReadonlyArray; - /** Paths the change creates, so the host has nothing to hand back for their old side. */ readonly created?: ReadonlyArray; readonly cursor?: string; }) => @@ -92,9 +84,7 @@ const readSlice = (input: { inFlight += 1; peakInFlight = Math.max(peakInFlight, inFlight); // Every read suspends before it answers, as a subprocess would, so what runs at - // once is the scheduler's answer rather than an artefact of resolving inline. The - // later a file is listed the sooner it answers, to leave the assembled patch - // nothing but the change list to take its order from. + // once is the scheduler's answer rather than an artefact of resolving inline. const answersAfter = input.paths.length - input.paths.indexOf(item.path); for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow; inFlight -= 1; @@ -127,7 +117,6 @@ const readSlice = (input: { return { slice, reads, peakInFlight }; }); -/** Which files the patch carries a section for, in the order it carries them. */ function patchedPaths(patch: string): ReadonlyArray { return [...patch.matchAll(/^diff --git a\/(?\S+) b\//gmu)].map( (match) => match.groups?.path ?? "", @@ -141,8 +130,6 @@ describe("getChangeRequestSummary", () => { const provider = yield* make.pipe( Effect.provide( - // listIterations and listIterationChanges are left unimplemented here, so a summary - // that reached for either would die with UnimplementedError instead of this passing. Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ getPullRequest: () => { pullRequestReads += 1; @@ -197,9 +184,6 @@ describe("getChangeRequest", () => { describe("getDiff reads", () => { it.effect("holds every reader together to one request's worth of processes", () => Effect.gen(function* () { - // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at - // once are two reads, so without a ceiling above them both they are twice a request's - // processes, each paying a Python interpreter's start-up on the same machine. const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; let inFlight = 0; let peakInFlight = 0; @@ -237,17 +221,10 @@ describe("getDiff reads", () => { it.effect("leaves a run of files it could not diff at all for the next slice", () => Effect.gen(function* () { - // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of - // hundred bytes with no edits in it, so a change made of them spends neither budget: the - // byte one would take well over a thousand of them, and the edit one never fills at all. - // A listing holds up to ten thousand entries and each still costs its two reads, which is - // what a file count is here to bound. const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`); const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths }); expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES); - // Well inside the byte budget, so the file count is what stopped it rather than either of - // the budgets that were already there. expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES); expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe( MAX_DIFF_SLICE_FILES, @@ -257,8 +234,6 @@ describe("getDiff reads", () => { it.effect("asks for both sides of several files at once rather than one side at a time", () => Effect.gen(function* () { - // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a - // slice read one side after another is most of what the Code tab waits for. const read = yield* readSlice({ paths: ["a.ts", "b.ts", "c.ts", "d.ts"], lines: 2, @@ -271,8 +246,6 @@ describe("getDiff reads", () => { it.effect("holds the number of files it reads at once down", () => Effect.gen(function* () { - // The host throttles, and `az` is a process on the same machine the reader runs agents on, - // so a long change is read in batches rather than all at once. const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`); const read = yield* readSlice({ paths, lines: 2, width: 4 }); @@ -298,7 +271,6 @@ describe("getDiff reads", () => { expect(patchedPaths(read.slice.patch)).toEqual(paths); expect(read.slice.truncated).toBe(true); - // Its section ends at its header, and the files around it still carry their hunks. expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts"); expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2); }), @@ -308,9 +280,6 @@ describe("getDiff reads", () => { describe("what one diff slice spends", () => { it.effect("stops on the byte ceiling without carrying what it read past it", () => Effect.gen(function* () { - // Two of these fill the slice, and the batch they were read in reached two files further. - // Those two belong to the next slice: carrying them would put the request past a ceiling - // that is there to bound what one answer weighs. const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; const read = yield* readSlice({ paths, lines: 100, width: 900 }); @@ -323,10 +292,6 @@ describe("what one diff slice spends", () => { it.effect("narrows what it reads at once as the slice fills", () => Effect.gen(function* () { - // Four files fit inside the budget and the fifth is past half of what is left of it, so - // reading four more would throw most of them away and read them again next slice. Every - // file is two `az` invocations, so the batch is judged against what the files before it - // weighed rather than left at its full width to the last file. const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"]; const read = yield* readSlice({ paths, lines: 50, width: 450 }); @@ -337,9 +302,6 @@ describe("what one diff slice spends", () => { it.effect("stops once the diff work one request may do is spent", () => Effect.gen(function* () { - // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let - // one request hold the thread through a dozen of them. Each of these is half of what one - // file is allowed, and the slice ends while there is still room for another. const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`); const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 }); @@ -351,9 +313,6 @@ describe("what one diff slice spends", () => { it.effect("carries a whole new file and stops the slice on what it weighed", () => Effect.gen(function* () { - // A creation has no edit distance to search out, so no edit bound applies to it and its - // section is the whole file. What keeps a run of them from filling one answer is the bytes - // they weighed, which the slice has to be charged for. const paths = ["new.ts", "b.ts", "c.ts", "d.ts"]; const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] }); @@ -381,10 +340,6 @@ describe("what one diff slice spends", () => { ); it.effect("keeps the pull request being read, not the one looked up first", () => Effect.gen(function* () { - // Where a pull request lives is read from the pull request itself, so an evicted entry - // costs a whole pull request read before any file can be asked for. Ordered by insertion - // alone a hit does not renew its entry, so the review being worked through is the first - // thing dropped once a listing has walked a cache's worth of cold pull requests. const HOT = 7; const readsOf = new Map(); @@ -408,7 +363,6 @@ describe("what one diff slice spends", () => { provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number }); yield* readDiff(HOT); - // A cache's worth of cold pull requests, with the open one read in between each of them. for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) { yield* readDiff(HOT + 1 + filled); yield* readDiff(HOT); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index 78d31da360f5..61028714db8d 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -36,30 +36,22 @@ import type { import { definition } from "../client/definition.ts"; /** - * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a - * Python interpreter's start-up, so reading them one after another is most of what the Code tab - * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its - * per-file stats with here, and low enough not to swamp the host's throttling or the machine. + * Every file is two `az` invocations, each paying a Python interpreter's start-up, so reading them + * one after another is most of what the Code tab waits for. */ const DIFF_FILE_CONCURRENCY = 4; /** - * How many `az` processes this build will have out at once, counted across every reader rather - * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews - * had sixteen Python interpreters starting at once and nothing above them. Held at what one - * request at full width spends, so a second reader waits behind the first instead of adding to it. + * How many `az` processes this build will have out at once, counted across every reader rather than + * per request. Held at what one request at full width spends, so a second reader waits behind the + * first instead of adding to it. */ export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; -/** How many pull requests' repository locations one provider remembers at once. */ export const LOCATION_CACHE_CAPACITY = 128; const CAPABILITIES: PullRequestCapabilities = { - // Azure serves no patch of its own, so the one the Code tab reads is built here out of the - // files an iteration changed and both sides of each of them. diff: true, - // Reading a conversation is a plain REST read, but posting one is not something this can - // claim without having run it, so the composer stays hidden. comment: false, actions: [...definition.changeRequestActions], // Azure squashes as a completion option; it has no rebase strategy of its own. @@ -67,34 +59,21 @@ const CAPABILITIES: PullRequestCapabilities = { // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. search: false, reactions: false, - // The patch has lines to write against, but writing a remark at all is what Azure is not - // offered for here, so nothing in a review is either. review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` // lists the ones this repository could name β€” that lives behind the identity and graph APIs, a - // different service with its own permissions. So the page takes a name here rather than being - // handed a menu built out of a guess. + // different service with its own permissions. reviewers: { request: true, listCandidates: false }, - // A new title and description travel on the same `az repos pr update` that moves a pull request. - // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on - // Azure DevOps at all, so there is nothing here it could rewrite either. edit: { changeRequest: true, comment: false }, // Azure does keep a viewed record of its own, but only behind the undocumented contribution // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway. - // So they are kept here instead, and the client says whose they are rather than implying the - // Azure DevOps page will show them. viewedFiles: "environment", }; /** - * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission - * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security - * namespaces, behind identity descriptors and token paths that would be several calls per pull - * request to resolve. - * - * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment - * they try. That is the safer half of an unknown: hiding a control from someone entitled to it - * leaves them no way through and no reason given. + * Azure DevOps states no permission anywhere `az repos pr show` or `az repos pr list` reach: the + * answer lives in the security namespaces, behind identity descriptors and token paths that would + * be several calls per pull request to resolve. */ const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { actions: CAPABILITIES.actions, @@ -104,7 +83,6 @@ const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { requestReviewers: CAPABILITIES.reviewers.request, }; -/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ export function azureDevOpsProviderFailure( error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError, ): PullRequestProviderFailure { @@ -142,8 +120,6 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq export const make = Effect.gen(function* () { const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; - // Made once with the provider, which the registry builds once, so this is the whole build's - // allowance rather than one request's. const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS); const readItemContent = (input: Parameters[0]) => diffSpawns.withPermits(1)(cli.readItemContent(input)); @@ -158,7 +134,6 @@ export const make = Effect.gen(function* () { cause: error, }); - /** Refuses what the capabilities already say this host cannot do. */ const unsupported = (operation: string) => Effect.fail( new PullRequestProviderError({ @@ -169,15 +144,13 @@ export const make = Effect.gen(function* () { }), ); - /** A pull request Azure could not place has no diff to read, which reads as an empty one. */ const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; /** - * Where a pull request's repository lives, which is the route every other read of it needs and - * the one thing only the pull request itself states. A pull request cannot move between - * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for - * a whole pull request read every time they checked whether a file had been pushed to. Least - * recently used, so a listing walking cold pull requests cannot evict the one being read. + * A pull request cannot move between repositories, so it is remembered rather than re-read: the + * marks alone would otherwise pay for a whole pull request read every time they checked whether a + * file had been pushed to. Least recently used, so a listing walking cold pull requests cannot + * evict the one being read. */ const locations = new Map(); @@ -203,11 +176,6 @@ export const make = Effect.gen(function* () { ); }; - /** - * Everything a diff read needs before it can ask for a file: where the repository lives, and - * which pushes the pull request has had. A client names neither, and the iterations are read - * afresh every time because the newest one is what a push adds. - */ const diffScope = (input: { readonly cwd: string; readonly number: number }) => Effect.gen(function* () { const location = yield* locationOf(input); @@ -223,10 +191,8 @@ export const make = Effect.gen(function* () { const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; /** - * Both sides of one changed file, read at once because neither answer depends on the other and - * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are - * asked for: Azure answers for a file that is not at a commit with a failure rather than with - * nothing. + * Only the sides a change actually has are asked for: Azure answers for a file that is not at a + * commit with a failure rather than with nothing. */ const readTexts = (input: { readonly cwd: string; @@ -265,9 +231,8 @@ export const make = Effect.gen(function* () { ); /** - * What the whole pull request changed, taken from its latest push. An iteration's changes are - * reported against the merge base rather than against the push before it, so the newest one is - * the whole of the change rather than the last slice of it. + * An iteration's changes are reported against the merge base rather than against the push before + * it, so the newest one is the whole of the change rather than the last slice of it. */ const listLatestChanges = (input: { readonly cwd: string; @@ -298,9 +263,6 @@ export const make = Effect.gen(function* () { getViewer: (input) => cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), - // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator, - // reviewer and branch, and has nothing that matches text. Sending it as one of those would - // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it. listChangeRequests: (input) => cli .listPullRequests({ @@ -324,8 +286,6 @@ export const make = Effect.gen(function* () { })), ), - // The polled path a linked thread's row stays live on: one `az` read, no iterations or - // changes behind it, since the file count that would cost is not shown here. getChangeRequestSummary: (input) => cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( Effect.mapError(fail("getChangeRequestSummary")), @@ -349,8 +309,6 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }); const location = pullRequest.location; - // The file count is two reads past the pull request itself, and it is the only thing - // riding on them, so a failure leaves it unknown rather than losing the whole detail. const changedFiles = location === null ? 0 @@ -412,8 +370,6 @@ export const make = Effect.gen(function* () { ), ), - // No request at all: Azure has nothing to say about the viewer that a pull request read can - // reach, so the answer is the same constant the detail carries. getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS), // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so @@ -444,11 +400,10 @@ export const make = Effect.gen(function* () { let edits = 0; let index = cursor?.fileIndex ?? 0; let full = false; - // How many files to read at once. Every file costs a request a side, and everything read - // past the point the slice fills is thrown away and read again by the next slice, so the - // batch narrows as the budgets do: what is left of each, over what a file has spent of it - // on average so far, which is the only estimate there is before a file is read. At least - // one, so a file heavier than the whole budget still moves the cursor. + // Every file costs a request a side, and everything read past the point the slice fills is + // thrown away and read again by the next slice, so the batch narrows as the budgets do: + // what is left of each, over what a file has spent of it on average so far, which is the + // only estimate there is before a file is read. const batchWidth = () => { if (sections.length === 0) return DIFF_FILE_CONCURRENCY; const admits = (left: number, spent: number) => @@ -457,7 +412,6 @@ export const make = Effect.gen(function* () { 1, Math.min( DIFF_FILE_CONCURRENCY, - // The file budget needs no estimate: a file spends exactly one of it. MAX_DIFF_SLICE_FILES - sections.length, admits(MAX_DIFF_SLICE_BYTES - bytes, bytes), admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits), @@ -469,9 +423,6 @@ export const make = Effect.gen(function* () { const read = yield* Effect.forEach( batch, (change) => - // A pair Azure refuses is one file rather than the whole slice: an oversize blob or - // a path `az` will not carry through leaves that file listed without its hunks, and - // everything around it still renders. readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe( // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` // at all is the read failing rather than the file, and belongs to the caller, @@ -498,10 +449,6 @@ export const make = Effect.gen(function* () { edits += file.edits; truncated = truncated || file.truncated; index += 1; - // A file whose diff was given up on spent the whole of what one file is allowed and - // has only a header to show for it, and a file the diff never ran on at all weighs - // almost nothing in either budget and still costs its two reads. So the file count - // bounds the request alongside the bytes. // Checked after the file is added rather than before it, so every slice carries at // least one: a section heavier than the whole budget would otherwise never be added, // and the read would answer the same slice forever without moving the cursor. @@ -528,10 +475,6 @@ export const make = Effect.gen(function* () { return slice; }).pipe(Effect.mapError(fail("getDiff"))), - // The patch is built from whole files, so opening the lines around a hunk is the same two - // reads over again rather than a wider request. Read against the latest iteration: nothing in - // the request says which push the reader is looking at, and expansion is stale after a - // mid-review push on every host here anyway. getDiffFileContents: (input) => Effect.gen(function* () { const scope = yield* diffScope(input); @@ -550,12 +493,9 @@ export const make = Effect.gen(function* () { }).pipe(Effect.mapError(fail("getDiffFileContents"))), /** - * What the head has of each marked file, which is the blob Azure already names on the change - * it reports. One read covers every path, since the latest iteration lists the whole change. - * * A path the change does not carry is at the empty revision, which is where a file the pull - * request deletes sits. When the change was too long to follow to its end, those paths are - * left out instead: reporting them as deleted would clear a file nobody has read. + * request deletes sits. When the change was too long to follow to its end, those paths are left + * out instead: reporting them as deleted would clear a file nobody has read. */ getFileRevisions: (input) => Effect.gen(function* () { @@ -601,8 +541,6 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("updateChangeRequest"))), - // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the - // list without it. listReviewerCandidates: () => Effect.fail( new PullRequestProviderError({ @@ -618,18 +556,13 @@ export const make = Effect.gen(function* () { .setPullRequestReviewers({ cwd: input.cwd, number: input.number, - // Azure names an identity by an email address or a guid, and has no team to ask, so a - // candidate's id is the whole of what it takes. reviewers: input.reviewers.map((reviewer) => reviewer.id), requested: input.requested, }) .pipe(Effect.mapError(fail("setReviewerRequest"))), - // Never called: `capabilities.comment` is false, and the service refuses a comment without it. comment: () => unsupported("comment"), - // Declared unsupported above, so the service refuses these before a provider is reached. - // They exist because every provider answers the whole port. submitReview: () => unsupported("submitReview"), replyToThread: () => unsupported("replyToThread"), diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index efecb2b3ec3a..355ae29cb722 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -45,9 +45,9 @@ export const discovery = { executable: "az", versionArgs: ["--version"], authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], - // `az` boots a fresh Python interpreter on every invocation, so even `az --version` - // takes ~6s on Windows and overruns the default budget, leaving the provider reported - // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. + // `az` boots a fresh Python interpreter on every invocation, so even `az --version` takes ~6s + // on Windows and overruns the default budget, leaving the provider reported as missing on + // machines where it is installed. probeTimeoutMs: 20_000, parseAuth: parseAzureAuth, installHint: diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsDiff.test.ts b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.test.ts index 30d575755199..04f8f429d924 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsDiff.test.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.test.ts @@ -58,7 +58,6 @@ describe("azureDevOpsFilePatch", () => { expect(patch.section).toContain("new file mode 100644"); expect(patch.section).toContain("--- /dev/null"); expect(patch.section).toContain("+++ b/DEMO.md"); - // Git points the range a new file does not have at line zero, not at line one. expect(patch.section).toContain("@@ -0,0 +1 @@"); expect(patch.section).toContain("+hello"); }); @@ -77,8 +76,6 @@ describe("azureDevOpsFilePatch", () => { }); it("keeps the carriage returns of a file with Windows line endings", () => { - // They are part of the line rather than around it, so a patch that dropped them would ask - // the reader to look at a change that is not the one on the host. const patch = azureDevOpsFilePatch({ change: change(), texts: texts("one\r\ntwo\r\n", "one\r\ntwo again\r\n"), @@ -130,7 +127,6 @@ describe("azureDevOpsFilePatch", () => { }); it("takes the host's word that a file is binary, whatever its bytes look like", () => { - // Azure hands such a file over base64-encoded, so nothing in the text it sent gives it away. const patch = azureDevOpsFilePatch({ change: change({ path: "logo.png", oldPath: "logo.png" }), texts: texts("b2xk", "bmV3", true), @@ -141,7 +137,6 @@ describe("azureDevOpsFilePatch", () => { }); it("counts an overlong file in bytes rather than in characters", () => { - // Three bytes each, so a ceiling counted in code units would let three times the size through. const patch = azureDevOpsFilePatch({ change: change({ path: "notes.md", oldPath: "notes.md" }), texts: texts("\u4e00".repeat(200_000), "\u4e8c".repeat(200_000)), @@ -157,12 +152,6 @@ describe("azureDevOpsFilePatch", () => { Array.from({ length: count }, (_, line) => `${prefix} ${line}`).join("\n"); it("lists a file too far apart to diff without its hunks", () => { - // Sharing no line at all costs one edit per line on each side, so this pair is twice the - // ceiling apart. Left to itself the search costs about the square of that and would hold the - // whole server, every websocket client with it, while it worked out a patch nobody reads. - // Writing both sides out instead would read as a genuine rewrite: the ceiling is a distance - // rather than a proportion, so a long file reaches it having changed in one corner, and that - // corner would be buried in a wall of red and green. const lines = (prefix: string) => Array.from({ length: MAX_FILE_DIFF_EDITS }, (_, line) => `${prefix} ${line}`).join("\n"); const patch = azureDevOpsFilePatch({ @@ -171,8 +160,6 @@ describe("azureDevOpsFilePatch", () => { }); expect(patch.truncated).toBe(true); - // And it says the search was given up on, because the reader of a run of files is meant to - // stop rather than spend that work again on each of the ones behind it. expect(patch.abandoned).toBe(true); expect(patch.section).toBe( [ @@ -185,10 +172,6 @@ describe("azureDevOpsFilePatch", () => { }); it("writes out a wholly new file however many lines it has", () => { - // Nothing on the old side means there was no edit distance to search out, so this is the - // minimal patch and not a stand-in for one. Fifteen thousand lines of thirty bytes is the - // shape this used to lose: inside the byte ceiling that gates every file, and well past every - // bound a two-sided file answers to, none of which is protecting against anything here. const contents = `${Array.from({ length: 15_000 }, () => "x".repeat(29)).join("\n")}\n`; const patch = azureDevOpsFilePatch({ change: change({ path: "DEMO.md", oldPath: "DEMO.md", changeKind: "new" }), @@ -200,17 +183,10 @@ describe("azureDevOpsFilePatch", () => { expect(patch.abandoned).toBe(false); expect(patch.edits).toBe(15_000); expect(patch.section).toContain("@@ -0,0 +1,15000 @@"); - // Only the `+++` of the header on top of the file's own lines, so nothing was dropped out of - // the middle. expect(patch.section.match(/^\+/gmu)).toHaveLength(15_001); }); it("keeps a wholly new file too heavy to write out listed without its hunks", () => { - // Every line carries a marker, so a side of very short lines answers with up to twice its own - // bytes: these 200,000 one-character lines fit the ceiling each side is read under and weigh - // about 600KB written out, more than twice what a whole slice may carry. There is no smaller - // true patch for a creation to fall back to, so it is listed without its hunks, the same as a - // side too big to read at all, rather than sent at a size the slice budget exists to prevent. const contents = `${Array.from({ length: 200_000 }, () => "x").join("\n")}\n`; const patch = azureDevOpsFilePatch({ change: change({ path: "bundle.min.js", oldPath: "bundle.min.js", changeKind: "new" }), @@ -220,7 +196,6 @@ describe("azureDevOpsFilePatch", () => { expect(byteLength(contents)).toBeLessThan(512 * 1024); expect(patch.truncated).toBe(true); expect(patch.abandoned).toBe(false); - // It still cost the walk over its lines, which is what the slice is charged for. expect(patch.edits).toBe(200_000); expect(patch.section).toBe( [ @@ -249,8 +224,6 @@ describe("azureDevOpsFilePatch", () => { }); it("gives an empty new file no hunk to read", () => { - // A file with nothing on either side has no lines to claim were replaced, and git writes it - // as a header alone. const patch = azureDevOpsFilePatch({ change: change({ path: "EMPTY.md", oldPath: "EMPTY.md", changeKind: "new" }), texts: texts("", ""), @@ -291,9 +264,6 @@ describe("azureDevOpsFilePatch", () => { }); it("counts what the diff worked out, which is what the file cost to diff", () => { - // The caller spends a budget of these across a slice, so they have to be the edits the search - // actually made: one line replaced is a removal and an addition, and the three lines of - // context around them cost nothing. const patch = azureDevOpsFilePatch({ change: change(), texts: texts("one\ntwo\nthree\nfour\n", "one\ntwo again\nthree\nfour\n"), @@ -312,10 +282,6 @@ describe("azureDevOpsFilePatch", () => { }); it("lists a file whose hunks outweigh its sides without them", () => { - // A handful of very long lines is a few edits and nowhere near the edit ceiling, and the - // patch carries both sides in full with three lines of context around each hunk, so the - // section comes out heavier than either side was. What one file weighs is what a slice's - // budget is spent in, so the edit ceiling alone does not bound this. const line = `${"a".repeat(400 * 1024)}\n`; const patch = azureDevOpsFilePatch({ change: change({ path: "min.js", oldPath: "min.js" }), @@ -377,8 +343,6 @@ describe("a file Azure names something a patch header cannot carry plainly", () texts: texts("one\n", "two\n"), }); - // Written as itself the name would start a line of its own, and a reader would take what - // followed for a header the patch never had. expect(patch.section.split("\n").slice(0, 3)).toEqual([ 'diff --git "a/line\\nfile.txt" "b/line\\nfile.txt"', '--- "a/line\\nfile.txt"', @@ -441,17 +405,12 @@ describe("a diff cursor", () => { }); it("reads anything it did not write as no position at all", () => { - // Which starts the read from the top rather than failing it: a cursor is the client's to - // hand back, and nothing downstream is worth refusing a whole diff over. for (const raw of [undefined, null, "", "abc", "1", "0:4", "1:-2", "1:2:3"]) { expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); } }); it("refuses a half it did not write rather than reading it as the first file", () => { - // `Number` is wider than the cursor: an empty, padded or hex half would otherwise pass as a - // position, and the read would resume against an iteration the client never saw instead of - // starting again from the latest one. for (const raw of ["1:", ":4", "1: ", " 1:4", "1:0x2", "0x1:2", "1e2:0", "1:4.0"]) { expect(parseAzureDevOpsDiffCursor(raw)).toBeNull(); } diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts index aa5323c32fa6..a961b4a348bd 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts @@ -4,9 +4,8 @@ import { structuredPatch } from "diff"; import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; /** - * How far a diff read got, and which push it was reading. Azure hangs a pull request's changed - * files off an iteration, so a push landing mid-read would renumber the list under a cursor that - * did not also pin the iteration. + * Azure hangs a pull request's changed files off an iteration, so a push landing mid-read would + * renumber the list under a cursor that did not also pin the iteration. */ export interface AzureDevOpsDiffCursor { readonly iterationId: number; @@ -15,17 +14,12 @@ export interface AzureDevOpsDiffCursor { const CURSOR_SEPARATOR = ":"; -/** - * Restricts each half to plain decimal, so `Number` cannot read a foreign cursor's `0x3` as - * three. - */ const CURSOR_COMPONENT = /^\d+$/; export function formatAzureDevOpsDiffCursor(cursor: AzureDevOpsDiffCursor): string { return `${cursor.iterationId}${CURSOR_SEPARATOR}${cursor.fileIndex}`; } -/** Null for anything this did not write, which starts the read from the top rather than failing. */ export function parseAzureDevOpsDiffCursor( raw: string | null | undefined, ): AzureDevOpsDiffCursor | null { @@ -41,70 +35,48 @@ export function parseAzureDevOpsDiffCursor( return { iterationId, fileIndex }; } -/** The two texts of one changed file, empty on whichever side the change does not have. */ export interface AzureDevOpsFileTexts { readonly oldContents: string; readonly newContents: string; - /** Azure's own flag for a file it hands back base64-encoded instead of as text. */ readonly binary: boolean; } export interface AzureDevOpsFilePatch { readonly section: string; - /** The file changed but its hunks are not in the section, so the patch has a hole in it. */ readonly truncated: boolean; - /** - * The diff was given up on partway, having spent the whole edit budget, so a caller reading a - * run of files stops here rather than paying that again for each one behind it. - */ readonly abandoned: boolean; - /** - * Lines added or removed, which is the edit distance the diff had to search out, and what a - * slice's budget is spent in: a file of short lines is cheap on the wire and dear to diff. - */ readonly edits: number; } /** - * Beyond this a file is shown as changed without its hunks. Azure hands back whole files rather - * than a patch, so an oversize file is paid for twice over before anything can be diffed. + * Azure hands back whole files rather than a patch, so an oversize file is paid for twice + * over before anything can be diffed. */ const MAX_FILE_BYTES = 512 * 1024; -/** Git's own default, and what the hunks from this repo's other hosts are already cut to. */ const PATCH_CONTEXT_LINES = 3; /** - * How far apart one file's two sides may be before it is listed without its hunks. The line diff - * costs about the square of the edit distance, so an unbounded pair sharing almost nothing could - * hold the whole server. Bounded in edits rather than milliseconds so a change slices the same - * way on every machine. + * The line diff costs about the square of the edit distance, so an unbounded pair sharing almost + * nothing could hold the whole server. Bounded in edits rather than milliseconds so a change + * slices the same way on every machine. */ export const MAX_FILE_DIFF_EDITS = 2_000; -/** - * A backstop for a machine slower than the edit ceiling was tuned for. The ceiling rather than - * this is what decides a patch's shape: a timeout that fires would slice the same change one way - * here and another on a busier host. - */ const MAX_FILE_DIFF_MILLIS = 2_000; -/** How much diff work one slice does before the rest is left for the next request. */ export const MAX_DIFF_SLICE_EDITS = 6_000; -/** How much patch one slice carries before the rest is left for the next request. */ export const MAX_DIFF_SLICE_BYTES = 256 * 1024; /** - * How many files one slice carries however little each one weighs. A binary, oversize, or - * unreadable entry is just a header, so neither budget above stops a run of thousands of them. + * A binary, oversize, or unreadable entry is just a header, so neither budget above stops a + * run of thousands of them. */ export const MAX_DIFF_SLICE_FILES = 300; -/** Git's own note for a side whose last line has no newline after it. */ const NO_NEWLINE_MARKER = "\\ No newline at end of file"; -/** A text's lines, without the empty one that a trailing newline leaves behind a split. */ function contentLines(contents: string): ReadonlyArray { if (contents === "") return []; const lines = contents.split("\n"); @@ -112,15 +84,10 @@ function contentLines(contents: string): ReadonlyArray { return lines; } -/** A NUL byte is git's own test for it, and it survives Azure's JSON envelope intact. */ function isBinary(contents: string): boolean { return contents.includes("\u0000"); } -/** - * What a file costs on the wire, which is its bytes rather than its code units: a ceiling counted - * in characters lets a file of three-byte glyphs through at three times the intended size. - */ export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); /** @@ -133,10 +100,9 @@ function hunkRange(start: number, lines: number): string { } /** - * The `diff --git` preamble a viewer reads a file's identity and fate from. Azure reports no file - * mode, so the ordinary one stands in. Names are quoted the way git quotes them, prefix inside the - * quoting: a header reader stops a bare name at its first tab or newline, so a path holding either - * would be truncated and its viewed mark would land on the wrong file. + * Azure reports no file mode, so the ordinary one stands in. Names are quoted the way git quotes + * them, prefix inside the quoting: a header reader stops a bare name at its first tab or newline, + * so a path holding either would be truncated and its viewed mark would land on the wrong file. */ function patchHeader(change: AzureDevOpsChangeEntry): string { const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); @@ -157,10 +123,6 @@ function patchHeader(change: AzureDevOpsChangeEntry): string { return lines.join("\n"); } -/** - * A file written out as wholly replaced: every old line gone, every new line arrived, in one - * hunk, which needs no edit-distance search at all. - */ function replacementSection(header: string, texts: AzureDevOpsFileTexts): string { const oldLines = contentLines(texts.oldContents); const newLines = contentLines(texts.newContents); @@ -177,10 +139,6 @@ function replacementSection(header: string, texts: AzureDevOpsFileTexts): string ].join("\n"); } -/** - * One file's section of a unified patch, built here because Azure has no route that returns one: - * its diff routes name the files that changed, and the contents are a separate read per side. - */ export function azureDevOpsFilePatch(input: { readonly change: AzureDevOpsChangeEntry; readonly texts: AzureDevOpsFileTexts; @@ -189,7 +147,6 @@ export function azureDevOpsFilePatch(input: { const { oldContents, newContents } = input.texts; if (input.texts.binary || isBinary(oldContents) || isBinary(newContents)) { - // Git's own wording for a file it will not spell out. const oldSide = quoteGitPatchPath(`a/${input.change.oldPath}`); const newSide = quoteGitPatchPath(`b/${input.change.path}`); const binary = `Binary files ${oldSide} and ${newSide} differ`; @@ -199,8 +156,6 @@ export function azureDevOpsFilePatch(input: { return { section: `${header}\n`, truncated: true, abandoned: false, edits: 0 }; } - // A creation or deletion has nothing on one side, so writing both sides in full is already the - // minimal patch and needs no diff search. const created = oldContents === "" && newContents !== ""; const deleted = newContents === "" && oldContents !== ""; if (created || deleted) { @@ -254,8 +209,6 @@ export function azureDevOpsFilePatch(input: { ...hunk.lines, ].join("\n"); }); - // A pure rename has no hunks to give but is still listed, since dropping it would take the file - // out of the change altogether. const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus // context can still exceed the size ceiling even well inside the edit ceiling. @@ -265,7 +218,6 @@ export function azureDevOpsFilePatch(input: { return { section, truncated: false, abandoned: false, edits }; } -/** A file listed without its hunks, for when the host would not hand one of its two sides over. */ export function azureDevOpsUnreadableFilePatch( change: AzureDevOpsChangeEntry, ): AzureDevOpsFilePatch { diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts index 8e863b8751d9..c9c1f3aea81b 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts @@ -14,7 +14,6 @@ import { const REST_URL = "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42"; -/** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */ function pullRequest(overrides: Record = {}): Record { return { pullRequestId: 42, @@ -49,9 +48,7 @@ describe("decodePullRequestListJson", () => { expect(batch.items[0]).toMatchObject({ number: 42, title: "Add the change requests page", - // The login is an email, because that is what `az account show` reports to compare with. author: { login: "bilal@acme.dev", name: "Bilal Hassan" }, - // Azure prefixes its refs, which no other host does. headBranch: "feat/page", baseBranch: "main", state: "open", @@ -155,8 +152,6 @@ describe("decodePullRequestJson", () => { ); expect(armed?.autoMergeEnabled).toBe(true); - // Azure leaves the field out entirely rather than sending it empty, so its absence is the - // whole of what it says about auto-complete being off. expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe( false, ); @@ -197,8 +192,6 @@ describe("decodePullRequestJson", () => { }); it("reports no repository location when Azure said too little to name one", () => { - // A web link places the pull request, but with no repository named there is nothing to - // address the routes that read its files and its conversation. const detail = expectSuccess( decodePullRequestJson( asJson( @@ -257,7 +250,6 @@ describe("decodeThreadsJson", () => { { id: 1, comments: [ - // Azure's own activity notes are events rather than remarks. { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" }, { id: 2, @@ -363,7 +355,6 @@ describe("decodeIterationsJson", () => { }); it("skips a push Azure could not place both ends of", () => { - // A patch is taken over a range, and an iteration missing either end names no range at all. const iterations = expectSuccess( decodeIterationsJson( asJson({ @@ -401,8 +392,6 @@ describe("decodeIterationChangesJson", () => { }); it("keeps a space at the end of a file's name, which belongs to the name", () => { - // Git will carry a name that ends in a space, and the patch and the viewed mark are both - // keyed by it. Tidying it here files the change under a name nothing else uses. const page = expectSuccess( decodeIterationChangesJson( asJson({ @@ -463,7 +452,6 @@ describe("decodeIterationChangesJson", () => { }); it("drops the folders Azure lists alongside the files that changed", () => { - // A review shows files, and a folder has no content on either side to show for one. const page = expectSuccess( decodeIterationChangesJson( asJson({ @@ -504,8 +492,6 @@ describe("decodeIterationChangesJson", () => { }); it("reads where a rename came from out of either of the two places Azure names it", () => { - // The iteration-changes route answers with `originalPath`; the commit routes answer with - // `sourceServerItem`, and both are the same fact under two names. const page = expectSuccess( decodeIterationChangesJson( asJson({ @@ -539,7 +525,6 @@ describe("decodeItemContentJson", () => { }); it("keeps Azure's own word that a file is binary", () => { - // Which it answers base64-encoded, so nothing in the text it sent would give it away. expect( expectSuccess( decodeItemContentJson( @@ -551,15 +536,14 @@ describe("decodeItemContentJson", () => { }); /** - * Every other fixture in this file is written from Azure's published contract. These are the - * shapes a real organisation answered with, read back through `az devops invoke` rather than - * `az rest`: the extension hands over the route's own body with one key of its own added, and a - * live repository leaves out fields the contract documents. + * These are the shapes a real organisation answered with, read back through `az devops invoke` + * rather than `az rest`: the extension hands over the route's own body with one key of its own + * added, and a live repository leaves out fields the contract documents. */ describe("what az devops invoke answers with", () => { it("reads the envelope the extension adds its own continuation token to", () => { // Set on every JSON body it returns, from a response header these routes do not send, so it - // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode. + // arrives as null rather than not at all. expect( expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), ).toEqual([]); @@ -591,9 +575,7 @@ describe("what az devops invoke answers with", () => { it("keeps the files of a change that names neither their object type nor their page after it", () => { // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no - // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences - // is what the defaults in the decoder are for, and reading any of them as stated would drop - // every file of every Azure change request. + // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. const page = expectSuccess( decodeIterationChangesJson( asJson({ diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts index e5e9694f1cd3..05589b18fb6d 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts @@ -22,7 +22,6 @@ import { azureDevOpsPullRequestWebUrl } from "./azureDevOpsPullRequests.ts"; */ const RawIdentitySchema = Schema.Struct({ displayName: Schema.optional(Schema.NullOr(Schema.String)), - /** An email or UPN, which is what `az account show` reports for the signed-in user. */ uniqueName: Schema.optional(Schema.NullOr(Schema.String)), imageUrl: Schema.optional(Schema.NullOr(Schema.String)), }); @@ -50,9 +49,6 @@ const RawPullRequestSchema = Schema.Struct({ mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), - // Required, and required to be non-empty: the wire contract will not carry a change request - // without a branch or a created time, so a row missing one is skipped rather than breaking the - // response it travels in. sourceRefName: TrimmedNonEmptyString, targetRefName: TrimmedNonEmptyString, creationDate: TrimmedNonEmptyString, @@ -80,7 +76,6 @@ const RawPullRequestSchema = Schema.Struct({ ), }); -/** A pull request thread, which is how Azure keeps its conversation. */ const RawThreadSchema = Schema.Struct({ id: Schema.Int, isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), @@ -96,7 +91,6 @@ const RawThreadSchema = Schema.Struct({ author: Schema.optional(Schema.NullOr(RawIdentitySchema)), publishedDate: Schema.optional(Schema.NullOr(Schema.String)), isDeleted: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** `system` marks the notes Azure writes itself, which are events, not comments. */ commentType: Schema.optional(Schema.NullOr(Schema.String)), }), ), @@ -114,11 +108,6 @@ const RawViewerSchema = Schema.Struct({ ), }); -/** - * Where a repository lives, in the terms Azure's REST routes address it by. They take the project - * and the repository as separate route parameters rather than as one path, so the pair travels - * together rather than as a URL that would have to be taken apart again to use. - */ export interface AzureDevOpsRepositoryLocation { readonly project: string; readonly repository: string; @@ -135,20 +124,13 @@ export interface AzureDevOpsPullRequest { readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; readonly createdAt: string; - /** - * Azure records no last-touched time on a pull request, so the closing time stands in where - * there is one and the creation time otherwise. The same fallback the rest of the app uses. - */ readonly updatedAt: string; readonly closedAt: string | null; readonly body: string; readonly reviewRequestLogins: ReadonlyArray; readonly reviewers: ReadonlyArray; - /** Where this pull request lives, when Azure said enough to work it out. */ readonly location: AzureDevOpsRepositoryLocation | null; - /** Whether Azure is set to complete this on its own once its policies pass. */ readonly autoMergeEnabled: boolean; - /** The completion strategy Azure stored with auto-complete, where it reported one. */ readonly autoMergeMethod?: PullRequestMergeMethod; } @@ -189,15 +171,10 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili case "rejectedbypolicy": return "conflicting"; default: - // `queued` and `notSet` mean Azure has not finished checking. return "unknown"; } } -/** - * Where a pull request's own repository sits. Taken from what Azure returned rather than from the - * local remote, whose shape differs between the modern, legacy and SSH forms. - */ function toLocation( raw: Schema.Schema.Type, ): AzureDevOpsRepositoryLocation | null { @@ -224,11 +201,6 @@ function toAutoMergeMethod( } } -/** - * Null when Azure said too little to place the pull request: a row with no browser url and no - * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract - * refuses to carry it either. - */ function toPullRequest( raw: Schema.Schema.Type, ): AzureDevOpsPullRequest | null { @@ -284,13 +256,11 @@ type DecodeFailure = Cause.Cause; export interface AzureDevOpsPullRequestBatch { readonly items: ReadonlyArray; - /** Zero-based positions of the decoded items in Azure's raw page. */ readonly rawIndexes: ReadonlyArray; /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ readonly rawCount: number; } -/** Malformed entries are skipped rather than failing the batch, as on the other hosts. */ export function decodePullRequestListJson( raw: string, ): Result.Result { @@ -322,7 +292,6 @@ export function decodePullRequestJson( : Result.fail(decoded.failure); } -/** `az account show --query user` reports the signed-in account, whose name is an email. */ export function decodeViewerJson(raw: string): Result.Result { const decoded = decodeViewer(raw); return Result.isSuccess(decoded) @@ -330,14 +299,6 @@ export function decodeViewerJson(raw: string): Result.Result, DecodeFailure> { @@ -379,11 +340,6 @@ export function decodeThreadsJson( ); } -/** - * One push's worth of a pull request. Azure records every push as an iteration and keys the whole - * review off them: the changed files, and the marks a reader leaves on those files, both hang - * from an iteration rather than from the pull request. - */ const RawIterationSchema = Schema.Struct({ id: Schema.Int, sourceRefCommit: Schema.optional( @@ -399,7 +355,6 @@ const RawIterationPageSchema = Schema.Struct({ value: Schema.Array(Schema.Unknow const RawChangeEntrySchema = Schema.Struct({ changeType: Schema.optional(Schema.NullOr(Schema.String)), sourceServerItem: Schema.optional(Schema.NullOr(Schema.String)), - /** Where a renamed file came from. Azure states it here on an iteration's changes. */ originalPath: Schema.optional(Schema.NullOr(Schema.String)), item: Schema.optional( Schema.NullOr( @@ -407,7 +362,6 @@ const RawChangeEntrySchema = Schema.Struct({ path: Schema.optional(Schema.NullOr(Schema.String)), objectId: Schema.optional(Schema.NullOr(Schema.String)), originalObjectId: Schema.optional(Schema.NullOr(Schema.String)), - /** Azure marks a directory this way; a review has nothing to show for one. */ isFolder: Schema.optional(Schema.NullOr(Schema.Boolean)), gitObjectType: Schema.optional(Schema.NullOr(Schema.String)), }), @@ -417,29 +371,22 @@ const RawChangeEntrySchema = Schema.Struct({ const RawChangePageSchema = Schema.Struct({ changeEntries: Schema.Array(Schema.Unknown), - /** Where the page after this one starts. Azure leaves it out on the last page. */ nextSkip: Schema.optional(Schema.NullOr(Schema.Number)), }); const RawItemContentSchema = Schema.Struct({ content: Schema.optional(Schema.NullOr(Schema.String)), - /** What Azure makes of the file it is handing over, which is where it says it is not text. */ contentMetadata: Schema.optional( Schema.NullOr(Schema.Struct({ isBinary: Schema.optional(Schema.NullOr(Schema.Boolean)) })), ), }); -/** The head and the merge base of one iteration, which is the range its patch is taken over. */ export interface AzureDevOpsIteration { readonly id: number; readonly headCommit: string; readonly mergeBaseCommit: string; } -/** - * What one file did across an iteration. `oldPath` differs from `path` only for a rename, which - * Azure reports by naming the file's previous home rather than as a delete and an add. - */ export interface AzureDevOpsChangeEntry { readonly path: string; readonly oldPath: string; @@ -448,17 +395,11 @@ export interface AzureDevOpsChangeEntry { readonly originalObjectId: string | null; } -/** - * One page of what an iteration changed, and where the next one starts. Azure pages this route - * rather than answering with the whole change, so a review large enough to be paged is followed - * to its end instead of being cut off at the first page's worth. - */ export interface AzureDevOpsChangePage { readonly changes: ReadonlyArray; readonly nextSkip: number | null; } -/** One file's text at one commit, and whether Azure says the text is text at all. */ export interface AzureDevOpsItemContent { readonly contents: string; readonly isBinary: boolean; @@ -481,11 +422,6 @@ function toRepositoryPath(value: string | null | undefined): string | null { return path.length === 0 ? null : path; } -/** - * Azure names a change with one word or two, and a rename arrives either alone or alongside the - * edit that came with it. Anything it has added since reads as a plain change, which shows the - * file rather than dropping it from the review. - */ function toChangeKind( raw: string | null | undefined, renamed: boolean, @@ -515,7 +451,6 @@ export function decodeIterationsJson( const iteration = decodedIteration.value; const headCommit = trimmed(iteration.sourceRefCommit?.commitId); const mergeBaseCommit = trimmed(iteration.commonRefCommit?.commitId); - // An iteration Azure cannot place both ends of names no range, and a patch needs both. if (headCommit === null || mergeBaseCommit === null) continue; iterations.push({ id: iteration.id, headCommit, mergeBaseCommit }); } @@ -534,8 +469,6 @@ export function decodeIterationChangesJson( const change = decodedChange.value; const path = toRepositoryPath(change.item?.path); if (path === null) continue; - // Azure lists the folders a change touched alongside the files themselves. A review shows - // files, and a folder has no content to show for either side of one. if (change.item?.isFolder === true) continue; if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; // Azure names where a renamed file came from in either of two places depending on the route diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts index 7ffc398490cf..2608cc73fb4f 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts @@ -40,7 +40,7 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ targetRefName: TrimmedNonEmptyString, status: Schema.String, isDraft: Schema.optional(Schema.Boolean), - /** The fork the head branch lives in. `az` prints `null` for a pull request from the same repo. */ + /** `az` prints `null` for a pull request from the same repo. */ forkSource: Schema.optional(Schema.Unknown), creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), @@ -79,11 +79,6 @@ function encodeAzureDevOpsPathSegment(segment: string): string { return encodeURIComponent(segment); } -/** - * The organization root a REST url belongs to, which is where a browser url has to be hung when - * Azure answered with neither a web link nor a repository url. Read from what Azure returned - * rather than from the local remote, whose shape varies. - */ function azureDevOpsOrganizationBaseFromRestApiUrl( value: string | null | undefined, ): string | null { @@ -116,12 +111,6 @@ function azureDevOpsOrganizationBaseFromRestApiUrl( } } -/** - * Where a pull request lives in a browser. Azure answers with a web link when asked for one and - * otherwise leaves it to be assembled, so all three routes are tried in the order they can be - * trusted. Takes plain fields so both the source control provider and the pull requests page - * can share it. - */ export function azureDevOpsPullRequestWebUrl(input: { readonly pullRequestId: number; readonly webLink?: string | null | undefined; diff --git a/packages/source-control-azure-devops/src/server/driver.ts b/packages/source-control-azure-devops/src/server/driver.ts index 5545ece49f82..3932e8425437 100644 --- a/packages/source-control-azure-devops/src/server/driver.ts +++ b/packages/source-control-azure-devops/src/server/driver.ts @@ -1,9 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -/** - * The Azure DevOps driver: repository operations, discovery, and pull requests through `az`. - * - * @module source-control-azure-devops/server/driver - */ import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; diff --git a/packages/source-control-bitbucket/src/client/definition.ts b/packages/source-control-bitbucket/src/client/definition.ts index 797dc93f64cb..65d11a707596 100644 --- a/packages/source-control-bitbucket/src/client/definition.ts +++ b/packages/source-control-bitbucket/src/client/definition.ts @@ -1,8 +1,3 @@ -/** - * Bitbucket's client definition. Browser- and React Native-safe. - * - * @module source-control-bitbucket/client/definition - */ import { makeProviderSettingsSchema, SourceControlProviderKind, @@ -28,11 +23,6 @@ const text = (annotations: Schema.Annotations.Key) => Schema.annotateKey(annotations), ); -/** - * Bitbucket API credentials, used before the `T3CODE_BITBUCKET_*` environment variables: an - * access token, or an Atlassian account email with an API token. The access token wins when both - * are saved. - */ export const settings = makeProviderSettingsSchema( { accessToken: text({ @@ -87,7 +77,6 @@ export const definition = defineSourceControlClient({ authorProfileUrl: () => null, referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, - // No checkout CLI, and the reference field never accepted Bitbucket URLs. checkoutCommandArgument: () => null, isChangeRequestReference: () => false, changeRequestUrlHost: (url) => url.hostname, diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 741dcbeb08a3..e3f64c731f79 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -35,11 +35,8 @@ import * as BitbucketClient from "../client/definition.ts"; import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; -/** A response body past this is cut short, so one huge diff cannot exhaust the server. */ const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; -/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ const MAX_REDIRECTS = 3; -/** A single API operation must bound both header delivery and body consumption. */ const BITBUCKET_API_REQUEST_TIMEOUT = "30 seconds"; const BitbucketApiEnvConfig = Config.all({ @@ -61,8 +58,6 @@ const BitbucketApiOperation = Schema.Literals([ "createPullRequest", "probeAuth", "checkoutPullRequest", - // The raw escape hatch. Callers name their own operation in their own error, the way the - // pull request wrappers do on top of `gh` and `glab`. "request", ]); type BitbucketApiOperation = typeof BitbucketApiOperation.Type; @@ -235,9 +230,9 @@ export class BitbucketCheckoutError extends Schema.TaggedError()( "BitbucketUntrustedUrlError", @@ -330,21 +325,10 @@ export class BitbucketApi extends Context.Service< { readonly probeAuth: Effect.Effect; - /** - * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints - * with JSON and a few β€” a pull request diff, for one β€” with plain text, so the body is - * handed back undecoded for the caller to read as it sees fit. - */ readonly request: (input: { readonly method: "GET" | "POST" | "PUT" | "DELETE"; - /** - * A path below the API base, or a whole URL as a paged response reports its next page. - * A whole URL is refused unless it belongs to the configured Bitbucket. - */ readonly url: string; - /** A JSON document, for the endpoints that take one. */ readonly body?: string; - /** Response bytes to keep; past this the body comes back cut short and marked. */ readonly maxBytes?: number; }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>; readonly listPullRequests: (input: { @@ -548,8 +532,8 @@ type BitbucketCredential = | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; /** - * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole - * header, and that error travels to clients as a cause, so an unusable token is treated as unset. + * A value the HTTP stack rejects makes it throw an error quoting the whole header, and that + * error travels to clients as a cause, so an unusable token is treated as unset. */ const HEADER_SAFE = /^[\x21-\x7e]+$/u; @@ -567,10 +551,6 @@ function credentialFrom(input: { return null; } -/** - * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which - * stay as a fallback. Within each source the access token wins. - */ function resolveCredential( settings: BitbucketClient.BitbucketSettings, env: Config.Success, @@ -614,7 +594,6 @@ function authFromCredential(credential: BitbucketCredential | null): SourceContr }; } -/** Null for anything that is not a url at all, which is never the configured Bitbucket. */ function originOf(value: string): string | null { try { return new URL(value).origin; @@ -627,8 +606,6 @@ function responseError( operation: BitbucketApiOperation, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect { - // Bounded like any other body: an error response is no smaller than a successful one, and - // only its length is reported anyway. return Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; const retryAt = retryAtFromHeader(response.headers["retry-after"], now); @@ -665,7 +642,6 @@ export const make = Effect.gen(function* () { const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; - // Read on every request so credentials saved in settings apply without a restart. const currentCredential = host.settings.get.pipe( Effect.map((settings) => resolveCredential( @@ -876,14 +852,6 @@ export const make = Effect.gen(function* () { }); }); - // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range - // URL, and the client does not follow redirects unless asked. The hop stays on the same host, - // so the credentials travel with it. - /** - * The one host these credentials may be sent to. A url that came back inside a response β€” a - * pagination cursor, or the target of a redirect β€” is data, not instruction, so it is checked - * against this before the account's token travels with it. - */ const apiOrigin = originOf(config.baseUrl); const trustedUrl = (value: string): string | null => { @@ -950,9 +918,8 @@ export const make = Effect.gen(function* () { send({ ...input, redirects: 0 }).pipe( Effect.flatMap((response) => HttpClientResponse.matchStatus({ - // Read through the body stream rather than `text`, so an oversized diff is stopped - // as it arrives instead of being materialized whole and then cut. The same collector - // the process runner bounds command output with. + // Read through the body stream rather than `text`, so an oversized diff is stopped as + // it arrives instead of being materialized whole and then cut. "2xx": (success) => collectUint8StreamText({ stream: success.stream, @@ -1111,12 +1078,6 @@ export const make = Effect.gen(function* () { ), Effect.map(defaultChangeRequestTargetBranch), ), - // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide - // an official checkout CLI. This provider-local path uses GitVcsDriver as a - // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this - // as the source-control provider model: if we support non-Git-compatible - // hosting providers or native JJ/Sapling checkout flows, move this into a - // VCS-specific change-request checkout capability. checkoutPullRequest: (input) => Effect.gen(function* () { const destinationRepository = yield* resolveRepository(input); diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts index adc004bb8481..d287fb90db2e 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts @@ -19,7 +19,6 @@ const layer = it.layer( ), ); -/** The shape `request` answers with: a body plus whether it had to be cut short. */ function response(body: string) { return { body, truncated: false }; } @@ -46,12 +45,10 @@ function valuePage(values: ReadonlyArray, next?: string): string { return JSON.stringify({ values, ...(next === undefined ? {} : { next }) }); } -/** Who opened the pull request, and two accounts that could review it. */ const bilal = { uuid: "{bilal}", nickname: "bilal" }; const octocat = { uuid: "{octocat}", nickname: "octocat" }; const hubot = { uuid: "{hubot}", nickname: "hubot" }; -/** One pull request as `/pullrequests/{id}` answers with it. */ function pullRequestJson(overrides: Record): string { return JSON.stringify({ id: 7, @@ -67,14 +64,12 @@ function pullRequestJson(overrides: Record): string { }); } -/** The request the nth call made. */ function callAt(index: number) { const call = mockedRequest.mock.calls[index]; assert.isDefined(call); return call[0]; } -/** The filter expression of the nth request, read back out of its query string. */ function filterOfCall(index: number): string | null { const url = callAt(index).url; return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q"); @@ -149,8 +144,7 @@ layer("BitbucketPullRequestApi.layer", (it) => { it.effect("counts the rows it walked past as more to come", () => Effect.gen(function* () { // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a - // hundred and drops one. That row is more results, and saying otherwise takes the "load - // more" away from a listing that has not finished. + // hundred and drops one. const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; mockedRequest .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) @@ -181,7 +175,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { }); expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")'); - // The state filter beside it still stands, which the brackets are there to keep. expect(callAt(0).url).toContain("state=OPEN"); }), ); @@ -231,7 +224,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 }, }); - // Inclusive, so the rows already sent at that instant come back for the caller to drop. expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00"); expect(callAt(0).url).toContain("sort=-updated_on"); }), @@ -321,7 +313,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { assert.isFalse(diff.truncated); expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/7/diff", - // A diff of any size would otherwise be read into memory whole. maxBytes: 8 * 1024 * 1024, }); }), @@ -397,10 +388,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { paths: ["a.ts", "missing.ts"], }); - // `b.ts` was not asked about and is reported anyway: parsing the patch for `a.ts` read it - // too, and the caller holding it is what stops the next tick paying for the patch again. - // `missing.ts` was asked about and the whole patch was read without finding it, which is - // what a file this pull request deletes looks like, so it is answered as the empty version. assert.deepStrictEqual( [...revisions.revisions], [ @@ -416,8 +403,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { it.effect("says nothing about the files past the end of a patch it could not read whole", () => Effect.gen(function* () { - // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at. - // Answering for it as deleted would clear a mark on it once and for good. mockedRequest.mockReturnValueOnce( Effect.succeed({ body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n", @@ -433,7 +418,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { }); assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]); - // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story. assert.strictEqual(revisions.complete, false); }), ); @@ -462,7 +446,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { number: 74, paths: ["a.ts"], }); - // A path nobody has asked about before, which is what every tick after the first names. const second = yield* api.getFileRevisions({ repository: "acme/web", number: 74, @@ -494,8 +477,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { yield* read(); assert.strictEqual(mockedRequest.mock.calls.length, 1); - // Well inside the window the caller holds versions for: a refresh drops what it holds so - // that the read after it reaches Bitbucket, and this must not answer that read instead. yield* TestClock.adjust(Duration.seconds(30)); yield* read(); assert.strictEqual(mockedRequest.mock.calls.length, 2); @@ -758,7 +739,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { const error = yield* Effect.flip(api.getViewer()); - // The fact only; the provider adds the operation around it. assert.strictEqual(error.detail, "Bitbucket returned HTTP 500."); }), ); @@ -797,8 +777,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { mockedRequest.mockReturnValueOnce( Effect.succeed( response( - // The reply arrives a page after the remark it answers, which is why the threads - // are only assembled once every page is in hand. JSON.stringify({ values: [ { @@ -829,7 +807,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { it.effect("stops the comment walk at its bound and says the conversation was cut short", () => Effect.gen(function* () { - // Bitbucket that always names a next page: the walk has to end itself. mockedRequest.mockReturnValue( Effect.succeed( response( @@ -877,7 +854,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { created_on: "2026-06-16T06:04:32+00:00", parent: { id: 10 }, }, - // A reply to a reply still belongs to the thread its root opened. { id: 12, content: { raw: "thanks" }, @@ -937,7 +913,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { inline: { path: "src/a.ts", from: 12 }, }); expect(callAt(1).url).toContain("/pullrequests/7/comments"); - // The verdict goes last, so a review that failed part-way is never a rejection either. expect(callAt(2).url).toContain("/pullrequests/7/request-changes"); }), ); @@ -1016,7 +991,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { }); const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - // An unmatched, empty permission page is treated as unknown and would allow this attempt. assert.isFalse(yield* api.getRepositoryPermission({ repository: " acme / web " })); }), ); @@ -1028,7 +1002,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { yield* api.getRepositoryPermission({ repository: 'acme/we"b' }); - // A quote would otherwise end the literal and leave the rest standing as filter syntax. assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"'); }), ); @@ -1081,7 +1054,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 }); - // The people live on the workspace: nothing on a repository lists who may review it. expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50"); expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ ["{octocat}", true], diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts index a15283f691cb..3c6a14fab19a 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts @@ -41,10 +41,6 @@ import { } from "./bitbucketPullRequestJson.ts"; import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; -/** - * Names the read that produced unusable output, so a failure reports the call it came from - * rather than borrowing another operation's message. - */ export class BitbucketPullRequestReadError extends Schema.TaggedError()( "BitbucketPullRequestReadError", { @@ -61,7 +57,6 @@ export class BitbucketPullRequestReadError extends Schema.TaggedError()( "BitbucketViewerUnavailableError", {}, @@ -75,7 +70,6 @@ export class BitbucketViewerUnavailableError extends Schema.TaggedError()( "BitbucketRepositoryUnsupportedError", { @@ -91,7 +85,6 @@ export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError()( "BitbucketDiffCommitError", {}, @@ -112,7 +105,6 @@ export type BitbucketPullRequestApiError = | BitbucketRepositoryUnsupportedError | BitbucketDiffCommitError; -/** The permissions endpoint refusing or missing for these credentials, rather than answering. */ function isRepositoryPermissionUnavailableError( error: BitbucketPullRequestApiError, ): error is BitbucketApi.BitbucketResponseError { @@ -123,21 +115,13 @@ function isRepositoryPermissionUnavailableError( } /** - * Bitbucket's own ceiling. Asking for more does not fail β€” it answers with an empty page and no - * error at all, so this is a number to respect rather than to push against. + * Asking for more does not fail β€” it answers with an empty page and no error at + * all, so this is a number to respect rather than to push against. */ const MAX_PAGE_SIZE = 50; -/** Pages to walk before a listing is reported as truncated. */ const MAX_LIST_PAGES = 10; -/** The page size for pull request conversations, commits, and checks. */ const CONVERSATION_PAGE_SIZE = 50; -/** - * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves - * fifty comments a page, so this is five hundred β€” beyond any pull request a person is reading, - * and an end to a walk whose only other stop is Bitbucket running out. - */ const CONVERSATION_PAGES = 10; -/** The same ceiling the gh and glab diff reads use. */ const DIFF_MAX_BYTES = 8 * 1024 * 1024; /** * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the @@ -154,16 +138,13 @@ export interface BitbucketPullRequestBatch { export class BitbucketPullRequestApi extends Context.Service< BitbucketPullRequestApi, { - /** A function rather than a value, so the request is built per call and not at layer time. */ readonly getViewer: () => Effect.Effect; readonly listPullRequests: (input: { readonly repository: string; readonly state: PullRequestListState; readonly limit: number; - /** Free text, matched against a pull request's title and description. */ readonly query?: string | undefined; - /** Where to carry on from, as a predicate on `updated_on` beside any other. */ readonly cursor?: ProviderListCursor | undefined; }) => Effect.Effect; @@ -172,7 +153,6 @@ export class BitbucketPullRequestApi extends Context.Service< readonly number: number; }) => Effect.Effect; - /** True where the credentials can write to the repository, which is what merging needs. */ readonly getRepositoryPermission: (input: { readonly repository: string; }) => Effect.Effect; @@ -180,7 +160,6 @@ export class BitbucketPullRequestApi extends Context.Service< readonly getPullRequestDiff: (input: { readonly repository: string; readonly number: number; - /** One commit's own changes, rather than everything the pull request carries. */ readonly commit?: string | undefined; }) => Effect.Effect< { readonly patch: string; readonly truncated: boolean }, @@ -193,11 +172,6 @@ export class BitbucketPullRequestApi extends Context.Service< }) => Effect.Effect; /** - * What the pull request's head has of each of these paths, as opaque ids, read off the pull - * request's own patch, the only place Bitbucket states a file's version. A path the patch does - * not carry is answered as the empty revision, and left out altogether when the patch was cut - * short at the byte ceiling and so cannot be spoken for. - * * Answers with every file the patch carries rather than only the paths asked about, since * reading one file's version here means parsing all of them. `complete` is false for a patch * cut short, which cannot speak for what came after the cut. @@ -238,11 +212,6 @@ export class BitbucketPullRequestApi extends Context.Service< readonly number: number; }) => Effect.Effect, BitbucketPullRequestApiError>; - /** - * Who this pull request may be sent to, and who it has already been sent to. Two reads at - * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull - * request, and neither answers for the other. - */ readonly listReviewerCandidates: (input: { readonly repository: string; readonly number: number; @@ -306,7 +275,6 @@ export class BitbucketPullRequestApi extends Context.Service< } >()("@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi") {} -/** `workspace/slug`; Bitbucket has no deeper nesting to address. */ function repositorySegments( repository: string, ): Result.Result< @@ -330,19 +298,13 @@ function repositoryPathOf(segments: { readonly workspace: string; readonly slug: )}`; } -/** - * A commit sha arrives from the reader and goes straight into a request path, so it is checked - * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a - * whole sha. - */ function isCommitSha(value: string): boolean { return /^[0-9a-f]{7,64}$/i.test(value); } /** * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks - * for each. It separates a declined pull request from one superseded by another, and both read - * as closed here. + * for each. */ function stateParams(state: PullRequestListState): ReadonlyArray { switch (state) { @@ -358,15 +320,8 @@ function stateParams(state: PullRequestListState): ReadonlyArray { } /** - * Bitbucket has no search term, only a filter expression, so free text becomes one: a - * case-insensitive contains against the two fields a pull request carries words in. The - * parentheses matter, because the expression is ANDed with the state filter beside it and an - * unbracketed `OR` would swallow it. - * - * A string literal in that grammar is delimited by double quotes, so the reader's text is - * escaped before it goes inside one β€” a quote would otherwise end the literal and leave the - * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so - * nothing in it reaches the query string as a parameter of its own. + * The parentheses matter, because the expression is ANDed with the state filter beside it and + * an unbracketed `OR` would swallow it. */ function searchFilter(query: string): string { const literal = filterLiteral(query); @@ -374,20 +329,18 @@ function searchFilter(query: string): string { } /** - * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or - * escaping the quote would only produce a literal backslash followed by a live quote. + * The backslash is escaped first, or escaping the quote would only produce a literal + * backslash followed by a live quote. */ function filterLiteral(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); } -/** Bitbucket's merge strategies, named differently from the three the contract carries. */ function mergeStrategy(method: PullRequestMergeMethod | undefined): string { switch (method) { case "squash": return "squash"; case "rebase": - // The linear history GitHub calls "rebase and merge". return "rebase_fast_forward"; default: return "merge_commit"; @@ -411,9 +364,6 @@ function bitbucketReviewPosition( export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; - /** - * The repository's API path and normalized segments, used for workspace reads and filters. - */ const withRepository = ( repository: string, use: ( @@ -428,11 +378,6 @@ export const make = Effect.gen(function* () { : Effect.fail(segments.failure); }; - /** - * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it - * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at - * the page cap β€” and anything but running out of pages means there is more to be had. - */ const listPage = (input: { readonly url: string; readonly limit: number; @@ -455,9 +400,6 @@ export const make = Effect.gen(function* () { if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) { return Effect.succeed({ items: collected.slice(0, input.limit), - // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the - // count rather than on the last page is holding rows it is about to drop. Those are - // more results just as surely as another page would be. truncated: next !== null || collected.length > input.limit, }); } @@ -485,9 +427,8 @@ export const make = Effect.gen(function* () { ); /** - * The conversation, following the `next` Bitbucket sends until it sends none. Threads are - * assembled once at the end rather than per page, because a reply and the remark it answers - * can land either side of a page boundary. + * Threads are assembled once at the end rather than per page, because a reply and the remark + * it answers can land either side of a page boundary. */ const commentsPage = (input: { readonly url: string; @@ -517,7 +458,6 @@ export const make = Effect.gen(function* () { }), ); - /** Walks a Bitbucket cursor to its end and combines every decoded item. */ const itemPages = (input: { readonly operation: string; readonly url: string; @@ -539,7 +479,6 @@ export const make = Effect.gen(function* () { }), ); - /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */ const diffStatPages = (input: { readonly url: string; readonly totals: BitbucketDiffStat; @@ -568,9 +507,6 @@ export const make = Effect.gen(function* () { input.commit !== undefined && !isCommitSha(input.commit) ? Effect.fail(new BitbucketDiffCommitError()) : withRepository(input.repository, (path) => - // Already a unified patch, so it needs no decoding at all, only a bound, which a - // diff of any size would otherwise ignore. A commit's own patch sits beside the pull - // request's at `/diff/{sha}` and reads the same way. bitbucket .request({ method: "GET", @@ -586,10 +522,9 @@ export const make = Effect.gen(function* () { ); /** - * What the version reads that come one tick at a time want out of the pull request's whole - * patch, shared between them. The parsed answer rather than the patch, which at this capacity - * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch - * of a hundred thousand lines again on every tick. + * The parsed answer rather than the patch, which at this capacity would hold sixteen bodies + * of up to the byte ceiling each resident, and saves walking a patch of a hundred thousand + * lines again on every tick. */ const revisionPatches = yield* Cache.makeWith( (key: string) => { @@ -603,8 +538,6 @@ export const make = Effect.gen(function* () { }, { capacity: REVISION_PATCH_CAPACITY, - // A failure is not held: the tick after it should reach Bitbucket rather than be handed the - // same error for as long as a good patch would have lasted. timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero), }, ); @@ -628,11 +561,9 @@ export const make = Effect.gen(function* () { listPullRequests: (input) => withRepository(input.repository, (path) => { const search = input.query?.trim() ?? ""; - // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one - // replacing the other. The boundary instant is read inclusively β€” the rows already sent - // at it come back and the caller drops them, which is what keeps their neighbours at the - // same instant from being skipped. A date is a bare literal in this grammar, and this one - // was checked against a timestamp's shape before it got here. + // The boundary instant is read inclusively β€” the rows already sent at it come back and + // the caller drops them, which is what keeps their neighbours at the same instant from + // being skipped. const predicates = [ ...(search.length === 0 ? [] : [searchFilter(search)]), ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]), @@ -659,9 +590,6 @@ export const make = Effect.gen(function* () { }), ), - // Ask for the caller's effective permission within this workspace. The full name identifies - // the repository by its slug, even when its display name differs. - // // Bitbucket has retired permission endpoints before (CHANGE-2770), and some credentials may // not reach `/user` endpoints at all. A 403, 404 or 410 here is read as a permission that // could not be learned, the same as an empty page: it grants, and the write itself says why @@ -790,8 +718,7 @@ export const make = Effect.gen(function* () { Effect.flatMap((current) => { // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's // `reviewers` is written whole, so the set that is already there is read first and - // the change applied to it. Everything else about the pull request is left out of - // the body, which leaves it as it was. + // the change applied to it. const uuids = new Set(current.reviewerIds); for (const reviewer of input.reviewers) { if (input.requested) uuids.add(reviewer.id); @@ -810,8 +737,6 @@ export const make = Effect.gen(function* () { runAction: (input) => withRepository(input.repository, (path) => { const pullRequest = `${path}/pullrequests/${input.number}`; - // Only merge and close reach here: the provider declares the others unsupported, so the - // surface never offers them. if (input.action === "merge") { return bitbucket .request({ @@ -850,7 +775,6 @@ export const make = Effect.gen(function* () { .request({ method: "POST", url: `${path}/pullrequests/${input.number}/comments`, - // A JSON document rather than a form field, so the body stays text whatever it says. body: JSON.stringify({ content: { raw: input.body } }), }) .pipe(Effect.asVoid), @@ -860,8 +784,6 @@ export const make = Effect.gen(function* () { withRepository(input.repository, (path) => bitbucket .request({ - // Bitbucket keeps a pull request's remarks and its line comments in the one - // collection, so this endpoint rewrites either kind. method: "PUT", url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( input.commentId, @@ -929,7 +851,6 @@ export const make = Effect.gen(function* () { withRepository(input.repository, (path) => bitbucket .request({ - // Resolving is a sub-resource that is created and deleted, rather than a field. method: input.resolved ? "POST" : "DELETE", url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( input.commentId, diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts index e26cb884893d..b018f4ca9806 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts @@ -208,8 +208,6 @@ describe("bitbucketViewerPermissions", () => { comment: true, resolve: true, verdicts: ["comment", "approve", "request-changes"], - // Bitbucket says nothing about who may set a reviewer, and an unreported permission is - // granted. requestReviewers: true, }); }); @@ -225,9 +223,6 @@ describe("bitbucketViewerPermissions", () => { }); it("treats an author with read access as any other reader, which is all Bitbucket says", () => { - // The repository permission is the whole of what Bitbucket reports per account; it says - // nothing about who opened this pull request, and its author may decline it with read access - // alone β€” so declining stays offered rather than being taken from them. expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]); }); }); diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index 7f8794730be5..375b7f4094be 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -39,18 +39,9 @@ const CAPABILITIES: PullRequestCapabilities = { }; /** - * What the configured account may do here, from the one thing Bitbucket states per viewer: the - * repository permission. Merging needs `write` or `admin`, so that is what narrows. - * - * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request - * decline their own with no more than read access, and the permission response says nothing about - * who opened this one β€” so withholding the control from the one person entitled to it is the - * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is - * enough to say something, to approve and to ask for changes. - * - * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the - * author of a pull request as well as from whoever can write, and says nothing here about which - * of the two this account is. + * Bitbucket lets the author of a pull request decline their own with no more than read access, + * and the permission response says nothing about who opened this one β€” so withholding the + * control from the one person entitled to it is the worse of the two mistakes. */ export function bitbucketViewerPermissions(input: { readonly canWrite: boolean; @@ -64,12 +55,9 @@ export function bitbucketViewerPermissions(input: { }; } -/** The failures that mean the credentials are the problem, rather than one request. */ export function bitbucketProviderFailure( error: BitbucketPullRequestApi.BitbucketPullRequestApiError, ): PullRequestProviderFailure { - // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be - // missing: unusable always means the credentials are absent or refused. if (error._tag === "BitbucketResponseError" && error.status === 401) { return { reason: "unauthenticated" }; } @@ -106,13 +94,11 @@ function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeReque state: pullRequest.state, isDraft: pullRequest.isDraft, mergeability: pullRequest.mergeability, - // Line counts are a separate read, which only the detail is worth spending on. additions: 0, deletions: 0, createdAt: pullRequest.createdAt, updatedAt: pullRequest.updatedAt, reviewRequestLogins: pullRequest.reviewRequestLogins, - // Bitbucket has no labels on a pull request. labels: [], }; } @@ -126,8 +112,6 @@ export const make = Effect.gen(function* () { provider: SourceControlProviderKind.make("bitbucket"), operation, ...bitbucketProviderFailure(error), - // Every Bitbucket failure states its own fact; this names the operation around it, so - // the two do not stack into "failed in x: failed in y: ...". detail: error.detail, cause: error, }); @@ -161,8 +145,6 @@ export const make = Effect.gen(function* () { kind: SourceControlProviderKind.make("bitbucket"), capabilities: CAPABILITIES, - // Bitbucket credentials come from the server's environment rather than a checkout, so the - // account is the same whichever workspace asks. getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), listChangeRequests: (input) => @@ -179,8 +161,6 @@ export const make = Effect.gen(function* () { Effect.map((batch) => ({ items: batch.items.map(toChangeRequest), truncated: batch.truncated, - // Bitbucket is asked for `-updated_on` whether or not it is being carried on from, - // so every page it answers is one a cursor can continue. continues: true, })), ), @@ -199,9 +179,6 @@ export const make = Effect.gen(function* () { api.getDiffStat(target), recoverRead(api.getMergeability(target), "unknown" as const), recoverRead(api.listChecks(target), []), - // A permission that could not be read is an unknown one, which is granted: a hidden - // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses - // at least says why. recoverRead(api.getRepositoryPermission(target), true), ], { concurrency: 5 }, @@ -238,8 +215,6 @@ export const make = Effect.gen(function* () { const target = { repository: input.repository, number: input.number }; return Effect.all( [ - // Reviews ride on the pull request itself, so this inexpensive core read is repeated - // here rather than making the core response wait for the conversation endpoints. api.getPullRequest(target), recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }), recoverRead(api.listCommits(target), []), @@ -360,7 +335,6 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("replyToThread"))), - // Never called: `capabilities.reactions` is false, and the service refuses without it. setReaction: () => Effect.fail( new PullRequestProviderError({ diff --git a/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.test.ts b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.test.ts index 00696287b785..8919849467c6 100644 --- a/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.test.ts +++ b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.test.ts @@ -13,7 +13,6 @@ import { decodeViewerJson, } from "./bitbucketPullRequestJson.ts"; -/** Shaped after a real api.bitbucket.org pull request, trimmed to the fields that are read. */ function pullRequest(overrides: Record = {}): Record { return { id: 897, @@ -61,7 +60,6 @@ describe("decodePullRequestPageJson", () => { baseBranch: "master", state: "open", isDraft: false, - // Bitbucket says nothing about conflicts on the pull request itself. mergeability: "unknown", }); expect(decoded.next).toBeNull(); @@ -160,7 +158,6 @@ describe("decodePullRequestJson", () => { state: "approved", participated_on: "2026-06-17T09:00:00+00:00", }, - // Added as a reviewer but has not voted, so there is no verdict to show. { user: { nickname: "sam", display_name: "Sam" }, role: "REVIEWER", @@ -237,7 +234,6 @@ describe("decodeCommentsJson", () => { expect(decoded.comments[0]).toMatchObject({ id: "797230941", kind: "issue-comment", - // An app account has no nickname, so its display name is the only handle it has. author: { login: "Release Bot" }, createdAt: "2026-05-15T01:58:38.220Z", }); @@ -396,8 +392,6 @@ describe("repository permission decoding", () => { }); it("grants write where Bitbucket named no permission at all", () => { - // An empty page is Bitbucket declining to say, which is an unknown standing rather than a - // refusal β€” and an unknown one is granted. expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true); }); }); diff --git a/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts index b59ea1a77075..3552f388d7b8 100644 --- a/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts +++ b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts @@ -28,7 +28,7 @@ import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChe const RawUserSchema = Schema.Struct({ /** * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are - * not accepted there. Braced, and sent back exactly as it arrived. + * not accepted there. */ uuid: Schema.optional(Schema.NullOr(Schema.String)), /** Absent on an app account, which is why `display_name` has to stand in for it. */ @@ -45,11 +45,6 @@ const RawUserSchema = Schema.Struct({ ), }); -/** - * Required, and required to be non-empty: the wire contract will not carry a change request - * without a branch or a link, so a row missing one is skipped rather than breaking the response - * it travels in. - */ const RawBranchSchema = Schema.Struct({ branch: Schema.Struct({ name: TrimmedNonEmptyString }), commit: Schema.optional( @@ -90,9 +85,7 @@ const RawPullRequestSchema = Schema.Struct({ const RawPageSchema = Schema.Struct({ values: Schema.Array(Schema.Unknown), - /** A total count, which Bitbucket omits on some endpoints. */ size: Schema.optional(Schema.NullOr(Schema.Int)), - /** Present only while a further page exists. */ next: Schema.optional(Schema.NullOr(Schema.String)), }); @@ -102,23 +95,18 @@ const RawCommentSchema = Schema.Struct({ user: Schema.optional(Schema.NullOr(RawUserSchema)), created_on: Schema.String, deleted: Schema.optional(Schema.Boolean), - /** A comment still being drafted by its author. */ pending: Schema.optional(Schema.Boolean), - /** Set on a reply, to the comment it answers β€” which may itself be a reply. */ parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))), inline: Schema.optional( Schema.NullOr( Schema.Struct({ path: Schema.optional(Schema.NullOr(Schema.String)), - /** The line in the file as it was; set instead of `to` on a removed line. */ from: Schema.optional(Schema.NullOr(Schema.Int)), - /** The line in the file as it is now. */ to: Schema.optional(Schema.NullOr(Schema.Int)), outdated: Schema.optional(Schema.NullOr(Schema.Boolean)), }), ), ), - /** Non-null once someone has marked the thread resolved. */ resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), links: Schema.optional( Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })), @@ -152,7 +140,6 @@ const RawDiffstatSchema = Schema.Struct({ lines_removed: Schema.optional(Schema.NullOr(Schema.Int)), }); -/** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */ const RawMemberSchema = Schema.Struct({ user: Schema.optional(Schema.NullOr(RawUserSchema)), }); @@ -162,10 +149,6 @@ const RawViewerSchema = Schema.Struct({ display_name: Schema.optional(Schema.NullOr(Schema.String)), }); -/** - * The caller's workspace-scoped repository permissions, filtered to one repository. - * One row, or none where Bitbucket names no permission for this account. - */ const RawRepositoryPermissionsSchema = Schema.Struct({ values: Schema.optional( Schema.NullOr( @@ -195,9 +178,7 @@ export interface BitbucketPullRequest { readonly body: string; readonly reviewRequestLogins: ReadonlyArray; readonly reviewers: ReadonlyArray; - /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */ readonly reviewerIds: ReadonlyArray; - /** Approvals and change requests, which Bitbucket keeps on its participants. */ readonly reviews: ReadonlyArray; } @@ -218,7 +199,6 @@ function toIsoUtc(value: string): string { }); } -/** An app account has no nickname, so the display name is the only handle it has. */ function toActor(raw: Schema.Schema.Type | null | undefined) { const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); return login === null @@ -257,10 +237,6 @@ function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus } } -/** - * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in - * the conversation. Participants who have only been added carry no verdict and are skipped. - */ function toReviews( raw: Schema.Schema.Type, ): ReadonlyArray { @@ -328,11 +304,9 @@ type DecodeFailure = Cause.Cause; export interface BitbucketPage { readonly items: ReadonlyArray; - /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ readonly next: string | null; } -/** Malformed entries are skipped rather than failing the page, as on the other hosts. */ export function decodePullRequestPageJson( raw: string, ): Result.Result, DecodeFailure> { @@ -366,11 +340,6 @@ export function decodeViewerJson(raw: string): Result.Result { const decoded = decodeRepositoryPermissions(raw); if (!Result.isSuccess(decoded)) { @@ -381,13 +350,9 @@ export function decodeRepositoryPermissionJson(raw: string): Result.Result; export interface BitbucketComments { @@ -421,18 +385,11 @@ export interface BitbucketComments { readonly next: string | null; } -/** - * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a - * line opens a thread, and every reply that leads back to it belongs in it. A reply whose - * parent is on a page that was not read has nowhere to go, and is left out rather than shown - * as a thread of its own β€” it still stands in the flat conversation, which needs no parent. - */ export function buildReviewThreads( comments: ReadonlyArray, ): ReadonlyArray { const byId = new Map(comments.map((comment) => [comment.id, comment])); const rootOf = (comment: Schema.Schema.Type) => { - // Bounded by the number of comments read, so a parent cycle cannot spin here. let current = comment; for (let step = 0; step < byId.size; step += 1) { const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); @@ -450,8 +407,6 @@ export function buildReviewThreads( const path = trimmed(inline?.path); if (path === null) continue; if (root.id === comment.id) { - // `to` is the line as the file stands now, `from` the line it replaced; a comment that - // carries only `from` was written against the removed side. const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; const line = side === "left" ? inline?.from : inline?.to; threads.set(root.id, { @@ -483,10 +438,6 @@ export function buildReviewThreads( }); } -/** - * Deleted comments and ones their author has not posted yet carry nothing to show. A comment - * pinned to a file is a line-level review comment, which is what that kind means. - */ export function decodeCommentsJson(raw: string): Result.Result { const decoded = decodePage(raw); if (!Result.isSuccess(decoded)) { @@ -595,7 +546,6 @@ export interface BitbucketDiffStatPage extends BitbucketDiffStat { readonly next: string | null; } -/** One entry per changed file, each carrying that file's line counts. */ export function decodeDiffstatJson( raw: string, ): Result.Result { @@ -621,10 +571,6 @@ export function decodeDiffstatJson( }); } -/** - * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the - * only statement Bitbucket makes that a pull request merges cleanly. - */ export function decodeConflictsJson( raw: string, ): Result.Result { diff --git a/packages/source-control-bitbucket/src/server/driver.ts b/packages/source-control-bitbucket/src/server/driver.ts index 871970423e97..381ce7bf206f 100644 --- a/packages/source-control-bitbucket/src/server/driver.ts +++ b/packages/source-control-bitbucket/src/server/driver.ts @@ -1,10 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -/** - * The Bitbucket Cloud driver: repository operations, discovery, and pull requests through the - * Bitbucket REST API. - * - * @module source-control-bitbucket/server/driver - */ import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; diff --git a/packages/source-control-core/src/client/definition.ts b/packages/source-control-core/src/client/definition.ts index 6fc6eb69b790..fde7ab9fba00 100644 --- a/packages/source-control-core/src/client/definition.ts +++ b/packages/source-control-core/src/client/definition.ts @@ -1,16 +1,3 @@ -/** - * The client half of a source control host package. - * - * Every host package exports a `SourceControlClientDefinition` from its `./client/definition` - * entry point, the way `./server/driver` is its server entry. Web, mobile, and client-runtime - * read labels, change request nouns, picker copy, and checkout commands from these definitions, - * so a new host needs no per-kind branches in client code. - * - * Definitions are browser- and React Native-safe: plain data and pure functions, no server or - * platform UI imports. - * - * @module source-control-core/client/definition - */ import { type PullRequestAction, pullRequestHostOf, @@ -21,144 +8,73 @@ import { import type { ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; import * as Schema from "effect/Schema"; -/** What a host calls a change request, e.g. `MR` and `merge request` on GitLab. */ export interface ChangeRequestTerminology { readonly shortLabel: string; readonly singular: string; } -/** Where a project's repository lives, for building its change request URLs. */ export interface ChangeRequestUrlInput { - /** The host below which the repository is addressed, as `pullRequestHostOf` reports it. */ readonly host: string; - /** The repository path below the host. */ readonly repository: string; readonly number: number; - /** The checkout's remote URL, whose origin some hosts serve their web pages from. */ readonly remoteUrl?: string | undefined; - /** The repository's browser URL, when the server resolved one from a hosting account. */ readonly webUrl?: string | undefined; } -/** A project's repository, as a change request link is matched against it. */ export type ChangeRequestProjectIdentity = Pick< RepositoryIdentity, "canonicalKey" | "locator" | "webUrl" | "displayName" | "owner" | "name" >; -/** The change request a checkout command is built for. */ export interface ChangeRequestCheckoutInput { readonly number: number; readonly headBranch: string; readonly headRepositoryNameWithOwner?: string | null | undefined; - /** The repository's web URL, for hosts whose checkout fetches from it. */ readonly repositoryUrl?: string | null | undefined; } export interface SourceControlClientDefinition { readonly kind: SourceControlProviderKind; - /** The host's name, as in "Open on GitHub". */ readonly label: string; - /** How the clone and publish pickers name the host; Forgejo's covers Gitea too. */ readonly pickerLabel: string; - /** Glyph key each client maps to its own icon; a key a client lacks draws the generic glyph. */ readonly icon: string; readonly changeRequest: ChangeRequestTerminology; - /** Placeholder for a repository path on this host, such as `owner/repo`. */ readonly repositoryPathHint: string; - /** - * The hostname a change request reference can be attributed to before any repository - * identity is known, or null when a hostname alone does not name this host. - */ readonly publicHost: string | null; - /** Subtitle beside the host in the publish picker. */ readonly publishDescription: string; - /** Where a publish lands, given the host the server is signed in to, if it reported one. */ readonly publishHost: (signedInHost: string | null) => string; - /** - * Who a new project's repository is published under, given the signed-in account: an owner to - * name, `null` owner to let the host's CLI pick the signed-in user, or null when the host needs - * a choice an account cannot make, such as Azure DevOps' project. - */ readonly newRepositoryOwner: (account: string | null) => { readonly owner: string | null } | null; - /** Which of a repository's clone URLs a new clone uses. */ readonly defaultCloneTransport: "https" | "ssh"; - /** The web URL of a change request, or null when this client cannot build one for the host. */ readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; /** * The actions this host can take on a change request, the same list its server provider - * declares in `capabilities.actions`. Surfaces with no server answer to hand, such as a thread's - * linked pull requests, offer actions from this. + * declares in `capabilities.actions`. */ readonly changeRequestActions: ReadonlySet; - /** A shell command that checks the change request out, or null when it cannot be built. */ readonly checkoutCommand: (changeRequest: ChangeRequestCheckoutInput) => string | null; - /** A comment author's profile page, or null where the host has none clients can link. */ readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null; - /** - * The repository URL that `#123` and commit SHAs in change request text link under, using - * GitHub's `/issues/` and `/commit/` routes, or null where the host does not route them so. - */ readonly referenceAutolinkRepositoryUrl: (repositoryUrl: string) => string | null; - /** Whether a review with this verdict must carry a summary, beyond what every host asks. */ readonly reviewSummaryRequired: (verdict: PullRequestReviewVerdict) => boolean; - /** - * The change request a pasted command names, such as `gh pr checkout 42`: its argument, which - * is a number or a URL. Null for anything that is not this host's checkout command. - */ readonly checkoutCommandArgument: (input: string) => string | null; - /** - * Whether a pasted URL is one of this host's change request URLs. Stricter than - * `isChangeRequestUrl`, which only tells hosts apart, because this decides what a reference - * field accepts. - */ readonly isChangeRequestReference: (url: string) => boolean; - /** - * Whether a change request link names this project's own repository. The link's host is where - * the repository is addressed, `link.authority` the web host and port where they differ. - */ readonly isChangeRequestInRepository: ( identity: ChangeRequestProjectIdentity, link: ChangeRequestLink, ) => boolean; - /** - * Whether this project can read a change request on the link's host on the link's behalf, for a - * repository nobody has checked out. False where the host's reads are scoped to the checkout. - */ readonly canReadChangeRequestOnHost: ( identity: ChangeRequestProjectIdentity, link: ChangeRequestLink, ) => boolean; - /** - * The host a change request URL addresses its repository below, as a `PullRequestRef.host` - * names it: the hostname, or the host and port for servers addressed by both. - */ readonly changeRequestUrlHost: (url: URL) => string; - /** - * The host a reference with none names, read from the checkout alone, or null where only the - * server can tell, such as when an SSH remote resolves to a different web host. - */ readonly checkoutChangeRequestHost: (identity: ChangeRequestProjectIdentity) => string | null; - /** Whether a change request URL has this host's path shape, e.g. GitLab's `/-/merge_requests/`. */ readonly isChangeRequestUrl: (url: string) => boolean; - /** - * What the environment saves for this host under `settings.sourceControlHosts[kind]`, rendered - * as a form in Source Control settings and read by the host's server package. Fields carry - * `providerSettingsForm` annotations, as agent provider settings do; a `secretSetting` is - * kept in the server's secret store. Omitted for a host with nothing to configure. - */ readonly settings?: SourceControlHostSettingsSchema; } -/** A host's settings struct. Its fields are strings, or a secret string per server host. */ export type SourceControlHostSettingsSchema = { readonly fields: Readonly>; } & Schema.Decoder; -/** - * Whether a URL's path is a change request at `route`, such as `/pull/`, followed by its number. - * Matching the parsed path keeps a query or fragment that mentions another host's route out. - */ export function isChangeRequestPath(url: string, route: string): boolean { try { const path = new URL(url).pathname; @@ -175,11 +91,6 @@ export function defineSourceControlClient false, }; -/** The host definitions a client loaded. */ export interface SourceControlClientRegistry { readonly definitions: ReadonlyArray; - /** - * The definition for a kind. No kind at all, before a repository reports its host, reads as - * the first definition; a kind this client lacks, `unknown` included, reads as - * `UNKNOWN_SOURCE_CONTROL_CLIENT`. - */ readonly get: (kind: string | null | undefined) => SourceControlClientDefinition; - /** The definition for a kind, or `undefined` for one this client lacks. */ readonly find: (kind: string) => SourceControlClientDefinition | undefined; - /** The definition whose public instance is `hostname`, if any. */ readonly findByPublicHost: (hostname: string) => SourceControlClientDefinition | undefined; - /** The definition whose change request path shape `url` has, if any. */ readonly findByChangeRequestUrl: (url: string) => SourceControlClientDefinition | undefined; - /** The name of the host a change request URL is on, for copy about that change request. */ readonly hostLabelForChangeRequestUrl: (url: string) => string; } @@ -307,10 +201,6 @@ export function makeSourceControlClientRegistry( }; } -/** - * A host's saved settings decoded with its schema. A blob that no longer decodes, such as one a - * newer build wrote, reads as the schema's defaults rather than failing the host. - */ export function readSourceControlHostSettings( schema: S, saved: unknown, diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 85f5d1893ffb..882bc73c1153 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -39,14 +39,6 @@ import type { } from "@t3tools/contracts"; import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts"; -/** - * The one failure shape every provider reports, so the service can decide what a failure means - * without knowing which CLI or API produced it. - * - * `reason` is the part the service acts on: a missing or unauthenticated tool disables the - * provider for the whole workspace, a rate limit pauses its host, and anything else is specific - * to the request. - */ export class PullRequestProviderError extends Schema.TaggedError()( "PullRequestProviderError", { @@ -69,10 +61,6 @@ export class PullRequestProviderError extends Schema.TaggedError; readonly labels: ReadonlyArray; - /** Absent from a host that does not summarise its reviews, which is every host but GitHub. */ readonly reviewDecision?: PullRequestReviewDecision | null | undefined; - /** Absent from a host that reports no check rollup on its listings. */ readonly checksState?: PullRequestChecksState | null | undefined; } -/** The fields needed to keep a linked thread's pull request status live. */ export interface ProviderChangeRequestSummary { readonly number: number; readonly title: string; @@ -119,12 +102,10 @@ export interface ProviderChangeRequestSummary { readonly headBranch: string; readonly baseBranch: string; readonly state: PullRequestState; - /** Present when the host says an open pull request is still a draft. */ readonly isDraft?: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: string; - /** Overview fields, present where the host's single read returns them at no extra cost. */ readonly author?: PullRequestActor | null | undefined; readonly additions?: number | undefined; readonly deletions?: number | undefined; @@ -133,13 +114,11 @@ export interface ProviderChangeRequestSummary { readonly checksState?: PullRequestChecksState | null | undefined; readonly mergeability?: PullRequestMergeability | undefined; /** - * The host-native stack the pull request sits in, from the same read. Null when the host says - * it is in none; absent when the read did not ask. + * Null when the host says it is in none; absent when the read did not ask. */ readonly stack?: PullRequestStackMembership | null | undefined; } -/** One layer of a host-native stack, bottom to top order is the array's. */ export interface ProviderChangeRequestStackLayer { readonly title?: string; readonly isDraft?: boolean; @@ -149,11 +128,6 @@ export interface ProviderChangeRequestStackLayer { readonly state: PullRequestState; } -/** - * A host-native stack: an ordered set of change requests the host itself merges and retargets as - * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay - * ignorant of which host said so. - */ export interface ProviderChangeRequestStack { readonly id: string; readonly number: number; @@ -164,62 +138,34 @@ export interface ProviderChangeRequestStack { export interface ProviderChangeRequestPage { readonly items: ReadonlyArray; - /** True when the host has more rows than the page size asked for. */ readonly truncated: boolean; /** - * Optional count-based cursor advance. Most hosts advance by the rows delivered after local - * de-duplication; an offset-paged host may need to count malformed raw rows it consumed too. + * Most hosts advance by the rows delivered after local de-duplication; an offset-paged host + * may need to count malformed raw rows it consumed too. */ readonly cursorAdvance?: number; - /** - * This page can be carried on from, so the service may hand the caller a cursor for it. False - * where the host answered in an order a cursor means nothing in, which leaves a larger `limit` - * as the only way to the rest β€” what every listing did before there were cursors. - */ readonly continues: boolean; } -/** - * Where a repository's next slice starts, as the provider that has to ask for it needs it. Built - * by the service out of the slice it just handed over, so the boundary that decides whether a row - * arrives twice or not at all is decided in one place rather than in four. - */ export interface ProviderListCursor { /** - * The instant of the oldest row already handed over, checked against a timestamp's shape before - * it gets here because it goes into a host's own filter. Asked for inclusively: several rows - * share one instant often enough β€” a bot that touches eight change requests writes one timestamp - * on all eight β€” and asking for strictly older would lose whichever of them the slice ended - * before. The service drops the ones it has already sent. + * Asked for inclusively: several rows share one instant often enough β€” a bot that touches eight + * change requests writes one timestamp on all eight β€” and asking for strictly older would lose + * whichever of them the slice ended before. */ readonly updatedBefore: string; - /** - * How many provider rows this repository has consumed so far, for a host that carries on by - * counting rather than by date. Usually this is the number handed over; malformed raw rows may - * count too when the provider reports a `cursorAdvance`. - */ readonly delivered: number; } -/** One repository's row inside an answer that spans several of them. */ export interface ProviderBatchedChangeRequest extends ProviderChangeRequest { - /** Provider-native identity, exactly as it was asked for, so the caller can file the row. */ readonly repository: string; } -/** - * One slice of a host read across several repositories at once, newest update first across all - * of them. There is no per-repository page here because the host was asked one question: the - * caller splits the rows by `repository` and works out where each of them carries on from the - * oldest row in the slice, which every repository the slice covers is now read up to. - */ export interface ProviderBatchedChangeRequestPage { readonly items: ReadonlyArray; - /** True when the host has more rows than the slice asked for, for any of the repositories. */ readonly truncated: boolean; } -/** The line counts for one change request, which a listing may leave for a second read. */ export interface ProviderChangeRequestStat { readonly repository: string; readonly number: number; @@ -228,7 +174,6 @@ export interface ProviderChangeRequestStat { } export interface ProviderChangeRequestDetail extends ProviderChangeRequest { - /** The head commit, where the host's detail read reports it. */ readonly headSha?: string | null; readonly body: string; readonly changedFiles: number; @@ -238,35 +183,22 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest { readonly checks: ReadonlyArray; readonly mergeCapabilities: PullRequestMergeCapabilities; readonly viewerPermissions: PullRequestViewerPermissions; - /** Absent from a host that cannot compare the branch with its base, which is most of them. */ readonly baseComparison?: PullRequestBaseComparison; readonly behindBy?: number; - /** Absent from a host that does not report whether it is armed to merge this on its own. */ readonly autoMergeEnabled?: boolean; - /** The strategy stored with an armed auto-merge, where the host reports it. */ readonly autoMergeMethod?: PullRequestMergeMethod; - /** Workflow runs on this head commit that still need a maintainer's approval. */ readonly workflowApprovalsRequired?: number; } -/** The conversation-shaped half of a detail, loaded after the core can already render. */ export interface ProviderChangeRequestActivity { - /** An optional richer actor, e.g. after GitHub's GraphQL read supplies an avatar. */ readonly author?: PullRequestActor | null; - /** Optional because most hosts already report their reviewer list in the core detail. */ readonly reviewers?: ReadonlyArray; readonly comments: ReadonlyArray; - /** - * The host's own count of the conversation, which a bounded read can fall short of. A host - * that reports no count of its own answers with what it handed over, which is the same number - * once the read went to the end. - */ readonly commentCount: number; readonly commentsTruncated: boolean; readonly reviewThreadsTruncated?: boolean; readonly reviewThreads: ReadonlyArray; readonly commits: ReadonlyArray; - /** The change request's own reactions, from a host that has them. */ readonly reactions?: ReadonlyArray; } @@ -275,7 +207,6 @@ export interface ProviderDiffSlice { /** Something in this slice could not be shown, as opposed to there being more slices. */ readonly truncated: boolean; readonly nextCursor: string | null; - /** The host's own counts for the files whose hunks it withheld from this slice. */ readonly omittedFileStats?: ReadonlyArray; } @@ -291,42 +222,26 @@ export interface ProviderFilesViewed { } /** - * What version each of the asked-for files is at, on the change request's head. Opaque strings, - * compared only against one another, where the empty string is the answer for a file the change - * request deletes rather than a gap. A path is absent only where the read could not say, so a - * provider converts its host's own absences on the way here. - * - * The whole path a version travels, and the three senses of null along it, are in - * `docs/internals/pull-request-file-revisions.md`. + * Opaque strings, compared only against one another, where the empty string is the answer for a + * file the change request deletes rather than a gap. A path is absent only where the read could + * not say, so a provider converts its host's own absences on the way here. */ export interface ProviderFileRevisions { readonly revisions: ReadonlyMap; /** - * Whether these are every file the change request carries rather than only the paths asked - * about. A host with no per-file version reads the whole change to answer for one file, and - * saying so is what keeps the next tick, naming a path nothing asked about before, from making - * it read the whole change again. False for a read cut short, which cannot speak past the cut. + * A host with no per-file version reads the whole change to answer for one file, and saying so + * is what keeps the next tick, naming a path nothing asked about before, from making it read + * the whole change again. False for a read cut short, which cannot speak past the cut. */ readonly complete?: boolean; } export interface ProviderRepositoryRef { readonly cwd: string; - /** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */ readonly repository: string; - /** - * The host it lives on, which `repository` deliberately leaves out β€” the same `owner/repo` - * exists on github.com and on a GitHub Enterprise install, and only the caller knows which - * one a project's remote points at. - */ readonly host: string; } -/** - * One host's change requests. Implementations own their own tool and JSON shapes and hand back - * the neutral types above; anything a host cannot do is declared in `capabilities` rather than - * failing at call time. - */ export interface PullRequestProviderApi { readonly withVerifiedCredential?: ( input: { readonly cwd: string; readonly host: string }, @@ -345,28 +260,16 @@ export interface PullRequestProviderApi { >; readonly kind: SourceControlProviderKind; readonly capabilities: PullRequestCapabilities; - /** - * Rewrites the message a merge will use, for a host that lets the merge carry custom text. - * Absent means the host always writes its own message, so nothing is read to decide on one. - * Today's only rewrite strips agent credits (`mergeMessage.removeAgentCredits`) when the - * project asks for it. - */ readonly mergeMessageRewrite?: (message: string) => string; /** - * The key a checkout's repository is known by on this host, where `owner/name` is not enough. * Absent means `owner/name` on the host identifies it, and any checkout on the host can serve a * change request in another repository there. * * A host that resolves it (Azure DevOps: `dev.azure.com/org/project/_git/repo`) derives its - * organization from the checkout, so only a checkout whose key matches the reference serves - * it. The key also names the repository in list cursors and on routing. + * organization from the checkout, so only a checkout whose key matches the reference serves it. */ - readonly repositoryKey?: (input: { - /** The checkout's canonical key, as its remote names it. */ - readonly canonicalKey: string; - }) => string; + readonly repositoryKey?: (input: { readonly canonicalKey: string }) => string; - /** The signed-in account, which is what involvement filtering compares against. */ readonly getViewer: (input: { readonly cwd: string; readonly host?: string; @@ -379,16 +282,11 @@ export interface PullRequestProviderApi { readonly viewer: string; readonly limit: number; /** - * Free text to narrow the listing by, as the host understands it. A host with no text - * filter of its own ignores it and answers with the page it would have answered with - * anyway β€” the caller narrows what it gets, so an unfiltered page is a wider answer - * rather than a wrong one. + * A host with no text filter of its own ignores it and answers with the page it + * would have answered with anyway β€” the caller narrows what it gets, so an + * unfiltered page is a wider answer rather than a wrong one. */ readonly query?: string | undefined; - /** - * Where to carry on from, rather than reading this repository from its newest row. Absent - * asks for the first slice, which is every listing that has not been continued. - */ readonly cursor?: ProviderListCursor | undefined; /** * Further narrowings, which a host applies as far as it can and ignores the rest of β€” @@ -400,21 +298,12 @@ export interface PullRequestProviderApi { ) => Effect.Effect; /** - * The same listing for a whole host in one request, for a host that has a search across - * repositories. Optional: three of the four hosts here have no such API, and breaking the port - * for them to spare GitHub a fan-out would be paying for the fix with everyone else's clarity. - * The caller falls back to `listChangeRequests` per repository where this is absent, and where - * it fails. - * * `limit` is the whole slice rather than a size per repository, because that is the shape of - * the answer: the newest `limit` rows across every repository named, which is exactly the rows - * a page ordered by update shows. - * - * `cursor` is one boundary for all of them, so a caller with repositories standing at different - * boundaries asks in groups rather than in one call. + * the answer: the newest `limit` rows across every repository named, which is exactly the + * rows a page ordered by update shows. `cursor` is one boundary for all of them, so a caller + * with repositories standing at different boundaries asks in groups rather than in one call. */ readonly listChangeRequestsAcross?: (input: { - /** Any checkout on the host, which is what the tool is run in. */ readonly cwd: string; readonly host: string; readonly repositories: ReadonlyArray; @@ -427,11 +316,6 @@ export interface PullRequestProviderApi { readonly filters?: PullRequestListFilters | undefined; }) => Effect.Effect; - /** - * The line counts for rows a listing has already handed over. Only implemented by a provider - * whose listing leaves them out β€” for everyone else the numbers arrived with the row, and the - * caller has nothing to ask for. - */ readonly listChangeRequestStats?: (input: { readonly cwd: string; readonly host: string; @@ -449,7 +333,6 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; - /** Hosts without a narrow read use their existing detail response for hover cards. */ readonly getChangeRequestPreview?: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect< @@ -457,37 +340,26 @@ export interface PullRequestProviderApi { PullRequestProviderError >; - /** - * The cheap live fields used by linked threads. Optional because a provider without a narrow - * endpoint can fall back to its full detail read at the service boundary. - */ readonly getChangeRequestSummary?: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; /** - * A cheap fingerprint of what a pull request watch reports, so it reads the change request in - * full only when this moves. Null when the host gave no answer for it. Optional: a host - * without one has its watched change requests read in full on every pass. + * Null when the host gave no answer for it. Optional: a host without one has + * its watched change requests read in full on every pass. */ readonly getChangeRequestWatchFingerprint?: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; - /** - * The host-native stack a change request belongs to, or null when it is not stacked. Optional - * because most hosts have no such object; the service derives chains from base branches there. - */ readonly getChangeRequestStack?: ( input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number }, ) => Effect.Effect; - /** Comments, line threads, and commits, kept off the critical path for the core detail. */ readonly getChangeRequestActivity: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; - /** One explicit page after a reader asks to continue an unfinished review thread. */ readonly getReviewThreadComments?: ( input: ProviderRepositoryRef & { readonly number: number; @@ -497,40 +369,26 @@ export interface PullRequestProviderApi { ) => Effect.Effect; /** - * The same answer `getChangeRequest` carries, on its own. Asked before anything is written, so - * a request that reached the server without going past the page is refused by what the host - * says rather than by what the client claimed β€” and asked freshly, because access granted or - * taken away since the page loaded is exactly the case this guards. - * - * Implementations read the cheapest thing that answers it, which for a host with nothing to say - * is no request at all. + * Asked before anything is written, so a request that reached the server without going past + * the page is refused by what the host says rather than by what the client claimed β€” and + * asked freshly, because access granted or taken away since the page loaded is exactly the + * case this guards. */ readonly getViewerPermissions: ( input: ProviderRepositoryRef & { readonly number: number; - /** Skip branch comparison when checking permission for an unrelated operation. */ readonly includeUpdateBranch?: boolean; }, ) => Effect.Effect; - /** - * One slice of the patch. Only called when `capabilities.diff` is true. A provider that can - * serve the whole diff at once answers with `nextCursor: null` and is done; one that pages - * hands back whatever it needs to find the next slice. - */ readonly getDiff: ( input: ProviderRepositoryRef & { readonly number: number; readonly cursor?: string | undefined; - /** One commit's own changes, rather than everything the change request carries. */ readonly commit?: string | undefined; }, ) => Effect.Effect; - /** - * Full files at the exact revisions the host used for its patch. Optional where the provider - * exposes no diff at all; the service refuses expansion there just as it refuses the patch. - */ readonly getDiffFileContents?: ( input: ProviderRepositoryRef & { readonly number: number; @@ -541,18 +399,13 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** - * Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is - * `"host"`, and read apart from the patch: this moves with every press rather than every push. - */ readonly getFilesViewed?: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; /** - * Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`. - * A host with no bulk form is still owed one round trip for the batch rather than one per file, - * since the point of gathering presses is that the host is asked once. + * A host with no bulk form is still owed one round trip for the batch rather than one per + * file, since the point of gathering presses is that the host is asked once. */ readonly setFilesViewed?: ( input: ProviderRepositoryRef & { @@ -562,11 +415,9 @@ export interface PullRequestProviderApi { ) => Effect.Effect; /** - * What version the head has of each of these files. Required of a host whose - * `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself: - * the marks live here, but only the host can say whether what a reader cleared last week is - * still what is in front of them. Asked for the marked paths alone, so the cost follows how - * much of the change request has been read rather than how large it is. + * Required of a host whose `capabilities.viewedFiles` is `"environment"`, and unused by one + * that keeps the marks itself: the marks live here, but only the host can say whether what a + * reader cleared last week is still what is in front of them. */ readonly getFileRevisions?: ( input: ProviderRepositoryRef & { @@ -581,19 +432,16 @@ export interface PullRequestProviderApi { readonly action: PullRequestAction; readonly stackNumber?: number; readonly expectedStackHeads?: ReadonlyArray; - /** Apply `mergeMessageRewrite` to the merge message; never sent to a host without one. */ readonly removeAgentCreditsOnMerge?: boolean; - /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ readonly mergeMethod?: PullRequestMergeMethod; - /** Only meaningful for `update-branch`; absent takes the host's own default. */ readonly updateMethod?: PullRequestUpdateMethod; }, ) => Effect.Effect; /** - * Rewrites the change request's own words. Only called when `capabilities.edit.changeRequest` - * is true, and never with both fields absent β€” the caller refuses that before it gets here, - * because a host asked to change nothing answers differently on each of them. + * Only called when `capabilities.edit.changeRequest` is true, and never with both fields + * absent β€” the caller refuses that before it gets here, because a host asked to change + * nothing answers differently on each of them. */ readonly updateChangeRequest?: ( input: ProviderRepositoryRef & { @@ -608,9 +456,6 @@ export interface PullRequestProviderApi { ) => Effect.Effect; /** - * Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is - * true, with an id exactly as the conversation carried it. - * * Whether this remark is the reader's to rewrite is the host's own answer: no read here can * settle it, since access can be taken away between the conversation being read and the * rewrite being sent, and a host refuses a stranger's remark with a sentence saying so. @@ -624,11 +469,6 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** - * Sends a whole review at once. Only called for a verdict the host declared in - * `capabilities.review.verdicts`, and with line comments only where it declared - * `inlineComment`. - */ readonly submitReview: ( input: ProviderRepositoryRef & { readonly number: number; @@ -639,25 +479,14 @@ export interface PullRequestProviderApi { ) => Effect.Effect; /** - * The people this viewer may ask for a review, with whoever has already been asked marked as - * such. Only called when `capabilities.reviewers.listCandidates` is true. - * - * The author is left out by each provider rather than by the caller, because only the provider - * knows how the host spells the same person in a candidate list and on a pull request. + * The author is left out by each provider rather than by the caller, because only the + * provider knows how the host spells the same person in a candidate list and on a pull + * request. */ readonly listReviewerCandidates: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; - /** - * Asks for a review, or takes the request back. Only called when - * `capabilities.reviewers.request` is true. - * - * One call for both directions, because that is what every host does with them: GitHub posts and - * deletes the same collection, and GitLab and Bitbucket write the whole reviewer set either way. - * Asking again somebody who has already reviewed is a request like any other β€” which is how a - * re-request is made. - */ readonly setReviewerRequest: ( input: ProviderRepositoryRef & { readonly number: number; @@ -669,15 +498,10 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** - * The repository's labels, with the ones already on the change request marked. Present with - * `setLabels` only where `capabilities.labels` is true; the service refuses both without it. - */ readonly listLabelCandidates?: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; - /** Puts labels on the change request, or takes them off. One call for both directions. */ readonly setLabels?: ( input: ProviderRepositoryRef & { readonly number: number; @@ -686,7 +510,6 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** Only called when `capabilities.review.reply` is true. */ readonly replyToThread: ( input: ProviderRepositoryRef & { readonly number: number; @@ -696,11 +519,8 @@ export interface PullRequestProviderApi { ) => Effect.Effect; /** - * Adds a reaction, or takes it back. Only called when `capabilities.reactions` is true. - * - * `subjectId` is a remark's id as the conversation carried it; absent means the change request - * itself, whose reactions sit on its description. Whatever a host needs to address either of - * them is worked out here, because the id a conversation travels with is the one the reader has. + * `subjectId` is a remark's id as the conversation carried it; absent means the change + * request itself, whose reactions sit on its description. */ readonly setReaction: ( input: ProviderRepositoryRef & { @@ -711,7 +531,6 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** Only called when `capabilities.review.resolve` is true. */ readonly setThreadResolution: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/packages/source-control-core/src/server/SourceControlHost.ts b/packages/source-control-core/src/server/SourceControlHost.ts index 442dc47d0c68..330fea4bbfaa 100644 --- a/packages/source-control-core/src/server/SourceControlHost.ts +++ b/packages/source-control-core/src/server/SourceControlHost.ts @@ -1,17 +1,3 @@ -/** - * SourceControlHost β€” what a source control provider package may ask of the server it runs in. - * - * Providers run inside a T3 server but must not import it. The server provides this one - * service; everything a provider needs from its environment (settings, the process runner) - * goes through it, so a provider package depends only on `@t3tools/source-control-core` and - * its own API and CLI code. HTTP, the filesystem, and paths come from Effect's platform - * services directly. - * - * `git` is the subset of the server's git driver that checking out a change request needs; it - * grows only when a provider needs another operation. - * - * @module source-control-core/server/SourceControlHost - */ import type { GitCommandError, ServerSettings, @@ -25,7 +11,6 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -/** One CLI invocation. The server bounds concurrency, output, and time for every run. */ export interface SourceControlProcessInput { readonly operation: string; readonly command: string; @@ -38,7 +23,6 @@ export interface SourceControlProcessInput { readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; readonly maxOutputBytes?: number; - /** What happens past `maxOutputBytes`: fail the run, or keep the first bytes. */ readonly outputMode?: "error" | "truncate" | undefined; readonly appendTruncationMarker?: boolean; } @@ -49,7 +33,6 @@ export interface SourceControlProcessOutput { readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; - /** Present on real process output; optional so narrow test doubles remain lightweight. */ readonly stdoutInvalidUtf8?: boolean; readonly stderrInvalidUtf8?: boolean; } @@ -78,7 +61,6 @@ export class SourceControlHost extends Context.Service< ) => Effect.Effect; }; readonly git: { - /** Runs `git -C cwd …args` through the server's git driver, with its limits and metrics. */ readonly execute: (input: { readonly operation: string; readonly cwd: string; @@ -91,8 +73,8 @@ export class SourceControlHost extends Context.Service< readonly revision: string; }) => Effect.Effect<{ readonly commitSha: string }, GitCommandError>; /** - * Lists the remotes of the repository at `cwd`. The outer effect resolves the repository - * and fails when the server cannot drive one there; the inner one lists its remotes. + * The outer effect resolves the repository and fails when the server cannot drive + * one there; the inner one lists its remotes. */ readonly remotes: ( cwd: string, @@ -102,7 +84,6 @@ export class SourceControlHost extends Context.Service< key: string, ) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; - /** Adds a remote for `url` unless one exists, and returns the name it is under. */ readonly ensureRemote: (input: { readonly cwd: string; readonly preferredName: string; diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts index 44d4cf4e95c5..e149fcd202a2 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -27,7 +27,6 @@ export interface SourceControlProviderContext { readonly provider: SourceControlProviderInfo; readonly remoteName: string; readonly remoteUrl: string; - /** An explicit web authority can disambiguate Forgejo logins sharing an SSH alias. */ readonly requestedHost?: string; } @@ -39,11 +38,6 @@ export interface SourceControlRefSelector { const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; -/** - * Sanitizes user-provided source-control identifiers before attaching them to - * contract errors. This is intentionally narrower than request validation: it - * only strips URL secrets and bounds diagnostic values sent over transport. - */ export function transportSafeSourceControlErrorValue(value: string): string { let printable = ""; for (const character of value) { @@ -60,9 +54,7 @@ export function transportSafeSourceControlErrorValue(value: string): string { url.search = ""; url.hash = ""; safe = url.toString(); - } catch { - // Plain repository and change-request identifiers are not URLs. - } + } catch {} return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); } @@ -94,7 +86,6 @@ export function sourceControlRefFromInput(input: { return input.source ?? parseSourceControlOwnerRef(input.headSelector); } -/** The repository path a remote URL names (`owner/name`, or deeper for nested groups). */ export function repositoryPathFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { @@ -114,19 +105,13 @@ export class SourceControlProvider extends Context.Service< { readonly kind: SourceControlProviderKind; /** - * How to look up a branch's change requests: which head selectors to ask about, and how many - * results to read per selector. The caller checks the owner against what comes back, so a - * host that cannot search `owner:branch` can drop those selectors without losing anything. - * Absent asks about every selector, reading 1 for the open lookup and 20 for any state. + * The caller checks the owner against what comes back, so a host that cannot search + * `owner:branch` can drop those selectors without losing anything. */ readonly headBranchProbe?: (input: { readonly headSelectors: ReadonlyArray; readonly state: "open" | "all"; }) => { readonly headSelectors: ReadonlyArray; readonly limit: number }; - /** - * The repository's change request template at `treeish`, for change request content - * generation to follow. Absent means the host has no template convention. - */ readonly readChangeRequestTemplate?: (input: { readonly cwd: string; readonly treeish: string; @@ -136,11 +121,6 @@ export class SourceControlProvider extends Context.Service< * more than that (Forgejo's HTTP installation mount). Absent means the whole path. */ readonly repositoryNameFromRemoteUrl?: (url: string) => string | null; - /** - * Fills in what a repository identity read from git cannot know, such as the browser URL of - * a host the remote URL does not name. `resolveContext` asks the registry which host (and - * base URL) serves a remote. Only consulted for identities this host may own. - */ readonly refineRepositoryIdentity?: (input: { readonly identity: RepositoryIdentity; readonly resolveContext: (input: { @@ -148,7 +128,6 @@ export class SourceControlProvider extends Context.Service< readonly context: SourceControlProviderContext; }) => Effect.Effect; }) => Effect.Effect; - /** Optional capability for issue and change-request subjects. */ readonly resolveLink?: ResolveSourceControlLink; readonly listChangeRequests: (input: { readonly cwd: string; diff --git a/packages/source-control-core/src/server/SourceControlRateLimit.ts b/packages/source-control-core/src/server/SourceControlRateLimit.ts index 262a975eacf5..82d4d30b44d2 100644 --- a/packages/source-control-core/src/server/SourceControlRateLimit.ts +++ b/packages/source-control-core/src/server/SourceControlRateLimit.ts @@ -21,9 +21,9 @@ export const CredentialScope = Context.Reference( ); /** - * Set by interactive callers (a user's read or write, not a background sweep). A host may let - * requests made under it spend a reserved quota and go through a rate-limit pause: a user acting - * on a change request should not be refused because a background read exhausted the quota. + * A host may let requests made under it spend a reserved quota and go through a rate-limit + * pause: a user acting on a change request should not be refused because a background read + * exhausted the quota. */ export const Interactive = Context.Reference( "@t3tools/source-control-core/server/SourceControlRateLimit/Interactive", diff --git a/packages/source-control-core/src/server/diffFileRevisions.ts b/packages/source-control-core/src/server/diffFileRevisions.ts index 135a23dfdff6..b2f7e4498d01 100644 --- a/packages/source-control-core/src/server/diffFileRevisions.ts +++ b/packages/source-control-core/src/server/diffFileRevisions.ts @@ -12,7 +12,6 @@ interface Entry { inBody: boolean; } -/** Where a quoted name closes, given git escapes every quote the name itself holds. */ function quotedEnd(rest: string): number { for (let at = 1; at < rest.length; at += 1) { const char = rest.charAt(at); @@ -26,9 +25,9 @@ function quotedEnd(rest: string): number { } /** - * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the - * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with - * that tab escaped, so the first literal tab is never part of what the file is called. + * Git ends the name with a tab when it holds a space, and a name with a tab of its own + * arrives quoted with that tab escaped, so the first literal tab is never part of what the + * file is called. */ function sidePath(rest: string, prefix: string): string | null { const tab = rest.indexOf("\t"); @@ -44,10 +43,9 @@ function headerSide(token: string, prefix: string): string | null { } /** - * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one - * two` can split in more than one place, so the split leaving both sides equal wins; a rename - * (the only case where sides differ) states its names on separate lines instead. A quoted name - * ends at its own closing quote and needs none of that guessing. + * `a/one two b/one two` can split in more than one place, so the split leaving both sides + * equal wins; a rename (the only case where sides differ) states its names on separate lines + * instead. */ function headerPaths(rest: string): readonly [string | null, string | null] { if (rest.startsWith(QUOTE)) { @@ -69,7 +67,6 @@ function headerPaths(rest: string): readonly [string | null, string | null] { return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)]; } -/** The right-hand id of `index .. `. */ function headRevision(rest: string): string | null { const gap = rest.indexOf(".."); if (gap === -1) return null; @@ -79,12 +76,6 @@ function headRevision(rest: string): string | null { return head.length === 0 ? null : head; } -/** - * What the head has of each file, as the blob ids from a unified patch's `index ..` - * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except - * for a deletion where only the old name exists. An entry with no `index` line (most often one - * Bitbucket excluded by pattern) is left out. - */ export function parseDiffFileRevisions(patch: string): ReadonlyMap { const revisions = new Map(); let entry: Entry | null = null; diff --git a/packages/source-control-core/src/server/discovery.ts b/packages/source-control-core/src/server/discovery.ts index 6a50de973e3d..b03f6a6efa4d 100644 --- a/packages/source-control-core/src/server/discovery.ts +++ b/packages/source-control-core/src/server/discovery.ts @@ -1,8 +1,3 @@ -/** - * Discovery specs: how each provider reports its CLI or API, and the shared probe that runs them. - * - * @module source-control-core/server/discovery - */ import * as NodeUtil from "node:util"; import { VcsProcessSpawnError, @@ -82,8 +77,6 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { readonly refineUnknownRemote: NonNullable; }; -// Most provider CLIs answer `--version` in well under a second, so a short budget keeps -// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs. const DEFAULT_PROBE_TIMEOUT_MS = 5_000; function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { @@ -321,7 +314,6 @@ export function isMissingExecutable(error: unknown): boolean { ); } -/** The CLI ran, but its output does not say whether it holds the remote's host. */ class UnknownRemoteUnanswered extends Schema.TaggedError()( "UnknownRemoteUnanswered", {}, @@ -329,7 +321,6 @@ class UnknownRemoteUnanswered extends Schema.TaggedError; diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index 3a227d0928d8..aef99bf5e4f9 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -1,16 +1,3 @@ -/** - * SourceControlDriver β€” what a source control provider package hands the server. - * - * One driver per host kind. The server lists its drivers once and both registries (repository - * operations and pull requests) iterate that list, so adding a host means writing its package - * and adding its driver to the list. - * - * `make` yields what it needs from the environment: the `SourceControlHost` port, Effect - * platform services, and the package's own services, which the server provides from the - * package's layers. Its `R` channel is the full list. - * - * @module source-control-core/server/driver - */ import type { SourceControlProviderKind } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; @@ -30,7 +17,6 @@ export interface SourceControlDriver { readonly make: Effect.Effect; } -/** Declares a driver with its `R` inferred from `make`. */ export const defineSourceControlDriver = ( driver: SourceControlDriver, ): SourceControlDriver => driver; diff --git a/packages/source-control-core/src/server/mergeMessage.ts b/packages/source-control-core/src/server/mergeMessage.ts index 20083b0d8228..8c95ddb44148 100644 --- a/packages/source-control-core/src/server/mergeMessage.ts +++ b/packages/source-control-core/src/server/mergeMessage.ts @@ -11,7 +11,6 @@ const AGENT_EMAILS = new Set([ const GENERATED_BY_AGENT = /^(?:πŸ€–\s*)?generated (?:with|by)\s+(?:claude(?: code)?|codex|cursor|github copilot|copilot|opencode|grok|antigravity)[.!]?$/i; -/** Remove standalone agent credits while keeping human signatures and quoted examples. */ export function removeAgentCredits(message: string): string { let fence: string | undefined; let removed = false; diff --git a/packages/source-control-core/src/server/pullRequestChecks.ts b/packages/source-control-core/src/server/pullRequestChecks.ts index 491b01803231..ed5b571f9423 100644 --- a/packages/source-control-core/src/server/pullRequestChecks.ts +++ b/packages/source-control-core/src/server/pullRequestChecks.ts @@ -1,31 +1,16 @@ import type { PullRequestCheck } from "@t3tools/contracts"; -/** ISO-8601 timestamps in UTC compare correctly as plain text, which is all the ordering needs. */ function isAtLeastAsNew(candidate: string | null, kept: string | null): boolean { if (candidate === null) return kept === null; return kept === null || candidate >= kept; } /** - * One row per check rather than one per run of it. - * * A host's rollup is a list of runs, not a list of checks: while a workflow is being re-run β€” or * while a second run of it is already live β€” the same check arrives twice, and both copies reach * the reader as what looks like a duplicate. Nothing in a check carries an id, so the name is what * identifies it, qualified by the workflow it belongs to since two workflows are free to name a * job the same thing. - * - * Within a group the newest run is the one worth showing: a re-run is the answer that replaces the - * one before it, and a run that never said when it happened loses to one that did. A tie goes to - * whichever came last, because a host lists a re-run after the run it repeats. - * - * The order is the host's own, held at the place each check first appeared, so a re-run landing - * mid-read replaces a row where it stands instead of reshuffling the list under the reader. - * - * Two checks that survive under the same name are then genuinely different ones, since they came - * from different workflows β€” each is shown as `workflow / name`, the way GitHub writes it itself. - * A survivor whose workflow the host did not name keeps its bare name rather than being qualified - * with nothing. */ export function dedupeChecks( entries: ReadonlyArray<{ @@ -38,7 +23,6 @@ export function dedupeChecks( for (const entry of entries) { const key = JSON.stringify([entry.workflowName ?? "", entry.check.name]); const kept = newestByCheck.get(key); - // Re-setting a key a Map already holds keeps its first position, which is the order wanted. if (kept === undefined || isAtLeastAsNew(entry.at, kept.at)) newestByCheck.set(key, entry); } const survivors = [...newestByCheck.values()]; diff --git a/packages/source-control-forgejo/src/client/definition.ts b/packages/source-control-forgejo/src/client/definition.ts index 7504e32e6a78..f9aa6df2a1d2 100644 --- a/packages/source-control-forgejo/src/client/definition.ts +++ b/packages/source-control-forgejo/src/client/definition.ts @@ -1,8 +1,3 @@ -/** - * Forgejo's client definition, which covers Gitea as well. Browser- and React Native-safe. - * - * @module source-control-forgejo/client/definition - */ import { SourceControlProviderKind } from "@t3tools/contracts"; import { type ChangeRequestProjectIdentity, @@ -59,9 +54,7 @@ export const definition = defineSourceControlClient({ ) { return `${remote.origin}/${repository}/pulls/${number}`; } - } catch { - // Not an HTTP remote. - } + } catch {} return `https://${host}/${repository}/pulls/${number}`; }, changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), @@ -76,7 +69,6 @@ export const definition = defineSourceControlClient({ reviewSummaryRequired: (verdict) => verdict === "request-changes", checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), - // Forgejo servers are addressed with their port, so two on one hostname stay apart. changeRequestUrlHost: (url) => url.host, checkoutChangeRequestHost: () => null, isChangeRequestInRepository: (identity, link) => { diff --git a/packages/source-control-forgejo/src/server/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts index 90501a4591ca..81e96f61fa9f 100644 --- a/packages/source-control-forgejo/src/server/ForgejoCli.ts +++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts @@ -63,7 +63,6 @@ export const ForgejoKeysSchema = Schema.Struct({ const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema); -/** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */ function forgejoKeysPaths(input: { readonly platform: string; readonly home: string; diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index f0a689db2aed..b04f6bd1f3b3 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -22,7 +22,6 @@ const processOutput = TestSourceControlHost.processOutput; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -/** The JSON body a request sends. Forgejo encodes bodies as UTF-8 text. */ const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => { assert.strictEqual(request.body._tag, "Uint8Array"); return request.body._tag === "Uint8Array" @@ -269,7 +268,6 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the remoteUrl: "", }); - // An installation mount in the HTTP path is the login's, not the repository's. assert.deepStrictEqual( yield* refine!({ identity: identity("http://forge.test:3000/git/team/repo.git"), @@ -281,7 +279,6 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the webUrl: "http://forge.test:3000/git/team/repo", }, ); - // An SSH alias keeps its whole path. assert.strictEqual( (yield* refine!({ identity: identity("ssh://git@ssh.forge.test/team/repo.git"), @@ -289,7 +286,6 @@ it.effect("gives Forgejo identities the browser URL of the login that serves the })).webUrl, "http://forge.test:3000/git/team/repo", ); - // Another host's identity, or a remote no Forgejo login serves, is left alone. const github = identity("https://github.com/team/repo.git", "github"); assert.strictEqual( yield* refine!({ identity: github, resolveContext: () => Effect.die("not asked") }), diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index 2c69f5776ebb..9bf81ee6c16d 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -120,7 +120,6 @@ export const makeDiscovery = Effect.gen(function* () { }), }, }); - // A configured fj account owns its requests, including authentication errors. if (fj.status === "available" && (login || Result.isFailure(credentials))) { if (login && fj.auth.status === "authenticated" && cli.getAccount) { const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result); @@ -151,7 +150,6 @@ export const makeDiscovery = Effect.gen(function* () { }) { const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); if (!remote) return null; - // Both CLIs claim Forgejo, so one failing does not stop the other from claiming the host. let failure: ForgejoCli.ForgejoCliError | undefined; for (const command of ["fj", "tea"] as const) { const logins = yield* listLogins({ @@ -159,7 +157,6 @@ export const makeDiscovery = Effect.gen(function* () { command, remoteUrl: input.context.remoteUrl, }).pipe( - // A CLI that is not installed holds no login. Effect.catchIf( (error) => isMissingExecutable(error.cause), () => Effect.succeed([]), @@ -189,7 +186,6 @@ export const makeDiscovery = Effect.gen(function* () { } satisfies SourceControlManagedCliDiscoverySpec; }); -/** HTTP paths can include an installation mount; Forgejo's API always names owner/repo. */ export function repositoryNameFromRemoteUrl(url: string): string | null { const path = SourceControlProvider.repositoryPathFromRemoteUrl(url); return path === null || !/^https?:\/\//iu.test(url.trim()) diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts index d2fe9ee4e4c3..dc5fa6a69116 100644 --- a/packages/source-control-forgejo/src/server/driver.ts +++ b/packages/source-control-forgejo/src/server/driver.ts @@ -1,10 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -/** - * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through - * `fj` or `tea`. - * - * @module source-control-forgejo/server/driver - */ import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; diff --git a/packages/source-control-gitcafe/src/client/definition.ts b/packages/source-control-gitcafe/src/client/definition.ts index 978fcee1de28..2204cac261c6 100644 --- a/packages/source-control-gitcafe/src/client/definition.ts +++ b/packages/source-control-gitcafe/src/client/definition.ts @@ -1,8 +1,3 @@ -/** - * GitCafe's client definition. Browser- and React Native-safe. - * - * @module source-control-gitcafe/client/definition - */ import { makeProviderSettingsSchema, secretSetting, @@ -23,7 +18,6 @@ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); const KIND = SourceControlProviderKind.make("gitcafe"); -/** A git.cafe token, used before `CAFE_TOKEN` and the `cafe` login. */ export const settings = makeProviderSettingsSchema({ token: TrimmedString.pipe( secretSetting, @@ -45,8 +39,6 @@ export const definition = defineSourceControlClient({ icon: "gitcafe", changeRequest: { shortLabel: "PR", singular: "pull request" }, repositoryPathHint: "owner/repo", - // Left unattributed so a reference with no repository identity behind it gets no checkout - // command guessed from its hostname alone. publicHost: null, publishDescription: "git.cafe", publishHost: () => "git.cafe", @@ -67,7 +59,6 @@ export const definition = defineSourceControlClient({ referenceAutolinkRepositoryUrl: () => null, reviewSummaryRequired: () => false, // `/pulls/` is Forgejo's shape too, so only GitCafe's own hosts claim it. - // `cafe pr checkout` and git.cafe URLs were never accepted in reference fields. checkoutCommandArgument: () => null, isChangeRequestReference: () => false, changeRequestUrlHost: (url) => url.hostname, diff --git a/packages/source-control-gitcafe/src/server/GitCafeApi.ts b/packages/source-control-gitcafe/src/server/GitCafeApi.ts index 949ece637faa..581ae677a287 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeApi.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeApi.ts @@ -15,10 +15,6 @@ import * as GitCafeHosts from "./gitCafeHosts.ts"; const NO_REDIRECT: RequestInit = { redirect: "manual" }; const TIMEOUT = Duration.seconds(30); -/** - * Why a request failed, as far as a caller needs to act on it: a missing CLI or refused token - * disables the provider, a limit pauses the host, and the rest belong to the one request. - */ export const GitCafeApiFailure = Schema.Literals([ "missing-tool", "unauthenticated", @@ -30,7 +26,6 @@ export const GitCafeApiFailure = Schema.Literals([ export class GitCafeApiError extends Schema.TaggedError()("GitCafeApiError", { host: Schema.String, operation: Schema.String, - /** GitCafe's HTTP status; null when no answer arrived. */ status: Schema.NullOr(Schema.Int), reason: GitCafeApiFailure, detail: Schema.String, @@ -45,21 +40,17 @@ export interface GitCafeApiRequest { readonly host: string; readonly operation: string; readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - /** Below `/api`, query string included, e.g. `/repos/owner/repo/pulls/7`. */ readonly path: string; readonly body?: unknown; } -/** GitCafe's REST API on its own hosts, authenticated with the `GitCafeCredentials` token. */ export class GitCafeApi extends Context.Service< GitCafeApi, { - /** The body of a successful answer. */ readonly request: (input: GitCafeApiRequest) => Effect.Effect; } >()("@t3tools/source-control-gitcafe/server/GitCafeApi") {} -/** GitCafe's RFC 7807 error body, e.g. `{"type":"https://cafe.sh/errors/not-found",...}`. */ const decodeProblem = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ @@ -164,7 +155,6 @@ export const make = Effect.gen(function* () { return yield* fail({ status, reason: reasonForStatus(status), - // GitCafe's own explanation is what the user acts on; it is kept short and single-line. detail: boundedProblemText(problem?.detail ?? problem?.title) ?? `GitCafe answered HTTP ${status}.`, }); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts index e54018f30088..f44c81ad1e5f 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.test.ts @@ -1,18 +1,3 @@ -/** - * Pins `./GitCafeCredentials.ts`, a service module pass 2 creates: - * - * - `class GitCafeCredentials extends Context.Service` keyed - * `"@t3tools/source-control-gitcafe/server/GitCafeCredentials"`, with - * `get(host) => Effect` and - * `invalidate(host) => Effect`. - * - `interface GitCafeCredential { host; token: Redacted; source: "env" | "cafe" }`. - * - `Schema.TaggedError`s `GitCafeCliMissingError`, `GitCafeNotSignedInError`, - * `GitCafeCliFailedError` (union `GitCafeCredentialUnavailableError`). - * - `environmentToken(host, env): string | null` β€” `CAFE_TOKEN` only for the host `CAFE_HOST` - * names (git.cafe when unset). - * - `make` / `layer`, yielding `SourceControlHost` (for `process.run`) and - * `HostProcess.Environment` / `HostProcess.WorkingDirectory` from `@t3tools/shared/HostProcess`. - */ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -103,14 +88,12 @@ describe("GitCafeCredentials", () => { assert.strictEqual(Redacted.value(saved.token), "saved-token"); assert.strictEqual(saved.source, "settings"); assert.deepStrictEqual(yield* credentials.cliEnv("git.cafe"), { CAFE_TOKEN: "saved-token" }); - // Staging never receives the production token saved in Settings. assert.deepStrictEqual(yield* credentials.cliEnv("staging.git.cafe"), {}); assert.strictEqual( Redacted.value((yield* credentials.get("staging.git.cafe")).token), "gct_staging", ); - // Removing it applies at once: the environment token takes over again. yield* settings.update({ sourceControlHosts: { gitcafe: { token: "" } } }); assert.strictEqual((yield* credentials.get("git.cafe")).source, "env"); }).pipe(Effect.provide(layer)); @@ -124,7 +107,6 @@ describe("GitCafeCredentials", () => { ); return Effect.gen(function* () { const credentials = yield* GitCafeCredentials.GitCafeCredentials; - // A token for staging is never taken from an environment that targets production. const credential = yield* credentials.get("Staging.Git.Cafe"); assert.strictEqual(Redacted.value(credential.token), "gct_1"); assert.strictEqual(credential.source, "cafe"); @@ -140,7 +122,6 @@ describe("GitCafeCredentials", () => { "get", ]); assert.strictEqual(calls[0]?.stdin, "protocol=https\nhost=staging.git.cafe\n\n"); - // A refused token is asked for again rather than reused. yield* credentials.invalidate("staging.git.cafe"); yield* credentials.get("staging.git.cafe"); assert.strictEqual(calls.length, 2); diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts index 1aeb7b66e969..c8fe6856cd5b 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -14,9 +14,7 @@ import { readSourceControlHostSettings } from "@t3tools/source-control-core/clie import * as GitCafeClient from "../client/definition.ts"; -/** How long a token is reused before `cafe` is asked again, so a re-login applies soon. */ const TOKEN_TTL = Duration.minutes(5); -/** No credential is retried sooner, so a fresh `cafe auth login` takes effect on the next read. */ const MISSING_TTL = Duration.seconds(10); export interface GitCafeCredential { @@ -25,7 +23,6 @@ export interface GitCafeCredential { readonly source: "settings" | "env" | "cafe"; } -/** No `CAFE_TOKEN` for the host, and no `cafe` on PATH to ask. */ export class GitCafeCliMissingError extends Schema.TaggedError()( "GitCafeCliMissingError", { host: Schema.String }, @@ -35,7 +32,6 @@ export class GitCafeCliMissingError extends Schema.TaggedError()( "GitCafeNotSignedInError", { host: Schema.String }, @@ -45,7 +41,6 @@ export class GitCafeNotSignedInError extends Schema.TaggedError()( "GitCafeCliFailedError", { host: Schema.String, cause: Schema.Defect() }, @@ -60,25 +55,18 @@ export type GitCafeCredentialUnavailableError = | GitCafeNotSignedInError | GitCafeCliFailedError; -/** Where GitCafe tokens come from. Callers ask per host and never see how the token was found. */ export class GitCafeCredentials extends Context.Service< GitCafeCredentials, { readonly get: ( host: string, ) => Effect.Effect; - /** Drops the held token after GitCafe refused it, so the next read asks its source again. */ readonly invalidate: (host: string) => Effect.Effect; - /** - * Environment for a `cafe` run against the host: the token saved in Settings as `CAFE_TOKEN`, - * which `cafe` uses before its own login, or nothing when none is saved. - */ readonly cliEnv: (host: string) => Effect.Effect>>; } >()("@t3tools/source-control-gitcafe/server/GitCafeCredentials") {} const normalizeHost = (host: string) => host.trim().toLowerCase(); -/** The host `cafe` and a token saved in Settings target unless told otherwise. */ const DEFAULT_HOST = "git.cafe"; /** @@ -97,7 +85,6 @@ export function environmentToken( return env.CAFE_TOKEN?.trim() || null; } -/** A Git credential `get` answer: `key=value` lines, of which only `password` matters here. */ function credentialPassword(output: string): string | null { for (const line of output.split(/\r?\n/u)) { if (line.startsWith("password=")) return line.slice("password=".length).trim() || null; @@ -145,7 +132,6 @@ export const make = Effect.gen(function* () { ), ); - /** A token saved in Settings, for production git.cafe only; read fresh on every lookup. */ const fromSettings = (host: string) => normalizeHost(host) !== DEFAULT_HOST ? Effect.succeed(null) @@ -170,7 +156,6 @@ export const make = Effect.gen(function* () { const cache = yield* Cache.makeWith(lookup, { capacity: 4, - // A transient failure (a timeout, a locked keyring) is asked again on the next read. timeToLive: (exit) => Exit.isSuccess(exit) ? TOKEN_TTL @@ -182,8 +167,6 @@ export const make = Effect.gen(function* () { }); return GitCafeCredentials.of({ - // A saved token is read fresh, so saving or removing one applies at once; the cache only - // spares asking `cafe` again. get: (host) => fromSettings(host).pipe( Effect.flatMap( diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index 0603dfec80ec..4a3521d5c7bb 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -1,21 +1,3 @@ -/** - * Pins the requests `./GitCafePullRequestProvider.ts` sends for each operation this layer - * supports. Pass 2 creates: - * - * - `./GitCafeApi.ts`: service `GitCafeApi` (key `"@t3tools/source-control-gitcafe/server/GitCafeApi"`) - * with `request({ host, operation, method?, path, body? }) => Effect` - * (the response body), sending `https:///api` with the `GitCafeCredentials` bearer - * token through `HttpClient`; `make` / `layer`. Refuses any host outside `GITCAFE_HOSTS`. - * `GitCafeApiError` carries the HTTP `status` so 401/429 map to unauthenticated/rate-limited. - * - `./GitCafePullRequestProvider.ts`: `make: Effect` - * with kind `"gitcafe"`, implementing list, summary/detail, activity, diff, stack, comment, - * reply, resolve, review submit, synchronous merge, and stack land/restack fenced by - * `expectedStackHeads`. - * - * Every write carries a fresh `requestId`, which GitCafe requires. - * - * Out of scope here (layer 3): review-revision fencing, async outcome polling. - */ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Context from "effect/Context"; @@ -37,19 +19,13 @@ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown interface SentRequest { readonly method: string; - /** The path below `/api`, without its query. */ readonly path: string; readonly query: URLSearchParams; readonly host: string; readonly body: unknown; - /** The `redirect` mode FetchHttpClient would hand to fetch for this request. */ readonly redirect: string | undefined; } -/** - * A GitCafe host answering `"METHOD /path"` (query stripped) from `routes`; anything else is a 404 - * problem document. Every request is recorded in order. - */ function fakeGitCafe( routes: Record unknown)>, options: { readonly status?: number; readonly env?: NodeJS.ProcessEnv } = {}, @@ -85,7 +61,6 @@ function fakeGitCafe( const layer = GitCafeApi.layer.pipe( Layer.provide(GitCafeCredentials.layer), Layer.provide(TestSourceControlHost.layer()), - // An environment token, so no test runs `cafe`. Layer.provide( Layer.succeed(HostProcess.Environment, options.env ?? { CAFE_TOKEN: "env-token" }), ), @@ -150,10 +125,6 @@ const root = { unresolve: false, }, }; -/** - * GitCafe's stack route for pull 7, as it answers: members carry no heads, so each unmerged - * layer's head comes from its own pull. - */ const stack = ( members: ReadonlyArray<{ number: number; headOid: string; state?: string }>, ): Record => ({ @@ -181,7 +152,6 @@ const stack = ( ), }); -/** GitCafe refuses a write without an idempotency key. */ const assertRequestId = (body: unknown) => assert.match( (body as { readonly requestId?: unknown } | undefined)?.requestId as string, @@ -290,7 +260,6 @@ describe("GitCafePullRequestProvider", () => { const provider = yield* GitCafePullRequestProvider.make; const result = yield* provider.getChangeRequestStack!(target); assert.strictEqual(result?.number, 3); - // A merged layer is not fenced on, so its pull is never read. assert.deepStrictEqual( result?.layers.map((layer) => [layer.number, layer.headSha]), [ @@ -376,7 +345,6 @@ describe("GitCafePullRequestProvider", () => { const diff = yield* provider.getDiff(target); assert.include(diff.patch, "-old\n+new\n"); assert.isNull(diff.nextCursor); - // The batched hunk read is a POST, but it writes nothing. assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/diff-files"]); }).pipe(Effect.provide(server.layer)); }); @@ -604,7 +572,6 @@ describe("GitCafePullRequestProvider", () => { it.effect.each(["authored", "reviewing"] as const)( "lists %s unnarrowed when GitCafe can't say who the viewer is", (involvement) => { - // No `filter-options` route: the fake answers 404, which is an ordinary failure. const server = fakeGitCafe({ "GET /repos/owner/repo/pulls": { items: [pull], next: null } }); return Effect.gen(function* () { const provider = yield* GitCafePullRequestProvider.make; @@ -618,7 +585,6 @@ describe("GitCafePullRequestProvider", () => { const listing = server.sent.find((request) => request.path === "/repos/owner/repo/pulls"); assert.isDefined(listing); assert.isNull(listing!.query.get(involvement === "reviewing" ? "reviewers" : "authors")); - // An unnarrowed row says nothing about who was asked to review it. assert.notInclude(page.items[0]?.reviewRequestLogins ?? [], "alice"); }).pipe(Effect.provide(server.layer)); }, @@ -640,7 +606,6 @@ describe("GitCafePullRequestProvider", () => { }, checks: { pending: 0, failing: 0, total: 0 }, }, - // Checks continue on a cursor too; a failure on a later page must not be dropped. [`GET /repos/owner/repo/commits/${headOid}/checks`]: (request: SentRequest) => ({ items: [ { @@ -653,7 +618,6 @@ describe("GitCafePullRequestProvider", () => { ], next: request.query.has("after") ? null : "checks-1", }), - // `/changes` counts no lines, and a pull past one page continues on a cursor. "GET /repos/owner/repo/pulls/7/changes": () => changePage++ === 0 ? { ...snapshot, items: files(0, 2), next: "cursor-1" } @@ -684,7 +648,6 @@ describe("GitCafePullRequestProvider", () => { detail.checks.map((check) => check.name), ["early", "late"], ); - // A requested reviewer shows before they have reviewed. assert.deepStrictEqual( detail.reviewers.map((reviewer) => reviewer.login), ["alice"], diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 4bbd54b73cf9..efcc1b9e2d9e 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -44,7 +44,6 @@ const CAPABILITIES: PullRequestCapabilities = { }; const PAGE_SIZE = 100; -/** Conversation pages read before the list is reported as truncated. */ const MAX_PAGES = 10; /** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */ const DIFF_FILES_BATCH = 64; @@ -53,7 +52,6 @@ const DIFF_FILES_BATCH = 64; * rather than reading every file. */ const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4; -/** Structural `/changes` pages, 500 files each, read to count a pull request's files. */ const MAX_CHANGE_PAGES = 20; const chunk = (items: ReadonlyArray, size: number): Array> => @@ -61,15 +59,9 @@ const chunk = (items: ReadonlyArray, size: number): Array items.slice(index * size, (index + 1) * size), ); -/** Files whose hunks GitCafe can return; binary and oversized ones are listed as they are. */ const detailablePaths = (files: ReadonlyArray) => files.filter((file) => !file.binary && !file.tooLarge).map((file) => file.path); -/** - * A strategy GitCafe lists, the viewer may use, and no blocker names. A blocker without - * strategies (changes requested, a lock) still leaves Merge offered, so pressing it shows - * GitCafe's own reason. - */ const mergeStrategyOpen = (status: typeof Status.Type, strategy: string) => status.merge.permitted !== false && status.merge.strategies.includes(strategy) && @@ -133,13 +125,11 @@ const MergeOutcome = Schema.Struct({ reason: Schema.optional(Schema.String), }); const StackOutcome = Schema.Struct({ id: Schema.String, state: Schema.String }); -/** Where a stack operation stopped short; anything else is underway or done. */ const FAILED_STACK_STATES = new Set(["failed", "stopped", "cancelled", "reconciliation_required"]); type PullRef = ProviderRepositoryRef & { readonly number: number }; type Paged = { readonly items: ReadonlyArray; readonly next: string | null }; -/** The heads a reader saw are still every unmerged layer the action moves. */ function stackHeadsCurrent( stack: Json.GitCafeStack, input: { readonly number: number; readonly merge: boolean }, @@ -163,7 +153,6 @@ function stackHeadsCurrent( ); } -/** Lays the detailed files over the structural page, keeping its order and anything omitted. */ const withDetails = ( files: ReadonlyArray, details: ReadonlyArray, @@ -185,7 +174,6 @@ export const make = Effect.gen(function* () { ...(cause === undefined ? {} : { cause }), }); - /** The repository's API root, refused before anything is sent for a host GitCafe is not on. */ const repositoryPath = ( operation: string, input: ProviderRepositoryRef, @@ -242,7 +230,6 @@ export const make = Effect.gen(function* () { Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)), ); - /** Every page of a cursor list, up to `MAX_PAGES`; `next` stays set when more remained. */ const readPages = , unknown, never, never>>( operation: string, input: Pick, @@ -277,8 +264,8 @@ export const make = Effect.gen(function* () { ); /** - * The stack a pull request belongs to, with each unmerged layer's head. GitCafe's stack lists no - * heads, and they are what a land or restack is fenced on, so each open layer's pull is read. + * GitCafe's stack lists no heads, and they are what a land or restack is fenced on, so each + * open layer's pull is read. */ const readStack = Effect.fn("GitCafePullRequestProvider.readStack")(function* ( operation: string, @@ -307,7 +294,6 @@ export const make = Effect.gen(function* () { return { ...stack, members }; }); - /** One `/diff-files` read, refused if GitCafe answers from another revision than the page. */ const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* ( input: PullRef, revision: { readonly version: number; readonly headOid: string; readonly baseOid: string }, @@ -377,11 +363,6 @@ export const make = Effect.gen(function* () { return yield* failure(operation, `GitCafe's stack operation ${outcome.state}.`); }); - /** - * Added and deleted line totals and the file count. `/changes` lists files without counting - * lines, so the counts come from batched hunk reads, and a pull request past the cap reports - * none rather than paying for every file. - */ const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* ( input: PullRef, version: number, @@ -409,8 +390,6 @@ export const make = Effect.gen(function* () { after = batch.next; if (after === null || after === previous || batch.items.length === 0) break; } - // A hunk read that loses a race with a push leaves the counts at zero rather than dropping - // the file count with them. const counted = files.some((file) => file.additions !== undefined) || files.length > LINE_STATS_MAX_FILES || @@ -471,7 +450,6 @@ export const make = Effect.gen(function* () { }, MergeOutcome, ); - // An accepted merge is GitCafe's to finish; the pull request's state reports when it has. if (outcome.state === "failed") return yield* failure("merge", outcome.reason ?? "GitCafe could not merge the pull request."); }); @@ -513,7 +491,6 @@ export const make = Effect.gen(function* () { actorId = options.value.actors.find( (actor) => actor.handle.toLowerCase() === input.viewer.toLowerCase(), )?.actorId; - // A viewer GitCafe knows no actor for has authored and been asked to review nothing. if (actorId === undefined) return { items: [], truncated: false, continues: false }; } } @@ -545,8 +522,6 @@ export const make = Effect.gen(function* () { return { items: items.slice(0, limit).map((pull) => { const item = Json.toChangeRequest(pull, input); - // GitCafe filtered by reviewer, so the viewer is one even where the pull omits it. An - // unnarrowed listing (no actor to filter by) says nothing about who was asked. return input.involvement === "reviewing" && actorId !== undefined ? { ...item, @@ -650,7 +625,6 @@ export const make = Effect.gen(function* () { pull.headOid === null ? Effect.succeed({ items: [], next: null }) : readPages(operation, input, `${path}/commits`, Json.GitCafeCommits), - // Reactions decorate the conversation; a failed read leaves them out, not it. readPages(operation, input, `${path}/reactions/`, Json.GitCafeReactions).pipe( Effect.orElseSucceed(() => undefined), ), diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts index 565071b9e096..20f8ab3941f9 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -1,16 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -/** - * Pins `./GitCafeSourceControlProvider.ts`, which pass 2 creates: - * - * - `discovery: SourceControlCliDiscoverySpec` β€” kind `"gitcafe"`, executable `"cafe"`, - * `authArgs` ending in `["auth", "status", "--json"]` against `https://git.cafe/api`, and a - * `parseAuth` that reads cafe's `{ schemaVersion: 1, data: { host, username } }` answer and its - * `{ schemaVersion: 1, error: { code, status, message } }` failure envelope. - * - `make: Effect` with - * kind `"gitcafe"`, resolving the repository and host from the checkout's remote. - * - * Uses `./GitCafeApi.ts` and `./GitCafeCredentials.ts` as pinned in the sibling tests. - */ import { assert, describe, it } from "@effect/vitest"; import { GitCommandError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -134,7 +122,6 @@ describe("GitCafeSourceControlProvider", () => { }); return Effect.gen(function* () { const provider = yield* GitCafeSourceControlProvider.make; - // The checkout has no remote at all, as when a repository is about to be created. assert.deepStrictEqual( yield* provider.getRepositoryCloneUrls({ cwd: "/repo", repository: "team/project" }), { diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts index db4bea8dbf4c..57304f2d4ad8 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -23,9 +23,7 @@ import * as GitCafeApi from "./GitCafeApi.ts"; import * as GitCafeCredentials from "./GitCafeCredentials.ts"; import * as GitCafeHosts from "./gitCafeHosts.ts"; -/** Discovery reports the production account; repository operations pick their own host. */ const AUTH_HOST = "git.cafe"; -/** `cafe repo create --clone` waits this long, polling at this pace, for admission to settle. */ const ADMISSION_TIMEOUT = "30 seconds"; const ADMISSION_POLL_INTERVAL = "500 millis"; const cliArgs = (host: string) => [ @@ -43,7 +41,6 @@ const decodeAuth = Schema.decodeUnknownResult( }), ), ); -/** `cafe`'s failure envelope, which it prints on its own line after any raw problem body. */ const decodeFailure = Schema.decodeUnknownResult( Schema.fromJsonString( Schema.Struct({ @@ -96,10 +93,8 @@ const cliDiscovery = { installHint: `Install the GitCafe CLI with \`bun install -g @gitcafe/cli\`, then run \`cafe auth login --host https://${AUTH_HOST}/api\`, save a token in Settings, or set CAFE_TOKEN on the server.`, } satisfies SourceControlCliDiscoverySpec; -/** `cafe auth status`'s reading, exposed for tests. */ export const parseAuth = cliDiscovery.parseAuth; -/** Probes `cafe` as the CLI spec does, with a token saved in Settings handed to it. */ export const makeDiscovery = Effect.gen(function* () { const { process } = yield* SourceControlHost.SourceControlHost; const credentials = yield* GitCafeCredentials.GitCafeCredentials; @@ -168,7 +163,6 @@ const cloneUrls = (target: Target) => ({ sshUrl: `ssh@${target.host}:${target.repository}.git`, }); -/** An scp-like `user@host:path` or `ssh://` remote; HTTPS remotes may carry userinfo too. */ const isSshRemote = (remoteUrl: string | undefined) => remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl)); @@ -230,7 +224,6 @@ export const make = Effect.gen(function* () { ), ); - /** The repository a call names, falling back to the checkout's own GitCafe remote. */ const resolveTarget = Effect.fn("GitCafeSourceControlProvider.resolveTarget")(function* ( operation: string, input: { @@ -242,8 +235,6 @@ export const make = Effect.gen(function* () { const named = input.repository === undefined ? null : GitCafeHosts.parseGitCafeRemote(input.repository); if (named) return named; - // A bare owner/name only asks the checkout which GitCafe host it is on, so a checkout with no - // remote (a repository being created) still resolves, on git.cafe. const readRemote = host.git .resolvePrimaryRemoteName(input.cwd) .pipe( @@ -263,7 +254,6 @@ export const make = Effect.gen(function* () { if (remote) return remote; return yield* error(operation, input.cwd, "No GitCafe repository remote was found."); } - // A bare owner/name lives on the checkout's own GitCafe host. const repository = GitCafeHosts.gitCafeRepositoryPath(input.repository); if (repository !== null) return { host: remote?.host ?? AUTH_HOST, repository } satisfies Target; @@ -285,7 +275,6 @@ export const make = Effect.gen(function* () { urlHost && match?.[2] ? { host: urlHost, repository: match[2] } : yield* resolveTarget("getChangeRequest", input); - // A pull request URL on another host is not a GitCafe pull request number. const number = match ? urlHost ? match[3] @@ -451,7 +440,6 @@ export const make = Effect.gen(function* () { Effect.timeoutOption(ADMISSION_TIMEOUT), Effect.map(Option.getOrElse(() => pending)), ); - // Anything short of complete is not yet usable. if (resource.state !== "complete") return yield* error( "createRepository", @@ -495,7 +483,6 @@ export const make = Effect.gen(function* () { : yield* git.ensureRemote({ cwd: input.cwd, preferredName: "gitcafe", - // SSH only when the checkout already reaches GitCafe over SSH; HTTPS otherwise. url: isSshRemote(input.context?.remoteUrl) ? `ssh@${pullHost}:${sourceRepository}.git` : `https://${pullHost}/${sourceRepository}.git`, @@ -508,7 +495,6 @@ export const make = Effect.gen(function* () { if (input.force !== true && !branches.includes(localBranch)) yield* git.fetchRemoteBranch({ ...remoteBranch, localBranch }); else yield* git.fetchRemoteTrackingBranch(remoteBranch); - // `checkout -B` moves even the checked-out branch, and refuses to overwrite local edits. if (input.force === true) yield* git.execute({ operation: "GitCafeSourceControlProvider.checkoutChangeRequest", diff --git a/packages/source-control-gitcafe/src/server/driver.ts b/packages/source-control-gitcafe/src/server/driver.ts index 2654f18b275e..6fce65b345a4 100644 --- a/packages/source-control-gitcafe/src/server/driver.ts +++ b/packages/source-control-gitcafe/src/server/driver.ts @@ -1,10 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -/** - * The GitCafe driver: repository operations and discovery through `cafe` and git, and pull - * requests through GitCafe's REST API on git.cafe and staging.git.cafe. - * - * @module source-control-gitcafe/server/driver - */ import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts index a828a99f6ff1..eba3e00eafb7 100644 --- a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts @@ -1,11 +1,3 @@ -/** - * Pins `./gitCafeHosts.ts`, which pass 2 creates as a plain module (no service): - * - * - `GITCAFE_HOSTS: readonly ["git.cafe", "staging.git.cafe"]` β€” the only hosts GitCafe lives on. - * - `isGitCafeHost(host: string): boolean` β€” case-insensitive membership in `GITCAFE_HOSTS`. - * - `parseGitCafeRemote(url: string): { readonly host: GitCafeHost; readonly repository: string } | null` - * β€” the host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. - */ import { assert, describe, it } from "@effect/vitest"; import * as GitCafeHosts from "./gitCafeHosts.ts"; diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts index a04b759b2d5f..33f3aa3204b7 100644 --- a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts @@ -1,14 +1,6 @@ -/** - * The hosts GitCafe lives on, and how a remote URL names one of its repositories. GitCafe has no - * self-hosted installs, so the list is fixed rather than discovered. - * - * @module source-control-gitcafe/server/gitCafeHosts - */ - export const GITCAFE_HOSTS = ["git.cafe", "staging.git.cafe"] as const; export type GitCafeHost = (typeof GITCAFE_HOSTS)[number]; -/** The host as GitCafe spells it, or null for any host GitCafe does not live on. */ export function gitCafeHost(host: string): GitCafeHost | null { const normalized = host.trim().toLowerCase(); return GITCAFE_HOSTS.find((candidate) => candidate === normalized) ?? null; @@ -25,7 +17,6 @@ const REMOTE_PATTERNS = [ /^[^@/:]+@([^/:]+):(.+)$/u, ]; -/** An `owner/name` repository path, without the `.git` suffix a clone URL carries. */ export function gitCafeRepositoryPath(path: string): string | null { const repository = path .trim() @@ -34,7 +25,6 @@ export function gitCafeRepositoryPath(path: string): string | null { return REPOSITORY.test(repository) ? repository : null; } -/** The host and `owner/name` of an https or ssh remote on a GitCafe host; null for anything else. */ export function parseGitCafeRemote( url: string, ): { readonly host: GitCafeHost; readonly repository: string } | null { diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts index 41d578cdf1e0..01de875fa42d 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -1,26 +1,3 @@ -/** - * Pins `./gitCafePullRequestJson.ts`, pure decoding of GitCafe's REST JSON into the neutral - * `Provider*` shapes. Pass 2 creates these exports: - * - * Schemas (REST bodies as GitCafe sends them): - * - `GitCafePull` (list row), `GitCafePullDetail` (row + description, closedAt, mergedAt, - * sourceRepo, lockedAt?, required `capabilities: { comment, review, merge, edit, moderate }`), - * - `GitCafeComments`, `GitCafeReviews`, `GitCafeCommits`, `GitCafeReactions` - * (cursor envelopes `{ items, next }`; commits also `{ truncated, headOid }`), - * - `GitCafeStackEnvelope` (`{ stack: null | { id, number, revision, landingBase, members } }`, - * members with an optional `headOid`), `GitCafeDiff` (`{ items, truncated? }`), - * `GitCafeChecks` (`{ items, next }`). - * - * Conversions (all take the repository host, so staging stays on staging): - * - `toChangeRequest(pull, { repository, host }): ProviderChangeRequest` - * - `toViewerPermissions(detail): PullRequestViewerPermissions` β€” fills `editChangeRequest`. - * - `toActivity({ comments, reviews, commits, reactions?, headOid?, host }): - * ProviderChangeRequestActivity` β€” fills `canEdit` on both copies of a remark and `canResolve` - * on review threads, from each comment's `capabilities`. - * - `toStack(stack, { repository, host }): ProviderChangeRequestStack` - * - `toDiff(diff): ProviderDiffSlice` - * - `toChecks(checks): ReadonlyArray` - */ import { assert, describe, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; @@ -239,7 +216,6 @@ describe("toActivity", () => { const canEdit = (id: string) => result.comments.find((comment) => comment.id === id)?.canEdit; assert.strictEqual(canEdit("root"), false); assert.strictEqual(canEdit("reply"), true); - // A host that says nothing about a remark leaves the page to guess. assert.isUndefined(canEdit("silent")); assert.deepStrictEqual( result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.canEdit]), @@ -345,7 +321,6 @@ describe("toActivity", () => { viewerReactionId: null, reactors: [actor], }, - // Neither a custom emoji nor one outside the shared set is shown under another name. { subject: { kind: "pull_request", id: "pr_one" }, emoji: { kind: "custom", id: "party-parrot" }, @@ -501,7 +476,6 @@ describe("toDiff", () => { truncated: false, }), ); - // Git quotes a name for the bytes a header can't carry, not for a space. assert.include(patch, "rename from old image.png"); assert.include(patch, "Binary files a/old image.png and b/new image.png differ"); }); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts index 453083a7a6b4..073e3fcebff4 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -1,9 +1,3 @@ -/** - * GitCafe's REST bodies, and their conversion into the neutral `Provider*` shapes. Every - * conversion takes the repository's host, so a staging repository's links stay on staging. - * - * @module source-control-gitcafe/server/gitCafePullRequestJson - */ import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; @@ -81,7 +75,6 @@ export const GitCafePullDetail = Schema.Struct({ closedAt: Schema.NullOr(IsoDateTime), mergedAt: Schema.NullOr(IsoDateTime), sourceRepo: Schema.NullOr(Schema.Struct({ owner: Schema.String, name: Schema.String })), - /** The base GitCafe last compared the pull request against, which a provider merge fences on. */ observedBaseOid: Schema.optional(Schema.NullOr(Schema.String)), mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])), lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)), @@ -107,7 +100,6 @@ const CommentCapabilities = Schema.Struct({ const GitCafeComment = Schema.Struct({ id: TrimmedNonEmptyString, threadId: TrimmedNonEmptyString, - /** What a resolution or rewrite fences on; absent from older answers. */ version: Schema.optional(NonNegativeInt), author: Actor, body: Schema.NullOr(Schema.String), @@ -172,7 +164,6 @@ export const GitCafeReactions = Schema.Struct({ const GitCafeStack = Schema.Struct({ id: TrimmedNonEmptyString, number: PositiveInt, - /** What a land or restack fences on. */ revision: PositiveInt, landingBase: Schema.String, members: Schema.Array( @@ -262,7 +253,6 @@ function toActor(actor: Actor, host: string): PullRequestActor | null { }; } -/** The reviewers GitCafe has asked, as the detail's reviewer list shows them. */ export function toReviewers( pull: Pick, host: string, @@ -273,7 +263,6 @@ export function toReviewers( }); } -/** A linked GitHub reviewer answers to both logins, so either one finds the review request. */ function reviewerLogins(actor: Actor, host: string): ReadonlyArray { const reviewer = toActor(actor, host); if (reviewer === null) return []; @@ -307,14 +296,11 @@ export function toChangeRequest( }; } -/** What GitCafe's own capabilities let this viewer do with the pull request. */ export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewerPermissions { const { comment, edit, review, merge, moderate } = detail.capabilities; - // A locked conversation keeps its composer for moderators only. const open = comment && (detail.lockedAt == null || moderate); return { actions: [ - // A merged pull request is settled; a closed one can only be reopened. ...(edit && detail.state === "open" ? ([detail.draft ? "ready" : "draft", "close"] as const) : edit && detail.state === "closed" @@ -324,7 +310,6 @@ export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewe ], stackRebase: merge, comment: open, - // Each thread's root narrows this further with its own `canResolve`. resolve: open, verdicts: review ? ["approve", "request-changes", "comment"] : [], requestReviewers: false, @@ -345,7 +330,6 @@ const REACTIONS: Readonly> = { "πŸ‘€": "eyes", }; -/** The emoji GitCafe takes for one of the shared reactions. */ export const REACTION_EMOJI: Readonly> = { "thumbs-up": "πŸ‘", "thumbs-down": "πŸ‘Ž", @@ -357,7 +341,6 @@ export const REACTION_EMOJI: Readonly eyes: "πŸ‘€", }; -/** The shared reaction an emoji stands for; custom and unlisted ones stand for none. */ export function reactionContent(emoji: string): PullRequestReactionContent | undefined { return REACTIONS[emoji.replaceAll("\uFE0F", "")]; } @@ -390,7 +373,6 @@ const REVIEW_STATES = { comment: "commented", } as const; -/** GitCafe persists a thread's identity apart from its line, so threads group by `threadId`. */ export function toActivity(input: { readonly comments: typeof GitCafeComments.Type; readonly reviews: typeof GitCafeReviews.Type; @@ -410,7 +392,6 @@ export function toActivity(input: { ...(comment.capabilities === undefined ? {} : { canEdit: comment.capabilities.edit }), ...(reactions === undefined ? {} : { reactions: reactions.byComment.get(comment.id) ?? [] }), }); - // Stable, so remarks written in the same instant keep GitCafe's own order. const ordered = input.comments.items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); const groups = new Map>(); for (const comment of ordered) { @@ -492,7 +473,6 @@ export function toStack(stack: GitCafeStack, target: RepositoryOnHost): Provider }; } -/** Converts GitCafe's structured hunks into a unified patch, keeping omitted files' counts. */ export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { const chunks: Array = []; const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = []; diff --git a/packages/source-control-github/src/client/definition.ts b/packages/source-control-github/src/client/definition.ts index efae9eaa572b..66c1cd003968 100644 --- a/packages/source-control-github/src/client/definition.ts +++ b/packages/source-control-github/src/client/definition.ts @@ -1,8 +1,3 @@ -/** - * GitHub's client definition. Browser- and React Native-safe. - * - * @module source-control-github/client/definition - */ import { GitHubHost, makeProviderSettingsSchema, @@ -33,12 +28,6 @@ export const GitHubHostChoice = Schema.Struct({ }); export type GitHubHostChoice = typeof GitHubHostChoice.Type; -/** - * Per-host choices, keyed by lowercased host such as `github.com`. `hosts` pins one of the logins - * `gh` holds for a host instead of its active one, or turns the host off; `tokens` holds a token - * per host, used before `GH_TOKEN` and friends, which win over `gh`. Source Control settings draws - * both with GitHub's own panels, since the logins come from `gh` at runtime. - */ export const settings = makeProviderSettingsSchema({ hosts: Schema.Record(GitHubHost, GitHubHostChoice).pipe( Schema.withDecodingDefault(Effect.succeed({})), @@ -89,7 +78,6 @@ export const definition = defineSourceControlClient({ checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, isChangeRequestReference: (url) => CHANGE_REQUEST_REFERENCE.test(url), changeRequestUrlHost: (url) => url.hostname, - // A GitHub web host is the checkout's own host, Enterprise installs included. checkoutChangeRequestHost: (identity) => pullRequestHostOf(identity, KIND), isChangeRequestInRepository: (identity, link) => isChangeRequestInProjectRepository(KIND, identity, link), diff --git a/packages/source-control-github/src/server/GitHubCredentials.test.ts b/packages/source-control-github/src/server/GitHubCredentials.test.ts index 76cced6bda83..aa4f67ce3c5b 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.test.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.test.ts @@ -18,7 +18,6 @@ const TOKEN_VARIABLES = [ "GITHUB_ENTERPRISE_TOKEN", ] as const; -/** Settings keep `enabled` explicit; a host the test leaves it off is enabled. */ const hostSettings = ( hosts: Record, ) => @@ -143,7 +142,6 @@ describe("GitHubCredentials", () => { yield* settings.update({ sourceControlHosts: { github: { hosts: {} } } }); expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); - // The unpinned token stayed cached; only the newly pinned account cost a gh call. expect(calls).toHaveLength(2); }).pipe(Effect.provide(layer)); }); diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index 69594c9455a7..b2d0b7d0ea34 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -19,9 +19,7 @@ import type { ServerSettings } from "@t3tools/contracts"; import * as GitHubClient from "../client/definition.ts"; -/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ const TOKEN_TTL = Duration.minutes(5); -/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ const MISSING_TTL = Duration.seconds(10); export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]); @@ -31,11 +29,9 @@ export interface GitHubCredential { readonly host: string; readonly token: Redacted.Redacted; readonly source: GitHubCredentialSource; - /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */ readonly fingerprint: string; } -/** Nothing in the environment, and no `gh` on PATH to ask. */ export class GitHubCliMissingError extends Schema.TaggedError()( "GitHubCliMissingError", { host: Schema.String }, @@ -45,7 +41,6 @@ export class GitHubCliMissingError extends Schema.TaggedError()( "GitHubNotSignedInError", { host: Schema.String, account: Schema.optional(Schema.String) }, @@ -57,7 +52,6 @@ export class GitHubNotSignedInError extends Schema.TaggedError()( "GitHubHostDisabledError", { host: Schema.String }, @@ -67,7 +61,6 @@ export class GitHubHostDisabledError extends Schema.TaggedError()( "GitHubCliFailedError", { host: Schema.String, cause: Schema.Defect() }, @@ -77,24 +70,18 @@ export class GitHubCliFailedError extends Schema.TaggedError Effect.Effect; - /** Drops the held token after GitHub refused it, so the next read asks its source again. */ readonly invalidate: (host: string) => Effect.Effect; } >()("@t3tools/source-control-github/server/GitHubCredentials") {} @@ -137,11 +124,9 @@ export const make = Effect.gen(function* () { const environment = yield* HostProcess.Environment; const workingDirectory = yield* HostProcess.WorkingDirectory; - /** `host:sha256(token)`, safe for cache keys and rate-limit scopes. */ const fingerprintOf = (host: string, token: string) => crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe( Effect.map((digest) => `${host}:${Hex.encode(digest)}`), - // Hashing a string in memory has no platform failure worth a typed error. Effect.orDie, ); @@ -186,30 +171,24 @@ export const make = Effect.gen(function* () { settings.sourceControlHosts[GitHubClient.definition.kind], ); - /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ const hostChoice = (host: string) => sourceControlHost.settings.get.pipe( Effect.map((settings) => readSettings(settings).hosts[host]), Effect.orElseSucceed(() => undefined), ); - /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */ const savedToken = (host: string) => sourceControlHost.settings.get.pipe( Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null), Effect.orElseSucceed(() => null), ); - /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */ const cacheKey = (host: string, account: string | undefined) => account === undefined ? host : `${host}\u0000${account}`; const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { const [host = key, choice] = key.split("\u0000"); - // An environment token wins over a pinned account, exactly as it does in gh. const fromEnv = environmentToken(host, environment); - // A pinned login gh no longer holds (logged out, expired) falls back to the active one, - // which is what discovery reports as the account in use. const token = fromEnv ?? (yield* fromGh(host, choice).pipe( @@ -228,7 +207,6 @@ export const make = Effect.gen(function* () { const cache = yield* Cache.makeWith(lookup, { capacity: 32, - // A transient gh failure (a timeout, a locked keyring) is asked again on the next read. timeToLive: (exit) => Exit.isSuccess(exit) ? TOKEN_TTL @@ -246,8 +224,6 @@ export const make = Effect.gen(function* () { if (choice?.enabled === false) { return yield* new GitHubHostDisabledError({ host }); } - // A token saved in Settings is the most deliberate choice, so it comes before the - // environment and gh. It is read from the secret store each time, so it needs no cache. const saved = yield* savedToken(host); if (saved !== null) { return { diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 1aff0879bec8..705be8fb556b 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -70,7 +70,6 @@ const coreResponse = (pullRequest: Readonly> = {}) => ({ }, }); -/** One request the CLI made, as the transport received it. */ type ApiCall = | { readonly kind: "graphql"; @@ -179,7 +178,6 @@ const layer = it.layer( ), ); -/** A successful answer; GraphQL reads take its body. */ function output( body: string, truncated = false, @@ -190,7 +188,6 @@ function output( return { status, headers, body, truncated, invalidUtf8 }; } -/** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { return { "x-ratelimit-resource": "graphql", @@ -262,7 +259,6 @@ function rows( }); } -/** A page of the searched listing. */ function pullRequests( count: number, firstNumber: number, @@ -275,7 +271,6 @@ function pullRequests( }); } -/** A page of a repository's own list, which is what the search-free fallback reads. */ function listedPullRequests( count: number, firstNumber: number, @@ -293,7 +288,6 @@ function listedPullRequests( }); } -/** A search that found nothing, which is also what GitHub says for a repository it does not index. */ const emptySearch = () => output(pullRequests(0, 1)); const emptyList = () => output(listedPullRequests(0, 1)); @@ -307,7 +301,6 @@ function pullRequestFiles(count: number, firstIndex: number): string { ); } -/** One thread's comments as the GraphQL read returns them, cursor and all. */ function threadComments( ids: ReadonlyArray, endCursor: string | null, @@ -396,39 +389,33 @@ const diffRefused = new GitHubRequestPolicy.GitHubApiResponseError({ status: 406, }); -/** The nth request the CLI made. */ function callAt(index: number): ApiCall { const call = mockedExecute.mock.calls[index]; assert.isDefined(call); return call[0]; } -/** The GraphQL variables of the nth request. */ function varsAt(index: number): Readonly> { const call = callAt(index); assert.strictEqual(call.kind, "graphql"); return call.kind === "graphql" ? (call.variables ?? {}) : {}; } -/** The GraphQL document of the nth request. */ function queryAt(index: number): string { const call = callAt(index); return call.kind === "graphql" ? call.query : ""; } -/** The REST path of the nth request. */ function pathAt(index: number): string { const call = callAt(index); return call.kind === "rest" ? call.path : ""; } -/** The search the nth request carried, or undefined for a read that did not search. */ function searchOfCall(index: number): string | undefined { const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; return typeof q === "string" ? q : undefined; } -/** One row as a search answers it, which is the listing's row one connection deeper. */ function searchItem(number: number, repository: string, updatedAt: string) { return { ...rowDefaults, @@ -564,10 +551,8 @@ const checkContextsPage = ( }, }); -/** A pull request's base and head, as the REST read a file expansion starts from answers them. */ const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); -/** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ const crossRepositoryDetail = (headRefOid = "abc123") => coreResponse({ headRefName: "feat/page", @@ -576,7 +561,6 @@ const crossRepositoryDetail = (headRefOid = "abc123") => headRepositoryOwner: { __typename: "User", login: "octocat" }, }); -/** Open pull requests on one head branch, every one of them from the same fork head. */ const heads = (numbers: ReadonlyArray) => ({ data: { repository: { @@ -601,7 +585,6 @@ const workflowRuns = (ids: ReadonlyArray) => ({ })), }); -/** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ function workflowApprovalRoutes( detail: () => unknown, headsAnswer: unknown, @@ -703,7 +686,6 @@ it.effect( expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); expect(encodeJson([first, second])).not.toContain("broad-credential"); expect(encodeJson([first, second])).not.toContain("restricted-credential"); - // The comment was written under the credential the page verified, not the switched one. expect( requests .filter((request) => request.url.endsWith("/graphql")) @@ -728,7 +710,6 @@ layer("GitHubPullRequestApi.layer", (it) => { const quota = yield* GitHubQuota.GitHubQuota; const resetAt = "2099-08-13T14:00:00Z"; yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); - // GitHub's count, not ours: the answer below reports the reserve has been reached. mockedExecute.mockReturnValue( Effect.succeed( output( @@ -863,7 +844,6 @@ layer("GitHubPullRequestApi.layer", (it) => { const input = { cwd: "/w", host: "github.identity-cancel.test" }; const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); yield* Deferred.await(firstStarted); - // The second reader is queued on the same credential's lock behind the first. const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); yield* Effect.yieldNow; yield* Fiber.interrupt(first); @@ -1006,7 +986,6 @@ layer("GitHubPullRequestApi.layer", (it) => { const [seven, eight] = yield* Fiber.join(reads); expect(seven?.remarks.startsWith("1 ")).toBe(true); - // GitHub had no answer for #8, so its watch reads it in full. expect(eight).toBeNull(); expect(mockedExecute).toHaveBeenCalledOnce(); expect(queryAt(0)).toContain( @@ -1328,7 +1307,6 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("reports truncation when GitHub repeats a cursor before the page is full", () => Effect.gen(function* () { - // Two pages of 100 that hand back the same cursor, for a page of 250. const repeating = output( encodeJson({ data: { @@ -1378,7 +1356,6 @@ layer("GitHubPullRequestApi.layer", (it) => { limit: 10, }); - // A closed pull request may also be a merged one, so the tab narrows through search. expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); }), ); @@ -1452,9 +1429,6 @@ layer("GitHubPullRequestApi.layer", (it) => { cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, }); - // One request for both repositories, carrying everything the per-repository read expresses - // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the - // order the page reads in. assert.strictEqual(mockedExecute.mock.calls.length, 1); assert.strictEqual( searchQueryOfCall(0), @@ -1502,7 +1476,6 @@ layer("GitHubPullRequestApi.layer", (it) => { query: 'x" is:merged repo:evil/repo', }); - // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. assert.strictEqual( searchQueryOfCall(0), 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', @@ -1526,7 +1499,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); - // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); assert.strictEqual(mockedExecute.mock.calls.length, 0); }), @@ -1539,7 +1511,6 @@ layer("GitHubPullRequestApi.layer", (it) => { searchPage([ searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), - // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. {}, ]), ), @@ -1563,7 +1534,6 @@ layer("GitHubPullRequestApi.layer", (it) => { ["pingdotgg/t3code", 9, "https://avatars/octocat"], ], ); - // The listing leaves the line counts to a read of their own. assert.deepStrictEqual( batch.items.map((item) => [item.additions, item.deletions]), [ @@ -1605,10 +1575,8 @@ layer("GitHubPullRequestApi.layer", (it) => { const overflowing = yield* read(); const capped = yield* read(); - // The extra row is the probe, and it is not handed on. assert.strictEqual(overflowing.items.length, 2); assert.isTrue(overflowing.truncated); - // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. assert.isTrue(capped.truncated); }), ); @@ -1776,7 +1744,6 @@ layer("GitHubPullRequestApi.layer", (it) => { changeRequests, }); - // Twenty-five aliases a request, so twenty-six rows are two requests. assert.strictEqual(mockedExecute.mock.calls.length, 2); assert.deepStrictEqual(stats, [ { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, @@ -1826,8 +1793,6 @@ layer("GitHubPullRequestApi.layer", (it) => { query: "pull requests page", }); - // The recency qualifier rides along, because free text would otherwise reorder the page - // by relevance and truncation would drop the newest matches. expect(searchOfCall(0)).toBe( 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', ); @@ -1891,8 +1856,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }, }); - // Quotes around anything a reader typed, and the one character that could end a quoted - // value early dropped rather than escaped. expect(searchOfCall(0)).toBe( 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", @@ -1948,7 +1911,6 @@ layer("GitHubPullRequestApi.layer", (it) => { filters: { labels: [["size:S", "size:XS"], ["bug"]] }, }); - // One qualifier satisfied by either size, and a second one that must hold as well. expect(searchOfCall(0)).toBe( 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', ); @@ -1982,10 +1944,6 @@ layer("GitHubPullRequestApi.layer", (it) => { filters: { checks: "passing" }, }); - // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is - // now a filter the fallback judges itself, the same as `draft`: an empty search answer - // under it is still ambiguous, and the row picked out afterwards is the one whose own - // `checksState` reads "passing". expect(searchOfCall(1)).toBeUndefined(); assert.deepStrictEqual( batch.items.map((item) => item.number), @@ -2013,8 +1971,6 @@ layer("GitHubPullRequestApi.layer", (it) => { filters: { checks: "passing" }, }); - // Pending equals neither "passing" nor "failing", so it satisfies neither filter value β€” - // the same row would also be dropped by `checks: "failing"`. assert.deepStrictEqual(batch.items, []); }), ); @@ -2040,10 +1996,6 @@ layer("GitHubPullRequestApi.layer", (it) => { filters: { draft: "hide" }, }); - // `draft` is a filter the fallback can judge over its own rows just as search judges it, - // so an empty search answer under it alone is still ambiguous between "nothing matches" - // and "this repository is not indexed" β€” and the fallback applies the filter itself, - // keeping only the non-draft row. expect(searchOfCall(1)).toBeUndefined(); expect(batch.items.map((item) => item.number)).toEqual([2]); }), @@ -2094,8 +2046,6 @@ layer("GitHubPullRequestApi.layer", (it) => { query: '-- is:merged label:secret "widen me"', }); - // Every word stays inside one phrase: nothing before it, nothing after it, and the - // leading dashes are text rather than the start of another argument. expect(searchOfCall(0)).toBe( String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, ); @@ -2124,8 +2074,6 @@ layer("GitHubPullRequestApi.layer", (it) => { query: String.raw`a\" is:merged`, }); - // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where - // this says it does; escaping the quote alone would have closed it early. expect(searchOfCall(0)).toBe( String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, ); @@ -2154,8 +2102,6 @@ layer("GitHubPullRequestApi.layer", (it) => { query: " ", }); - // An empty phrase would match nothing rather than everything, so it is left out; the - // order the page reads rows in is asked for whether or not anything was typed. expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); }), ); @@ -2176,8 +2122,6 @@ layer("GitHubPullRequestApi.layer", (it) => { cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, }); - // Inclusive, so the rows already sent at that instant come back for the caller to drop β€” - // which is what keeps the ones beside them from being skipped. expect(searchOfCall(0)).toBe( "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", ); @@ -2187,9 +2131,6 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("answers a search that found nothing with nothing, not with the whole repository", () => Effect.gen(function* () { - // The fallback is for a repository the index does not cover. Under a text search an empty - // answer means the text matched nothing, and listing everything instead would fill the - // page with rows the reader did not search for. mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; @@ -2211,7 +2152,6 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("reads a repository GitHub will not search from its own list", () => Effect.gen(function* () { - // GitHub answers for a repository outside its search index with no rows and no error. mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), @@ -2229,8 +2169,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }); assert.strictEqual(batch.items.length, 3); - // The fallback itself uses no search, then narrows the decoded rows locally. They arrive - // newest-created first, so nothing can carry on from them. expect(searchOfCall(1)).toBeUndefined(); expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); assert.isFalse(batch.continues); @@ -2264,8 +2202,6 @@ layer("GitHubPullRequestApi.layer", (it) => { limit: 10, }); - // Individual requests for this viewer and team requests survive. The fallback cannot - // resolve team membership, so dropping team-routed reviews would hide legitimate work. expect(batch.items.map((item) => item.number)).toEqual([1, 2]); expect(searchOfCall(1)).toBeUndefined(); assert.isFalse(batch.continues); @@ -2309,7 +2245,6 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("bounds a sparse search-free fallback and reports the unread tail", () => Effect.gen(function* () { - // A repository with far more pull requests than the bound, none of them for this reader. let listed = 0; mockedExecute.mockImplementation((call) => { if (call.kind !== "graphql" || call.operationName !== "PullRequestList") { @@ -2344,7 +2279,6 @@ layer("GitHubPullRequestApi.layer", (it) => { limit: 2, }); - // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. const last = mockedExecute.mock.calls.length - 1; expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); @@ -2375,13 +2309,10 @@ layer("GitHubPullRequestApi.layer", (it) => { cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, }); - // A repository that answered the search once answers it again, so an empty slice under a - // cursor is the end of it rather than a repository search cannot reach. assert.strictEqual(mockedExecute.mock.calls.length, 1); }), ); - /** The state a merge or branch update reads first: behind its base and blocked on checks. */ const actionState = (pullRequest: Readonly> = {}) => ({ data: { repository: { @@ -2425,7 +2356,6 @@ layer("GitHubPullRequestApi.layer", (it) => { updateMethod: "rebase", }); - // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. expect(variablesOf("updatePullRequestBranch(")).toEqual([ { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, @@ -2721,7 +2651,6 @@ layer("GitHubPullRequestApi.layer", (it) => { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH", - // Pinned to the head the message was read from, so it cannot describe other commits. expectedHeadOid: "abc123", commitBody: "Details\n\nCo-authored-by: Alice ", }, @@ -2757,7 +2686,6 @@ layer("GitHubPullRequestApi.layer", (it) => { removeAgentCreditsOnMerge: true, }); expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); - // A merge queue takes the pull request through auto-merge, with its own message. expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, ]); @@ -2850,7 +2778,6 @@ layer("GitHubPullRequestApi.layer", (it) => { action: "enable-auto-merge", mergeMethod: "squash", }); - // No strategy asked for is GitHub's own default, exactly as it is for a merge now. yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2982,7 +2909,6 @@ layer("GitHubPullRequestApi.layer", (it) => { expect(restCallsTo("actions/runs?")[0]?.path).toBe( "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", ); - // Each run is approved only after the head is read again and still lists it. expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ ["POST", "repos/acme/web/actions/runs/10/approve"], ["POST", "repos/acme/web/actions/runs/11/approve"], @@ -3011,7 +2937,6 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.provideService(KnownWorkflowRuns, { headSha: "abc123", runs: [ - // Live shape: waiting on approval is completed + action_required. { id: 10, status: "completed", @@ -3120,8 +3045,6 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("refuses workflow approval when the head list stops before its end", () => Effect.gen(function* () { - // GitHub hands the same cursor back, so the second page is never read past. #7 looks - // unique on what was read, and an unread page could still hold another head like it. const repeating = heads([7]); repeating.data.repository.pullRequests.pageInfo = { hasNextPage: true, @@ -3221,7 +3144,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); - // Paged a hundred at a time, it stops at the first page past the bound. expect(error).toMatchObject({ _tag: "GitHubWorkflowApprovalRefusedError", reason: "run-list-truncated", @@ -3291,7 +3213,6 @@ layer("GitHubPullRequestApi.layer", (it) => { limit: 10, }); - // A request sent to github.com would read a different repository of the same name. assert.isAbove(mockedExecute.mock.calls.length, 0); expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( new Set(["github.acme.dev"]), @@ -3332,9 +3253,7 @@ layer("GitHubPullRequestApi.layer", (it) => { assert.strictEqual(diff.patch, "diff --git a/a b/a"); assert.isNull(diff.nextCursor); assert.isFalse(diff.truncated); - // The common case pays for one request and not the files API on top of it. assert.strictEqual(mockedExecute.mock.calls.length, 1); - // GitHub's combined pull-request diff: one section per changed file, not one per commit. const call = callAt(0); assert.strictEqual(call.kind, "rest"); if (call.kind === "rest") { @@ -3346,7 +3265,6 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => Effect.gen(function* () { - // GitHub answers 406 rather than a diff past 300 changed files. mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; @@ -3359,7 +3277,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }); assert.isFalse(diff.truncated); - // A short page is the end of the change set, so there is nothing to carry on from. assert.isNull(diff.nextCursor); expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); @@ -3381,7 +3298,6 @@ layer("GitHubPullRequestApi.layer", (it) => { number: 7, }); - // A full page means more files, which the reader asks for; it is not a truncated slice. assert.isFalse(diff.truncated); assert.isNotNull(diff.nextCursor); assert.strictEqual(mockedExecute.mock.calls.length, 2); @@ -3402,7 +3318,6 @@ layer("GitHubPullRequestApi.layer", (it) => { assert.isNull(second.nextCursor); expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); - // The second slice is one request: the cursor already says where to read. assert.strictEqual(mockedExecute.mock.calls.length, 3); assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); }), @@ -3442,10 +3357,8 @@ layer("GitHubPullRequestApi.layer", (it) => { commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", }); - // One request: the commit's own changes never take the whole-diff road. assert.strictEqual(mockedExecute.mock.calls.length, 1); assert.isNull(diff.nextCursor); - // The commit endpoint wraps its files in an object, which the decoder unwraps. expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); assert.strictEqual( pathAt(0), @@ -3575,7 +3488,6 @@ layer("GitHubPullRequestApi.layer", (it) => { assert.strictEqual(error.path, "src/large.ts"); assert.strictEqual(error.reason, "oversized"); } - // A deleted file is read at the base revision only. assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); }), ); @@ -3747,14 +3659,12 @@ layer("GitHubPullRequestApi.layer", (it) => { viewer: "maria-rcks", }); - // The same credential again is answered from what was verified, without asking GitHub. expect(yield* cli.getRoutingIdentity(input)).toEqual({ accountId: "123", viewer: "maria-rcks", }); expect(mockedExecute).toHaveBeenCalledTimes(1); - // A switched credential is not trusted on the strength of the old one's answer. mockedCredential.mockImplementation((host) => Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), ); @@ -3767,7 +3677,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ), ); - // GitHub's own refusal is reported as itself, so the page can say what went wrong. const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); expect(failure._tag).toBe("GitHubApiResponseError"); expect(String(failure)).not.toContain("test-credential-b"); @@ -3796,7 +3705,6 @@ layer("GitHubPullRequestApi.layer", (it) => { comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }); - // One request, so nothing is on the pull request until the verdict is. assert.strictEqual(mockedExecute.mock.calls.length, 1); expect(restCallsTo("/reviews")).toMatchObject([ { @@ -3856,7 +3764,6 @@ layer("GitHubPullRequestApi.layer", (it) => { expect(queryAt(0)).not.toContain("unresolveReviewThread("); expect(queryAt(1)).toContain("unresolveReviewThread("); expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); - // A GitHub Enterprise thread is resolved on its own host, not on github.com. expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); }), ); @@ -3919,7 +3826,6 @@ layer("GitHubPullRequestApi.layer", (it) => { ); assert.strictEqual(error._tag, "GitHubSubjectScopeError"); - // Refused before any mutation was sent. assert.strictEqual(mockedExecute.mock.calls.length, 1); }), ); @@ -3929,7 +3835,6 @@ layer("GitHubPullRequestApi.layer", (it) => { route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - // Its own pull request: a node id looked up once is remembered for the life of the service. yield* cli.setReaction({ cwd: "/w", repository: "acme/web", @@ -3982,7 +3887,6 @@ layer("GitHubPullRequestApi.layer", (it) => { yield* rewrite({ body: "A better description." }); yield* rewrite({ title: "Both", body: "at once." }); - // One node id lookup for the pull request, then a mutation per rewrite. assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); expect(variablesOf("updatePullRequest(")).toEqual([ { pullRequestId: "PR_kwDOA", title: "A better title" }, @@ -4058,7 +3962,6 @@ layer("GitHubPullRequestApi.layer", (it) => { assert.strictEqual(error._tag, "GitHubSubjectScopeError"); if (error._tag === "GitHubSubjectScopeError") assert.strictEqual(error.operation, "updateComment"); - // Refused before any mutation was sent. assert.strictEqual(mockedExecute.mock.calls.length, 1); }), ); @@ -4148,7 +4051,6 @@ layer("GitHubPullRequestApi.layer", (it) => { rebase: true, }); expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); - // Conversation activity is its own read, and asks for the head of the conversation once. expect(queryAt(1)).toContain("reviews("); expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); assert.strictEqual(mockedExecute.mock.calls.length, 2); @@ -4380,16 +4282,12 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); - // What matters is that it fails at all: an empty patch with no cursor would render as a - // change with no files and report the rest of it as already read. The refusal that sent - // the read down this road is the one reported, by design. assert.strictEqual(error, diffRefused); }), ); it.effect("pages an oversized patch by file rather than handing back a severed one", () => Effect.gen(function* () { - // The whole diff came back, but cut at a byte, which lands mid-file. mockedExecute.mockReturnValueOnce( Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), ); @@ -4403,7 +4301,6 @@ layer("GitHubPullRequestApi.layer", (it) => { number: 7, }); - // The severed patch is thrown away; what comes back is assembled from whole files. assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); expect(slice.patch).toContain("src/file1.ts"); expect(slice.patch).not.toContain("diff --git a/a b/a"); @@ -4438,7 +4335,6 @@ layer("GitHubPullRequestApi.layer", (it) => { it.effect("stops at the thread bound and says the conversation was cut short", () => Effect.gen(function* () { - // A host that never runs out of pages: the walk has to end itself. mockedExecute.mockReturnValue( Effect.succeed( output( @@ -4581,7 +4477,6 @@ layer("GitHubPullRequestApi.layer", (it) => { number: 7, }); - // One request, because both answers hang off the same repository object. assert.strictEqual(mockedExecute.mock.calls.length, 1); expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); @@ -4815,8 +4710,6 @@ layer("GitHubPullRequestApi.layer", (it) => { number: 7, }); - // The people, who has been asked and who opened the pull request all hang off the same - // repository object, so the menu costs one request. assert.strictEqual(mockedExecute.mock.calls.length, 1); expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ @@ -4912,7 +4805,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }); assert.strictEqual(mockedExecute.mock.calls.length, 3); - // The first page asks from the start; each one after it carries the cursor before it. expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ undefined, "cursor-0", @@ -4959,7 +4851,6 @@ layer("GitHubPullRequestApi.layer", (it) => { number: 7, }); - // The same cursor twice is a page GitHub already gave, so reading stops there. assert.strictEqual(mockedExecute.mock.calls.length, 2); assert.isTrue(viewed.truncated); }), @@ -4987,7 +4878,6 @@ layer("GitHubPullRequestApi.layer", (it) => { ], }); - // One request to learn the pull request's node id, one for every press together. assert.strictEqual(mockedExecute.mock.calls.length, 2); expect(queryAt(1)).toContain("_0_markFileAsViewed:markFileAsViewed("); expect(queryAt(1)).toContain("_1_unmarkFileAsViewed:unmarkFileAsViewed("); @@ -5041,7 +4931,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }); yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); - // One lookup, then a mutation per write, every one of them addressed by the id it answered. assert.strictEqual(mockedExecute.mock.calls.length, 4); expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); expect( @@ -5083,10 +4972,6 @@ layer("GitHubPullRequestApi.layer", (it) => { ); it.effect("keeps the pull request being ticked through, not the one looked up first", () => Effect.gen(function* () { - // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is - // working down is the first thing evicted once a listing has walked a cache's worth of cold - // pull requests, and every press after that pays a round trip again. - // This block shares one cache, so these numbers are its own and it runs last. const HOT = 9_000; const lookupsOf = new Map(); mockedExecute.mockImplementation((input) => { @@ -5110,7 +4995,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }); yield* tick(HOT); - // A cache's worth of cold pull requests, with the open one pressed in between each of them. for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) { yield* tick(HOT + 1 + filled); yield* tick(HOT); diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 261dad81c949..0e73babffc08 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -107,10 +107,6 @@ import type { ProviderListCursor, } from "@t3tools/source-control-core/server/PullRequestProvider"; -/** - * Names the read that produced unusable output, so a failure reports the call it came from - * rather than borrowing another operation's message. - */ export class GitHubPullRequestReadError extends Schema.TaggedError()( "GitHubPullRequestReadError", { @@ -124,7 +120,6 @@ export class GitHubPullRequestReadError extends Schema.TaggedError()( "GitHubViewerLoginUnavailableError", { @@ -136,7 +131,6 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError()( "GitHubPullRequestUpdatedAtUnavailableError", { @@ -150,7 +144,6 @@ export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErr } } -/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ export class GitHubDiffCursorError extends Schema.TaggedError()( "GitHubDiffCursorError", { @@ -162,7 +155,6 @@ export class GitHubDiffCursorError extends Schema.TaggedError()( "GitHubDiffCommitError", { @@ -174,7 +166,6 @@ export class GitHubDiffCommitError extends Schema.TaggedError()( "GitHubDiffRevisionsUnavailableError", { @@ -190,7 +181,6 @@ export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError()( "GitHubDiffFileContentsUnavailableError", { @@ -206,12 +196,6 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError()( "GitHubRepositorySelectorError", { @@ -224,7 +208,6 @@ export class GitHubRepositorySelectorError extends Schema.TaggedError()( "GitHubSubjectScopeError", { @@ -237,7 +220,6 @@ export class GitHubSubjectScopeError extends Schema.TaggedError()( "GitHubWorkflowApprovalRefusedError", { @@ -259,7 +241,6 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError()( "GitHubWorkflowApprovalHeadUnavailableError", { @@ -272,7 +253,6 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr } } -/** The pull request moved after its approval candidates were read. */ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError()( "GitHubWorkflowApprovalHeadChangedError", { @@ -340,15 +320,11 @@ const { isNotFoundOnly } = GitHubGraphQlClient; /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; const DIFF_TIMEOUT = Duration.seconds(60); -/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; -/** What `gh pr list --state` asked a repository's own list for, without search. */ const LIST_STATES: Record> = { all: ["OPEN", "CLOSED", "MERGED"], open: ["OPEN"], - // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out - // locally by `matchesUnsortedListing`. closed: ["CLOSED", "MERGED"], merged: ["MERGED"], }; @@ -359,35 +335,17 @@ interface GitHubPullRequestListPage { readonly endCursor: string | null; } -/** A search-free fallback may scan older rows for local filters, but never the whole repository. */ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; -/** What the files API serves at most in one response, which is what one slice is made of. */ const DIFF_FILES_PAGE_SIZE = 100; -/** - * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL - * budget per page, against a change request nobody reviews in one sitting past the first few - * hundred files: beyond this the read stops and says it was cut short. - */ const FILES_VIEWED_MAX_PAGES = 5; /** Files cleared or restored per request; a reader rarely ticks more than a page of them at once. */ const FILES_VIEWED_PER_REQUEST = 100; -/** - * How many pull requests' node ids are remembered at once. A long-lived server sees far more of - * them than a reader ever has open, and least recently used rather than first in: a listing - * walking cold pull requests must not evict the review being ticked through. - */ export const NODE_ID_CACHE_CAPACITY = 128; -/** - * Pages of review threads to follow before the conversation is reported as truncated. GitHub - * serves a hundred threads a page, so this is a thousand threads β€” past anything a pull request - * a person is reading has, and short of walking a repository-sized conversation forever. - */ const REVIEW_THREAD_PAGES = 10; -/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ const CHECK_CONTEXT_PAGES = 10; export interface GitHubPullRequestListBatch { @@ -410,14 +368,13 @@ export interface GitHubPullRequestStat { */ const STAT_ALIASES_PER_REQUEST = 25; /** - * How long a summary read waits for company. The background sync asks for every linked pull - * request at once, and each read reaches the resolver after its own cache check, so a batch - * needs a moment longer than one scheduler tick to gather them. + * The background sync asks for every linked pull request at once, and each read reaches the + * resolver after its own cache check, so a batch needs a moment longer than one scheduler + * tick to gather them. */ const SUMMARY_BATCH_WINDOW = "10 millis"; export interface GitHubPullRequestSearchBatch { - /** Rows across every repository asked for, newest update first, each naming its own. */ readonly items: ReadonlyArray; readonly truncated: boolean; } @@ -426,15 +383,12 @@ export interface GitHubPullRequestDiffSlice { readonly patch: string; /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ readonly truncated: boolean; - /** Where the next slice starts, or null once the patch is whole. */ readonly nextCursor: string | null; - /** GitHub's own counts for the files whose hunks it withheld from this slice. */ readonly omittedFileStats?: ReadonlyArray; } export interface GitHubPullRequestFilesViewed { readonly files: ReadonlyArray; - /** GitHub had more files than the page budget below would read. */ readonly truncated: boolean; } @@ -469,21 +423,12 @@ export class GitHubPullRequestApi extends Context.Service< readonly involvement: PullRequestInvolvement; readonly viewer: string; readonly limit: number; - /** Free text for `--search`, matched as one literal phrase. */ readonly query?: string | undefined; - /** Where to carry on from, as a `updated:` qualifier on the same search. */ readonly cursor?: ProviderListCursor | undefined; - /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ readonly filters?: PullRequestListFilters | undefined; }) => Effect.Effect; - /** - * The same listing for a whole host in one search. `limit` is the size of the slice across - * all of the repositories rather than per repository, because that is what a search answers: - * the newest rows of the lot, which is exactly the page. - */ readonly searchPullRequests: (input: { - /** Any checkout on the host; the search names its repositories itself. */ readonly cwd: string; readonly host: string; readonly repositories: ReadonlyArray; @@ -496,7 +441,6 @@ export class GitHubPullRequestApi extends Context.Service< readonly filters?: PullRequestListFilters | undefined; }) => Effect.Effect; - /** The line counts the search leaves out, for rows already on the page. */ readonly listPullRequestStats: (input: { readonly cwd: string; readonly host: string; @@ -513,8 +457,8 @@ export class GitHubPullRequestApi extends Context.Service< readonly number: number; }) => Effect.Effect; /** - * What a watch compares between passes, batched like summaries. Null when GitHub gave no - * answer for this pull request, so the watch reads it in full instead. + * Null when GitHub gave no answer for this pull request, so the watch reads + * it in full instead. */ readonly getPullRequestWatchFingerprint: (input: { readonly cwd: string; @@ -553,10 +497,6 @@ export class GitHubPullRequestApi extends Context.Service< readonly isCrossRepository: true; }) => Effect.Effect, GitHubPullRequestApiError>; - /** - * The host-native stack this pull request is in, or null when it is in none β€” which is also - * the answer for a host that refuses the stacks preview altogether. - */ readonly getPullRequestStack: (input: { readonly cwd: string; readonly includeDetails?: boolean; @@ -577,9 +517,7 @@ export class GitHubPullRequestApi extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ readonly cursor?: string | undefined; - /** One commit's own changes, rather than everything the pull request carries. */ readonly commit?: string | undefined; }) => Effect.Effect; @@ -597,10 +535,6 @@ export class GitHubPullRequestApi extends Context.Service< GitHubPullRequestApiError >; - /** - * Which files of the pull request the signed-in account has cleared, and which of those have - * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL. - */ readonly getPullRequestFilesViewed: (input: { readonly cwd: string; readonly repository: string; @@ -609,8 +543,8 @@ export class GitHubPullRequestApi extends Context.Service< }) => Effect.Effect; /** - * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, - * so a burst is batched with aliases into one document rather than one subprocess per press. + * GitHub takes a single path per mutation, so a burst is batched with aliases into one + * document rather than one subprocess per press. */ readonly setPullRequestFilesViewed: (input: { readonly cwd: string; @@ -627,7 +561,6 @@ export class GitHubPullRequestApi extends Context.Service< readonly number: number; }) => Effect.Effect; - /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ readonly listActorAvatars: (input: { readonly cwd: string; readonly repository: string; @@ -644,17 +577,14 @@ export class GitHubPullRequestApi extends Context.Service< readonly cursor: string; }) => Effect.Effect; - /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ readonly getViewerAccess: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - /** Manual action checks may use the quota held back from automatic reads. */ readonly allowReserve?: boolean | undefined; }) => Effect.Effect; - /** Who this pull request may be sent to, and who it has already been sent to. */ readonly listReviewerCandidates: (input: { readonly cwd: string; readonly repository: string; @@ -671,11 +601,9 @@ export class GitHubPullRequestApi extends Context.Service< readonly id: string; readonly kind: PullRequestReviewerKind; }>; - /** False deletes the same collection a request posts to, which takes the request back. */ readonly requested: boolean; }) => Effect.Effect; - /** The repository's labels, and which of them this pull request already wears. */ readonly listLabelCandidates: (input: { readonly cwd: string; readonly repository: string; @@ -689,7 +617,6 @@ export class GitHubPullRequestApi extends Context.Service< readonly host: string; readonly number: number; readonly labels: ReadonlyArray; - /** False takes each label off; true adds each to whatever is already there. */ readonly applied: boolean; }) => Effect.Effect; @@ -740,12 +667,6 @@ export class GitHubPullRequestApi extends Context.Service< readonly resolved: boolean; }) => Effect.Effect; - /** - * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls - * reactable β€” a comment, a review, or the pull request itself, which is looked up here - * because nothing in the conversation names it. A given `subjectId` is confirmed to belong - * to this pull request before the mutation runs, since nothing else ties the two together. - */ readonly setReaction: (input: { readonly cwd: string; readonly repository: string; @@ -756,7 +677,6 @@ export class GitHubPullRequestApi extends Context.Service< readonly reacted: boolean; }) => Effect.Effect; - /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ readonly updatePullRequest: (input: { readonly cwd: string; readonly repository: string; @@ -766,11 +686,6 @@ export class GitHubPullRequestApi extends Context.Service< readonly body?: string | undefined; }) => Effect.Effect; - /** - * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed - * to belong to this pull request before the mutation runs, the way a reaction subject is. - * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here. - */ readonly updateComment: (input: { readonly cwd: string; readonly repository: string; @@ -783,11 +698,6 @@ export class GitHubPullRequestApi extends Context.Service< } >()("@t3tools/source-control-github/server/GitHubPullRequestApi") {} -/** - * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. - * The host is not read off the identity: it travels alongside it, because the identity a - * project records is the path below its host and never names the host itself. - */ function parseRepositorySelector(value: string): { readonly owner: string; readonly name: string; @@ -797,38 +707,27 @@ function parseRepositorySelector(value: string): { } /** - * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor - * arrives from the reader as a string and goes straight into a request path, so it is parsed - * rather than trusted; the length bound keeps a page number out of exponential notation. + * The cursor arrives from the reader as a string and goes straight into a request path, so + * it is parsed rather than trusted; the length bound keeps a page number out of exponential + * notation. */ function diffCursorPage(cursor: string): number | null { return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; } -/** - * A commit sha arrives from the reader and goes straight into a request path, so it is checked - * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a - * whole sha. - */ function isCommitSha(value: string): boolean { return /^[0-9a-f]{7,64}$/i.test(value); } /** - * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole - * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so - * text typed into a search box could widen the very listing it is meant to narrow β€” inside them - * it is only text. The two characters that could end the phrase early are therefore escaped - * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which - * would let everything after it out of the phrase. - * - * The phrase is one argv element, so nothing in it can become a flag of its own either. + * Quoting is the whole defence: outside quotes GitHub reads `is:merged` as a qualifier and + * `label:x` as another, so text typed into a search box could widen the very listing it is + * meant to narrow β€” inside them it is only text. */ function searchPhrase(query: string): string { return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; } -/** GitHub's own spelling of a review state, which is not the contract's. */ const REVIEW_QUALIFIERS = { approved: "approved", "changes-requested": "changes_requested", @@ -837,9 +736,9 @@ const REVIEW_QUALIFIERS = { } as const; /** - * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the - * one character that could end the quoted value early is dropped rather than escaped: no GitHub - * label or login holds a double quote, so there is nothing to preserve and everything to lose. + * Values a reader typed are quoted, and the one character that could end the quoted value early + * is dropped rather than escaped: no GitHub label or login holds a double quote, so there is + * nothing to preserve and everything to lose. */ function qualifierValue(value: string): string { return `"${value.replaceAll('"', "").trim()}"`; @@ -867,14 +766,6 @@ function filterQualifiers( ]; } -/** - * The same narrowings over a row that has already arrived, for the search-free fallback. Every - * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by - * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an - * absent state on purpose β€” a row with no checks configured, or whose checks are still `pending`, - * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would - * not have surfaced for `status:success` or `status:failure`. - */ function matchesFilters( item: GitHubPullRequestListItem, filters: PullRequestListFilters | undefined, @@ -898,7 +789,6 @@ function matchesFilters( ); } -/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */ function matchesUnsortedListing( item: GitHubPullRequestListItem, input: { @@ -919,20 +809,12 @@ function matchesUnsortedListing( return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); } -/** What a repository selector may hold before it goes into a search as itself. */ const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; /** - * A listing as one GitHub search, across one repository or several: the only way to read a - * whole host in one request, and the only order (`updated`) a continuation can carry on from. - * - * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is - * `is:closed is:unmerged` here. - * - * Null where a repository is not `owner/name`. A name is written into the query as itself, and a - * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its - * own β€” so an unaddressable one refuses the whole read rather than being escaped into something - * GitHub might still read. + * A name is written into the query as itself, and a name holding a space could otherwise end + * the `repo:` qualifier and start a qualifier of its own β€” so an unaddressable one refuses the + * whole read rather than being escaped into something GitHub might still read. */ function searchQuery(input: { readonly repositories: ReadonlyArray; @@ -949,17 +831,14 @@ function searchQuery(input: { const query = input.query?.trim() ?? ""; return [ "is:pr", - // "all" is every state, which `is:pr` already is. ...(input.state === "open" ? ["is:open"] : []), ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []), ...(input.state === "merged" ? ["is:merged"] : []), ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []), ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []), ...(query.length === 0 ? [] : [searchPhrase(query)]), - // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), ...filterQualifiers(input.filters, input.viewer), - // The order the page reads its rows in, and the only order a continuation can carry on from. "sort:updated-desc", ...repositories.map((repository) => `repo:${repository}`), ].join(" "); @@ -1023,11 +902,8 @@ export const make = Effect.gen(function* () { function* (input: { readonly cwd: string; readonly host: string }) { const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); const host = input.host.toLowerCase(); - // A missing or signed-out credential keeps its own error, so the page can say which. const { token, fingerprint: key } = yield* api.credential(host); const credential = { host, token, credentialFingerprint: key }; - // A cold page may ask several times. Wait per credential and check again after the - // first verification; cancellation releases the next waiter without losing its request. return yield* Effect.acquireUseRelease( Effect.sync(() => { const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; @@ -1205,9 +1081,6 @@ export const make = Effect.gen(function* () { const key = `${input.host} ${owner}/${name} ${input.number}`; const held = nodeIds.get(key); if (held !== undefined) { - // Put back at the end on every hit, so what falls out is the pull request nobody has looked - // at rather than the one being ticked through: a run of cold reads would otherwise evict the - // open review and make it pay a round trip per press. nodeIds.delete(key); nodeIds.set(key, held); return Effect.succeed(held); @@ -1229,8 +1102,7 @@ export const make = Effect.gen(function* () { }; /** - * Whether a client-given subject actually belongs to the pull request the request names. A - * subject id is trusted to be whatever node it names, and that node can hang off any pull + * A subject id is trusted to be whatever node it names, and that node can hang off any pull * request on the host β€” so the mutation itself would write wherever the id actually belongs, * not wherever the request says it does, unless this confirms the two agree first. */ @@ -1277,16 +1149,6 @@ export const make = Effect.gen(function* () { ), ); - /** - * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed - * files, and still serves those files' hunks here. - * - * A page is a whole number of files, so each one parses on its own; the caller carries on from - * `nextCursor` for as long as GitHub keeps handing pages back. - * - * A named commit is read from the commit endpoint, which lists the same file entries and pages - * them the same way, only wrapped in an object. - */ const diffFilesPage = (input: { readonly cwd: string; readonly repository: string; @@ -1311,9 +1173,7 @@ export const make = Effect.gen(function* () { .pipe( Effect.flatMap((response) => { // Checked before decoding: a byte-truncated response is a JSON prefix, which would - // fail to parse. Nothing of this page can be shown, and an empty patch would render - // as a change with no files rather than as the failure it is; slices already handed - // over stay with the reader either way. + // fail to parse. if (response.truncated) { return Effect.fail( readError( @@ -1363,8 +1223,6 @@ export const make = Effect.gen(function* () { timeout: DIFF_TIMEOUT, }); const refs = decodeRevisionRefs(refsResponse.body); - // A root commit has no parent, which is an absent old revision. Every file in it is new, - // so that is a usable answer whenever the caller does not need the old side. const baseRef = Option.isSome(refs) ? input.commit === undefined ? refs.value.base?.sha @@ -1532,10 +1390,6 @@ export const make = Effect.gen(function* () { const workflowApprovalReadError = (cwd: string, cause: unknown) => readError(cwd, "listWorkflowRunsRequiringApproval", cause); - /** - * Every open pull request whose head branch carries this name, up to one past the limit. - * `truncated` says some were never read, which is never enough to call a head unique. - */ const listHeadsByBranch = (input: { readonly cwd: string; readonly repository: string; @@ -1571,10 +1425,6 @@ export const make = Effect.gen(function* () { return { heads: pages.flatMap((page) => page.heads), truncated }; }); - /** - * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is - * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did. - */ const listActionRequiredRuns = (input: { readonly cwd: string; readonly repository: string; @@ -1583,7 +1433,6 @@ export const make = Effect.gen(function* () { readonly headBranch: string; }) => Effect.gen(function* () { - // The checks revalidator may have just confirmed every run of this head as current. const known = yield* KnownWorkflowRuns; if (known !== null && known.headSha === input.headSha) { return known.runs.flatMap((run) => @@ -1930,21 +1779,7 @@ export const make = Effect.gen(function* () { ); // GitHub does not index every repository for search, and one it will not search answers // with no rows rather than with an error β€” so an empty listing is read again from the - // repository's own list. Those rows come back newest-created first, an order no `updated:` - // qualifier can carry on from, so that page says it cannot be continued and the reader - // reaches the rest of it by asking for a larger page, as every listing used to. - // - // Only ever the first slice: a repository that answered the search once will answer it - // again, so an empty slice under a cursor is a repository that has run out. - // A text search that finds nothing has found nothing: falling back would answer it with the - // repository's whole list, which is every row the reader did not search for. The fallback - // is for a repository the index does not cover, and a listing with no text to match is the - // only place an empty answer can mean that. - // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just - // as well as search judges them over its own, so carrying them into the fallback answers - // the same read rather than a wider one. Free text is the one thing the fallback cannot - // judge locally β€” it lists rows, it does not search their text β€” so a query still rules - // the fallback out: an empty answer under one is already the answer. + // repository's own list. const hasQuery = (input.query?.trim().length ?? 0) > 0; return read(true).pipe( Effect.filterOrElse( @@ -2000,9 +1835,6 @@ export const make = Effect.gen(function* () { }), ); } - // One extra row reveals that the host has more than the slice shows, the way the - // per-repository read does β€” up to GitHub's own ceiling on a search page, past which - // `hasNextPage` is what says there is more. const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS); return client .PullRequestSearch({ @@ -2164,15 +1996,12 @@ export const make = Effect.gen(function* () { if (input.commit !== undefined && !isCommitSha(input.commit)) { return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); } - // A cursor only ever comes from the files walk, so a reader carrying one is already past - // the point where `gh pr diff` had anything to say. if (input.cursor !== undefined) { const page = diffCursorPage(input.cursor); return page === null ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) : filesPage(page); } - // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. if (input.commit !== undefined) { return filesPage(1); } @@ -2188,20 +2017,12 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.flatMap((response) => - // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor - // something the reader can carry on from. The files API can serve the same change a - // whole number of files at a time, so an oversized patch takes that road as well. response.truncated ? filesPage(1) - : // One read served the whole patch, so there is no next slice to ask for. - Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), + : Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), ), // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read - // from the files API instead, a page per call. Only once the direct read has failed: a - // pull request GitHub will serve a diff for must not pay for a second request. A - // fallback that fails too reports the original refusal, which is the one that explains - // the page. Narrowed to a request GitHub answered and refused: a missing credential or - // a rate limit fails the same way for every request. + // from the files API instead, a page per call. Effect.catchTags({ GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), }), @@ -2269,8 +2090,6 @@ export const make = Effect.gen(function* () { for (const login of read.botLogins) botLogins.add(login); for (const [login, avatarUrl] of read.avatarsByLogin) avatarsByLogin.set(login, avatarUrl); - // The roster, the commits and the viewer's standing travel with every page, and the - // first one already carries all of them. if (page === 0) { reviewers = read.reviewers; reactions = read.reactions; @@ -2287,9 +2106,6 @@ export const make = Effect.gen(function* () { page += 1; } while (cursor !== null && page < REVIEW_THREAD_PAGES); - // Almost never entered: the embedded page already holds every dismissal a pull request - // ordinarily accrues. Followed so a review whose event fell past that page still finds - // its reason. if (dismissalCursor !== null) { let after: string | null = dismissalCursor; const pages: Array<{ @@ -2328,8 +2144,6 @@ export const make = Effect.gen(function* () { comments: reviewThreadConversation(reviewThreads), dismissalsByReviewId, reviewThreads, - // GitHub's own count of each thread, so the number the page shows is the host's even - // where a bound kept some of the words on GitHub. commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), reviewThreadsTruncated: cursor !== null, @@ -2380,8 +2194,6 @@ export const make = Effect.gen(function* () { setReviewerRequest: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - // Posting to a login GitHub has already been asked about is what a re-request is, so - // there is nothing to say here about somebody who has reviewed once already. return api .rest({ host: input.host, @@ -2407,9 +2219,6 @@ export const make = Effect.gen(function* () { setLabels: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - // A pull request is an issue to the labels API. Adding posts a list and leaves what was - // already there; taking off is one delete per label, since the endpoint names one in its - // path. The name goes into the path encoded, because a label may carry a space or a slash. const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; if (input.applied) { return api @@ -2640,7 +2449,6 @@ export const make = Effect.gen(function* () { Effect.flatMap(found(target)), ); if (action === "update-branch") { - // Already current is done, the way `gh pr update-branch` reports it. if (state.baseRef?.compare?.behindBy === 0) return; // GitHub updates with a merge commit unless asked to rebase, which is its own default. return yield* client @@ -2677,8 +2485,6 @@ export const make = Effect.gen(function* () { } // A merge queue takes a pull request through auto-merge rather than a direct merge, and // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does. - // GitHub stores the strategy with a standing instruction rather than choosing one at - // merge time, so arming still names it. const auto = state.isMergeQueueEnabled === true || (action === "enable-auto-merge" && @@ -2709,8 +2515,6 @@ export const make = Effect.gen(function* () { submitReview: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - // The whole review is one request, so nothing is visible to anyone else until the verdict - // is sent. return api .rest({ host: input.host, diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index d98fbc108f12..7073c288f06a 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -192,7 +192,6 @@ it.effect("uses one narrow read for a linked pull request summary", () => expect(summary.state).toBe("open"); expect(summaryReads).toBe(1); - // The author's avatar comes from the login-shaped URL, not a second request. expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80"); }), ); @@ -267,7 +266,6 @@ describe("gitHubViewerPermissions", () => { didAuthor: false, }), ).toEqual({ - // Arming a merge for later is the merge, so it travels with it. actions: [ "delete-branch", "merge", @@ -290,8 +288,6 @@ describe("gitHubViewerPermissions", () => { }); it("leaves a passer-by on a repository they can only read nothing but the review", () => { - // Every open-source pull request somebody else opened: GitHub says no to all five actions - // and to resolving, and yes to commenting and to every verdict. expect( gitHubViewerPermissions({ canWrite: false, @@ -304,7 +300,6 @@ describe("gitHubViewerPermissions", () => { comment: true, resolve: false, verdicts: ["comment", "approve", "request-changes"], - // Asking somebody else to review is the one thing read access never stretches to. requestReviewers: false, labels: false, }); @@ -331,11 +326,9 @@ describe("gitHubViewerPermissions", () => { didAuthor: true, }), ).toEqual({ - // Merging is the one thing writing is needed for, now or later; the rest an author may do. actions: ["ready", "draft", "close", "reopen"], comment: true, resolve: true, - // GitHub refuses an author's approval of their own change, so the page does not offer one. verdicts: ["comment"], requestReviewers: false, labels: false, @@ -1100,8 +1093,6 @@ describe("loginAvatarUrl", () => { }); it("has nothing for an app, which names no page", () => { - // `dependabot[bot]` has a picture, but not at `/dependabot[bot].png` β€” a guess that 404s is - // worse than the initials it would replace. expect(loginAvatarUrl("dependabot[bot]", "github.com")).toBeNull(); }); diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index 3a99c6d072ba..7ab10f263949 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -46,18 +46,6 @@ const CAPABILITIES: PullRequestCapabilities = { }; /** - * What the signed-in account may do here, from the three things GitHub says about it. - * - * Merging needs a role that can push, which is the one thing a stranger on an open-source - * repository never has. The other four actions go by `viewerCanUpdate`, because the author of a - * pull request may close it, reopen it and move it in and out of draft with no more than read - * access on the repository it was opened against. - * - * Commenting and reviewing are not gated at all: read access is enough to say something and - * enough to approve or ask for changes, which is what open-source review consists of. Resolving a - * conversation is the exception β€” GitHub allows it to whoever can write, and to the author of the - * pull request the conversation is on. - * * Asking somebody else for a review needs write access, which is the one thing here an author * cannot do on their own pull request: GitHub shows an outside contributor the reviewer control * and refuses the request behind it. @@ -91,12 +79,10 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, requestReviewers: access.canWrite, ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), - // Triage is the one role that labels without writing, which is what triage is for. labels: access.canTriage, }; } -/** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ export function gitHubProviderFailure( error: GitHubPullRequestApi.GitHubPullRequestApiError, ): PullRequestProviderFailure { @@ -123,12 +109,7 @@ export function gitHubProviderFailure( /** * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are - * applied here by login. An actor already carrying one keeps it. - * - * A login GitHub did not answer for falls back to the picture every GitHub install serves at - * `/.png`. The lookup is one more request per repository and can be refused β€” a rate - * limit, a slow host β€” and a face that comes and goes between two loads of the same page reads - * as a bug in the page rather than as a request that failed quietly. + * applied here by login. */ function withAvatar( actor: PullRequestActor | null, @@ -186,7 +167,6 @@ export function loginAvatarUrl(login: string, host: string): string | null { return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; } -/** True where markdown would render nothing: whitespace, or only HTML comments. */ const rendersEmpty = (body: string): boolean => body.replace(//g, "").trim().length === 0; @@ -293,8 +273,6 @@ export const make = Effect.gen(function* () { host: input.host, ids: [...new Set(page.items.flatMap((item) => item.authorId ?? []))], }) - // A listing without faces is still a listing, so a failed lookup falls back to - // the initials rather than taking the rows down with it. .pipe( Effect.orElseSucceed(() => new Map()), Effect.map((avatarsByLogin) => ({ @@ -308,11 +286,6 @@ export const make = Effect.gen(function* () { ), ), - /** - * The same listing for a whole host in one search. The avatar lookup the per-repository read - * needs is not here: a search reports an author's picture itself, so a face costs no request - * of its own β€” `withAvatar` still stands behind it for the login GitHub answered nothing for. - */ listChangeRequestsAcross: (input) => cli .searchPullRequests({ @@ -349,8 +322,6 @@ export const make = Effect.gen(function* () { getChangeRequestSummary: (input) => cli.getPullRequestSummary(input).pipe( - // `gh pr view` names the author without an avatar; the login-shaped URL every user - // has stands in, without the second request the listing spends on it. Effect.map((summary) => ({ ...summary, ...(summary.author === undefined @@ -412,8 +383,7 @@ export const make = Effect.gen(function* () { Effect.all( [ cli.getPullRequestActivity(input), - // Line comments live on review threads, which `gh pr view --json` cannot reach. A - // GraphQL hiccup degrades to a truncated conversation rather than blanking activity. + // Line comments live on review threads, which `gh pr view --json` cannot reach. cli.listReviewThreadComments(input).pipe( Effect.orElseSucceed(() => ({ comments: [], @@ -520,9 +490,6 @@ export const make = Effect.gen(function* () { if (input.includeUpdateBranch === false) { return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions"))); } - // The core detail already carries the viewer's access, the merge settings, and the base - // comparison, so one read usually answers what used to take three. When that heavier read - // fails, the light access read still answers, withholding only update-branch. return cli.getPullRequestDetail(input).pipe( Effect.provideService(SourceControlRateLimit.Interactive, true), Effect.map((pullRequest) => diff --git a/packages/source-control-github/src/server/GitHubQuota.test.ts b/packages/source-control-github/src/server/GitHubQuota.test.ts index 4c2c707cde7d..f74788e2ebb8 100644 --- a/packages/source-control-github/src/server/GitHubQuota.test.ts +++ b/packages/source-control-github/src/server/GitHubQuota.test.ts @@ -20,7 +20,6 @@ describe("GitHubQuota", () => { yield* quota.observe("github.com", headers(499)); const refused = yield* Effect.flip(quota.admit("github.com", "graphql")); assert.strictEqual(refused.retryAt, RESET); - // Another quota, another host and another account each have their own balance. yield* quota.admit("github.com", "core"); yield* quota.admit("ghe.example", "graphql"); yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b")); @@ -38,10 +37,8 @@ describe("GitHubQuota", () => { yield* quota.admit("github.com", "graphql"); yield* quota.observe("github.com", headers(400)); yield* Effect.flip(quota.admit("github.com", "graphql")); - // An answer from an older window says nothing about this one. yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000)); yield* Effect.flip(quota.admit("github.com", "graphql")); - // A later window replaces it. yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000)); yield* quota.admit("github.com", "graphql"); }).pipe(Effect.provide(GitHubQuota.layer)), diff --git a/packages/source-control-github/src/server/GitHubQuota.ts b/packages/source-control-github/src/server/GitHubQuota.ts index 3bdffb80e629..e0da9f0b838a 100644 --- a/packages/source-control-github/src/server/GitHubQuota.ts +++ b/packages/source-control-github/src/server/GitHubQuota.ts @@ -7,13 +7,8 @@ import * as Ref from "effect/Ref"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -/** The share of each quota a background read leaves for a user's next click. */ const RESERVE_RATIO = 0.1; -/** - * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …). - * Each is spent and reset on its own, so each is tracked on its own. - */ type GitHubQuotaResource = string; interface QuotaSnapshot { @@ -22,10 +17,6 @@ interface QuotaSnapshot { readonly resetAtMs: number; } -/** - * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer - * carries. Null when the answer named no quota, which an Enterprise host with limits off does. - */ function quotaFromHeaders( headers: Readonly>, ): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { @@ -47,16 +38,8 @@ function quotaFromHeaders( } /** - * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that - * would dip below it is refused until the quota resets, so a user's next click still has budget. - * - * The balance is GitHub's own count from the latest answer's headers, so it already includes - * what anything else spent with the same token, such as an agent's `gh` commands. Requests in - * flight together all see that one balance; the reserve absorbs what they spend before their - * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub - * spends nothing, and a local count would drift below GitHub's for the rest of the window. - * - * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. + * Nothing is debited locally, because a 304 or a request that never reached GitHub spends + * nothing, and a local count would drift below GitHub's for the rest of the window. */ export class GitHubQuota extends Context.Service< GitHubQuota, @@ -101,10 +84,9 @@ const make = Effect.gen(function* () { const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); yield* Ref.update(snapshots, (current) => { const previous = current.get(key); - // The latest answer wins. GitHub serves requests from several regions, so a later answer - // can report more left in the same window, and keeping the lower one would hold - // background work at the reserve until the reset. An answer from an older window says - // nothing of this one. + // GitHub serves requests from several regions, so a later answer can report + // more left in the same window, and keeping the lower one would hold background + // work at the reserve until the reset. if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; const next = new Map(current); next.set(key, { diff --git a/packages/source-control-github/src/server/GitHubRest.test.ts b/packages/source-control-github/src/server/GitHubRest.test.ts index 92c7dde57fd1..c8f0779a854d 100644 --- a/packages/source-control-github/src/server/GitHubRest.test.ts +++ b/packages/source-control-github/src/server/GitHubRest.test.ts @@ -94,7 +94,6 @@ describe("environmentToken", () => { expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe( "github", ); - // An enterprise token only goes to the host GH_HOST names, never to whatever a remote says. expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBeNull(); expect( GitHubCredentials.environmentToken("git.acme.internal", { @@ -159,7 +158,6 @@ describe("GitHubRest", () => { yield* TestClock.setTime(NOW); const api = yield* GitHubRest.GitHubRest; const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; - // The first answer leaves 499 of 5000, under the last tenth; the next background read waits. yield* api.rest(read); const refused = yield* Effect.flip(api.rest(read)); expect(refused).toMatchObject({ diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 1fbe0dd10cb6..e1e48092a5c6 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -171,7 +171,6 @@ function harness(input: { >; readonly localBranches?: ReadonlyArray; readonly resolved?: string; - /** Fails the local branch listing, the first git read a checkout makes. */ readonly gitFailure?: unknown; }) { const git: Array = []; @@ -314,7 +313,6 @@ describe("GitHubSourceControlProvider repository resolution", () => { return Effect.gen(function* () { const gh = yield* GitHubSourceControlProvider.make; yield* gh.getDefaultBranch({ cwd: "/repo" }); - // A provider's host hint for the same alias (`github` here) resolves the same way. yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("github") }); assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); }).pipe(Effect.provide(layer)); @@ -381,7 +379,6 @@ describe("GitHubSourceControlProvider change request lookups by head", () => { context: githubContext("github.com"), }) .pipe(Effect.forkChild); - // Each branch's own git reads come first, so a sweep's lookups arrive spread out. const firstLookup = yield* lookup("feature/a"); yield* TestClock.adjust("200 millis"); const secondLookup = yield* lookup("feature/b"); @@ -614,7 +611,6 @@ describe("GitHubSourceControlProvider writes", () => { }); assert.strictEqual(requests[0]!.method, "POST"); assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); - // A user's own write, so the background's reserve is not held against it. assert.strictEqual(requests[0]!.allowReserve, true); assert.deepStrictEqual(requests[0]!.body, { base: "main", @@ -692,7 +688,6 @@ describe("GitHubSourceControlProvider writes", () => { "code.example.test GET user", "code.example.test GET repos/me/notes", ]); - // Malformed names are still refused before any request. for (const repository of ["me/", "/notes", "a/b/c/d", "", "two words"]) { const error = yield* Effect.flip(gh.getRepositoryCloneUrls({ cwd: "/repo", repository })); assert.include(error.message, "Repositories are named owner/name."); @@ -753,7 +748,6 @@ describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { return Effect.gen(function* () { const gh = yield* GitHubSourceControlProvider.make; yield* Effect.flip(gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true })); - // Nothing touched the local branches: `main` must not be reset to the fork's commit. assert.deepStrictEqual(git, []); }).pipe(Effect.provide(layer)); }); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index a3187ec19df9..68b83d88098a 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -111,10 +111,6 @@ function authAccounts(accounts: ReadonlyArray) { })); } -/** - * Reads `gh auth status --json hosts`. The headline account is the one GitHub requests will - * use: Settings can pin a login per host or turn a host off, and an environment token beats both. - */ export function parseGitHubAuth( input: SourceControlAuthProbeInput, settings: GitHubClient.GitHubSettings = readSourceControlHostSettings(GitHubClient.settings, {}), @@ -123,7 +119,6 @@ export function parseGitHubAuth( const authStatus = parseGitHubAuthStatus(input.stdout); const hosts = [...new Set(authStatus.accounts.map((entry) => entry.host))]; const fallback = findAuthenticatedGitHubAccount(authStatus.accounts); - // Lead with the host gh would pick, unless Settings turned it off. const orderedHosts = fallback ? [fallback.host, ...hosts.filter((host) => host !== fallback.host)] : hosts; @@ -173,7 +168,7 @@ export function parseGitHubAuth( } // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit - // non-zero, which reads exactly like a signed-out CLI. Name the real problem instead. + // non-zero, which reads exactly like a signed-out CLI. if (input.exitCode !== 0 && output.includes("unknown flag: --json")) { return providerAuth({ status: "unknown", @@ -211,16 +206,13 @@ const decodeViewer = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Struct({ login: Schema.String })), ); -/** The environment variable gh would take a github.com token from, if one is set. */ function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null { return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null; } /** - * GitHub is usable with a token saved in Settings, one from the environment, or `gh` to hand one - * over. Reads the - * GitHub settings on every probe, so a saved account choice shows on rescan. An environment - * token is checked against the API, since `gh auth status` may not know it. + * An environment token is checked against the API, since `gh auth status` may + * not know it. */ export const makeDiscovery = Effect.gen(function* () { const api = yield* GitHubRest.GitHubRest; @@ -246,11 +238,9 @@ export const makeDiscovery = Effect.gen(function* () { process, spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); - // A token saved in Settings wins over the environment, which wins over gh. const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; const variable = environmentTokenVariable(environment); const tokenSource = savedToken ? "the token saved in Settings" : variable; - // A host turned off in Settings stays off even with a token. if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) return cli; const viewer = yield* api .rest({ host: "github.com", operation: "discovery", path: "user" }) @@ -258,7 +248,6 @@ export const makeDiscovery = Effect.gen(function* () { const login = Result.isSuccess(viewer) ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login : undefined; - // The per-host logins stay, so the account picker still lists every host gh knows. const accounts = cli.auth.accounts === undefined ? {} : { accounts: cli.auth.accounts }; return { ...cli, @@ -274,8 +263,7 @@ export const makeDiscovery = Effect.gen(function* () { : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`, }) : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" - ? // Only a refusal says the token is bad; a network error or a pause says nothing. - providerAuth({ + ? providerAuth({ status: "unknown", host: "github.com", detail: `Could not check ${savedToken ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`, @@ -292,12 +280,10 @@ export const makeDiscovery = Effect.gen(function* () { } satisfies SourceControlProviderDiscoveryItem; }), /** - * Claims a custom host GitHub holds a credential for, from whichever source supplies it - * (Settings, the environment or gh). A host turned off in Settings is claimed too, so its - * error says so instead of "unsupported host". + * A host turned off in Settings is claimed too, so its error says so + * instead of "unsupported host". */ refineUnknownRemote: ({ context }) => { - // Identity resolution also probes providers before a web base URL is known. const url = URL.parse(context.provider.baseUrl); if (!url?.host) return Effect.succeed(null); // A timeout or keyring failure propagates: the registry does not keep that answer. @@ -322,10 +308,6 @@ export const makeDiscovery = Effect.gen(function* () { } satisfies SourceControlManagedCliDiscoverySpec; }); -/** - * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the - * operation and request context; `cause` keeps the raw failure for logs, never for transport. - */ class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ readonly detail: string; readonly cause: unknown; @@ -336,7 +318,6 @@ const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; -/** `notFound` names what was missing, which only the operation knows. */ function fromGitHubApiError( error: GitHubRequestPolicy.GitHubApiError, notFound = PULL_REQUEST_NOT_FOUND, @@ -349,7 +330,6 @@ function fromGitHubApiError( ); case "GitHubNotSignedInError": case "GitHubHostDisabledError": - // A missing or turned-off credential already says what to do about it. return failure(error.message, error); case "GitHubApiAuthenticationError": return failure( @@ -367,7 +347,6 @@ function fromGitHubApiError( case "GitHubCliFailedError": case "GitHubApiResponseError": case "GitHubApiRequestError": - // GitHub's own reason ("A pull request already exists…") when it gave one. return failure(error.message.trim() || "GitHub request failed.", error); } } @@ -402,15 +381,15 @@ const GRAPHQL_STATES: Record< all: ["OPEN", "CLOSED", "MERGED"], }; /** - * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one - * single lookup, but half that keeps each answer near half a second. + * A document of a hundred costs one point, the same as one single lookup, but + * half that keeps each answer near half a second. */ const HEAD_LOOKUPS_PER_DOCUMENT = 50; /** - * How long a head lookup waits for company. Branch discovery reaches GitHub only after each - * branch's own git reads, so lookups started together arrive tens of milliseconds apart. - * A background sweep's lookups spread over up to ~300ms, and every document costs a point no - * matter how few heads it holds, so reads without reserve wait longer. + * Branch discovery reaches GitHub only after each branch's own git reads, so lookups started + * together arrive tens of milliseconds apart. A background sweep's lookups spread over up to + * ~300ms, and every document costs a point no matter how few heads it holds, so reads + * without reserve wait longer. */ const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; @@ -420,13 +399,7 @@ const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; * processing limit; twenty-five took ~7s. */ const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; -/** A full document is 5,000 rows of well under 2 KB each. */ const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; -/** - * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch - * named like a busy default (`main`) is the case this bounds; the owner's own row is near the - * top because the newest come first. - */ const OWNER_HEAD_SCAN_LIMIT = 100; function trimmed(value: string): string | null { @@ -458,15 +431,12 @@ function toPullRequestRecord(node: PullRequestNode): NormalizedGitHubPullRequest }); } -/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ export function pullRequestCheckoutBranchName(input: { readonly headRefName: string; readonly headOwner: string | null; readonly isCrossRepository: boolean; readonly defaultBranch: string | null; }): string { - // gh prefixes the owner only where the fork's branch would take over the default branch's - // name, which is the one collision every fork pull request from `main` would hit. return input.isCrossRepository && input.headOwner !== null && input.defaultBranch !== null && @@ -475,7 +445,6 @@ export function pullRequestCheckoutBranchName(input: { : input.headRefName; } -/** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => context === undefined ? undefined : new URL(context.provider.baseUrl).host; @@ -497,10 +466,6 @@ export const make = Effect.gen(function* () { timeoutMs: 5_000, }); - /** - * Uses GH_REPO, then gh's remote selection on the detected host. The provider context is - * a fallback when the checkout's remotes cannot resolve that repository. - */ const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( function* (input: { readonly cwd: string; @@ -584,11 +549,6 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), ); - /** - * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the - * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive together - * share one GraphQL document. - */ const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { readonly cwd: string; readonly headSelector: string; @@ -600,8 +560,6 @@ export const make = Effect.gen(function* () { }) { const locator = yield* resolveRepository(input); const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); - // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the - // owner is matched on the rows it returns. const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); const result = yield* graphqlRead( @@ -731,9 +689,6 @@ export const make = Effect.gen(function* () { }); /** - * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a - * fork gets a remote of its own), checked out under the name gh would give it, and set to - * track the head. A head branch that is gone is read from the base's `refs/pull//head`. * An existing branch fast-forwards, or with `force` is reset to the pull request. */ const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( @@ -785,7 +740,6 @@ export const make = Effect.gen(function* () { defaultBranch, }); - /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ const headRemote = Effect.gen(function* () { if (!isCrossRepository) return yield* baseRemote; if (headNameWithOwner === null) return yield* failure("The fork is gone."); @@ -805,7 +759,6 @@ export const make = Effect.gen(function* () { .listLocalBranchNames(input.cwd) .pipe(Effect.mapError(gitFailure))).includes(localBranch); - // The commit to check out, fetched from the head branch where it still exists. const target = yield* Effect.gen(function* () { const remoteName = yield* headRemote; yield* git.fetchRemoteTrackingBranch({ @@ -858,7 +811,6 @@ export const make = Effect.gen(function* () { }, ); - /** Reports a failure against the operation and request that made it, with transport-safe context. */ const providerError = ( operation: string, @@ -933,8 +885,7 @@ export const make = Effect.gen(function* () { // `owner:branch` or `remote:branch` selector silently lists zero pull requests while // spending a GraphQL call; the bare branch is always among the selectors. Without the owner, // a bare branch also lists same-named branches on other forks (`main`, `patch-1`), so read a - // full page and let the owner check pick the right head. gh fetches up to 100 in one - // request, and GitHub prices a first:100 connection like first:1. + // full page and let the owner check pick the right head. headBranchProbe: ({ headSelectors }) => ({ headSelectors: headSelectors.filter((selector) => !selector.includes(":")), limit: 100, @@ -951,7 +902,6 @@ export const make = Effect.gen(function* () { return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); }, listChangeRequests: (input) => - // An open lookup is a user waiting on a status; the rest may be a background sweep. (input.state === "open" ? Effect.succeed(true) : SourceControlRateLimit.Interactive).pipe( Effect.flatMap((allowReserve) => listByHead({ @@ -990,7 +940,6 @@ export const make = Effect.gen(function* () { host: locator.host, operation: "createPullRequest", method: "POST", - // A user's own write may spend the reserve the background leaves for it. allowReserve: true, path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. @@ -1014,7 +963,6 @@ export const make = Effect.gen(function* () { Effect.map((locator) => locator.host), Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), )).toLowerCase(); - // A bare name is the signed-in account's repository, the way `gh repo view` reads it. const bareName = input.repository.trim().replace(/\.git$/i, ""); const locator = parseGitHubRepositorySelector(input.repository, fallbackHost) ?? diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index cc13f4f78fa3..8aec1f6c40ac 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -1,10 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -/** - * The GitHub driver: repository operations and discovery through `gh` and git, and pull requests - * through the GitHub REST and GraphQL APIs. - * - * @module source-control-github/server/driver - */ import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; diff --git a/packages/source-control-github/src/server/gitHubAuthStatus.ts b/packages/source-control-github/src/server/gitHubAuthStatus.ts index c894879a68d3..2319e04cc5df 100644 --- a/packages/source-control-github/src/server/gitHubAuthStatus.ts +++ b/packages/source-control-github/src/server/gitHubAuthStatus.ts @@ -25,7 +25,6 @@ export interface GitHubAuthStatusAccount { readonly authenticated: boolean; readonly active: boolean; readonly error: string | null; - /** The variable (GH_TOKEN and kin) the login came from; it overrides every stored login. */ readonly environmentVariable: string | null; } @@ -80,11 +79,6 @@ export function findAuthenticatedGitHubAccount( ); } -/** - * The login GitHub requests for a host will use, honoring Settings: an environment token - * first (gh's own precedence), then the pinned account, then gh's active login. - * Returns undefined when the host is turned off or has no usable login. - */ export function effectiveGitHubAccount( host: string, accounts: ReadonlyArray, diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts index e6915c6bf01e..284f38605824 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts @@ -1,9 +1,3 @@ -/** - * GitHub's pull request template convention: the first single template found at a known path, - * or the only Markdown file in a template directory, read from the committed base tree. - * - * @module source-control-github/server/gitHubChangeRequestTemplate - */ import type { GitCommandError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; diff --git a/packages/source-control-github/src/server/gitHubConditionalChecks.test.ts b/packages/source-control-github/src/server/gitHubConditionalChecks.test.ts index 2f1398ff46d6..efda61f7d78c 100644 --- a/packages/source-control-github/src/server/gitHubConditionalChecks.test.ts +++ b/packages/source-control-github/src/server/gitHubConditionalChecks.test.ts @@ -83,7 +83,7 @@ it.effect( ); yield* poll(); expect(reads).toBe(1); - // The fork's runs were just confirmed, so the read is handed them instead of listing them. + expect(known[0]).toEqual({ headSha: sha, runs: [{ id: 9, status: "completed", conclusion: "action_required" }], diff --git a/packages/source-control-github/src/server/gitHubConditionalChecks.ts b/packages/source-control-github/src/server/gitHubConditionalChecks.ts index 5d5f4df4907b..ebef5ba5f7b1 100644 --- a/packages/source-control-github/src/server/gitHubConditionalChecks.ts +++ b/packages/source-control-github/src/server/gitHubConditionalChecks.ts @@ -37,10 +37,6 @@ const decodeWorkflowRuns = Schema.decodeUnknownOption( export type KnownWorkflowRun = typeof WorkflowRunSchema.Type; -/** - * Every `pull_request` workflow run of one head, as the revalidator just confirmed them against - * GitHub. A read running under it filters these rather than asking for the runs again. - */ export const KnownWorkflowRuns = Context.Reference<{ readonly headSha: string; readonly runs: ReadonlyArray; @@ -50,7 +46,6 @@ export const KnownWorkflowRuns = Context.Reference<{ type Validator = { etag: string | undefined; next: boolean; body: string }; -/** A host that answers these with one of them has no conditional REST for it at all. */ const UNSUPPORTED_STATUSES = new Set([404, 405, 501]); export const makeChecksRevalidator = Effect.gen(function* () { @@ -90,7 +85,6 @@ export const makeChecksRevalidator = Effect.gen(function* () { operation: "revalidateChecks", status, }); - /** Whether the endpoint has a next page, or null where the host cannot say. */ const get = (endpoint: string, head = false) => Effect.gen(function* () { const previous = entry.validators.get(endpoint); @@ -165,8 +159,6 @@ export const makeChecksRevalidator = Effect.gen(function* () { } const now = yield* Clock.currentTimeMillis; if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value; - // Every page of the head's runs was just confirmed current, so the read filters them - // rather than listing the runs a second time. const pages = runPages.map((endpoint) => decodeWorkflowRuns(entry.validators.get(endpoint)?.body ?? ""), ); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 12c3c0b7014e..8d6272f6ce6f 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -236,10 +236,6 @@ const decodePullRequestFilesViewedJson = through( toPullRequestFilesViewedPage, ); -/** - * Rows as a repository's own GraphQL list answers them. Entries may use flat shapes for brevity: - * `reviewRequests` and `latestReviews` as arrays, and `checks` as the rollup state. - */ function listJson(entries: ReadonlyArray>): string { return JSON.stringify({ data: { @@ -430,8 +426,6 @@ describe("pull request search decoding", () => { }); it("maps the rollup enum the search answers with onto the same three words", () => { - // The search asks GitHub for the verdict rather than the checks behind it, so this path sees - // one enum where the listing sees an array. const batch = expectSuccess( decodePullRequestSearchJson( searchJson(["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED", null]), @@ -562,7 +556,6 @@ describe("pull request detail decoding", () => { ], }); - /** The same conversation as the GraphQL activity query answers it. */ const activityJson = (raw: Record) => JSON.stringify({ data: { @@ -747,14 +740,12 @@ describe("pull request detail decoding", () => { startedAt: "2026-10-08T04:06:13Z", completedAt: "0001-01-01T00:00:00Z", }; - // A run still queued has not started either, so it carries no time at all. const queued = { ...running, status: "QUEUED", startedAt: "0001-01-01T00:00:00Z" }; const checksOf = (rollup: ReadonlyArray>) => { const detail = detailOf(rollup.map(inWorkflow)); return [detail.checks.map((check) => [check.name, check.status]), detail.checksState]; }; - // Both listing orders, so the run still going wins on its own rather than by coming last. for (const rollup of [ [finished, running], [running, finished], @@ -767,7 +758,6 @@ describe("pull request detail decoding", () => { it("merges reviews with comments in time order and keeps a bodyless approval", () => { const detail = expectSuccess(activity(JSON.parse(detailJson) as Record)); - // r2 approved without writing anything, which is still the event worth seeing. expect(detail.comments.map((comment) => comment.id)).toEqual(["r1", "c1", "r2"]); expect(detail.comments.at(-1)?.reviewState).toBe("APPROVED"); }); @@ -786,8 +776,6 @@ describe("pull request detail decoding", () => { activity({ ...raw, reviews: [ - // What a reviewer leaving inline comments produces: a container with a state but - // nothing to read. Its comments come from the review threads instead. { id: "r4", body: "", state: "COMMENTED", submittedAt: "2026-07-07T00:00:00Z" }, { id: "r5", @@ -841,7 +829,6 @@ describe("review thread decoding", () => { data: { repository: { pullRequest: { reviewThreads: { totalCount, pageInfo, nodes } } } }, }); - /** The same query carries the review roster, so it is built alongside the threads. */ const reviewJson = (input: { readonly requested?: ReadonlyArray; readonly reviewed?: ReadonlyArray; @@ -865,8 +852,6 @@ describe("review thread decoding", () => { decodeReviewThreadsJson( reviewJson({ requested: [{ login: "julius", name: "Julius", avatarUrl: "https://avatars/j.png" }], - // An app that has reviewed is no longer an outstanding request, which is why asking - // only for requests reported nobody on a pull request a bot had reviewed. reviewed: [ { __typename: "Bot", @@ -1016,7 +1001,6 @@ describe("review thread decoding", () => { const result = expectSuccess( decodeReviewThreadsJson( reviewJson({ - // Recorded from pingdotgg/t3code#12329, where CODEOWNERS asked the team. requested: [ { combinedSlug: "pingdotgg/ping-devs", @@ -1207,14 +1191,11 @@ describe("reaction decoding", () => { viewerHasReacted: true, reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "bilal" }] }, }, - // Not one of the eight the contract carries. { content: "PARTY_PARROT", reactors: { totalCount: 1, nodes: [{ login: "hubot" }] }, }, - // Nobody behind it, which GitHub still answers a group for. { content: "HEART", reactors: { totalCount: 0, nodes: [] } }, - // More reactors than the bounded read named, and no `viewerHasReacted` at all. { content: "ROCKET", reactors: { totalCount: 140, nodes: [{ login: "a" }, { login: "b" }, { login: "c" }] }, @@ -1489,7 +1470,6 @@ describe("review thread decoding", () => { }); it("carries what the reader may do with the pull request, off the conversation read", () => { - // The same response the threads arrive in, so knowing this costs no request of its own. expect( expectSuccess( decodeReviewThreadsJson( @@ -1577,7 +1557,6 @@ describe("review thread decoding", () => { isResolved: true, isOutdated: true, path: "src/a.ts", - // GitHub reports no current line once the thread has fallen off the diff. line: null, diffSide: "RIGHT", comments: { totalCount: 1, nodes: [comment("c3", "stale")] }, @@ -1607,8 +1586,6 @@ describe("review thread decoding", () => { ]), ), ); - // A resolved conversation is finished work, not unsaid work: the timeline reads it and the - // diff pins it to its line, the same as any other. const threads = decoded.threads.map((entry) => entry.thread); expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]); expect(threads).toHaveLength(1); @@ -1821,8 +1798,6 @@ describe("reviewer candidate decoding", () => { }); it("keeps a requested team apart from the people, so the request can be taken back", () => { - // A team is never among the assignable users, and a request that cannot be seen cannot be - // undone β€” so the ones GitHub reports are carried, marked as the teams they are. const list = expectSuccess( decodeReviewerCandidatesJson( candidatesJson({ @@ -1833,7 +1808,6 @@ describe("reviewer candidate decoding", () => { ); expect(list.candidates).toEqual([ { - // Asked by slug, shown by the same `org/slug` as its reviewer row. id: "reviewers", kind: "team", login: "acme/reviewers", @@ -2057,7 +2031,6 @@ describe("decodePullRequestFilesJson", () => { const result = expectSuccess( decodePullRequestFilesJson( JSON.stringify([ - // Binary: it changed, and none of it can be shown. { filename: "logo.png", status: "modified", additions: 4, deletions: 2 }, { filename: "src/app.ts", @@ -2070,7 +2043,6 @@ describe("decodePullRequestFilesJson", () => { ), ); - // Dropping it would take the file out of the change altogether, not just its contents. expect(result.patch).toContain("diff --git a/logo.png b/logo.png"); expect(result.patch).toContain("diff --git a/src/app.ts b/src/app.ts"); expect(result.truncated).toBe(true); @@ -2155,7 +2127,6 @@ describe("decodePullRequestFilesViewedJson", () => { }); describe("host-native stack decoding", () => { - /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ function stack(overrides: Record = {}) { return { id: 42, @@ -2179,7 +2150,6 @@ describe("host-native stack decoding", () => { }; } - /** The one stack a listing answered with, which these reads all expect to find. */ function expectStack(overrides: Record = {}) { const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)]))); if (decoded === null) throw new Error("expected a stack"); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 83f4f069dc08..b3c8e6851ec2 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -157,7 +157,6 @@ function nodesOf( return (connection?.nodes ?? []).flatMap((node) => (node === null ? [] : [node])); } -/** GitHub's reaction names, which are the same eight the contract carries under other spellings. */ const REACTION_CONTENT_BY_GITHUB: Readonly> = { THUMBS_UP: "thumbs-up", THUMBS_DOWN: "thumbs-down", @@ -231,11 +230,8 @@ const RawWorkflowRunsSchema = Schema.Struct({ const RawPullRequestFileSchema = Schema.Struct({ filename: Schema.String, status: Schema.optional(Schema.NullOr(Schema.String)), - /** Only on a rename, where it names the file the hunks are counted against. */ previous_filename: Schema.optional(Schema.NullOr(Schema.String)), - /** Absent for a binary file, and for one whose diff GitHub considers too large. */ patch: Schema.optional(Schema.NullOr(Schema.String)), - /** Whether anything was withheld is the difference between a binary file and a pure rename. */ additions: Schema.optional(Schema.NullOr(Schema.Int)), deletions: Schema.optional(Schema.NullOr(Schema.Int)), }); @@ -339,7 +335,6 @@ function gitHubReviewPosition(position: PullRequestReviewPosition): { } } -/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ export function buildReviewSubmission(input: { readonly verdict: PullRequestReviewVerdict; readonly body: string; @@ -358,7 +353,6 @@ export function buildReviewSubmission(input: { export interface GitHubPullRequestListItem { readonly stack?: PullRequestStackMembership; - /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ readonly authorId: string | null; readonly number: number; readonly title: string; @@ -369,24 +363,19 @@ export interface GitHubPullRequestListItem { readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; - /** Null where GitHub has no verdict to summarise, which includes a draft nobody has reviewed. */ readonly reviewDecision: PullRequestReviewDecision | null; readonly additions: number; readonly deletions: number; readonly createdAt: string; readonly updatedAt: string; readonly reviewRequestLogins: ReadonlyArray; - /** At least one outstanding request targets a team rather than an individual login. */ readonly hasTeamReviewRequest: boolean; readonly labels: ReadonlyArray; - /** Null where the head commit reported no checks, which is not the same as passing none. */ readonly checksState: PullRequestChecksState | null; } export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { - /** True only when GitHub says the head belongs to another repository. */ readonly isCrossRepository?: boolean; - /** The owner of the head branch's repository; null where `gh` did not say. */ readonly headRepositoryOwner: string | null; readonly headRepositoryNameWithOwner?: string | null; readonly headSha?: string | null; @@ -395,9 +384,7 @@ export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { readonly mergedAt: string | null; readonly closedAt: string | null; readonly checks: ReadonlyArray; - /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */ readonly autoMergeEnabled?: boolean; - /** Absent where auto-merge is off or GitHub did not report the stored strategy. */ readonly autoMergeMethod?: PullRequestMergeMethod; } @@ -426,9 +413,8 @@ function trimmed(value: string | null | undefined): string | null { } /** - * Null once a connection has nothing further, which is what ends every walk below. GitHub sends - * an `endCursor` on a page that is also the last one, so the flag is what decides, not the - * cursor's presence. + * GitHub sends an `endCursor` on a page that is also the last one, so the flag is what + * decides, not the cursor's presence. */ function nextCursorOf(pageInfo: PageInfoFields | null | undefined): string | null { return pageInfo?.hasNextPage === true ? trimmed(pageInfo.endCursor) : null; @@ -555,8 +541,7 @@ function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod /** * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an * approval from an app (a review bot) or from anyone without the required permission leaves it - * empty. The reviewers still said something, and a row should show it: when GitHub reports no - * verdict, the latest review per reviewer decides, changes requested outranking approval. + * empty. */ function toReviewDecisionWithReviews( value: string | null | undefined, @@ -590,10 +575,6 @@ function toLabels(raw: ReadonlyArray | undefined): ReadonlyArray | undefined, ): ReadonlyArray { @@ -643,7 +624,6 @@ function toCheckStatus(raw: CheckFields): PullRequestCheckStatus { /** What GitHub writes where a run has not reached that moment yet, which is not a time. */ const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; -/** Sorts after every real timestamp, so the deduper keeps a run that has not finished. */ const STILL_RUNNING = "9999-12-31T23:59:59Z"; function realTimestamp(value: string | null | undefined): string | null { @@ -657,10 +637,9 @@ function isNamelessCheck(raw: CheckFields): boolean { } /** - * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last - * had something to say. A run that has not finished outranks any finished run of the same check: - * a workflow triggered by both `push` and `pull_request` runs the check twice on one commit, and - * the run that finished first must not hide the one still going. + * A run that has not finished outranks any finished run of the same check: a workflow triggered + * by both `push` and `pull_request` runs the check twice on one commit, and the run that + * finished first must not hide the one still going. */ function toCheckEntries(raw: ReadonlyArray | null | undefined): ReadonlyArray<{ readonly check: PullRequestCheck; @@ -690,20 +669,6 @@ function toCheckEntries(raw: ReadonlyArray | null | undefined): Rea }); } -/** - * The one word a listing row has space for. A failure outranks anything still running, the way - * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. - * - * Null rather than "passing" for a head commit with no checks at all, so a repository that runs - * none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as - * GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count - * towards neither, so the row and the detail header never disagree about one head commit. - * - * Counted off the deduped checks rather than the raw rollup, so the word and the list under it - * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is - * counted as it comes, since the cross-repository search dresses GitHub's own rollup enum as one - * nameless row, and nothing nameless can collide with anything. - */ function rollupChecksState( raw: ReadonlyArray | null | undefined, ): PullRequestChecksState | null { @@ -723,7 +688,6 @@ function toChecks( return dedupeChecks(toCheckEntries(raw)); } -/** The states that are a verdict in themselves, rather than a wrapper around line comments. */ function isReviewVerdict(reviewState: string | null): boolean { switch (reviewState?.toUpperCase()) { case "APPROVED": @@ -860,8 +824,6 @@ function toDetail(raw: DetailFields): GitHubPullRequestDetail { mergedAt: trimmed(raw.mergedAt), closedAt: trimmed(raw.closedAt), checks: toChecks(raw.statusCheckRollup), - // A JSON null is GitHub saying "nobody armed this"; a missing key is GitHub not saying, and - // the difference survives here rather than being flattened into false. ...(raw.autoMergeRequest === undefined ? {} : { autoMergeEnabled: raw.autoMergeRequest !== null }), @@ -942,11 +904,6 @@ export function toPullRequestRows( }; } -/** - * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as - * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull - * request, or one whose fields no longer decode. - */ function toRow( entry: unknown, ): { readonly item: GitHubPullRequestListItem; readonly repository: string | null } | null { @@ -994,7 +951,6 @@ function rollupStates(commits: { } export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem { - /** `owner/name` as GitHub spells it, which is how a row from a search finds its repository. */ readonly repository: string; } @@ -1016,7 +972,6 @@ export function toStackMembership(raw: { : undefined; } -/** What GitHub allows in an owner or repository name. */ const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; /** @@ -1083,10 +1038,6 @@ export function toPullRequestSummary(pr: SummarizedPullRequest): GitHubPullReque }; } -/** - * Two parts, so a watch reads only what moved: `status` (state, mergeability, head, and checks) - * needs the detail read, and `remarks` also needs the far costlier activity read. - */ export interface GitHubPullRequestWatchFingerprint { readonly status: string; readonly remarks: string; @@ -1133,7 +1084,6 @@ export function toPullRequestWatchFingerprint( } export interface GitHubPullRequestCore extends GitHubPullRequestDetail { - /** Teams asked for a review. `reviewRequestLogins` holds the people. */ readonly reviewRequestTeams: ReadonlyArray; readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; readonly comparison: GitHubBaseComparison | null; @@ -1216,11 +1166,9 @@ export function decodePullRequestDetailJson( export interface GitHubWorkflowRunPage { readonly runs: ReadonlyArray; - /** Runs on the page, counted before decoding, which is what decides whether to page on. */ readonly rawCount: number; } -/** One page of `actions/runs`. */ export function decodeWorkflowRunsJson( raw: string, ): Result.Result { @@ -1254,10 +1202,8 @@ export function toPullRequestHeads(result: PullRequestHeads.Result): { } export interface GitHubPullRequestActivityPage { - /** Only on the first page, which is the one that asks for them. */ readonly author?: PullRequestActor | null; readonly commits?: ReadonlyArray; - /** Issue comments and reviews, each list unsorted. */ readonly remarks: ReadonlyArray; readonly nextCommentsCursor: string | null; readonly nextReviewsCursor: string | null; @@ -1317,7 +1263,6 @@ export function toPullRequestCheckContextsPage(result: PullRequestCheckContexts. }; } -/** The checks and their one-word verdict, from every context of the head commit. */ export function pullRequestChecksFromContexts(contexts: ReadonlyArray): { readonly checks: ReadonlyArray; readonly checksState: PullRequestChecksState | null; @@ -1327,67 +1272,40 @@ export function pullRequestChecksFromContexts(contexts: ReadonlyArray; - /** Dismissal reasons by the dismissed review's node id, read off the timeline. */ readonly dismissalsByReviewId: ReadonlyMap; - /** Whole conversations, kept anchored so the diff can pin them to their line. */ readonly reviewThreads: ReadonlyArray; - /** The host's own count of the conversation, which a bounded read can fall short of. */ readonly commentCount: number; readonly truncated: boolean; readonly reviewThreadsTruncated: boolean; - /** The pull request's own reactions, which sit on its description. */ readonly reactions: ReadonlyArray; - /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */ readonly reactionsById: ReadonlyMap>; readonly editedAtById: ReadonlyMap; - /** - * Everyone on the review: those still asked and those who have already answered. Whoever has - * reviewed is no longer an outstanding request, so asking only for requests reports nobody on - * a pull request that has in fact been reviewed. - */ readonly reviewers: ReadonlyArray; - /** - * Avatars by login, for the actors `gh pr view --json` reports without one β€” which is all of - * them, since no `gh` JSON field carries an avatar. Collected from everyone this query names, - * so an app's avatar arrives the same way a person's does. - */ readonly avatarsByLogin: ReadonlyMap; readonly botLogins: ReadonlySet; - /** Per-commit line counts carried by the same bounded pull-request query. */ readonly commitStats: ReadonlyMap< string, { readonly additions: number; readonly deletions: number } >; /** - * The newest hundred commits, oldest to newest, off the same query's `commits(last: ...)`. * Empty wherever the read never happened (an install too old for the field, a degraded page), * which the caller reads as "keep the `gh pr view` list" rather than as "this pull request has * no commits". */ readonly commits: ReadonlyArray; - /** What GitHub says the reader may do with this pull request, read off the same response. */ readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; } -/** One thread as this page found it, with what it takes to finish reading it. */ export interface GitHubReviewThreadEntry { readonly thread: PullRequestReviewThread; - /** How many comments GitHub says the thread holds, read or not. */ readonly commentCount: number; - /** Where the rest of this thread's comments carry on from, or null once it is whole. */ readonly nextCommentCursor: string | null; } export interface GitHubReviewThreadPage { readonly threads: ReadonlyArray; - /** Where the next page of threads starts, or null once the host has handed them all over. */ readonly nextCursor: string | null; - /** The pull request's own reactions, which sit on its description. */ readonly reactions: ReadonlyArray; - /** - * Reactions by node id, for the conversation comments and reviews `gh pr view --json` answers - * for without any. Only ids with a reaction are here; the rest carry none. - */ readonly reactionsById: ReadonlyMap>; readonly editedAtById: ReadonlyMap; readonly reviewers: ReadonlyArray; @@ -1399,17 +1317,10 @@ export interface GitHubReviewThreadPage { >; readonly commits: ReadonlyArray; readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; - /** Dismissal reasons by the dismissed review's node id, which the review itself never carries. */ readonly dismissalsByReviewId: ReadonlyMap; - /** Where the rest of the dismissal events start, or null once this page carried them all. */ readonly nextDismissalCursor: string | null; } -/** - * The threads as one flat conversation, which is what the timeline reads. Every comment of - * every thread, resolved or not: a resolved conversation is still what was said, and a reply is - * as much of it as the remark it answers. - */ export function reviewThreadConversation( threads: ReadonlyArray, ): ReadonlyArray { @@ -1526,7 +1437,6 @@ export function toReviewThreadPage(result: ReviewThreads.Result): GitHubReviewTh const reviewers = new Map(); for (const raw of [...requestedReviewers, ...latestReviews.map((node) => node.author)]) { const actor = toActor(raw) ?? toRequestedTeam(raw); - // Keyed by login, so someone who was asked and then answered appears once. if (actor !== null && !reviewers.has(actor.login)) reviewers.set(actor.login, actor); } const commitStats = new Map(); @@ -1595,25 +1505,14 @@ export function toReviewThreadCommentsPage(result: ReviewThreadComments.Result): }; } -/** Repository settings returned alongside the pull request viewer permissions. */ export interface GitHubRepositoryAccess { readonly mergeCapabilities: PullRequestMergeCapabilities; readonly canWrite: boolean; } /** - * Whether the viewer's role on the repository is one that can push, which is what merging needs. - * TRIAGE and READ are not: a triager moves issues about and neither of them lands a commit. - * - * An install that reports no permission at all does not count as write. This is the exception to - * "an unknown permission is granted": write is what merging and closing somebody else's change - * need, and offering those to a reader who cannot use them wastes the press and reads as the app - * being wrong. Everything softer β€” commenting, reviewing, resolving β€” keeps the granting default, - * because being unable to say something is the worse failure there. - * - * A null permission is not that. GitHub answers null only to a GitHub App installation, and no - * API says what an installation may do, so null is granted and GitHub refuses whatever the app - * was not given. A person always gets a role, READ at the least. + * GitHub answers null only to a GitHub App installation, and no API says what an installation + * may do, so null is granted and GitHub refuses whatever the app was not given. */ export function toCanWrite(viewerPermission: string | null | undefined): boolean { if (viewerPermission === null) return true; @@ -1627,25 +1526,20 @@ export function toCanWrite(viewerPermission: string | null | undefined): boolean } } -/** Triage is the least role GitHub lets label a pull request; it is not a write. */ function toCanTriage(viewerPermission: string | null | undefined): boolean { return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); } /** - * Who a review may be asked of, and who it has already been asked of, in one read. - * * `assignableUsers` is the list GitHub's own reviewer picker is built from β€” everyone with access * to the repository β€” rather than `collaborators`, which the REST API refuses to anyone without * push access and which would therefore be empty for exactly the reader most likely to be looking. * - * Teams are asked for only where one has already been requested, so a request to a team can be - * taken back. The teams a repository could newly be sent to live on the owning organization and - * need `read:org`, which a repository-scoped token need not carry β€” and a query GitHub refuses - * fails whole, taking the people down with the teams. + * The teams a repository could newly be sent to live on the owning organization and need + * `read:org`, which a repository-scoped token need not carry β€” and a query GitHub refuses fails + * whole, taking the people down with the teams. */ export interface GitHubBaseComparison { - /** Null where the host could not compare, which the page reads as "unknown". */ readonly behindBy: number | null; readonly viewerCanUpdate: boolean; } @@ -1702,11 +1596,6 @@ export function toReviewerCandidates( }; } -/** - * The body of `POST`/`DELETE /repos/{owner}/{repo}/pulls/{number}/requested_reviewers`, which - * takes people and teams in two lists of its own. The same body serves both methods, because - * GitHub takes a request back from exactly whoever it was made of. - */ const ReviewerRequestSchema = Schema.Struct({ reviewers: Schema.Array(Schema.String), team_reviewers: Schema.Array(Schema.String), @@ -1758,27 +1647,16 @@ export function toLabelCandidates( }; } -/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ export function buildLabelRequest(labels: ReadonlyArray): { readonly labels: ReadonlyArray; } { return { labels }; } -/** - * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the - * repository, the other two about this pull request in particular β€” which is why an author with - * only read access can still be told apart from a passer-by. - */ export interface GitHubViewerAccess { readonly canWrite: boolean; readonly canDeleteBranch?: boolean; - /** - * The viewer's role reaches triage, which is the least that may label. Everyone who can write - * can triage; a triager is the one role that can label without being able to merge. - */ readonly canTriage: boolean; - /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ readonly canUpdate: boolean; readonly didAuthor: boolean; /** @@ -1810,18 +1688,14 @@ export function toViewerPermissions( export interface GitHubPullRequestFilesPatch { readonly patch: string; - /** At least one file's hunks were withheld by GitHub, so they are missing from the patch. */ readonly truncated: boolean; - /** Files GitHub returned, counted before decoding, so the caller can page. */ readonly rawCount: number; - /** GitHub's own counts for the files whose hunks it withheld. */ readonly omittedFileStats: ReadonlyArray; } /** * The files API returns hunks per file with no `diff --git` header, so the unified patch every - * diff viewer expects is assembled here. This decodes one page; walking pages is the caller's - * job, which is why the raw file count comes back with the patch. + * diff viewer expects is assembled here. */ export function decodePullRequestFilesJson( raw: string, @@ -1829,10 +1703,6 @@ export function decodePullRequestFilesJson( return Result.map(decodeUnknownList(raw), toFilesPatch); } -/** - * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped - * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it. - */ export function decodeCommitFilesJson( raw: string, ): Result.Result { @@ -1864,13 +1734,10 @@ function toFilesPatch(entries: ReadonlyArray): GitHubPullRequestFilesPa omittedFileStats.push({ path: value.filename, additions, deletions }); } } - // A rename counts its hunks against the old path, which is the only place it is named. const oldPath = status === "renamed" ? value.previous_filename || value.filename : value.filename; const header = [ `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`, - // The files API reports no file mode, so the ordinary one stands in: the viewer reads - // these lines as "added" and "removed" rather than for the mode they carry. ...(status === "added" ? ["new file mode 100644"] : []), ...(status === "removed" ? ["deleted file mode 100644"] : []), ...(status === "renamed" @@ -1897,11 +1764,9 @@ export interface GitHubPullRequestFilesViewedPage { readonly path: string; readonly state: PullRequestFileViewedState; }>; - /** Where the next page carries on, or null once the host has no more to give. */ readonly nextCursor: string | null; } -/** Anything this host does not name is treated as unread, which is the state that asks for least. */ function toFileViewedState(raw: string): PullRequestFileViewedState { switch (raw.trim().toUpperCase()) { case "VIEWED": @@ -1928,7 +1793,6 @@ export function toPullRequestFilesViewedPage( }; } -/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ const RawStackPullRequestSchema = Schema.Struct({ title: Schema.optional(Schema.String), draft: Schema.optional(Schema.Boolean), @@ -1938,12 +1802,6 @@ const RawStackPullRequestSchema = Schema.Struct({ merged_at: Schema.optional(Schema.NullOr(Schema.String)), }); -/** - * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may - * still move. Only what a stack is made of is required β€” where it lives, what it stands on, and - * its pull requests β€” and `base` is accepted both as the ref object the preview sends today and - * as the bare branch name it started out as. - */ const RawStackSchema = Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), number: Schema.Int, @@ -1970,14 +1828,9 @@ export interface GitHubPullRequestStack { readonly number: number; readonly url: string; readonly base: string; - /** Bottom to top, which is the order GitHub lists them in. */ readonly layers: ReadonlyArray; } -/** - * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in - * at most one stack, so the array is GitHub's way of saying "none" rather than a page. - */ export function decodePullRequestStacksJson( raw: string, ): Result.Result { @@ -1988,7 +1841,6 @@ export function decodePullRequestStacksJson( return Result.succeed({ id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), number: stack.number, - // The page a person opens where the preview reports one; the API URL is what it always has. url: trimmed(stack.html_url) ?? stack.url, base: typeof stack.base === "string" ? stack.base : stack.base.ref, layers: stack.pull_requests.map((pullRequest) => ({ diff --git a/packages/source-control-github/src/server/gitHubPullRequests.ts b/packages/source-control-github/src/server/gitHubPullRequests.ts index 24a4c069cf3a..104e85b3afce 100644 --- a/packages/source-control-github/src/server/gitHubPullRequests.ts +++ b/packages/source-control-github/src/server/gitHubPullRequests.ts @@ -13,7 +13,6 @@ export interface NormalizedGitHubPullRequestRecord { readonly url: string; readonly baseRefName: string; readonly headRefName: string; - /** The head commit, when the read asked for `headRefOid`. */ readonly headSha?: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index 3b33f0c38e40..7c6f754192bb 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -31,7 +31,6 @@ describe("selectGitHubBaseRepository", () => { select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), { owner: "acme", name: "other" }, ); - // gh ranks remote names in any case. assert.deepStrictEqual( select({ remotes: remotes( diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index 21f68b4cdb20..bd36758605e1 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -4,14 +4,12 @@ import { isSshRemoteUrl, } from "@t3tools/shared/sourceControl"; -/** A repository on a GitHub host: the API it is read through, and its owner and name. */ export interface GitHubRepositoryLocator { readonly host: string; readonly owner: string; readonly name: string; } -/** The fetch remotes in `git remote -v` output, by name. */ export function parseFetchRemotes( remotes: string, ): ReadonlyArray<{ readonly name: string; readonly url: string }> { @@ -23,14 +21,12 @@ export function parseFetchRemotes( } /** - * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a - * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin - * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of - * `git config --get-regexp '^remote\..*\.gh-resolved$'`. + * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a prompt: the + * remote `gh repo set-default` marked, else the first of upstream, github, origin (in any case), else + * the only remote. * - * Null whenever gh might weigh the remotes differently: a remote on another host or under an - * SSH alias, more than one mark, or several remotes with none of those names. Callers then - * fall back to the provider's remote, then to the best-ranked remote on the host. + * Null whenever gh might weigh the remotes differently: a remote on another host or under an SSH alias, + * more than one mark, or several remotes with none of those names. */ export function selectGitHubBaseRepository(input: { readonly remotes: string; @@ -69,14 +65,12 @@ export function selectGitHubBaseRepository(input: { } /** - * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick, - * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller - * names when no repository was found there. GH_REPO is the caller's to check first. + * `host` is the API host it settled on, which a caller names when no repository was + * found there. GH_REPO is the caller's to check first. */ export function resolveGitHubRepository(input: { readonly remotes: string; readonly resolved: string; - /** The checkout's GitHub API host, when the caller already knows it. */ readonly hostHint: string | undefined; readonly defaultHost: string; }): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } { @@ -114,7 +108,6 @@ export function resolveGitHubRepository(input: { return { host, locator: null }; } -/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ export function parseGitHubRepositorySelector( selector: string, defaultHost: string, @@ -138,10 +131,6 @@ export function parseGitHubRepositorySelector( return null; } -/** - * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request - * URL, or a branch name. - */ export function parsePullRequestReference( reference: string, ): @@ -162,30 +151,26 @@ export function parsePullRequestReference( number: Number(match[3]), }; } - } catch { - // Not a URL after all; read it as a branch. - } + } catch {} } return { kind: "branch", headSelector: trimmed }; } /** - * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH - * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through - * `github.com`, which is what such an alias almost always stands for (issue #6198). + * An SSH alias (`git@github-work:owner/repo`) names no API host of its own; it is read + * through `github.com`, which is what such an alias almost always stands for (issue + * #6198). */ export function gitHubApiHostForRemote(remoteUrl: string): string | null { const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); if (provider === null) return null; const host = new URL(provider.baseUrl).host.toLowerCase(); - // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { return host.includes("github") ? "github.com" : null; } return provider.kind === "github" ? host : null; } -/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ function apiHostForHint(host: string): string { const normalized = host.toLowerCase(); return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; diff --git a/packages/source-control-github/src/server/githubStackActions.test.ts b/packages/source-control-github/src/server/githubStackActions.test.ts index 5ed6e8c7cf2d..44cb6446d383 100644 --- a/packages/source-control-github/src/server/githubStackActions.test.ts +++ b/packages/source-control-github/src/server/githubStackActions.test.ts @@ -25,7 +25,6 @@ type Call = ReadonlyArray; /** The next answer in the script: a REST body, or for GraphQL the `data` to answer with. */ type Send = (call: Call, kind: "graphql" | "rest") => Effect.Effect; -/** Every git command the cascade ran, in order; none actually runs. */ let gitCalls: Array> = []; const fakeGit = Layer.mergeAll( TestSourceControlHost.layer({ diff --git a/packages/source-control-github/src/server/githubStackActions.ts b/packages/source-control-github/src/server/githubStackActions.ts index 7acb6b5ada76..1f2e52b4ff43 100644 --- a/packages/source-control-github/src/server/githubStackActions.ts +++ b/packages/source-control-github/src/server/githubStackActions.ts @@ -83,7 +83,6 @@ export class GitHubStackRebaseFailedError extends Schema.TaggedError()( "GitHubStackRebaseConflictError", { number: Schema.Int, completed: Schema.Int }, @@ -24,7 +22,6 @@ export class GitHubStackRebaseConflictError extends Schema.TaggedError()( "GitHubStackRebaseGitError", { step: Schema.String, number: Schema.Int, completed: Schema.Int, cause: Schema.Defect() }, @@ -34,27 +31,15 @@ export class GitHubStackRebaseGitError extends Schema.TaggedError ` β€” so a rebased lower layer is never replayed into - * the one above it, which is what GitHub's per-PR "update branch" does and why it conflicts. - * - * It works in a throwaway clone, so the environment's checkout never moves, and pushes each - * layer with a lease on the head that was reviewed, so a push that landed meanwhile is refused - * rather than overwritten. The stacks API has no rebase endpoint to call instead. - */ export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (input: { readonly host: string; readonly repository: string; readonly base: string; readonly layers: ReadonlyArray; - /** Where to fetch and push; the repository's own URL on its host unless a test points elsewhere. */ readonly remote?: string; }) { const api = yield* GitHubRest.GitHubRest; diff --git a/packages/source-control-gitlab/src/client/definition.ts b/packages/source-control-gitlab/src/client/definition.ts index 4555cb6abd6c..67cbc507cffc 100644 --- a/packages/source-control-gitlab/src/client/definition.ts +++ b/packages/source-control-gitlab/src/client/definition.ts @@ -1,8 +1,3 @@ -/** - * GitLab's client definition. Browser- and React Native-safe. - * - * @module source-control-gitlab/client/definition - */ import { SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlClient, diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts index b4546162f30c..3121eed13cc9 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts @@ -43,7 +43,6 @@ function mergeRequests(count: number, firstNumber: number): string { ); } -/** A page of `/diffs` as GitLab serves it, a full one unless the count says otherwise. */ function diffPage(firstIndex: number, count = 100): string { return JSON.stringify( Array.from({ length: count }, (_, index) => ({ @@ -54,7 +53,6 @@ function diffPage(firstIndex: number, count = 100): string { ); } -/** A page of merge request notes, which is what the flat conversation is read from. */ function notes(count: number, firstId: number): string { return JSON.stringify( Array.from({ length: count }, (_, index) => ({ @@ -66,11 +64,9 @@ function notes(count: number, firstId: number): string { ); } -/** Who opened the merge request, and somebody already reviewing it. */ const author = { id: 1, username: "bilal" }; const reviewer = { id: 5, username: "octocat" }; -/** One merge request as `/merge_requests/:iid` answers with it. */ function mergeRequestJson(overrides: Record): string { return JSON.stringify({ iid: 7, @@ -85,12 +81,10 @@ function mergeRequestJson(overrides: Record): string { }); } -/** The endpoint or subcommand of the nth glab invocation. */ function argsOfCall(index: number): ReadonlyArray { return callAt(index).args; } -/** The whole nth invocation, so a request body can be asserted alongside its path. */ function callAt(index: number) { const call = mockedExecute.mock.calls[index]; assert.isDefined(call); @@ -167,7 +161,6 @@ layer("GitLabPullRequestCli.layer", (it) => { query: "page", }); - // GitLab matches `search` against title and description, which is more than the row shows. expect(argsOfCall(0)[1]).toContain("search=page"); }), ); @@ -264,7 +257,6 @@ layer("GitLabPullRequestCli.layer", (it) => { const path = argsOfCall(0)[1] ?? ""; expect(path).toContain("search=-a%26per_page%3D1%20%22b%22"); - // The page size the walk fixed is still the only one in the query. assert.strictEqual(path.match(/per_page=/g)?.length, 1); }), ); @@ -310,8 +302,6 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("stops walking when every row on a page fails to decode", () => Effect.gen(function* () { - // Full pages of unusable rows: nothing is collected, so the collected-count bound never - // trips and only the page bound can end the walk. const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" }))); mockedExecute.mockReturnValue(Effect.succeed(output(unusable))); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -326,7 +316,6 @@ layer("GitLabPullRequestCli.layer", (it) => { }); assert.strictEqual(batch.items.length, 0); - // ceil((150 + 1) / 100) pages, not one request per page forever. assert.strictEqual(mockedExecute.mock.calls.length, 2); }), ); @@ -514,7 +503,6 @@ layer("GitLabPullRequestCli.layer", (it) => { "--header", "Content-Type: application/json", ]); - // A JSON body, so a comment reading as a literal `true` stays text. expect(call[0].stdin).toBe('{"body":"true"}'); }), ); @@ -530,10 +518,8 @@ layer("GitLabPullRequestCli.layer", (it) => { number: 7, }); - // One page per call: the reader asks for the rest, the walk does not run on by itself. assert.strictEqual(mockedExecute.mock.calls.length, 1); assert.isNotNull(diff.nextCursor); - // A full page means more files, not a slice with something missing from it. assert.isFalse(diff.truncated); expect(argsOfCall(0)[1]).toContain("merge_requests/7/diffs?per_page=100&page=1"); }), @@ -552,7 +538,6 @@ layer("GitLabPullRequestCli.layer", (it) => { const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); expect(argsOfCall(1)[1]).toContain("page=2"); - // A short page is the end of the change set, so there is nothing to carry on from. assert.isNull(second.nextCursor); expect(second.patch).toContain("diff --git a/src/100.ts b/src/100.ts"); }), @@ -596,8 +581,6 @@ layer("GitLabPullRequestCli.layer", (it) => { const commitPath = "projects/acme%2Fweb/repository/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/diff"; expect(argsOfCall(0)[1]).toBe(`${commitPath}?per_page=100&page=1`); - // The whole path, not just the page: a cursor branch that dropped the commit would still - // ask for page 2, of the merge request's own diff. expect(argsOfCall(1)[1]).toBe(`${commitPath}?per_page=100&page=2`); assert.isNull(second.nextCursor); }), @@ -797,7 +780,6 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("fails a diff page cut off mid-JSON rather than calling the diff whole", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( - // A byte-truncated prefix: valid JSON never survives the cut. Effect.succeed({ ...output('[{"old_path":"src/x.ts","new_p'), stdoutTruncated: true }), ); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -806,8 +788,6 @@ layer("GitLabPullRequestCli.layer", (it) => { cli.getMergeRequestDiff({ cwd: "/w", repository: "acme/web", number: 7 }), ); - // An empty slice with no cursor would report every file from this page on as already - // read, which is the one answer that loses a change without saying so. assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); }), ); @@ -904,7 +884,6 @@ layer("GitLabPullRequestCli.layer", (it) => { it.effect("stops the note walk at its bound and says the conversation was cut short", () => Effect.gen(function* () { - // GitLab that never answers short: the walk has to end itself. mockedExecute.mockReturnValue(Effect.succeed(output(notes(100, 1)))); const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; @@ -951,7 +930,6 @@ layer("GitLabPullRequestCli.layer", (it) => { }, ], }, - // A plain note is the timeline's business, not the diff's. { id: "def456", notes: [{ id: 3, body: "ship it", created_at: "2026-07-01Z" }] }, ]), ), @@ -1014,7 +992,6 @@ layer("GitLabPullRequestCli.layer", (it) => { ], }); - // The diff revisions first, because a positioned comment cannot be placed without them. expect(argsOfCall(0)[1]).toContain("merge_requests/7"); expect(argsOfCall(1)[1]).toContain("/discussions"); expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ @@ -1032,7 +1009,6 @@ layer("GitLabPullRequestCli.layer", (it) => { }, }); expect(argsOfCall(2)[1]).toContain("/notes"); - // The verdict goes last, so a review that failed part-way is never an approval. expect(argsOfCall(3)[1]).toContain("/approve"); }), ); @@ -1151,7 +1127,6 @@ layer("GitLabPullRequestCli.layer", (it) => { reacted: false, }); - // Nothing to delete: the reaction the caller asked to take back is already gone. assert.strictEqual(mockedExecute.mock.calls.length, 2); }), ); @@ -1187,8 +1162,6 @@ layer("GitLabPullRequestCli.layer", (it) => { }), ); - // Nothing failed to decode: GitLab answered, and the answer has nowhere to put a - // positioned comment. assert.strictEqual(error._tag, "GitLabDiffRefsUnavailableError"); }), ); @@ -1209,7 +1182,6 @@ layer("GitLabPullRequestCli.layer", (it) => { }); expect(argsOfCall(1)[1]).toBe("projects/acme%2Fweb/users?per_page=100"); - // The author is left out, and whoever GitLab already has as a reviewer is marked. expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ ["5", true], ["9", false], @@ -1278,7 +1250,6 @@ layer("GitLabPullRequestCli.layer", (it) => { requested: true, }); - // Sending it as a number would rewrite the reviewer set around something nobody chose. expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); }), ); @@ -1321,7 +1292,6 @@ layer("GitLabPullRequestCli.layer", (it) => { description: "What this changes.", }); - // A title sent as an empty string would wipe the one the merge request already has. expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." }); }), ); @@ -1370,7 +1340,6 @@ layer("GitLabPullRequestCli.layer", (it) => { "--header", "Content-Type: application/json", ]); - // A JSON body, so a note rewritten to a literal `true` stays text. expect(callAt(0).stdin).toBe('{"body":"true"}'); }), ); @@ -1412,13 +1381,10 @@ layer("GitLabPullRequestCli.layer", (it) => { paths: ["src/a.ts", "src/gone.ts"], }); - // Every path was looked for at the head, so one the head does not have is one the merge - // request removed: said as the empty version, which a mark on it was stamped with too. expect([...revisions]).toEqual([ ["src/a.ts", "aaa"], ["src/gone.ts", ""], ]); - // The head the reader is looking at, not whatever the source branch has moved on to. const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); expect(body).toMatchObject({ variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] }, @@ -1444,9 +1410,6 @@ layer("GitLabPullRequestCli.layer", (it) => { ), ), ); - // What GitLab says of a project the token cannot see. It is not the head having none of - // these files, and reading it that way would report every file the reader has cleared as - // changed on nothing worse than a permission. mockedExecute.mockReturnValueOnce( Effect.succeed(output(JSON.stringify({ data: { project: null } }))), ); @@ -1529,7 +1492,6 @@ layer("GitLabPullRequestCli.layer", (it) => { assert.strictEqual(revisions.size, 150); assert.strictEqual(revisions.get("src/149.ts"), "oid-src/149.ts"); - // The diff refs, then two batches: a hundred paths and the fifty left over. assert.strictEqual(mockedExecute.mock.calls.length, 3); }), ); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index 7e84236920e2..dd5debf2c99f 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -46,10 +46,6 @@ import { } from "./gitLabMergeRequestJson.ts"; import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; -/** - * Names the read that produced unusable output, so a failure reports the call it came from - * rather than borrowing another operation's message. - */ export class GitLabMergeRequestReadError extends Schema.TaggedError()( "GitLabMergeRequestReadError", { @@ -68,7 +64,6 @@ export class GitLabMergeRequestReadError extends Schema.TaggedError()( "GitLabViewerUnavailableError", { @@ -85,8 +80,6 @@ export class GitLabViewerUnavailableError extends Schema.TaggedError()( "GitLabDiffRefsUnavailableError", { @@ -104,7 +97,6 @@ export class GitLabDiffRefsUnavailableError extends Schema.TaggedError()( "GitLabDiffCursorError", { @@ -121,7 +113,6 @@ export class GitLabDiffCursorError extends Schema.TaggedError()( "GitLabDiffCommitError", { @@ -138,7 +129,6 @@ export class GitLabDiffCommitError extends Schema.TaggedError()( "GitLabDiffCommitParentUnavailableError", { @@ -156,7 +146,6 @@ export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedError()( "GitLabDiffFileContentsUnavailableError", { @@ -189,13 +178,7 @@ export type GitLabPullRequestCliError = /** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */ const MAX_PAGE_SIZE = 100; -/** Commit history is read one page deep; the rest of a long history stays on GitLab. */ const COMMIT_PAGE_SIZE = 100; -/** - * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at - * a hundred, so this is a thousand notes and a thousand discussions β€” more than any merge - * request a person is reading holds, and a walk that ends whatever the host has. - */ const CONVERSATION_PAGES = 10; const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; const DIFF_TIMEOUT_MS = 60_000; @@ -204,15 +187,12 @@ const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; export interface GitLabMergeRequestListBatch { readonly items: ReadonlyArray; readonly truncated: boolean; - /** Raw GitLab rows consumed to produce this page, including malformed rows. */ readonly cursorAdvance: number; } export interface GitLabMergeRequestDiffSlice { readonly patch: string; - /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ readonly truncated: boolean; - /** Where the next slice starts, or null once the patch is whole. */ readonly nextCursor: string | null; } @@ -230,9 +210,7 @@ export class GitLabPullRequestCli extends Context.Service< readonly involvement: PullRequestInvolvement; readonly viewer: string; readonly limit: number; - /** Free text for GitLab's own `search`, which matches title and description. */ readonly query?: string | undefined; - /** Where to carry on from in GitLab's stable update-ordered row set. */ readonly cursor?: ProviderListCursor | undefined; }) => Effect.Effect; @@ -261,9 +239,7 @@ export class GitLabPullRequestCli extends Context.Service< readonly cwd: string; readonly repository: string; readonly number: number; - /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ readonly cursor?: string | undefined; - /** One commit's own changes, rather than everything the merge request carries. */ readonly commit?: string | undefined; }) => Effect.Effect; @@ -285,14 +261,6 @@ export class GitLabPullRequestCli extends Context.Service< readonly repository: string; }) => Effect.Effect; - /** - * What the merge request's head has of each of these paths, as blob ids. - * - * The head sha comes from the merge request's own diff refs, so the answer is the version a - * reader is looking at rather than whatever the source branch has moved on to. A path the - * head does not have is answered as the empty revision, since the batch it was asked in was - * looked at; a batch GitLab did not answer for is left out instead. - */ readonly getFileRevisions: (input: { readonly cwd: string; readonly repository: string; @@ -300,11 +268,6 @@ export class GitLabPullRequestCli extends Context.Service< readonly paths: ReadonlyArray; }) => Effect.Effect, GitLabPullRequestCliError>; - /** - * Who this merge request may be sent to, and who it has already been sent to. Two reads at - * once, because GitLab keeps the people with access on the project and the reviewers on the - * merge request, and neither answers for the other. - */ readonly listReviewerCandidates: (input: { readonly cwd: string; readonly repository: string; @@ -327,7 +290,6 @@ export class GitLabPullRequestCli extends Context.Service< readonly mergeMethod?: PullRequestMergeMethod; }) => Effect.Effect; - /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */ readonly updateMergeRequest: (input: { readonly cwd: string; readonly repository: string; @@ -385,7 +347,6 @@ export class GitLabPullRequestCli extends Context.Service< readonly resolved: boolean; }) => Effect.Effect; - /** The awards on the merge request and on every note of it, keyed by the note's REST id. */ readonly listReactions: (input: { readonly cwd: string; readonly repository: string; @@ -398,10 +359,6 @@ export class GitLabPullRequestCli extends Context.Service< GitLabPullRequestCliError >; - /** - * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent - * awards the merge request itself, which is where its description's reactions live. - */ readonly setReaction: (input: { readonly cwd: string; readonly repository: string; @@ -413,7 +370,6 @@ export class GitLabPullRequestCli extends Context.Service< } >()("@t3tools/source-control-gitlab/server/GitLabPullRequestCli") {} -/** The REST API addresses a project by its URL-encoded full path. */ function projectPath(repository: string): string { return encodeURIComponent(repository.trim()); } @@ -435,8 +391,6 @@ function gitLabReviewPositionLines( } function stateParam(state: PullRequestListState): string { - // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, - // and it spans every state under `all`. return state === "open" ? "opened" : state; } @@ -454,20 +408,10 @@ function involvementParams(input: { } } -/** - * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor - * arrives from the reader as a string and goes straight into a query, so it is parsed rather - * than trusted; the length bound keeps a page number out of exponential notation. - */ function diffCursorPage(cursor: string): number | null { return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; } -/** - * A commit sha arrives from the reader and goes straight into a request path, so it is checked - * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a - * whole sha. - */ function isCommitSha(value: string): boolean { return /^[0-9a-f]{7,64}$/i.test(value); } @@ -495,8 +439,6 @@ function actionArgs( ...(mergeMethod === "squash" ? ["--squash"] : []), ...(mergeMethod === "rebase" ? ["--rebase"] : []), ]; - // The same command with the flag the other way up: here the wait is the whole point, so - // glab is told to arm the merge rather than talked out of it. case "enable-auto-merge": return [ "merge", @@ -505,7 +447,6 @@ function actionArgs( ...(mergeMethod === "squash" ? ["--squash"] : []), ...(mergeMethod === "rebase" ? ["--rebase"] : []), ]; - // Never reached: taking the arming back has no `glab mr` command, so it goes to the API. case "disable-auto-merge": return []; case "ready": @@ -514,14 +455,10 @@ function actionArgs( return ["update", "--draft"]; case "close": return ["close"]; - // A rebase, because GitLab has no other way to move a branch onto its target: there is no - // merge-the-target-in equivalent of GitHub's update button, which is why this host declares - // `rebase` alone and never has to read the method it was handed. case "update-branch": return ["rebase"]; case "reopen": return ["reopen"]; - // Never reached: this host does not declare the action, so the service refuses it first. case "revert": case "approve-workflows": case "delete-branch": @@ -559,13 +496,6 @@ export const make = Effect.gen(function* () { ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), }); - /** - * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is - * bounded twice over: it stops on a short page or once the extra row that reveals a next - * page has been read, and it never asks for more pages than the caller's page needs. The - * second bound is what makes it terminate when every row on a page fails to decode, which - * leaves nothing collected but does not mean GitLab has run out of rows. - */ const listPage = (input: { readonly cwd: string; readonly repository: string; @@ -595,10 +525,6 @@ export const make = Effect.gen(function* () { path: `projects/${projectPath(input.repository)}/merge_requests?${query([ ["state", stateParam(input.state)], ...involvementParams(input), - // The listing is read through `glab api` rather than `glab mr list`, so the search is - // the REST API's own `search` parameter β€” the one `mr list --search` passes on. It - // matches title and description, and travels URL-encoded like every other value here, - // so no text in it can become a parameter of its own. ...searchParams(input.query), ["order_by", "updated_at"], ["sort", "desc"], @@ -674,14 +600,6 @@ export const make = Effect.gen(function* () { ); }; - /** - * One page of a merge request's files, as a patch that stands on its own. GitLab pages - * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the - * caller carries on from it for as long as GitLab keeps handing full pages back. - * - * A named commit is read from the commit's own diff, which answers in the same shape and pages - * the same way. - */ const diffPage = (input: { readonly cwd: string; readonly repository: string; @@ -743,12 +661,6 @@ export const make = Effect.gen(function* () { }), ); - /** - * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short - * page is the only thing that says it is done β€” and the raw count decides, not the kept one: - * the notes GitLab wrote itself are dropped, and a whole page of them still means there is - * more to read. - */ const notesPage = (input: { readonly cwd: string; readonly repository: string; @@ -792,7 +704,6 @@ export const make = Effect.gen(function* () { }), ); - /** The positioned discussions, walked the same way and stopped by the same bound. */ const discussionsPage = (input: { readonly cwd: string; readonly repository: string; @@ -837,8 +748,8 @@ export const make = Effect.gen(function* () { ); /** - * The revisions a positioned comment is written against. GitLab resolves a comment's line - * against these three shas, so a review with line comments cannot be sent without them. + * GitLab resolves a comment's line against these three shas, so a review with line + * comments cannot be sent without them. */ const getDiffRefs = (input: { readonly cwd: string; @@ -861,8 +772,6 @@ export const make = Effect.gen(function* () { }), ); } - // A merge request with no diff refs is a well-formed answer that cannot carry a - // positioned comment β€” a dead end, but not something that failed to be read. return decoded.success === null ? Effect.fail( new GitLabDiffRefsUnavailableError({ @@ -915,10 +824,6 @@ export const make = Effect.gen(function* () { }), ); - /** - * The merge request itself, which several calls need for different parts of it: the detail for - * everything, and the reviewer paths for the ids GitLab writes a reviewer set with. - */ const mergeRequestDetail = (input: { readonly cwd: string; readonly repository: string; @@ -926,8 +831,6 @@ export const make = Effect.gen(function* () { }): Effect.Effect => api({ cwd: input.cwd, - // How far behind the target branch this one is comes only when asked for by name, and it - // is asked for here rather than on a second read because it is the same merge request. path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([ ["include_diverged_commits_count", "true"], ])}`, @@ -947,7 +850,6 @@ export const make = Effect.gen(function* () { }), ); - /** The people with access to the project, one page deep. */ const projectUsers = (input: { readonly cwd: string; readonly repository: string; @@ -973,7 +875,6 @@ export const make = Effect.gen(function* () { }), ); - /** Where an award is written: a note of the merge request, or the merge request itself. */ const awardSubjectPath = (input: { readonly repository: string; readonly number: number; @@ -985,11 +886,6 @@ export const make = Effect.gen(function* () { : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`; }; - /** - * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same - * count as the conversation itself: awards past the notes that were read belong to notes the - * page is not showing. - */ const awardsPage = (input: { readonly cwd: string; readonly repository: string; @@ -1118,9 +1014,6 @@ export const make = Effect.gen(function* () { // a reader has cleared as changed over a project the token cannot see. if (page === null) continue; for (const [path, oid] of page) revisions.set(path, oid); - // Within a batch that was answered, every path was looked for at the head, so - // one that is not there is one the merge request removed. Said as the empty - // revision, which is an answer the caller can compare against and keep. for (const path of paths) { if (!revisions.has(path)) revisions.set(path, ""); } @@ -1180,8 +1073,7 @@ export const make = Effect.gen(function* () { return; } // GitLab deletes an award by its id and takes no emoji name there, so the reader's own - // award of that name is looked up first. Nothing to delete is success: the reaction the - // caller asked to take back is already gone. + // award of that name is looked up first. const viewer = yield* viewerUsername({ cwd: input.cwd }); const listed = yield* api({ cwd: input.cwd, path: subject }); const own = decodeOwnAwardIdJson(listed.stdout.trim(), { @@ -1342,13 +1234,10 @@ export const make = Effect.gen(function* () { Effect.flatMap((mergeRequest) => { // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the // whole set, so the set that is already there is read first and the change applied to - // it. Asking again for somebody already on it writes the same set back, which is how - // GitLab re-requests a review. + // it. const ids = new Set(mergeRequest.reviewerIds); for (const reviewer of input.reviewers) { const id = Number(reviewer.id); - // A candidate GitLab did not name is not an id it would accept, and sending it would - // rewrite the reviewer set around a number nobody chose. if (!Number.isSafeInteger(id) || id <= 0) continue; if (input.requested) ids.add(id); else ids.delete(id); @@ -1460,8 +1349,6 @@ export const make = Effect.gen(function* () { cwd: input.cwd, path: `${mergeRequest}/notes`, method: "POST", - // A JSON body rather than a `--raw-field`, for the reason the plain comment gives: - // glab coerces a field that reads as a literal `true` or a number. // @effect-diagnostics-next-line preferSchemaOverJson:off stdin: JSON.stringify({ body: input.body }), }); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts index 6800bc467541..903d44d8ca40 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts @@ -8,7 +8,6 @@ import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts"; describe("gitLabViewerPermissions", () => { it("offers everything to a viewer GitLab says can merge", () => { expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({ - // Arming a merge for later and taking the arming back answer to the same `can_merge`. actions: [ "merge", "ready", @@ -22,17 +21,12 @@ describe("gitLabViewerPermissions", () => { comment: true, resolve: true, verdicts: ["comment", "approve"], - // GitLab says nothing about who may set a reviewer, and an unreported permission is granted. requestReviewers: true, - // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so - // offering the choice would be offering something no request could carry out. updateMethods: ["rebase"], }); }); it("keeps merge, now and later, from a viewer GitLab says cannot", () => { - // `user.can_merge` already accounts for the role, the approval rules and a protected target - // branch, so it is the one answer here that does not have to be inferred. expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({ actions: ["ready", "draft", "close", "reopen"], comment: true, @@ -43,15 +37,10 @@ describe("gitLabViewerPermissions", () => { }); it("names no way of updating a branch it will not let this viewer update", () => { - // The action and the strategy behind it go together: a button offered with nothing to press - // it with, or a strategy left standing next to a withheld button, is a half-refusal. expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined(); }); it("treats an author with read access as any other reader, which is all GitLab says", () => { - // Its REST API names no relationship between the viewer and the merge request beyond - // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken - // from the one person entitled to them. expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([ "ready", "draft", @@ -144,8 +133,6 @@ describe("getChangeRequest base freshness", () => { it.effect("says nothing at all where GitLab counted nothing", () => Effect.gen(function* () { - // An install too old to answer has to leave the page silent rather than let it claim the - // branch is current, which is the one wrong thing this banner could say. const changeRequest = yield* readWith({}); expect(changeRequest.baseComparison).toBe("unknown"); @@ -177,7 +164,6 @@ describe("rewriting what has already been said", () => { body: "What this changes.", }); - // GitLab calls it the description, and the title stays out of the request entirely. expect(updateMergeRequest).toHaveBeenCalledWith({ cwd: "/w", repository: "acme/web", diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index a25d5314ef4d..9dcea0fa54c8 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -20,18 +20,14 @@ const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, actions: [...definition.changeRequestActions], - // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. mergeMethods: ["merge", "squash", "rebase"], // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing - // that merges the target back in the way GitHub's update button can. Declaring only what it - // does is what lets a request to merge the target in be refused instead of quietly rebasing. + // that merges the target back in the way GitHub's update button can. updateMethods: ["rebase"], search: true, reactions: true, // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside - // that browser can read or write them. So the marks made here are this environment's own: they - // follow the reader between the clients connected to it, but they are not the ones gitlab.com - // shows, and the surface says so rather than implying a review can be carried on from there. + // that browser can read or write them. viewedFiles: "environment", review: { inlineComment: true, @@ -58,25 +54,14 @@ const MERGE_ACTIONS: ReadonlySet = new Set([ ]); /** - * What the signed-in account may do here. GitLab answers exactly one of these questions per - * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing - * narrowed. - * - * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never - * whether they opened this merge request β€” and its author may close it, reopen it and move it in - * and out of draft whatever their role, just as the author of a note may resolve the discussion - * it started. Withholding those controls from the one person entitled to them is the worse of the - * two mistakes, so they are offered and GitLab explains any refusal itself. - * - * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author - * and from anyone with the Developer role, and states neither of those two facts here. + * GitLab's REST API reports the viewer's role on the project but never whether they opened this + * merge request β€” and its author may close it, reopen it and move it in and out of draft + * whatever their role, just as the author of a note may resolve the discussion it started. */ export function gitLabViewerPermissions(input: { readonly viewerCanMerge: boolean; }): PullRequestViewerPermissions { return { - // Arming the merge and taking the arming back are the merge, deferred, so they answer to - // the same `can_merge` the merge itself does. actions: CAPABILITIES.actions.filter( (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge, ), @@ -88,7 +73,6 @@ export function gitLabViewerPermissions(input: { }; } -/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ export function gitLabProviderFailure( error: GitLabPullRequestCli.GitLabPullRequestCliError, ): PullRequestProviderFailure { @@ -132,8 +116,6 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError(fail("listChangeRequests")), - // GitLab is asked for its merge requests by update, newest first, whether or not it is - // being carried on from β€” so every page it answers is one a cursor can continue. Effect.map((batch) => ({ ...batch, continues: true })), ), @@ -180,8 +162,6 @@ export const make = Effect.gen(function* () { cli .listDiscussions(input) .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))), - // The notes endpoint carries no award of any kind, so they are read alongside it. A - // failed read costs the conversation its reactions rather than its words. cli.listReactions(input).pipe( Effect.orElseSucceed(() => ({ reactions: [] as ReadonlyArray, @@ -198,9 +178,6 @@ export const make = Effect.gen(function* () { ...comment, reactions: awards.reactionsByNoteId.get(comment.id) ?? [], })), - // GitLab reports no count of its own, so the walk's own total is the host's: the - // notes endpoint carries every comment on the merge request, including the ones - // written under a discussion, and it is read until GitLab runs out. commentCount: notes.comments.length, commentsTruncated: notes.truncated || discussions.truncated, reviewThreads: discussions.threads.map((thread) => ({ @@ -214,8 +191,6 @@ export const make = Effect.gen(function* () { })), ), - // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge - // request, so there is no cheaper thing to ask GitLab. getViewerPermissions: (input) => cli .getMergeRequestDetail(input) @@ -226,9 +201,8 @@ export const make = Effect.gen(function* () { getDiffFileContents: (input) => cli.getMergeRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), - // What each marked file is at the head, which is what tells a mark that still stands from one - // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too, - // so this stales at the same moment its web UI would. + // GitLab's own local-storage marks are keyed on the blob id too, so this stales at the same + // moment its web UI would. getFileRevisions: (input) => cli.getFileRevisions(input).pipe( Effect.mapError(fail("getFileRevisions")), @@ -281,8 +255,6 @@ export const make = Effect.gen(function* () { comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), - // The kind is not read: every comment this provider hands out, positioned or not, carries a - // plain REST note id, and one endpoint rewrites both. updateComment: (input) => cli .updateNote({ diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts index c12e4e6100d4..1c4268ba4004 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -230,7 +230,6 @@ it("tells a host glab has no login for apart from output that cannot say", () => auth: { exitCode: ChildProcessSpawner.ExitCode(1), stdout: "", stderr }, }); - // A settled "no" is cached; anything else makes the next read ask glab again. assert.strictEqual( refine("x git.example.test has not been authenticated with glab. Run `glab auth login`"), null, diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index e7d62ab8e8c4..c8cd955b3f79 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -80,7 +80,6 @@ function parseGitLabAuth(input: SourceControlAuthProbeInput) { }); } -// What glab prints when it holds no login for the host, across its 1.x releases. const GITLAB_HOST_NOT_LOGGED_IN_PATTERN = /\bnot (?:been )?authenticated with glab\b|\bno gitlab instances? ha(?:s|ve) been authenticated\b/iu; diff --git a/packages/source-control-gitlab/src/server/driver.ts b/packages/source-control-gitlab/src/server/driver.ts index 56d75ccc64de..2b555786015a 100644 --- a/packages/source-control-gitlab/src/server/driver.ts +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -1,9 +1,4 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; -/** - * The GitLab driver: repository operations, discovery, and merge requests through `glab`. - * - * @module source-control-gitlab/server/driver - */ import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts index 1cb81087e0c8..9618914e28a5 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts @@ -200,10 +200,8 @@ describe("decodeMergeRequestDetailJson", () => { expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled; expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true); - // The newer name for the same fact, which older GitLab installs do not send. expect(armed({ auto_merge_enabled: true })).toBe(true); expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false); - // Absent is GitLab not saying, which the page must not read as "not armed". expect(armed({})).toBeUndefined(); }); @@ -234,10 +232,7 @@ describe("decodeMergeRequestDetailJson", () => { expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits; expect(behind({ diverged_commits_count: 3 })).toBe(3); - // Counted and found level, which is the one answer that entitles the page to say so. expect(behind({ diverged_commits_count: 0 })).toBe(0); - // An install that does not answer, and a null where the answer would have gone, are both - // silence: reading either as zero would tell a stale branch it is current. expect(behind({})).toBeUndefined(); expect(behind({ diverged_commits_count: null })).toBeUndefined(); }); @@ -289,7 +284,6 @@ describe("decodeNotesJson", () => { kind: "issue-comment", body: "Looks good.", }); - // The raw count keeps the dropped notes visible to the caller, which needs them to page. expect(notes.rawCount).toBe(3); }); @@ -561,8 +555,6 @@ describe("merge request viewer fields", () => { }); it("leaves merging permitted where GitLab answered without the field", () => { - // Only the single-merge-request endpoint carries `user`, and an install that answers without - // it has said nothing about the viewer rather than said no. expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true); expect( expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge, @@ -594,7 +586,6 @@ describe("decodeAwardEmojiJson", () => { }, { id: "gid://gitlab/Note/7", - // Not one of the eight the contract carries. awardEmoji: { nodes: [{ name: "partyparrot", user: { username: "bilal" } }], }, @@ -608,14 +599,9 @@ describe("decodeAwardEmojiJson", () => { ), ); - // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own - // username is left out of `actors` β€” the page names them "You" instead β€” while `count` still - // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on. expect(result.reactions).toEqual([ { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true }, ]); - // Note 7's only award named nobody the eight recognise, so it carries no reactions and is - // left out of the map rather than kept empty. expect([...result.reactionsByNoteId]).toEqual([ ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]], ]); @@ -698,7 +684,6 @@ describe("gitLabAwardName", () => { }); describe("decodeRepositoryBlobsJson", () => { - /** Null is the query going unanswered, which these cases are not about. */ function expectBlobs(result: Result.Result | null, unknown>) { const blobs = expectSuccess(result); expect(blobs).not.toBe(null); @@ -758,9 +743,6 @@ describe("decodeRepositoryBlobsJson", () => { }); it("keys a blob by the path the host spelled, spaces and all", () => { - // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands - // under a key the asked-for path is not spelled with, and the caller fills that path in as - // the empty revision: a mark on the file then never compares against the real head blob. const blobs = expectBlobs( decodeRepositoryBlobsJson( JSON.stringify({ @@ -785,14 +767,11 @@ describe("decodeRepositoryBlobsJson", () => { expect([...blobs]).toEqual([ [" leading.ts", "aaa111"], ["trailing.ts ", "bbb222"], - // A name that is only spaces is one Git carries too, so it is a path like any other. [" ", "ccc333"], ]); }); it("tells a project the reader cannot see from a revision with none of the files", () => { - // Null is the query going unanswered. Read as an empty answer it would say the head has none - // of the asked-for files, which reports every file a reader has cleared as changed. expect( expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), ).toBe(null); diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index d61816b516b0..72eaa7f1194b 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -27,10 +27,6 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; * adds a pipeline status or a merge status must not fail the whole payload. */ const RawUserSchema = Schema.Struct({ - /** - * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the - * id is carried alongside the handle rather than looked up again when a review is asked for. - */ id: Schema.optional(Schema.Int), username: Schema.String, name: Schema.optional(Schema.NullOr(Schema.String)), @@ -51,7 +47,6 @@ const RawMergeRequestSchema = Schema.Struct({ author: Schema.optional(Schema.NullOr(RawUserSchema)), source_branch: Schema.String, target_branch: Schema.String, - /** The source branch's head commit. */ sha: Schema.optional(Schema.NullOr(Schema.String)), state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), @@ -67,31 +62,17 @@ const RawMergeRequestSchema = Schema.Struct({ // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. changes_count: Schema.optional(Schema.NullOr(Schema.String)), head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), - /** - * What the requesting account may do, which only the single-merge-request endpoint carries. - * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts - * for the role, the approval rules and a protected target branch β€” none of which a project's - * access level on its own would tell apart. - */ user: Schema.optional( Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), ), - /** - * Whether GitLab is holding this merge request to merge it once its pipeline goes green. - * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also - * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so - * either one saying yes is a yes. - */ merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** The merge request's stored squash choice, including project-policy overrides. */ squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), squash: Schema.optional(Schema.NullOr(Schema.Boolean)), /** - * How far the target branch has moved on since this one left it, which is the same number - * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab - * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a - * single merge request β€” a list never carries it, however it is asked for. + * It costs a walk of the two branches, so GitLab withholds it unless + * `include_diverged_commits_count` asks for it, and answers it only for a single merge + * request β€” a list never carries it, however it is asked for. */ diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), }); @@ -101,7 +82,6 @@ const RawNoteSchema = Schema.Struct({ body: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawUserSchema)), created_at: Schema.String, - /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ system: Schema.optional(Schema.Boolean), type: Schema.optional(Schema.NullOr(Schema.String)), position: Schema.optional( @@ -114,11 +94,6 @@ const RawNoteSchema = Schema.Struct({ ), }); -/** - * A discussion note carrying its place in the diff, which is the shape the whole thread view - * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a - * discussion resolved once every resolvable note in it is. - */ const RawDiscussionNoteSchema = Schema.Struct({ id: Schema.Int, body: Schema.optional(Schema.NullOr(Schema.String)), @@ -184,9 +159,7 @@ const RawDiffSchema = Schema.Struct({ renamed_file: Schema.optional(Schema.Boolean), deleted_file: Schema.optional(Schema.Boolean), diff: Schema.optional(Schema.NullOr(Schema.String)), - /** GitLab omits the hunks for a file it considers too large to inline. */ too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** And for one it collapsed, which withholds them the same way. */ collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), }); @@ -194,7 +167,6 @@ const RawViewerSchema = Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)), }); -/** A GitLab project settles on one merge strategy plus an optional squash. */ const RawProjectMergeSettingsSchema = Schema.Struct({ merge_method: Schema.optional(Schema.NullOr(Schema.String)), squash_option: Schema.optional(Schema.NullOr(Schema.String)), @@ -210,10 +182,6 @@ export interface GitLabMergeRequestListItem { readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; - /** - * GitLab reports neither added nor removed lines on a merge request, so both stay zero and - * the surface omits the stat. The Code tab counts them from the patch it already fetched. - */ readonly additions: number; readonly deletions: number; readonly createdAt: string; @@ -229,20 +197,15 @@ export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { readonly closedAt: string | null; readonly reviewers: ReadonlyArray; readonly checks: ReadonlyArray; - /** False only where GitLab said so; an answer without the field leaves merging permitted. */ readonly viewerCanMerge: boolean; - /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ readonly reviewerIds: ReadonlyArray; - /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ readonly autoMergeEnabled?: boolean; - /** GitLab only exposes the stored strategy separately when that strategy is squash. */ readonly autoMergeMethod?: PullRequestMergeMethod; /** * Absent where GitLab did not count, which is not the same as a branch that has nothing behind * it: an install too old to answer must not be read as saying the branch is current. */ readonly divergedCommits?: number; - /** Absent where GitLab did not report the head commit. */ readonly headSha?: string; } @@ -266,7 +229,6 @@ function toState(raw: Schema.Schema.Type): PullReq case "closed": return "closed"; default: - // `locked` is an open merge request whose discussion is locked. return "open"; } } @@ -281,23 +243,17 @@ function toMergeability( case "cannot_be_merged": return "conflicting"; default: - // `unchecked` and `checking` mean GitLab has not finished the merge check yet. return "unknown"; } } function toLabels(raw: ReadonlyArray | null | undefined): ReadonlyArray { - // GitLab returns label names only, so there is no colour to carry. return (raw ?? []).flatMap((label) => { const name = trimmed(label); return name === null ? [] : [{ name, color: null }]; }); } -/** - * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is - * the floor either way, which reads better than dropping an uncounted change set to nothing. - */ function toChangedFiles(value: string | null | undefined): number { const parsed = Number.parseInt(value?.trim() ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; @@ -314,7 +270,6 @@ function toPipelineStatus(value: string | null | undefined): PullRequestCheckSta return "cancelled"; case "skipped": return "skipped"; - // A pipeline waiting on a person is not progress, and it is not a failure either. case "manual": case "scheduled": return "neutral"; @@ -323,10 +278,6 @@ function toPipelineStatus(value: string | null | undefined): PullRequestCheckSta } } -/** - * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one - * check. The jobs behind it stay one click away through the pipeline URL. - */ function toChecks( raw: Schema.Schema.Type, ): ReadonlyArray { @@ -380,7 +331,6 @@ function toDetail(raw: Schema.Schema.Type): GitLab changedFiles: toChangedFiles(raw.changes_count), mergedAt: trimmed(raw.merged_at), closedAt: trimmed(raw.closed_at), - // Built from the reviewers themselves rather than from their logins, so the avatars survive. reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { const actor = toActor(reviewer); return actor === null ? [] : [actor]; @@ -422,14 +372,10 @@ export interface GitLabProjectUsers { export interface GitLabMergeRequestListBatch { readonly items: ReadonlyArray; - /** Zero-based positions of the decoded items in GitLab's raw page. */ readonly rawIndexes: ReadonlyArray; - /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ readonly rawCount: number; } -/** Malformed entries are skipped rather than failing the batch: one unexpected merge request - * must not blank the whole list. */ export function decodeMergeRequestListJson( raw: string, ): Result.Result { @@ -465,14 +411,6 @@ export function decodeViewerJson(raw: string): Result.Result { @@ -496,12 +434,6 @@ export function decodeProjectUsersJson( return Result.succeed({ candidates, rawCount: decoded.success.length }); } -/** - * GitLab settles the strategy per project rather than offering all three per merge request: - * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a - * separate switch. An unrecognized setting offers nothing rather than offering a strategy the - * project forbids. - */ export function decodeProjectMergeCapabilitiesJson( raw: string, ): Result.Result { @@ -513,23 +445,12 @@ export function decodeProjectMergeCapabilitiesJson( const squashOption = decoded.success.squash_option?.trim().toLowerCase(); return Result.succeed({ merge: mergeMethod === "merge", - // Both semi-linear and fast-forward histories are reached by rebasing onto the target. rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", - // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, - // rather than offering a squash the project may forbid. squash: squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", }); } -/** - * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the - * root of a line-level discussion, which is what the review-comment kind means. - * - * The raw note count comes back alongside, because dropping notes hides whether the page was - * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. - */ -/** The three revisions a positioned comment is written against. */ export interface GitLabDiffRefs { readonly baseSha: string; readonly headSha: string; @@ -538,15 +459,9 @@ export interface GitLabDiffRefs { export interface GitLabDiscussions { readonly threads: ReadonlyArray; - /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ readonly rawCount: number; } -/** - * Positioned discussions only. GitLab returns the merge request's whole conversation here, - * including the plain notes the timeline already shows, and only a positioned one belongs - * against a line of the diff. - */ export function decodeDiscussionsJson( raw: string, ): Result.Result { @@ -562,8 +477,6 @@ export function decodeDiscussionsJson( const root = notes[0]; const position = root?.position; if (root === undefined || !position || position.position_type !== "text") continue; - // A comment on an added or context line carries `new_line`; one on a removed line carries - // only `old_line`, and belongs against the file as it was. const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; const path = trimmed(side === "left" ? position.old_path : position.new_path); const line = side === "left" ? position.old_line : position.new_line; @@ -574,9 +487,6 @@ export function decodeDiscussionsJson( line: typeof line === "number" && line > 0 ? line : null, side, isResolved: root.resolved === true, - // GitLab reports no equivalent of "written against a line that has since moved", so a - // thread the diff cannot place is worked out from the diff itself rather than claimed - // here. isOutdated: false, comments: notes.map((note) => ({ id: String(note.id), @@ -665,11 +575,9 @@ export function decodeCommitsJson( })(), }); } - // GitLab lists a merge request's commits newest first; the timeline reads oldest first. return Result.succeed(commits.toReversed()); } -/** The exact comparison GitLab uses for a commit-scoped diff. */ export function decodeCommitDiffRefsJson( raw: string, ): Result.Result { @@ -694,17 +602,10 @@ function diffHeaderPaths(raw: Schema.Schema.Type): { export interface GitLabMergeRequestPatch { readonly patch: string; - /** At least one file's hunks were withheld by GitLab as too large to inline. */ readonly truncated: boolean; - /** Files GitLab returned, counted before decoding, so the caller can page. */ readonly rawCount: number; } -/** - * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff - * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, - * which is why the raw file count comes back with the patch. - */ export function decodeMergeRequestDiffsJson( raw: string, ): Result.Result { @@ -720,7 +621,6 @@ export function decodeMergeRequestDiffsJson( const value = file.value; const hunks = value.diff ?? ""; if (hunks.length === 0) { - // A file GitLab declined to inline still belongs in the file list, header only. truncated = truncated || value.too_large === true || value.collapsed === true; } const { from, to } = diffHeaderPaths(value); @@ -746,7 +646,6 @@ export function decodeMergeRequestDiffsJson( }); } -/** GitLab's award names for the eight reactions the contract carries. */ const GITLAB_AWARD_BY_CONTENT: Readonly> = { "thumbs-up": "thumbsup", "thumbs-down": "thumbsdown", @@ -767,13 +666,6 @@ export function gitLabAwardName(content: PullRequestReactionContent): string { return GITLAB_AWARD_BY_CONTENT[content]; } -/** - * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the - * conversation comes from carries no award at all, and asking per note would be a request each. - * - * `currentUser` rides along because GitLab names who awarded but never says whether that is the - * reader β€” so the comparison is made here rather than paid for with a request of its own. - */ export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { currentUser { username } project(fullPath: $fullPath) { @@ -849,11 +741,6 @@ const RawAwardEmojiPageSchema = Schema.Struct({ const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); -/** - * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username - * is left out of `actors` β€” the page names them "You" instead, and leaving it in would name them - * twice β€” but `count` still counts them along with everyone else. - */ function toReactions( nodes: Schema.Schema.Type, viewer: string | null, @@ -864,8 +751,6 @@ function toReactions( { count: number; actors: string[]; viewer: boolean } >(); for (const node of nodes?.nodes ?? []) { - // An award outside the eight is left out rather than shown under a name the picker has no - // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; if (content === undefined) continue; const username = trimmed(node?.user?.username); @@ -886,14 +771,12 @@ function toReactions( ); } -/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */ function noteIdOf(gid: string | null | undefined): string | null { const id = trimmed(gid)?.split("/").at(-1); return id !== undefined && /^\d+$/.test(id) ? id : null; } export interface GitLabAwardEmojiPage { - /** The merge request's own awards, which are the ones on its description. */ readonly reactions: ReadonlyArray; readonly reactionsByNoteId: ReadonlyMap>; readonly nextCursor: string | null; @@ -934,10 +817,6 @@ const RawAwardSchema = Schema.Struct({ const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); -/** - * The reader's own award of one name on a subject, which is what taking a reaction back is - * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. - */ export function decodeOwnAwardIdJson( raw: string, input: { readonly content: PullRequestReactionContent; readonly viewer: string }, @@ -958,11 +837,6 @@ export function decodeOwnAwardIdJson( return Result.succeed(null); } -/** - * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking - * the tree, since GitLab charges this query by how many paths it is given. A path the revision - * does not have comes back missing rather than as an error, which is the answer for a deleted file. - */ export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) { project(fullPath: $fullPath) { repository { @@ -1012,8 +886,7 @@ const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); * Blob ids by path, or null where GitLab did not answer the query at all (a project the token * cannot see, or a repository with no blobs connection). That case must be told apart from an * empty answer: read as "the revision has none of these files", it would report every cleared - * file as changed again. A node missing either half is left out, since the caller treats an - * absent path as one the revision does not carry. + * file as changed again. */ export function decodeRepositoryBlobsJson( raw: string, diff --git a/packages/source-control-testing/src/TestSourceControlHost.ts b/packages/source-control-testing/src/TestSourceControlHost.ts index a4cb30db7341..b76b2f8ca6f8 100644 --- a/packages/source-control-testing/src/TestSourceControlHost.ts +++ b/packages/source-control-testing/src/TestSourceControlHost.ts @@ -1,11 +1,3 @@ -/** - * A `SourceControlHost.SourceControlHost` for provider tests. Settings start from the defaults - * or what the test supplies, and `TestSourceControlHostSettings` patches them mid-test the way a - * client's settings write would. Process runs and git operations go to what the test supplies; - * anything else dies, so an unexpected CLI or git call is visible. - * - * @module source-control-testing/TestSourceControlHost - */ import { DEFAULT_SERVER_SETTINGS, type ServerSettings, @@ -25,7 +17,6 @@ export interface TestSourceControlHostOptions { readonly git?: Partial; } -/** Lets a test change the settings its `layer` reports. */ export class TestSourceControlHostSettings extends Context.Service< TestSourceControlHostSettings, { readonly update: (patch: ServerSettingsPatch) => Effect.Effect } @@ -74,7 +65,6 @@ export const layer = ( }), ); -/** A successful run's output, for tests that script CLI responses. */ export const processOutput = ( stdout: string, options?: { diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index b4b42598e382..e149908a9604 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -72,7 +72,6 @@ export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`; } -/** Names the remote state directory for a target: the first 16 hex chars of its SHA-256 key. */ export const remoteStateKey = Effect.fn("ssh/command.remoteStateKey")(function* ( target: DesktopSshEnvironmentTarget, ): Effect.fn.Return { diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 78bac0aac93f..955f0ffed0b8 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -100,7 +100,6 @@ server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => { ), ), ); - // A failed PID assertion must still close the owned fixture server. yield* Effect.addFinalizer(() => Effect.gen(function* () { if (yield* child.isRunning) { @@ -166,7 +165,6 @@ server.listen(0, "127.0.0.1", () => { { cwd: fixture, detached: false }, ), ); - // A failed assertion must still stop this captured fixture process. yield* Effect.addFinalizer(() => child.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore), ); @@ -195,7 +193,6 @@ server.listen(0, "127.0.0.1", () => { port: null, }), ); - // Redirect only the state directory. Never use the developer's SSH state. const isolatedScript = script.replace( /^STATE_DIR=.*$/mu, 'STATE_DIR="$T3_TEST_STATE_DIR"', diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 9bb303d6afef..ebdf12696ed8 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -118,14 +118,10 @@ describe("ssh tunnel scripts", () => { assert.notInclude(script, "npm exec"); assert.notInclude(script, "t3@latest"); assert.notInclude(script, 'exec t3 "$@"'); - // Concurrent launches serialize on a per-version mkdir lock and recheck - // the completion marker after acquiring it. assert.include( script, 'T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"', ); - // mkdir is the exclusive create; the pid follows atomically. A dead owner - // is reclaimed at once, a never-published owner after a short grace. assert.include(script, 'while ! mkdir "$T3_LOCK" 2>/dev/null; do'); assert.include(script, 'mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"'); assert.include(script, 'if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then'); @@ -140,8 +136,6 @@ describe("ssh tunnel scripts", () => { script.indexOf('"$T3_STAGING/t3" --version'), script.indexOf('> "$T3_STAGING/.install-complete"'), ); - // Node discovery is defined for the dev path but only ever invoked inside - // the node-script branch, which the archive path skips entirely. assert.equal(script.split("ensure_remote_node_path || true").length - 1, 1); assert.isBelow( script.indexOf("ensure_remote_node_path || true"), @@ -755,7 +749,7 @@ describe("ssh tunnel scripts", () => { // On macOS, Node can bind 127.0.0.1: while another process (such as the // desktop app with network access on) listens on *:. Linux refuses that // bind unless both sockets set SO_REUSEPORT, so on Linux the preload adds it to -// the picker's probe and the holder sets it too. macOS needs neither. +// the picker's probe and the holder sets it too. const MACOS_BIND_PRELOAD = `const net = require("node:net"); const listen = net.Server.prototype.listen; net.Server.prototype.listen = function (port, host, callback) { @@ -824,7 +818,6 @@ describe("remote port picker", () => { ); }); -// Runs a generated script with an isolated HOME, as an SSH remote would. const runScript = (home: string, args: ReadonlyArray) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -857,9 +850,6 @@ const runScript = (home: string, args: ReadonlyArray) => return { stdout, stderr, exitCode }; }); -// The archive runner is generated shell; string assertions cannot prove the -// lock excludes concurrent installers. Run the real script against a tiny -// fake archive served from a file:// mirror. describe("archive runner script", () => { const hostPlatform = HostProcess.Platform.defaultValue(); const hostArch = HostProcess.Architecture.defaultValue(); @@ -868,9 +858,6 @@ describe("archive runner script", () => { const runRunner = (home: string, runner: string) => runScript(home, [runner, "--version"]); - // A fake "executable" that answers --version, packed the way the release - // workflow packs the real archive: one top-level directory named after the - // stem, checksummed in SHA256SUMS. const makeMirror = Effect.fn("makeMirror")(function* (root: string) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const platform = hostPlatform === "darwin" ? "darwin" : "linux"; @@ -921,8 +908,6 @@ describe("archive runner script", () => { archiveVersion, ); - // A lock left by a crashed installer (dead pid) must not block the - // next launch, and neither must one that never published a pid. const lock = `${versionsDir}/.${archiveVersion}.install.lock`; yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true }); yield* fs.makeDirectory(lock); @@ -940,9 +925,6 @@ describe("archive runner script", () => { ); }); -// Reuse decisions also live in generated shell. Drive the real launch script -// against a fake server that answers readiness probes and writes the default -// runtime file the way `t3 serve` does. describe("launch script", () => { const windowsHost = HostProcess.Platform.defaultValue() === "win32"; const FAKE_SERVER = `const fs = require("node:fs"); @@ -993,8 +975,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { }); }); - // Whether anything still accepts connections on the port. Unlike a PID - // check, this cannot mistake an unreaped, exited server for a running one. const isListening = (port: number) => Effect.callback((resume) => { const socket = NodeNet.connect(port, "127.0.0.1"); @@ -1007,9 +987,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { const launchResult = (stdout: string) => JSON.parse(stdout.trim().split("\n").at(-1) ?? ""); - // Writes the launch script for a fake server into a temporary HOME. The - // seeded remembered port keeps the test off the default 3773, which a live - // T3 on this machine may own. const prepareLaunch = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-launch-script-" }); @@ -1029,7 +1006,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { .pipe(Effect.map((pid) => Number(pid.trim()))); // Launched servers outlive the script, so stop each PID the script // recorded, including one it recorded just before a failed assertion. - // SIGKILL also ends a server a test left stopped. const launchedPids: Array = []; yield* Effect.addFinalizer(() => readManagedPid.pipe( @@ -1056,9 +1032,8 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { assert.equal(launched.serverKind, "managed"); return { launched, pid }; }); - // Starts a separate server on the default home, as a service would, and - // returns the port it reports. An unreaped server runs under a parent that - // never waits on it, so once it exits it lingers as a zombie. + // An unreaped server runs under a parent that never waits on it, so once it + // exits it lingers as a zombie. const startDefaultHomeServer = ( flags: ReadonlyArray, options?: { readonly unreaped?: boolean }, @@ -1099,14 +1074,10 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { yield* prepareLaunch; const { launched, pid: managedPid } = yield* launchManaged; - // The managed server wrote the default runtime file itself, so a - // reconnect must keep it rather than stop it as an external server. assert.deepEqual(yield* runLaunch, launched); assert.equal(yield* readManagedPid, managedPid); assert.isTrue(isAlive(managedPid)); - // A remembered port that is stale must not hide our own server: the - // runtime file names its port. yield* fs.writeFileString(`${stateDir}/port`, `${yield* freeLoopbackPort}\n`); assert.deepEqual(yield* runLaunch, launched); assert.equal(yield* readManagedPid, managedPid); @@ -1115,8 +1086,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { `${launched.remotePort}`, ); - // A different server on the default home, such as a service, still - // takes over from the managed one. const servicePort = yield* startDefaultHomeServer([]); assert.deepEqual(yield* runLaunch, { remotePort: servicePort, @@ -1128,8 +1097,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { 60_000, ); - // A stopped process keeps its listener but answers nothing, like a server - // stuck in a long SQLite stall. it.effect.skipIf(windowsHost)( "waits for its busy managed server instead of replacing it", () => @@ -1138,7 +1105,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { const { readManagedPid, runLaunch, launchManaged } = yield* prepareLaunch; const { launched, pid } = yield* launchManaged; - // It resumes after the two 2-second probes that used to replace it. process.kill(pid, "SIGSTOP"); yield* spawner.spawn(ChildProcess.make("sh", ["-c", `sleep 5; kill -CONT ${pid}`])); assert.deepEqual(yield* runLaunch, launched); @@ -1157,7 +1123,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { yield* prepareLaunch; const { launched, pid } = yield* launchManaged; - // Shorten the one-minute readiness budget so the test stays fast. const script = yield* fs.readFileString(launch); assert.include(script, 'wait_ready "60000"'); yield* fs.writeFileString( @@ -1187,7 +1152,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { const { stateDir, readManagedPid, runLaunch, launchManaged } = yield* prepareLaunch; const { pid: previousPid } = yield* launchManaged; - // A different runner on disk means the client updated: replace the server. yield* fs.writeFileString(`${stateDir}/run-t3.sh`, "# previous runner\n"); assert.equal((yield* runLaunch).serverKind, "managed"); const replacementPid = yield* readManagedPid; @@ -1247,9 +1211,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { const { stateDir, launchManaged, runLaunch } = yield* prepareLaunch; const { launched, pid } = yield* launchManaged; - // An interrupted launch, or a recycled PID, can leave a PID that matches - // the runtime file without the managed marker. A changed runner must not - // turn that into a restart of a server this launcher cannot claim. yield* fs.remove(`${stateDir}/managed`); yield* fs.writeFileString(`${stateDir}/run-t3.sh`, "# superseded runner\n"); assert.deepEqual(yield* runLaunch, { @@ -1267,12 +1228,9 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const { stateDir, runLaunch, startDefaultHomeServer } = yield* prepareLaunch; - // Ownership files from an earlier managed launch that lost its PID. yield* fs.writeFileString(`${stateDir}/managed`, "managed\n"); const servicePort = yield* startDefaultHomeServer(["--answer-once"]); - // The server answers adoption and then stalls. A second readiness - // probe would launch another server on the same home. assert.deepEqual(yield* runLaunch, { remotePort: servicePort, serverKind: "external" }); assert.isFalse(yield* fs.exists(`${stateDir}/pid`)); }).pipe(Effect.provide(NodeServices.layer)), @@ -1288,10 +1246,6 @@ server.listen(Number(flag("--port")), "127.0.0.1", () => { const { pid: previousPid } = yield* launchManaged; yield* startDefaultHomeServer(["--exit-after-answer"], { unreaped: true }); - // The service answers adoption and then exits while the managed - // server is being stopped, staying a zombie, so the script must launch - // a new server rather than return the dead one. The freed port may be - // reused. const result = yield* runLaunch; assert.equal(result.serverKind, "managed"); const replacementPid = yield* readManagedPid; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 472dcfc4188d..355e5c66c0bf 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -66,17 +66,8 @@ const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { - /** - * Dev mode: run `node ` on the remote instead of a release archive. - * The only mode that needs Node on the remote. - */ readonly nodeScriptPath?: string | null; readonly nodeEngineRange?: string | null; - /** - * Exact version whose self-contained release archive the remote installs - * and runs. Required unless `nodeScriptPath` is set; the remote then needs - * neither Node nor npm. - */ readonly archiveVersion?: string | null; readonly releaseBaseUrl?: string | null; } @@ -246,8 +237,6 @@ function applyScriptPlaceholders( return result; } -// Re-exported from the shared HTTP readiness module so existing importers -// (notably tunnel.test.ts) keep resolving it from here. export { describeReadinessCause }; export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs"); @@ -834,7 +823,6 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) { throw new SshInvalidArchiveVersionError({ archiveVersion }); } - // Strip the `/v` the helper appends: the script builds URLs itself. const releaseBaseUrl = cliReleaseDownloadBaseUrl("", input?.releaseBaseUrl ?? undefined).replace( /\/v$/u, "", @@ -975,8 +963,6 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s"], stdin: buildRemotePairingScript(stateKey, runner), - // Pairing may be the first command on a cold remote, so it can install - // the archive on the way. ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }), ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 2936f2004b7d..af145b2d713c 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -29,15 +29,6 @@ import { const encoder = new TextEncoder(); -/** - * Asserts nothing reachable from `error` contains `secret`. Recurses through - * nested objects, arrays, and `cause` chains rather than checking only - * top-level strings: a leak one level down (say, a wrapped cause carrying raw - * stderr) is just as visible in a log, and a shallow check would pass it. - * - * Walks values instead of serializing so it holds for fields added later, and - * tracks visited objects so a cyclic cause chain terminates. - */ function assertCarriesNoSecret(error: object, secret: string): void { const seen = new WeakSet(); @@ -167,7 +158,6 @@ describe("tailscale", () => { ); it.effect("reads Tailscale SSH enablement from local prefs", () => { - // `tailscale debug prefs` output, trimmed. const prefsJson = '{\n\t"ControlURL": "https://controlplane.tailscale.com",\n\t"RunSSH": true,\n\t"WantRunning": true\n}\n'; const layer = layerMockSpawner((command, args) => { @@ -268,8 +258,6 @@ describe("tailscale", () => { assert.equal(serveError.subcommand, "serve"); assert.strictEqual(serveError.cause, defect); - // What callers actually rely on: the desktop endpoint providers recover - // with `Effect.orElseSucceed`, which only sees the typed error channel. const degraded = yield* readTailscaleStatus.pipe( Effect.orElseSucceed(() => null), Effect.provide(layer), @@ -313,8 +301,6 @@ describe("tailscale", () => { const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); assert.instanceOf(error, TailscaleCommandExitError); - // Unmatched stderr degrades to "unknown" rather than passing text - // through β€” that fallback is what keeps novel output from leaking. assert.equal(error.stderrDiagnostic, "unknown"); assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); assertCarriesNoSecret(error, "fluffy-badger"); @@ -374,8 +360,6 @@ describe("tailscale", () => { assert.notProperty(error, "command"); assert.notProperty(error, "stderr"); assert.notInclude(error.message, "tskey-auth-secret-token-value"); - // The diagnostic classifies the failure without quoting stderr, so the - // key cannot reach a log through it either. assert.equal(error.stderrDiagnostic, "permission-denied"); assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 14f7757f406e..a5553ecd0298 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -12,8 +12,6 @@ export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); -// tailscale is a real executable everywhere (`tailscale.exe` on Windows), so -// it is always spawned directly rather than through cmd.exe shell mode. const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => platform === "win32" ? "tailscale.exe" : "tailscale"; @@ -36,8 +34,6 @@ export const TailscaleStderrDiagnostic = Schema.Literals([ ]); export type TailscaleStderrDiagnostic = typeof TailscaleStderrDiagnostic.Type; -// Matched against stderr, most specific first. Patterns are deliberately short -// and anchored on tailscale's own wording. const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< readonly [RegExp, Exclude] > = [ @@ -46,7 +42,6 @@ const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< [/permission denied|access denied|must be root|operation not permitted/i, "permission-denied"], ]; -/** Classifies stderr into a safe label, dropping the text itself. */ const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { if (stderr.trim().length === 0) { return undefined; @@ -85,11 +80,6 @@ export class TailscaleCommandExitError extends Schema.TaggedError Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -285,9 +274,8 @@ const TailscalePrefsJson = Schema.Struct({ const decodeTailscalePrefsJson = Schema.decodeEffect(Schema.fromJsonString(TailscalePrefsJson)); /** - * Whether this node serves Tailscale SSH (`tailscale up --ssh`), read from its - * prefs. `tailscale status --json` does not report this for the local node. - * `tailscale down` clears WantRunning but keeps RunSSH, so check both. + * `tailscale status --json` does not report this for the local node. `tailscale + * down` clears WantRunning but keeps RunSSH, so check both. */ export const parseTailscaleSshEnabled = ( rawPrefsJson: string, diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index 85f7ea8da5fe..25a53b73d7d9 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -1,18 +1,7 @@ #!/usr/bin/env node /** - * Packages the server single-executable into a self-contained per-platform - * archive: the `t3` binary, the web client, the resource monitor, and a - * production install of the native packages the bundle keeps external. The - * archive is the unit every runtime installer downloads, so nothing in it may - * require Node, npm, or a compiler on the machine that unpacks it. - * - * Layout inside the archive (a single top-level directory named after the - * archive stem): - * - * t3 | t3.exe the single-executable - * client/ web app served by the server - * resource-monitor/ per-platform Rust helper, same paths as the npm package - * node_modules/ runtime externals (node-pty, msgpackr-extract, fff) + * The archive is the unit every runtime installer downloads, so nothing in it + * may require Node, npm, or a compiler on the machine that unpacks it. */ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -91,7 +80,6 @@ export class CliArchiveInputMissingError extends Schema.TaggedError `'${value.replaceAll("'", "''")}'`; const script = [ "$ErrorActionPreference = 'Stop';", diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 4b7443aa09bc..1edd8f0551be 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -104,9 +104,6 @@ const stagePackageExtensions = { "@electron-webauthn/macos": { peerDependenciesMeta: { typescript: { optional: true } } }, }; -// A minimal stand-in for the Linux CLI release archive: one top-level -// directory named after the archive stem holding the executable, the web -// client, and the runtime externals with node-pty built from source. const makeLinuxCliArchiveFixture = Effect.fn("test.makeLinuxCliArchiveFixture")(function* (input: { readonly root: string; readonly stem: string; @@ -227,9 +224,7 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu const stem = wslRuntimeArchiveStem(WINDOWS_PAYLOAD_FIXTURE_VERSION, input.targetArch ?? "x64"); const sourceArchivePath = input.wslRuntime === "loose-server-tree" - ? // The old hand-rolled runtime: apps/server/dist + node_modules at the - // archive root, no single stem directory, no `t3` executable. - yield* makeLinuxCliArchiveFixture({ + ? yield* makeLinuxCliArchiveFixture({ root: path.join(tempDir, "wsl-runtime"), stem: "apps", omitMembers: ["apps/t3", "apps/client/index.html"], @@ -473,8 +468,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }, packageExtensions: stagePackageExtensions, }); - // Windows stages only win32 natives; WSL runs the separately built Linux - // CLI archive rather than anything installed here. assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), { supportedArchitectures: { os: ["win32"], @@ -555,9 +548,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }, ); - // Empty maps must not be written β€” pnpm would still require reviewed - // packages if allowBuilds is present but incomplete, and omitting empty - // patchedDependencies keeps the stage yaml minimal. assert.deepStrictEqual( createStageWorkspaceConfig({ platform: "mac", @@ -578,11 +568,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("limits Electron locales and excludes separately packaged resources", () => { assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]); - // Every platform staging input is emitted once at resources/, so adding one - // without its exclusion silently packs a second copy into app.asar. The - // snapshot below cannot catch that on its own: adding a resource and - // forgetting the exclusion leaves the exclusion list untouched, so it still - // matches. Assert the invariant first, where the failure names the culprit. for (const resource of [ ...WSL_RUNTIME_EXTRA_RESOURCES, ...LINUX_BROWSER_SECRET_EXTRA_RESOURCES, @@ -663,8 +648,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { false, ); - // Windows unpacks native files explicitly so their JavaScript and metadata - // stay archived. Other platforms retain electron-builder's defaults. assert.notProperty(mac, "asar"); assert.notProperty(linux, "asar"); assert.notProperty(mac, "asarUnpack"); @@ -684,14 +667,11 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ...WINDOWS_SERVER_EXTRA_RESOURCES, ...WSL_RUNTIME_EXTRA_RESOURCES, ]); - // No Linux CLI archive means staging never writes the runtime, so - // listing it here would fail the build on a missing source file. assert.deepStrictEqual(winWithoutWslRuntime.extraResources, [ ...DESKTOP_EXTRA_RESOURCES, ...WINDOWS_SERVER_EXTRA_RESOURCES, ]); assert.deepStrictEqual(win.nsis, { differentialPackage: true }); - // The Claude SDK platform packages and .bin shims never ship. assert.deepStrictEqual(WINDOWS_SERVER_ASAR_IGNORE_GLOBS, [ "**/node_modules/@cursor/sdk-*", "**/node_modules/@cursor/sdk-*/**", @@ -712,19 +692,14 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { iconSize: 120, iconTextSize: 12, }); - // A Linux AppImage build also emits the .deb from the same run. assert.deepStrictEqual((linux.linux as Record).target, ["AppImage", "deb"]); - // Linux must register the renderer schemes so the generated .desktop - // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links. assert.deepStrictEqual((linux.linux as Record).protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); - // macOS also offers itself as a web browser, so it can be chosen as the default. assert.deepStrictEqual((mac.mac as Record).protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, { name: "Web site URL", schemes: ["http", "https"], role: "Viewer" }, ]); - // macOS lists a default browser only when it also opens web pages as documents. const macInfo = (mac.mac as { extendInfo: Record }).extendInfo; assert.strictEqual(macInfo.NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac, true); assert.deepStrictEqual(macInfo.CFBundleDocumentTypes, [ @@ -951,7 +926,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-x64/pty.node"), ), ); - // Signed builds sign every unpacked .node, so the ELF prebuild stays packed. assert.isFalse( yield* fs.exists(path.join(unpackedRoot, "node_modules/node-pty/prebuilds/linux-x64")), ); @@ -1576,9 +1550,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); return Effect.gen(function* () { - // `universal` is a mac-only arch the option type still admits. The helper - // script only knows x64 and arm64, so the request maps to x64, the same - // concrete target the Linux resource monitor resolves it to. yield* stageBrowserSecret({ repoRoot: "/repo", stageResourcesDir: "/stage/resources", @@ -1619,9 +1590,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); return Effect.gen(function* () { - // The helper links against the host's libsecret and its build script is - // a no-op elsewhere, so a Linux artifact built on macOS would ship - // without it and report the keyring as unavailable on every import. const error = yield* stageBrowserSecret({ repoRoot: "/repo", stageResourcesDir: "/stage/resources", @@ -1979,7 +1947,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("grants in-app browser passkey entitlements only when the provisioning profile does", () => { const configuration = { appId: "com.t3tools.t3code", teamId: "ABC1234567" }; - // Profiles are CMS envelopes around a plain XML plist. const profile = (entitlements: string, outside = "") => `0\x82\x1f\x9a\x06\t*\x86H${outside}Entitlements${entitlements}\x00\x01`; const teamWildcardGroups = `keychain-access-groups @@ -1999,8 +1966,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), { touchIdKeychainAccessGroup: undefined, browserPasskeys: false }, ); - // Only real values inside the Entitlements dict count: not comments, not - // explicit false, not keys elsewhere in the profile. assert.deepStrictEqual( resolveMacWebAuthnEntitlements( profile( @@ -2222,8 +2187,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { from: "apps/desktop/prod-resources/wsl-runtime.tar.gz.sha256", to: "wsl-runtime.tar.gz.sha256", }); - // Both the staging and the packaging config hang off this one decision: - // Windows only, and only when CI handed the build a Linux CLI archive. const runtimeArchivePath = "/tmp/t3-1.2.3-linux-x64.tar.gz"; assert.isTrue(bundlesWslRuntime({ platform: "win", runtimeArchivePath })); assert.isFalse(bundlesWslRuntime({ platform: "win", runtimeArchivePath: undefined })); @@ -2260,8 +2223,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { fs.readFile(archivePath), ]); assert.deepStrictEqual(staged, source); - // The digest both gates installation inside the distro and names the - // extracted runtime's cache directory. const hash = yield* fs.readFileString(hashPath); assert.equal(hash, `${NodeCrypto.createHash("sha256").update(source).digest("hex")}\n`); }), @@ -2481,10 +2442,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ); }); -// The self-containment check runs the packaged tree in a scratch directory. Its -// own node_modules holds the sidecar externals and must be ignored, but any -// node_modules *above* it would let Node's parent walk satisfy an import that is -// missing from the package, so the probe refuses to run in that case. it("lists ancestor node_modules, nearest first, excluding the start directory", () => { assert.deepStrictEqual(ancestorNodeModulesPaths("C:\\tmp\\probe\\app", "\\"), [ "C:\\tmp\\probe\\node_modules", @@ -2500,9 +2457,6 @@ it("includes the filesystem root for posix paths", () => { ]); }); -// A UNC root must keep its \\server\share prefix. Rebuilding it from segments -// produced relative paths, which fs.exists resolves against the build cwd, so -// the guard checked directories that do not exist and silently passed. it("keeps the prefix of a UNC path instead of going relative", () => { const paths = ancestorNodeModulesPaths("\\\\server\\share\\tmp\\app", "\\"); for (const candidate of paths) { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 5dd431bc4d9e..1c4d80f88822 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -111,8 +111,6 @@ const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknow const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig)); const encodeStageWorkspaceConfig = Schema.encodeEffect(fromYaml(StageWorkspaceConfig)); -// Decodes the repo's pnpm-workspace.yaml. A PR CI test calls this too, so a bad -// entry fails the PR instead of the nightly desktop build. export const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -975,8 +973,6 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // so the SDK's optional platform packages (each a ~200MB bundled executable) // are dead weight. The trailing dash keeps the SDK's own JS package. "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", - // Nothing in the packaged app enables source maps or serves them: the web - // client's maps alone were 50 MB of app.asar that no request ever read. "!**/*.map", "!**/*.d.cts", "!apps/desktop/resources/browser-secret", @@ -1006,9 +1002,7 @@ export const LINUX_FILE_EXCLUSIONS = [ ] as const; // node-pty publishes both Darwin prebuilds in one package. Single-architecture -// apps only need the native target; universal apps need both. An omitted arch -// preserves the existing common exclusions for callers that only inspect the -// generic platform config. +// apps only need the native target; universal apps need both. export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { if (arch === undefined || arch === "universal") { return [...MAC_FILE_EXCLUSIONS]; @@ -1018,19 +1012,15 @@ export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { return [...MAC_FILE_EXCLUSIONS, `!**/node_modules/node-pty/prebuilds/darwin-${unusedArch}/**/*`]; } // Windows ships the server tree (bundle + node_modules) as a separate -// resources/server.asar sidecar instead of loose files: the NSIS installer -// then extracts a handful of large archives instead of thousands of small -// files, which dominates install (and update) time. The Windows primary runs -// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE -// runtime. WSL does not use this sidecar: it runs the Linux CLI archive -// embedded as resources/wsl-runtime.tar.gz (see WSL_RUNTIME_ARCHIVE_NAME). +// resources/server.asar sidecar instead of loose files: the NSIS installer then +// extracts a handful of large archives instead of thousands of small files, +// which dominates install (and update) time. export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; // dlopen/spawn need real files, so native modules, shared libraries, and // helper executables live in each archive's .unpacked sibling (the standard // asar redirect convention). Everything else stays packed. export const WINDOWS_NATIVE_ASAR_UNPACK_GLOB = "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}"; -// The server sidecar unpacks .node files separately, keeping only Windows ones. export const WINDOWS_SERVER_ASAR_UNPACK_GLOBS = [ "**/*.dll", "**/*.exe", @@ -1070,10 +1060,6 @@ export const WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT = 80; export const WINDOWS_SERVER_RESOURCE_SOURCE_DIR = "apps/desktop/prod-resources/windows-server"; export const WINDOWS_SERVER_EXTRA_RESOURCES = [ { - // Copy the archive and its .unpacked sibling from one parent directory. - // Mapping the .unpacked directory as an independent FileSet silently - // omitted it from Windows packages even though electron-builder copied - // the adjacent archive. from: WINDOWS_SERVER_RESOURCE_SOURCE_DIR, to: ".", filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`], @@ -1090,11 +1076,6 @@ export const WSL_RUNTIME_ARCHIVE_HASH_EXTRA_RESOURCE = { to: WSL_RUNTIME_ARCHIVE_HASH_NAME, } as const; -// The WSL runtime is the Linux CLI release archive (t3--linux- -// .tar.gz, built by scripts/build-cli-archive.ts) copied in verbatim, so WSL -// runs the exact bytes a Linux user downloads. This one predicate decides both -// whether the archive is staged and whether the packaging config ships it: -// listing an extraResource whose source was never written fails electron-builder. export const bundlesWslRuntime = (input: { readonly platform: typeof BuildPlatform.Type; readonly runtimeArchivePath: string | undefined; @@ -1326,9 +1307,7 @@ function escapeXml(value: string): string { * claims a restricted entitlement its embedded profile does not carry. */ export interface MacWebAuthnEntitlements { - /** Keychain group for Electron's Touch ID passkeys. */ readonly touchIdKeychainAccessGroup: string | undefined; - /** Apple's managed browser entitlement, which allows passkeys for any site. */ readonly browserPasskeys: boolean; } @@ -1342,10 +1321,6 @@ const ProvisioningProfilePlist = Schema.Struct({ }); const isProvisioningProfilePlist = Schema.is(ProvisioningProfilePlist); -/** - * Reads the Entitlements dict of the XML plist a provisioning profile wraps in - * its CMS envelope. Anything unreadable grants nothing. - */ const readProfileEntitlements = (provisioningProfile: string) => { const start = provisioningProfile.indexOf("", start); @@ -1468,7 +1443,6 @@ export const stageCursorSdkPlatformPackages = Effect.fn("stageCursorSdkPlatformP yield* fs.makeDirectory(destination, { recursive: true }); const sdkDirectory = path.join(nodeModulesDir, "@cursor/sdk"); if (!(yield* fs.exists(sdkDirectory))) return; - // pnpm's isolated layout puts optional packages beside the real SDK directory. const cursorDirectory = path.dirname(yield* fs.realPath(sdkDirectory)); for (const name of yield* fs.readDirectory(cursorDirectory)) { if (!name.startsWith("sdk-")) continue; @@ -1680,9 +1654,6 @@ const BuildEnvConfig = Config.all({ verbose: Config.Boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), mockUpdates: Config.Boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), mockUpdateServerPort: Config.String("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), - // Path to the Linux CLI release archive (t3--linux-x64.tar.gz) built - // by the build_linux_cli CI job. The Windows build embeds it verbatim as the - // WSL runtime. wslRuntime: Config.String("T3CODE_DESKTOP_WSL_RUNTIME").pipe(Config.option), }); @@ -1854,7 +1825,6 @@ export const preflightLinuxDesktopBuild = Effect.fn("preflightLinuxDesktopBuild" const reuseCaptureHelpers = yield* Config.Boolean( "T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS", ).pipe(Config.withDefault(false)); - // Rust is only optional when every Linux Rust artifact comes from a cache. const needsRust = !reuseResourceMonitor || !reuseCaptureHelpers; const rustTarget = resolveResourceMonitorRustTargets("linux", arch)[0]!; @@ -1987,14 +1957,6 @@ export const preflightWindowsDesktopBuild = Effect.fn("preflightWindowsDesktopBu }, ); -/** - * Every `node_modules` directory that would be visible from `startDir`. - * - * The self-containment check is only meaningful in a directory with none of - * these: Node walks parents when resolving a bare import, so a stray - * node_modules above the probe would satisfy imports that are missing from the - * packaged tree and turn the check into a silent pass. - */ function trimTrailingSeparators(value: string): string { let end = value.length; while (end > 1 && (value[end - 1] === "/" || value[end - 1] === "\\")) end -= 1; @@ -2002,8 +1964,6 @@ function trimTrailingSeparators(value: string): string { } /** - * Length of the `\\server\share` prefix, or 0 when the path is not UNC. - * * The share is the highest real directory on a UNC path: `\\server` on its own * is not one, so the ancestor walk must stop there. */ @@ -2027,8 +1987,6 @@ export function ancestorNodeModulesPaths( // rebuilt into a relative path that silently resolves against the build cwd. const paths: string[] = []; let current = trimTrailingSeparators(startDir); - // On a UNC path the share itself is the root: \\server is not a directory, so - // walking past \\server\share would emit paths that cannot exist. const uncRootLength = uncShareRootLength(current); for (;;) { const cut = Math.max(current.lastIndexOf("/"), current.lastIndexOf("\\")); @@ -2052,7 +2010,6 @@ const decodeNativeMarkerManifest = Schema.decodeUnknownSync( Schema.fromJsonString(NativeMarkerManifest), ); -/** Locate a package inside the pnpm store, which is where the real files live. */ const findStorePackageDirectory = Effect.fn("findStorePackageDirectory")(function* ( repoRoot: string, packageName: string, @@ -2076,7 +2033,6 @@ const findStorePackageDirectory = Effect.fn("findStorePackageDirectory")(functio return null; }); -/** Whether a package builds or ships a native addon it loads at runtime. */ const hasNativeLoaderMarkers = Effect.fn("hasNativeLoaderMarkers")(function* (packageDir: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -2175,9 +2131,6 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel output: `Could not extract ${input.asarPath} for the bundle self-containment check: ${String(cause)}`, }), }); - // Keep the existing symlink isolation guard even though the sidecar stage - // is hoisted and should be physical. A future package-manager layout change - // must not let the probe resolve through the build tree. yield* copyDirectoryPreservingSymlinks(extractedApp, probeApp); // Guard the guard: if anything above the probe provides a node_modules, a @@ -2200,12 +2153,6 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel }); } - // --version exercises the eagerly loaded module graph, which is where a - // missing dependency shows up, without starting a server or touching disk - // state. It does not cover lazily imported externals: node-pty is checked - // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node - // and the bun adapters are covered by the shared runtime-external closure - // and emitted-bundle checks. yield* runCommand( ChildProcess.make( process.execPath, @@ -2218,9 +2165,6 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel cwd: probeApp, stdout: "pipe", stderr: "pipe", - // NODE_PATH would let a createRequire call inside the bundle resolve - // a missing external from outside the packaged tree, which is the - // whole thing this is trying to rule out. env: { ...process.env, NODE_PATH: "" }, }, ), @@ -2229,9 +2173,6 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel verbose: input.verbose, }, ).pipe( - // Printing a version should be immediate. A regression that blocks (on - // stdin, a port, a lock) would otherwise hang release CI until the job - // times out with nothing useful in the log. Effect.timeout(BUNDLE_SELF_CHECK_TIMEOUT), Effect.catchTags({ TimeoutError: () => @@ -2408,12 +2349,9 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp }) { if (input.platform !== "linux") return; // The helper links against the host's libsecret, so it can only be built on - // Linux; the build script is a no-op elsewhere. A Linux artifact from - // another host would ship without it and every v11 cookie import would - // report the keyring as unavailable, so refuse rather than package that - // silently. `universal` is a mac-only arch the option type still admits; - // the helper script rejects it, so it maps to the concrete x64 the Linux - // resource monitor uses for the same request. + // Linux; the build script is a no-op elsewhere. A Linux artifact from another + // host would ship without it and every v11 cookie import would report the + // keyring as unavailable, so refuse rather than package that silently. const hostPlatform = yield* HostProcess.Platform; if (hostPlatform !== "linux") { return yield* new LinuxBrowserSecretHostError({ hostPlatform }); @@ -2645,11 +2583,6 @@ function validateBundledClientAssets(clientDir: string) { }); } -// The main-process bundle inlines every JS dependency (see -// apps/desktop/vite.config.ts), so the packaged app only installs the packages -// that bundle leaves external: native addons and playwright-core. Everything -// else already lives inside dist-electron and would only duplicate what the -// server bundle carries too. export function resolveDesktopRuntimeDependencies( dependencies: Record | undefined, catalog: Record, @@ -2695,13 +2628,6 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest"; } -// Pull request builds (`-pr..`) and the maintainers' preview train -// (`-preview..`) are downloaded by hand and never through an -// updater. Building them without a publish config means electron-builder -// emits no `latest*.yml`/`nightly*.yml` manifests or blockmaps for them and -// the app ships without `app-update.yml`, so neither a stable nor a nightly -// install can be pointed at one of these releases, and the build itself -// reports that no update feed is configured instead of polling. export function isDesktopPreviewVersion(version: string): boolean { return /-pr\./.test(version) || /-preview\.\d{8}\.\d+$/.test(version); } @@ -2762,9 +2688,6 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( readonly provisioningProfilePath: string; } | undefined, - // Windows only, and false when no Linux CLI archive was handed to the build: - // staging skips the archive in that case, and listing a resource whose - // source file was never written fails the electron-builder step. wslRuntimeBundled = false, arch?: typeof BuildArch.Type, ) { @@ -2843,8 +2766,6 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( name: "T3 Code", schemes: ["t3code", "t3code-dev"], }, - // Lets people choose T3 Code as their default web browser, which opens - // each link in a new thread's browser panel. { name: "Web site URL", schemes: ["http", "https"], @@ -2891,20 +2812,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( const path = yield* Path.Path; const repoRoot = yield* RepoRoot; buildConfig.linux = { - // The .deb is built from the same unpacked app after the AppImage. - // electron-builder lists both in latest-linux.yml and writes - // resources/package-type into the .deb only, so electron-updater updates - // each install in its own format. target: target === "AppImage" ? [target, "deb"] : [target], executableName: "t3code", icon: "icons", category: "Development", synopsis: "Desktop GUI for coding agents", - // Required by the .deb control file. maintainer: "T3 Tools ", - // electron-builder turns these into MimeType=x-scheme-handler/; - // in the .desktop entry (Exec already gets %U), so browsers can hand - // t3code:// OAuth callbacks to the app. protocols: [ { name: "T3 Code", @@ -2919,13 +2832,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( }; buildConfig.deb = { // FPM runs outside the staged app directory, so source paths must be absolute. - // AppStream consumers associate this metadata with our t3code.desktop entry. fpm: [ `${path.join(repoRoot, "apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml")}=/usr/share/metainfo/com.t3tools.t3code.metainfo.xml`, `${path.join(repoRoot, "LICENSE")}=/usr/share/doc/t3code/copyright`, ], - // Electron's runtime libraries. Debian 13 and Ubuntu 24.04 renamed some - // for 64-bit time; the old name is the fallback for older releases. + // Debian 13 and Ubuntu 24.04 renamed some for 64-bit time; the old name + // is the fallback for older releases. depends: [ "libasound2t64 | libasound2", "libatspi2.0-0t64 | libatspi2.0-0", @@ -2944,9 +2856,6 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( if (platform === "win") { buildConfig.npmRebuild = false; - // Keep blockmap-based differential downloads enabled while changing the - // installed file topology. The optimization is in the payload shape, not - // in trading update bandwidth for install speed. buildConfig.nsis = { differentialPackage: true }; const winConfig: Record = { target: [target], @@ -2990,10 +2899,9 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f } }); -// Copy the Linux CLI release archive into the stage verbatim and record its -// SHA-256 beside it. The desktop app verifies the digest inside the distro -// before extracting, and the digest also names the extracted runtime's cache -// directory, so it must be computed from the exact bytes that ship. +// The desktop app verifies the digest inside the distro before extracting, and +// the digest also names the extracted runtime's cache directory, so it must be +// computed from the exact bytes that ship. export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: { readonly sourceArchivePath: string; readonly archivePath: string; @@ -3032,11 +2940,6 @@ export const parseWslRuntimeArchiveMembers = (listing: string): ReadonlyArray member.replace(/^\.\//, "").replace(/\/$/, "")) .filter((member) => member.length > 0); -// Stage and pack the Windows server sidecar: the bundled server plus a hoisted -// install of only its runtime-external/native dependency closure for win32. -// The Windows primary runs from the archive through the asar-aware -// ELECTRON_RUN_AS_NODE runtime. Shipping one packed archive instead of -// thousands of loose files is what makes the NSIS install/update fast. export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: { readonly sourceDir: string; readonly asarPath: string; @@ -3300,8 +3203,6 @@ export const validateWindowsPackagedPayload = Effect.fn( readonly stageDistDir: string; readonly appExecutableName: string; readonly targetArch: typeof BuildArch.Type; - // The version the embedded Linux CLI archive must carry; its top-level - // directory is named t3--linux-. readonly appVersion: string; readonly expectWslRuntime?: boolean; readonly fileLimit?: number; @@ -3347,9 +3248,6 @@ export const validateWindowsPackagedPayload = Effect.fn( const unpackedFiles = yield* Effect.try({ try: () => { - // The entry lookup proves the archive contains the server executable, - // while the single header walk identifies every file ASAR redirects to - // the unpacked sibling at runtime. // @electron/asar resolves entry names using the host path separator. // POSIX separators work on Linux/macOS but fail on Windows even when the // entry is present in the archive. @@ -3479,7 +3377,6 @@ export const validateWindowsPackagedPayload = Effect.fn( } const requiredMembers = [`${stem}/t3`, `${stem}/client`, `${stem}/node_modules`]; const missingMembers = requiredMembers.filter((member) => !members.includes(member)); - // node-pty can load a source build or the prebuild for the WSL target. const ptyCandidates = [ `${stem}/node_modules/node-pty/build/Release/pty.node`, `${stem}/node_modules/node-pty/prebuilds/linux-${input.targetArch}/pty.node`, @@ -3495,7 +3392,6 @@ export const validateWindowsPackagedPayload = Effect.fn( cause: new Error("WSL runtime archive is not a Linux CLI release archive"), }); } - // The CLI archive runs the single-executable, never a loose server bundle. const bundleEntry = members.find((member) => member.endsWith("/bin.mjs")); if (bundleEntry !== undefined) { return yield* invalidWslRuntime( @@ -3652,14 +3548,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } } - // Assert against the emitted bundle, not the bundler config. `alwaysBundle` - // only forces packages IN, so a transitive dependency of an external package - // is bundled by default however the predicate is written β€” that silently - // inlined a native loader (node-gyp-build-optional-packages) while every - // list-based test still passed. An inlined native loader resolves its - // prebuilds relative to the bundle and quietly falls back to a slower - // pure-JS path, so this fails the build rather than shipping a silent - // regression. + // An inlined native loader resolves its prebuilds relative to the bundle and + // quietly falls back to a slower pure-JS path, so this fails the build rather + // than shipping a silent regression. { const chunkNames = (yield* fs.readDirectory(distDirs.serverDist)).filter((entry) => entry.endsWith(".mjs"), @@ -3686,17 +3577,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( packages: [""], }); } - // The check above is one-directional: it only proves nothing external got - // inlined. A regression to externalizing everything would also pass it, - // since source-file regions still exist -- and that is the failure this - // whole change exists to prevent, because those packages are not in the - // selected sidecar closure and both backends would die on ERR_MODULE_NOT_FOUND. - // `effect` is imported by every server module, so it is inlined in any - // correctly bundled build. - // The list-based check above only sees packages someone already thought to - // list. bufferutil and utf-8-validate were inlined for exactly that reason: - // native, but absent from the list, so nothing flagged them. Ask the store - // what each inlined package actually is instead. const nativeInlined: string[] = []; for (const name of [...inlinedPackages].sort()) { const packageDir = yield* findStorePackageDirectory(repoRoot, name); @@ -3753,8 +3633,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.verbose, ); } - // On Windows the server tree ships in the server.asar sidecar instead of - // app.asar (see stageWindowsServerSidecar), so the app stage omits it. if (options.platform !== "win") { yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); } @@ -3837,10 +3715,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); } - // Windows splits dependencies per process: app.asar carries only the - // desktop main-process externals, while the server bundle's externals live - // in the server.asar sidecar (see stageWindowsServerSidecar). macOS and - // Linux merge both sets into one app.asar. const stageDependencies = options.platform === "win" ? { ...resolvedDesktopRuntimeDependencies } @@ -3871,7 +3745,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( description: "T3 Code is an open-source desktop app for coding agents. Work with your existing agent subscriptions, review code changes, and run commands in your projects. Connect from desktop, web, or mobile to continue working remotely.", license: "MIT", - // Required by the .deb control file. homepage: "https://t3.codes", author: "T3 Tools", main: "apps/desktop/dist-electron/boot.cjs", @@ -3928,8 +3801,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); yield* stageKeyringNativeBinaries(stageAppDir, options.platform, options.arch); - // Only the Windows artifact carries the server sidecar and the WSL runtime; - // other platforms ignore the --wsl-runtime input. if (options.platform === "win" && windowsServerAsarPath) { yield* stageWindowsServerSidecar({ stageRoot, @@ -4046,18 +3917,6 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }); } - // Prove the packaged bundle is self-contained by loading it the way the WSL - // backend does, rather than by reasoning about the emitted source. - // - // Static analysis kept getting this wrong here. Scanning for bare imports - // matched specifiers inside effect's JSDoc examples and inside ajv's runtime - // codegen template, and asserting that one sentinel package was inlined - // missed a build that inlined `effect` while leaving `yaml` external. Node's - // resolver has no such ambiguity: it either finds every import or it does not. - // - // Only Windows unpacks anything; macOS and Linux keep the whole tree inside - // the app asar. Windows validates and executes the separately packed server - // sidecar after electron-builder copies it into the final payload. if (options.platform === "win") { yield* validateWindowsPackagedPayload({ stageDistDir, diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 6ba620528bfe..6f604db9f518 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -44,7 +44,6 @@ const run = Effect.fn("test.run")(function* ( return { stdout, stderr, exitCode }; }); -/** A tar.gz laid out like build-cli-archive.ts writes, with a stub `t3` that echoes its args. */ const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -114,7 +113,6 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { version: VERSION, allowMissing: true, }); - // Platform packages in CLI_ARCHIVE_PLATFORM_KEYS order, launcher last. assert.deepStrictEqual( outputs.map((output) => output.name), ["@t3code/t3-darwin-arm64", "@t3code/t3-linux-x64", "t3"], @@ -140,16 +138,12 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { ]); assert.equal(linuxManifest.preferUnplugged, true); assert.isUndefined(linuxManifest.bin); - // The shipped node_modules is declared, or npm prunes it as extraneous - // on the next install in the same project and the executable breaks. assert.deepStrictEqual(linuxManifest.dependencies, { "@ff-labs/fff-node": "0.9.4", "node-pty": "1.1.0", }); assert.deepStrictEqual(linuxManifest.bundleDependencies, ["@ff-labs/fff-node", "node-pty"]); - // Archive contents sit at the package root, not under the archive stem. assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html"))); - // A root README, or npm would display a bundled dependency's. assert.include( yield* fs.readFileString(path.join(linuxDir, "README.md")), "# @t3code/t3-linux-x64", @@ -180,13 +174,9 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { assert.isUndefined(launcherManifest.engines); assert.isTrue(yield* fs.exists(path.join(launcherDir, "bin/t3.js"))); - // The scratch dirs must not be left behind next to the packages. const outputEntries = yield* fs.readDirectory(fixture.outputDir); assert.deepStrictEqual(outputEntries.sort(), ["@t3code", "t3", "t3.tgz"]); - // The tarball is what gets published: it must carry node_modules (which - // `npm publish ` would strip) under npm's `package/` root, with the - // executable bit intact. const listing = yield* run( "tar", ["-tzvf", path.join(fixture.outputDir, "@t3code/t3-linux-x64.tgz")], @@ -201,8 +191,6 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { listing.stdout, ); - // NODE_PATH stands in for node_modules: require.resolve finds the - // platform package there exactly as it would after `npm install`. const hostPlatform = yield* HostProcess.Platform; const hostArch = yield* HostProcess.Architecture; const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record; @@ -217,8 +205,6 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { ); assert.equal(passthrough.exitCode, 7); - // Run the entry point used by already-installed service updaters from - // the published tarball, including their preflight arguments. const installedLauncher = path.join(fixture.root, "installed-launcher"); yield* fs.makeDirectory(installedLauncher); const unpack = yield* run( diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index 289a6b32522f..20f74443e1c8 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -1,23 +1,9 @@ #!/usr/bin/env node /** - * Turns the per-platform CLI archives of one release into the npm packages - * behind `npx t3` / `npm i -g t3`: one `@t3code/t3-` package per - * archive holding the archive's contents verbatim, plus the `t3` launcher - * that lists them as optionalDependencies and execs the one npm installed. - * The bytes a user gets from npm are therefore the release archive's, and - * running them needs neither a Node runtime, npm, nor a native build. - * - * Output layout under `--output-dir`: - * - * @t3code/t3-/ archive contents flattened + package.json - * @t3code/t3-.tgz the same tree as an npm tarball - * t3/ launcher: package.json, bin/t3.js, README.md - * t3.tgz the launcher as an npm tarball - * - * The tarballs are what gets published. `npm publish ` always drops - * `node_modules/` (npm-packlist ignores it whatever `files` says, and - * bundleDependencies needs an arborist tree these flattened installs are - * not), whereas `npm publish ` uploads the bytes as given. + * `npm publish ` always drops `node_modules/` (npm-packlist ignores it + * whatever `files` says, and bundleDependencies needs an arborist tree these + * flattened installs are not), whereas `npm publish ` uploads the + * bytes as given. */ import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -89,13 +75,10 @@ export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): stri } /** - * package.json for one platform package; `os`/`cpu` let npm skip the other - * five. The archive's runtime `node_modules` (native addons and their - * loaders) ships inside the tarball, and npm only keeps a nested tree it can - * account for: anything not declared is extraneous and pruned on the next - * `npm install` in that project, which then breaks the executable. Declaring - * every bundled package as a bundled dependency at the exact version on disk - * makes npm treat the tree as part of this package and leave it alone. + * The archive's runtime `node_modules` (native addons and their loaders) ships + * inside the tarball, and npm only keeps a nested tree it can account for: + * anything not declared is extraneous and pruned on the next `npm install` in + * that project, which then breaks the executable. */ export function npmPlatformPackageManifest( platformKey: CliArchivePlatformKey, @@ -122,7 +105,6 @@ export function npmPlatformPackageManifest( const PackageVersion = Schema.Struct({ version: Schema.String }); const decodePackageVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageVersion)); -/** Every top-level package under `node_modules`, scoped ones included, at the version its manifest names. */ const readBundledPackages = Effect.fn("readBundledPackages")(function* (nodeModulesDir: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -147,9 +129,8 @@ const readBundledPackages = Effect.fn("readBundledPackages")(function* (nodeModu }); /** - * README for one platform package. Without one at the package root, npm - * shows the first README it finds in the tarball, which is a bundled - * dependency's (ffi-rs). + * Without one at the package root, npm shows the first README it finds in the + * tarball, which is a bundled dependency's (ffi-rs). */ export function npmPlatformPackageReadme(platformKey: CliArchivePlatformKey): string { return [ @@ -168,7 +149,6 @@ export function npmPlatformPackageReadme(platformKey: CliArchivePlatformKey): st ].join("\n"); } -/** package.json for the `t3` launcher. No engines: bin/t3.js is trivial CJS. */ export function npmLauncherPackageManifest( version: string, platformKeys: ReadonlyArray, @@ -187,11 +167,6 @@ export function npmLauncherPackageManifest( }; } -/** - * The launcher every `npx t3` runs. Plain CommonJS with no dependencies so it - * loads on any Node that npm itself runs on; the real work happens in the - * single-executable it execs. - */ export const NPM_LAUNCHER_SCRIPT = `#!/usr/bin/env node "use strict"; const { spawnSync } = require("node:child_process"); @@ -246,9 +221,8 @@ const runCommand = Effect.fn("runCommand")(function* ( }); /** - * Extracts an archive and returns its single top-level directory. `.tar.gz` - * goes through tar everywhere; `.zip` through the bsdtar Windows ships or, - * elsewhere, `unzip`, since GNU tar cannot read zip. + * `.tar.gz` goes through tar everywhere; `.zip` through the bsdtar Windows + * ships or, elsewhere, `unzip`, since GNU tar cannot read zip. */ const extractArchive = Effect.fn("extractArchive")(function* (archive: string, into: string) { const fs = yield* FileSystem.FileSystem; @@ -286,10 +260,6 @@ const hostTar = Effect.map(HostProcess.Platform, (platform) => platform === "win32" ? windowsSystemTar() : "tar", ); -/** - * Writes `stageDir/package` as a gzipped npm tarball and then moves the tree - * to `packageDir` so the contents stay inspectable beside the tarball. - */ const packAndPlace = Effect.fn("packAndPlace")(function* (input: { readonly stageDir: string; readonly packageDir: string; @@ -311,11 +281,6 @@ export interface NpmPackageOutput { readonly tarball: string; } -/** - * Extracts one archive, adds its package.json, and emits the package dir and - * tarball. The scratch dir lives inside the output dir so the extracted tree - * is renamed into place rather than copied across filesystems. - */ const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: { readonly key: CliArchivePlatformKey; readonly archive: string; @@ -365,7 +330,6 @@ const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: return output; }, Effect.scoped); -/** Writes the launcher package (package.json, bin/t3.js, README) and its tarball. */ const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: { readonly outputDir: string; readonly version: string; @@ -429,8 +393,6 @@ export const buildNpmPlatformPackages = Effect.fn("buildNpmPlatformPackages")(fu yield* fs.makeDirectory(path.join(input.outputDir, NPM_PLATFORM_PACKAGE_SCOPE), { recursive: true, }); - // Each archive stages in its own scratch dir, so all of them unpack and - // compress at once. Sequentially this took about 45s for five archives. const platformOutputs = yield* Effect.forEach( archives, ({ key, archive }) => diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 950d0214c491..83f75d574fa8 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -407,9 +407,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - // Browser dev is single-origin: Vite proxies the backend, and the client - // resolves it from window.location.origin. Baking a localhost URL here is - // what breaks sharing a dev server to another device. it.effect.each(["dev", "dev:web"] as const)( "leaves the client backend URLs unset in %s mode", (mode) => @@ -441,8 +438,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - // Desktop pins the renderer at loopback deliberately; an ambient marker - // must not make Vite discard those URLs. it.effect("clears the single-origin marker in dev:desktop mode", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ @@ -485,9 +480,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - // HOST is Vite's bind address and gates the HMR pin in vite.config.ts. An - // inherited one would survive into browser dev and point HMR at the wrong - // interface β€” invisible over a shared origin, since the page still loads. it.effect.each(["dev", "dev:web"] as const)("drops an inherited HOST in %s mode", (mode) => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ @@ -508,8 +500,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - // --host configures the *backend* (T3CODE_HOST). It must not become Vite's - // bind address by way of an inherited HOST that happens to agree with it. it.effect("drops an inherited HOST even when --host is given", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ @@ -531,7 +521,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - // Desktop sets HOST itself, so the clearing must not reach it. it.effect("still pins HOST for dev:desktop", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ @@ -724,8 +713,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - // A port free on loopback can be taken on the interface the server will - // actually bind, so --host/T3CODE_HOST has to be probed as well. it.effect("adds a non-loopback bind host to the probe list", () => Effect.sync(() => { assert.deepStrictEqual(devPortProbeHosts("0.0.0.0"), ["127.0.0.1", "::1", "0.0.0.0"]); @@ -743,9 +730,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - // Only the backend honours --host/T3CODE_HOST. Vite reads HOST (set for - // desktop only), so judging the web port against the backend's interface - // would reject ports for a server that never binds there. it.effect("passes the port role so only the server port sees the bind host", () => Effect.gen(function* () { const probed: Array<{ port: number; role: string | undefined }> = []; @@ -904,10 +888,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // `tailscale serve` config outlives the process, so a dry run that shared // would replace and then tear down whatever mapping the port already had. - // Base-dir precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) - // lives in runDevRunnerWithInput; the env builder must not consult the - // ambient variable on its own, or it would silently outrank the worktree - // default and land dev state on the user's real database. it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ @@ -928,9 +908,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - // Sharing dev:desktop would publish a URL whose renderer dials the - // visitor's own loopback, and would clobber the VITE_DEV_SERVER_URL that - // Electron loads from. It must decline, not half-work. it.effect("declines to share for dev:desktop and still starts the stack", () => { let spawnCount = 0; const spawnerLayer = Layer.succeed( @@ -956,9 +933,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); }); - // Single-origin browser dev proxies the backend at localhost, so a backend - // bound only to a specific interface breaks every proxied request in a way - // that looks like a broken server. Reject the combination up front. it.effect("rejects a specific non-loopback --host for browser dev modes", () => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -987,9 +961,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); }); - // Wildcards keep loopback answering, so the proxy target stays valid and - // the combination must keep working β€” it is the documented way to serve a - // LAN interface and the browser proxy at once. it.effect("still spawns the stack for a wildcard --host in dev mode", () => { let spawnCount = 0; const spawnerLayer = Layer.succeed( @@ -1015,8 +986,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); }); - // dev:server does not proxy β€” the client talks to the backend directly β€” - // so a specific interface bind stays legitimate there. it.effect("keeps a specific --host working for dev:server", () => { let spawnCount = 0; const spawnerLayer = Layer.succeed( @@ -1060,8 +1029,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { captured = spawned.options?.env; return Effect.succeed(mockProcess(0)); } - // tailscale: answer `status --json` with a valid tailnet name, - // succeed the `serve`/`off` calls. return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(2), diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index c8130119ac41..31f521a96d8a 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -41,17 +41,12 @@ const FETCH_BAD_PORTS = new Set([ 6669, 6679, 6697, 10080, ]); // Dev servers bind loopback, so loopback is the only interface whose -// availability decides whether we can use a port. Probing wildcards too made -// the runner walk away from a perfectly free port whenever something else held -// the same number on another interface β€” `tailscale serve` does exactly that, -// which silently moved the ports out from under a URL that had just been shared. +// availability decides whether we can use a port. const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const; /** - * Bind hosts on which a backend still answers `http://localhost:`, which - * is where single-origin browser dev proxies to. Loopback and the wildcards - * qualify; a specific interface (e.g. a LAN IP) does not β€” the OS binds only - * that address and the proxy target goes dark. + * Loopback and the wildcards qualify; a specific interface (e.g. a LAN IP) does + * not β€” the OS binds only that address and the proxy target goes dark. */ export function isProxiableBindHost(host: string): boolean { const normalized = host.trim(); @@ -88,8 +83,7 @@ const MODE_ARGS = { type DevMode = keyof typeof MODE_ARGS; /** * `role` matters because only the backend honours `--host`/`T3CODE_HOST`; the - * web port is always loopback. Passed explicitly rather than inferred from the - * port number, which stops distinguishing them under a large port offset. + * web port is always loopback. */ type PortAvailabilityCheck = ( port: number, @@ -252,11 +246,6 @@ export function resolveOffset(config: { return Effect.succeed({ offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }); } - // Worktrees get ports derived from their path so each one is stable across - // restarts and distinct from its siblings. Without this every worktree starts - // at offset 0 and scan-collides onto whatever happens to be free that minute, - // so ports move under you between runs β€” which breaks any URL you already - // shared. The main checkout keeps the documented 5733/13773. const worktreePath = config.worktreePath?.trim(); if (worktreePath) { const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1; @@ -309,8 +298,6 @@ export function createDevRunnerEnv({ return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; - // Precedence (--home-dir > worktree .t3 > ambient T3CODE_HOME) is resolved - // by the caller; an unset t3Home here genuinely means "use the default". const configuredBaseDir = t3Home?.trim() || undefined; const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); const isDesktopMode = mode === "dev:desktop"; @@ -343,24 +330,18 @@ export function createDevRunnerEnv({ // only place we set it. An inherited one (an exported HOST, a container, // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin // its HMR socket to that address β€” see the `explicitHost` gate in - // apps/web/vite.config.ts. Over a shared origin that is invisible: the - // page loads and only HMR quietly dials the wrong machine. + // apps/web/vite.config.ts. delete output.HOST; if (mode === "dev" || mode === "dev:web") { // Browser dev is single-origin: everything (including /ws) is proxied // through Vite, so the client must resolve its backend from - // window.location.origin rather than a baked-in localhost URL. See - // resolveConfiguredPrimaryTarget in apps/web/src/environments/primary/target.ts - // β€” it only defers to the origin when both of these are absent. Baking - // localhost here is what breaks any non-localhost origin (tailnet, LAN, - // phone): the remote browser dials its own machine. + // window.location.origin rather than a baked-in localhost URL. delete output.VITE_HTTP_URL; delete output.VITE_WS_URL; // Deleting is not enough on its own: vite.config.ts calls loadRepoEnv, // which merges `.env`/`.env.local` *under* this env, so a developer // with either URL in their `.env` would get it back and silently lose - // single-origin mode. This states the intent positively so Vite can - // ignore those values rather than infer from their absence. + // single-origin mode. output.T3CODE_SINGLE_ORIGIN_DEV = "1"; } else { output.VITE_HTTP_URL = `http://localhost:${serverPort}`; @@ -445,21 +426,6 @@ export function checkPortAvailabilityOnHosts( }); } -/** - * Hosts to probe for a dev server bound to `configuredHost`. - * - * Loopback is always checked because the web server and the desktop renderer - * target reach it there. When `--host`/`T3CODE_HOST` moves the backend onto - * another interface, that interface decides whether the bind actually - * succeeds β€” probing only loopback would hand back a port that is free here - * and taken there, and the server would fail to start. - * - * `configuredHost` applies to the *backend* only. Vite takes its bind address - * from `HOST`, which the runner sets for desktop alone, so the web port stays - * on loopback and must not be judged against the backend's interface β€” - * a port free on loopback but busy on that interface would otherwise be - * rejected for a server that was never going to bind there. - */ export function devPortProbeHosts(configuredHost: string | undefined): ReadonlyArray { const host = configuredHost?.trim(); if (!host || DEV_PORT_PROBE_HOSTS.includes(host as (typeof DEV_PORT_PROBE_HOSTS)[number])) { @@ -632,11 +598,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { ), ); - // Single-origin browser dev proxies the backend at localhost. A wildcard - // bind still answers there; a specific non-loopback interface does not, - // which breaks every proxied request in a way that reads as "server is - // broken" rather than "flag combination is unsupported". Reject it up - // front instead. (dev:server and dev:desktop don't proxy β€” untouched.) if ( (input.mode === "dev" || input.mode === "dev:web") && input.host !== undefined && @@ -658,8 +619,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { startOffset: offset, hasExplicitServerPort: input.port !== undefined, hasExplicitDevUrl: input.devUrl !== undefined, - // A non-loopback bind host decides whether the backend can actually take - // the port, so it has to be probed alongside loopback. checkPortAvailability: makeDefaultCheckPortAvailability(input.host), }); @@ -711,12 +670,6 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { if (input.mode === "dev:server") { yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server)."); } else if (input.mode === "dev:desktop") { - // Desktop is not single-origin: the renderer gets VITE_HTTP_URL and - // VITE_WS_URL baked to loopback, so a tailnet visitor would load the UI - // and then watch it dial its own 127.0.0.1 for the backend. Worse, - // sharing would overwrite VITE_DEV_SERVER_URL, which is the origin - // Electron itself loads the renderer from. Refuse rather than hand out - // a URL that is broken in a way the user cannot see. yield* Effect.logWarning( "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.", ); @@ -725,20 +678,9 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { // process (and reboots), so the cleanup has to be registered atomically // with creating it. An interrupt landing in between would otherwise // leave a mapping pointing at a port nothing is listening on. - // - // Deliberately no ownership tracking beyond that: if a second runner - // takes this port during a fast restart, the first's exit can briefly - // tear down the new mapping β€” visible (the URL stops working) and fixed - // by re-running --share. A lease protocol closing that window existed - // and was removed as more machinery than a dev convenience warrants. - // - // A tailnet that isn't up shouldn't stop the dev server from starting β€” - // warn, and carry on serving locally. const shared = yield* Effect.acquireRelease( shareDevServer({ webPort: sharedWebPort }), () => - // Serve config outlives this process, so a cleanup that did not - // take leaves a tailnet URL pointing at a port nothing serves. unshareDevServer(sharedWebPort).pipe( Effect.flatMap((result) => result.cleared @@ -772,17 +714,13 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { ] .filter((entry) => entry && entry.length > 0) .join(","); - // The server builds its pairing URL from this, so the URL printed at - // startup is already the shareable one β€” no rewriting by hand. An - // explicit --dev-url still wins. if (input.devUrl === undefined) { env.VITE_DEV_SERVER_URL = shared.url; } // A shared origin serves a remote browser, where unbundled dev's // per-module requests each pay a tailnet round trip β€” a cold module - // graph takes minutes to first paint. Bundled dev collapses that to - // a few chunk requests. Only defaulted, so T3CODE_BUNDLED_DEV=0 - // still opts a --share run back out. + // graph takes minutes to first paint. Bundled dev collapses that to a + // few chunk requests. if (env.T3CODE_BUNDLED_DEV === undefined) { env.T3CODE_BUNDLED_DEV = "1"; } diff --git a/scripts/export-android-icons.ts b/scripts/export-android-icons.ts index 7e100577e760..9f339ef1d774 100644 --- a/scripts/export-android-icons.ts +++ b/scripts/export-android-icons.ts @@ -1,17 +1,10 @@ #!/usr/bin/env node -// Renders the Android launcher and splash artwork from the Icon Composer SVG sources. -// // Icon Composer exports already contain a rounded-square silhouette, and Android masks // the central 72dp of a 108dp adaptive canvas, so exporting them as a foreground produces // a double-framed icon with the letters cropped by the mask. Instead, each variant gets a // full-bleed background layer (the artwork behind the wordmark) and a shared transparent // foreground that keeps the wordmark inside the safe zone. -// -// The Android 12+ splash screen masks its icon to a circle covering the central two thirds -// of a 288dp canvas, which is the same proportion the launcher crops. Composing the two -// adaptive layers into one 288dp image therefore makes the splash frame the wordmark -// exactly like the launcher icon does. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -24,21 +17,13 @@ import sharp from "sharp"; type IconVariant = "dev" | "nightly" | "prod"; -// 108dp at xxxhdpi. Expo's prebuild derives every launcher density bucket from this. const ADAPTIVE_CANVAS = 432; -// 288dp at xxxhdpi: the full Android 12+ splash canvas, so the icon needs no upscaling. const SPLASH_CANVAS = 1152; -// Icon Composer's layer sources use a 128pt viewBox; the wordmark path spans this box. const TEXT = { x: 15.53, y: 37, width: 94.5, height: 57 }; -// Wordmark width as a fraction of the 108dp canvas. The visible area is 72dp (66dp -// guaranteed), so 0.48 leaves the letters at ~72% of the mask with room for the -// launcher's own zoom effects. const WORDMARK_FRACTION = 0.48; -// Icon Composer positions layers on a 1024pt canvas, with translation relative to center. const COMPOSER_CANVAS_PT = 1024; const SVG_DENSITY = 300; const OUTPUT_DIRECTORY = "apps/mobile/assets"; -// Production has no background artwork, so its splash composes onto the adaptive color. const PRODUCTION_BACKGROUND_COLOR = "#000000"; export class AndroidIconRenderError extends Schema.TaggedError()( @@ -118,8 +103,6 @@ const renderForeground = Effect.fn("androidIcons.renderForeground")(function* ( const renderDevelopmentBackground = Effect.fn("androidIcons.renderDevelopmentBackground")( function* (repositoryRoot: string, size: number) { - // The annotation layer shares the wordmark's coordinate space, so it is scaled and - // centered the same way to keep the dimension lines around the letters. const annotations = yield* readLayerSource(repositoryRoot, "dev", "annotations.svg"); const defs = annotations.match(/[\s\S]*?<\/defs>/)?.[0] ?? ""; const body = annotations.replace(/^[\s\S]*?<\/defs>/, "").replace(/<\/svg>\s*$/, ""); diff --git a/scripts/install.test.ts b/scripts/install.test.ts index e0fb60517e4a..54af8a577727 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -8,7 +8,6 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; -// util-linux's script gives the real installer a terminal without a browser or extra packages. describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer terminal", () => { it.each([false, true])( "preserves download and install behavior (HTTP failure: %s)", diff --git a/scripts/knip-schemas.ts b/scripts/knip-schemas.ts index afc8385a69b9..824c43eb31ff 100644 --- a/scripts/knip-schemas.ts +++ b/scripts/knip-schemas.ts @@ -4,8 +4,6 @@ import type { Preprocessor } from "knip"; // Knip needs the legacy compiler API, which TypeScript 7 no longer exports. import ts from "typescript-legacy"; -// Effect 4 schemas carry this marker, including aliases and Schema.Class constructors. -// Checking the type avoids evaluating application modules or exempting schema factories/decoders. const schemaTypeId = "~effect/Schema/Schema"; const preprocess: Preprocessor = (options) => { @@ -62,7 +60,6 @@ const preprocess: Preprocessor = (options) => { checker.getExportsOfModule(module).flatMap((symbol) => { const exported = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; - // Duplicate exports can name a private value with a public type of the same name. const target = exported.flags & ts.SymbolFlags.Value ? exported diff --git a/scripts/legend-list-initial-reveal.test.ts b/scripts/legend-list-initial-reveal.test.ts index 00e3e30594af..1c60447874f9 100644 --- a/scripts/legend-list-initial-reveal.test.ts +++ b/scripts/legend-list-initial-reveal.test.ts @@ -3,8 +3,6 @@ import * as NodeFS from "node:fs"; import * as NodeVM from "node:vm"; import { describe, expect, it, vi } from "vite-plus/test"; -// Exercise the shipped patch without loading React Native in the Node runner. -// Native scroll delivery and animation frames advance independently here. function createList(bundle: string) { const source = NodeFS.readFileSync( new URL(`../apps/mobile/node_modules/@legendapp/list/${bundle}`, import.meta.url), diff --git a/scripts/lib/build-target-arch.test.ts b/scripts/lib/build-target-arch.test.ts index d86573dca5a9..903e9ec7466f 100644 --- a/scripts/lib/build-target-arch.test.ts +++ b/scripts/lib/build-target-arch.test.ts @@ -27,13 +27,10 @@ const withHostRuntime = ( describe("build-target-arch", () => { it.effect("uses the resolved host arch when selecting the default Windows build arch", () => Effect.gen(function* () { - // This mirrors the packaging script's default-path behavior: the current - // process is x64, but the machine itself is ARM64, so the default build - // target should be win-arm64 rather than win-x64. const arch = yield* getDefaultBuildArch("win", { archChoices: ["x64", "arm64"] }).pipe( withHostRuntime("win32", "x64", { - PROCESSOR_ARCHITECTURE: "AMD64", // The currently running Node process is x64. - PROCESSOR_ARCHITEW6432: "ARM64", // The process is x64, but the actual Windows host is ARM64. + PROCESSOR_ARCHITECTURE: "AMD64", + PROCESSOR_ARCHITEW6432: "ARM64", }), ); diff --git a/scripts/lib/cli-executable-imports.ts b/scripts/lib/cli-executable-imports.ts index 3391445beb2a..d70afb5a053f 100644 --- a/scripts/lib/cli-executable-imports.ts +++ b/scripts/lib/cli-executable-imports.ts @@ -3,15 +3,11 @@ import * as NodeModule from "node:module"; import ts from "typescript-legacy"; /** - * Scan an emitted bundle chunk for ESM imports of packages that are not Node - * built-ins. - * * Inside a Node single-executable, `import` statements and `import()` can only * resolve built-in modules; any file-backed specifier throws at module * evaluation (static) or at first use (dynamic). External packages therefore * have to be reached through `createRequire`, which reads the real filesystem - * in every runtime. The bundler cannot enforce this, so the check reads what it - * produced. + * in every runtime. */ export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray { const specifiers = new Set(); diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index de017e0c7cfe..5581510ad777 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -18,7 +18,6 @@ import { shouldBundleCliDependency, } from "./cli-external-packages.ts"; -// Only the field this test cares about; decoding ignores everything else. // optionalDependencies matter as much as dependencies here: every native family // in the list declares its actual platform bindings there (ffi-rs -> @yuuang/*, // fff-node -> @ff-labs/fff-bin-*), so reading only `dependencies` would check @@ -57,9 +56,6 @@ describe("shouldBundleCliDependency", () => { } }); - // The real package is `node-gyp-build-optional-packages`, reached by prefix. - // It is transitive to a selected dependency root, so the runtime closure test - // below ensures it follows that root into the sidecar. it("treats prefix-matched siblings as external", () => { assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false); }); @@ -95,23 +91,12 @@ describe("selectCliRuntimeExternalDependencies", () => { }); }); -// An external package is loaded from the real filesystem, so its own `require` -// also resolves from the real filesystem. If one of its dependencies was -// bundled away instead of left external, that dependency does not follow the -// selected root into the sidecar. -// -// Found the hard way: msgpackr-extract's node-gyp-build-optional-packages -// required detect-libc, which was bundled. Windows was fine; WSL got -// MODULE_NOT_FOUND. it.layer(NodeServices.layer)("external package dependency closure", (it) => { // Read manifests off disk from the pnpm store rather than resolving them. // `require("/package.json")` cannot do this job: under pnpm isolation a - // transitive package (node-addon-api, ffi-rs) is not reachable - // by name from this file at all, and an `exports` map can refuse the - // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not - // installed", which would let this test skip everything and pass while - // checking nothing. The store contains the dependency graph the sidecar's - // minimal production install resolves. + // transitive package (node-addon-api, ffi-rs) is not reachable by name from + // this file at all, and an `exports` map can refuse the `/package.json` + // subpath outright (@ff-labs/fff-node). const readInstalledPackages = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -121,9 +106,7 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { ); // The store holds regular files too (lock.yaml), so a path built under one - // raises ENOTDIR rather than reporting absence. That throws on Linux while - // Windows quietly returns false, which is exactly the kind of difference - // this test exists to catch, so treat any failure as "not there". + // raises ENOTDIR rather than reporting absence. const isPresent = (candidate: string) => fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); @@ -165,10 +148,6 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { const installed = yield* readInstalledPackages; const found = [...installed.keys()].filter(isRuntimeExternal); - // Without this the closure check below can pass vacuously: if nothing is - // read, nothing is checked. node-pty is the one native root every - // platform ships, and node-addon-api is its transitive runtime - // dependency, so require them by name. for (const required of ["node-pty", "node-addon-api"]) { assert.ok( found.includes(required), @@ -184,10 +163,6 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { const installed = yield* readInstalledPackages; const violations: string[] = []; const seen = new Set(); - // Seeded from what is actually installed and matches a prefix, so scoped - // prefixes like "@yuuang/" and "@ff-labs/" are covered too. Seeding from - // the prefix strings themselves would skip every scoped entry, since a - // prefix is not a package name. const queue = [...installed.keys()].filter(isRuntimeExternal); for (const name of queue) { @@ -219,8 +194,6 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { ); }); -// Configuring the bundler is not the same as checking what it emitted. These -// exercise the scanner against the marker shape rolldown actually produces. describe("findInlinedExternalPackages", () => { const region = (path: string) => `//#region ${path} var x = 1; @@ -256,11 +229,6 @@ var x = 1; assert.strictEqual(result.regionCount, 2); }); - // regionCount is what separates "clean" from "this scan went blind because the - // marker format changed". A caller that ignores it gets a vacuous pass. - // The scan has to answer both directions. Checking only that externals are - // absent still passes on a bundle that externalized everything, which is the - // failure this whole change prevents. it("reports the packages that were inlined, not just the violations", () => { const source = region("../../node_modules/.pnpm/effect@4.0.0/node_modules/effect/dist/index.js") + @@ -286,10 +254,6 @@ var x = 1; }); }); -// The single-executable build can only `import` built-ins. A file-backed -// import of an external package passes every bundler check and the regular -// `node dist/bin.mjs` path, then fails inside the executable, so the scan -// reads the emitted module graph instead. describe("findEsmImportsOfExternalPackages", () => { it("flags static and dynamic imports of file-backed packages", () => { const source = [ diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index a1846e1175c2..678d3d6e9151 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -1,29 +1,15 @@ /** - * The single source of truth for packages the server CLI bundle must NOT inline. - * * Two consumers derive from this list, and they must never disagree: * * - apps/server/vite.config.ts decides what stays external to the bundle. * - scripts/build-desktop-artifact.ts selects the runtime dependency roots for * the Windows server sidecar. * - * A runtime package that is external but absent from the sidecar fails as soon - * as Node resolves it from the emitted bundle. Keeping both consumers on one - * list prevents packaging from drifting away from the bundle boundary. - * - * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover - * a package's platform-specific siblings β€” `node-gyp-build` covers - * `node-gyp-build-optional-packages`, `@yuuang/` covers every `ffi-rs-*` binding. */ /** - * External because Node actually loads them from disk at runtime. - * - * Native addons (.node), the JS wrappers that dlopen them by real path, and β€” - * critically β€” the ordinary JS packages those wrappers require. An external - * package is loaded from the real filesystem, so its own `require` also - * resolves from the real filesystem; a dependency that was bundled away exists - * only inside the emitted bundle and is unreachable there. This closure is - * enforced by a test, not by inspection. + * An external package is loaded from the real filesystem, so its own `require` + * also resolves from the real filesystem; a dependency that was bundled away + * exists only inside the emitted bundle and is unreachable there. */ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ // Cursor ships computed Webpack imports and platform helper packages. @@ -38,14 +24,6 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "@clerk/electron-passkeys", "node-gyp-build", "node-addon-api", - // ws's optional accelerators. Nothing in this repo declares them, so they are - // not in the staged production install and the packaged app does not ship - // them either way -- ws wraps the require in try/catch and falls back to its - // JS paths. They are listed because they were being inlined from the dev - // store: both carry binding.gyp and prebuilds and load through - // node-gyp-build, and a native loader inlined into a bundle chunk searches - // for prebuilds that cannot be beside it. Listing them keeps that from - // becoming real if either is ever declared as a dependency. "bufferutil", "utf-8-validate", ] as const; @@ -72,26 +50,20 @@ export function isRuntimeExternalCliDependency(id: string): boolean { } /** - * True when `id` must stay out of the bundle. - * * This has to be wired to the bundler's `neverBundle`, not just to * `alwaysBundle`. `alwaysBundle` only forces packages IN β€” returning false from * it means "no opinion", and the default then applies: a declared dependency - * stays external, but a transitive one gets bundled. That is how a native - * loader such as node-gyp-build ended up inlined while node-pty (a declared - * dependency) stayed external. + * stays external, but a transitive one gets bundled. */ export function isExternalCliDependency(id: string): boolean { return isRuntimeExternalCliDependency(id); } -/** True when the CLI bundle should inline `id` rather than leave it external. */ export function shouldBundleCliDependency(id: string): boolean { if (id.startsWith("node:")) return false; return !isExternalCliDependency(id); } -/** Select direct dependency roots whose runtime closure belongs in the sidecar. */ export function selectCliRuntimeExternalDependencies( dependencies: Readonly>, ): Record { @@ -101,25 +73,8 @@ export function selectCliRuntimeExternalDependencies( } /** - * Scan an emitted bundle chunk for runtime-external packages that were inlined. - * - * Configuring the bundler is not the same as checking what it produced. The - * `alwaysBundle` predicate only forces packages IN; returning false from it - * means "no opinion", so a transitive dependency still gets bundled by default. - * A native loader and its helper (node-gyp-build-optional-packages and - * detect-libc, when msgpackr-extract was still a dependency) were inlined that - * way while every list-based test passed, which is why this reads the artifact - * instead. - * * `regionCount` is reported so the caller can tell "nothing was inlined" apart * from "the marker format changed and this scan no longer sees anything". - * - * `inlinedPackages` is every package seen in a region, which lets the caller - * check the opposite direction too. Verifying only that externals are absent - * would still pass if the bundler reverted to leaving everything external: the - * scan would see source-file regions, report nothing inlined, and the packaged - * backends would then fail with ERR_MODULE_NOT_FOUND because those packages - * are not in the selected sidecar closure either. */ export function findInlinedExternalPackages(source: string): { readonly regionCount: number; diff --git a/scripts/lib/cursor-sdk-packaging.test.ts b/scripts/lib/cursor-sdk-packaging.test.ts index efaaa96b4954..cfbb312f186e 100644 --- a/scripts/lib/cursor-sdk-packaging.test.ts +++ b/scripts/lib/cursor-sdk-packaging.test.ts @@ -30,9 +30,6 @@ const decodeManifest = Schema.decodeUnknownSync( const repoRoot = NodeURL.fileURLToPath(new URL("../..", import.meta.url)); -// Copy the installed production JS dependency graph, with no pnpm symlinks back -// into the checkout. Optional platform executables are covered by desktop staging -// tests; this probe never creates a local agent or contacts Cursor. async function stagePackage(name: string, from: string, destination: string): Promise { const require = NodeModule.createRequire(from); let source = NodePath.dirname(require.resolve(name)); @@ -68,7 +65,6 @@ async function stagePackage(name: string, from: string, destination: string): Pr it("loads packaged Cursor catalog chunks without credentials or checkout dependencies", async () => { const scratch = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-cursor-package-")); try { - // Missing staged dependencies must not resolve from a developer's /tmp tree. for (let parent = NodePath.dirname(scratch); ; parent = NodePath.dirname(parent)) { assert.isFalse(NodeFS.existsSync(NodePath.join(parent, "node_modules"))); if (parent === NodePath.dirname(parent)) break; diff --git a/scripts/lib/desktop-external-packages.ts b/scripts/lib/desktop-external-packages.ts index 5fb2b2df15c0..d22e7bd42ec0 100644 --- a/scripts/lib/desktop-external-packages.ts +++ b/scripts/lib/desktop-external-packages.ts @@ -1,15 +1,8 @@ /** - * Packages the desktop main-process bundle must NOT inline. - * - * The desktop bundle follows the same policy as the server CLI bundle (see - * cli-external-packages.ts): everything is inlined except what Node has to - * load from the real filesystem. Both `apps/desktop/vite.config.ts` and the - * artifact stage in scripts/build-desktop-artifact.ts derive from this list, - * so a package that is external is also the only kind of package the staged - * production install carries. Anything not listed here ships inside - * `dist-electron/*.cjs` and has no `node_modules` presence at all. - * - * Entries are matched as prefixes so platform-specific siblings are covered. + * Both `apps/desktop/vite.config.ts` and the artifact stage in + * scripts/build-desktop-artifact.ts derive from this list, so a package that is + * external is also the only kind of package the staged production install + * carries. */ export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [ // Native addons and the wrappers that dlopen them by real path. @@ -29,7 +22,6 @@ export function isDesktopRuntimeExternalDependency(id: string): boolean { return DESKTOP_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix)); } -/** Select the desktop dependency roots whose runtime closure the stage must install. */ export function selectDesktopRuntimeExternalDependencies( dependencies: Readonly>, ): Record { diff --git a/scripts/lib/dev-share.test.ts b/scripts/lib/dev-share.test.ts index cddc95e63614..0ed4309556d5 100644 --- a/scripts/lib/dev-share.test.ts +++ b/scripts/lib/dev-share.test.ts @@ -22,11 +22,6 @@ interface CallResult { const encode = (value: string) => Stream.make(new TextEncoder().encode(value)); -/** - * Answers `tailscale status --json` with a valid tailnet name, and lets each - * test set the outcome of the `off` (pre-clear) and `serve` calls separately β€” - * they are the same subcommand and are told apart by the trailing `off`. - */ const spawnerLayer = (input: { readonly off?: CallResult; readonly serve?: CallResult; @@ -89,7 +84,6 @@ describe("unshareDevServer", () => { ); assert.isFalse(result.cleared); assert.include(result.explanation, "permission denied"); - // Structured, so a wrapping error can keep the real chain. assert.equal(result.cause?._tag, "TailscaleCommandExitError"); }), ); @@ -121,9 +115,6 @@ describe("shareDevServer", () => { }), ); - // The stale-mapping clear runs before serve, so a failure here leaves the - // port serving nothing. Saying only "serve failed" would let an operator - // assume their previous mapping survived. it.effect("reports that the prior mapping was cleared when serve fails", () => Effect.gen(function* () { const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( @@ -139,13 +130,10 @@ describe("shareDevServer", () => { assert.instanceOf(error, DevServeFailedError); assert.equal(error.stage, "serve"); assert.equal(error.webPort, 5788); - // The underlying failure is preserved rather than flattened to a string. assert.equal( (error.cause as { _tag?: string } | undefined)?._tag, "TailscaleCommandExitError", ); - // Unclassifiable stderr is never quoted (it can carry auth keys), so the - // message points at the command instead of echoing the CLI. assert.notInclude(error.message, "port already in use"); assert.include(error.message, "run the command by hand"); assert.include(error.message, "no longer served"); @@ -153,8 +141,6 @@ describe("shareDevServer", () => { }), ); - // A recognized failure gets our own wording for it β€” enough to act on - // without passing CLI text through. it.effect("explains a recognized serve failure without quoting stderr", () => Effect.gen(function* () { const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( @@ -178,8 +164,6 @@ describe("shareDevServer", () => { }), ); - // Serving over routes we could not remove yields a URL that loads but whose - // /ws and /api quietly point at a dead backend. it.effect("refuses to serve when the existing mapping could not be cleared", () => Effect.gen(function* () { const error: DevShareError = yield* shareDevServer({ webPort: 5788 }).pipe( @@ -188,7 +172,6 @@ describe("shareDevServer", () => { ); assert.instanceOf(error, DevServeFailedError); - // A distinct stage: the prior mapping survived, so nothing was replaced. assert.equal(error.stage, "clear-existing"); assert.include(error.message, "could not clear the existing mapping"); assert.include(error.message, "permission denied"); diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 08d3b750a672..8bcccd8c982c 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -1,13 +1,4 @@ /** - * Shares a running dev server on the local tailnet via `tailscale serve`, so it - * can be opened from a phone, another laptop, or by whoever is reviewing the - * work. - * - * Thin wrapper over `@t3tools/tailscale` (the same client the server's own - * `--tailscale-serve` uses). What it adds is dev-share semantics: replacing a - * stale mapping left by a killed run, and refusing to serve over routes it - * could not remove. - * * Because browser dev is single-origin (Vite proxies the backend β€” see * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering * the web port is enough; the backend needs no mapping of its own. @@ -37,24 +28,11 @@ const DIAGNOSTIC_EXPLANATIONS: Record error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why") : undefined; -/** - * Three distinct failures, three classes: each has its own caller-visible - * message and its own remedy, and `shareDevServer` chooses between them - * structurally. A single error with a `reason` discriminator would encode that - * distinction twice and put a lookup table in the `message` getter. - * - * Each wraps a real underlying failure and so keeps it as `cause`; the message - * is derived only from the structural fields, never from `cause.message`. - */ export class TailscaleUnavailableError extends Schema.TaggedError()( "TailscaleUnavailableError", { cause: Schema.Defect() }, @@ -68,7 +46,6 @@ export class TailscaleUnavailableError extends Schema.TaggedError()( "TailnetNameMissingError", {}, @@ -82,11 +59,6 @@ export class TailnetNameMissingError extends Schema.TaggedError()( "DevServeFailedError", { @@ -116,8 +88,6 @@ export type DevShareError = | DevServeFailedError; /** - * Removes any mapping for `webPort`, reporting whether the port is now clear. - * * Runs uninterruptibly: this is called from a finalizer on the way out of an * interrupted program, and cancelling the cleanup subprocess would leave * exactly the stale mapping it exists to remove. @@ -128,8 +98,6 @@ export const unshareDevServer = ( { readonly cleared: boolean; readonly explanation?: string | undefined; - // Kept structured so a caller wrapping this can preserve the real error - // chain rather than a flattened string. readonly cause?: TailscaleCommandError | undefined; }, never, @@ -139,7 +107,6 @@ export const unshareDevServer = ( Effect.as({ cleared: true } as const), Effect.catch((error: TailscaleCommandError) => Effect.succeed( - // "Nothing was mapped" leaves the port clear either way. error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic === "no-existing-handler" ? ({ cleared: true } as const) @@ -160,10 +127,6 @@ export interface DevShareResult { readonly host: string; } -/** - * Publishes `webPort` on the tailnet at the same port number and returns the - * resulting HTTPS URL. Idempotent: re-running replaces any existing mapping. - */ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (input: { readonly webPort: number; }) { @@ -174,11 +137,6 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in return yield* new TailnetNameMissingError(); } - // Clear any mapping left behind by a run that was killed before its finalizer - // could fire. Serve config survives both the process and a reboot, and a - // stale entry may carry path routes we no longer want β€” older versions mapped - // /ws, /api and friends to a separate backend port, and serving "/" alone - // would leave those pointing at a port nothing is listening on. const cleared = yield* unshareDevServer(input.webPort); if (!cleared.cleared) { // Serving over routes we failed to remove would hand out a URL that is @@ -195,9 +153,7 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in // Proxy to the hostname Vite binds rather than the package default of // 127.0.0.1. Vite listens on `localhost`, which Node 17+ resolves to `::1` // first, so it only binds the IPv6 loopback and a 127.0.0.1 target has - // nothing behind it (tailscale answers 502). Passing `localhost` lets the - // tailscale proxy resolve it the same way Node did. Not a literal `[::1]`: - // tailscale rejects that form. + // nothing behind it (tailscale answers 502). yield* ensureTailscaleServe({ localPort: input.webPort, servePort: input.webPort, diff --git a/scripts/lib/icon-export.ts b/scripts/lib/icon-export.ts index f48228a94fd3..655d8565f0f1 100644 --- a/scripts/lib/icon-export.ts +++ b/scripts/lib/icon-export.ts @@ -25,7 +25,6 @@ export function readPngDimensions(contents: Buffer): { }; } -/** Encodes PNG renditions directly into a modern, multi-resolution ICO file. */ export function encodePngIco(images: ReadonlyArray): Buffer { if (images.length === 0) { throw new Error("An ICO file requires at least one PNG rendition."); diff --git a/scripts/lib/resolve-catalog.ts b/scripts/lib/resolve-catalog.ts index b9d199462317..0a66d4673d9f 100644 --- a/scripts/lib/resolve-catalog.ts +++ b/scripts/lib/resolve-catalog.ts @@ -14,12 +14,6 @@ class CatalogDependencyResolutionError extends Schema.TaggedError, catalog: Record, @@ -32,8 +26,6 @@ export function resolveCatalogDependencies( } const catalogKey = spec.slice("catalog:".length).trim(); - // Overrides can include a parent selector and a version range. A bare - // `catalog:` looks up the final package name, keeping its scope intact. const selector = name.split(">").at(-1) ?? name; const versionIndex = selector.indexOf("@", 1); const packageName = versionIndex === -1 ? selector : selector.slice(0, versionIndex); diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index 9d33fe2eb293..ce5261960192 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -342,7 +342,6 @@ const main = Command.make( const output = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-client-" }); const { mobile } = yield* roots; yield* command("pod", ["install"], true, path.join(mobile, "ios")); - // Target this simulator only, without Expo's desktop activation or log streaming. yield* command( "xcrun", [ diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index 044c92029c51..45a496587fa0 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -54,10 +54,6 @@ const PROJECT_SCRIPTS = JSON.stringify([ const SHOWCASE_TERMINAL_PROMPT = "\u001b[1;32mβ†’\u001b[0m \u001b[1;36mt3code\u001b[0m \u001b[1;34mgit:(\u001b[1;31mfeat/remote-command-center\u001b[1;34m)\u001b[0m \u001b[1;33mβœ—\u001b[0m "; -// A dev-server startup mirroring the web settings' terminal font preview: -// zsh-style prompt, brand line, addresses, the thread's 612-test summary, -// and a READY badge, so the scene exercises bold, dim, underline, the six -// accent colors, and a background cell. export const SHOWCASE_TERMINAL_BUFFER = [ `${SHOWCASE_TERMINAL_PROMPT}vpr dev`, "", @@ -230,10 +226,6 @@ export const SHOWCASE_THREADS = [ "Delayed work now carries a concise reason through the trace, so the wait is legible without changing scheduling behavior.", snoozeMinutes: 8 * 60, }, - // Finished work, settled by hand: the list keeps it as a receded tail so - // the active block above reads as everything still in flight. The active - // block stays small enough that the settled tail begins above the fold β€” - // a store screenshot has to show that history exists, not just imply it. { id: "handoff-haptics", projectId: "t3code", @@ -629,9 +621,7 @@ function seedDatabase( // replace the one that actually explains the failure. try { database.exec("ROLLBACK"); - } catch { - // Nothing to roll back. - } + } catch {} throw error; } finally { database.close(); diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 544d8056981e..10b32e760055 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -11,14 +11,12 @@ export type { ShowcaseScene }; export type ShowcaseAppearance = "light" | "dark"; -/** Every palette the mobile appearance settings can select. */ export const SHOWCASE_THEMES = MOBILE_THEME_IDS; export const DEFAULT_SHOWCASE_THEME = MOBILE_DEFAULT_THEME_ID; export type ShowcaseTheme = MobileThemeId; export interface ShowcaseStoreAssetSpec { readonly store: "apple" | "google-play"; - /** Device directory relative to ShowcaseConfig.outputDirectory. */ readonly directory: string; readonly width: number; readonly height: number; @@ -30,15 +28,10 @@ export interface ShowcaseStoreAssetSpec { export interface ShowcaseIosDevice { readonly id: string; readonly platform: "ios"; - /** Exact name from `xcrun simctl list devices available`. */ readonly simulator: string; - /** Device type used to create a disposable simulator when the named one is absent. */ readonly simulatorDeviceType?: string; - /** Appearance used when the CLI does not pass --appearance. */ readonly appearance: ShowcaseAppearance; - /** Palette used when the CLI does not pass --theme. */ readonly theme: ShowcaseTheme; - /** Orientation applied by the capture harness. Defaults to portrait. */ readonly orientation?: "portrait" | "landscape"; readonly scenes: ReadonlyArray; readonly storeAsset: ShowcaseStoreAssetSpec; @@ -47,16 +40,11 @@ export interface ShowcaseIosDevice { export interface ShowcaseAndroidDevice { readonly id: string; readonly platform: "android"; - /** Exact name from `emulator -list-avds`. */ readonly avd: string; - /** Appearance used when the CLI does not pass --appearance. */ readonly appearance: ShowcaseAppearance; - /** Palette used when the CLI does not pass --theme. */ readonly theme: ShowcaseTheme; - /** Native ABI used by the AVD, from its config.ini `abi.type`. */ readonly abi?: "arm64-v8a" | "x86_64" | "x86" | "armeabi-v7a"; readonly scenes: ReadonlyArray; - /** Optional capture viewport. Omit to use the AVD's native size and density. */ readonly viewport?: { readonly width: number; readonly height: number; @@ -88,12 +76,6 @@ export function resolveShowcaseAndroidAbi( ); } -/** - * The defaults cover every App Store Connect and Google Play upload slot used - * by the mobile app. Edit this matrix (or pass --device / --scene) without - * changing the runner. Every target declares and validates its exact upload - * dimensions so SDK or emulator changes cannot silently produce invalid files. - */ const config: ShowcaseConfig = { outputDirectory: "artifacts/app-store/screenshots", // Dedicated port so the harness cannot attach to a normal mobile dev server @@ -104,8 +86,6 @@ const config: ShowcaseConfig = { { id: "iphone-6.9", platform: "ios", - // A disposable device lands on the newest runtime, whose default lock - // screen wallpaper suits both appearances; a stock one may be older. simulator: "T3 Showcase iPhone 17 Pro Max", simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max", appearance: "dark", diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index a3f11e3a37c7..1b1446bb3699 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -112,8 +112,6 @@ it("parses repeatable and expanded theme filters", () => { ); }); -// The app normalizes an unknown id back to its default palette, so a typo here -// would otherwise produce screenshots labeled with a theme they do not show. it("rejects unsupported themes instead of capturing the default palette", () => { assert.throws(() => parseShowcaseCliArgs(["--theme", "sunset"]), /Unsupported theme 'sunset'/u); }); @@ -181,8 +179,6 @@ it("expands both appearances into independent upload-ready directories", () => { ); }); -// Every palette needs its own leaf folder: one directory holding several themes -// would mix upload slots and break the per-store screenshot count limits. it("expands themes into independent upload-ready directories per appearance", () => { const options = parseShowcaseCliArgs([ "--device", @@ -333,8 +329,6 @@ it("seeds a playful multi-environment project spectrum", () => { } const primaryThread = SHOWCASE_THREADS.find((thread) => thread.id === "remote-command-center"); assert.equal(primaryThread !== undefined && !("snoozeMinutes" in primaryThread), true); - // Every project contributes to both the active block and the settled tail, - // so each list scope screenshots with the same two-part structure. for (const project of SHOWCASE_PROJECTS) { const projectThreads = SHOWCASE_THREADS.filter((thread) => thread.projectId === project.id); assert.equal( diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 772ab3896350..8379604ba516 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -232,8 +232,6 @@ export function showcaseCaptureDirectory( outputDirectory: string, capture: Pick, ): string { - // Each palette owns a leaf folder so one upload slot never mixes themes and - // every folder keeps a store-legal screenshot count of its own. return NodePath.join( outputDirectory, capture.device.storeAsset.directory, @@ -812,11 +810,10 @@ async function iosSimulatorDataPath(udid: string): Promise { } // generativeexperiencesd posts a "Ready for Apple Intelligence" follow-up -// banner on an eligible device's first boot, and CoreFollowUp re-surfaces it -// on every boot until the user dismisses it. Stamping the readiness marker -// before boot makes the daemon skip the post, and dropping the CoreFollowUp -// store clears a banner that a previous boot already queued. Runs while the -// device is shut down so the files are read fresh on the next boot. +// banner on an eligible device's first boot, and CoreFollowUp re-surfaces it on +// every boot until the user dismisses it. Stamping the readiness marker before +// boot makes the daemon skip the post, and dropping the CoreFollowUp store +// clears a banner that a previous boot already queued. async function suppressIosSystemFollowUps(udid: string): Promise { const dataPath = await iosSimulatorDataPath(udid); const preferences = NodePath.join(dataPath, "Library/Preferences"); @@ -875,8 +872,7 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin // iPadOS 26 windowing ("Chamois") runs UIRequiresFullScreen apps in a fixed // portrait compatibility window, which defeats the in-app landscape rotation -// the capture build relies on. Switch the device to Full Screen Apps mode -// (Settings > Multitasking & Gestures) and restart SpringBoard to apply it. +// the capture build relies on. async function ensureIosFullScreenAppsMode(udid: string): Promise { const current = await commandOutput("xcrun", [ "simctl", @@ -1002,10 +998,6 @@ async function allowIosNotificationsUntilReady(udid: string, ready: Promise { await runAxe(udid, ["button", "lock"]); const deadline = Date.now() + 15_000; @@ -1055,7 +1047,6 @@ async function wakeIosLockScreen(udid: string): Promise { function pngIsBlack(bytes: Uint8Array): boolean { const { data } = PNG.sync.read(Buffer.from(bytes)); - // Sample a sparse grid; a sleeping display is uniformly black. for (let offset = 0; offset < data.length; offset += 4 * 997) { if ((data[offset] ?? 0) > 8 || (data[offset + 1] ?? 0) > 8 || (data[offset + 2] ?? 0) > 8) { return false; @@ -1065,13 +1056,11 @@ function pngIsBlack(bytes: Uint8Array): boolean { } async function unlockIosSimulator(udid: string): Promise { - // The first press wakes the display, the second dismisses the lock screen. await runAxe(udid, ["button", "home"]); await delay(2_000); await runAxe(udid, ["button", "home"]); } -/** Mirrors the app's staged hero row (showcaseAgentActivity.ts). */ function showcaseAgentAlert(): { readonly title: string; readonly body: string } { const thread = SHOWCASE_THREADS.find((candidate) => candidate.id === "pocket-command-center"); const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === thread?.projectId); @@ -1092,8 +1081,6 @@ async function captureIos( const startedByRunner = simulator.state !== "Booted"; registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner }); if (!startedByRunner) { - // Clear transient SpringBoard state (permission prompts, stale URL-open - // confirmations, keyboards) without erasing the developer's simulator. await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]); } await suppressIosSystemFollowUps(simulator.udid); @@ -1110,8 +1097,6 @@ async function captureIos( await runCommand("xcrun", ["simctl", "install", simulator.udid, appPath]); } - // The dev-client launch URL carries the dev menu preferences (SDK 58), so - // nothing is written into the app container ahead of launch. const metroUrl = `http://${metroHost}:${config.metroPort}?${DEV_CLIENT_LAUNCH_FLAGS}`; const scenePath = NodePath.join( await iosAppContainer(simulator.udid), @@ -1401,8 +1386,6 @@ async function captureAndroid( await runAdb(serial, ["install", "-r", apkPath]); } await runAdb(serial, ["shell", "pm", "clear", ANDROID_PACKAGE]); - // The agent-activity scene posts notifications; granting up front keeps the - // runtime prompt off every capture. await runAdb(serial, [ "shell", "pm", @@ -1663,9 +1646,6 @@ async function main(): Promise { if (import.meta.main) { void main().catch((error: unknown) => { - // Stack over message: the harness only fails in CI, where the line that - // threw is the whole diagnosis and there is nobody at a terminal to - // re-run it with more output. NodeProcess.stderr.write( `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, ); diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts index 3c890d29d251..88f7f809f388 100644 --- a/scripts/notify-discord-release.test.ts +++ b/scripts/notify-discord-release.test.ts @@ -31,7 +31,6 @@ const latestAnnouncement = { timestamp: "2026-05-01T01:41:00.000Z", } as const; const nightlyAnnouncement = { ...latestAnnouncement, target: "prerelease" } as const; -// Deliberately fake. All HTTP requests below use an injected client. const webhookUrl = new URL("https://discord.com/api/webhooks/123456/test-secret-token"); const intro = "A new T3 Code prerelease is available for nightly testers."; const notes = `## What's Changed\n* Fix remote reconnection by @contributor in https://github.com/pingdotgg/t3code/pull/10\n* Improve thread search in https://github.com/pingdotgg/t3code/pull/11\n\n**Full Changelog**: https://github.com/pingdotgg/t3code/compare/v1.2.2...v1.2.3`; @@ -443,8 +442,6 @@ it.effect("bounds total retry time while preserving delays within the deadline", }), ); -// Run the checked-in shell commands with gh/node stubs, then send their captured -// arguments through the real CLI parser and an injected HTTP client. function workflowRun(workflow: string, stepName: string) { const step = workflow.split(` - name: ${stepName}\n`)[1]?.split("\n - name:")[0]; assert.ok(step, `Missing workflow step: ${stepName}`); diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 689fe965a2c5..105898e0b9c5 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -130,14 +130,11 @@ function summarizePayload(payload: DiscordWebhookPayload) { } as const; } -// Keep release text inert, including occurrences of the intentionally pinged role. const suppressMentions = (text: string) => text.replace(/@(everyone|here)\b|<@[!&]?\d+>/g, (mention) => mention.replace("@", "@\u200b")); const escapeLinkLabel = (text: string) => text.replace(/[\\[\]]/g, "\\$&"); -// Compact GitHub's generated entries and comparison link, preserving custom -// notes. Contributor links are GitHub profiles, not pings. function formatReleaseNotes(notes: string) { return notes .replace( @@ -161,8 +158,6 @@ function formatReleaseNotes(notes: string) { ); } -// Preserve every character and prefer whole lines, then words. A single oversized -// line still needs splitting, but never between the halves of a Unicode character. function splitDescription(text: string, limit: number) { const chunks: string[] = []; while (text.length > limit) { @@ -285,7 +280,6 @@ export const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( const response = yield* HttpClientRequest.post(requestUrl).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), - // Bound requests and retry waits together without shortening Retry-After. Effect.timeout("1 minute"), Effect.mapError( (error) => diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index c3ae72484e2d..08cb3ae86ed9 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -96,13 +96,9 @@ export const resolveNightlyTargetVersion = (version: string) => { return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`); }; -/** Prerelease trains that share nightly's date-and-run versioning. */ export const PrereleaseChannel = Schema.Literals(["nightly", "preview"]); export type PrereleaseChannel = typeof PrereleaseChannel.Type; -// The preview label is deliberately loud: the releases page is the one place -// a preview build can be found, and its name is the first thing a visitor -// reads before the warning in the body. const CHANNEL_RELEASE_LABELS: Record = { nightly: "Nightly", preview: "Preview (maintainer test build, do not install)", diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index d6c17d060e42..58e6c3dcccf1 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -151,9 +151,6 @@ const parseStableTag = (tag: string): StableVersion | undefined => { if (!major || !minor || !patch) return undefined; const prereleaseIdentifiers = prerelease ? prerelease.split(".") : []; - // Nightly and preview tags also start with `v` and carry their channel as - // the prerelease identifier. They must not be considered stable candidates - // when resolving the previous stable tag. if (prereleaseIdentifiers[0] === "nightly" || prereleaseIdentifiers[0] === "preview") { return undefined; } @@ -178,8 +175,6 @@ const parseNightlyTag = ( tag: string, channel: "nightly" | "preview" = "nightly", ): NightlyVersion | undefined => { - // Accept both the current `v` format and the legacy `nightly-v` - // format so release note diffs keep working across the tag-format transition. const match = new RegExp( `^(?:nightly-)?v(\\d+)\\.(\\d+)\\.(\\d+)-${channel}\\.(\\d{8})\\.(\\d+)$`, ).exec(tag); diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index c56801c9c463..cee8b567aa97 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -1,11 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off - runs before `vp i`, so only Node built-ins exist. -/** - * Worktree setup, run by the t3.json "Setup Worktree" action as - * `node scripts/setup-worktree.ts`. Plain Node keeps one command working in - * every shell T3 Code spawns (zsh, bash, fish, PowerShell): it installs - * dependencies, links the main checkout's gitignored env files into this - * worktree, then warms the web dependency cache. - */ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; diff --git a/scripts/sign-macos.ts b/scripts/sign-macos.ts index 3686db6aaf85..6d00d13f13b9 100644 --- a/scripts/sign-macos.ts +++ b/scripts/sign-macos.ts @@ -1,6 +1,5 @@ import { sign as signApplication, type SignOptions } from "@electron/osx-sign"; -/** Sign files with matching options together instead of spawning codesign for each file. */ export default async function sign(options: SignOptions): Promise { await signApplication({ ...options, batchCodesignCalls: true }); } diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index 9780680cd9e1..dd545b967175 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -1,11 +1,4 @@ #!/usr/bin/env node -/** - * Unpacks a CLI archive into a scratch directory and runs the executable the - * way an installer would: no repo, no node_modules, no Node on PATH. Catches - * the failures that only show inside the single-executable, such as an - * external package reached through `import` or a native addon the hardened - * runtime refuses to load. - */ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Duration from "effect/Duration"; @@ -125,10 +118,6 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { }); } - // Starting the server is what actually opens sqlite, loads the terminal - // and search stacks (node-pty, fff, msgpackr-extract), and serves the - // client, so probe a real `serve` in a scratch home rather than a - // command that only reads package metadata. const net = yield* NetService.NetService; const port = yield* net.findAvailablePort(47700); const home = path.join(scratch, "home"); diff --git a/vite.config.ts b/vite.config.ts index 2839535c3b6f..928540a0e3ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,6 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import * as NodeURL from "node:url"; -/** Import restrictions every file keeps, including the one module exempt from the glyph rule. */ const RESTRICTED_IMPORT_PATHS = [ { name: "@t3tools/client-runtime", @@ -16,11 +15,6 @@ const RESTRICTED_IMPORT_PATHS = [ }, ]; -/** - * The cva functions behind components/ui exports. They style a foreign element to look - * like a Button or Toggle, which bypasses the component's variants; render the component - * instead (`render={