From 47b6c756f0b5fb0c8110ca60250eb34c43a21741 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Thu, 10 Sep 2026 23:26:14 -0400
Subject: [PATCH 01/17] fix: prompt unexpectedly signed-out Cloud users once
per version (#19966)
* fix: prompt unexpectedly signed-out Cloud users once per version
* fix: align sign-in card English catalog with runtime defaults
* fix: stack notification cards by their rendered height
* fix: wait for fresh auth before showing signout card
* fix: require verified auth before signout recovery
* fix: retry transient auth readiness failures
---
.../profile-cloud-auth-status.test.ts | 106 +++++++
.../runtime/rpc/methods/client-ui-schemas.ts | 1 +
.../runtime/rpc/methods/client-ui.test.ts | 2 +
.../src/app-shell/AppRootSurfaces.tsx | 27 +-
.../src/components/NotificationCardStack.tsx | 9 +
src/renderer/src/components/StarNagCard.tsx | 17 +-
.../src/components/UnexpectedSignoutCard.tsx | 269 ++++++++++++++++++
.../components/UpdateCard.error-card.test.tsx | 7 +-
src/renderer/src/components/UpdateCard.tsx | 5 +-
.../unexpected-signout-card.test.tsx | 152 ++++++++++
.../unexpected-signout-visibility.test.ts | 150 ++++++++++
.../unexpected-signout-visibility.ts | 22 ++
src/renderer/src/i18n/locales/en.json | 15 +
.../store/slices/ui-notice-dismissals.test.ts | 78 +++++
.../ui/ui-slice-contract-preferences.ts | 4 +
.../slices/ui/ui-slice-hydration-actions.ts | 2 +
.../ui/ui-slice-hydration-sanitizers.ts | 13 +
.../slices/ui/ui-slice-update-actions.ts | 12 +
src/shared/constants.ts | 1 +
src/shared/persisted-ui-state-types.ts | 2 +
20 files changed, 866 insertions(+), 28 deletions(-)
create mode 100644 src/main/orca-profiles/profile-cloud-auth-status.test.ts
create mode 100644 src/renderer/src/components/NotificationCardStack.tsx
create mode 100644 src/renderer/src/components/UnexpectedSignoutCard.tsx
create mode 100644 src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx
create mode 100644 src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts
create mode 100644 src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts
diff --git a/src/main/orca-profiles/profile-cloud-auth-status.test.ts b/src/main/orca-profiles/profile-cloud-auth-status.test.ts
new file mode 100644
index 00000000000..7a28783e9a9
--- /dev/null
+++ b/src/main/orca-profiles/profile-cloud-auth-status.test.ts
@@ -0,0 +1,106 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ActiveOrcaProfileState } from './profile-index-store'
+import type { OrcaCloudSessionReadResult } from './profile-cloud-session-store'
+import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status'
+
+const { readSession, configuration } = vi.hoisted(() => ({
+ readSession: vi.fn<() => OrcaCloudSessionReadResult>(),
+ configuration: { configured: true }
+}))
+
+vi.mock('./profile-cloud-session-store', () => ({ readOrcaCloudSession: readSession }))
+vi.mock('./profile-cloud-auth-config', () => ({
+ getOrcaCloudAuthConfig: () => configuration,
+ isOrcaCloudDevAuthEnabled: () => false
+}))
+
+function activeProfile(linked: boolean): ActiveOrcaProfileState {
+ const profile: ActiveOrcaProfileState['profile'] = {
+ id: 'profile-1',
+ name: 'Personal',
+ avatar: { kind: 'initials', initials: 'P', color: 'neutral' },
+ kind: linked ? 'cloud-linked' : 'local',
+ createdAt: 0,
+ updatedAt: 0,
+ lastOpenedAt: 0,
+ ...(linked
+ ? {
+ cloud: {
+ cloudProfileId: 'cloud-1',
+ userId: 'user-1',
+ email: 'a@example.com',
+ linkedAt: 0
+ }
+ }
+ : {})
+ }
+ return {
+ profile,
+ index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] },
+ dataFile: '',
+ profileDirectory: ''
+ }
+}
+
+const absentSessions: OrcaCloudSessionReadResult[] = [
+ { status: 'missing', persistence: 'none' },
+ { status: 'decrypt-failed', persistence: 'none', error: 'Cannot decrypt' },
+ { status: 'unreadable', persistence: 'none', error: 'Permission denied' }
+]
+
+describe('unexpected sign-out auth evidence', () => {
+ beforeEach(() => {
+ readSession.mockReset()
+ configuration.configured = true
+ })
+
+ it.each(absentSessions)('requires a preserved cloud link for $status credentials', (session) => {
+ readSession.mockReturnValue(session)
+ const linked = activeProfile(true)
+ expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({
+ state: 'reconnect-required',
+ cloud: linked.profile.cloud,
+ persistence: 'none',
+ credentialError: 'error' in session ? session.error : undefined
+ })
+ readSession.mockClear()
+ const signedOut = getOrcaProfileAuthStatusFromProfile(activeProfile(false), '')
+ expect(signedOut.state).toBe('local')
+ expect(signedOut.cloud).toBeUndefined()
+ expect(readSession).not.toHaveBeenCalled()
+ })
+
+ it.each(absentSessions)(
+ 'keeps unconfigured linked profiles out of reconnect for $status',
+ (session) => {
+ configuration.configured = false
+ readSession.mockReturnValue(session)
+ expect(getOrcaProfileAuthStatusFromProfile(activeProfile(true), '').state).toBe(
+ 'unconfigured'
+ )
+ expect(getOrcaProfileAuthStatusFromProfile(activeProfile(false), '').state).toBe(
+ 'unconfigured'
+ )
+ }
+ )
+
+ it('treats a live memory-only session as connected, then reconnects after its loss', () => {
+ readSession.mockReturnValue({
+ status: 'found',
+ persistence: 'memory-only',
+ session: {
+ accessToken: 'access',
+ refreshToken: 'refresh',
+ expiresAt: Date.now() + 60_000,
+ capabilities: { flags: {}, refreshedAt: 0 }
+ }
+ })
+ const linked = activeProfile(true)
+ expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({
+ state: 'connected',
+ persistence: 'memory-only'
+ })
+ readSession.mockReturnValue({ status: 'missing', persistence: 'none' })
+ expect(getOrcaProfileAuthStatusFromProfile(linked, '').state).toBe('reconnect-required')
+ })
+})
diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts
index 943d0081fdf..32e62e105d7 100644
--- a/src/main/runtime/rpc/methods/client-ui-schemas.ts
+++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts
@@ -171,6 +171,7 @@ const UiUpdateFields = z
usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
statusBarUsageMode: z.enum(['verbose', 'compact']).optional(),
dismissedUpdateVersion: NullableString.optional(),
+ dismissedUnexpectedSignoutVersion: NullableString.optional(),
lastUpdateCheckAt: z.number().finite().nullable().optional(),
pendingUpdateNudgeId: NullableString.optional(),
dismissedUpdateNudgeId: NullableString.optional(),
diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts
index 39048611155..3a26b1c615d 100644
--- a/src/main/runtime/rpc/methods/client-ui.test.ts
+++ b/src/main/runtime/rpc/methods/client-ui.test.ts
@@ -607,6 +607,8 @@ describe('client UI RPC methods', () => {
],
['taskResumeState.jiraPreset', { taskResumeState: { jiraPreset: 'assigned' } }],
['taskResumeState.jiraQuery', { taskResumeState: { jiraQuery: 'ENG' } }],
+ ['dismissedUnexpectedSignoutVersion', { dismissedUnexpectedSignoutVersion: '1.2.3' }],
+ ['dismissedUnexpectedSignoutVersion null', { dismissedUnexpectedSignoutVersion: null }],
['activeView', { activeView: 'tasks' }],
['showDotfilesByWorktree', { showDotfilesByWorktree: { 'repo::/worktree': true } }],
['setupGuideSidebarDismissed', { setupGuideSidebarDismissed: true }],
diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx
index 202c99df4d2..31de9b1ce8d 100644
--- a/src/renderer/src/app-shell/AppRootSurfaces.tsx
+++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx
@@ -1,3 +1,4 @@
+import { NotificationCardStack } from '../components/NotificationCardStack'
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
@@ -58,6 +59,11 @@ const SshPassphraseDialog = lazy(() =>
const UpdateCard = lazy(() =>
import('../components/UpdateCard').then((module) => ({ default: module.UpdateCard }))
)
+const UnexpectedSignoutCard = lazy(() =>
+ import('../components/UnexpectedSignoutCard').then((module) => ({
+ default: module.UnexpectedSignoutCard
+ }))
+)
const RemoteServerUpdateDialog = lazy(
() => import('../components/settings/RemoteServerUpdateDialog')
)
@@ -273,16 +279,23 @@ export function AppRootSurfaces(props: {
) : null}
- {shouldMountUpdateCard ? (
+
+ {shouldMountUpdateCard ? (
+
+
+
+
+
+ ) : null}
-
-
+
+
- ) : null}
-
-
-
+
+
+
+
diff --git a/src/renderer/src/components/NotificationCardStack.tsx b/src/renderer/src/components/NotificationCardStack.tsx
new file mode 100644
index 00000000000..9d3d5cddf3c
--- /dev/null
+++ b/src/renderer/src/components/NotificationCardStack.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from 'react'
+
+export function NotificationCardStack({ children }: { children: ReactNode }): React.JSX.Element {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx
index f0e184143fc..0bf3312463e 100644
--- a/src/renderer/src/components/StarNagCard.tsx
+++ b/src/renderer/src/components/StarNagCard.tsx
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react'
import { ExternalLink, Star, X } from 'lucide-react'
import { Card } from './ui/card'
import { Button } from './ui/button'
-import { useAppStore } from '../store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
@@ -25,12 +24,6 @@ export function StarNagCard(): React.JSX.Element | null {
const [busy, setBusy] = useState(false)
const [mode, setMode] = useState('gh')
const mountedRef = useMountedRef()
- // Why: UpdateCard lives at the same bottom-right slot. When it is visible
- // (any non-idle / non-not-available state), stack the star-nag card above
- // it instead of overlapping — we must not cover a pending update prompt
- // because that's a higher-priority action.
- const updateStatus = useAppStore((s) => s.updateStatus)
- const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available'
useEffect(() => {
const unsubscribeShow = window.api.starNag.onShow((payload) => {
@@ -147,15 +140,7 @@ export function StarNagCard(): React.JSX.Element | null {
}
return (
-
+
diff --git a/src/renderer/src/components/UnexpectedSignoutCard.tsx b/src/renderer/src/components/UnexpectedSignoutCard.tsx
new file mode 100644
index 00000000000..ee0e8949ec3
--- /dev/null
+++ b/src/renderer/src/components/UnexpectedSignoutCard.tsx
@@ -0,0 +1,269 @@
+import { useEffect, useRef, useState } from 'react'
+import { BookOpen, ChevronDown, CircleUserRound, Files, Smartphone, X } from 'lucide-react'
+import { useAppStore } from '../store'
+import { translate } from '@/i18n/i18n'
+import { cn } from '@/lib/utils'
+import { Button } from './ui/button'
+import { Card } from './ui/card'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'
+import { shouldShowUnexpectedSignoutCard } from './unexpected-signout/unexpected-signout-visibility'
+
+function readPreviewFlag(): boolean {
+ if (!import.meta.env.DEV) {
+ return false
+ }
+ try {
+ if (new URLSearchParams(window.location.search).get('showSignoutCard') === '1') {
+ return true
+ }
+ return window.localStorage.getItem('orca-debug-show-signout-card') === '1'
+ } catch {
+ return false
+ }
+}
+
+function FeatureRow({
+ icon: Icon,
+ title,
+ description
+}: {
+ icon: typeof Files
+ title: string
+ description: string
+}): React.JSX.Element {
+ return (
+
+
+
+
{title}
+
{description}
+
+
+ )
+}
+
+export function UnexpectedSignoutCard(): React.JSX.Element | null {
+ const authStatus = useAppStore((s) => s.orcaProfileAuthStatus)
+ const persistedUIReady = useAppStore((s) => s.persistedUIReady)
+ const persistedDismissedVersion = useAppStore((s) => s.dismissedUnexpectedSignoutVersion)
+ const dismissedVersions = useAppStore((s) => s.unexpectedSignoutDismissedVersions)
+ const dismissForVersion = useAppStore((s) => s.dismissUnexpectedSignoutCard)
+ const connecting = useAppStore((s) => s.orcaProfileConnecting)
+ const connect = useAppStore((s) => s.connectCurrentOrcaProfile)
+ const [appVersion, setAppVersion] = useState
(null)
+ const [authRefreshReady, setAuthRefreshReady] = useState(false)
+ const [expanded, setExpanded] = useState(false)
+ const [preview] = useState(readPreviewFlag)
+ const [previewDismissed, setPreviewDismissed] = useState(false)
+ const reconnectingProfile = useRef(null)
+
+ useEffect(() => {
+ let cancelled = false
+ let attempts = 0
+ const refresh = (): void => {
+ attempts += 1
+ void useAppStore
+ .getState()
+ .fetchOrcaProfileAuthStatus()
+ .then((status) => {
+ if (cancelled) {
+ return
+ }
+ if (status != null) {
+ setAuthRefreshReady(true)
+ } else if (attempts < 3) {
+ window.setTimeout(refresh, 500)
+ }
+ })
+ }
+ refresh()
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ useEffect(() => {
+ let cancelled = false
+ void window.api.updater
+ .getVersion()
+ .then((version) => {
+ if (!cancelled) {
+ setAppVersion(version)
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setAppVersion(null)
+ }
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const dismissedVersion =
+ appVersion && dismissedVersions.includes(appVersion) ? appVersion : persistedDismissedVersion
+ const eligible = shouldShowUnexpectedSignoutCard({
+ authStatus,
+ persistedUIReady,
+ appVersion,
+ dismissedVersion
+ })
+
+ const visible = preview ? persistedUIReady && !previewDismissed : authRefreshReady && eligible
+
+ // Observe recovery independently of visibility and asynchronous version/hydration reads.
+ useEffect(() => {
+ if (preview || !authRefreshReady) {
+ return
+ }
+ if (authStatus?.state === 'reconnect-required' && authStatus.configured && authStatus.cloud) {
+ reconnectingProfile.current = authStatus.activeProfileId
+ } else if (authStatus?.state === 'connected') {
+ if (
+ reconnectingProfile.current === authStatus.activeProfileId &&
+ persistedUIReady &&
+ appVersion
+ ) {
+ reconnectingProfile.current = null
+ if (dismissedVersion !== appVersion) {
+ dismissForVersion(appVersion)
+ }
+ }
+ } else {
+ reconnectingProfile.current = null
+ }
+ }, [
+ preview,
+ authRefreshReady,
+ authStatus,
+ persistedUIReady,
+ appVersion,
+ dismissedVersion,
+ dismissForVersion
+ ])
+
+ if (!visible) {
+ return null
+ }
+
+ const email = authStatus?.cloud?.email?.trim() || null
+ const canConnect = authStatus?.configured === true
+
+ const handleDismiss = (): void => {
+ if (preview) {
+ setPreviewDismissed(true)
+ } else if (appVersion) {
+ dismissForVersion(appVersion)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {translate(
+ 'auto.components.UnexpectedSignoutCard.9f2c1a4b7d',
+ "You've been signed out"
+ )}
+
+
+
+
+
+
+ {email
+ ? translate(
+ 'auto.components.UnexpectedSignoutCard.7b4d9e1f2a',
+ 'Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.',
+ { value0: email }
+ )
+ : translate(
+ 'auto.components.UnexpectedSignoutCard.5a1c8d3e6f',
+ 'Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.'
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx
index 1e6da6879f7..186a285b26d 100644
--- a/src/renderer/src/components/UpdateCard.error-card.test.tsx
+++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../../shared/update-status-types'
import { useAppStore } from '../store'
import { UpdateCard } from './UpdateCard'
+import { NotificationCardStack } from './NotificationCardStack'
const openUrl = vi.fn()
const download = vi.fn()
@@ -31,7 +32,11 @@ function renderWithInitialStatus(updateStatus: UpdateStatus): RenderResult {
updateCardCollapsed: false,
updateReassuranceSeen: true
})
- return render()
+ return render(
+
+
+
+ )
}
function renderAfterAvailableStatus(): RenderResult {
diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx
index 4ccf95ff252..e2023ae21e1 100644
--- a/src/renderer/src/components/UpdateCard.tsx
+++ b/src/renderer/src/components/UpdateCard.tsx
@@ -239,10 +239,7 @@ export function UpdateCard(): React.JSX.Element | null {
!reassuranceSeen &&
((status.state === 'available' && !status.externallyManaged) || status.state === 'downloading')
return (
-
+
{showReassurance && (
diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx b/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx
new file mode 100644
index 00000000000..f220d803a01
--- /dev/null
+++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx
@@ -0,0 +1,152 @@
+// @vitest-environment happy-dom
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAppStore } from '../../store'
+import { getDefaultUIState } from '../../../../shared/constants'
+import { UnexpectedSignoutCard } from '../UnexpectedSignoutCard'
+import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
+
+const status: OrcaProfileAuthStatus = {
+ activeProfileId: 'profile-1',
+ configured: true,
+ state: 'reconnect-required',
+ persistence: 'none',
+ cloud: {
+ cloudProfileId: 'cloud-1',
+ userId: 'user-1',
+ email: 'user@example.com',
+ displayName: 'User',
+ linkedAt: 0
+ }
+}
+const persist = vi.fn().mockResolvedValue(undefined)
+
+beforeEach(() => {
+ vi.stubEnv('DEV', false)
+ persist.mockClear()
+ window.localStorage.clear()
+ window.history.replaceState({}, '', '/')
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: {
+ ui: { set: persist },
+ updater: { getVersion: vi.fn().mockResolvedValue('1.4.197') }
+ }
+ })
+ useAppStore.setState(useAppStore.getInitialState(), true)
+ useAppStore.setState({
+ orcaProfileAuthStatus: status,
+ persistedUIReady: true,
+ fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(status)
+ })
+})
+afterEach(() => {
+ cleanup()
+ vi.unstubAllEnvs()
+})
+
+async function showCard(): Promise
{
+ render()
+ await screen.findByRole('complementary')
+}
+
+describe('unexpected signout lifecycle', () => {
+ it('stamps successful sign-in and never re-arms in the same version', async () => {
+ await showCard()
+ act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }))
+ await waitFor(() =>
+ expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' })
+ )
+ act(() => useAppStore.setState({ orcaProfileAuthStatus: status }))
+ expect(screen.queryByRole('complementary')).toBeNull()
+ cleanup()
+ render()
+ await act(async () => {})
+ expect(screen.queryByRole('complementary')).toBeNull()
+ expect(persist).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not treat a cached connected status as a successful re-sign-in', async () => {
+ useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } })
+ render()
+ await act(async () => {})
+ act(() => useAppStore.setState({ orcaProfileAuthStatus: status }))
+ expect(screen.queryByRole('complementary')).not.toBeNull()
+ expect(persist).not.toHaveBeenCalled()
+ })
+
+ it('waits for hydration before stamping an already recovered session', async () => {
+ useAppStore.setState({ persistedUIReady: false })
+ render()
+ await act(async () => {})
+ act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }))
+ expect(persist).not.toHaveBeenCalled()
+ act(() => useAppStore.setState({ persistedUIReady: true }))
+ await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
+ act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }))
+ expect(persist).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps a failed sign-in available without stamping dismissal', async () => {
+ useAppStore.setState({ connectCurrentOrcaProfile: vi.fn().mockResolvedValue(undefined) })
+ await showCard()
+ fireEvent.click(screen.getByRole('button', { name: 'Sign in to Orca' }))
+ await act(async () => {})
+ expect(screen.queryByRole('complementary')).not.toBeNull()
+ expect(persist).not.toHaveBeenCalled()
+ })
+
+ it('retains a reopened dismissal through stale UI sync and a renderer remount', async () => {
+ useAppStore.getState().hydratePersistedUI(
+ {
+ ...getDefaultUIState(),
+ dismissedUnexpectedSignoutVersion: '1.4.197'
+ },
+ 'startup'
+ )
+ render()
+ await act(async () => {})
+ act(() => useAppStore.getState().hydratePersistedUI(getDefaultUIState()))
+ expect(screen.queryByRole('complementary')).toBeNull()
+ cleanup()
+ render()
+ await act(async () => {})
+ expect(screen.queryByRole('complementary')).toBeNull()
+ expect(persist).not.toHaveBeenCalledWith(
+ expect.objectContaining({ dismissedUnexpectedSignoutVersion: expect.anything() })
+ )
+ })
+
+ it('persists X dismissal and stays hidden after remount', async () => {
+ await showCard()
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
+ expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' })
+ cleanup()
+ render()
+ await act(async () => {})
+ expect(screen.queryByRole('complementary')).toBeNull()
+ })
+
+ it.each(['storage', 'query'])('ignores the %s preview flag in production', async (source) => {
+ if (source === 'storage') {
+ window.localStorage.setItem('orca-debug-show-signout-card', '1')
+ } else {
+ window.history.replaceState({}, '', '/?showSignoutCard=1')
+ }
+ useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } })
+ render()
+ await act(async () => {})
+ expect(screen.queryByRole('complementary')).toBeNull()
+ expect(persist).not.toHaveBeenCalled()
+ })
+
+ it('dismisses a development preview without writing real dismissal state', async () => {
+ vi.stubEnv('DEV', true)
+ window.localStorage.setItem('orca-debug-show-signout-card', '1')
+ useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } })
+ await showCard()
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
+ expect(screen.queryByRole('complementary')).toBeNull()
+ expect(persist).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts
new file mode 100644
index 00000000000..c1b8459204f
--- /dev/null
+++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, it } from 'vitest'
+import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
+import { shouldShowUnexpectedSignoutCard } from './unexpected-signout-visibility'
+
+function reconnectRequired(): OrcaProfileAuthStatus {
+ return {
+ activeProfileId: 'profile-1',
+ configured: true,
+ state: 'reconnect-required',
+ persistence: 'none',
+ cloud: {
+ cloudProfileId: 'cloud-1',
+ userId: 'user-1',
+ email: 'user@example.com',
+ displayName: 'User',
+ linkedAt: 0
+ }
+ }
+}
+
+describe('shouldShowUnexpectedSignoutCard', () => {
+ it.each([
+ { name: 'unknown auth', auth: null, visible: false },
+ {
+ name: 'missing cloud link',
+ auth: { ...reconnectRequired(), cloud: undefined },
+ visible: false
+ },
+ {
+ name: 'unconfigured linked profile',
+ auth: { ...reconnectRequired(), configured: false, state: 'unconfigured' },
+ visible: false
+ },
+ {
+ name: 'unconfigured reconnect status',
+ auth: { ...reconnectRequired(), configured: false },
+ visible: false
+ },
+ {
+ name: 'local with stale cloud metadata',
+ auth: { ...reconnectRequired(), state: 'local' },
+ visible: false
+ },
+ {
+ name: 'live memory-only session',
+ auth: { ...reconnectRequired(), state: 'connected', persistence: 'memory-only' },
+ visible: false
+ },
+ {
+ name: 'decrypt failure with retained link',
+ auth: { ...reconnectRequired(), credentialError: 'Cannot decrypt' },
+ visible: true
+ },
+ {
+ name: 'unreadable session with retained link',
+ auth: { ...reconnectRequired(), credentialError: 'Permission denied' },
+ visible: true
+ }
+ ] satisfies { name: string; auth: OrcaProfileAuthStatus | null; visible: boolean }[])(
+ '$name',
+ ({ auth, visible }) => {
+ expect(
+ shouldShowUnexpectedSignoutCard({
+ authStatus: auth,
+ persistedUIReady: true,
+ appVersion: '1.4.197',
+ dismissedVersion: null
+ })
+ ).toBe(visible)
+ }
+ )
+
+ it('shows when linked but the session is gone', () => {
+ expect(
+ shouldShowUnexpectedSignoutCard({
+ authStatus: reconnectRequired(),
+ persistedUIReady: true,
+ appVersion: '1.4.197',
+ dismissedVersion: null
+ })
+ ).toBe(true)
+ })
+
+ it('hides after an explicit sign-out (link removed)', () => {
+ expect(
+ shouldShowUnexpectedSignoutCard({
+ authStatus: {
+ activeProfileId: 'profile-1',
+ configured: true,
+ state: 'local',
+ persistence: 'none'
+ },
+ persistedUIReady: true,
+ appVersion: '1.4.197',
+ dismissedVersion: null
+ })
+ ).toBe(false)
+ })
+
+ it('hides when connected', () => {
+ const status = reconnectRequired()
+ status.state = 'connected'
+ expect(
+ shouldShowUnexpectedSignoutCard({
+ authStatus: status,
+ persistedUIReady: true,
+ appVersion: '1.4.197',
+ dismissedVersion: null
+ })
+ ).toBe(false)
+ })
+
+ it('shows only once per app version', () => {
+ expect(
+ shouldShowUnexpectedSignoutCard({
+ authStatus: reconnectRequired(),
+ persistedUIReady: true,
+ appVersion: '1.4.197',
+ dismissedVersion: '1.4.197'
+ })
+ ).toBe(false)
+ expect(
+ shouldShowUnexpectedSignoutCard({
+ authStatus: reconnectRequired(),
+ persistedUIReady: true,
+ appVersion: '1.4.198',
+ dismissedVersion: '1.4.197'
+ })
+ ).toBe(true)
+ })
+
+ it('waits for hydration and version', () => {
+ expect(
+ shouldShowUnexpectedSignoutCard({
+ authStatus: reconnectRequired(),
+ persistedUIReady: false,
+ appVersion: '1.4.197',
+ dismissedVersion: null
+ })
+ ).toBe(false)
+ expect(
+ shouldShowUnexpectedSignoutCard({
+ authStatus: reconnectRequired(),
+ persistedUIReady: true,
+ appVersion: null,
+ dismissedVersion: null
+ })
+ ).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts
new file mode 100644
index 00000000000..a9a47b2d98b
--- /dev/null
+++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts
@@ -0,0 +1,22 @@
+import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
+
+export type UnexpectedSignoutGate = {
+ authStatus: OrcaProfileAuthStatus | null
+ persistedUIReady: boolean
+ appVersion: string | null
+ dismissedVersion: string | null
+}
+
+export function shouldShowUnexpectedSignoutCard(gate: UnexpectedSignoutGate): boolean {
+ if (!gate.persistedUIReady || gate.appVersion === null) {
+ return false
+ }
+ if (gate.dismissedVersion === gate.appVersion) {
+ return false
+ }
+ return (
+ gate.authStatus?.configured === true &&
+ gate.authStatus.state === 'reconnect-required' &&
+ gate.authStatus.cloud != null
+ )
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index fe4e115ae77..607359d1b71 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -16883,6 +16883,21 @@
"HostedReviewUnlinkMenuItem": {
"label": "Unlink {{value0}} from workspace",
"description": "Orca will hide {{value0}} {{value1}} details for this workspace. The {{value0}} and branch on {{value2}} won’t be changed."
+ },
+ "UnexpectedSignoutCard": {
+ "9f2c1a4b7d": "You've been signed out",
+ "3e8f5c2a91": "Dismiss",
+ "7b4d9e1f2a": "Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.",
+ "5a1c8d3e6f": "Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.",
+ "1f6b2c9d4e": "What you get back",
+ "8d2e4f7a1b": "Artifact sharing",
+ "2c9a5b6e8d": "Publish HTML and Markdown files and manage every shared link from Orca.",
+ "6e3f1a9c5b": "Orca Relay",
+ "4b7d2e8f1a": "Connect Orca Mobile to this desktop across cellular or any Wi-Fi.",
+ "9a4c6b2d7e": "Skill sharing",
+ "3d8e5f1b9c": "Share skills behind an unlisted link and install them on any machine you use.",
+ "7e1a9c4d2f": "Signing in…",
+ "c5b3e8a17d": "Sign in to Orca"
}
},
"i18n": {
diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts
index f17c77c78e3..180f96e427a 100644
--- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts
+++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts
@@ -280,3 +280,81 @@ describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => {
expect(setUI).toHaveBeenCalledWith({ osc52ClipboardDefaultOnNoticePending: false })
})
})
+
+describe('unexpected sign-out dismissal persistence', () => {
+ it('persists a dismissal once even when effects repeat', () => {
+ const setUI = vi.fn(() => Promise.resolve())
+ vi.stubGlobal('window', { api: { ui: { set: setUI } } })
+ const store = createUIStore()
+
+ store.getState().dismissUnexpectedSignoutCard('1.2.3')
+ store.getState().dismissUnexpectedSignoutCard('1.2.3')
+
+ expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3')
+ expect(setUI).toHaveBeenCalledExactlyOnceWith({ dismissedUnexpectedSignoutVersion: '1.2.3' })
+ })
+
+ it.each([undefined, null, '1.2.2'])(
+ 'does not re-arm a local dismissal from stale hydration (%s)',
+ (dismissedUnexpectedSignoutVersion) => {
+ vi.stubGlobal('window', { api: { ui: { set: vi.fn(() => Promise.resolve()) } } })
+ const store = createUIStore()
+ store.getState().dismissUnexpectedSignoutCard('1.2.3')
+
+ store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion }))
+
+ expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3')
+ }
+ )
+
+ it('defaults legacy profiles and restores dismissal on reopen', () => {
+ const store = createUIStore()
+ expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull()
+ store
+ .getState()
+ .hydratePersistedUI(
+ makePersistedUI({ dismissedUnexpectedSignoutVersion: undefined }),
+ 'startup'
+ )
+ expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull()
+ const reopened = createUIStore()
+ reopened
+ .getState()
+ .hydratePersistedUI(
+ makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }),
+ 'startup'
+ )
+ expect(reopened.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3')
+ })
+
+ it('accepts another window dismissal after hydrating an older version', () => {
+ const store = createUIStore()
+ store
+ .getState()
+ .hydratePersistedUI(
+ makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.2' }),
+ 'startup'
+ )
+ store
+ .getState()
+ .hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }))
+ expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3')
+ })
+})
+
+describe('unexpected sign-out hydrated dismissal history', () => {
+ it.each([undefined, null, '1.2.2', '1.2.4'])(
+ 'retains an observed dismissal after a different version sync (%s)',
+ (dismissedUnexpectedSignoutVersion) => {
+ const store = createUIStore()
+ store
+ .getState()
+ .hydratePersistedUI(
+ makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }),
+ 'startup'
+ )
+ store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion }))
+ expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3')
+ }
+ )
+})
diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts
index dff5f50c7e3..d913f998d2c 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts
@@ -175,6 +175,10 @@ export type UISlicePersistence = {
dismissedUpdateVersion: string | null
dismissUpdate: (versionOverride?: string) => void
clearDismissedUpdateVersion: () => void
+ /** App version that dismissed the unexpected-sign-out card; null = never dismissed. */
+ dismissedUnexpectedSignoutVersion: string | null
+ unexpectedSignoutDismissedVersions: string[]
+ dismissUnexpectedSignoutCard: (version: string) => void
/** Dev-only channel override; null follows the running build's own channel. */
releaseChannelOverride: ReleaseChannel | null
setReleaseChannelOverride: (channel: ReleaseChannel | null) => void
diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts
index a5daf8a500d..6ca8d702bd3 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts
@@ -52,6 +52,7 @@ import {
} from '../persisted-ui-write-baseline'
import {
hydrateTrustedOrcaHooks,
+ hydrateUnexpectedSignoutDismissal,
normalizeHydratedVisibleWorkspaceHostIds,
preserveStringArrayIdentity,
sanitizeHydratedActiveView,
@@ -226,6 +227,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par
return DEFAULT_PET_ID
})(),
dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null,
+ ...hydrateUnexpectedSignoutDismissal(s, ui.dismissedUnexpectedSignoutVersion),
// Why: a persisted value from a build that knew a different channel set
// would otherwise survive as-is; activeChannel only falls back on null,
// so an unknown string reaches listBuilds and the segmented control.
diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts
index bfed25a75cc..9737a575f06 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts
@@ -238,3 +238,16 @@ export function migrateStatusBarItems(items: readonly string[] | undefined): Sta
}
return out as StatusBarItem[]
}
+
+export function hydrateUnexpectedSignoutDismissal(
+ state: Pick,
+ version: string | null | undefined
+): Pick {
+ const observed = state.unexpectedSignoutDismissedVersions
+ return {
+ dismissedUnexpectedSignoutVersion: version ?? null,
+ // A later sync must never undo any dismissal observed in this session.
+ unexpectedSignoutDismissedVersions:
+ typeof version === 'string' && !observed.includes(version) ? [...observed, version] : observed
+ }
+}
diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts
index e4beadf4c6c..942ff9610a5 100644
--- a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts
+++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts
@@ -43,6 +43,18 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial
updateChangelog: null,
updateUserInitiatedCycle: false,
dismissedUpdateVersion: null,
+ dismissedUnexpectedSignoutVersion: null,
+ unexpectedSignoutDismissedVersions: [],
+ dismissUnexpectedSignoutCard: (version) => {
+ if (get().unexpectedSignoutDismissedVersions.includes(version)) {
+ return
+ }
+ set({
+ dismissedUnexpectedSignoutVersion: version,
+ unexpectedSignoutDismissedVersions: [...get().unexpectedSignoutDismissedVersions, version]
+ })
+ void window.api.ui.set({ dismissedUnexpectedSignoutVersion: version }).catch(console.error)
+ },
clearDismissedUpdateVersion: () => {
set({ dismissedUpdateVersion: null })
},
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 7a06e11dba3..6840d92adc9 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -296,6 +296,7 @@ export function getDefaultUIState(): PersistedUIState {
usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY,
statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE,
dismissedUpdateVersion: null,
+ dismissedUnexpectedSignoutVersion: null,
lastUpdateCheckAt: null,
trustedOrcaHooks: {},
setupScriptPromptDismissedRepoIds: [],
diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts
index 943813d7255..2a3eb411a0e 100644
--- a/src/shared/persisted-ui-state-types.ts
+++ b/src/shared/persisted-ui-state-types.ts
@@ -125,6 +125,8 @@ export type PersistedUIState = {
/** Client-side footer presentation; verbose preserves the pre-roster all-window default. */
statusBarUsageMode?: StatusBarUsageMode
dismissedUpdateVersion: string | null
+ /** App version that last dismissed the unexpected-sign-out card; null = never. Re-arms on each new version while still signed out. */
+ dismissedUnexpectedSignoutVersion?: string | null
lastUpdateCheckAt: number | null
/** Dev-only update channel override; absent means the build's own channel. */
releaseChannelOverride?: ReleaseChannel | null
From 2c984bcdaa7950e628a18180aad9537243e1a92f Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Thu, 10 Sep 2026 23:28:39 -0400
Subject: [PATCH 02/17] feat(ai-vault-search): session search index as a
transcript reader consumer (#19687)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(ai-vault-search): add the session search index schema and row modules
The FTS5 index that PR 2 folds the transcript reader's message stream into:
two FTS tables behind visibility views, the identifier shadow column, the
resumable fork digest, redaction at the row-insert choke point, and the
bounded compaction, warm-up and retention lanes.
Schema version starts at 1: this branch drops the query log and the fts5vocab
table, which the query engine reintroduces with its own bump.
* feat(ai-vault-search): stage index writes and publish them atomically
The writer stages a read's rows against an unpublished session row and a batch
id, then flips the whole read visible in one transaction: both FTS tables are
written together per row, and publish nulls the batch pointer before dropping
the batch so a recycled rowid can never name a later in-flight write.
Buffering replaces the branch's streamed producer. The transcript reader pushes
messages synchronously, so a staged write cannot make it wait; rows are held to
128 of them or 256 KB and then flushed in one transaction, which bounds both the
retained bytes and one stall.
The store owns the database and the set of files the index is behind on. It
carries no query, coverage or scheduling: draining that set is the indexer's.
* feat(ai-vault-search): register the index as a transcript reader consumer
The index keeps its own per-file cursor in the `files` table and never consults
the parse cache; the branch's AsyncLocalStorage capture scope is gone.
Three refusals, each leaving the cursor where it was and recording the file for
a later whole re-read: an append whose predecessor offset is not this index's
own cursor (or whose dev/ino changed) is declined in `beginRead`, a staging
failure ends the read's rows without throwing back at the reader, and an
`incomplete` outcome never publishes. A null session drops the file's rows and
still advances the cursor, because the file was read through.
Nothing in production registers it: the indexer decides when the index is live.
* test(ai-vault-search): measure the index's disk and latency cost
The write benchmark indexes a synthetic corpus through the real reader and
reports rows/s, per-table bytes per transcript MB, write amplification and
rebuild time; the retention benchmark compares the batched purge against a
whole-file delete. The corpus generator ships with them, so the numbers are
reproducible on any host and no real transcript is ever read.
* fix(ai-vault-search): decline a source the message channel cannot reach
An OpenCode SQLite candidate decodes inside a worker, so the reader reports
every read of it as incomplete. Staging a batch first meant one staging session
and one tombstone per scan of every such session, forever. Declining in
`beginRead` also keeps it out of the re-read set, because no re-read helps: the
index cannot cover that source until its capture path lands.
* fix(ai-vault-search): redact a message before it is cut into rows
A credential is a shape, and a shape split across two chunks matches neither
half: an 8000-char boundary landing inside a PEM block or a JWT indexed the key
material in full, identifier shadow terms included. Redaction moves ahead of
chunking, and the row type is branded so only `searchMessageRows` can mint a row
an insert site accepts.
`upsertFile` stops erasing dev/ino when a candidate carries none. A host that
cannot stat identity (SSH, WSL, a degraded Windows stat) was overwriting a
proven identity with NULL, which made the next rename-replace of that path
undetectable.
* test(ai-vault-search): pin each index guard on its own
Three guards were each shadowed by another, so mutating any one of them left
every test green. Driving the store directly separates the writer's own
predecessor-offset check from the consumer's cursor check, and a stubbed store
proves `beginRead` refuses before the writer is ever asked.
`publishable` gets the case it never had: a second read of the same path
publishes first, and the stale stage is tombstoned rather than resurrected. It
covers the overlap the parse file lane normally prevents, so the invalidation
slot being per-path is not load-bearing on its own.
Two ratchets: every provider fingerprint needs a fixture that reaches past
`SECRET_ANCHOR`, and every FTS read in the index modules must subtract staged
rows through a visibility view.
* fix(ai-vault-search): rebuild an index that cannot be trusted, and index retention
Opening self-heals. A file too torn to open, or one whose recovery fails, is
unlinked with its sidecars and reopened once; a second failure throws. Before
this a torn index broke open permanently, even though the index is a cache over
the transcripts and throwing it away costs only a re-scan.
A `meta` table with no readable version row is now `stale`, not `fresh`. Seeding
the current version over it would have kept whatever rows the old schema left.
`fresh` means no `meta` table at all, and no longer triggers a rebuild: the
first open of a new profile was doing create-remove-create.
Recovery moves inside open, because retiring a batch that outlived its writer is
part of opening the index rather than of using it. Its append case gets the test
it never had: 200 rows staged onto a live session and abandoned leave the
published generation intact and nothing else.
Also an index on files(mtime_ms), so the retention pass seeks the expiring end
of the list instead of scanning and sorting it, a pragma read-back test, and a
sessions.file_path comment that says what is actually true of OpenCode.
* fix(ai-vault-search): key a session's cwd the way the sidebar already does
`cwd_key` had its own normalizer, which qualified a WSL cwd with its distro:
`/home/me/repo` was stored as `//wsl/ubuntu/home/me/repo` while the sidebar's
`folderGroupKey` stored `/home/me/repo`. Any later join between an indexed hit
and a sidebar group would have returned nothing for exactly the WSL users the
qualification was meant to help. The key is now the shared normalizer verbatim,
pinned against `folderGroupKey` for POSIX, Windows drive, /mnt/c, both WSL UNC
aliases, a Linux path, and the root.
The collision it guarded against is real: two distros both spell
`/home/me/repo`. It is also a collision every SSH host has, neither key
qualifies for SSH, and the honest fix is a column naming the execution host
rather than a path key that only some hosts spell differently.
`/` now keys as `/` instead of the empty string. An empty key cannot be told
apart from a session with no cwd, and the scope filter builds its child prefix
as `key + '/'`, which would have been `//`.
* test(ai-vault-search): widen the FTS visibility ratchet past whole literals
Concatenating or interpolating a table name hid it from the per-literal scan,
so `'SELECT rowid FROM ' + table` passed. The unit is now the file, the scan
covers src/main and src/relay rather than one directory, and both bypass shapes
are checker unit tests. Exemptions carry a reason and the census fails on one
that has stopped being needed, which is how the schema module lost its.
* fix(ai-vault-search): keep a failed rebuild's real cause, and read per statement
Closing the stale handle before the unlink left `db` dangling: if the unlink or
the reopen threw, the failure path closed it a second time and node:sqlite's
ERR_INVALID_STATE replaced the real cause. Nothing classifies that as worth
retrying, so an index a virus scanner or a second Orca was holding would never
rebuild. The handle is nulled while none is open.
The visibility ratchet moves from the file to the statement. A file is far too
coarse for what it exists to guard: the query module will name both views
somewhere, and that whitelisted every raw read in it. Statements come from
literals and their concatenation chains, split on `;`, and a table name
assembled at runtime counts as a read in any file that names an FTS table. The
roots now cover cli, shared and preload as well.
Half a recorded identity is now stated to be no identity. `remote-session-file-stat`
spreads dev and ino independently and the COALESCE preserves whichever half a
host could prove, so one number cannot tell a rename-replace from a same-file
re-read; comparing it would decline healthy resumes on a coincidence.
`discard` clearing the staging slot gets a test: without it the map grew one
entry per path for the store's life.
* fix(ai-vault-search): give a behind consumer a way to get the read it needs
`markStale` promised a whole re-read that nothing could deliver. The reader
picks append or replace from the session list's resume point, so with an empty
index and a warm parse cache every read arrives as `append`, the consumer
declines every one, and nothing is ever indexed. That is the state on first
enablement inside a running app.
`requestWholeTranscriptRead` in the reader drops that path's resume point, which
is the one lever that changes the next read's mode, and it lives with the cache
that owns it rather than with the consumer that wants it. `takeStale` documents
that its paths need it before a scan is re-dispatched.
Pausing no longer empties the re-read set. `acceptsCandidate` answers whether a
write may start now and was being used for both jobs, so reconfiguring retention
while paused dropped every entry and a declined read during a pause was never
recorded at all. Retention alone prunes; a paused decline is recorded, bounded,
with the oldest dropped and the drops counted, because a set that silently
forgets is worse than one that says it is incomplete.
A read that decodes no session now tombstones its staged rows before the store
schedules cleanup. The cleanup lane reads the tombstone table the moment it is
scheduled, so the old order left that batch on disk until some later write, and
for the last read before a shutdown that is never.
* fix(ai-vault-search): create the directory the index lives in
Nothing made `/ai-vault-search/`, and SQLite's failure for a missing
parent is `unable to open database file`, which is correctly not classified as
corruption — so the retry never fired and the feature stranded on any profile
that had never held an index.
* refactor(ai-vault-search): store transcript content as written
Decision: the index does not redact. A secret in a transcript is already
plaintext under the user's home directory and is treated as compromised, so the
index is a second copy of content the user already holds, not a new exposure.
The reference agent-session-search products do not redact either. What a snippet
may carry once it leaves this machine is a transport policy and belongs where
the wire is, not in the write path.
Removes the redaction module and its census, the branded row type that existed
to prove redaction had run before an insert, and the two chunk-boundary tests.
`insertSearchMessage` takes a plain chunk again and identifier shadow terms come
off the raw text. `src/main/observability/redactor.ts` goes back to what main
has, so this PR no longer touches it at all.
Chunking and the fork digest are unchanged; the digest always folded raw message
text, ahead of chunking, so its tests hold as written.
Cost: redaction was 14 ms per 10.5 MB of transcript, about 3% of a rebuild and
below the benchmark's run-to-run spread, so the write numbers are unchanged at
roughly 22-25k rows/s and 22-27 MB/s.
* fix(ai-vault-search): hide a tombstoned session's messages, not only its row
`visible_sessions` already subtracted session-keyed tombstones; the message
half filtered on the batch pointer alone. A published row outlives its session
row until the cleanup lane reaches it, so between a `replace` publish and that
drain both generations answered, and a removed file kept answering after its
session was gone.
Both views now subtract the same set, over a partial index so neither read
scans the tombstone table.
* fix(ai-vault-search): drain the rows a read that never published staged
Only `writePublished` and `removeFile` scheduled the cleanup lane. A read that
staged rows and then declined to publish them tombstoned its batch and told
nobody, so 256 rows of a 300-message incomplete read sat in `messages` and both
FTS tables until some unrelated write happened to schedule a pass. Open-time
recovery had the same hole: it wrote the tombstones and drained none.
The abandoned branch now schedules the drain the published branch already got,
and opening schedules one pass for what recovery just tombstoned. Recovery no
longer adds a batch tombstone when the session-keyed one already covers those
rows, which it did once more on every reopen.
* fix(ai-vault-search): continue the cursor of a file that decoded no session
A read that decodes no session — an excluded Codex worker transcript — advances
the cursor and leaves `files.session_row_id` null. Every later append was then
declined, so a file that only ever grows would be re-read whole on every pass
for the rest of its life.
An append now only has to continue this index's own cursor; it creates the
session row when there is none to resume. That also collapses the `append` flag,
which meant both "resume this session" and "do not own it", into the resumed row
id itself.
* refactor(ai-vault-search): drop the index path module nothing calls
No caller in this PR or the two that follow it: the composition root that would
capture the userData path is PR 3b's.
* refactor(ai-vault-search): read the schema version as stale or not
Only the stale answer was ever acted on; the fresh/current split named two ways
of being fine.
* fix(ai-vault-search): read the resumed session id off an optional row
* refactor(ai-vault-search): leave page warmup and the freshness check to PR 4
`warm()` and `session-search-page-warmup.ts` have their first caller in the
query engine, which is what knows which pages are worth warming; the module
itself went with the previous commit. `isSessionSearchFileCurrent` has its first
caller in the reconciler. `session-search-file-cursor.ts` stays, because
`fileIdentity` and both its types are load-bearing here.
* refactor(ai-vault-search): stop pruning the re-read set on a retention change
The prune walked the whole set to drop what the next `beginRead` would refuse
anyway: `acceptsCandidate` applies the window when the re-read is dispatched,
and `markStale` applies it again before recording anything. The bound stays.
* fix(ai-vault-search): keep a batch tombstone from outliving its batch row
Draining a session-keyed tombstone deletes the session's batch rows, but left
any batch-keyed tombstone naming them in place. `search_write_batches` empties
on every publish, so ids restart low and the next read takes the freed rowid;
the stale tombstone then deleted that live batch's staged rows and the session
published missing messages, with no re-read to fill the gap.
The session drain now clears those tombstones in the same transaction.
* refactor(ai-vault-search): commit a file's rows and its cursor in one transaction
The staged-publish model is replaced by one SQLite transaction per file in WAL
mode. A read buffers its decoded rows and writes them, its session and its
cursor together; a reader on another handle sees the last committed state, which
is the "never a torn session" guarantee the staging machinery was built to
provide. A crash rolls the whole file back and it is re-read.
Gone with it: `search_write_batches`, `search_pending_deletes`,
`messages.batch_id`, `sessions.index_ready`, both `visible_*` views, the
open-time recovery pass, the cleanup lane and `settled()`, and the ratchet test
that made every query site read through a view. Rounds 2 through 6 were all
seams between those pieces.
A file whose rows exceed `SESSION_SEARCH_COMMIT_CHARS` is cut into chunks. The
reader only hands out a byte offset when a read finishes, so a chunk records one
no append can continue from: its rows answer searches as a coherent prefix of
the session, and the next whole read replaces them.
Retention keeps no record of unfinished work. It cuts a session loose from its
file in one small transaction, which is what stops it answering, then reclaims
its rows in bounded batches and hands the freed pages back as it goes. Rows
whose session row is gone are the record of what an interrupted purge left.
Deleted for want of a caller in PR 2, 3 or 4: the WAL budget (a buffered write
has no staging window to grow one across), the compaction module (folded into
the drain), the `sessions_content_hash` index (fork folding runs in JS over rows
PR 4 already holds), `lastWriteAt`, `failures`, `SessionSearchStoreOptions`,
`openStageCount` and `chunkMessageText`.
* test(ai-vault-search): price a per-file commit and the ceiling that bounds it
The write benchmark now times every transaction, because with one per file that
is the whole stall a file costs. A second phase indexes a single synthetic
100 MB transcript, which is what the commit ceiling is chosen against.
* fix(ai-vault-search): stop a fenced write reopening a transaction per message
A chunk write that finds the file record moved under it — a `removeFile`, or an
overlapping read of the same path — can never land anything afterwards. It kept
buffering, so every remaining message re-entered `BEGIN IMMEDIATE` only to roll
back, once per message for the rest of a file that may be a hundred megabytes.
It now stops at the first refusal and drops what it holds.
* fix(ai-vault-search): never hand a live session the id of a purged one
`sessions.id` was a plain rowid alias, so SQLite reissued it as max+1. That id
names rows in `messages` for far longer than the row itself lives: retention
cuts a session loose in one transaction and reclaims its messages over many, and
a session created inside that window was handed a freed id and adopted whatever
of the purged conversation the drain had not reached. The drain then skipped
those rows for good, because their session row exists again, and a search
answered for a purged transcript under a live session's name.
Reachable with no crash at all: an append of a growing transcript the parser
decoded no session from creates its session row mid-purge. AUTOINCREMENT is the
class fix; the schema version goes to 3 so a file written by an earlier commit
of this branch rebuilds rather than keeping a plain-rowid table under a
`CREATE TABLE IF NOT EXISTS`. `messages.id` was checked and is not exposed to
the same window: a row and its two FTS entries always go in one transaction.
Two smaller ones in the same pass. `removeFile` did not fence a read of a path
this index had never written: `current()` compared a cursor, and on an unknown
path the absent row and the absent expectation are both undefined, so the write
recreated the source after its owner had proven it gone. The writer now counts
removals per path and a write compares that count, which is a positive fence
rather than an inferred one. And `drainOrphanedMessages`,
`CONTENT_HASH_MESSAGE_LIMIT` and `CONTENT_HASH_MIN_MESSAGES` are no longer
exported: nothing outside their own modules reads them.
* fix(ai-vault-search): report a half-written file as held, not as unknown
`indexedFile` returned null for a file a chunked read left partway through, the
same answer it gives for a path the index has never seen. A caller asking "do
you hold this file" therefore read a half-written one as new, asked for whatever
read the parse cache offered, and the reader picked append — which the consumer
then declined. Only the stale set healed it, a cycle later.
It now reports the record with a null `byteOffset`, and
`requiresWholeRead(indexed)` names that state. Null rather than an added flag
because the mtime and size on that record are the file's real ones: a freshness
check comparing only those would call a half-written file current, and a boolean
is easy to not read, while every site that does arithmetic on the offset has to
say what null means at compile time.
The test also found the writer accepting an append that passed the partial
sentinel back as its predecessor offset. Nothing in the reader produces a
negative offset, but the sentinel is not a byte offset and no caller should be
able to continue it; `beginWrite` now refuses it outright.
* fix(ai-vault-search): cut a chunk at whitespace, never mid-token
The 8,000-char chunker backed up only to a newline, and only when one sat in
the second half of the window. A wrapped paragraph, a CJK transcript or a
minified log has no newline there, so the cut landed inside whatever word
straddled the target: the user's term was filed as two halves and matched
neither. It now backs up to the last Unicode whitespace in the second half,
and falls through to the target when there is none, because 4,000 characters
without a space is not a word.
A phrase that straddles a chunk boundary is still not matched. Chunks are
separate FTS rows and FTS5 cannot span them; that is stated at the chunker.
* feat(ai-vault-search): cap an indexed tool row at 3,072 characters
Tool output is 80-97 % of a transcript's bytes and a single message may be a
quarter of a megabyte, so without a cap the index, the buffer a read holds and
the transaction it commits are all sized by how much a tool printed rather
than by how much is worth searching. A `tool` message now becomes one row of
at most 3,072 characters, kept from the head, where the command and the first
lines of its output are. User and assistant text is never capped.
Measured on the write benchmark, 40 sessions with tool output at 95 % of
message text (the real band), per transcript MB: index 1,334,126 -> 353,973
bytes, write amplification 1.27x -> 0.34x, rows 18,804 -> 9,600, rebuild
1,676 -> 379 ms, largest transaction 49.7 -> 12.1 ms. On the default corpus,
whose tool results are 1.4 KB and so under the cap, nothing changes at all:
2,167,887 bytes per transcript MB on both arms.
The corpus generator takes `toolResultWords` and the benchmark reads
ORCA_SEARCH_BENCH_TOOL_WORDS so both arms are the same harness.
* fix(ai-vault-search): check the commit ceiling per row, not per message
The buffered-chars check ran after a whole message had been folded into rows,
so a single message could carry a transaction as far past the ceiling as it
was large. A conversation turn is one message and can be megabytes; the
ceiling exists to bound how long one commit holds the process and how large a
WAL it produces, and neither bound survived a message that overshot it.
* style(ai-vault-search): undo a stray reformat of untouched assertions
Three assertions in the file-write test were expanded by a formatter run that
was not the repo's, and the expansion survived because a multi-line object
literal is preserved once it exists. Restored to what they were.
* feat(ai-vault-search): write a session's identity with its first commit
A chunked read created its session row with an empty session id, an empty
title, an empty resume command and null cwd and timestamps, and only filled
them in on the final commit. Those chunks answer searches the moment they
land, so until the read ended every hit they produced named a session nothing
could identify — and a crash between chunks left it that way for good, since
the re-read that heals it is the same read starting over.
The reader already knows the id, cwd and timestamps from a transcript's
opening lines; it just had nowhere to put them. `TranscriptReadStart` now
carries an optional `identity()` the consumer calls during the read, backed by
an optional `identity()` on `ResumableSessionParseState`: one line in the
shared accumulator fold (which covers cursor, copilot, droid, gemini,
antigravity and the graph parsers) and one each in the Claude and Codex folds,
which keep their own state. A chunk commit writes what it returns; the final
commit overwrites it from the decoded session, so the mid-read title stays
provisional.
Two `cwd` guards in the Codex fold collapse into the `?? ` form the `branch`
line beside one of them already used. `extractString` never returns an empty
string, so it is the same assignment in one line instead of four, and it is
what keeps the file under the 300-line cap with the identity accessor added.
* refactor(ai-vault-search): drop conversation_fts for a column filter
The second FTS table existed for query latency alone: a column-filtered
`messages_fts MATCH '{user_text assistant_text}: q'` returns the identical
rowid set, which PR 2's round 5 review proved and PR 4's benchmark re-checked
per query. PR 4 then measured the filter at 1.16-1.36x the p95 of the second
table on a 105 MB corpus across two points in the tool-output band, under the
2x bar the decision was set at, and closes the stack's open decision 3.
It cost a quarter of the index on a corpus whose tool output is half the
message text. With the tool-row cap it is a smaller saving than the decision
was framed around, but it is still a table, a write per conversational row and
a delete per reclaimed one.
Schema version 4: `CREATE TABLE IF NOT EXISTS` is a no-op over an existing
index, so only the bump makes a file that still carries the table go.
* feat(ai-vault-search): expose the index handle a composed reader queries
PR 4's engine reads the index this store owns. One getter lets it compose over
the store's handle instead of opening a second connection to the same file,
and it carries the two rules this PR measured: never hold a read transaction
across an await, and no `.iterate()` outliving its statement. Either pins a
read snapshot, and a checkpoint cannot pass one, so the WAL grows without
bound for as long as it is held (10 MB to 266 MB on the write benchmark).
* fix(ai-vault-search): cut a chunk at punctuation, not only whitespace
The 8,000-character chunker backed up to the last whitespace in the second
half of the window and fell through to the target when it found none. A
minified tool result has none to find: valid minified JSON runs past 8,000
characters without a space, so the cut landed inside whatever word straddled
the target, and a search for `pericardium` matched neither `perica` nor
`rdium`. That is the shape most likely to be chunked in the first place,
because tool output is 80-97 % of a transcript's bytes.
The backoff now takes any character that cannot be inside a token — anything
outside a letter, digit or underscore — and still falls through to the target
when the window holds none, because 4,000 characters without one is not a
word. Surrogates are excluded from the class so a cut never lands between the
halves of an astral character.
A few of the tokenizer's own `tokenchars` (`. - / +`) are cut on even though
unicode61 keeps them inside a token. That costs the joined form of a path, and
only in a window with no whitespace anywhere, which is a far smaller loss than
the torn word this exists to prevent.
* fix(ai-vault-search): never chunk a read that cannot name its session
`updateProvisionalSession` is fed by the resumable readers alone. Claude and
Codex carry an `identity()` off their fold; `readWholeTranscript` supplies
none, because a format rewritten in place has no resumable state to ask. So a
Grok, Cursor, Gemini or OpenCode file large enough to pass the commit ceiling
published its chunks under a session with an empty id, an empty title and a
null cwd — rows that answer searches at once and that an interrupted read
leaves behind for good, since the re-read that heals them is the same read
starting over.
`add` now commits a chunk only while the read can name what it is writing. A
read with no identity, or one whose parser has decoded no id yet, keeps
buffering and commits whole at `finish`. The whole-file formats are the ones
with nothing to give and they are small — the largest on this machine is 5 MB
— so buffering one to the end costs nothing, and chunking stays reserved for
the readers that can say which session a prefix belongs to.
`updateProvisionalSession` takes a non-null identity now, so the invariant is
the type rather than a guard that silently wrote nothing.
* perf(ai-vault-search): cut a replaced generation loose instead of deleting it
The first transaction of a replace deleted every row of the old session before
inserting its own bounded chunk, so the transaction was sized by the history
being replaced rather than by the rows being written. On the synthetic 100 MB
transcript the longest replace transaction was 1,255-1,283 ms against
668-672 ms for the same file read fresh, and the gap grows with the session.
A replace now mints a new session row, points the `files` row at it and
deletes the one old `sessions` row, all in the same transaction. Every
retrieval joins `sessions`, so the old generation stops answering the moment
that commits — the same thing retention's first transaction does — and its
messages are reclaimed afterwards by `drainOrphanedMessages`, the bounded
batch loop that already exists for exactly this set. `sessions.id` is
AUTOINCREMENT, so the freed id is never handed to another session while those
rows still name it.
The longest replace transaction is now 746-848 ms, the same band as a fresh
read. The trade is stated plainly: the pass does more total work, 3.7 s
against 3.1 s, because the reclaim is 360 bounded transactions with an
incremental_vacuum step each instead of one large delete. It yields between
them, so none of it holds the process, and returning the pages per batch also
takes the index from 173.6 MB to 162.0 MB.
The store schedules the drain off the committing stack: an async function runs
synchronously to its first `await`, so calling it inline would put the first
batch back inside the transaction's own call. One drain at a time, with a
repeat flag for a replace that commits while one is running.
* fix(ai-vault-search): preserve search tokens and index Codex tools
* fix(ai-vault-search): match SQLite marks and Codex file-change records
* fix(ai-vault-search): preserve stat pairs and validate benchmark results
---
.../session-search-retention-benchmark.ts | 148 +++++
.../scripts/session-search-write-benchmark.ts | 266 ++++++++
.../session-search-content-hash.test.ts | 48 ++
.../session-search-content-hash.ts | 45 ++
.../session-search-cwd-key.test.ts | 37 ++
.../session-search-file-cursor.ts | 41 ++
.../session-search-file-records.ts | 134 ++++
.../session-search-file-write.test.ts | 614 ++++++++++++++++++
.../session-search-identifier-split.test.ts | 29 +
.../session-search-identifier-split.ts | 54 ++
.../session-search-index-consumer.test.ts | 373 +++++++++++
.../session-search-index-consumer.ts | 118 ++++
.../session-search-index-test-fixture.ts | 124 ++++
.../session-search-index-writer.test.ts | 228 +++++++
.../session-search-index-writer.ts | 359 ++++++++++
.../session-search-live-transcript.test.ts | 208 ++++++
.../session-search-message-rows.test.ts | 249 +++++++
.../session-search-message-rows.ts | 135 ++++
.../session-search-retention-delete.test.ts | 188 ++++++
.../session-search-retention-delete.ts | 102 +++
.../session-search-row-identity.test.ts | 116 ++++
.../session-search-schema.test.ts | 350 ++++++++++
.../ai-vault-search/session-search-schema.ts | 198 ++++++
.../ai-vault-search/session-search-store.ts | 253 ++++++++
.../session-search-synthetic-corpus.test.ts | 44 ++
.../session-search-synthetic-corpus.ts | 154 +++++
.../session-search-transcript-fixtures.ts | 118 ++++
.../ai-vault/session-scanner-accumulator.ts | 29 +-
.../session-scanner-codex-message-records.ts | 9 +
.../ai-vault/session-scanner-codex-parser.ts | 19 +-
.../session-scanner-codex-record-fast-path.ts | 14 +-
...session-scanner-codex-tool-records.test.ts | 125 ++++
.../session-scanner-codex-tool-records.ts | 95 +++
...ession-scanner-omp-subagent-transcripts.ts | 1 +
.../session-scanner-primary-parsers.ts | 2 +
src/main/ai-vault/session-scanner-types.ts | 8 +-
.../ai-vault/session-transcript-consumers.ts | 22 +
.../ai-vault/session-transcript-reader.ts | 28 +-
38 files changed, 5070 insertions(+), 15 deletions(-)
create mode 100644 config/scripts/session-search-retention-benchmark.ts
create mode 100644 config/scripts/session-search-write-benchmark.ts
create mode 100644 src/main/ai-vault-search/session-search-content-hash.test.ts
create mode 100644 src/main/ai-vault-search/session-search-content-hash.ts
create mode 100644 src/main/ai-vault-search/session-search-cwd-key.test.ts
create mode 100644 src/main/ai-vault-search/session-search-file-cursor.ts
create mode 100644 src/main/ai-vault-search/session-search-file-records.ts
create mode 100644 src/main/ai-vault-search/session-search-file-write.test.ts
create mode 100644 src/main/ai-vault-search/session-search-identifier-split.test.ts
create mode 100644 src/main/ai-vault-search/session-search-identifier-split.ts
create mode 100644 src/main/ai-vault-search/session-search-index-consumer.test.ts
create mode 100644 src/main/ai-vault-search/session-search-index-consumer.ts
create mode 100644 src/main/ai-vault-search/session-search-index-test-fixture.ts
create mode 100644 src/main/ai-vault-search/session-search-index-writer.test.ts
create mode 100644 src/main/ai-vault-search/session-search-index-writer.ts
create mode 100644 src/main/ai-vault-search/session-search-live-transcript.test.ts
create mode 100644 src/main/ai-vault-search/session-search-message-rows.test.ts
create mode 100644 src/main/ai-vault-search/session-search-message-rows.ts
create mode 100644 src/main/ai-vault-search/session-search-retention-delete.test.ts
create mode 100644 src/main/ai-vault-search/session-search-retention-delete.ts
create mode 100644 src/main/ai-vault-search/session-search-row-identity.test.ts
create mode 100644 src/main/ai-vault-search/session-search-schema.test.ts
create mode 100644 src/main/ai-vault-search/session-search-schema.ts
create mode 100644 src/main/ai-vault-search/session-search-store.ts
create mode 100644 src/main/ai-vault-search/session-search-synthetic-corpus.test.ts
create mode 100644 src/main/ai-vault-search/session-search-synthetic-corpus.ts
create mode 100644 src/main/ai-vault-search/session-search-transcript-fixtures.ts
create mode 100644 src/main/ai-vault/session-scanner-codex-tool-records.test.ts
create mode 100644 src/main/ai-vault/session-scanner-codex-tool-records.ts
diff --git a/config/scripts/session-search-retention-benchmark.ts b/config/scripts/session-search-retention-benchmark.ts
new file mode 100644
index 00000000000..095eb5f29b0
--- /dev/null
+++ b/config/scripts/session-search-retention-benchmark.ts
@@ -0,0 +1,148 @@
+import assert from 'node:assert/strict'
+import { mkdtemp, rm, stat } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
+import {
+ syntheticCandidate,
+ syntheticSession,
+ userMessages
+} from '../../src/main/ai-vault-search/session-search-index-test-fixture'
+import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store'
+import SyncDatabase from '../../src/main/sqlite/sync-database'
+
+// Bundle with esbuild --bundle --platform=node, then run on the host under test.
+// Every mode seeds through SessionSearchStore so the three arms are comparable;
+// only `whole-file` leaves the shipped path, because it is the baseline the
+// batched purge exists to replace.
+
+const ROWS = 60_000
+
+/** The purge yields with `setImmediate` between chunks, so a peer chain samples each gap. */
+async function sampleLoopStalls(running: () => boolean, intervals: number[]): Promise {
+ let previous = performance.now()
+ while (running()) {
+ await yieldToEventLoop()
+ const now = performance.now()
+ intervals.push(now - previous)
+ previous = now
+ }
+}
+
+/** What a search would still return: rows whose session row is still there. */
+function visibleRows(db: SyncDatabase): number {
+ return (
+ db
+ .prepare(`SELECT count(*) AS n FROM messages m JOIN sessions s ON s.id = m.session_row_id`)
+ .get() as { n: number }
+ ).n
+}
+
+const root = await mkdtemp(join(tmpdir(), 'orca-search-retention-bench-'))
+try {
+ for (const mode of ['whole-file', 'batched', 'batched-pinned-reader']) {
+ const path = join(root, `${mode}.sqlite`)
+ const errors: unknown[] = []
+ const store = new SessionSearchStore(path, (error) => errors.push(error))
+ let reader: SyncDatabase | null = null
+ try {
+ const write = store.beginWrite(syntheticCandidate(), 'replace', 0)!
+ for (const message of userMessages(
+ 'synthetic benchmark needle repeated context for a representative coding conversation with commands and paths src/example.ts',
+ ROWS
+ )) {
+ write.add(message)
+ }
+ assert.equal(
+ write.commit({
+ session: syntheticSession(),
+ byteOffset: 4096,
+ incomplete: false
+ }),
+ true
+ )
+ assert.deepEqual(errors, [])
+ // Truncating first is what makes walBytes below the purge's own growth.
+ const checkpoint = new SyncDatabase(path)
+ checkpoint.pragma('wal_checkpoint(TRUNCATE)')
+ checkpoint.close()
+ if (mode === 'batched-pinned-reader') {
+ reader = new SyncDatabase(path, { readonly: true })
+ reader.exec('BEGIN')
+ reader.prepare('SELECT count(*) FROM messages').get()
+ }
+ const probe = new SyncDatabase(path, { readonly: true })
+ const intervals: number[] = []
+ const started = performance.now()
+ if (mode === 'whole-file') {
+ const raw = new SyncDatabase(path)
+ try {
+ raw.exec('BEGIN IMMEDIATE')
+ const ids = raw.prepare('SELECT id FROM messages').all() as {
+ id: number
+ }[]
+ for (const { id } of ids) {
+ raw.prepare('DELETE FROM messages_fts WHERE rowid=?').run(id)
+ }
+ raw.exec('DELETE FROM messages; DELETE FROM sessions; DELETE FROM files; COMMIT')
+ } finally {
+ raw.close()
+ }
+ intervals.push(performance.now() - started)
+ } else {
+ let purging = true
+ const purge = store.purgeOlderThan(Date.now() + 60_000)
+ // Hiding is immediate: cutting the session loose from its file is the
+ // first transaction, so a read one turn in already sees nothing, long
+ // before the rows are gone.
+ const hiddenEarly = yieldToEventLoop().then(() => visibleRows(probe))
+ const sampler = sampleLoopStalls(() => purging, intervals)
+ await purge
+ purging = false
+ await sampler
+ assert.equal(await hiddenEarly, 0)
+ assert.deepEqual(errors, [])
+ }
+ const wallMs = performance.now() - started
+ probe.close()
+ reader?.exec('COMMIT')
+ reader?.close()
+ reader = null
+ const after = new SyncDatabase(path, { readonly: true })
+ try {
+ for (const table of ['messages_fts']) {
+ assert.equal(
+ (
+ after.prepare(`SELECT count(*) AS n FROM ${table}`).get() as {
+ n: number
+ }
+ ).n,
+ 0
+ )
+ }
+ } finally {
+ after.close()
+ }
+ const walBytes = (await stat(`${path}-wal`)).size
+ intervals.sort((a, b) => a - b)
+ console.log(
+ JSON.stringify({
+ mode,
+ platform: process.platform,
+ node: process.version,
+ rows: ROWS,
+ wallMs: Math.round(wallMs),
+ samples: intervals.length,
+ maxStepMs: Math.round(intervals.at(-1) ?? 0),
+ p95StepMs: Math.round(intervals[Math.floor(intervals.length * 0.95)] ?? 0),
+ walBytes
+ })
+ )
+ } finally {
+ reader?.close()
+ store.close()
+ }
+ }
+} finally {
+ await rm(root, { recursive: true, force: true })
+}
diff --git a/config/scripts/session-search-write-benchmark.ts b/config/scripts/session-search-write-benchmark.ts
new file mode 100644
index 00000000000..db09275cd98
--- /dev/null
+++ b/config/scripts/session-search-write-benchmark.ts
@@ -0,0 +1,266 @@
+import assert from 'node:assert/strict'
+import { rm, stat } from 'node:fs/promises'
+import { join } from 'node:path'
+import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
+import {
+ createSessionParseStats,
+ parseAgentSessionFileCached,
+ resetSessionParseCacheForTests
+} from '../../src/main/ai-vault/session-scanner-parse-cache'
+import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers'
+import { requestWholeTranscriptRead } from '../../src/main/ai-vault/session-transcript-reader'
+import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer'
+import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store'
+import { writeSyntheticTranscriptCorpus } from '../../src/main/ai-vault-search/session-search-synthetic-corpus'
+import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures'
+import SyncDatabase from '../../src/main/sqlite/sync-database'
+
+// Measures the real transcript reader and search store over a synthetic corpus.
+// Never point this at a real transcript tree.
+
+/**
+ * How long the longest single transaction held the process.
+ *
+ * With one transaction per file that is the whole stall a file costs, so it is
+ * the number the commit ceiling exists to bound. Measured by wrapping `exec`,
+ * because the writer's transactions are the only ones this benchmark runs.
+ */
+function recordTransactionDurations(durations: number[]): () => void {
+ const exec = SyncDatabase.prototype.exec
+ let started = 0
+ SyncDatabase.prototype.exec = function (this: SyncDatabase, sql: string): void {
+ if (sql === 'BEGIN IMMEDIATE') {
+ started = performance.now()
+ }
+ exec.call(this, sql)
+ if (sql === 'COMMIT' && started > 0) {
+ durations.push(performance.now() - started)
+ started = 0
+ }
+ }
+ return () => {
+ SyncDatabase.prototype.exec = exec
+ }
+}
+
+/** The writer commits synchronously, so a peer chain samples the gap each read leaves. */
+async function sampleLoopStalls(running: () => boolean, stalls: number[]): Promise {
+ let previous = performance.now()
+ while (running()) {
+ await yieldToEventLoop()
+ const now = performance.now()
+ stalls.push(now - previous)
+ previous = now
+ }
+}
+
+function tableBytes(db: SyncDatabase): Record {
+ const rows = db.prepare('SELECT name, sum(pgsize) AS bytes FROM dbstat GROUP BY name').all() as {
+ name: string
+ bytes: number
+ }[]
+ const group = (prefix: string): number =>
+ rows
+ .filter((row) => row.name === prefix || row.name.startsWith(`${prefix}_`))
+ .reduce((sum, row) => sum + row.bytes, 0)
+ return {
+ messagesFts: group('messages_fts'),
+ messages: group('messages') - group('messages_fts'),
+ sessions: group('sessions'),
+ total: rows.reduce((sum, row) => sum + row.bytes, 0)
+ }
+}
+
+function assertIndexedMessages(db: SyncDatabase, expected: number): number {
+ const { n } = db
+ .prepare('SELECT count(*) AS n FROM messages m JOIN sessions s ON s.id = m.session_row_id')
+ .get() as { n: number }
+ assert.equal(n, expected, 'indexed message count')
+ return n
+}
+
+async function checkpointedFileBytes(db: SyncDatabase, path: string): Promise {
+ // Flush committed WAL pages before reporting the final database footprint.
+ const [checkpoint] = db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[]
+ assert.equal(checkpoint?.busy, 0, 'storage measurement requires a completed checkpoint')
+ return (await stat(path)).size
+}
+
+// The default corpus puts tool output at about half the message text; set this
+// far higher to price the tool-row cap against the real 80-97 % band.
+const toolResultWords = Number(process.env.ORCA_SEARCH_BENCH_TOOL_WORDS ?? 200)
+const corpus = await writeSyntheticTranscriptCorpus({ toolResultWords })
+const indexPath = join(corpus.root, 'index.sqlite')
+try {
+ const errors: unknown[] = []
+ const store = new SessionSearchStore(indexPath, (error) => errors.push(error))
+ const unregister = registerSessionSearchIndexConsumer(store)
+ const stalls: number[] = []
+ const transactions: number[] = []
+ const restoreExec = recordTransactionDurations(transactions)
+ let indexing = true
+ try {
+ const stats = createSessionParseStats()
+ const started = performance.now()
+ const sampler = sampleLoopStalls(() => indexing, stalls)
+ for (const path of corpus.files) {
+ await parseAgentSessionFileCached(
+ await sessionCandidate('claude', path),
+ process.platform,
+ stats
+ )
+ }
+ indexing = false
+ await sampler
+ restoreExec()
+ const rebuildMs = performance.now() - started
+ assert.deepEqual(errors, [])
+
+ const reader = new SyncDatabase(indexPath, { readonly: true })
+ try {
+ const rows = assertIndexedMessages(reader, corpus.messageCount)
+ const sessions = (
+ reader.prepare('SELECT count(*) AS n FROM sessions').get() as {
+ n: number
+ }
+ ).n
+ assert.equal(sessions, corpus.files.length)
+ const bytes = tableBytes(reader)
+ const perMb = (value: number): number =>
+ Math.round((value / (corpus.transcriptBytes / (1024 * 1024))) * 10) / 10
+ const fileBytes = await checkpointedFileBytes(store.connection, indexPath)
+ stalls.sort((a, b) => a - b)
+ transactions.sort((a, b) => a - b)
+ console.log(
+ JSON.stringify(
+ {
+ platform: process.platform,
+ node: process.version,
+ transcriptMb: Math.round((corpus.transcriptBytes / (1024 * 1024)) * 100) / 100,
+ toolResultWords,
+ sessions,
+ rows,
+ rebuildMs: Math.round(rebuildMs),
+ rowsPerSecond: Math.round(rows / (rebuildMs / 1000)),
+ transcriptMbPerSecond:
+ Math.round((corpus.transcriptBytes / (1024 * 1024) / (rebuildMs / 1000)) * 100) / 100,
+ bytesPerTranscriptMb: {
+ messagesFts: perMb(bytes.messagesFts),
+ messages: perMb(bytes.messages),
+ sessions: perMb(bytes.sessions),
+ total: perMb(bytes.total)
+ },
+ writeAmplification: Math.round((bytes.total / corpus.transcriptBytes) * 100) / 100,
+ fileWriteAmplification: Math.round((fileBytes / corpus.transcriptBytes) * 100) / 100,
+ transactions: transactions.length,
+ maxTransactionMs: Math.round((transactions.at(-1) ?? 0) * 100) / 100,
+ maxLoopStallMs: Math.round(stalls.at(-1) ?? 0),
+ p95LoopStallMs: Math.round(stalls[Math.floor(stalls.length * 0.95)] ?? 0),
+ loopStallSamples: stalls.length,
+ parseStats: stats
+ },
+ null,
+ 2
+ )
+ )
+ } finally {
+ reader.close()
+ }
+ } finally {
+ indexing = false
+ restoreExec()
+ unregister()
+ resetTranscriptConsumersForTests()
+ resetSessionParseCacheForTests()
+ store.close()
+ }
+} finally {
+ await rm(corpus.root, { recursive: true, force: true })
+}
+
+// Phase two: one transcript far larger than any real one, to price the ceiling
+// that decides whether a file commits once or in chunks.
+const largeTurns = Number(process.env.ORCA_SEARCH_BENCH_LARGE_TURNS ?? 23_000)
+const large = await writeSyntheticTranscriptCorpus({
+ sessions: 1,
+ turnsPerSession: largeTurns,
+ seed: 2
+})
+const largeIndexPath = join(large.root, 'index.sqlite')
+try {
+ const errors: unknown[] = []
+ const store = new SessionSearchStore(largeIndexPath, (error) => errors.push(error))
+ const unregister = registerSessionSearchIndexConsumer(store)
+ const transactions: number[] = []
+ const restoreExec = recordTransactionDurations(transactions)
+ try {
+ const stats = createSessionParseStats()
+ const started = performance.now()
+ await parseAgentSessionFileCached(
+ await sessionCandidate('claude', large.files[0]!),
+ process.platform,
+ stats
+ )
+ const indexMs = performance.now() - started
+ restoreExec()
+ assert.deepEqual(errors, [])
+ assertIndexedMessages(store.connection, large.messageCount)
+ transactions.sort((a, b) => a - b)
+
+ // The same file again, over a generation the index already holds. That is
+ // the pass a growing transcript really costs, and the one whose transaction
+ // used to be sized by the old session rather than by the chunk being
+ // written. The drain that reclaims the cut-loose generation runs after the
+ // commit, so its bounded batches are in `replaceTransactions` too.
+ const replaceTransactions: number[] = []
+ const restoreReplaceExec = recordTransactionDurations(replaceTransactions)
+ requestWholeTranscriptRead(large.files[0]!)
+ const replaceStarted = performance.now()
+ await parseAgentSessionFileCached(
+ await sessionCandidate('claude', large.files[0]!),
+ process.platform,
+ stats
+ )
+ const replaceMs = performance.now() - replaceStarted
+ // Finishes whatever the scheduled drain has not reached, so the reclaim is
+ // priced rather than left half done under the next measurement.
+ const reclaimStarted = performance.now()
+ await store.purgeOlderThan(null)
+ const reclaimMs = performance.now() - reclaimStarted
+ restoreReplaceExec()
+ assert.deepEqual(errors, [])
+ assertIndexedMessages(store.connection, large.messageCount)
+ replaceTransactions.sort((a, b) => a - b)
+
+ console.log(
+ JSON.stringify(
+ {
+ phase: 'single-large-file',
+ transcriptMb: Math.round((large.transcriptBytes / (1024 * 1024)) * 100) / 100,
+ indexMs: Math.round(indexMs),
+ transactions: transactions.length,
+ maxTransactionMs: Math.round(transactions.at(-1) ?? 0),
+ replaceMs: Math.round(replaceMs),
+ replaceTransactions: replaceTransactions.length,
+ maxReplaceTransactionMs: Math.round(replaceTransactions.at(-1) ?? 0),
+ reclaimMs: Math.round(reclaimMs),
+ indexMb:
+ Math.round(
+ ((await checkpointedFileBytes(store.connection, largeIndexPath)) / (1024 * 1024)) *
+ 100
+ ) / 100
+ },
+ null,
+ 2
+ )
+ )
+ } finally {
+ restoreExec()
+ unregister()
+ resetTranscriptConsumersForTests()
+ resetSessionParseCacheForTests()
+ store.close()
+ }
+} finally {
+ await rm(large.root, { recursive: true, force: true })
+}
diff --git a/src/main/ai-vault-search/session-search-content-hash.test.ts b/src/main/ai-vault-search/session-search-content-hash.test.ts
new file mode 100644
index 00000000000..1e7232fc855
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-content-hash.test.ts
@@ -0,0 +1,48 @@
+import { expect, it } from 'vitest'
+import {
+ EMPTY_CONTENT_HASH,
+ foldContentHash,
+ isCollapsibleContentHash
+} from './session-search-content-hash'
+import { userMessages } from './session-search-index-test-fixture'
+
+it('reaches the same digest whether the prefix arrives whole or in two appends', () => {
+ const messages = userMessages('turn', 5)
+ const whole = foldContentHash(EMPTY_CONTENT_HASH, messages)
+ const resumed = foldContentHash(
+ foldContentHash(EMPTY_CONTENT_HASH, messages.slice(0, 2)),
+ messages.slice(2)
+ )
+
+ expect(resumed).toEqual(whole)
+ expect(whole.count).toBe(5)
+})
+
+it('freezes once the prefix limit is reached so later appends cannot move it', () => {
+ // Found rather than imported: the limit is the module's business, and a test
+ // that reads it off the export cannot notice the fold ignoring it.
+ const capped = foldContentHash(EMPTY_CONTENT_HASH, userMessages('turn', 64))
+ expect(capped.count).toBeLessThan(64)
+ expect(foldContentHash(capped, userMessages('later', 20))).toEqual(capped)
+})
+
+it('separates two conversations that share an opening prompt', () => {
+ const shared = userMessages('same opening', 1)
+ const first = foldContentHash(EMPTY_CONTENT_HASH, [
+ ...shared,
+ { role: 'user', text: 'left', timestamp: null }
+ ])
+ const second = foldContentHash(EMPTY_CONTENT_HASH, [
+ ...shared,
+ { role: 'user', text: 'right', timestamp: null }
+ ])
+ expect(first.hash).not.toBe(second.hash)
+})
+
+it('refuses to collapse on a prefix too short to mean anything', () => {
+ const one = foldContentHash(EMPTY_CONTENT_HASH, userMessages('only turn', 1))
+ expect(isCollapsibleContentHash(one.hash, one.count)).toBe(false)
+ const two = foldContentHash(EMPTY_CONTENT_HASH, userMessages('two turns', 2))
+ expect(isCollapsibleContentHash(two.hash, two.count)).toBe(true)
+ expect(isCollapsibleContentHash(null, 9)).toBe(false)
+})
diff --git a/src/main/ai-vault-search/session-search-content-hash.ts b/src/main/ai-vault-search/session-search-content-hash.ts
new file mode 100644
index 00000000000..a9d2ab229da
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-content-hash.ts
@@ -0,0 +1,45 @@
+import { createHash } from 'node:crypto'
+import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers'
+
+// Why: Claude `--resume` and Codex fork copy the parent transcript into a new
+// file under a new session id, so one conversation lands N times in results.
+// The shared opening prefix is what identifies the copy; the tail diverges.
+const CONTENT_HASH_MESSAGE_LIMIT = 8
+// One shared opening prompt is not evidence of a fork; two turns is.
+const CONTENT_HASH_MIN_MESSAGES = 2
+
+export type SessionContentHash = { hash: string | null; count: number }
+
+export const EMPTY_CONTENT_HASH: SessionContentHash = { hash: null, count: 0 }
+
+/**
+ * Chained digest over the first `CONTENT_HASH_MESSAGE_LIMIT` messages. Chaining
+ * (rather than hashing one joined string) makes it resumable, so an `append`
+ * can finish a prefix a short `replace` started; once the limit is reached the
+ * value is frozen and later appends leave it untouched.
+ */
+export function foldContentHash(
+ previous: SessionContentHash,
+ messages: readonly TranscriptMessage[]
+): SessionContentHash {
+ let { hash, count } = previous
+ for (const message of messages) {
+ if (count >= CONTENT_HASH_MESSAGE_LIMIT) {
+ break
+ }
+ hash = createHash('sha256')
+ .update(hash ?? '')
+ .update('\0')
+ .update(message.role)
+ .update('\0')
+ .update(message.text)
+ .digest('hex')
+ count += 1
+ }
+ return { hash, count }
+}
+
+/** Sessions collapse only on a hash that covers enough turns to mean anything. */
+export function isCollapsibleContentHash(hash: string | null, count: number): hash is string {
+ return hash !== null && count >= CONTENT_HASH_MIN_MESSAGES
+}
diff --git a/src/main/ai-vault-search/session-search-cwd-key.test.ts b/src/main/ai-vault-search/session-search-cwd-key.test.ts
new file mode 100644
index 00000000000..24d915eedc9
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-cwd-key.test.ts
@@ -0,0 +1,37 @@
+import { expect, it } from 'vitest'
+import { folderGroupKey } from '../../shared/ai-vault-session-filters'
+import { cwdKey } from './session-search-file-records'
+
+// The sidebar groups sessions by `folderGroupKey`, which is the shared
+// normalizer under a `folder:` prefix. A hit's `cwd_key` has to be the same
+// string, or joining an indexed hit to a sidebar group returns nothing.
+const CASES: [name: string, cwd: string][] = [
+ ['a POSIX path', '/repo/app'],
+ ['a trailing slash', '/repo/app/'],
+ ['a Windows drive', 'C:\\Users\\me\\repo'],
+ ['a WSL interop mount', '/mnt/c/Users/me/repo'],
+ ['a WSL UNC path', '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'],
+ ['the wsl$ alias for the same path', '//wsl$/Ubuntu/home/me/repo'],
+ ['a Linux path from inside WSL', '/home/me/repo'],
+ ['the filesystem root', '/']
+]
+
+it.each(CASES)('keys %s exactly as the sidebar does', (_name, cwd) => {
+ expect(`folder:${cwdKey(cwd)}`).toBe(folderGroupKey(cwd))
+})
+
+it('keeps the root as a path rather than collapsing it to nothing', () => {
+ // An empty key is indistinguishable from "no cwd", and the scope filter builds
+ // its child prefix as `key + '/'`, which would be `//` for an empty key.
+ expect(cwdKey('/')).toBe('/')
+})
+
+it('has no key for a session whose cwd the transcript never recorded', () => {
+ expect(cwdKey(null)).toBeNull()
+})
+
+it('folds the two WSL UNC aliases onto one key', () => {
+ expect(cwdKey('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).toBe(
+ cwdKey('//wsl$/ubuntu/home/me/repo')
+ )
+})
diff --git a/src/main/ai-vault-search/session-search-file-cursor.ts b/src/main/ai-vault-search/session-search-file-cursor.ts
new file mode 100644
index 00000000000..2ba978b00ae
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-file-cursor.ts
@@ -0,0 +1,41 @@
+import type { FileWithMtime } from '../ai-vault/session-scanner-types'
+
+// Why the index keeps its own cursor: the parse cache's cursor answers "what
+// does the session list already show", which is a different question from "what
+// bytes of this file are already rows". They diverge the moment either side
+// declines a read, so neither may consult the other.
+
+/** Filesystem identity, when discovery could prove it. */
+export type SessionSearchFileIdentity = { dev: number; ino: number } | null
+
+/**
+ * What the index holds for one transcript.
+ *
+ * A null `byteOffset` is a file the index holds rows for and cannot continue:
+ * a chunked read committed a prefix, and the reader only hands out an offset
+ * when a read finishes. Null rather than a flag because every caller that does
+ * arithmetic on the offset then has to say what it means here, at compile time,
+ * instead of ignoring a boolean it did not know to read.
+ */
+export type SessionSearchIndexedFile = {
+ byteOffset: number | null
+ mtimeMs: number
+ sizeBytes: number | null
+}
+
+/**
+ * Whether this file has to be read from the start, whatever its stat says.
+ *
+ * The mtime and size are the real ones, so a freshness check that compares only
+ * those would call a half-written file current and never re-read it. Every such
+ * check must start here.
+ */
+export function requiresWholeRead(indexed: SessionSearchIndexedFile | null): boolean {
+ return indexed !== null && indexed.byteOffset === null
+}
+
+export function fileIdentity(file: FileWithMtime): SessionSearchFileIdentity {
+ return typeof file.dev === 'number' && typeof file.ino === 'number'
+ ? { dev: file.dev, ino: file.ino }
+ : null
+}
diff --git a/src/main/ai-vault-search/session-search-file-records.ts b/src/main/ai-vault-search/session-search-file-records.ts
new file mode 100644
index 00000000000..2ee79cbdc13
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-file-records.ts
@@ -0,0 +1,134 @@
+import { fileIdentity } from './session-search-file-cursor'
+import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers'
+import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
+import type SyncDatabase from '../sqlite/sync-database'
+import { EMPTY_CONTENT_HASH, type SessionContentHash } from './session-search-content-hash'
+import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
+
+/**
+ * The stored comparison key for a session's working directory.
+ *
+ * Why the shared normalizer verbatim: the sidebar already groups sessions by
+ * `folderGroupKey`, which is this function under a prefix. A second spelling
+ * here means any later join between an indexed hit and a sidebar group returns
+ * nothing. An earlier version qualified a WSL cwd with its distro so two
+ * distros could not collide at `/home/me/repo`; that is a real collision, but it
+ * is one every SSH host has too, neither key qualifies for SSH, and the fix for
+ * it is a column that names the execution host, not a path key that only some
+ * hosts spell differently.
+ */
+export function cwdKey(cwd: string | null): string | null {
+ return cwd ? normalizeRuntimePathForComparison(cwd) : null
+}
+
+export class SessionSearchFileRecords {
+ constructor(private readonly db: SyncDatabase) {}
+ /**
+ * The row a read hangs its messages off, before the parser has said what the
+ * session is. The same transaction fills it in: from the decoded session when
+ * the read finished, and from `updateProvisionalSession` when this is a chunk
+ * of one that has not.
+ */
+ createSessionRow(candidate: SessionFileCandidate): number {
+ return Number(
+ this.db
+ .prepare(
+ `INSERT INTO sessions(agent,session_id,file_path,title,resume_command)
+ VALUES (?,'',?,'','')`
+ )
+ .run(candidate.agent, candidate.file.path).lastInsertRowid
+ )
+ }
+
+ /**
+ * Writes what the parser knows so far onto a session a chunk is committing.
+ *
+ * Rows a chunk commits answer searches the moment they land, so the session
+ * they hang off has to be nameable before the read producing it ends — and it
+ * may never end, because a crash between chunks leaves exactly this row. That
+ * is why the identity is required rather than optional: a read that has none
+ * does not chunk at all. The final commit overwrites all of it from the
+ * decoded session; until then the title in particular is provisional.
+ */
+ updateProvisionalSession(rowId: number, identity: TranscriptSessionIdentity): void {
+ this.db
+ .prepare(
+ `UPDATE sessions SET session_id = ?, title = ?, cwd = ?, cwd_key = ?,
+ created_at = ?, updated_at = ? WHERE id = ?`
+ )
+ .run(
+ identity.sessionId,
+ identity.title ?? '',
+ identity.cwd,
+ cwdKey(identity.cwd),
+ identity.createdAt,
+ identity.updatedAt,
+ rowId
+ )
+ }
+
+ contentHash(rowId: number): SessionContentHash {
+ const row = this.db
+ .prepare('SELECT content_hash, content_hash_count FROM sessions WHERE id = ?')
+ .get(rowId) as { content_hash: string | null; content_hash_count: number } | undefined
+ return row ? { hash: row.content_hash, count: row.content_hash_count } : EMPTY_CONTENT_HASH
+ }
+
+ updateSession(session: AiVaultSession, rowId: number, contentHash: SessionContentHash): void {
+ const values = [
+ session.agent,
+ session.sessionId,
+ session.filePath,
+ session.codexHome,
+ session.title,
+ session.cwd,
+ cwdKey(session.cwd),
+ session.branch,
+ session.createdAt,
+ session.updatedAt,
+ session.messageCount,
+ session.resumeCommand,
+ contentHash.hash,
+ contentHash.count
+ ]
+ this.db
+ .prepare(
+ `UPDATE sessions SET agent = ?, session_id = ?, file_path = ?, codex_home = ?, title = ?,
+ cwd = ?, cwd_key = ?, branch = ?, created_at = ?, updated_at = ?, message_count = ?, resume_command = ?,
+ content_hash = ?, content_hash_count = ? WHERE id = ?`
+ )
+ .run(...values, rowId)
+ }
+
+ upsertFile(
+ candidate: SessionFileCandidate,
+ byteOffset: number,
+ sessionRowId: number | null
+ ): void {
+ const { file } = candidate
+ const identity = fileIdentity(file)
+ this.db
+ .prepare(
+ `INSERT INTO files(path, dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(path) DO UPDATE SET
+ -- Partial observations must never create a pair that no stat proved.
+ dev = CASE WHEN excluded.dev IS NOT NULL AND excluded.ino IS NOT NULL
+ THEN excluded.dev ELSE files.dev END,
+ ino = CASE WHEN excluded.dev IS NOT NULL AND excluded.ino IS NOT NULL
+ THEN excluded.ino ELSE files.ino END,
+ byte_offset = excluded.byte_offset, mtime_ms = excluded.mtime_ms,
+ size_bytes = excluded.size_bytes, session_row_id = excluded.session_row_id`
+ )
+ .run(
+ file.path,
+ identity?.dev ?? null,
+ identity?.ino ?? null,
+ byteOffset,
+ file.mtimeMs,
+ file.sizeBytes ?? null,
+ sessionRowId
+ )
+ }
+}
diff --git a/src/main/ai-vault-search/session-search-file-write.test.ts b/src/main/ai-vault-search/session-search-file-write.test.ts
new file mode 100644
index 00000000000..942565e18d6
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-file-write.test.ts
@@ -0,0 +1,614 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
+import SyncDatabase from '../sqlite/sync-database'
+import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
+import { cwdKey } from './session-search-file-records'
+import { requiresWholeRead } from './session-search-file-cursor'
+import { SessionSearchIndexWriter } from './session-search-index-writer'
+import { deleteExpiredSearchFiles } from './session-search-retention-delete'
+import {
+ openSessionSearchIndexFile,
+ replayTranscriptRead,
+ syntheticCandidate,
+ syntheticSession,
+ SYNTHETIC_TRANSCRIPT,
+ userMessages,
+ type SessionSearchIndexFile
+} from './session-search-index-test-fixture'
+import { SessionSearchStore } from './session-search-store'
+
+// Every assertion here reads through `index.db`, a second connection to the same
+// file. That is the whole consistency model: one transaction per file in WAL
+// mode, so another handle sees the last committed state and never a session part
+// way through being rewritten.
+
+let index: SessionSearchIndexFile
+let store: SessionSearchStore
+let errors: unknown[]
+
+beforeEach(async () => {
+ index = await openSessionSearchIndexFile('ss-file-write')
+ errors = []
+ store = new SessionSearchStore(index.path, (error) => errors.push(error))
+ registerSessionSearchIndexConsumer(store)
+})
+
+afterEach(async () => {
+ vi.restoreAllMocks()
+ resetTranscriptConsumersForTests()
+ store.close()
+ await index.close()
+})
+
+function matches(db: SyncDatabase, table: string, term: string): number {
+ return (
+ db
+ .prepare(
+ `SELECT count(*) AS n FROM ${table} JOIN messages m ON m.id = ${table}.rowid
+ JOIN sessions s ON s.id = m.session_row_id WHERE ${table} MATCH ?`
+ )
+ .get(term) as { n: number }
+ ).n
+}
+
+/** Fails the nth statement matching `pick`, wherever the writer prepares it. */
+function failOnStatement(pick: (sql: string) => boolean, nth: number): void {
+ const prepare = SyncDatabase.prototype.prepare
+ let seen = 0
+ vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function (
+ this: SyncDatabase,
+ sql: string
+ ) {
+ if (pick(sql) && ++seen === nth) {
+ throw new Error('index write crashed mid transaction')
+ }
+ return prepare.call(this, sql)
+ })
+}
+
+function counts(db: SyncDatabase): Record {
+ const one = (sql: string): number => (db.prepare(sql).get() as { n: number }).n
+ return {
+ sessions: one('SELECT count(*) AS n FROM sessions'),
+ messages: one('SELECT count(*) AS n FROM messages'),
+ files: one('SELECT count(*) AS n FROM files'),
+ full: one('SELECT count(*) AS n FROM messages_fts')
+ }
+}
+
+it('writes a whole read in one transaction', () => {
+ replayTranscriptRead({ messages: userMessages('needle text', 300) })
+
+ const after = counts(index.db)
+ expect(after.sessions).toBe(1)
+ expect(after.messages).toBe(300)
+ expect(after.full).toBe(300)
+ expect(errors).toEqual([])
+})
+
+it('files every row in one FTS table, under the column its role owns', () => {
+ replayTranscriptRead({
+ messages: [
+ { role: 'user', text: 'alpha question', timestamp: null },
+ { role: 'assistant', text: 'beta answer', timestamp: null },
+ { role: 'tool', text: 'gamma tool output', timestamp: null }
+ ]
+ })
+
+ // One table carries all three; the conversation scope is a column filter over
+ // it, which is what the second table used to be.
+ expect(counts(index.db).full).toBe(3)
+ expect(matches(index.db, 'messages_fts', 'gamma')).toBe(1)
+ expect(matches(index.db, 'messages_fts', '{user_text assistant_text}: gamma')).toBe(0)
+ expect(matches(index.db, 'messages_fts', '{user_text assistant_text}: beta')).toBe(1)
+})
+
+it('leaves the index exactly as it found it when a read never finishes', () => {
+ const write = store.beginWrite(syntheticCandidate(), 'replace', 0)!
+ for (const message of userMessages('neverfinished', 200)) {
+ write.add(message)
+ }
+ // The process dies here: the rows only ever existed in this buffer.
+ expect(counts(index.db)).toMatchObject({
+ sessions: 0,
+ messages: 0,
+ files: 0
+ })
+})
+
+it('rolls a whole file back when a write throws part way through its transaction', () => {
+ replayTranscriptRead({
+ messages: userMessages('firstgeneration', 3),
+ outcome: { byteOffset: 40 }
+ })
+ const before = counts(index.db)
+
+ failOnStatement((sql) => sql.startsWith('INSERT INTO messages('), 50)
+ replayTranscriptRead({
+ messages: userMessages('crashedgeneration', 100),
+ outcome: { byteOffset: 900 }
+ })
+ vi.restoreAllMocks()
+
+ // Not one of the 49 rows that were already inserted survived, the previous
+ // generation is untouched, and the cursor still describes what is really here.
+ expect(counts(index.db)).toEqual(before)
+ expect(matches(index.db, 'messages_fts', 'crashedgeneration')).toBe(0)
+ expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3)
+ expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40)
+ expect(errors).toHaveLength(1)
+ // The file is owed a re-read, which is the only reason anything was lost.
+ expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT])
+
+ // And the connection is usable again: a transaction left open by the failure
+ // would take down every write after it, not just the one that threw.
+ replayTranscriptRead({
+ messages: userMessages('afterthecrash', 2),
+ outcome: { byteOffset: 900 }
+ })
+ expect(matches(index.db, 'messages_fts', 'afterthecrash')).toBe(2)
+ expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(900)
+})
+
+it('takes the rows back when recording the cursor is what fails', () => {
+ replayTranscriptRead({
+ messages: userMessages('firstgeneration', 3),
+ outcome: { byteOffset: 40 }
+ })
+
+ // The cursor is written last, so this is the crash point that would leave rows
+ // no cursor describes: a later append would continue from an offset those rows
+ // already cover, and index the same span twice.
+ failOnStatement((sql) => sql.startsWith('INSERT INTO files('), 1)
+ replayTranscriptRead({
+ messages: userMessages('crashedgeneration', 5),
+ outcome: { byteOffset: 900 }
+ })
+ vi.restoreAllMocks()
+
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3 })
+ expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3)
+ expect(matches(index.db, 'messages_fts', 'crashedgeneration')).toBe(0)
+ expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40)
+})
+
+it('shows a reader on another handle one generation or the other, never a mixture', async () => {
+ replayTranscriptRead({
+ messages: userMessages('firstgeneration', 3),
+ outcome: { byteOffset: 40 }
+ })
+ expect(counts(index.db).messages).toBe(3)
+
+ const write = store.beginWrite(syntheticCandidate(), 'replace', 0)!
+ for (const message of userMessages('secondgeneration', 7)) {
+ write.add(message)
+ // Every point at which the other handle could issue a query mid-read.
+ expect(counts(index.db).messages).toBe(3)
+ expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(0)
+ }
+ expect(
+ write.commit({
+ session: syntheticSession(),
+ byteOffset: 900,
+ incomplete: false
+ })
+ ).toBe(true)
+
+ expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
+ expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(7)
+ // The old three are cut loose, not deleted, so they are still on disk and
+ // already unreachable; the drain the store scheduled hands them back.
+ expect(counts(index.db).messages).toBe(10)
+ await vi.waitFor(() => {
+ expect(counts(index.db).messages).toBe(7)
+ })
+})
+
+// Four of these fill the 400-char ceiling the two tests below construct.
+const CHUNKED_MESSAGE = `chunkedneedle ${'filler '.repeat(12)}nd`
+
+const PROVISIONAL_IDENTITY = {
+ sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
+ cwd: '/repo/app',
+ title: 'provisional title',
+ createdAt: '2026-05-01T10:00:00.000Z',
+ updatedAt: '2026-05-01T10:05:00.000Z'
+}
+
+// Only a read that can name its session chunks at all, so every test below that
+// wants a chunk has to supply one.
+const named = (): typeof PROVISIONAL_IDENTITY => PROVISIONAL_IDENTITY
+
+it('leaves the session consistent after every chunk of a file too large for one transaction', () => {
+ expect(CHUNKED_MESSAGE.length).toBe(100)
+ const writer = new SessionSearchIndexWriter(index.db, 400)
+ const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
+ for (const [position, message] of userMessages(CHUNKED_MESSAGE, 10).entries()) {
+ write.add(message)
+ const rows = counts(index.db).messages
+ // Four messages per chunk, and nothing else reaches the file between them.
+ expect(rows).toBe(Math.floor((position + 1) / 4) * 4)
+ // Whatever landed is a coherent prefix of this session and answers searches.
+ expect(matches(index.db, 'messages_fts', 'chunkedneedle')).toBe(rows)
+ if (rows > 0) {
+ // The cursor a chunk leaves refuses every append rather than inventing an
+ // offset the reader never gave it.
+ expect(requiresWholeRead(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null))).toBe(true)
+ expect(writer.beginWrite(syntheticCandidate(), 'append', 0)).toBeNull()
+ }
+ }
+ expect(counts(index.db).messages).toBe(8)
+
+ expect(
+ write.commit({
+ session: syntheticSession(),
+ byteOffset: 4096,
+ incomplete: false
+ })
+ ).toBe(true)
+ expect(counts(index.db)).toMatchObject({
+ sessions: 1,
+ messages: 10,
+ full: 10
+ })
+ expect(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(4096)
+})
+
+it('holds the ceiling against a single message larger than it', () => {
+ const writer = new SessionSearchIndexWriter(index.db, 8000)
+ const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
+ const exec = SyncDatabase.prototype.exec
+ let opened = 0
+ vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function (
+ this: SyncDatabase,
+ sql: string
+ ) {
+ if (sql === 'BEGIN IMMEDIATE') {
+ opened += 1
+ }
+ exec.call(this, sql)
+ })
+
+ // One conversation turn, three times the ceiling. Checked once per message,
+ // this commits all 24,000 characters in a single transaction — the ceiling
+ // bounds nothing that a message can exceed on its own.
+ write.add({ role: 'assistant', text: 'a'.repeat(24_000), timestamp: null })
+ vi.restoreAllMocks()
+
+ expect(opened).toBe(3)
+ expect(counts(index.db).messages).toBe(3)
+ expect(
+ write.commit({
+ session: syntheticSession(),
+ byteOffset: 4096,
+ incomplete: false
+ })
+ ).toBe(true)
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3 })
+})
+
+it('names a session on its first chunk, not only when the read ends', () => {
+ const writer = new SessionSearchIndexWriter(index.db, 400)
+ const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
+ for (const message of userMessages(CHUNKED_MESSAGE, 10)) {
+ write.add(message)
+ }
+
+ // The chunks that landed already answer searches, so the session they hang
+ // off has to be nameable on another handle before the read ends. This is also
+ // the whole record a crash between chunks leaves behind.
+ expect(counts(index.db).messages).toBe(8)
+ expect(
+ index.db.prepare('SELECT session_id, cwd, cwd_key, title, created_at FROM sessions').get()
+ ).toEqual({
+ session_id: PROVISIONAL_IDENTITY.sessionId,
+ cwd: '/repo/app',
+ cwd_key: cwdKey('/repo/app'),
+ title: 'provisional title',
+ created_at: '2026-05-01T10:00:00.000Z'
+ })
+
+ // And the decoded session still wins at the end: the mid-read title is
+ // provisional, never a value the final commit has to defer to.
+ expect(
+ write.commit({
+ session: syntheticSession({ title: 'the settled title' }),
+ byteOffset: 4096,
+ incomplete: false
+ })
+ ).toBe(true)
+ expect(index.db.prepare('SELECT title FROM sessions').get()).toEqual({
+ title: 'the settled title'
+ })
+})
+
+it('commits a whole-file read over the ceiling in one transaction, never a chunk', () => {
+ // The whole-file readers (Grok, Cursor, Gemini, OpenCode) pass no identity:
+ // their formats are rewritten in place and have no resumable state to ask.
+ const writer = new SessionSearchIndexWriter(index.db, 400)
+ const write = writer.beginWrite(syntheticCandidate(), 'replace', 0)!
+ const exec = SyncDatabase.prototype.exec
+ let opened = 0
+ vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function (
+ this: SyncDatabase,
+ sql: string
+ ) {
+ if (sql === 'BEGIN IMMEDIATE') {
+ opened += 1
+ }
+ exec.call(this, sql)
+ })
+
+ for (const message of userMessages(CHUNKED_MESSAGE, 10)) {
+ write.add(message)
+ // Chunking here would publish rows under a session with an empty id, an
+ // empty title and a null cwd, and an interrupted read would leave that
+ // prefix answering searches for good.
+ expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0 })
+ }
+ expect(write.commit({ session: syntheticSession(), byteOffset: 4096, incomplete: false })).toBe(
+ true
+ )
+ vi.restoreAllMocks()
+
+ expect(opened).toBe(1)
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 10, full: 10 })
+ // And a real cursor, not the partial sentinel a chunk would have left.
+ expect(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(4096)
+})
+
+it('starts chunking only once the parser has an id to name the session with', () => {
+ const writer = new SessionSearchIndexWriter(index.db, 400)
+ let decoded: typeof PROVISIONAL_IDENTITY | null = null
+ const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, () => decoded)!
+ for (const message of userMessages(CHUNKED_MESSAGE, 4)) {
+ write.add(message)
+ }
+ // Past the ceiling, but the parser has decoded nothing: the buffer keeps
+ // growing rather than naming a session it cannot name.
+ expect(counts(index.db).messages).toBe(0)
+
+ decoded = PROVISIONAL_IDENTITY
+ write.add(userMessages(CHUNKED_MESSAGE, 1)[0]!)
+
+ // Everything held goes with the first chunk that can say what it is.
+ expect(counts(index.db).messages).toBe(5)
+ expect(index.db.prepare('SELECT session_id, cwd FROM sessions').get()).toEqual({
+ session_id: PROVISIONAL_IDENTITY.sessionId,
+ cwd: '/repo/app'
+ })
+})
+
+it('reports a chunk-partial file as held, and as one that must be read whole', () => {
+ const writer = new SessionSearchIndexWriter(index.db, 400)
+ const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
+ for (const message of userMessages(CHUNKED_MESSAGE, 10)) {
+ write.add(message)
+ }
+
+ // Held, with no cursor to continue. Reporting nothing here reads as "never
+ // indexed", so a caller asks for whatever read the parse cache offers, the
+ // reader picks append, and only a decline heals it a cycle later.
+ const held = writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)
+ expect(held).not.toBeNull()
+ expect(held?.byteOffset).toBeNull()
+ expect(requiresWholeRead(held)).toBe(true)
+ expect(held?.mtimeMs).toBe(syntheticCandidate().file.mtimeMs)
+
+ // A file this index has never seen is still the other answer, so the two
+ // states a caller has to tell apart are distinguishable.
+ expect(writer.indexedFile('/never-seen.jsonl', null)).toBeNull()
+ expect(requiresWholeRead(null)).toBe(false)
+
+ // And no offset continues it, including the one the chunk recorded.
+ for (const offset of [0, -1, 400, 1000]) {
+ expect(writer.beginWrite(syntheticCandidate(), 'append', offset)).toBeNull()
+ }
+})
+
+it('re-reads a chunked file whole when its writer died between chunks', async () => {
+ const writer = new SessionSearchIndexWriter(index.db, 400)
+ const abandoned = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
+ for (const message of userMessages(CHUNKED_MESSAGE, 10)) {
+ abandoned.add(message)
+ }
+ expect(counts(index.db).messages).toBe(8)
+
+ // Nothing can continue that prefix, so the only way forward is a whole re-read,
+ // and that replaces every row the dead writer left.
+ expect(requiresWholeRead(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null))).toBe(true)
+ const replacement = writer.beginWrite(syntheticCandidate(), 'replace', 0)!
+ replacement.add(userMessages('wholereread', 1)[0]!)
+ expect(
+ replacement.commit({
+ session: syntheticSession(),
+ byteOffset: 4096,
+ incomplete: false
+ })
+ ).toBe(true)
+ // The eight stranded rows stop answering the moment the replace commits, and
+ // the drain hands them back after it rather than inside it.
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 9 })
+ expect(matches(index.db, 'messages_fts', 'chunkedneedle')).toBe(0)
+ await deleteExpiredSearchFiles(index.db, null, () => false)
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 1 })
+})
+
+it('stops a chunked read whose file was removed between its chunks', () => {
+ const writer = new SessionSearchIndexWriter(index.db, 400)
+ const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
+ const messages = userMessages(CHUNKED_MESSAGE, 10)
+ for (const message of messages.slice(0, 4)) {
+ write.add(message)
+ }
+ expect(counts(index.db).messages).toBe(4)
+
+ writer.removeFile(SYNTHETIC_TRANSCRIPT)
+ const exec = SyncDatabase.prototype.exec
+ let opened = 0
+ vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function (
+ this: SyncDatabase,
+ sql: string
+ ) {
+ if (sql === 'BEGIN IMMEDIATE') {
+ opened += 1
+ }
+ exec.call(this, sql)
+ })
+ for (const message of messages.slice(4)) {
+ write.add(message)
+ }
+ expect(write.commit({ session: syntheticSession(), byteOffset: 4096, incomplete: false })).toBe(
+ false
+ )
+ vi.restoreAllMocks()
+
+ // Not one row of the removed source came back. The read stopped at the first
+ // refusal rather than reopening a transaction it already knows will roll back,
+ // once for every message left in a file that may be a hundred megabytes.
+ expect(opened).toBe(1)
+ expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0, full: 0 })
+})
+
+it('fences a first-ever read whose file was removed before it committed', () => {
+ const candidate = syntheticCandidate({ path: '/never-indexed.jsonl' })
+ const write = store.beginWrite(candidate, 'replace', 0)!
+ for (const message of userMessages('removedbeforefirstcommit', 3)) {
+ write.add(message)
+ }
+ // The path was never indexed, so there is no cursor for the removal to move.
+ // PR 3's retirement sweep removes exactly these: paths the index deferred over
+ // budget and never wrote, while the registered consumer is fed concurrently.
+ store.removeFile('/never-indexed.jsonl')
+
+ expect(write.commit({ session: syntheticSession(), byteOffset: 300, incomplete: false })).toBe(
+ false
+ )
+ expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0, full: 0 })
+})
+
+it('replaces the previous generation without ever showing both', async () => {
+ replayTranscriptRead({ messages: userMessages('firstgeneration', 10) })
+ replayTranscriptRead({ messages: userMessages('secondgeneration', 10) })
+
+ expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
+ expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(10)
+ await vi.waitFor(() => {
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 10, full: 10 })
+ })
+})
+
+it('replaces a generation by cutting the old one loose, not by deleting it inline', async () => {
+ const writer = new SessionSearchIndexWriter(index.db)
+ const first = writer.beginWrite(syntheticCandidate(), 'replace', 0)!
+ for (const message of userMessages('firstgeneration', 200)) {
+ first.add(message)
+ }
+ expect(first.commit({ session: syntheticSession(), byteOffset: 100, incomplete: false })).toBe(
+ true
+ )
+ const before = index.db.prepare('SELECT id FROM sessions').get() as { id: number }
+ expect(counts(index.db).messages).toBe(200)
+
+ const second = writer.beginWrite(syntheticCandidate(), 'replace', 0)!
+ for (const message of userMessages('secondgeneration', 3)) {
+ second.add(message)
+ }
+ expect(second.commit({ session: syntheticSession(), byteOffset: 200, incomplete: false })).toBe(
+ true
+ )
+
+ // The transaction inserted three rows and deleted one, rather than deleting
+ // two hundred: all 203 are still on disk, and the old 200 already answer
+ // nothing, because every retrieval joins `sessions`.
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 203, full: 203 })
+ expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
+ expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(3)
+
+ // A new session row, with `files` repointed at it in that same transaction.
+ // AUTOINCREMENT never hands the freed id back while orphans still name it.
+ const after = index.db.prepare('SELECT id FROM sessions').get() as { id: number }
+ expect(after.id).toBeGreaterThan(before.id)
+ expect(index.db.prepare('SELECT session_row_id FROM files').get()).toEqual({
+ session_row_id: after.id
+ })
+
+ await deleteExpiredSearchFiles(index.db, null, () => false)
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3, full: 3 })
+})
+
+it('drains what a replace cut loose without being asked', async () => {
+ replayTranscriptRead({ messages: userMessages('firstgeneration', 200) })
+ replayTranscriptRead({ messages: userMessages('secondgeneration', 3) })
+
+ // The store schedules the reclaim the way it schedules retention's. Hiding a
+ // generation and never reclaiming it would grow the file by every re-read.
+ expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
+ await vi.waitFor(() => {
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3, full: 3 })
+ })
+ expect(errors).toEqual([])
+})
+
+it('continues a session across an append rather than replaying it', () => {
+ replayTranscriptRead({
+ messages: userMessages('openingturn', 3),
+ outcome: { byteOffset: 40 }
+ })
+ replayTranscriptRead({
+ messages: userMessages('laterturn', 2),
+ mode: 'append',
+ previousByteOffset: 40,
+ outcome: { byteOffset: 90 }
+ })
+
+ expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 5 })
+ expect(matches(index.db, 'messages_fts', 'openingturn')).toBe(3)
+ expect(matches(index.db, 'messages_fts', 'laterturn')).toBe(2)
+ expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(90)
+})
+
+it('stops answering for a removed file the moment it is removed', () => {
+ replayTranscriptRead({ messages: userMessages('removedneedle', 3) })
+ store.removeFile(SYNTHETIC_TRANSCRIPT)
+
+ expect(counts(index.db)).toMatchObject({
+ sessions: 0,
+ messages: 0,
+ files: 0,
+ full: 0
+ })
+ expect(matches(index.db, 'messages_fts', 'removedneedle')).toBe(0)
+})
+
+it('writes nothing for an incomplete read and owes the file a whole re-read', () => {
+ replayTranscriptRead({
+ messages: userMessages('incompleteread', 300),
+ outcome: { incomplete: true }
+ })
+
+ expect(counts(index.db)).toMatchObject({
+ sessions: 0,
+ messages: 0,
+ files: 0,
+ full: 0
+ })
+ expect(store.pendingFileCount).toBe(1)
+ expect(errors).toEqual([])
+})
+
+it('exposes the handle a composed reader queries through', () => {
+ replayTranscriptRead({ messages: userMessages('composedreader', 3) })
+
+ // PR 4's engine reads through this rather than opening a second connection,
+ // so it sees a write the moment the transaction commits.
+ expect(store.connection.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ n: 3 })
+})
+
+it('closes twice without turning the second call into an error', () => {
+ store.close()
+ // node:sqlite throws ERR_INVALID_STATE on a second close of one handle, and a
+ // store is closed both by whoever owns it and by a teardown that cannot know.
+ expect(() => store.close()).not.toThrow()
+ store = new SessionSearchStore(index.path, (error) => errors.push(error))
+})
diff --git a/src/main/ai-vault-search/session-search-identifier-split.test.ts b/src/main/ai-vault-search/session-search-identifier-split.test.ts
new file mode 100644
index 00000000000..24f8a67d5c3
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-identifier-split.test.ts
@@ -0,0 +1,29 @@
+import { expect, it } from 'vitest'
+import { identifierShadowTerms, identifierShadowText } from './session-search-identifier-split'
+
+it('splits a camel-case symbol into its pieces and keeps the whole', () => {
+ expect(identifierShadowTerms('call resolveTerminalPath here')).toEqual([
+ 'resolveterminalpath',
+ 'resolve',
+ 'terminal',
+ 'path'
+ ])
+})
+
+it('splits a path into its segments and extension', () => {
+ // The whole path already tokenizes on its own; only the pieces need shadowing.
+ expect(identifierShadowText('src/main/foo-bar.ts')).toBe('src main foo bar ts')
+})
+
+it('leaves ordinary prose alone', () => {
+ expect(identifierShadowTerms('the quick brown fox')).toEqual([])
+})
+
+it('shadows a screaming-case constant', () => {
+ expect(identifierShadowTerms('MAX_RETRIES')).toEqual(['max', 'retries'])
+})
+
+it('stops at the term limit rather than growing with the message', () => {
+ const text = Array.from({ length: 50 }, (_unused, index) => `alpha_beta${index}`).join(' ')
+ expect(identifierShadowTerms(text, 10)).toHaveLength(10)
+})
diff --git a/src/main/ai-vault-search/session-search-identifier-split.ts b/src/main/ai-vault-search/session-search-identifier-split.ts
new file mode 100644
index 00000000000..e2df1822cfa
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-identifier-split.ts
@@ -0,0 +1,54 @@
+// Identifier shadow terms: `resolveTerminalPath` → `resolve terminal path`,
+// `src/main/foo-bar.ts` → `src main foo bar ts`. Stored in a separate FTS5
+// column so a partial identifier still matches; the largest single accuracy
+// win measured in the retrieval shoot-out (MRR 0.50 → 0.55).
+
+const RAW_TOKEN = /[A-Za-z0-9_./-]+/g
+const CAMEL_PIECE = /[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+/g
+const SEPARATOR = /[_./-]+/
+// Worth shadowing: has a separator, a camel boundary, or is SCREAMING_CASE.
+const INTERESTING = /[_./-]|[a-z0-9][A-Z]|^[A-Z]{2,}[0-9_]*$/
+const MIN_TOKEN = 3
+const MAX_TOKEN = 120
+const MIN_PIECE = 2
+
+function hasMixedCase(piece: string): boolean {
+ return /[a-z]/.test(piece) && /[A-Z]/.test(piece)
+}
+
+export function identifierShadowTerms(text: string, limit = 4000): string[] {
+ const out: string[] = []
+ const seen = new Set()
+ for (const match of text.matchAll(RAW_TOKEN)) {
+ const token = match[0]
+ if (token.length < MIN_TOKEN || token.length > MAX_TOKEN || !INTERESTING.test(token)) {
+ continue
+ }
+ const parts: string[] = []
+ for (const piece of token.split(SEPARATOR)) {
+ if (!piece) {
+ continue
+ }
+ parts.push(piece)
+ if (hasMixedCase(piece)) {
+ parts.push(...(piece.match(CAMEL_PIECE) ?? []))
+ }
+ }
+ for (const part of parts) {
+ const lowered = part.toLowerCase()
+ if (lowered.length < MIN_PIECE || seen.has(lowered)) {
+ continue
+ }
+ seen.add(lowered)
+ out.push(lowered)
+ if (out.length >= limit) {
+ return out
+ }
+ }
+ }
+ return out
+}
+
+export function identifierShadowText(text: string, limit?: number): string {
+ return identifierShadowTerms(text, limit).join(' ')
+}
diff --git a/src/main/ai-vault-search/session-search-index-consumer.test.ts b/src/main/ai-vault-search/session-search-index-consumer.test.ts
new file mode 100644
index 00000000000..ca02333cf0b
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-index-consumer.test.ts
@@ -0,0 +1,373 @@
+import { afterEach, beforeEach, expect, it } from 'vitest'
+import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
+import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
+import {
+ openSessionSearchIndexFile,
+ replayTranscriptRead,
+ syntheticCandidate,
+ syntheticSession,
+ SYNTHETIC_TRANSCRIPT,
+ userMessages,
+ type SessionSearchIndexFile
+} from './session-search-index-test-fixture'
+import { SessionSearchStore, STALE_PATH_LIMIT } from './session-search-store'
+
+let index: SessionSearchIndexFile
+let store: SessionSearchStore
+let errors: unknown[]
+
+beforeEach(async () => {
+ index = await openSessionSearchIndexFile('ss-index-consumer')
+ errors = []
+ store = new SessionSearchStore(index.path, (error) => errors.push(error))
+ registerSessionSearchIndexConsumer(store)
+})
+
+afterEach(async () => {
+ resetTranscriptConsumersForTests()
+ store.close()
+ await index.close()
+})
+
+function indexedMessages(): number {
+ return (
+ index.db.prepare('SELECT count(*) AS n FROM messages').get() as {
+ n: number
+ }
+ ).n
+}
+
+function cursor(): number | null | undefined {
+ return store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset
+}
+
+it('appends onto its own cursor and carries the content hash forward', async () => {
+ replayTranscriptRead({
+ messages: userMessages('first half', 3),
+ outcome: { byteOffset: 100 }
+ })
+ const first = index.db
+ .prepare('SELECT content_hash AS hash, content_hash_count AS count FROM sessions')
+ .get() as { hash: string; count: number }
+
+ replayTranscriptRead({
+ mode: 'append',
+ previousByteOffset: 100,
+ messages: userMessages('second half', 2),
+ outcome: { byteOffset: 220 }
+ })
+
+ expect(indexedMessages()).toBe(5)
+ expect(cursor()).toBe(220)
+ const second = index.db
+ .prepare('SELECT content_hash AS hash, content_hash_count AS count FROM sessions')
+ .get() as { hash: string; count: number }
+ expect(second.count).toBe(first.count + 2)
+ expect(second.hash).not.toBe(first.hash)
+ expect(store.takeStale()).toEqual([])
+})
+
+it('appends onto a file it read through and decoded no session from', async () => {
+ // An excluded Codex worker transcript: read through, nothing to index, and
+ // still growing. Its cursor is sound, so a re-read of the whole file every
+ // pass buys nothing.
+ replayTranscriptRead({
+ messages: userMessages('excluded span', 3),
+ outcome: { session: null, byteOffset: 100 }
+ })
+ expect(cursor()).toBe(100)
+ expect(store.takeStale()).toEqual([])
+
+ replayTranscriptRead({
+ mode: 'append',
+ previousByteOffset: 100,
+ messages: userMessages('decoded at last', 2),
+ outcome: { byteOffset: 220 }
+ })
+
+ expect(indexedMessages()).toBe(2)
+ expect(cursor()).toBe(220)
+ expect(store.takeStale()).toEqual([])
+})
+
+it('declines an append that starts past its own cursor and records the file', async () => {
+ replayTranscriptRead({
+ messages: userMessages('indexed span', 3),
+ outcome: { byteOffset: 100 }
+ })
+
+ // The session list read further than this index did, so the appended span
+ // continues from bytes the index never saw.
+ replayTranscriptRead({
+ mode: 'append',
+ previousByteOffset: 900,
+ messages: userMessages('unseen span', 4),
+ outcome: { byteOffset: 1200 }
+ })
+
+ expect(indexedMessages()).toBe(3)
+ expect(cursor()).toBe(100)
+ expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT])
+})
+
+it('declines a file whose identity changed under the same path', async () => {
+ const original = syntheticCandidate({ dev: 1, ino: 10 })
+ replayTranscriptRead({
+ candidate: original,
+ messages: userMessages('original file', 2),
+ outcome: { byteOffset: 100 }
+ })
+
+ replayTranscriptRead({
+ candidate: syntheticCandidate({ dev: 1, ino: 77 }),
+ mode: 'append',
+ previousByteOffset: 100,
+ messages: userMessages('replacement file', 2),
+ outcome: { byteOffset: 200 }
+ })
+
+ expect(indexedMessages()).toBe(2)
+ expect(store.takeStale()).toHaveLength(1)
+})
+
+it('never advances the cursor for an incomplete read', async () => {
+ replayTranscriptRead({
+ messages: userMessages('complete span', 3),
+ outcome: { byteOffset: 100 }
+ })
+
+ replayTranscriptRead({
+ mode: 'append',
+ previousByteOffset: 100,
+ messages: userMessages('partial span', 5),
+ outcome: { byteOffset: 400, incomplete: true }
+ })
+
+ expect(indexedMessages()).toBe(3)
+ expect(cursor()).toBe(100)
+ expect(
+ (
+ index.db.prepare('SELECT count(*) AS n FROM messages').get() as {
+ n: number
+ }
+ ).n
+ ).toBe(3)
+ expect(store.takeStale()).toHaveLength(1)
+})
+
+it('indexes nothing at all from a read that was incomplete from the start', async () => {
+ replayTranscriptRead({
+ messages: userMessages('unreachable', 4),
+ outcome: { byteOffset: 0, incomplete: true }
+ })
+
+ expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
+ n: 0
+ })
+ expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({
+ n: 0
+ })
+ expect(cursor()).toBeUndefined()
+})
+
+it('drops a file whose parser returned no session', async () => {
+ replayTranscriptRead({
+ messages: userMessages('was indexed', 3),
+ outcome: { byteOffset: 100 }
+ })
+
+ replayTranscriptRead({
+ messages: userMessages('now rejected', 2),
+ outcome: { session: null, byteOffset: 300 }
+ })
+
+ expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
+ n: 0
+ })
+ expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({
+ n: 0
+ })
+ // The file is still read through, so a later scan does not re-read it.
+ expect(cursor()).toBe(300)
+})
+
+it('writes nothing for a source whose parser cannot reach the channel', async () => {
+ // An OpenCode SQLite candidate decodes in a worker, so every read of it is
+ // incomplete, and no re-read would help.
+ const candidate = {
+ ...syntheticCandidate({ path: '/opencode/opencode.db#session-1' }),
+ agent: 'opencode' as const
+ }
+ replayTranscriptRead({
+ candidate,
+ messages: [],
+ outcome: { byteOffset: 0, incomplete: true }
+ })
+
+ expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
+ n: 0
+ })
+ expect(store.takeStale()).toEqual([])
+})
+
+it('ignores a candidate older than the retention cutoff', async () => {
+ store.setRetentionCutoffMs(Date.now())
+ replayTranscriptRead({ messages: userMessages('too old', 3) })
+
+ expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
+ n: 0
+ })
+ expect(store.takeStale()).toEqual([])
+})
+
+it('stops writing while the store refuses writes, but remembers what it skipped', async () => {
+ store.setAcceptingWrites(false)
+ replayTranscriptRead({ messages: userMessages('paused', 3) })
+
+ expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
+ n: 0
+ })
+ expect(errors).toEqual([])
+ // A pause is exactly the window in which every read is declined. Forgetting
+ // them would leave the whole paused span unindexed with nothing to replay it.
+ expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT])
+})
+
+it('keeps the paused re-read set when the retention window is reconfigured', async () => {
+ store.setAcceptingWrites(false)
+ replayTranscriptRead({ messages: userMessages('paused', 2) })
+ expect(store.pendingFileCount).toBe(1)
+
+ // The set records what still has to be read, not what is worth keeping. A
+ // window that now excludes this file is enforced where the re-read is
+ // dispatched, so nothing is written and the file leaves the set there.
+ store.setRetentionCutoffMs(Date.now())
+ expect(store.pendingFileCount).toBe(1)
+
+ store.setAcceptingWrites(true)
+ expect(store.takeStale()).toHaveLength(1)
+ replayTranscriptRead({ messages: userMessages('outside the window now', 2) })
+ expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
+ n: 0
+ })
+ expect(store.pendingFileCount).toBe(0)
+})
+
+it('drops the oldest record rather than growing without a bound, and says so', () => {
+ store.setAcceptingWrites(false)
+ for (let index = 0; index < STALE_PATH_LIMIT + 5; index++) {
+ store.markStale(syntheticCandidate({ path: `/transcript-${index}.jsonl` }))
+ }
+
+ expect(store.pendingFileCount).toBe(STALE_PATH_LIMIT)
+ expect(store.droppedPendingFileCount).toBe(5)
+ const kept = store.takeStale().map((candidate) => candidate.file.path)
+ expect(kept).not.toContain('/transcript-0.jsonl')
+ expect(kept).toContain(`/transcript-${STALE_PATH_LIMIT + 4}.jsonl`)
+})
+
+it('keeps the session list running when the index write fails', async () => {
+ replayTranscriptRead({
+ messages: userMessages('healthy', 2),
+ outcome: { byteOffset: 100 }
+ })
+ index.db.exec('DROP TABLE messages_fts')
+
+ expect(() =>
+ replayTranscriptRead({
+ mode: 'append',
+ previousByteOffset: 100,
+ messages: userMessages('broken', 400),
+ outcome: { byteOffset: 500 }
+ })
+ ).not.toThrow()
+ expect(errors.length).toBeGreaterThan(0)
+ expect(store.takeStale()).toHaveLength(1)
+})
+
+it('unregisters cleanly, leaving later reads unindexed', async () => {
+ resetTranscriptConsumersForTests()
+ replayTranscriptRead({ messages: userMessages('after unregister', 3) })
+
+ expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
+ n: 0
+ })
+})
+
+it('drops a removed source and keeps its cursor gone', async () => {
+ replayTranscriptRead({
+ messages: userMessages('present', 3),
+ outcome: { byteOffset: 100 }
+ })
+ store.removeFile(SYNTHETIC_TRANSCRIPT)
+
+ expect(cursor()).toBeUndefined()
+ expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
+ n: 0
+ })
+ expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({
+ n: 0
+ })
+})
+
+it('writes the session metadata the read decoded', async () => {
+ replayTranscriptRead({
+ messages: userMessages('metadata', 1),
+ outcome: {
+ session: syntheticSession({
+ sessionId: 'abc-123',
+ title: 'a titled session',
+ cwd: '/repo/app',
+ branch: 'main',
+ messageCount: 1,
+ resumeCommand: 'claude --resume abc-123'
+ }),
+ byteOffset: 42
+ }
+ })
+
+ expect(
+ index.db
+ .prepare('SELECT session_id, title, cwd, cwd_key, branch, resume_command FROM sessions')
+ .get()
+ ).toEqual({
+ session_id: 'abc-123',
+ title: 'a titled session',
+ cwd: '/repo/app',
+ cwd_key: '/repo/app',
+ branch: 'main',
+ resume_command: 'claude --resume abc-123'
+ })
+})
+
+it('keeps a proven file identity when a later read cannot stat it', async () => {
+ const withIdentity = syntheticCandidate({ dev: 1, ino: 10 })
+ replayTranscriptRead({
+ candidate: withIdentity,
+ messages: userMessages('first', 2),
+ outcome: { byteOffset: 100 }
+ })
+
+ // A host that cannot prove identity re-reads the same file.
+ replayTranscriptRead({
+ candidate: syntheticCandidate(),
+ mode: 'append',
+ previousByteOffset: 100,
+ messages: userMessages('second', 2),
+ outcome: { byteOffset: 200 }
+ })
+ expect(indexedMessages()).toBe(4)
+
+ // The stored identity survived, so a rename-replace is still detectable.
+ replayTranscriptRead({
+ candidate: syntheticCandidate({ dev: 1, ino: 99 }),
+ mode: 'append',
+ previousByteOffset: 200,
+ messages: userMessages('replacement', 2),
+ outcome: { byteOffset: 300 }
+ })
+
+ expect(indexedMessages()).toBe(4)
+ expect(cursor()).toBe(200)
+ expect(store.takeStale()).toHaveLength(1)
+})
diff --git a/src/main/ai-vault-search/session-search-index-consumer.ts b/src/main/ai-vault-search/session-search-index-consumer.ts
new file mode 100644
index 00000000000..e4fa6da4a8e
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-index-consumer.ts
@@ -0,0 +1,118 @@
+import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser'
+import {
+ registerTranscriptConsumer,
+ type TranscriptConsumer,
+ type TranscriptMessage,
+ type TranscriptReadConsumer,
+ type TranscriptReadOutcome,
+ type TranscriptReadStart
+} from '../ai-vault/session-transcript-consumers'
+import { fileIdentity } from './session-search-file-cursor'
+import type { SessionSearchFileWrite } from './session-search-index-writer'
+import type { SessionSearchStore } from './session-search-store'
+
+/**
+ * The search index as a consumer of the transcript reader.
+ *
+ * It keeps its own cursor in the `files` table and never consults the parse
+ * cache: the two answer different questions and diverge the moment either
+ * declines a read. Three refusals, each of which leaves the cursor where it
+ * was and records the file for a later whole re-read:
+ *
+ * - `beginRead` returns null when this index's cursor is behind the offset an
+ * `append` continues from, or when the file's identity changed.
+ * - a buffering failure stops the read's rows without failing the session list.
+ * - an `incomplete` outcome never commits; those rows are not the whole span.
+ */
+export class SessionSearchIndexConsumer implements TranscriptConsumer {
+ constructor(private readonly store: SessionSearchStore) {}
+
+ beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null {
+ const { candidate } = start
+ if (!this.store.acceptsCandidate(candidate)) {
+ // A pause is a reason not to write now, not a reason to forget the read.
+ // `markStale` applies the retention rule itself, so a candidate that is
+ // out of scope rather than merely paused is still dropped here.
+ this.store.markStale(candidate)
+ return null
+ }
+ // A parser that decodes where the channel cannot reach it reports every read
+ // as incomplete. Declining here is not the same as being behind: no re-read
+ // would help, so the file is not recorded either.
+ if (!parserPublishesMessages(candidate)) {
+ return null
+ }
+ if (start.mode === 'append') {
+ const cursor = this.store.indexedFile(candidate.file.path, fileIdentity(candidate.file))
+ if (!cursor || cursor.byteOffset !== start.previousByteOffset) {
+ // This index never saw the span before `previousByteOffset`; appending
+ // here would leave a hole no later read can fill. A null cursor is the
+ // file a chunked read left half written, which no offset continues.
+ this.store.markStale(candidate)
+ return null
+ }
+ }
+ const write = this.store.beginWrite(
+ candidate,
+ start.mode,
+ start.previousByteOffset,
+ start.identity
+ )
+ if (!write) {
+ this.store.markStale(candidate)
+ return null
+ }
+ return new SessionSearchReadConsumer(this.store, start, write)
+ }
+}
+
+class SessionSearchReadConsumer implements TranscriptReadConsumer {
+ private failed = false
+
+ constructor(
+ private readonly store: SessionSearchStore,
+ private readonly start: TranscriptReadStart,
+ private readonly write: SessionSearchFileWrite
+ ) {}
+
+ message(message: TranscriptMessage): void {
+ if (this.failed) {
+ return
+ }
+ try {
+ this.write.add(message)
+ } catch (error) {
+ // Never throws back into the reader: the channel would drop this consumer
+ // for the rest of the read and `finish` would never run. Failing here
+ // keeps the whole read on one path — the buffer is dropped and the file is
+ // re-read.
+ this.failed = true
+ this.store.reportWriteFailure(error)
+ }
+ }
+
+ finish(outcome: TranscriptReadOutcome): void {
+ const { candidate } = this.start
+ let committed = false
+ try {
+ // An incomplete read's rows are not the whole span, so the cursor must not
+ // move past them; the file is re-read whole instead.
+ committed = !this.failed && !outcome.incomplete && this.write.commit(outcome)
+ } catch (error) {
+ this.store.reportWriteFailure(error)
+ }
+ if (committed) {
+ this.store.writeCommitted(candidate)
+ return
+ }
+ this.store.markStale(candidate)
+ }
+}
+
+/**
+ * Registers the index with the reader and returns the unregister function.
+ * Nothing in production calls this yet: PR 3 owns when the index is live.
+ */
+export function registerSessionSearchIndexConsumer(store: SessionSearchStore): () => void {
+ return registerTranscriptConsumer(new SessionSearchIndexConsumer(store))
+}
diff --git a/src/main/ai-vault-search/session-search-index-test-fixture.ts b/src/main/ai-vault-search/session-search-index-test-fixture.ts
new file mode 100644
index 00000000000..baa3e2976fe
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-index-test-fixture.ts
@@ -0,0 +1,124 @@
+import { mkdtemp } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { removeTree } from '../../shared/windows-transient-lock-removal'
+import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
+import { TranscriptMessageChannel } from '../ai-vault/session-transcript-channel'
+import type {
+ TranscriptMessage,
+ TranscriptReadOutcome,
+ TranscriptReadStart
+} from '../ai-vault/session-transcript-consumers'
+import type SyncDatabase from '../sqlite/sync-database'
+import { openSessionSearchDatabase } from './session-search-schema'
+
+export const SYNTHETIC_TRANSCRIPT = 'synthetic-transcript'
+
+export function syntheticCandidate(
+ overrides: Partial = {}
+): SessionFileCandidate {
+ const at = new Date(1740000000000)
+ return {
+ agent: 'claude',
+ codexHome: null,
+ file: {
+ path: SYNTHETIC_TRANSCRIPT,
+ mtimeMs: at.getTime(),
+ modifiedAt: at.toISOString(),
+ sizeBytes: 4096,
+ ...overrides
+ }
+ }
+}
+
+export function syntheticSession(overrides: Partial = {}): AiVaultSession {
+ const at = new Date(1740000000000).toISOString()
+ return {
+ id: 'fixture',
+ executionHostId: 'local',
+ agent: 'claude',
+ sessionId: 'fixture',
+ title: 'fixture session',
+ cwd: '/fixture',
+ branch: null,
+ model: null,
+ filePath: SYNTHETIC_TRANSCRIPT,
+ codexHome: null,
+ createdAt: at,
+ updatedAt: at,
+ modifiedAt: at,
+ messageCount: 0,
+ totalTokens: 0,
+ previewMessages: [],
+ queuedMessageCount: 0,
+ subagentTranscriptCount: 0,
+ resumeCommand: '',
+ subagent: null,
+ ...overrides
+ }
+}
+
+export function userMessages(text: string, count: number): TranscriptMessage[] {
+ return Array.from({ length: count }, (_unused, index) => ({
+ role: 'user' as const,
+ text,
+ timestamp: new Date(1740000000000 + index * 1000).toISOString()
+ }))
+}
+
+/**
+ * Drives one read through the real fan-out channel, so a test exercises the
+ * registration path the transcript reader uses rather than the consumer alone.
+ */
+export function replayTranscriptRead(args: {
+ candidate?: SessionFileCandidate
+ mode?: TranscriptReadStart['mode']
+ previousByteOffset?: number
+ messages: TranscriptMessage[]
+ outcome?: Partial
+}): void {
+ const candidate = args.candidate ?? syntheticCandidate()
+ const mode = args.mode ?? 'replace'
+ const channel = new TranscriptMessageChannel()
+ channel.beginRead({
+ candidate,
+ mode,
+ previousByteOffset: args.previousByteOffset ?? 0
+ })
+ for (const message of args.messages) {
+ channel.push(message)
+ }
+ channel.finishRead({
+ session: syntheticSession(),
+ byteOffset: 4096,
+ incomplete: false,
+ ...args.outcome
+ })
+}
+
+export type SessionSearchIndexFile = {
+ path: string
+ /** The store keeps its own connection private, so row assertions need this one. */
+ db: SyncDatabase
+ close: () => Promise
+}
+
+/** An on-disk index: `:memory:` is per-connection, so a second reader needs a real file. */
+export async function openSessionSearchIndexFile(name: string): Promise {
+ const root = await mkdtemp(join(tmpdir(), `${name}-`))
+ const path = join(root, 'index.sqlite')
+ const db = openSessionSearchDatabase(path)
+ let open = true
+ return {
+ path,
+ db,
+ close: async () => {
+ if (open) {
+ open = false
+ db.close()
+ }
+ await removeTree(root)
+ }
+ }
+}
diff --git a/src/main/ai-vault-search/session-search-index-writer.test.ts b/src/main/ai-vault-search/session-search-index-writer.test.ts
new file mode 100644
index 00000000000..46d147dcce0
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-index-writer.test.ts
@@ -0,0 +1,228 @@
+import { afterEach, beforeEach, expect, it } from 'vitest'
+import { SessionSearchIndexConsumer } from './session-search-index-consumer'
+import {
+ openSessionSearchIndexFile,
+ syntheticCandidate,
+ syntheticSession,
+ SYNTHETIC_TRANSCRIPT,
+ userMessages,
+ type SessionSearchIndexFile
+} from './session-search-index-test-fixture'
+import { SessionSearchStore } from './session-search-store'
+
+// The store is driven directly here. Every guard below is also shadowed by the
+// consumer's own check, so a test that goes through the consumer proves nothing
+// about which of the two is holding.
+
+let index: SessionSearchIndexFile
+let store: SessionSearchStore
+let errors: unknown[]
+
+beforeEach(async () => {
+ index = await openSessionSearchIndexFile('ss-index-writer')
+ errors = []
+ store = new SessionSearchStore(index.path, (error) => errors.push(error))
+})
+
+afterEach(async () => {
+ store.close()
+ await index.close()
+})
+
+function count(table: string): number {
+ return (
+ index.db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as {
+ n: number
+ }
+ ).n
+}
+
+function indexRead(previousByteOffset: number, byteOffset: number, text: string): boolean {
+ const write = store.beginWrite(
+ syntheticCandidate(),
+ previousByteOffset === 0 ? 'replace' : 'append',
+ previousByteOffset
+ )
+ if (!write) {
+ return false
+ }
+ for (const message of userMessages(text, 2)) {
+ write.add(message)
+ }
+ return write.commit({
+ session: syntheticSession(),
+ byteOffset,
+ incomplete: false
+ })
+}
+
+it('refuses an append whose predecessor offset is not the committed cursor', () => {
+ expect(indexRead(0, 100, 'first')).toBe(true)
+
+ expect(store.beginWrite(syntheticCandidate(), 'append', 900)).toBeNull()
+ expect(store.beginWrite(syntheticCandidate(), 'append', 99)).toBeNull()
+ // The one offset that does continue the committed span is accepted.
+ expect(store.beginWrite(syntheticCandidate(), 'append', 100)).not.toBeNull()
+})
+
+it('refuses to commit a write whose cursor moved underneath it', () => {
+ const stale = store.beginWrite(syntheticCandidate(), 'replace', 0)!
+ for (const message of userMessages('stalegeneration', 40)) {
+ stale.add(message)
+ }
+ // A second read of the same path finishes first. Without the parse file lane
+ // this is the overlap that would otherwise resurrect the stale rows.
+ expect(indexRead(0, 200, 'winninggeneration')).toBe(true)
+
+ expect(
+ stale.commit({
+ session: syntheticSession(),
+ byteOffset: 100,
+ incomplete: false
+ })
+ ).toBe(false)
+ expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(200)
+ expect(count('sessions')).toBe(1)
+ expect(count('messages')).toBe(2)
+ expect(errors).toEqual([])
+})
+
+it('refuses to commit a write whose file was removed mid-read', () => {
+ expect(indexRead(0, 100, 'firstgeneration')).toBe(true)
+ const write = store.beginWrite(syntheticCandidate(), 'append', 100)!
+ for (const message of userMessages('afterremoval', 10)) {
+ write.add(message)
+ }
+ store.removeFile(SYNTHETIC_TRANSCRIPT)
+
+ // Committing here would put a source back that its owner proved was deleted.
+ expect(
+ write.commit({
+ session: syntheticSession(),
+ byteOffset: 300,
+ incomplete: false
+ })
+ ).toBe(false)
+ expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)).toBeNull()
+ expect(count('sessions')).toBe(0)
+ expect(count('messages')).toBe(0)
+ expect(count('files')).toBe(0)
+})
+
+it('declines a behind cursor in beginRead before it ever reaches the store', () => {
+ const attempted: number[] = []
+ const stub = {
+ acceptsCandidate: () => true,
+ indexedFile: () => ({ byteOffset: 100, mtimeMs: 1, sizeBytes: 1 }),
+ beginWrite: (_candidate: unknown, _mode: unknown, previousByteOffset: number) => {
+ attempted.push(previousByteOffset)
+ return { add: () => undefined, commit: () => true }
+ },
+ markStale: () => undefined
+ } as unknown as SessionSearchStore
+ const consumer = new SessionSearchIndexConsumer(stub)
+
+ expect(
+ consumer.beginRead({
+ candidate: syntheticCandidate(),
+ mode: 'append',
+ previousByteOffset: 900
+ })
+ ).toBeNull()
+ // The store was never asked, so the writer's own guard cannot be what refused.
+ expect(attempted).toEqual([])
+ expect(
+ consumer.beginRead({
+ candidate: syntheticCandidate(),
+ mode: 'append',
+ previousByteOffset: 100
+ })
+ ).not.toBeNull()
+ expect(attempted).toEqual([100])
+})
+
+it("hands the read's identity accessor to the store", () => {
+ const captured: unknown[] = []
+ const stub = {
+ acceptsCandidate: () => true,
+ indexedFile: () => null,
+ beginWrite: (
+ _candidate: unknown,
+ _mode: unknown,
+ _previousByteOffset: unknown,
+ identity: unknown
+ ) => {
+ captured.push(identity)
+ return { add: () => undefined, commit: () => true }
+ },
+ markStale: () => undefined
+ } as unknown as SessionSearchStore
+ const identity = (): null => null
+
+ new SessionSearchIndexConsumer(stub).beginRead({
+ candidate: syntheticCandidate(),
+ mode: 'replace',
+ previousByteOffset: 0,
+ identity
+ })
+
+ // Dropped here, a chunked read writes rows under a session with no id and no
+ // cwd for as long as the read lasts, and for ever if it crashes first.
+ expect(captured).toEqual([identity])
+})
+
+it('treats half a recorded identity as no identity at all', () => {
+ // New partial observations are not stored as identities.
+ const partial = {
+ ...syntheticCandidate({ dev: 7 }),
+ agent: 'claude' as const
+ }
+ const write = store.beginWrite(partial, 'replace', 0)!
+ for (const message of userMessages('halfidentity', 2)) {
+ write.add(message)
+ }
+ write.commit({
+ session: syntheticSession(),
+ byteOffset: 100,
+ incomplete: false
+ })
+ expect(index.db.prepare('SELECT dev, ino FROM files').get()).toEqual({
+ dev: null,
+ ino: null
+ })
+ // Older indexes may still carry a half-pair.
+ index.db.exec('UPDATE files SET dev = 7')
+
+ // One matching number is not proof of sameness, and one mismatching number is
+ // not proof of replacement. Neither compares, so neither declines.
+ expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, { dev: 7, ino: 99 })?.byteOffset).toBe(100)
+ expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, { dev: 8, ino: 99 })?.byteOffset).toBe(100)
+ expect(store.beginWrite(syntheticCandidate({ dev: 8, ino: 99 }), 'append', 100)).not.toBeNull()
+})
+
+it.each([
+ [null, { dev: null, ino: null }],
+ [
+ { dev: 7, ino: 11 },
+ { dev: 7, ino: 11 }
+ ]
+])('never combines partial stats with the previous identity %j', (initial, expected) => {
+ const observations = [initial ?? {}, { dev: 9 }, { ino: 13 }, { dev: 17, ino: 19 }]
+ for (const [position, identity] of observations.entries()) {
+ const write = store.beginWrite(
+ syntheticCandidate(identity),
+ position ? 'append' : 'replace',
+ position * 100
+ )!
+ expect(
+ write.commit({
+ session: syntheticSession(),
+ byteOffset: (position + 1) * 100,
+ incomplete: false
+ })
+ ).toBe(true)
+ expect(index.db.prepare('SELECT dev, ino FROM files').get()).toEqual(
+ position === 3 ? { dev: 17, ino: 19 } : expected
+ )
+ }
+})
diff --git a/src/main/ai-vault-search/session-search-index-writer.ts b/src/main/ai-vault-search/session-search-index-writer.ts
new file mode 100644
index 00000000000..a5c981b5bb2
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-index-writer.ts
@@ -0,0 +1,359 @@
+import type SyncDatabase from '../sqlite/sync-database'
+import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
+import type {
+ TranscriptMessage,
+ TranscriptReadOutcome,
+ TranscriptSessionIdentity
+} from '../ai-vault/session-transcript-consumers'
+import { EMPTY_CONTENT_HASH, foldContentHash } from './session-search-content-hash'
+import type {
+ SessionSearchFileIdentity,
+ SessionSearchIndexedFile
+} from './session-search-file-cursor'
+import { SessionSearchFileRecords } from './session-search-file-records'
+import {
+ deleteSearchMessages,
+ insertSearchMessage,
+ searchMessageRows
+} from './session-search-message-rows'
+
+/**
+ * How much decoded text one transaction may carry.
+ *
+ * A file's rows are buffered in memory and written in one transaction, so the
+ * whole read is either in the index or not. The ceiling is what keeps that
+ * promise affordable: at the measured 26 MB of transcript per second it caps a
+ * single commit near a second and the WAL it produces near 64 MB, and it is far
+ * above the largest real transcript (the 40-session benchmark corpus is 10.5 MB
+ * in total), so an ordinary file never reaches it. Above the ceiling the read is
+ * cut into chunks that each leave the index consistent — but only a read that
+ * can name its session chunks at all. See `add`.
+ */
+export const SESSION_SEARCH_COMMIT_CHARS = 32 * 1024 * 1024
+
+/**
+ * The cursor of a file whose rows are a prefix, written by a chunk of a read
+ * that has not reached the end of the file.
+ *
+ * The reader hands out byte offsets only when a read finishes, so a chunk has
+ * no honest offset to record. This one is unusable on purpose: `indexedFile`
+ * reports no cursor for it, so an append is declined and the file is re-read
+ * whole. The rows are still a coherent prefix of that session and answer
+ * searches until the re-read replaces them.
+ */
+const PARTIAL_FILE_CURSOR = -1
+
+type FileRow = {
+ dev: number | null
+ ino: number | null
+ byte_offset: number
+ mtime_ms: number
+ size_bytes: number | null
+ session_row_id: number | null
+}
+
+type FileCursor = Pick
+
+export type SessionSearchFileWrite = {
+ /**
+ * Buffers one message, committing a chunk when the buffer reaches the ceiling
+ * — and only while this read can name the session it is writing.
+ *
+ * A chunk's rows answer searches the moment they land, so a read with no
+ * `identity` would publish them under a session with an empty id, an empty
+ * title and a null cwd, and an interrupted read would leave that prefix
+ * behind for good. The readers that supply no identity are the whole-file
+ * ones (Grok, Cursor, Gemini, OpenCode), whose formats are rewritten in place
+ * and have no resumable state to ask; they are also small — the largest on
+ * the author's machine is 5 MB — so buffering one to the end and committing
+ * it whole costs nothing. Chunking stays reserved for the readers that can
+ * say which session this is before the read ends.
+ */
+ add(message: TranscriptMessage): void
+ /**
+ * Writes this file's rows, its session and its cursor in one transaction.
+ * False when the file's record changed under this read — it was removed, or
+ * another writer moved the cursor these rows continue from. A read that never
+ * calls this leaves the index exactly as it found it, unless it chunked.
+ */
+ commit(outcome: TranscriptReadOutcome): boolean
+}
+
+export class SessionSearchIndexWriter {
+ private readonly records: SessionSearchFileRecords
+ // Removals per path, so a write can prove its source was not dropped under it
+ // rather than infer it from the cursor. In memory is enough: one process owns
+ // the index, and a removal only has to fence writes this process opened.
+ private readonly removals = new Map()
+
+ constructor(
+ private readonly db: SyncDatabase,
+ private readonly commitChars: number = SESSION_SEARCH_COMMIT_CHARS,
+ /**
+ * Called after a transaction that left a session's messages with no session
+ * row, so the owner can start the bounded drain that reclaims them.
+ * Synchronous work here would put the cost back where it was taken from.
+ */
+ private readonly onOrphanedRows: () => void = () => undefined
+ ) {
+ this.records = new SessionSearchFileRecords(db)
+ }
+
+ /**
+ * What the index holds for this file, or null when it holds nothing usable:
+ * an unknown path, or one whose recorded identity no longer matches.
+ *
+ * A file a chunked read left half written is reported, with a null cursor.
+ * Reporting nothing for it would read as "never indexed", so the caller would
+ * ask for whatever read the parse cache offers, the reader would pick append,
+ * and the decline would be the only thing that ever forced the whole read.
+ */
+ indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null {
+ const row = this.db
+ .prepare(
+ 'SELECT dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id FROM files WHERE path = ?'
+ )
+ .get(path) as FileRow | undefined
+ if (!row) {
+ return null
+ }
+ // Older indexes can carry half-pairs; only a complete identity can prove replacement.
+ if (identity && row.dev !== null && row.ino !== null) {
+ if (row.dev !== identity.dev || row.ino !== identity.ino) {
+ return null
+ }
+ }
+ return {
+ byteOffset: row.byte_offset === PARTIAL_FILE_CURSOR ? null : row.byte_offset,
+ mtimeMs: row.mtime_ms,
+ sizeBytes: row.size_bytes
+ }
+ }
+
+ /**
+ * Opens a buffered write for one read, or returns null when the read cannot
+ * extend what the index holds: an `append` whose predecessor byte offset is
+ * not this index's own cursor covers a span the index never saw.
+ */
+ beginWrite(
+ candidate: SessionFileCandidate,
+ mode: 'replace' | 'append',
+ previousByteOffset: number,
+ identity?: () => TranscriptSessionIdentity | null
+ ): SessionSearchFileWrite | null {
+ const path = candidate.file.path
+ const cursor = this.cursor(path)
+ if (mode === 'append') {
+ // The partial sentinel is not a byte offset, so nothing continues it —
+ // including a caller that reads it back off the row and passes it in.
+ if (cursor === undefined || cursor.byte_offset === PARTIAL_FILE_CURSOR) {
+ return null
+ }
+ if (cursor.byte_offset !== previousByteOffset) {
+ return null
+ }
+ }
+ // A file the index read through and decoded no session from still has a
+ // cursor worth continuing: it has no session row to hang new rows off, so
+ // this read makes one. Declining instead would force a whole re-read of
+ // that file on every pass for as long as it grows.
+ return this.buffered(candidate, cursor, mode === 'append', identity)
+ }
+
+ /**
+ * Drops a source: its session, its rows and its file record, in one
+ * transaction. Unbounded on purpose — the caller has proven this one file is
+ * gone and expects it out of results when the call returns, and a read of it
+ * that is still in flight is fenced by the cursor its commit re-reads.
+ */
+ removeFile(path: string): void {
+ this.removals.set(path, (this.removals.get(path) ?? 0) + 1)
+ const cursor = this.cursor(path)
+ this.db.exec('BEGIN IMMEDIATE')
+ try {
+ this.dropSession(cursor?.session_row_id ?? null)
+ this.db.prepare('DELETE FROM files WHERE path = ?').run(path)
+ this.db.exec('COMMIT')
+ } catch (error) {
+ this.db.exec('ROLLBACK')
+ throw error
+ }
+ }
+
+ private cursor(path: string): FileCursor | undefined {
+ return this.db
+ .prepare('SELECT session_row_id,byte_offset FROM files WHERE path = ?')
+ .get(path) as FileCursor | undefined
+ }
+
+ private buffered(
+ candidate: SessionFileCandidate,
+ opened: FileCursor | undefined,
+ append: boolean,
+ identity?: () => TranscriptSessionIdentity | null
+ ): SessionSearchFileWrite {
+ const db = this.db
+ const path = candidate.file.path
+ const buffer: TranscriptMessage[] = []
+ let bufferedChars = 0
+ // What this write believes the file record holds. Re-read inside every
+ // transaction: a `removeFile` or another writer between two chunks means
+ // these rows no longer continue anything, and committing on top of that
+ // would resurrect a deleted source or duplicate a span.
+ let expected = opened
+ const removalsAtStart = this.removals.get(path) ?? 0
+ // The session row is reused across re-reads of one file, so a `replace`
+ // swaps a session's rows rather than minting a second generation of it.
+ let session = opened?.session_row_id ?? null
+ let hash = append && session !== null ? this.records.contentHash(session) : EMPTY_CONTENT_HASH
+ // A replace owns the session's whole row set, so the old generation goes in
+ // the same transaction as the first of the new one. Chunk two onwards must
+ // not repeat it.
+ //
+ // It goes by being cut loose, not by being deleted. Deleting every old row
+ // inline sizes the transaction by the session being replaced rather than by
+ // the chunk being written: 1,286 ms against 720 ms fresh on the 100 MB
+ // corpus, and it grows with the history. Instead the first transaction
+ // mints a new session row, points `files` at it and deletes the one old
+ // `sessions` row. Every retrieval joins `sessions`, so the old generation
+ // stops answering the moment that commits, and its messages are reclaimed
+ // afterwards by the same bounded drain retention uses — which is where the
+ // old rows would have ended up had the process died here anyway.
+ // `sessions.id` is AUTOINCREMENT, so the freed id is never handed to
+ // another session while those rows still name it (round 8).
+ let replaced = append
+ // Set by the transaction that cut a generation loose; read once it commits.
+ let orphaned = false
+ // Set when the file record moved under this read. Nothing this write holds
+ // can land after that, so it stops buffering rather than reopening a
+ // transaction it already knows will roll back, once per remaining message.
+ let fenced = false
+
+ // Why a counter and not the cursor alone: on a path this index never wrote,
+ // `expected` and the absent row are both undefined, so the cursor compare
+ // reads a removal as no change and the write recreates the source.
+ const current = (): boolean => {
+ if ((this.removals.get(path) ?? 0) !== removalsAtStart) {
+ return false
+ }
+ const row = this.cursor(path)
+ return (
+ row?.session_row_id === expected?.session_row_id &&
+ row?.byte_offset === expected?.byte_offset
+ )
+ }
+
+ /**
+ * `outcome` is null for a chunk of a read that has not reached the file's
+ * end, and `named` is what that chunk writes onto its session row.
+ */
+ const write = (
+ outcome: TranscriptReadOutcome | null,
+ named: TranscriptSessionIdentity | null
+ ): boolean => {
+ const decoded = outcome?.session ?? null
+ db.exec('BEGIN IMMEDIATE')
+ try {
+ if (!current()) {
+ db.exec('ROLLBACK')
+ return false
+ }
+ if (outcome && !decoded) {
+ // Read through, but nothing to search: the cursor advances so the file
+ // is not re-read whole on every pass, and whatever generation was here
+ // — including this read's own committed chunks — goes with it.
+ this.dropSession(session)
+ session = null
+ this.records.upsertFile(candidate, outcome.byteOffset, null)
+ } else {
+ if (replaced) {
+ session ??= this.records.createSessionRow(candidate)
+ } else {
+ const previous = session
+ session = this.records.createSessionRow(candidate)
+ if (previous !== null) {
+ db.prepare('DELETE FROM sessions WHERE id = ?').run(previous)
+ orphaned = true
+ }
+ replaced = true
+ }
+ for (const row of buffer) {
+ insertSearchMessage(db, session, row)
+ }
+ if (decoded) {
+ this.records.updateSession(decoded, session, hash)
+ } else if (named) {
+ // A chunk's rows answer searches as soon as they land, so the
+ // session they hang off is written with whatever the parser has
+ // decoded rather than left empty until a read that may never end.
+ // `add` refuses to chunk without this, so it is never absent here.
+ this.records.updateProvisionalSession(session, named)
+ }
+ this.records.upsertFile(
+ candidate,
+ outcome ? outcome.byteOffset : PARTIAL_FILE_CURSOR,
+ session
+ )
+ }
+ db.exec('COMMIT')
+ } catch (error) {
+ db.exec('ROLLBACK')
+ throw error
+ }
+ // After the transaction that cut them loose is durable, never before: a
+ // rollback leaves the old session row standing and nothing to reclaim.
+ if (orphaned) {
+ orphaned = false
+ this.onOrphanedRows()
+ }
+ expected = {
+ session_row_id: session,
+ byte_offset: outcome ? outcome.byteOffset : PARTIAL_FILE_CURSOR
+ }
+ buffer.length = 0
+ bufferedChars = 0
+ return true
+ }
+
+ return {
+ add: (message) => {
+ if (fenced) {
+ return
+ }
+ hash = foldContentHash(hash, [message])
+ // The ceiling is checked per row, not per message: one message is a whole
+ // conversation turn and may be megabytes, so checking it after the whole
+ // message had been buffered let a single one carry a transaction as far
+ // past the ceiling as it was large.
+ for (const row of searchMessageRows([message])) {
+ buffer.push(row)
+ bufferedChars += row.text.length
+ if (bufferedChars < this.commitChars) {
+ continue
+ }
+ // Publishing a chunk under a session nothing can identify is worse
+ // than holding the buffer: the rows answer searches at once, and an
+ // interrupted read leaves that prefix for good. A read with nothing
+ // to name it keeps buffering and commits whole at `finish`.
+ const named = identity?.() ?? null
+ if (named && !write(null, named)) {
+ fenced = true
+ buffer.length = 0
+ bufferedChars = 0
+ return
+ }
+ }
+ },
+ commit: (outcome) => !fenced && write(outcome, null)
+ }
+ }
+
+ /** Caller's transaction: drops a session and every row that hangs off it. */
+ private dropSession(sessionRowId: number | null): void {
+ if (sessionRowId === null) {
+ return
+ }
+ deleteSearchMessages(this.db, sessionRowId)
+ this.db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionRowId)
+ }
+}
diff --git a/src/main/ai-vault-search/session-search-live-transcript.test.ts b/src/main/ai-vault-search/session-search-live-transcript.test.ts
new file mode 100644
index 00000000000..7f37ca662cb
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-live-transcript.test.ts
@@ -0,0 +1,208 @@
+import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, expect, it } from 'vitest'
+import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
+import {
+ registerTranscriptConsumer,
+ resetTranscriptConsumersForTests,
+ type TranscriptSessionIdentity
+} from '../ai-vault/session-transcript-consumers'
+import { requestWholeTranscriptRead } from '../ai-vault/session-transcript-reader'
+import SyncDatabase from '../sqlite/sync-database'
+import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
+import { SessionSearchStore } from './session-search-store'
+import {
+ assistantRecord,
+ CLAUDE_SESSION_ID as SESSION_ID,
+ CODEX_ROLLOUT_FILE,
+ CODEX_SESSION_ID,
+ codexRolloutLines,
+ parseTranscript,
+ userRecord
+} from './session-search-transcript-fixtures'
+
+let tempRoots: string[] = []
+let store: SessionSearchStore
+// The store keeps its connection private, so row assertions need a second one.
+let reader: SyncDatabase
+let errors: unknown[]
+
+beforeEach(async () => {
+ resetSessionParseCacheForTests()
+ resetTranscriptConsumersForTests()
+ errors = []
+ const path = join(await makeTempDir(), 'index.sqlite')
+ store = new SessionSearchStore(path, (error) => errors.push(error))
+ registerSessionSearchIndexConsumer(store)
+ reader = new SyncDatabase(path, { readonly: true })
+})
+
+afterEach(async () => {
+ resetTranscriptConsumersForTests()
+ reader.close()
+ store.close()
+ await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
+ tempRoots = []
+})
+
+async function makeTempDir(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-session-search-live-'))
+ tempRoots.push(root)
+ return root
+}
+
+/** Sessions a query would return for one FTS term, read on a second handle. */
+function sessionsMatching(term: string): string[] {
+ return (
+ reader
+ .prepare(
+ `SELECT DISTINCT s.session_id AS id FROM messages_fts
+ JOIN messages m ON m.id = messages_fts.rowid
+ JOIN sessions s ON s.id = m.session_row_id
+ WHERE messages_fts MATCH ? ORDER BY s.session_id`
+ )
+ .all(term) as { id: string }[]
+ ).map((row) => row.id)
+}
+
+it('indexes a Claude transcript through the reader and resumes on append', async () => {
+ const root = await makeTempDir()
+ const path = join(root, `${SESSION_ID}.jsonl`)
+ await writeFile(
+ path,
+ `${[
+ userRecord(0, 'find the flaky terminal reattach'),
+ assistantRecord(1, 'look at resolveTerminalPath first')
+ ].join('\n')}\n`
+ )
+ await parseTranscript(path)
+ expect(errors).toEqual([])
+ expect(sessionsMatching('reattach')).toEqual([SESSION_ID])
+ // The identifier column shadows a camel-case symbol into its pieces.
+ expect(sessionsMatching('terminal')).toEqual([SESSION_ID])
+
+ await appendFile(path, `${assistantRecord(2, 'the zygomorphic follow-up landed')}\n`)
+ const resumed = await parseTranscript(path)
+ // The reader resumed, so the index saw an `append`, not a whole re-read.
+ expect(resumed.stats).toMatchObject({ incremental: 1, fullParses: 0 })
+ expect(errors).toEqual([])
+ expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID])
+ // An append extends one session rather than creating a second.
+ expect(reader.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
+ n: 1
+ })
+})
+
+it('keeps a tool result searchable but out of the conversation half', async () => {
+ const root = await makeTempDir()
+ const codexHome = await makeTempDir()
+ const path = join(root, CODEX_ROLLOUT_FILE)
+ await writeFile(
+ path,
+ `${codexRolloutLines(
+ ['rg', 'pericardium'],
+ `outputonly ${'padding '.repeat(600)}tailonly`,
+ 'promptonly search for the module'
+ ).join('\n')}\n`
+ )
+ await parseTranscript(path, 'codex', codexHome)
+ expect(errors).toEqual([])
+
+ expect(sessionsMatching('pericardium')).toHaveLength(1)
+ // The prompt is conversation; the command output is not, and the column
+ // filter is what tells them apart.
+ expect(sessionsMatching('outputonly')).toHaveLength(1)
+ expect(sessionsMatching('tailonly')).toHaveLength(0)
+ expect(sessionsMatching('rg')).toHaveLength(1)
+ expect(sessionsMatching('{user_text assistant_text}: promptonly')).toHaveLength(1)
+ expect(sessionsMatching('{user_text assistant_text}: outputonly')).toHaveLength(0)
+ expect(sessionsMatching('{user_text assistant_text}: rg')).toHaveLength(0)
+})
+
+/** What `start.identity()` returns at each message of one read. */
+function recordIdentityPerMessage(): (TranscriptSessionIdentity | null)[] {
+ const seen: (TranscriptSessionIdentity | null)[] = []
+ registerTranscriptConsumer({
+ beginRead: (start) => ({
+ message: () => {
+ seen.push(start.identity?.() ?? null)
+ },
+ finish: () => undefined
+ })
+ })
+ return seen
+}
+
+it('names the session mid-read, before the reader has finished the file', async () => {
+ const root = await makeTempDir()
+ const path = join(root, `${SESSION_ID}.jsonl`)
+ await writeFile(
+ path,
+ `${[
+ userRecord(0, 'find the flaky terminal reattach'),
+ assistantRecord(1, 'look at resolveTerminalPath first')
+ ].join('\n')}\n`
+ )
+ const seen = recordIdentityPerMessage()
+ await parseTranscript(path)
+
+ // A chunked read commits partway through a file this size or larger, so what
+ // it can name the session with is exactly this.
+ expect(seen.length).toBeGreaterThan(0)
+ expect(seen[0]).toMatchObject({
+ sessionId: SESSION_ID,
+ cwd: '/repo/app',
+ createdAt: expect.any(String)
+ })
+})
+
+it('names a Codex session mid-read from its own opening record', async () => {
+ const root = await makeTempDir()
+ const codexHome = await makeTempDir()
+ const path = join(root, CODEX_ROLLOUT_FILE)
+ await writeFile(
+ path,
+ `${codexRolloutLines(['rg', 'pericardium'], 'src/main/pericardium.ts:12: match', 'search for the pericardium module').join('\n')}\n`
+ )
+ const seen = recordIdentityPerMessage()
+ await parseTranscript(path, 'codex', codexHome)
+
+ // Codex builds its own resumable state rather than the shared accumulator
+ // fold, so it is the other half of the surface a chunked commit depends on.
+ expect(seen[0]).toMatchObject({
+ sessionId: CODEX_SESSION_ID,
+ cwd: '/repo/app'
+ })
+})
+
+it('indexes a file the session list already read past, once a whole read is asked for', async () => {
+ const root = await makeTempDir()
+ const path = join(root, `${SESSION_ID}.jsonl`)
+ await writeFile(path, `${userRecord(0, 'the opening prompt')}\n`)
+
+ // The state on first enablement inside a running app: the session list has
+ // read this file, so the parse cache is warm, while the index is empty.
+ resetTranscriptConsumersForTests()
+ await parseTranscript(path)
+ registerSessionSearchIndexConsumer(store)
+
+ await appendFile(path, `${assistantRecord(1, 'a zygomorphic reply')}\n`)
+ const appended = await parseTranscript(path)
+ expect(appended.stats).toMatchObject({ incremental: 1, fullParses: 0 })
+ // The append continued from a byte offset the index never saw, so it declined.
+ expect(sessionsMatching('zygomorphic')).toEqual([])
+
+ const behind = store.takeStale()
+ expect(behind.map((candidate) => candidate.file.path)).toEqual([path])
+ for (const candidate of behind) {
+ requestWholeTranscriptRead(candidate.file.path)
+ }
+
+ const reread = await parseTranscript(path)
+ expect(reread.stats).toMatchObject({ incremental: 0, fullParses: 1 })
+ expect(errors).toEqual([])
+ expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID])
+ expect(sessionsMatching('opening')).toEqual([SESSION_ID])
+ expect(store.takeStale()).toEqual([])
+})
diff --git a/src/main/ai-vault-search/session-search-message-rows.test.ts b/src/main/ai-vault-search/session-search-message-rows.test.ts
new file mode 100644
index 00000000000..d2cf2fbca61
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-message-rows.test.ts
@@ -0,0 +1,249 @@
+import { expect, it } from 'vitest'
+import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers'
+import { insertSearchMessage, searchMessageRows } from './session-search-message-rows'
+import {
+ openSessionSearchIndexFile,
+ type SessionSearchIndexFile
+} from './session-search-index-test-fixture'
+
+/** Every column of the FTS table, so an assertion cannot miss the shadow terms. */
+async function indexedColumns(
+ index: SessionSearchIndexFile,
+ message: TranscriptMessage
+): Promise {
+ for (const row of searchMessageRows([message])) {
+ insertSearchMessage(index.db, 1, row)
+ }
+ const full = index.db
+ .prepare('SELECT user_text, assistant_text, tool_text, identifiers FROM messages_fts')
+ .all() as Record[]
+ return full.flatMap((row) => Object.values(row))
+}
+
+it('splits an oversized message on a line boundary and keeps every character', () => {
+ const line = `${'padding '.repeat(11)}word\n`
+ const text = line.repeat(400)
+ const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map(
+ (row) => row.text
+ )
+
+ expect(chunks.length).toBeGreaterThan(1)
+ expect(chunks.join('')).toBe(text)
+ for (const chunk of chunks) {
+ expect(chunk.length).toBeLessThanOrEqual(8000)
+ expect(chunk.endsWith('\n')).toBe(true)
+ }
+})
+
+it('cuts at whitespace rather than through the word on the boundary', async () => {
+ const index = await openSessionSearchIndexFile('ss-rows-whitespace')
+ try {
+ // The 8,000th character lands inside `pericardium`. Cutting at the target
+ // would file `per` under one row and `icardium` under another, and the word
+ // the user types would match neither.
+ const text = `${' '.repeat(7997)}pericardium`
+ const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])]
+ expect(chunks.map((row) => row.text).join('')).toBe(text)
+ for (const row of chunks) {
+ insertSearchMessage(index.db, 1, row)
+ }
+
+ expect(
+ index.db
+ .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
+ .get('pericardium')
+ ).toEqual({ n: 1 })
+ } finally {
+ await index.close()
+ }
+})
+
+it.each(['/repo/pericardium.ts', 'PROJ-12345', 'C++', 'cafe\u0301ine'])(
+ 'preserves the exact FTS token %s at a chunk boundary',
+ async (token) => {
+ const index = await openSessionSearchIndexFile('ss-rows-tokenchars')
+ try {
+ const text = ' '.repeat(7998) + token
+ const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])]
+ expect(chunks.map((row) => row.text).join('')).toBe(text)
+ for (const row of chunks) {
+ insertSearchMessage(index.db, 1, row)
+ }
+ expect(
+ index.db
+ .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
+ .get(`"${token}"`)
+ ).toEqual({ n: 1 })
+ } finally {
+ await index.close()
+ }
+ }
+)
+
+it.each(['\u0305', '\u030d', '\u0332'])(
+ 'cuts at a combining mark unicode61 treats as a separator: %s',
+ async (mark) => {
+ const index = await openSessionSearchIndexFile('ss-rows-unicode-separator')
+ try {
+ const text = `${'x'.repeat(7997)}${mark}pericardium`
+ for (const row of searchMessageRows([{ role: 'user', text, timestamp: null }])) {
+ insertSearchMessage(index.db, 1, row)
+ }
+ expect(
+ index.db
+ .prepare("SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH 'pericardium'")
+ .get()
+ ).toEqual({ n: 1 })
+ } finally {
+ await index.close()
+ }
+ }
+)
+
+it('backs up to any whitespace, not only a newline', () => {
+ // An ideographic space separates words in a CJK transcript exactly as a
+ // space does here, and a newline-only backoff tears the token after it.
+ const text = `${'\u4e00'.repeat(7000)}\u3000${'\u4e8c'.repeat(2000)}`
+ const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map(
+ (row) => row.text
+ )
+
+ expect(chunks[0]).toBe(`${'\u4e00'.repeat(7000)}\u3000`)
+ expect(chunks.join('')).toBe(text)
+})
+
+it('cuts at punctuation when the window holds no whitespace at all', async () => {
+ const index = await openSessionSearchIndexFile('ss-rows-minified')
+ try {
+ // Valid minified JSON, the shape a tool result carries: 8,000 characters
+ // without a single space. The 8,000th lands inside `pericardium`, and a
+ // whitespace-only backoff has nothing in the window to back up to, so it
+ // files `perica` under one row and `rdium` under the next.
+ const text = `{"pad":"${'x'.repeat(7976)}","note":"pericardium"}`
+ expect(JSON.parse(text)).toEqual({ pad: 'x'.repeat(7976), note: 'pericardium' })
+ expect(text.slice(7994, 8005)).toBe('pericardium')
+ expect(/\s/.test(text)).toBe(false)
+
+ const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])]
+ expect(chunks.map((row) => row.text).join('')).toBe(text)
+ for (const row of chunks) {
+ insertSearchMessage(index.db, 1, row)
+ }
+
+ expect(
+ index.db
+ .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
+ .get('pericardium')
+ ).toEqual({ n: 1 })
+ } finally {
+ await index.close()
+ }
+})
+
+it('keeps a 9,000-character identifier whole rather than cutting at its underscores', () => {
+ // `_` sits inside a token for this tokenizer, so it is not a boundary. A
+ // snake_case name that long holds none at all, and the target itself is the
+ // honest cut — backing up to every `_` would file the name in pieces.
+ const text = 'ab_'.repeat(3000)
+ expect(text.length).toBe(9000)
+ const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map(
+ (row) => row.text
+ )
+
+ expect(chunks.map((chunk) => chunk.length)).toEqual([8000, 1000])
+ expect(chunks.join('')).toBe(text)
+})
+
+it('still chunks a message that holds no whitespace at all', () => {
+ // A 20,000-character token is not a word, so the target itself is the cut and
+ // the message is still bounded.
+ const chunks = [
+ ...searchMessageRows([{ role: 'user', text: 'a'.repeat(20_000), timestamp: null }])
+ ]
+ expect(chunks.map((row) => row.text.length)).toEqual([8000, 8000, 4000])
+})
+
+it('leaves a message that fits as a single row', () => {
+ const rows = [...searchMessageRows([{ role: 'user', text: 'short enough', timestamp: null }])]
+ expect(rows.map((row) => row.text)).toEqual(['short enough'])
+})
+
+it('caps a tool row at its head and never caps the conversation', async () => {
+ const index = await openSessionSearchIndexFile('ss-rows-tool-cap')
+ try {
+ // The reader hands over untruncated text (its own bound is 256 KB per
+ // message and a consumer may be handed more); the cap is this module's.
+ const output = `pericardium ${'padding '.repeat(140_000)}`
+ expect(output.length).toBeGreaterThan(1024 * 1024)
+
+ const toolRows = [...searchMessageRows([{ role: 'tool', text: output, timestamp: null }])]
+ expect(toolRows).toHaveLength(1)
+ expect(toolRows[0]!.text.length).toBe(3072)
+ // The head is what identifies what ran, so it is what survives.
+ expect(toolRows[0]!.text.startsWith('pericardium ')).toBe(true)
+
+ // The same text as an assistant turn is conversation, and keeps every byte.
+ const assistantRows = [
+ ...searchMessageRows([{ role: 'assistant', text: output, timestamp: null }])
+ ]
+ expect(assistantRows.map((row) => row.text).join('')).toBe(output)
+ expect(assistantRows.length).toBeGreaterThan(100)
+
+ for (const row of toolRows) {
+ insertSearchMessage(index.db, 1, row)
+ }
+ expect(
+ index.db
+ .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
+ .get('pericardium')
+ ).toEqual({ n: 1 })
+ } finally {
+ await index.close()
+ }
+})
+
+it('files a tool row under the tool column alone', async () => {
+ const index = await openSessionSearchIndexFile('ss-message-rows-tool')
+ try {
+ for (const row of searchMessageRows([
+ { role: 'tool', text: 'rg pericardium', timestamp: null }
+ ])) {
+ insertSearchMessage(index.db, 1, row)
+ }
+ expect(index.db.prepare('SELECT count(*) AS n FROM messages_fts').get()).toEqual({ n: 1 })
+ // What makes a conversation-scoped search exclude it: the column filter, not
+ // a second table.
+ expect(
+ index.db
+ .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
+ .get('{user_text assistant_text}: pericardium')
+ ).toEqual({ n: 0 })
+ expect(
+ index.db
+ .prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
+ .get('{tool_text}: pericardium')
+ ).toEqual({ n: 1 })
+ } finally {
+ await index.close()
+ }
+})
+
+it('stores a chunk exactly as the transcript wrote it', async () => {
+ const index = await openSessionSearchIndexFile('ss-rows-verbatim')
+ try {
+ const text = 'deploy with AKIAIOSFODNN7EXAMPLE and the resolveTerminalPath fix'
+ const stored = await indexedColumns(index, {
+ role: 'assistant',
+ text,
+ timestamp: null
+ })
+
+ // The index is a second copy of content the user already holds in plaintext,
+ // so it neither rewrites nor drops any of it.
+ expect(stored).toContain(text)
+ // Identifier shadow terms come off that same raw chunk.
+ expect(stored.some((column) => column.includes('resolve terminal path'))).toBe(true)
+ } finally {
+ await index.close()
+ }
+})
diff --git a/src/main/ai-vault-search/session-search-message-rows.ts b/src/main/ai-vault-search/session-search-message-rows.ts
new file mode 100644
index 00000000000..21254c183aa
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-message-rows.ts
@@ -0,0 +1,135 @@
+import type SyncDatabase from '../sqlite/sync-database'
+import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers'
+import { sliceAtCodeUnitLimit } from '../ai-vault/session-scanner-text-normalization'
+import { identifierShadowText } from './session-search-identifier-split'
+
+const CHUNK_TARGET_CHARS = 8000
+
+/**
+ * How much of one tool output is indexed. Its head: a command, its arguments and
+ * the first lines of what it printed are what a user searches for, while the
+ * tail is the padding that makes these messages large in the first place.
+ *
+ * Tool output is 80-97 % of a transcript's bytes, and a single one can be a
+ * quarter of a megabyte (the reader's own per-message bound). Without this the
+ * index, the in-memory buffer a read holds and the transaction it commits are
+ * all sized by how much a tool printed rather than by how much is worth
+ * searching. 3 KB was the accuracy/size sweet spot in the original design
+ * measurement. User and assistant text is never capped: it is the conversation,
+ * and it is small.
+ */
+const TOOL_ROW_CHARS = 3072
+
+// Keep unicode61's tokenchars intact, including before an available space.
+// SQLite ext/fts5/fts5_unicode2.c: sqlite3Fts5UnicodeIsdiacritic, with remove_diacritics=1.
+const FOLDED_DIACRITIC =
+ /[\u0300-\u0304\u0306-\u030c\u030f\u0311\u031b\u0323-\u0328\u032d-\u032e\u0330-\u0331]/
+const TOKEN_BOUNDARY = /[^\p{L}\p{N}\p{Co}_.\-/+\uD800-\uDFFF]/u
+
+/**
+ * Index just past the last token boundary in `[floor, end)`, or -1 when the
+ * window holds none. Not only a newline: a wrapped paragraph, a CJK transcript
+ * separated by ideographic spaces and a minified log all chunk on a boundary a
+ * tokenizer would have picked anyway.
+ */
+function lastTokenBoundaryEnd(text: string, floor: number, end: number): number {
+ for (let at = end - 1; at >= floor; at--) {
+ if (TOKEN_BOUNDARY.test(text[at]!) && !FOLDED_DIACRITIC.test(text[at]!)) {
+ return at + 1
+ }
+ }
+ return -1
+}
+
+/**
+ * Splits an oversized message into rows of at most `CHUNK_TARGET_CHARS`, cutting
+ * on a token boundary so no token is torn in half and every word stays
+ * searchable. A phrase that straddles two chunks is not matched: chunks are
+ * separate FTS rows and FTS5 cannot span them.
+ */
+function* textChunks(text: string): Generator {
+ if (text.length <= CHUNK_TARGET_CHARS) {
+ yield text
+ return
+ }
+ let start = 0
+ while (start < text.length) {
+ let end = Math.min(text.length, start + CHUNK_TARGET_CHARS)
+ if (end < text.length) {
+ // Only the second half of the window: backing up further would trade a
+ // torn token for chunks half the size. No boundary at all in 4,000
+ // characters is not a word, so the target itself is the honest cut.
+ const split = lastTokenBoundaryEnd(text, start + CHUNK_TARGET_CHARS / 2, end)
+ if (split > start) {
+ end = split
+ }
+ }
+ yield text.slice(start, end)
+ start = end
+ }
+}
+
+/**
+ * The row policy for one message: a `tool` message becomes one capped row, and
+ * anything else becomes N chunks, because FTS5 ranks a short row far better
+ * than a huge one.
+ */
+export function* searchMessageRows(
+ messages: Iterable
+): Generator {
+ for (const message of messages) {
+ if (message.role === 'tool') {
+ yield {
+ ...message,
+ text: sliceAtCodeUnitLimit(message.text, TOOL_ROW_CHARS)
+ }
+ continue
+ }
+ for (const text of textChunks(message.text)) {
+ yield { ...message, text }
+ }
+ }
+}
+
+/**
+ * Writes one row into `messages` and `messages_fts` in the caller's
+ * transaction, so a message is never present in one and absent from the other.
+ * A conversation-scoped query filters the columns rather than reading a second
+ * table (see the schema).
+ */
+export function insertSearchMessage(
+ db: SyncDatabase,
+ sessionId: number,
+ message: TranscriptMessage
+): void {
+ const text = message.text
+ const id = db
+ .prepare('INSERT INTO messages(session_row_id, role, ts) VALUES (?, ?, ?)')
+ .run(sessionId, message.role, message.timestamp).lastInsertRowid
+ const user = message.role === 'user' ? text : ''
+ const assistant = message.role === 'assistant' ? text : ''
+ const tool = message.role === 'tool' ? text : ''
+ db.prepare(
+ 'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)'
+ ).run(id, user, assistant, tool, identifierShadowText(text))
+}
+
+/**
+ * Deletes up to `limit` of a session's rows from `messages` and `messages_fts`,
+ * in the caller's transaction, and reports how many went. Bounded
+ * because a retention sweep must not hold one transaction over a whole
+ * session; a replace passes no limit, since its rows and their replacements
+ * have to land together.
+ */
+export function deleteSearchMessages(db: SyncDatabase, sessionId: number, limit = -1): number {
+ const ids = db
+ .prepare('SELECT id FROM messages WHERE session_row_id = ? LIMIT ?')
+ .all(sessionId, limit) as { id: number }[]
+ const full = db.prepare('DELETE FROM messages_fts WHERE rowid = ?')
+ const message = db.prepare('DELETE FROM messages WHERE id = ?')
+ for (const { id } of ids) {
+ full.run(id)
+ message.run(id)
+ }
+ return ids.length
+}
diff --git a/src/main/ai-vault-search/session-search-retention-delete.test.ts b/src/main/ai-vault-search/session-search-retention-delete.test.ts
new file mode 100644
index 00000000000..c21c8d7fe2b
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-retention-delete.test.ts
@@ -0,0 +1,188 @@
+import { expect, it } from 'vitest'
+import type SyncDatabase from '../sqlite/sync-database'
+import {
+ deleteExpiredSearchFiles,
+ RETENTION_DELETE_ROWS_PER_STEP
+} from './session-search-retention-delete'
+import { openSessionSearchIndexFile } from './session-search-index-test-fixture'
+import { SessionSearchStore } from './session-search-store'
+
+function seed(db: SyncDatabase, id: number, rows: number, mtime: number): void {
+ db.prepare(
+ `INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,resume_command)
+ VALUES (?, 'claude', ?, ?, 'synthetic retention', '/fixture', '/fixture', '')`
+ ).run(id, String(id), String(id))
+ db.prepare('INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES (?,1,?,?)').run(
+ String(id),
+ mtime,
+ id
+ )
+ db.exec('BEGIN')
+ for (let i = 0; i < rows; i++) {
+ const row = db
+ .prepare("INSERT INTO messages(session_row_id,role) VALUES (?,'user')")
+ .run(id).lastInsertRowid
+ db.prepare('INSERT INTO messages_fts(rowid,user_text) VALUES (?,?)').run(row, 'retentionneedle')
+ }
+ db.exec('COMMIT')
+}
+
+/**
+ * Sessions a search would still return. Every retrieval joins a message to its
+ * session, which is what makes cutting the session loose enough to hide the
+ * whole thing while its rows are still being reclaimed.
+ */
+function visibleSessionIds(db: SyncDatabase): string[] {
+ return (
+ db
+ .prepare(
+ `SELECT DISTINCT s.session_id AS id FROM messages_fts
+ JOIN messages m ON m.id = messages_fts.rowid
+ JOIN sessions s ON s.id = m.session_row_id
+ WHERE messages_fts MATCH 'retentionneedle' ORDER BY s.session_id`
+ )
+ .all() as { id: string }[]
+ ).map((row) => row.id)
+}
+
+function count(db: SyncDatabase, table: string): number {
+ return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n
+}
+
+it('seeks the expiring end of the file list instead of scanning it', async () => {
+ const index = await openSessionSearchIndexFile('ss-retention-plan')
+ try {
+ seed(index.db, 1, 1, 1)
+ const plan = (
+ index.db
+ .prepare('EXPLAIN QUERY PLAN SELECT path FROM files WHERE mtime_ms < ? ORDER BY mtime_ms')
+ .all(100) as { detail: string }[]
+ )
+ .map((row) => row.detail)
+ .join(' ')
+ // Without files_mtime this is "SCAN files" plus a "USE TEMP B-TREE FOR ORDER BY".
+ expect(plan).toContain('files_mtime')
+ expect(plan).not.toContain('TEMP B-TREE')
+ } finally {
+ await index.close()
+ }
+})
+
+it('hides an expiring session at once, then reclaims its rows in bounded steps', async () => {
+ const index = await openSessionSearchIndexFile('ss-retention-yield')
+ seed(index.db, 1, 1025, 1)
+ seed(index.db, 2, 1, 200)
+ let previous = 1025
+ const steps: number[] = []
+ try {
+ await deleteExpiredSearchFiles(
+ index.db,
+ 100,
+ () => false,
+ async () => {
+ const left = count(index.db, 'messages WHERE session_row_id=1')
+ steps.push(previous - left)
+ previous = left
+ // Cut loose in the very first transaction, so no query ever sees it with
+ // some of its messages already gone.
+ expect(visibleSessionIds(index.db)).toEqual(['2'])
+ }
+ )
+ // The file transaction, then one bounded batch per step until the rows are gone.
+ expect(steps).toEqual([0, RETENTION_DELETE_ROWS_PER_STEP, 256, 256, 256, 1])
+ expect(count(index.db, 'messages_fts')).toBe(1)
+ expect(count(index.db, 'sessions')).toBe(1)
+ } finally {
+ await index.close()
+ }
+})
+
+it('finishes an interrupted deletion after reopening', async () => {
+ const index = await openSessionSearchIndexFile('ss-retention-resume')
+ let store = new SessionSearchStore(index.path)
+ let closed = false
+ let steps = 0
+ try {
+ seed(index.db, 1, 513, 1)
+ await deleteExpiredSearchFiles(
+ index.db,
+ 100,
+ () => closed,
+ async () => {
+ if (++steps === 2) {
+ store.close()
+ closed = true
+ }
+ }
+ )
+ // Some rows went, the rest did not, and nothing recorded that anywhere.
+ const stranded = count(index.db, 'messages')
+ expect(stranded).toBeGreaterThan(0)
+ expect(stranded).toBeLessThan(513)
+ expect(visibleSessionIds(index.db)).toEqual([])
+
+ store = new SessionSearchStore(index.path)
+ closed = false
+ // Rows nothing points at are the whole record of unfinished work, so the
+ // rest goes even with retention now unlimited.
+ await store.purgeOlderThan(null)
+ expect(count(index.db, 'messages')).toBe(0)
+ expect(count(index.db, 'messages_fts')).toBe(0)
+ } finally {
+ if (!closed) {
+ store.close()
+ }
+ await index.close()
+ }
+})
+
+it('cancels retention between batches and resumes without exposing a partial session', async () => {
+ const index = await openSessionSearchIndexFile('ss-retention-cancel')
+ const store = new SessionSearchStore(index.path)
+ try {
+ seed(index.db, 1, 1025, 1)
+ const controller = new AbortController()
+ const purge = store.purgeOlderThan(100, controller.signal)
+ setImmediate(() => controller.abort())
+ await purge
+ const remaining = count(index.db, 'messages')
+ expect(remaining).toBeGreaterThan(0)
+ expect(remaining).toBeLessThan(1025)
+ expect(visibleSessionIds(index.db)).toEqual([])
+ await store.purgeOlderThan(null)
+ expect(count(index.db, 'messages')).toBe(0)
+ } finally {
+ store.close()
+ await index.close()
+ }
+})
+
+it('keeps a file a read refreshed after the expiry list was taken', async () => {
+ const index = await openSessionSearchIndexFile('ss-retention-refreshed')
+ try {
+ seed(index.db, 1, 2, 1)
+ seed(index.db, 2, 2, 2)
+ let refreshed = false
+ // The scan of `files` happens once, up front. A read of the second transcript
+ // lands while the first is being deleted, which makes it new enough to keep.
+ await deleteExpiredSearchFiles(
+ index.db,
+ 100,
+ () => false,
+ async () => {
+ if (!refreshed) {
+ refreshed = true
+ index.db.prepare('UPDATE files SET mtime_ms = 500 WHERE path = ?').run('2')
+ }
+ }
+ )
+
+ // Only the per-file transaction re-reading the mtime it is about to act on
+ // keeps that session; the list it came from says both should go.
+ expect(count(index.db, 'files')).toBe(1)
+ expect(visibleSessionIds(index.db)).toEqual(['2'])
+ expect(count(index.db, 'messages')).toBe(2)
+ } finally {
+ await index.close()
+ }
+})
diff --git a/src/main/ai-vault-search/session-search-retention-delete.ts b/src/main/ai-vault-search/session-search-retention-delete.ts
new file mode 100644
index 00000000000..e8d407f8be9
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-retention-delete.ts
@@ -0,0 +1,102 @@
+import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
+import type SyncDatabase from '../sqlite/sync-database'
+import { deleteSearchMessages } from './session-search-message-rows'
+
+export const RETENTION_DELETE_ROWS_PER_STEP = 256
+// Why in step with the deletes rather than one sweep at the end: `auto_vacuum =
+// INCREMENTAL` holds every freed page until something asks for it back, and
+// asking for a whole purge's worth at once is one long stall (40 ms per 22 MB
+// freed, measured) instead of many short ones.
+const RECLAIM_PAGES_PER_STEP = 2000
+
+/**
+ * Drops every file older than the cutoff, then hands its rows back in bounded
+ * steps.
+ *
+ * The two halves are separate on purpose. Cutting a session loose from its file
+ * is one small transaction, and it is what makes the session stop answering
+ * searches — every read joins `sessions`, so a row whose session is gone is
+ * already unreachable. Reclaiming those rows is the expensive half, and it can
+ * be paused, interrupted or resumed at any point without a reader ever seeing a
+ * session that is half deleted. A crash in the middle leaves rows nothing
+ * points at, and `drainOrphanedMessages` finds them on the next pass.
+ */
+export async function deleteExpiredSearchFiles(
+ db: SyncDatabase,
+ cutoffMs: number | null,
+ closed: () => boolean,
+ yieldStep: () => Promise = yieldToEventLoop
+): Promise {
+ if (cutoffMs !== null) {
+ const expired = db
+ .prepare('SELECT path FROM files WHERE mtime_ms < ? ORDER BY mtime_ms')
+ .all(cutoffMs) as { path: string }[]
+ for (const { path } of expired) {
+ if (closed()) {
+ return
+ }
+ db.exec('BEGIN IMMEDIATE')
+ try {
+ // Re-read under the lock: a read of this file may have landed since the
+ // list was taken, which makes it new enough to keep.
+ const file = db
+ .prepare('SELECT session_row_id FROM files WHERE path = ? AND mtime_ms < ?')
+ .get(path, cutoffMs) as { session_row_id: number | null } | undefined
+ if (file) {
+ db.prepare('DELETE FROM sessions WHERE id = ?').run(file.session_row_id)
+ db.prepare('DELETE FROM files WHERE path = ?').run(path)
+ }
+ db.exec('COMMIT')
+ } catch (error) {
+ db.exec('ROLLBACK')
+ throw error
+ }
+ await yieldStep()
+ }
+ }
+ await drainOrphanedMessages(db, closed, yieldStep)
+}
+
+/**
+ * Deletes rows whose session no longer exists, a bounded batch per transaction.
+ *
+ * That set is exactly what retention, a replace that cut its old generation
+ * loose, a removed source and an interrupted earlier drain leave behind, so the
+ * index needs no record of unfinished work beyond the rows themselves.
+ *
+ * Exported for the store, which runs it after a replace commits for the same
+ * reason retention runs it after its own small transaction: cutting a session
+ * loose is what hides it, and reclaiming its rows is the half that must not
+ * hold one transaction.
+ */
+export async function drainOrphanedMessages(
+ db: SyncDatabase,
+ closed: () => boolean,
+ yieldStep: () => Promise = yieldToEventLoop
+): Promise {
+ // Ordered by session so one call to this walks a session's rows to the end
+ // before paying for the scan that finds the next one.
+ const nextOrphan = db.prepare(
+ `SELECT session_row_id FROM messages
+ WHERE session_row_id NOT IN (SELECT id FROM sessions) LIMIT 1`
+ )
+ let orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id
+ while (orphan !== undefined && !closed()) {
+ db.exec('BEGIN IMMEDIATE')
+ let deleted = 0
+ try {
+ deleted = deleteSearchMessages(db, orphan, RETENTION_DELETE_ROWS_PER_STEP)
+ db.exec('COMMIT')
+ } catch (error) {
+ db.exec('ROLLBACK')
+ throw error
+ }
+ db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`)
+ if (deleted < RETENTION_DELETE_ROWS_PER_STEP) {
+ orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id
+ }
+ await yieldStep()
+ }
+ // A `removeFile` frees its pages outside this loop and may leave none to drain.
+ db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`)
+}
diff --git a/src/main/ai-vault-search/session-search-row-identity.test.ts b/src/main/ai-vault-search/session-search-row-identity.test.ts
new file mode 100644
index 00000000000..4adc655b584
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-row-identity.test.ts
@@ -0,0 +1,116 @@
+import { afterEach, beforeEach, expect, it } from 'vitest'
+import type SyncDatabase from '../sqlite/sync-database'
+import { deleteExpiredSearchFiles } from './session-search-retention-delete'
+import {
+ openSessionSearchIndexFile,
+ syntheticCandidate,
+ syntheticSession,
+ userMessages,
+ type SessionSearchIndexFile
+} from './session-search-index-test-fixture'
+import { SessionSearchStore } from './session-search-store'
+
+// A session row id outlives the row: it names the rows in `messages` until a
+// retention drain has walked all of them, which takes many transactions. These
+// tests are about what may be handed that id in the meantime.
+
+let index: SessionSearchIndexFile
+let store: SessionSearchStore
+let errors: unknown[]
+
+beforeEach(async () => {
+ index = await openSessionSearchIndexFile('ss-row-identity')
+ errors = []
+ store = new SessionSearchStore(index.path, (error) => errors.push(error))
+})
+
+afterEach(async () => {
+ store.close()
+ await index.close()
+})
+
+const OLD_MTIME = 1_000
+const LIVE_MTIME = 1_000_000
+const LIVE_PATH = '/live.jsonl'
+
+function count(db: SyncDatabase, table: string): number {
+ return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n
+}
+
+/** Rows a search would return for a term: the join every retrieval makes. */
+function matches(db: SyncDatabase, term: string): number {
+ return (
+ db
+ .prepare(
+ `SELECT count(*) AS n FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid
+ JOIN sessions s ON s.id = m.session_row_id WHERE messages_fts MATCH ?`
+ )
+ .get(term) as { n: number }
+ ).n
+}
+
+function indexFile(path: string, mtimeMs: number, text: string, rows: number): void {
+ const write = store.beginWrite(syntheticCandidate({ path, mtimeMs }), 'replace', 0)!
+ for (const message of userMessages(text, rows)) {
+ write.add(message)
+ }
+ expect(write.commit({ session: syntheticSession(), byteOffset: 50, incomplete: false })).toBe(
+ true
+ )
+}
+
+it('never hands a live session the rows of a purged one', async () => {
+ // Two expiring transcripts, each large enough that reclaiming their rows takes
+ // several transactions, and one live transcript the parser decoded no session
+ // from — so it holds a cursor and no session row of its own.
+ indexFile('/old-a.jsonl', OLD_MTIME, 'purgedneedle', 400)
+ indexFile('/old-b.jsonl', OLD_MTIME, 'purgedneedle', 400)
+ const live = syntheticCandidate({ path: LIVE_PATH, mtimeMs: LIVE_MTIME })
+ const opening = store.beginWrite(live, 'replace', 0)!
+ opening.add(userMessages('excluded', 1)[0]!)
+ expect(opening.commit({ session: null, byteOffset: 50, incomplete: false })).toBe(true)
+
+ let appended = false
+ await deleteExpiredSearchFiles(
+ index.db,
+ LIVE_MTIME,
+ () => false,
+ async () => {
+ // The window: both expiring sessions are cut loose, most of their rows are
+ // still on disk, and the live transcript grows. The append is legitimate —
+ // it continues this index's own cursor — and it needs a session row.
+ if (appended || count(index.db, 'sessions') > 0) {
+ return
+ }
+ appended = true
+ const write = store.beginWrite(live, 'append', 50)!
+ for (const message of userMessages('liveneedle', 2)) {
+ write.add(message)
+ }
+ expect(
+ write.commit({ session: syntheticSession(), byteOffset: 120, incomplete: false })
+ ).toBe(true)
+ }
+ )
+
+ expect(appended).toBe(true)
+ // Reusing a freed id would adopt whatever of that session's rows the drain had
+ // not reached, and put them behind a live session no purge will visit again.
+ expect(matches(index.db, 'purgedneedle')).toBe(0)
+ expect(matches(index.db, 'liveneedle')).toBe(2)
+ expect(count(index.db, 'messages')).toBe(2)
+ expect(errors).toEqual([])
+})
+
+it('never reissues a session row id a delete freed', () => {
+ for (const path of ['/a.jsonl', '/b.jsonl', '/c.jsonl']) {
+ indexFile(path, OLD_MTIME, 'seeded', 1)
+ }
+ const before = (index.db.prepare('SELECT max(id) AS id FROM sessions').get() as { id: number }).id
+ index.db.exec('DELETE FROM sessions')
+
+ indexFile('/d.jsonl', OLD_MTIME, 'seeded', 1)
+ expect((index.db.prepare('SELECT id FROM sessions').get() as { id: number }).id).toBeGreaterThan(
+ before
+ )
+})
diff --git a/src/main/ai-vault-search/session-search-schema.test.ts b/src/main/ai-vault-search/session-search-schema.test.ts
new file mode 100644
index 00000000000..b23f36cde55
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-schema.test.ts
@@ -0,0 +1,350 @@
+import type * as NodeFs from 'node:fs'
+import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ removeTree,
+ WINDOWS_RM_MAX_RETRIES,
+ WINDOWS_RM_RETRY_DELAY_MS
+} from '../../shared/windows-transient-lock-removal'
+import SyncDatabase from '../sqlite/sync-database'
+import {
+ SESSION_SEARCH_SCHEMA_VERSION,
+ openSessionSearchDatabase,
+ removeSessionSearchDatabase
+} from './session-search-schema'
+
+const recordedRmSync = vi.hoisted(() => vi.fn())
+vi.mock('node:fs', async () => {
+ const actual = await vi.importActual('node:fs')
+ return {
+ ...actual,
+ rmSync: (...args: Parameters) => {
+ recordedRmSync(...args)
+ return actual.rmSync(...args)
+ }
+ }
+})
+
+let roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.map((root) => removeTree(root)))
+ roots = []
+})
+
+async function tempDatabasePath(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-session-search-schema-'))
+ roots.push(root)
+ return join(root, 'index.sqlite')
+}
+
+function schemaVersion(db: SyncDatabase): string | undefined {
+ return (
+ db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as
+ | { value: string }
+ | undefined
+ )?.value
+}
+
+describe('openSessionSearchDatabase', () => {
+ it('keeps a current-version index and its rows', async () => {
+ const path = await tempDatabasePath()
+ const first = openSessionSearchDatabase(path)
+ first.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
+ first.close()
+
+ const second = openSessionSearchDatabase(path)
+ expect(schemaVersion(second)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
+ expect(second.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
+ c: 1
+ })
+ second.close()
+ })
+
+ it('carries one FTS table and throws away an index that carries two', async () => {
+ const path = await tempDatabasePath()
+ const fresh = openSessionSearchDatabase(path)
+ const tables = (): string[] =>
+ (
+ fresh
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%_fts'")
+ .all() as { name: string }[]
+ ).map((row) => row.name)
+ expect(tables()).toEqual(['messages_fts'])
+
+ // What an index written before this bump looks like: the second table, and
+ // rows in it. `CREATE TABLE IF NOT EXISTS` would leave both in place, so
+ // only the version bump makes that file go.
+ fresh.exec('CREATE VIRTUAL TABLE conversation_fts USING fts5(user_text, assistant_text)')
+ fresh.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
+ fresh.prepare("UPDATE meta SET value = '3' WHERE key = 'schema_version'").run()
+ fresh.close()
+
+ const rebuilt = openSessionSearchDatabase(path)
+ expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
+ expect(
+ rebuilt
+ .prepare("SELECT count(*) AS n FROM sqlite_master WHERE name = 'conversation_fts'")
+ .get()
+ ).toEqual({ n: 0 })
+ expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ c: 0 })
+ rebuilt.close()
+ })
+
+ it('replaces the file on a version mismatch instead of dropping tables in place', async () => {
+ const path = await tempDatabasePath()
+ const stale = openSessionSearchDatabase(path)
+ stale.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
+ stale
+ .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'")
+ .run(String(SESSION_SEARCH_SCHEMA_VERSION + 1))
+ stale.close()
+ // Why: a stale sidecar must go with the main file, or SQLite replays it into the new one.
+ await writeFile(`${path}-wal`, 'stale wal bytes')
+ const before = await stat(path)
+
+ const fresh = openSessionSearchDatabase(path)
+ expect(schemaVersion(fresh)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
+ expect(fresh.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
+ c: 0
+ })
+ fresh.close()
+ // Why not inode: ext4 hands a freed inode straight back to the next create.
+ // The planted sidecar is gone (a fresh WAL is checkpointed away on close).
+ await expect(stat(`${path}-wal`)).rejects.toMatchObject({ code: 'ENOENT' })
+ expect((await stat(path)).mtimeMs).toBeGreaterThanOrEqual(before.mtimeMs)
+ })
+
+ it('removes the database with every sidecar', async () => {
+ const path = await tempDatabasePath()
+ openSessionSearchDatabase(path).close()
+ await writeFile(`${path}-shm`, '')
+ removeSessionSearchDatabase(path)
+ for (const suffix of ['', '-wal', '-shm']) {
+ await expect(stat(`${path}${suffix}`)).rejects.toMatchObject({
+ code: 'ENOENT'
+ })
+ }
+ })
+})
+
+it('rebuilds a file too corrupt to open instead of refusing forever', async () => {
+ const path = await tempDatabasePath()
+ const healthy = openSessionSearchDatabase(path)
+ healthy.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
+ healthy.close()
+ // A torn page, not a truncation: SQLite opens the header and fails on the read.
+ const bytes = await readFile(path)
+ bytes.fill(0x7f, 4096, Math.min(bytes.length, 12_288))
+ await writeFile(path, bytes)
+
+ const rebuilt = openSessionSearchDatabase(path)
+ try {
+ expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
+ expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
+ c: 0
+ })
+ } finally {
+ rebuilt.close()
+ }
+})
+
+it('rebuilds a file that is not a database at all', async () => {
+ const path = await tempDatabasePath()
+ await writeFile(path, 'not a SQLite database')
+
+ const rebuilt = openSessionSearchDatabase(path)
+ try {
+ expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
+ } finally {
+ rebuilt.close()
+ }
+})
+
+it('gives up rather than looping when a fresh file still cannot be opened', async () => {
+ const path = await tempDatabasePath()
+ await writeFile(path, 'not a SQLite database')
+ // Every open of this path fails, so the one permitted retry is exhausted.
+ const open = vi.spyOn(SyncDatabase.prototype, 'pragma').mockImplementation(() => {
+ throw Object.assign(new Error('database disk image is malformed'), {
+ code: 'SQLITE_CORRUPT'
+ })
+ })
+ try {
+ expect(() => openSessionSearchDatabase(path)).toThrow(/malformed/)
+ } finally {
+ open.mockRestore()
+ }
+})
+
+it('surfaces the unlink failure itself when a stale index cannot be removed', async () => {
+ const path = await tempDatabasePath()
+ const stale = openSessionSearchDatabase(path)
+ stale
+ .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'")
+ .run(String(SESSION_SEARCH_SCHEMA_VERSION + 1))
+ stale.close()
+ recordedRmSync.mockReset()
+ recordedRmSync.mockImplementation(() => {
+ throw Object.assign(new Error('EPERM: operation not permitted, unlink'), {
+ code: 'EPERM'
+ })
+ })
+ try {
+ // The stale handle is closed before the unlink, so the failure path must not
+ // close it again: ERR_INVALID_STATE would bury the cause and would not be
+ // classified as worth a rebuild.
+ expect(() => openSessionSearchDatabase(path)).toThrow(/EPERM/)
+ expect(() => openSessionSearchDatabase(path)).not.toThrow(/not open/)
+ } finally {
+ recordedRmSync.mockReset()
+ }
+})
+
+it('creates the directory the index lives in', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-session-search-mkdir-'))
+ roots.push(root)
+ // The real layout: `/ai-vault-search/index.sqlite`, where nothing
+ // has made that folder yet. SQLite would fail with `unable to open database
+ // file`, which is correctly not treated as corruption, so it never retries.
+ const db = openSessionSearchDatabase(join(root, 'ai-vault-search', 'index.sqlite'))
+ try {
+ expect(schemaVersion(db)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
+ } finally {
+ db.close()
+ }
+})
+
+it('rebuilds a newer index rather than reading a schema it does not know', async () => {
+ const path = await tempDatabasePath()
+ const newer = openSessionSearchDatabase(path)
+ newer.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
+ newer
+ .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'")
+ .run(String(SESSION_SEARCH_SCHEMA_VERSION + 1))
+ newer.close()
+
+ const rebuilt = openSessionSearchDatabase(path)
+ try {
+ expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
+ expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
+ c: 0
+ })
+ } finally {
+ rebuilt.close()
+ }
+})
+
+it('rebuilds when meta exists but its version row is gone', async () => {
+ const path = await tempDatabasePath()
+ const damaged = openSessionSearchDatabase(path)
+ damaged.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
+ // A meta table with no version is a damaged index, never a fresh one: seeding
+ // the current version over it would keep whatever the old schema left behind.
+ damaged.prepare("DELETE FROM meta WHERE key = 'schema_version'").run()
+ damaged.close()
+
+ const rebuilt = openSessionSearchDatabase(path)
+ try {
+ expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
+ expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
+ c: 0
+ })
+ } finally {
+ rebuilt.close()
+ }
+})
+
+it('opens with the pragmas the write path depends on', async () => {
+ const db = openSessionSearchDatabase(await tempDatabasePath())
+ try {
+ // auto_vacuum=2 is INCREMENTAL, and only takes on an empty file: without it
+ // a purge cannot hand pages back in bounded steps.
+ expect(Number(db.pragma('auto_vacuum', { simple: true }))).toBe(2)
+ expect(String(db.pragma('journal_mode', { simple: true })).toLowerCase()).toBe('wal')
+ expect(Number(db.pragma('synchronous', { simple: true }))).toBe(1)
+ // A WAL with no size limit never hands its space back after a large write.
+ expect(Number(db.pragma('journal_size_limit', { simple: true }))).toBe(8388608)
+ // Zero here turns every contended write into an immediate SQLITE_BUSY.
+ expect(Number(db.pragma('busy_timeout', { simple: true }))).toBe(5000)
+ } finally {
+ db.close()
+ }
+})
+
+it("walks a session's rows through an index rather than scanning the table", async () => {
+ const db = openSessionSearchDatabase(await tempDatabasePath())
+ try {
+ // The replace delete and the orphan drain both take this path, once per file.
+ const plan = (
+ db
+ .prepare('EXPLAIN QUERY PLAN SELECT id FROM messages WHERE session_row_id = ? LIMIT ?')
+ .all(1, 1) as { detail: string }[]
+ )
+ .map((row) => row.detail)
+ .join(' ')
+ expect(plan).toContain('messages_session')
+ } finally {
+ db.close()
+ }
+})
+
+it('keeps only the session indexes a retrieval query can seek', async () => {
+ const db = openSessionSearchDatabase(await tempDatabasePath())
+ try {
+ const names = (
+ db
+ .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='sessions'")
+ .all() as { name: string }[]
+ )
+ .map((row) => row.name)
+ .sort()
+ // One per shape PR 4's retrieval seeks: the agent filter, the newest-first
+ // order and date window, and the folder-prefix range scan. Fork folding reads
+ // `content_hash` off rows it already holds, so that column is not indexed.
+ expect(names).toEqual(['sessions_agent', 'sessions_cwd_key', 'sessions_updated_at'])
+ } finally {
+ db.close()
+ }
+})
+
+it("retries a Windows lock that outlives rmSync's own retries", async () => {
+ const path = await tempDatabasePath()
+ openSessionSearchDatabase(path).close()
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+ recordedRmSync.mockReset()
+ const locked = Object.assign(new Error('EPERM: operation not permitted'), {
+ code: 'EPERM'
+ })
+ recordedRmSync.mockImplementationOnce(() => {
+ throw locked
+ })
+ try {
+ expect(() => removeSessionSearchDatabase(path)).not.toThrow()
+ expect(recordedRmSync.mock.calls.length).toBe(5)
+ await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
+ } finally {
+ recordedRmSync.mockReset()
+ vi.restoreAllMocks()
+ }
+})
+
+it('gives Windows the shared retry options for a late handle release', async () => {
+ const path = await tempDatabasePath()
+ vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+ recordedRmSync.mockClear()
+ try {
+ removeSessionSearchDatabase(path)
+ expect(recordedRmSync).toHaveBeenCalled()
+ for (const [, options] of recordedRmSync.mock.calls) {
+ expect(options).toMatchObject({
+ maxRetries: WINDOWS_RM_MAX_RETRIES,
+ retryDelay: WINDOWS_RM_RETRY_DELAY_MS
+ })
+ }
+ } finally {
+ vi.restoreAllMocks()
+ }
+})
diff --git a/src/main/ai-vault-search/session-search-schema.ts b/src/main/ai-vault-search/session-search-schema.ts
new file mode 100644
index 00000000000..2da1e64bc11
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-schema.ts
@@ -0,0 +1,198 @@
+import { mkdirSync } from 'node:fs'
+import { dirname } from 'node:path'
+import SyncDatabase from '../sqlite/sync-database'
+import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
+
+// The index stores transcript content as written, with no redaction. A secret in
+// a transcript is already plaintext under the user's home directory and is
+// treated as compromised; this is a second copy of content the user already
+// holds. What a snippet may carry once it leaves this machine is a transport
+// policy, decided where the wire is.
+
+// Bump to drop and rebuild: the index is a cache over the transcripts, never a source.
+export const SESSION_SEARCH_SCHEMA_VERSION = 5
+
+// unicode61 keeps `_ . - /` inside tokens so paths and identifiers match exactly;
+// the `identifiers` column carries the split form (see session-search-identifier-split).
+// Why: `+` keeps `C++` a token of its own instead of the letter `c`; `#` is
+// left out so `#123` still answers a search for `123`.
+const TOKENIZER = `tokenize="unicode61 tokenchars '_.-/+'"`
+
+const SCHEMA_SQL = `
+CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
+CREATE TABLE IF NOT EXISTS sessions(
+ -- AUTOINCREMENT, because this id names rows in the messages table for longer
+ -- than the row itself lives: retention cuts a session loose in one
+ -- transaction and reclaims its messages over many. A plain rowid is reissued
+ -- as max+1, so a session created inside that window would be handed a freed
+ -- id and adopt whatever of the purged conversation the drain had not reached,
+ -- behind a live session no later purge visits.
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ agent TEXT NOT NULL,
+ session_id TEXT NOT NULL,
+ -- The transcript this session was decoded from. Not unique: OpenCode's SQLite
+ -- sessions all report the store's own path here, while files.path holds the
+ -- synthetic db#sessionId candidate that really is one per session.
+ file_path TEXT NOT NULL,
+ codex_home TEXT,
+ title TEXT NOT NULL,
+ cwd TEXT,
+ cwd_key TEXT,
+ branch TEXT,
+ created_at TEXT,
+ updated_at TEXT,
+ message_count INTEGER NOT NULL DEFAULT 0,
+ resume_command TEXT NOT NULL,
+ -- Chained digest of the first N messages; forks of one conversation share it.
+ content_hash TEXT,
+ content_hash_count INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS sessions_agent ON sessions(agent);
+CREATE INDEX IF NOT EXISTS sessions_updated_at ON sessions(updated_at);
+CREATE INDEX IF NOT EXISTS sessions_cwd_key ON sessions(cwd_key);
+CREATE TABLE IF NOT EXISTS files(
+ path TEXT PRIMARY KEY,
+ dev INTEGER,
+ ino INTEGER,
+ byte_offset INTEGER NOT NULL,
+ mtime_ms REAL NOT NULL,
+ size_bytes INTEGER,
+ session_row_id INTEGER
+);
+-- Retention walks the expiring end of this column; without it that is a full scan and a sort.
+CREATE INDEX IF NOT EXISTS files_mtime ON files(mtime_ms);
+CREATE TABLE IF NOT EXISTS messages(
+ id INTEGER PRIMARY KEY,
+ session_row_id INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ ts TEXT
+);
+-- Both the replace delete and the orphan drain walk a session's rows through this.
+CREATE INDEX IF NOT EXISTS messages_session ON messages(session_row_id);
+-- One FTS table, not two. A conversation-scoped search is a column filter on
+-- this one — 'MATCH {user_text assistant_text}: q' with bm25 weights that zero
+-- the other two — and PR 4 measured that at 1.16-1.36x the p95 of a dedicated
+-- second table on a 105 MB corpus, under the 2x bar the decision was set at.
+CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
+ user_text, assistant_text, tool_text, identifiers, ${TOKENIZER}, detail=full
+);
+`
+
+/**
+ * Opens the index, rebuilding it whenever what is on disk cannot be trusted:
+ * a different schema version, a version SQLite cannot report, or a file torn
+ * badly enough that opening or recovery fails. The index is a cache over the
+ * transcripts, so throwing away a bad one costs a re-scan and nothing else;
+ * refusing to open would strand the feature until a human deleted the file.
+ */
+export function openSessionSearchDatabase(path: string): SyncDatabase {
+ // SQLite will not create the directory, and its failure is `unable to open
+ // database file`, which is correctly not corruption — so without this the
+ // feature strands on a profile that has never held an index.
+ if (path !== ':memory:') {
+ mkdirSync(dirname(path), { recursive: true })
+ }
+ try {
+ return openExisting(path)
+ } catch (error) {
+ if (!isUnusableDatabaseError(error)) {
+ throw error
+ }
+ // One retry only: a second failure on a file we just created is not corruption.
+ removeSessionSearchDatabase(path)
+ return openExisting(path)
+ }
+}
+
+function openExisting(path: string): SyncDatabase {
+ // Nulled while no handle is open, because closing an already-closed handle
+ // throws ERR_INVALID_STATE, which would replace whatever really failed —
+ // an unlink refused by a virus scanner or a second Orca holding the file —
+ // with an error nothing classifies as worth rebuilding for.
+ let db: SyncDatabase | null = openWithPragmas(path)
+ try {
+ if (isStaleSchema(db)) {
+ // Why: DROP TABLE on a multi-GB FTS index takes minutes and runs inside the
+ // scanner service's init, past its ready timeout; unlinking is instant.
+ db.close()
+ db = null
+ removeSessionSearchDatabase(path)
+ db = openWithPragmas(path)
+ }
+ db.exec(SCHEMA_SQL)
+ db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run(
+ 'schema_version',
+ String(SESSION_SEARCH_SCHEMA_VERSION)
+ )
+ return db
+ } catch (error) {
+ db?.close()
+ throw error
+ }
+}
+
+// SQLite reports a torn file at the first statement that has to read a page, so
+// this has to match on the message as well as the code.
+const UNUSABLE_DATABASE =
+ /SQLITE_CORRUPT|SQLITE_NOTADB|file is not a database|database disk image is malformed/i
+
+function isUnusableDatabaseError(error: unknown): boolean {
+ if (!(error instanceof Error)) {
+ return false
+ }
+ const code = (error as { code?: unknown }).code
+ return (
+ (typeof code === 'string' && UNUSABLE_DATABASE.test(code)) ||
+ UNUSABLE_DATABASE.test(error.message)
+ )
+}
+
+function openWithPragmas(path: string): SyncDatabase {
+ const db = new SyncDatabase(path)
+ try {
+ // Why: only takes effect on an empty file; it is what lets a purge hand pages
+ // back in bounded steps instead of a full VACUUM. Set before any table exists.
+ db.pragma('auto_vacuum = INCREMENTAL')
+ // The whole consistency model: a file's rows and its cursor land in one
+ // transaction, and a reader on another handle sees the last committed state
+ // of the index rather than a session half way through being rewritten.
+ db.pragma('journal_mode = WAL')
+ db.pragma('synchronous = NORMAL')
+ db.pragma('journal_size_limit = 8388608')
+ db.pragma('busy_timeout = 5000')
+ return db
+ } catch (error) {
+ db?.close()
+ throw error
+ }
+}
+
+export function removeSessionSearchDatabase(path: string): void {
+ if (path === ':memory:') {
+ return
+ }
+ for (const suffix of ['', '-wal', '-shm', '-journal']) {
+ removeTreeSync(`${path}${suffix}`)
+ }
+}
+
+/**
+ * Whether what is on disk has to be thrown away. No `meta` table at all is a
+ * file with nothing in it to throw away, and removing it would make the first
+ * open of every new profile a create-remove-create. A meta table whose version
+ * row is missing or unparseable is a damaged index rather than a new one:
+ * seeding the current version over it would keep whatever rows the old schema
+ * left.
+ */
+function isStaleSchema(db: SyncDatabase): boolean {
+ const table = db
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'")
+ .get()
+ if (!table) {
+ return false
+ }
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as
+ | { value: string }
+ | undefined
+ return (row ? Number(row.value) : Number.NaN) !== SESSION_SEARCH_SCHEMA_VERSION
+}
diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts
new file mode 100644
index 00000000000..da9f4d6f731
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-store.ts
@@ -0,0 +1,253 @@
+import type SyncDatabase from '../sqlite/sync-database'
+import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
+import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers'
+import type {
+ SessionSearchFileIdentity,
+ SessionSearchIndexedFile
+} from './session-search-file-cursor'
+import {
+ SESSION_SEARCH_COMMIT_CHARS,
+ SessionSearchIndexWriter,
+ type SessionSearchFileWrite
+} from './session-search-index-writer'
+import { deleteExpiredSearchFiles, drainOrphanedMessages } from './session-search-retention-delete'
+import { openSessionSearchDatabase } from './session-search-schema'
+
+// A paused store keeps recording what it declined, so the set needs a ceiling.
+// Above it the oldest record goes and the drop is counted, because a re-read set
+// that silently forgets is worse than one that says it is incomplete.
+export const STALE_PATH_LIMIT = 20_000
+
+/**
+ * Owns the index database. PR 2 scope: the write half only — the transcript
+ * consumer writes through it and nothing reads from it yet. Lifecycle (who
+ * indexes, when, and how the re-read set is drained) belongs to the service.
+ */
+export class SessionSearchStore {
+ private readonly db: SyncDatabase
+ private readonly writer: SessionSearchIndexWriter
+ private closed = false
+ private acceptingWrites = true
+ private retentionCutoffMs: number | null = null
+ // Files this index knows it is behind on. Filled by a declined or abandoned
+ // read; PR 3's indexer drains it. Nothing here schedules the re-read.
+ private readonly stale = new Map()
+ private droppedStalePaths = 0
+ // One drain at a time. A replace that commits while one is running asks for
+ // another pass rather than starting a second walk of the same rows.
+ private draining = false
+ private drainRequested = false
+
+ constructor(
+ path: string,
+ private readonly onError: (error: unknown) => void = (error) =>
+ console.warn(
+ '[ai-vault-search] index write failed:',
+ error instanceof Error ? error.name : 'IndexError'
+ )
+ ) {
+ this.db = openSessionSearchDatabase(path)
+ this.writer = new SessionSearchIndexWriter(this.db, SESSION_SEARCH_COMMIT_CHARS, () =>
+ this.scheduleOrphanDrain()
+ )
+ }
+
+ /**
+ * Reclaims the rows a replace cut loose, once its transaction has committed.
+ *
+ * The same split retention makes, for the same reason: deleting the old
+ * session row is what stops it answering, because every retrieval joins
+ * `sessions`, and handing its messages back is the expensive half that must
+ * not hold one transaction. Nothing records the work: rows whose session row
+ * is gone are the whole record, so a crash before or during a drain is found
+ * by the next one.
+ */
+ private scheduleOrphanDrain(): void {
+ this.drainRequested = true
+ if (this.draining || this.closed) {
+ return
+ }
+ this.draining = true
+ // Off the committing stack. An async function runs synchronously up to its
+ // first `await`, so calling the drain here would put its first batch back
+ // inside the call that committed the replace — the cost this took out.
+ void Promise.resolve().then(() => this.runOrphanDrain())
+ }
+
+ private async runOrphanDrain(): Promise {
+ try {
+ while (this.drainRequested && !this.closed) {
+ this.drainRequested = false
+ await drainOrphanedMessages(this.db, () => this.closed)
+ }
+ } catch (error) {
+ if (!this.closed) {
+ this.onError(error)
+ }
+ } finally {
+ this.draining = false
+ }
+ }
+
+ /**
+ * The index handle, for a reader composed over this store (PR 4's engine).
+ *
+ * Two rules come with it, both measured in this PR. **Never hold a read
+ * transaction across an `await`**: a checkpoint cannot pass an open read
+ * snapshot, so a paginated read that opened `BEGIN` and yielded between pages
+ * takes the WAL from 10 MB to 266 MB and it does not come back. And **no
+ * `.iterate()` that outlives its statement**, which is the same pin by
+ * another name. Every retrieval a single synchronous statement is the whole
+ * contract.
+ */
+ get connection(): SyncDatabase {
+ return this.db
+ }
+
+ setAcceptingWrites(accept: boolean): void {
+ this.acceptingWrites = accept
+ }
+
+ /** The oldest transcript mtime worth indexing; PR 3 derives it from the retention setting. */
+ setRetentionCutoffMs(cutoffMs: number | null): void {
+ this.retentionCutoffMs = cutoffMs
+ }
+
+ /** Whether this candidate is new enough to be worth holding rows for at all. */
+ private withinRetention(candidate: SessionFileCandidate): boolean {
+ return this.retentionCutoffMs === null || candidate.file.mtimeMs >= this.retentionCutoffMs
+ }
+
+ /** Whether a write for this candidate may start right now. */
+ acceptsCandidate(candidate: SessionFileCandidate): boolean {
+ return !this.closed && this.acceptingWrites && this.withinRetention(candidate)
+ }
+
+ indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null {
+ try {
+ return this.writer.indexedFile(path, identity)
+ } catch (error) {
+ this.onError(error)
+ return null
+ }
+ }
+
+ /** Null when this read cannot extend the index, or when the store refuses writes. */
+ beginWrite(
+ candidate: SessionFileCandidate,
+ mode: 'replace' | 'append',
+ previousByteOffset: number,
+ identity?: () => TranscriptSessionIdentity | null
+ ): SessionSearchFileWrite | null {
+ if (!this.acceptsCandidate(candidate)) {
+ return null
+ }
+ try {
+ return this.writer.beginWrite(candidate, mode, previousByteOffset, identity)
+ } catch (error) {
+ this.reportWriteFailure(error)
+ return null
+ }
+ }
+
+ writeCommitted(candidate: SessionFileCandidate): void {
+ // Why: a list scan queues every file the backfill has not reached yet; once
+ // one lands, a later pass must not re-read the whole queue.
+ this.stale.delete(candidate.file.path)
+ }
+
+ reportWriteFailure(error: unknown): void {
+ this.onError(error)
+ }
+
+ /**
+ * Records a file whose content the index is behind on, for a later whole
+ * re-read. Recorded while paused too: a pause is exactly the window in which
+ * reads are declined, so refusing to remember them would lose every file the
+ * pause covered.
+ */
+ markStale(candidate: SessionFileCandidate): void {
+ if (this.closed || !this.withinRetention(candidate)) {
+ return
+ }
+ // Re-inserting moves the path to the end, so the oldest record is the one
+ // dropped when a long pause overruns the bound.
+ this.stale.delete(candidate.file.path)
+ this.stale.set(candidate.file.path, candidate)
+ while (this.stale.size > STALE_PATH_LIMIT) {
+ const oldest = this.stale.keys().next()
+ if (oldest.done) {
+ break
+ }
+ this.stale.delete(oldest.value)
+ this.droppedStalePaths += 1
+ }
+ }
+
+ /**
+ * Files the index knew it was behind on and could not keep a record of. A
+ * non-zero count means the re-read set is incomplete, so coverage cannot be
+ * reported as whole until a full pass runs.
+ */
+ get droppedPendingFileCount(): number {
+ return this.droppedStalePaths
+ }
+
+ /**
+ * Hands the re-read set to its scheduler and clears it.
+ *
+ * These paths are behind, not merely dirty: the index declined their last read
+ * because it covered a span the index never saw. Re-dispatching a scan is not
+ * enough on its own, because the reader picks `append` from the session list's
+ * resume point and the consumer will decline again. The caller must pass each
+ * path to `requestWholeTranscriptRead` first.
+ */
+ takeStale(): SessionFileCandidate[] {
+ const candidates = [...this.stale.values()]
+ this.stale.clear()
+ return candidates
+ }
+
+ get pendingFileCount(): number {
+ return this.stale.size
+ }
+
+ /**
+ * Drops a source's rows. Only a proven deletion may call this: an unreadable
+ * source is `unverifiable`, not `missing`, and keeps its rows
+ * (docs/reference/ssh-execution-boundary.md).
+ */
+ removeFile(path: string): void {
+ this.stale.delete(path)
+ try {
+ this.writer.removeFile(path)
+ } catch (error) {
+ this.onError(error)
+ }
+ }
+
+ /** Cuts expired sessions loose at once, then reclaims their rows in resumable batches. */
+ async purgeOlderThan(cutoffMs: number | null, signal?: AbortSignal): Promise {
+ try {
+ await deleteExpiredSearchFiles(
+ this.db,
+ cutoffMs,
+ () => this.closed || signal?.aborted === true
+ )
+ } catch (error) {
+ if (!this.closed) {
+ this.onError(error)
+ }
+ }
+ }
+
+ close(): void {
+ // node:sqlite throws ERR_INVALID_STATE on a second close, and a store is
+ // closed both by its owner and by a test's teardown.
+ if (this.closed) {
+ return
+ }
+ this.closed = true
+ this.db.close()
+ }
+}
diff --git a/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts b/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts
new file mode 100644
index 00000000000..9b6a48beb4e
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts
@@ -0,0 +1,44 @@
+import { rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import { expect, it } from 'vitest'
+import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
+import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
+import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
+import { SessionSearchStore } from './session-search-store'
+import { writeSyntheticTranscriptCorpus } from './session-search-synthetic-corpus'
+import { parseTranscript } from './session-search-transcript-fixtures'
+
+it.each([Infinity, -Infinity, Number.NaN, -1, 1.5])(
+ 'rejects invalid corpus loop bounds: %s',
+ async (value) => {
+ for (const field of ['sessions', 'turnsPerSession', 'toolResultWords']) {
+ await expect(writeSyntheticTranscriptCorpus({ [field]: value })).rejects.toThrow(RangeError)
+ }
+ }
+)
+
+it.each([0, 200, 2000])(
+ 'counts the indexed messages with %s tool words',
+ async (toolResultWords) => {
+ const corpus = await writeSyntheticTranscriptCorpus({
+ sessions: 1,
+ turnsPerSession: 1,
+ toolResultWords
+ })
+ const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'))
+ const unregister = registerSessionSearchIndexConsumer(store)
+ try {
+ await parseTranscript(corpus.files[0]!)
+ expect(corpus.messageCount).toBe(toolResultWords === 0 ? 3 : 4)
+ expect(store.connection.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({
+ n: corpus.messageCount
+ })
+ } finally {
+ unregister()
+ resetTranscriptConsumersForTests()
+ resetSessionParseCacheForTests()
+ store.close()
+ await rm(corpus.root, { recursive: true, force: true })
+ }
+ }
+)
diff --git a/src/main/ai-vault-search/session-search-synthetic-corpus.ts b/src/main/ai-vault-search/session-search-synthetic-corpus.ts
new file mode 100644
index 00000000000..14e8a27fe5f
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-synthetic-corpus.ts
@@ -0,0 +1,154 @@
+import { mkdtemp, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+// Why synthetic and in-repo: the cost model has to be reproducible on any host
+// and must never read a real transcript. The shapes here mirror what a Claude
+// JSONL transcript actually holds — prose turns, a pasted diff, tool calls and
+// their output — because the index's disk cost tracks the mix, not the size.
+
+const WORDS = [
+ 'terminal',
+ 'reattach',
+ 'worktree',
+ 'resolveTerminalPath',
+ 'src/main/ai-vault/session-transcript-reader.ts',
+ 'the',
+ 'index',
+ 'cursor',
+ 'byteOffset',
+ 'publish',
+ 'staged',
+ 'transaction',
+ 'MAX_RETRIES',
+ 'relay',
+ 'daemon',
+ 'pty',
+ 'snapshot',
+ 'because'
+]
+
+/** Deterministic: the same seed gives the same corpus on every host and run. */
+function mulberry32(seed: number): () => number {
+ let state = seed >>> 0
+ return () => {
+ state = (state + 0x6d2b79f5) >>> 0
+ let t = Math.imul(state ^ (state >>> 15), 1 | state)
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ }
+}
+
+function words(random: () => number, count: number): string {
+ const out: string[] = []
+ for (let index = 0; index < count; index++) {
+ out.push(WORDS[Math.floor(random() * WORDS.length)])
+ }
+ return out.join(' ')
+}
+
+export type SyntheticCorpus = {
+ root: string
+ files: string[]
+ /** Total bytes of transcript written, the denominator of write amplification. */
+ transcriptBytes: number
+ messageCount: number
+}
+
+export type SyntheticCorpusOptions = {
+ sessions?: number
+ turnsPerSession?: number
+ seed?: number
+ /**
+ * Words per tool result. The default keeps tool output at about half the
+ * message text; the real distribution is 80-97 %, which is what prices the
+ * tool-row cap, so the benchmark runs a second arm well above the default.
+ */
+ toolResultWords?: number
+}
+
+/** Writes a corpus of Claude JSONL transcripts and reports what it cost on disk. */
+export async function writeSyntheticTranscriptCorpus(
+ options: SyntheticCorpusOptions = {}
+): Promise {
+ const sessions = options.sessions ?? 40
+ const turns = options.turnsPerSession ?? 60
+ const toolWords = options.toolResultWords ?? 200
+ for (const [name, value] of Object.entries({
+ sessions,
+ turnsPerSession: turns,
+ toolResultWords: toolWords
+ })) {
+ if (!Number.isSafeInteger(value) || value < 0) {
+ throw new RangeError(`${name} must be a finite non-negative safe integer`)
+ }
+ }
+ const random = mulberry32(options.seed ?? 1)
+ const root = await mkdtemp(join(tmpdir(), 'orca-search-corpus-'))
+ const files: string[] = []
+ let transcriptBytes = 0
+ let messageCount = 0
+
+ for (let session = 0; session < sessions; session++) {
+ const sessionId = `00000000-0000-4000-8000-${String(session).padStart(12, '0')}`
+ const lines: string[] = []
+ for (let turn = 0; turn < turns; turn++) {
+ const at = new Date(1740000000000 + turn * 60_000).toISOString()
+ lines.push(
+ JSON.stringify({
+ type: 'user',
+ sessionId,
+ timestamp: at,
+ cwd: `/repo/app-${session % 7}`,
+ gitBranch: 'main',
+ message: { role: 'user', content: words(random, 40) }
+ })
+ )
+ lines.push(
+ JSON.stringify({
+ type: 'assistant',
+ sessionId,
+ timestamp: at,
+ message: {
+ role: 'assistant',
+ model: 'claude-fable-5',
+ content: [
+ { type: 'text', text: words(random, 120) },
+ {
+ type: 'tool_use',
+ name: 'Bash',
+ input: { command: `rg ${words(random, 3)}` }
+ }
+ ]
+ }
+ })
+ )
+ lines.push(
+ JSON.stringify({
+ type: 'user',
+ sessionId,
+ timestamp: at,
+ message: {
+ role: 'user',
+ content: [
+ {
+ type: 'tool_result',
+ tool_use_id: 'toolu_1',
+ content: words(random, toolWords)
+ }
+ ]
+ }
+ })
+ )
+ // Empty tool results emit no searchable message.
+ messageCount += toolWords === 0 ? 3 : 4
+ }
+ const path = join(root, `${sessionId}.jsonl`)
+ const body = `${lines.join('\n')}\n`
+ await writeFile(path, body)
+ transcriptBytes += Buffer.byteLength(body)
+ files.push(path)
+ }
+
+ return { root, files, transcriptBytes, messageCount }
+}
diff --git a/src/main/ai-vault-search/session-search-transcript-fixtures.ts b/src/main/ai-vault-search/session-search-transcript-fixtures.ts
new file mode 100644
index 00000000000..bc7eda9a8ff
--- /dev/null
+++ b/src/main/ai-vault-search/session-search-transcript-fixtures.ts
@@ -0,0 +1,118 @@
+import { stat } from 'node:fs/promises'
+import {
+ createSessionParseStats,
+ parseAgentSessionFileCached,
+ type SessionParseStats
+} from '../ai-vault/session-scanner-parse-cache'
+import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
+
+// Transcript builders shared by the session-search store tests; each file owns
+// its temp directories, this module only shapes records and drives the parser.
+
+export const CLAUDE_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
+export const CODEX_SESSION_ID = '019f0000-1111-7222-8333-444444444444'
+export const CODEX_ROLLOUT_FILE = `rollout-2026-05-01T10-00-00-${CODEX_SESSION_ID}.jsonl`
+
+const RECORD_EPOCH_MS = 1740000000000
+
+export function recordTimestamp(index: number): string {
+ return new Date(RECORD_EPOCH_MS + index * 60_000).toISOString()
+}
+
+export function userRecord(
+ index: number,
+ content: unknown,
+ sessionId = CLAUDE_SESSION_ID,
+ cwd = '/repo/app'
+): string {
+ return JSON.stringify({
+ type: 'user',
+ sessionId,
+ timestamp: recordTimestamp(index),
+ cwd,
+ gitBranch: 'main',
+ message: { role: 'user', content }
+ })
+}
+
+export function assistantRecord(
+ index: number,
+ content: unknown,
+ sessionId = CLAUDE_SESSION_ID
+): string {
+ return JSON.stringify({
+ type: 'assistant',
+ sessionId,
+ timestamp: recordTimestamp(index),
+ message: { role: 'assistant', model: 'claude-fable-5', content }
+ })
+}
+
+export async function sessionCandidate(
+ agent: SessionFileCandidate['agent'],
+ path: string,
+ codexHome: string | null = null
+): Promise {
+ const fileStat = await stat(path)
+ return {
+ agent,
+ codexHome,
+ file: {
+ path,
+ mtimeMs: fileStat.mtimeMs,
+ modifiedAt: fileStat.mtime.toISOString(),
+ sizeBytes: fileStat.size,
+ dev: fileStat.dev,
+ ino: fileStat.ino
+ }
+ }
+}
+
+export async function parseTranscript(
+ path: string,
+ agent: SessionFileCandidate['agent'] = 'claude',
+ codexHome: string | null = null
+): Promise<{ stats: SessionParseStats }> {
+ const stats = createSessionParseStats()
+ await parseAgentSessionFileCached(
+ await sessionCandidate(agent, path, codexHome),
+ process.platform,
+ stats
+ )
+ return { stats }
+}
+
+function codexLine(record: Record): string {
+ return JSON.stringify(record)
+}
+
+/** Minimal Codex rollout: meta, one user message, one completed shell command. */
+export function codexRolloutLines(command: string[], output: string, prompt: string): string[] {
+ return [
+ codexLine({
+ timestamp: recordTimestamp(0),
+ type: 'session_meta',
+ payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', git: { branch: 'main' } }
+ }),
+ codexLine({
+ timestamp: recordTimestamp(1),
+ type: 'response_item',
+ payload: { type: 'message', role: 'user', content: prompt }
+ }),
+ codexLine({
+ timestamp: recordTimestamp(2),
+ type: 'response_item',
+ payload: {
+ type: 'function_call',
+ call_id: 'call-1',
+ name: 'shell',
+ arguments: JSON.stringify({ command })
+ }
+ }),
+ codexLine({
+ timestamp: recordTimestamp(3),
+ type: 'response_item',
+ payload: { type: 'function_call_output', call_id: 'call-1', output }
+ })
+ ]
+}
diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts
index 88e09627eb7..18f273d6be3 100644
--- a/src/main/ai-vault/session-scanner-accumulator.ts
+++ b/src/main/ai-vault/session-scanner-accumulator.ts
@@ -23,7 +23,11 @@ import {
normalizePreviewText,
timestampMs
} from './session-scanner-values'
-import { NO_TRANSCRIPT_MESSAGES, type TranscriptMessageSink } from './session-transcript-consumers'
+import {
+ NO_TRANSCRIPT_MESSAGES,
+ type TranscriptMessageSink,
+ type TranscriptSessionIdentity
+} from './session-transcript-consumers'
import {
boundedText,
transcriptMessageRole,
@@ -64,6 +68,28 @@ export function createAccumulator(args: {
}
}
+/**
+ * The session identity a fold holds right now. Null until it has an id, which
+ * every supported format writes in the opening lines of the transcript.
+ */
+export function accumulatorSessionIdentity(
+ accumulator: SessionAccumulator
+): TranscriptSessionIdentity | null {
+ const sessionId = accumulator.sessionId.trim()
+ if (!sessionId) {
+ return null
+ }
+ return {
+ sessionId,
+ cwd: accumulator.cwd,
+ // The generated fallback is `finalizeSession`'s, not this one's: a title
+ // that is still absent mid-read is better said to be absent.
+ title: accumulator.title ?? accumulator.fallbackTitle,
+ createdAt: accumulator.createdAt,
+ updatedAt: accumulator.updatedAt
+ }
+}
+
export function cloneSessionAccumulator(accumulator: SessionAccumulator): SessionAccumulator {
return { ...accumulator, previewMessages: [...accumulator.previewMessages] }
}
@@ -77,6 +103,7 @@ export function accumulatorFoldResumeState(
): ResumableSessionParseState {
return {
consumeLine: (line) => consumeRecordLine(accumulator, line),
+ identity: () => accumulatorSessionIdentity(accumulator),
clone: () =>
accumulatorFoldResumeState(cloneSessionAccumulator(accumulator), consumeRecordLine),
touchFile: (file) => {
diff --git a/src/main/ai-vault/session-scanner-codex-message-records.ts b/src/main/ai-vault/session-scanner-codex-message-records.ts
index 5aa739275ff..a8a5c3c9d13 100644
--- a/src/main/ai-vault/session-scanner-codex-message-records.ts
+++ b/src/main/ai-vault/session-scanner-codex-message-records.ts
@@ -1,3 +1,7 @@
+import {
+ publishCodexResponseTool,
+ publishCodexCompletedTool
+} from './session-scanner-codex-tool-records'
import { normalizePromptField } from '../../shared/agent-status-field-normalization'
import { addPreviewContent } from './session-scanner-accumulator'
import type { SessionAccumulator } from './session-scanner-types'
@@ -8,6 +12,10 @@ export function consumeCodexResponseMessage(
payload: Record,
timestamp: unknown
): boolean {
+ publishCodexResponseTool(accumulator, payload, timestamp)
+ if (payload.type !== 'message') {
+ return false
+ }
accumulator.messageCount++
const role =
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown'
@@ -24,6 +32,7 @@ export function consumeCodexCompletedMessage(
payload: Record,
timestamp: unknown
): boolean {
+ publishCodexCompletedTool(accumulator, payload, timestamp)
const item = asRecord(payload.item)
if (!item) {
return false
diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts
index 02a385400de..b3571d4a337 100644
--- a/src/main/ai-vault/session-scanner-codex-parser.ts
+++ b/src/main/ai-vault/session-scanner-codex-parser.ts
@@ -4,6 +4,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
+ accumulatorSessionIdentity,
cloneSessionAccumulator,
createAccumulator,
finalizeSession,
@@ -153,19 +154,13 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo
accumulator.title = metadataTitle
state.titleSource = 'meta'
}
- const cwd = extractString(payload.cwd)
- if (cwd) {
- accumulator.cwd = cwd
- }
+ accumulator.cwd = extractString(payload.cwd) ?? accumulator.cwd
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
return
}
if (record.type === 'turn_context' && payload) {
- const cwd = extractString(payload.cwd)
- if (cwd) {
- accumulator.cwd = cwd
- }
+ accumulator.cwd = extractString(payload.cwd) ?? accumulator.cwd
const model = extractModel(payload)
if (model) {
accumulator.model = model
@@ -177,7 +172,7 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo
return
}
- if (record.type === 'response_item' && payload.type === 'message') {
+ if (record.type === 'response_item') {
if (state.historyMode === 'paginated') {
return
}
@@ -285,7 +280,10 @@ function codexResumeStateFromParseState(
return {
consumeLine: (line) => consumeCodexRecordLine(state, line),
consumeLineBytes: (line) => {
- const timelineOnlyRecord = readCodexTimelineOnlyRecord(line)
+ const timelineOnlyRecord = readCodexTimelineOnlyRecord(
+ line,
+ state.accumulator.messages.active && state.historyMode !== 'paginated'
+ )
if (timelineOnlyRecord) {
updateTimeline(state.accumulator, timelineOnlyRecord.timestamp)
} else {
@@ -293,6 +291,7 @@ function codexResumeStateFromParseState(
}
},
shouldStop: () => state.rejectedWorkerSession,
+ identity: () => accumulatorSessionIdentity(state.accumulator),
clone: () =>
codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader),
touchFile: (file) => {
diff --git a/src/main/ai-vault/session-scanner-codex-record-fast-path.ts b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts
index 1c4322f89f6..852ec174e95 100644
--- a/src/main/ai-vault/session-scanner-codex-record-fast-path.ts
+++ b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts
@@ -1,3 +1,5 @@
+import { CODEX_TOOL_RESPONSE_TYPES } from './session-scanner-codex-tool-records'
+
// Records below this size are decoded and parsed exactly: JSON.parse on a
// kilobyte costs less than the risk of a prefix heuristic, and the scan cost
// this path exists to remove is entirely in megabyte-scale records.
@@ -22,7 +24,10 @@ const PARSED_EVENT_TYPES = new Set([
])
/** Returns the timestamp only when the record cannot affect other visible session fields. */
-export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string } | null {
+export function readCodexTimelineOnlyRecord(
+ line: Buffer,
+ includeTools = false
+): { timestamp: string } | null {
if (line.length <= CODEX_RECORD_PREFIX_LIMIT) {
return null
}
@@ -41,6 +46,13 @@ export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string }
if (!payloadType) {
return null
}
+ if (
+ includeTools &&
+ recordType === 'response_item' &&
+ CODEX_TOOL_RESPONSE_TYPES.has(payloadType)
+ ) {
+ return null
+ }
const parsedPayloadTypes =
recordType === 'response_item' ? PARSED_RESPONSE_ITEM_TYPES : PARSED_EVENT_TYPES
return parsedPayloadTypes.has(payloadType) ? null : { timestamp }
diff --git a/src/main/ai-vault/session-scanner-codex-tool-records.test.ts b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts
new file mode 100644
index 00000000000..a5aa909bfa1
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts
@@ -0,0 +1,125 @@
+import { expect, it } from 'vitest'
+import { createCodexSessionResumeState } from './session-scanner-codex-parser'
+import type { TranscriptMessage } from './session-transcript-consumers'
+import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path'
+
+const timestamp = '2026-05-01T10:00:00.000Z'
+const file = {
+ path: '/fixture/rollout.jsonl',
+ mtimeMs: Date.parse(timestamp),
+ modifiedAt: timestamp
+}
+const record = (type: string, payload: Record): Buffer =>
+ Buffer.from(JSON.stringify({ timestamp, type, payload }))
+
+it.each(['function_call_output', 'custom_tool_call_output'])(
+ 'reads large %s records only when a consumer needs them',
+ (type) => {
+ const line = record('response_item', { type, output: 'outputonly '.repeat(300) })
+ expect(readCodexTimelineOnlyRecord(line)).toEqual({ timestamp })
+ expect(readCodexTimelineOnlyRecord(line, true)).toBeNull()
+ const messages: TranscriptMessage[] = []
+ const state = createCodexSessionResumeState(file, null, {
+ active: true,
+ push: (message) => messages.push(message)
+ })
+ state.consumeLineBytes!(line)
+ expect(messages).toEqual([{ role: 'tool', text: 'outputonly '.repeat(300), timestamp }])
+ }
+)
+
+it.each([false, true])(
+ 'uses one tool representation across append when paginated=%s',
+ async (paginated) => {
+ const messages: TranscriptMessage[] = []
+ let state = createCodexSessionResumeState(file, null, {
+ active: true,
+ push: (message) => messages.push(message)
+ })
+ const consume = (type: string, payload: Record) =>
+ state.consumeLineBytes!(record(type, payload))
+ consume('session_meta', { id: 'session-1', history_mode: paginated ? 'paginated' : 'full' })
+ consume('response_item', { type: 'message', role: 'user', content: 'promptonly' })
+ consume('event_msg', {
+ type: 'item_completed',
+ item: { type: 'UserMessage', content: [{ type: 'text', text: 'promptonly' }] }
+ })
+ consume('response_item', {
+ type: 'function_call',
+ name: 'shell',
+ arguments: '{"command":"commandonly"}'
+ })
+ // The next scan resumes between the call and its output.
+ state = state.clone()
+ consume('response_item', { type: 'function_call_output', output: 'outputonly' })
+ consume('event_msg', {
+ type: 'item_completed',
+ item: { type: 'CommandExecution', command: ['commandonly'], aggregated_output: 'outputonly' }
+ })
+ expect(messages.filter((message) => message.text.includes('commandonly'))).toHaveLength(1)
+ expect(messages.filter((message) => message.text === 'outputonly')).toEqual([
+ { role: 'tool', text: 'outputonly', timestamp }
+ ])
+ expect(messages.filter((message) => message.role === 'user')).toHaveLength(1)
+ expect(await state.finalize(process.platform)).toMatchObject({ messageCount: 1 })
+ }
+)
+
+it.each([
+ { type: 'add', content: '+ addedneedle' },
+ { type: 'delete', content: '+ addedneedle' },
+ { type: 'update', unified_diff: '+ addedneedle', move_path: null }
+])('publishes paginated $type file changes', (change) => {
+ const messages: TranscriptMessage[] = []
+ const state = createCodexSessionResumeState(file, null, {
+ active: true,
+ push: (message) => messages.push(message)
+ })
+ state.consumeLineBytes!(record('session_meta', { id: 'session-1', history_mode: 'paginated' }))
+ state.consumeLineBytes!(
+ record('event_msg', {
+ type: 'item_completed',
+ item: { type: 'FileChange', changes: { 'src/changed.ts': change } }
+ })
+ )
+ expect(messages.map((message) => [message.role, message.text])).toEqual([
+ ['tool', 'apply_patch: src/changed.ts'],
+ ['tool', '+ addedneedle']
+ ])
+})
+
+it('normalizes custom calls and structured results through the existing content reader', () => {
+ const messages: TranscriptMessage[] = []
+ const state = createCodexSessionResumeState(file, null, {
+ active: true,
+ push: (message) => messages.push(message)
+ })
+ state.consumeLineBytes!(
+ record('response_item', { type: 'custom_tool_call', name: 'apply_patch', input: 'patchneedle' })
+ )
+ state.consumeLineBytes!(
+ record('response_item', {
+ type: 'custom_tool_call_output',
+ output: { content: [{ type: 'text', text: 'resultneedle' }] }
+ })
+ )
+ expect(messages.map((message) => message.text)).toEqual([
+ 'apply_patch: patchneedle',
+ 'resultneedle'
+ ])
+})
+
+it('keeps local shell argv searchable', () => {
+ const messages: TranscriptMessage[] = []
+ const state = createCodexSessionResumeState(file, null, {
+ active: true,
+ push: (message) => messages.push(message)
+ })
+ state.consumeLineBytes!(
+ record('response_item', {
+ type: 'local_shell_call',
+ action: { type: 'exec', command: ['rg', 'argvneedle'] }
+ })
+ )
+ expect(messages.map((message) => message.text)).toEqual(['tool: rg argvneedle'])
+})
diff --git a/src/main/ai-vault/session-scanner-codex-tool-records.ts b/src/main/ai-vault/session-scanner-codex-tool-records.ts
new file mode 100644
index 00000000000..976d5eff36d
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-codex-tool-records.ts
@@ -0,0 +1,95 @@
+import { timestampIso } from './session-scanner-accumulator'
+import { asRecord } from './session-scanner-record-value'
+import type { SessionAccumulator } from './session-scanner-types'
+import { transcriptMessagesFromContent } from './session-transcript-message-content'
+
+export const CODEX_TOOL_RESPONSE_TYPES = new Set([
+ 'function_call',
+ 'local_shell_call',
+ 'custom_tool_call',
+ 'function_call_output',
+ 'custom_tool_call_output'
+])
+
+function publishToolContent(
+ accumulator: SessionAccumulator,
+ content: unknown,
+ timestamp: unknown
+): void {
+ for (const message of transcriptMessagesFromContent('tool', content, timestampIso(timestamp))) {
+ accumulator.messages.push(message)
+ }
+}
+
+export function publishCodexResponseTool(
+ accumulator: SessionAccumulator,
+ payload: Record,
+ timestamp: unknown
+): void {
+ if (!accumulator.messages.active || !CODEX_TOOL_RESPONSE_TYPES.has(String(payload.type))) {
+ return
+ }
+ if (payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') {
+ const output = asRecord(payload.output)
+ publishToolContent(
+ accumulator,
+ [{ type: 'tool_result', content: output?.content ?? output?.output ?? payload.output }],
+ timestamp
+ )
+ return
+ }
+ const input = payload.arguments ?? payload.input ?? payload.action
+ const action = asRecord(input)
+ const normalizedInput =
+ action && Array.isArray(action.command)
+ ? { ...action, command: action.command.filter((part) => typeof part === 'string').join(' ') }
+ : input
+ publishToolContent(
+ accumulator,
+ [
+ {
+ type: 'tool_use',
+ name: payload.name ?? 'tool',
+ input: normalizedInput
+ }
+ ],
+ timestamp
+ )
+}
+
+export function publishCodexCompletedTool(
+ accumulator: SessionAccumulator,
+ payload: Record,
+ timestamp: unknown
+): void {
+ if (!accumulator.messages.active) {
+ return
+ }
+ const item = asRecord(payload.item)
+ if (item?.type === 'CommandExecution' || item?.type === 'command_execution') {
+ const command = Array.isArray(item.command)
+ ? item.command.filter((part) => typeof part === 'string').join(' ')
+ : item.command
+ publishToolContent(
+ accumulator,
+ [
+ { type: 'tool_use', name: 'shell', input: command },
+ { type: 'tool_result', content: item.aggregated_output ?? item.aggregatedOutput }
+ ],
+ timestamp
+ )
+ } else if (item?.type === 'FileChange' || item?.type === 'file_change') {
+ const changes = asRecord(item.changes) ?? {}
+ for (const [path, value] of Object.entries(changes)) {
+ const change = asRecord(value)
+ publishToolContent(
+ accumulator,
+ [
+ { type: 'tool_use', name: 'apply_patch', input: { path } },
+ { type: 'tool_result', content: change?.unified_diff ?? change?.content }
+ ],
+ timestamp
+ )
+ }
+ }
+}
diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts
index 57cadebeee0..07f3646b537 100644
--- a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts
+++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts
@@ -101,6 +101,7 @@ export function withOmpSubagentTranscriptCount(
): ResumableSessionParseState {
return {
consumeLine: (line) => state.consumeLine(line),
+ identity: () => state.identity?.() ?? null,
clone: () => withOmpSubagentTranscriptCount(state.clone(), transcriptFilePath),
touchFile: (file) => state.touchFile(file),
finalize: async (platform, options) => {
diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts
index 9783f8a0520..62149920b5a 100644
--- a/src/main/ai-vault/session-scanner-primary-parsers.ts
+++ b/src/main/ai-vault/session-scanner-primary-parsers.ts
@@ -12,6 +12,7 @@ import type {
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
+ accumulatorSessionIdentity,
addPreviewContent,
createAccumulator,
finalizeSession,
@@ -205,6 +206,7 @@ function claudeResumeStateFromParseState(
): ResumableSessionParseState {
return {
consumeLine: (line) => consumeClaudeSessionLine(state, line),
+ identity: () => accumulatorSessionIdentity(state.accumulator),
clone: () => claudeResumeStateFromParseState(cloneClaudeSessionParseState(state)),
touchFile: (file) => {
state.accumulator.modifiedAt = file.modifiedAt
diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts
index b1d480aa944..f4b60270544 100644
--- a/src/main/ai-vault/session-scanner-types.ts
+++ b/src/main/ai-vault/session-scanner-types.ts
@@ -5,7 +5,10 @@ import type {
AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
-import type { TranscriptMessageSink } from './session-transcript-consumers'
+import type {
+ TranscriptMessageSink,
+ TranscriptSessionIdentity
+} from './session-transcript-consumers'
import type { SessionSidecarObservation } from './session-sidecar-stat'
export type AiVaultScanOptions = {
@@ -103,6 +106,9 @@ export type ResumableSessionParseState = {
consumeLineBytes?(line: Buffer): void
// Lets a parser terminate an excluded transcript without draining the file.
shouldStop?(): boolean
+ // What the fold knows about the session right now, for a consumer that has to
+ // commit before the read ends (see TranscriptSessionIdentity).
+ identity?(): TranscriptSessionIdentity | null
clone(): ResumableSessionParseState
// Refresh per-scan file metadata (mtime display string) without re-parsing.
touchFile(file: FileWithMtime): void
diff --git a/src/main/ai-vault/session-transcript-consumers.ts b/src/main/ai-vault/session-transcript-consumers.ts
index 6707b298586..7aff8dcf87b 100644
--- a/src/main/ai-vault/session-transcript-consumers.ts
+++ b/src/main/ai-vault/session-transcript-consumers.ts
@@ -27,12 +27,34 @@ export const NO_TRANSCRIPT_MESSAGES: TranscriptMessageSink = {
push: () => undefined
}
+/**
+ * What a parser has decoded about the session so far, mid-read.
+ *
+ * Provisional by construction: it is read before the file ends, so a title can
+ * still change and a timestamp can still move. Every field the transcript
+ * formats put in their opening lines, which is what a consumer that has to
+ * commit before the read finishes needs to name what it is holding.
+ */
+export type TranscriptSessionIdentity = {
+ sessionId: string
+ cwd: string | null
+ title: string | null
+ createdAt: string | null
+ updatedAt: string | null
+}
+
export type TranscriptReadStart = {
candidate: SessionFileCandidate
/** `replace`: the whole file is being re-read; `append`: a resumed read. */
mode: 'replace' | 'append'
/** Byte offset the messages of this read continue from. */
previousByteOffset: number
+ /**
+ * The session identity decoded so far, or null before the parser has an id.
+ * Called during the read, never here: nothing is decoded yet when a read
+ * begins. Absent when the read has no resumable parse state to ask.
+ */
+ identity?: () => TranscriptSessionIdentity | null
}
export type TranscriptReadOutcome = {
diff --git a/src/main/ai-vault/session-transcript-reader.ts b/src/main/ai-vault/session-transcript-reader.ts
index 228b0c832a3..3fc0ddcc146 100644
--- a/src/main/ai-vault/session-transcript-reader.ts
+++ b/src/main/ai-vault/session-transcript-reader.ts
@@ -3,7 +3,10 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
import { parseAgentSessionFile, parserPublishesMessages } from './session-scanner-agent-parser'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
-import type { SessionParseResumePoint } from './session-parse-cache-store'
+import {
+ invalidateSessionParseCacheEntry,
+ type SessionParseResumePoint
+} from './session-parse-cache-store'
import { TranscriptMessageChannel } from './session-transcript-channel'
const NEWLINE_BYTE = 0x0a
@@ -29,6 +32,24 @@ export type ResumableTranscriptRead = {
resume: SessionParseResumePoint
}
+/**
+ * Ask for the next read of `path` to be a whole-file `replace`.
+ *
+ * Why this lives here: a consumer never chooses its own mode. The reader picks
+ * `append` or `replace` from the resume point the session list left behind, so a
+ * consumer that declined an append has no way to get the span it missed — with
+ * an empty index and a warm parse cache, every read arrives as `append`, every
+ * one is declined, and nothing is ever indexed. Dropping the resume point is the
+ * one lever that changes the next read's mode, and only the reader's own cache
+ * owns it.
+ *
+ * The cost is a re-parse for the session list too. That is the honest price of a
+ * second consumer being behind, and it is paid once per file rather than per scan.
+ */
+export function requestWholeTranscriptRead(path: string): void {
+ invalidateSessionParseCacheEntry(path)
+}
+
/**
* Read an append-only transcript, resuming from `resume` when the file only
* grew and the recorded offset still sits on a line boundary. Anything else
@@ -70,7 +91,10 @@ export async function readResumableTranscript(args: {
channel.beginRead({
candidate: args.candidate,
mode: canResume ? 'append' : 'replace',
- previousByteOffset: startOffset
+ previousByteOffset: startOffset,
+ // Read by a consumer during the read, not here: the fold has decoded
+ // nothing yet at this point of a whole-file read.
+ identity: () => state.identity?.() ?? null
})
try {
const readResult = await consumeCompleteJsonlLines({
From ecd7b19ad42ab78d190b37ca22cd57ab783103fb Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 10 Sep 2026 20:50:40 -0700
Subject: [PATCH 03/17] fix(native-chat): pass agent-implemented slash commands
through to the agent (#19929)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(native-chat): pass agent-implemented slash commands through to the agent
Claim what the host implements; pass through what the agent implements.
Claude's harness expands a slash command out of the message text, so the
host claimed catalog commands it had no way to run and answered "/init is
not available in chat sessions" for commands Claude does run. Codex's
app-server has no slash parser at all, so its catalog stays claimed —
except /goal, which the model carries out through its own goal tools.
* fix(native-chat): offer the agent-run commands in the structured picker
Codex reports no command catalog, so its structured `/` menu is the host
fallback -- which listed only the host's own commands and hid `/goal`, the
one command the model itself acts on. The picker now appends the profile's
text-driven commands, described from the curated catalog, so a command that
passes through is discoverable and not merely typable.
The menu invariant holds either way: a pick is answered by the host or run
by the agent, never refused with "not available in chat sessions".
* fix mobile structured command reconciliation
* fix(mobile): keep native chat controller within lint budget
* fix mobile controller lint budget
---------
Co-authored-by: Merge Sim
---
.../src/session/MobileNativeChatComposer.tsx | 2 +-
.../use-mobile-native-chat-controller.ts | 7 +-
...structured-native-chat-send-bridge.test.ts | 88 +++++++++++++
...bile-structured-native-chat-send-bridge.ts | 15 +--
.../native-chat/NativeChatComposer.test.tsx | 14 +-
.../use-native-chat-composer-catalog.test.tsx | 20 +++
.../use-native-chat-composer-catalog.ts | 2 +-
...ive-chat-structured-composer-send.test.tsx | 82 ++++++++++++
src/shared/native-chat-agent-profiles.test.ts | 31 ++++-
src/shared/native-chat-agent-profiles.ts | 48 ++++++-
.../structured-agent-session-composer.test.ts | 123 +++++++++++++++++-
.../structured-agent-session-composer.ts | 32 ++++-
12 files changed, 426 insertions(+), 38 deletions(-)
create mode 100644 mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts
create mode 100644 src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx
diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx
index c16b69ced89..20c43aa6c8b 100644
--- a/mobile/src/session/MobileNativeChatComposer.tsx
+++ b/mobile/src/session/MobileNativeChatComposer.tsx
@@ -130,7 +130,7 @@ export function MobileNativeChatComposer({
if (trigger.kind === 'slash') {
const commands =
structuredCommands !== undefined
- ? structuredSlashCommands(structuredCommands)
+ ? structuredSlashCommands(structuredCommands, agent)
: agent
? getVerifiedNativeChatCommands(agent)
: []
diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts
index d3d68b85032..5b941367451 100644
--- a/mobile/src/session/use-mobile-native-chat-controller.ts
+++ b/mobile/src/session/use-mobile-native-chat-controller.ts
@@ -140,7 +140,7 @@ export function useMobileNativeChatController(args: {
)
const {
permission: legacyNativeChatPermission,
- question: legacyNativeChatQuestion,
+ question: legacyQuestion,
detectedAsk: nativeChatDetectedAsk,
ask: nativeChatAskPrompt
} = useMobileNativeChatPrompts({
@@ -242,6 +242,7 @@ export function useMobileNativeChatController(args: {
})
const structuredNativeChatSend = useMobileStructuredNativeChatSendBridge({
+ agent: activeChatResolution?.agent === 'claude' ? 'claude' : 'codex',
sendStructured: structuredNativeChat.sendWithOutcome,
captureSendOrigin,
clearDraftForSend,
@@ -309,9 +310,7 @@ export function useMobileNativeChatController(args: {
nativeChatPermission: activeChatStructured
? structuredNativeChat.permission
: legacyNativeChatPermission,
- nativeChatQuestion: activeChatStructured
- ? structuredNativeChat.question
- : legacyNativeChatQuestion,
+ nativeChatQuestion: activeChatStructured ? structuredNativeChat.question : legacyQuestion,
nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null,
nativeChatAskKey,
dismissNativeChatAsk,
diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts
new file mode 100644
index 00000000000..3cf432381c2
--- /dev/null
+++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts
@@ -0,0 +1,88 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
+import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
+import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
+import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
+
+const ORIGIN: MobileNativeChatSendOrigin = {
+ draftKey: 'draft',
+ draftEditGeneration: 0,
+ pendingKey: 'pending',
+ normalizedText: 'command',
+ baselineOccurrences: 0,
+ baselineTailMessageId: null,
+ baselineResolved: true
+}
+
+describe('useMobileStructuredNativeChatSendBridge', () => {
+ let renderer: ReactTestRenderer | null = null
+ let sendWithOutcome: (text: string) => Promise
+ const acceptSend = vi.fn()
+ const captureSendOrigin = vi.fn(() => ORIGIN)
+ const clearDraftForSend = vi.fn()
+ const holdUnconfirmedSend = vi.fn()
+ const onSendError = vi.fn()
+ const restoreRejectedDraft = vi.fn()
+ const sendStructured = vi.fn()
+
+ function Harness({ agent }: { agent: AgentSessionHandleProvider }): null {
+ sendWithOutcome = useMobileStructuredNativeChatSendBridge({
+ agent,
+ acceptSend,
+ captureSendOrigin,
+ clearDraftForSend,
+ holdUnconfirmedSend,
+ onSendError,
+ restoreRejectedDraft,
+ sendStructured
+ }).sendWithOutcome
+ return null
+ }
+
+ function mount(agent: AgentSessionHandleProvider): void {
+ act(() => {
+ renderer = create(createElement(Harness, { agent }))
+ })
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ it('optimistically echoes accepted commands owned by the active agent', async () => {
+ sendStructured.mockResolvedValue('accepted')
+ mount('claude')
+
+ await expect(sendWithOutcome('/init')).resolves.toBe('accepted')
+
+ expect(acceptSend).toHaveBeenCalledWith(ORIGIN, '/init', undefined)
+ expect(restoreRejectedDraft).not.toHaveBeenCalled()
+ })
+
+ it('holds unknown delivery for commands owned by the active agent', async () => {
+ sendStructured.mockResolvedValue('unknown')
+ mount('claude')
+
+ await expect(sendWithOutcome('/review')).resolves.toBe('unknown')
+
+ expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, '/review', expect.any(Function))
+ expect(restoreRejectedDraft).not.toHaveBeenCalled()
+ })
+
+ it('keeps host-command reconciliation for Codex', async () => {
+ sendStructured.mockResolvedValue('unknown')
+ mount('codex')
+
+ await expect(sendWithOutcome('/review')).resolves.toBe('unknown')
+
+ expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, '/review')
+ expect(holdUnconfirmedSend).not.toHaveBeenCalled()
+ })
+})
diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts
index 70261e9df9b..d1cfe3d42f4 100644
--- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts
+++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts
@@ -1,4 +1,5 @@
import { useCallback } from 'react'
+import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
import { isStructuredAgentSessionComposerCommand } from '../../../src/shared/structured-agent-session-composer'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
@@ -10,6 +11,7 @@ type StructuredNativeChatAttachment = {
}
export function useMobileStructuredNativeChatSendBridge(args: {
+ agent: AgentSessionHandleProvider
sendStructured: (
text: string,
images?: string[],
@@ -37,6 +39,7 @@ export function useMobileStructuredNativeChatSendBridge(args: {
} {
const {
acceptSend,
+ agent,
captureSendOrigin,
clearDraftForSend,
holdUnconfirmedSend,
@@ -56,6 +59,7 @@ export function useMobileStructuredNativeChatSendBridge(args: {
onSendError('Message not sent (disconnected)')
return 'rejected'
}
+ const isHostCommand = isStructuredAgentSessionComposerCommand(text, agent)
clearDraftForSend(origin, text)
const outcome =
attachments !== undefined
@@ -66,19 +70,13 @@ export function useMobileStructuredNativeChatSendBridge(args: {
? await sendStructured(text, images)
: await sendStructured(text)
if (outcome === 'accepted') {
- if (
- !isStructuredAgentSessionComposerCommand(text, 'codex') &&
- !isStructuredAgentSessionComposerCommand(text, 'claude')
- ) {
+ if (!isHostCommand) {
acceptSend(origin, text.trimEnd(), images)
}
return 'accepted'
}
if (outcome === 'unknown') {
- if (
- isStructuredAgentSessionComposerCommand(text, 'codex') ||
- isStructuredAgentSessionComposerCommand(text, 'claude')
- ) {
+ if (isHostCommand) {
restoreRejectedDraft(origin, text)
return 'unknown'
}
@@ -92,6 +90,7 @@ export function useMobileStructuredNativeChatSendBridge(args: {
},
[
acceptSend,
+ agent,
captureSendOrigin,
clearDraftForSend,
holdUnconfirmedSend,
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
index 45d95140f9e..055230c1a4e 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
@@ -306,12 +306,14 @@ describe('NativeChatComposer', () => {
expect(mocks.setDraft).toHaveBeenCalledWith('')
})
- // The structured menu offers only what the dispatcher can carry out. Listing the
- // agent's TUI catalog here answered every pick with "not available in chat sessions".
+ // The structured menu offers only what a pick can carry out: the host's own
+ // commands, plus the ones the agent itself runs from message text (Codex `/goal`).
+ // Listing the agent's whole TUI catalog here answered every pick with
+ // "not available in chat sessions".
it.each([
- ['claude', 'compact'],
- ['codex', 'vim']
- ] as const)('offers %s only actionable structured slash commands', (agent, withheld) => {
+ ['claude', 'compact', ['model', 'effort']],
+ ['codex', 'vim', ['model', 'effort', 'goal']]
+ ] as const)('offers %s only actionable structured slash commands', (agent, withheld, offered) => {
mocks.draft = '/'
render(
{
const names = (mocks.fieldProps?.autocomplete?.items ?? [])
.filter((item) => item.kind === 'command')
.map((item) => item.name)
- expect(names).toEqual(['model', 'effort'])
+ expect(names).toEqual([...offered])
expect(names).not.toContain(withheld)
})
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx
index 9e7b3abc5b2..427a56c739f 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx
@@ -7,6 +7,7 @@ import { EMPTY_HISTORY } from './native-chat-composer-state'
import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
+import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat-slash-commands'
import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer'
function transport(sessionCommands?: NativeChatStructuredComposerTransport['sessionCommands']) {
@@ -47,6 +48,25 @@ describe('composer catalog authority', () => {
'clear'
])
})
+ // Codex reports no catalog, so the hook's fallback is its entire `/` menu; the
+ // agent has to reach structuredSlashCommands or `/goal` is invisible there.
+ it('offers Codex the commands its model runs from message text', () => {
+ const { result } = renderHook(() => useNativeChatComposerCatalog('codex', transport()))
+ expect(result.current.agentCommands).toEqual(structuredSlashCommands([], 'codex'))
+ expect(result.current.agentCommands.map(({ name }) => name)).toContain('goal')
+ })
+ it('leaves the Claude route on its own commands, reported or not', () => {
+ const reported = [{ name: 'init', kind: 'command' as const }]
+ const withReport = renderHook(() => useNativeChatComposerCatalog('claude', transport(reported)))
+ expect(withReport.result.current.agentCommands).toEqual(
+ sessionSlashCommandSuggestions('claude', reported)
+ )
+ const withoutReport = renderHook(() => useNativeChatComposerCatalog('claude', transport()))
+ expect(withoutReport.result.current.agentCommands.map(({ name }) => name)).toEqual([
+ 'model',
+ 'effort'
+ ])
+ })
it('respects empty catalogs and command-only catalogs without reviving disk skills', () => {
const { result, rerender } = renderHook(
({ reported }) => useNativeChatComposerCatalog('claude', transport(reported)),
diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts
index 273e9fbfa60..d2e0b93ac40 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts
@@ -34,7 +34,7 @@ export function useNativeChatComposerCatalog(
? getVerifiedNativeChatCommands(agent)
: reported !== undefined
? sessionSlashCommandSuggestions(agent, reported)
- : structuredSlashCommands(conversationCommands),
+ : structuredSlashCommands(conversationCommands, agent),
[agent, conversationCommands, reported, structured]
)
const sessionSkillNames = useMemo(
diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx
new file mode 100644
index 00000000000..82cd0f0cb03
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx
@@ -0,0 +1,82 @@
+// @vitest-environment happy-dom
+import { renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
+import type { AgentType } from '../../../../shared/agent-status-types'
+import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
+import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
+import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send'
+
+vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() }))
+vi.mock('@/lib/worker-terminal-takeover-report', () => ({
+ reportStructuredSessionUserInput: vi.fn()
+}))
+
+const ATTACHMENT = { id: 'a1', path: '/tmp/shot.png' } as NativeChatComposerImageAttachment
+
+function harness(agent: AgentType) {
+ const structuredTransport = {
+ send: vi.fn(() => true),
+ dispatchCommand: (text: string) =>
+ dispatchStructuredAgentSessionComposerCommand(text, {
+ agent,
+ snapshot: [],
+ invokeAction: async () => true,
+ setOption: async () => true,
+ conversationCommands: ['clear', 'compact'],
+ runConversationCommand: async () => ({ accepted: true, error: null })
+ }),
+ optionSnapshot: [],
+ onError: vi.fn(),
+ runtime: 'local',
+ sessionId: 'session-test',
+ runtimeEnvironmentId: null
+ } as unknown as NativeChatStructuredComposerTransport
+ const { result } = renderHook(() =>
+ useNativeChatStructuredComposerSend({
+ agent,
+ draft: '',
+ imageAttachments: [ATTACHMENT],
+ structuredTransport,
+ clearImageAttachments: vi.fn(),
+ clearSkillOrigin: vi.fn(),
+ setHistory: vi.fn(),
+ setDraft: vi.fn(),
+ setCaret: vi.fn()
+ })
+ )
+ return { send: result.current, structuredTransport }
+}
+
+// The guard exists because a host command sends no message, so its attachments
+// would be dropped without a word. A pass-through command IS the message, so the
+// attachments ride along with it.
+describe('attachment guard follows what the host claims', () => {
+ it.each([
+ ['claude', '/clear'],
+ ['claude', '/model'],
+ ['codex', '/permissions']
+ ] as const)('refuses attachments on the host-claimed %s command %s', (agent, text) => {
+ const { send, structuredTransport } = harness(agent)
+ send(text)
+ expect(structuredTransport.onError).toHaveBeenCalledWith(
+ 'Remove attachments before using a chat-session command.'
+ )
+ expect(structuredTransport.send).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ ['claude', '/init'],
+ ['claude', '/review'],
+ ['codex', '/goal ship the fix']
+ ] as const)('sends %s attachments along with the passed-through %s', async (agent, text) => {
+ const { send, structuredTransport } = harness(agent)
+ send(text)
+ await vi.waitFor(() =>
+ expect(structuredTransport.send).toHaveBeenCalledWith(text, [ATTACHMENT])
+ )
+ expect(structuredTransport.onError).not.toHaveBeenCalledWith(
+ 'Remove attachments before using a chat-session command.'
+ )
+ })
+})
diff --git a/src/shared/native-chat-agent-profiles.test.ts b/src/shared/native-chat-agent-profiles.test.ts
index 4d64e283c01..546a4bf9db0 100644
--- a/src/shared/native-chat-agent-profiles.test.ts
+++ b/src/shared/native-chat-agent-profiles.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
-import { getNativeChatAgentProfile } from './native-chat-agent-profiles'
+import {
+ getHostClaimedNativeChatCommands,
+ getNativeChatAgentProfile,
+ getVerifiedNativeChatCommands
+} from './native-chat-agent-profiles'
describe('native chat agent picker profiles', () => {
// The composer types the same `/` for every agent; skillPrefix is only the
@@ -27,3 +31,28 @@ describe('native chat agent picker profiles', () => {
expect(getNativeChatAgentProfile('custom-agent')).toBeNull()
})
})
+
+describe('host-claimed native chat commands', () => {
+ function names(agent: string): string[] {
+ return getHostClaimedNativeChatCommands(agent).map((command) => command.name)
+ }
+
+ // Claude's harness expands a slash command out of the message body, so claiming
+ // its catalog only answered "/init is not available" for commands that do run.
+ it('claims nothing from the Claude-family catalog', () => {
+ expect(names('claude')).toEqual([])
+ expect(names('openclaude')).toEqual([])
+ })
+
+ it('keeps the Codex catalog claimed except the model-driven /goal', () => {
+ expect(names('codex')).toContain('permissions')
+ expect(names('codex')).toContain('vim')
+ expect(names('codex')).not.toContain('goal')
+ expect(getVerifiedNativeChatCommands('codex').map((command) => command.name)).toContain('goal')
+ })
+
+ it('claims the whole catalog for agents with no pass-through policy', () => {
+ expect(names('custom-agent')).toEqual(['clear', 'help'])
+ expect(names('grok')).toEqual([])
+ })
+})
diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts
index 6f86313d17d..82fe88e9ac3 100644
--- a/src/shared/native-chat-agent-profiles.ts
+++ b/src/shared/native-chat-agent-profiles.ts
@@ -5,20 +5,31 @@ export type NativeChatAgentProfile = {
skillPrefix: '$' | '/'
/** OpenClaude reads Claude-owned roots, so this can differ from the agent. */
skillSourceOwner: AgentType
+ /** The agent's own harness expands a slash command out of the message text, so
+ * the chat host claims only the commands it implements itself. */
+ expandsSlashCommandsFromText?: true
+ /** Catalog commands the model acts on when they arrive as prose, even though
+ * the runtime has no slash parser of its own. */
+ textDrivenCommands?: readonly string[]
}
const NATIVE_CHAT_AGENT_PROFILES: Partial> = {
codex: {
skillPrefix: '$',
- skillSourceOwner: 'codex'
+ skillSourceOwner: 'codex',
+ // The app-server has no slash parser, but the model owns goal tools and
+ // calls create_goal itself when `/goal ` reaches it as prose.
+ textDrivenCommands: ['goal']
},
claude: {
skillPrefix: '/',
- skillSourceOwner: 'claude'
+ skillSourceOwner: 'claude',
+ expandsSlashCommandsFromText: true
},
openclaude: {
skillPrefix: '/',
- skillSourceOwner: 'claude'
+ skillSourceOwner: 'claude',
+ expandsSlashCommandsFromText: true
},
grok: {
skillPrefix: '/',
@@ -38,3 +49,34 @@ export function getNativeChatAgentProfile(
export function getVerifiedNativeChatCommands(agent: AgentType): readonly SlashCommandSuggestion[] {
return agent === 'grok' ? [] : getAgentSlashCommands(agent)
}
+
+/** The mirror of the claimed set: catalog commands this agent acts on when they
+ * arrive as message text. The picker offers these too, so a command the agent
+ * implements is discoverable and not merely typable. */
+export function getTextDrivenNativeChatCommands(
+ agent: AgentType | null | undefined
+): readonly SlashCommandSuggestion[] {
+ if (!agent) {
+ return []
+ }
+ const names = new Set(getNativeChatAgentProfile(agent)?.textDrivenCommands ?? [])
+ return names.size === 0
+ ? []
+ : getVerifiedNativeChatCommands(agent).filter((command) => names.has(command.name))
+}
+
+/** Catalog commands the chat host answers itself. Whatever is left over reaches
+ * the agent as ordinary text, which is only correct where the agent implements
+ * the command — so an agent unclaims a command only via the profile above.
+ * Claiming stays the default: it is what stops a hand-typed `/clear` from being
+ * sent to the model as literal prompt text. */
+export function getHostClaimedNativeChatCommands(
+ agent: AgentType
+): readonly SlashCommandSuggestion[] {
+ const profile = getNativeChatAgentProfile(agent)
+ if (profile?.expandsSlashCommandsFromText) {
+ return []
+ }
+ const passedThrough = new Set(profile?.textDrivenCommands ?? [])
+ return getVerifiedNativeChatCommands(agent).filter((command) => !passedThrough.has(command.name))
+}
diff --git a/src/shared/structured-agent-session-composer.test.ts b/src/shared/structured-agent-session-composer.test.ts
index ec3b301a8e2..b51de0396be 100644
--- a/src/shared/structured-agent-session-composer.test.ts
+++ b/src/shared/structured-agent-session-composer.test.ts
@@ -6,15 +6,59 @@ import {
} from './structured-agent-session-composer'
describe('structuredSlashCommands', () => {
- // The composer menu and the dispatcher read this one list. When they disagreed,
+ const hostController = {
+ snapshot: [],
+ invokeAction: async () => true,
+ setOption: async () => true,
+ conversationCommands: ['clear', 'compact'] as const,
+ runConversationCommand: async () => ({ accepted: true, error: null })
+ }
+ const REFUSAL = /is not available in chat sessions/
+
+ // The composer menu and the dispatcher read the same policy. When they disagreed,
// a Claude session was offered Codex-only tokens that missed the command guard
- // and reached the model as literal prompt text instead of erroring.
- it.each(['codex', 'claude'] as const)('offers %s only commands it also accepts', (agent) => {
- const offered = structuredSlashCommands()
- expect(offered.length).toBeGreaterThan(0)
- for (const command of offered) {
- expect(isStructuredAgentSessionComposerCommand(`/${command.name}`, agent)).toBe(true)
+ // and reached the model as literal prompt text instead of erroring. A row is
+ // honored either way now: the host answers it, or it passes through to the agent.
+ it.each(['codex', 'claude'] as const)(
+ 'offers %s only commands the host answers or the agent runs',
+ async (agent) => {
+ const offered = structuredSlashCommands(['clear', 'compact'], agent)
+ expect(offered.length).toBeGreaterThan(0)
+ for (const command of offered) {
+ const outcome = await dispatchStructuredAgentSessionComposerCommand(`/${command.name}`, {
+ ...hostController,
+ agent
+ })
+ expect(outcome.error ?? '').not.toMatch(REFUSAL)
+ }
}
+ )
+
+ // Codex reports no catalog of its own, so this fallback is its whole `/` menu —
+ // without the row, a command that now works is impossible to discover.
+ it('offers Codex the /goal the model acts on, described from the catalog', () => {
+ const offered = structuredSlashCommands(['clear', 'compact'], 'codex')
+ expect(offered.map((command) => command.name)).toEqual([
+ 'model',
+ 'effort',
+ 'clear',
+ 'compact',
+ 'goal'
+ ])
+ expect(offered.find((command) => command.name === 'goal')?.description).toBe(
+ 'Set or view the goal'
+ )
+ // Picking it must reach the model, not the host's refusal.
+ expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false)
+ })
+
+ it('adds nothing for an agent whose own harness expands its commands', () => {
+ expect(structuredSlashCommands(['clear', 'compact'], 'claude').map((c) => c.name)).toEqual([
+ 'model',
+ 'effort',
+ 'clear',
+ 'compact'
+ ])
})
it('offers only the commands a chat session can carry out', () => {
@@ -98,3 +142,68 @@ describe('dispatchStructuredAgentSessionComposerCommand', () => {
expect(runConversationCommand).not.toHaveBeenCalled()
})
})
+
+describe('agent-implemented commands pass through to the agent', () => {
+ const controller = {
+ snapshot: [],
+ invokeAction: async () => true,
+ setOption: async () => true
+ }
+ const PASSED_THROUGH = { handled: false, accepted: false, error: null }
+
+ // Claude's harness runs a slash command it finds in the message text, so
+ // claiming these answered "not available" for commands that do work.
+ it.each(['init', 'review', 'help'] as const)(
+ 'sends /%s on to the Claude harness instead of refusing it',
+ async (name) => {
+ expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(false)
+ expect(
+ await dispatchStructuredAgentSessionComposerCommand(`/${name}`, {
+ ...controller,
+ agent: 'claude'
+ })
+ ).toEqual(PASSED_THROUGH)
+ }
+ )
+
+ it.each(['clear', 'compact', 'model', 'effort'] as const)(
+ 'still claims the host-owned /%s on Claude',
+ async (name) => {
+ expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(true)
+ expect(
+ (
+ await dispatchStructuredAgentSessionComposerCommand(`/${name}`, {
+ ...controller,
+ agent: 'claude'
+ })
+ ).handled
+ ).toBe(true)
+ }
+ )
+
+ // Codex's app-server has no slash parser, but the model owns goal tools and
+ // creates a real goal from `/goal ` arriving as prose.
+ it('passes /goal through on Codex, arguments and all', async () => {
+ expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false)
+ expect(
+ await dispatchStructuredAgentSessionComposerCommand('/goal ship the fix', {
+ ...controller,
+ agent: 'codex'
+ })
+ ).toEqual(PASSED_THROUGH)
+ })
+
+ it('keeps refusing a Codex command the model cannot carry out', async () => {
+ expect(isStructuredAgentSessionComposerCommand('/permissions', 'codex')).toBe(true)
+ expect(
+ await dispatchStructuredAgentSessionComposerCommand('/permissions', {
+ ...controller,
+ agent: 'codex'
+ })
+ ).toMatchObject({
+ handled: true,
+ error:
+ '/permissions is not available in chat sessions. Use the slash menu to see available commands.'
+ })
+ })
+})
diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts
index 70d10c34cfa..093d588effb 100644
--- a/src/shared/structured-agent-session-composer.ts
+++ b/src/shared/structured-agent-session-composer.ts
@@ -1,4 +1,7 @@
-import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles'
+import {
+ getHostClaimedNativeChatCommands,
+ getTextDrivenNativeChatCommands
+} from './native-chat-agent-profiles'
import type { AgentType } from './agent-status-types'
import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options'
import type { SlashCommandSuggestion } from './native-chat-slash-commands'
@@ -50,30 +53,45 @@ function commandParts(text: string): { name: string; argument: string } | null {
return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null
}
-/** The commands the composer menu offers. Strictly what the dispatcher honors,
- * so a menu pick is never answered with "not available". */
+/** The commands the composer menu offers when the host reports no catalog of its
+ * own: the host's own commands, plus the ones this agent acts on from message
+ * text. Both are honored — the first here, the second by the agent — so a menu
+ * pick is never answered with "not available". */
export function structuredSlashCommands(
- commands: readonly AgentSessionConversationCommand[] = []
+ commands: readonly AgentSessionConversationCommand[] = [],
+ agent?: AgentType | null
): readonly SlashCommandSuggestion[] {
- return [
+ const hostOwned = [
...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS,
...CONVERSATION_COMMANDS.filter((entry) =>
commands.includes(entry.name as AgentSessionConversationCommand)
)
]
+ // Why: a host with no catalog to report would otherwise hide the commands the
+ // agent itself implements, e.g. Codex's `/goal`.
+ return [
+ ...hostOwned,
+ ...getTextDrivenNativeChatCommands(agent).filter(
+ (entry) => !hostOwned.some((offered) => offered.name === entry.name)
+ )
+ ]
}
/** Wider than the offered menu on purpose: a TUI-only command still has to be
* claimed here and answered, or a hand-typed `/clear` reaches the model as
- * literal prompt text. */
+ * literal prompt text. Commands the agent itself implements are deliberately
+ * absent — the profile unclaims those so they pass through as text. */
function structuredRecognizedCommands(agent: AgentType): readonly SlashCommandSuggestion[] {
return [
...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS,
...CONVERSATION_COMMANDS,
- ...getVerifiedNativeChatCommands(agent)
+ ...getHostClaimedNativeChatCommands(agent)
]
}
+/** Whether the chat host, rather than the agent, owns this command. Callers also
+ * use it to refuse attachments: a host command sends no message, so attachments
+ * would be silently dropped, whereas a pass-through command is a real send. */
export function isStructuredAgentSessionComposerCommand(
text: string,
agent: AgentType = 'codex'
From cdf41df37c6c16acae89b06795f0ee5267182632 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 10 Sep 2026 21:09:04 -0700
Subject: [PATCH 04/17] perf(terminal): stop rebuilding per-workspace
collections on a worktree switch (#19975)
Two allocations scale with the workspace count and are rebuilt on inputs that
cannot change their result.
`workspaceSurfaces` took `renderedActiveWorktreeId` as a memo dep, but
`projectWorkspaceSurfaces` reads that id only behind a truthy
`activeWorkspaceResolvedHostId` (the folder-collision tie-break), which is null
unless the active workspace is itself a folder workspace. Every git-worktree
switch therefore re-projected every surface and re-derived the id array to reach
an identical answer. Gate the id on the host so the memo holds.
The parked-watcher sync built a fresh empty `Set` for every workspace surface,
even though only a mounted workspace can park a tab and the sync only reads the
set. Share one empty instance for the rest.
Both keep every effect firing on exactly the inputs it fired on before.
---
...minal-parked-watcher-sync-entries.test.tsx | 110 ++++++++++++++++++
.../terminal-workspace-surface-ids.test.tsx | 79 +++++++++++++
.../use-terminal-watcher-effects.ts | 14 ++-
.../use-terminal-workspace-foundation.ts | 8 +-
.../workspace-surface-projection.test.ts | 18 +++
.../workspace-surface-projection.ts | 1 +
6 files changed, 224 insertions(+), 6 deletions(-)
create mode 100644 src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx
diff --git a/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx
new file mode 100644
index 00000000000..e14faedbc18
--- /dev/null
+++ b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx
@@ -0,0 +1,110 @@
+// @vitest-environment happy-dom
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useTerminalWatcherEffects } from './use-terminal-watcher-effects'
+import type { ParkedTerminalTabWatcherSyncEntry } from './terminal-pane/terminal-parked-tab-watchers'
+import type { TerminalColdActivationController } from './terminal-cold-activation'
+
+const mocks = vi.hoisted(() => ({
+ sync: vi.fn(),
+ prune: vi.fn()
+}))
+vi.mock('@/store', () => ({
+ useAppStore: Object.assign(() => 'unverifiable', {
+ getState: () => ({ activeWorktreeId: null })
+ })
+}))
+vi.mock('@/lib/workspace-terminal-host-authority', () => ({
+ createWorkspaceTerminalHostAuthoritySelector: () => () => 'unverifiable'
+}))
+vi.mock('./terminal-pane/terminal-parked-tab-watchers', () => ({
+ canWatcherCoverParkedTerminalTab: () => true,
+ disposeAllParkedTerminalWatchers: vi.fn(),
+ pruneParkedTerminalWatchers: mocks.prune,
+ syncParkedTerminalTabWatchersForWorkspaces: mocks.sync,
+ terminalWatcherLiveWorkspaceIds: (ids: Iterable) => new Set(ids)
+}))
+;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const SURFACE_COUNT = 423
+const PARKED_WORKTREE_ID = 'repo-1::/worktree-0'
+const surfaceIds = Array.from({ length: SURFACE_COUNT }, (_, index) => `repo-1::/worktree-${index}`)
+
+let root: Root | undefined
+afterEach(async () => {
+ await act(async () => root?.unmount())
+ vi.clearAllMocks()
+})
+
+function renderWatcherEffects(): Promise {
+ function Watcher(): null {
+ useTerminalWatcherEffects({
+ activationDeferredMountTabIdsByWorktreeRef: { current: new Map() },
+ activeTabId: null,
+ activeTabIdByWorktree: {},
+ activeView: 'terminal',
+ activeWorktreeId: null,
+ activityTerminalPortals: [],
+ anyMountedWorktreeHasLayout: false,
+ backgroundMountRevision: 0,
+ effectiveParkedTerminalWorktreeIds: new Set([PARKED_WORKTREE_ID]),
+ evictionExemptTerminalTabIds: new Set(['tab-exempt']),
+ getEffectiveLayoutForWorktree: () => null,
+ groupsByWorktree: {},
+ hydrationSucceeded: false,
+ measurableBackgroundWorktreeIdsRef: { current: new Set() },
+ mountedWorktreeIdsRef: { current: new Set([PARKED_WORKTREE_ID]) },
+ pendingStartupByTabId: {},
+ // Another workspace is on screen, so the mounted one is hidden and parks.
+ renderedActiveWorktreeId: 'repo-1::/worktree-9',
+ tabsByWorktree: {
+ [PARKED_WORKTREE_ID]: [{ id: 'tab-parked' }, { id: 'tab-exempt' }]
+ },
+ terminalParkingEnabled: true,
+ terminalStartupRestorationReady: false,
+ terminalTitleSnapshotAuthorityEnabled: true,
+ workspaceSessionReady: false,
+ workspaceSurfaceIds: surfaceIds
+ } as unknown as TerminalColdActivationController)
+ return null
+ }
+ root = createRoot(document.createElement('div'))
+ return act(async () => root?.render())
+}
+
+function lastSyncEntries(): Map {
+ return mocks.sync.mock.calls.at(-1)?.[0] as Map
+}
+
+describe('parked terminal watcher sync entries', () => {
+ it('publishes an entry for every surface so closed-tab disposal still sees it', async () => {
+ await renderWatcherEffects()
+
+ const entries = lastSyncEntries()
+ expect(entries.size).toBe(SURFACE_COUNT)
+ expect([...entries.keys()]).toEqual(surfaceIds)
+ expect(mocks.prune).toHaveBeenCalledWith(new Set(surfaceIds))
+ })
+
+ it('parks the hidden mounted workspace tabs and exempts the eviction-exempt tab', async () => {
+ await renderWatcherEffects()
+
+ const parkedEntry = lastSyncEntries().get(PARKED_WORKTREE_ID)
+ expect([...(parkedEntry?.parkedTabIds ?? [])]).toEqual(['tab-parked'])
+ })
+
+ it('does not allocate a parked-tab-id set per unmounted surface', async () => {
+ await renderWatcherEffects()
+
+ const entries = lastSyncEntries()
+ const unmountedSets = new Set(
+ [...entries]
+ .filter(([workspaceId]) => workspaceId !== PARKED_WORKTREE_ID)
+ .map(([, entry]) => entry.parkedTabIds)
+ )
+ // Pre-fix this was one empty Set per surface (422 of them) on every fire.
+ expect(unmountedSets.size).toBe(1)
+ expect([...unmountedSets][0]?.size).toBe(0)
+ })
+})
diff --git a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx
index 770b1cfc47b..59984d7fe06 100644
--- a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx
+++ b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx
@@ -8,6 +8,7 @@ import { makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures'
import { useTerminalWorkspaceFoundation } from './use-terminal-workspace-foundation'
import { applyTerminalColdActivation } from './terminal-cold-activation'
import { collectTerminalParkingPassCandidates } from './terminal-parking-pass-candidates'
+import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
import type { WorkspaceSurface } from './workspace-surface-projection'
import type { TerminalParkingFoundation } from './use-terminal-parking-foundation'
@@ -147,6 +148,84 @@ describe('workspace surface ids', () => {
])
expect(hiddenSince.has('repo::/stale')).toBe(false)
})
+ it('keeps the surface projection stable across a git-worktree switch', () => {
+ const repo = makeRepo()
+ const worktrees = Array.from({ length: 423 }, (_, index) =>
+ makeWorktree(`repo-1::/worktree-${index}`, `Workspace ${index}`)
+ )
+ useAppStore.setState({
+ worktreesByRepo: { [repo.id]: worktrees },
+ activeWorktreeId: worktrees[0].id
+ })
+
+ const { result } = renderHook(() => useTerminalWorkspaceFoundation())
+ const surfaces = result.current.workspaceSurfaces
+ const ids = result.current.workspaceSurfaceIds
+ const idSet = result.current.workspaceSurfaceIdSet
+ expect(surfaces).toHaveLength(423)
+
+ // Pre-fix every switch re-ran the projection (423 surface objects) and re-derived
+ // the id array, because the active id was a dep even with no folder host to tie-break.
+ for (let index = 1; index < 10; index += 1) {
+ act(() => {
+ useAppStore.setState({ activeWorktreeId: worktrees[index].id })
+ })
+ expect(result.current.renderedActiveWorktreeId).toBe(worktrees[index].id)
+ expect(result.current.workspaceSurfaces).toBe(surfaces)
+ expect(result.current.workspaceSurfaceIds).toBe(ids)
+ expect(result.current.workspaceSurfaceIdSet).toBe(idSet)
+ }
+ })
+
+ it('still re-projects when the active folder workspace owns the collision tie-break', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const localFolder: FolderWorkspace = {
+ id: 'folder-shared',
+ projectGroupId: 'group-shared',
+ name: 'orca',
+ folderPath: '/work/orca-local',
+ connectionId: null,
+ executionHostId: 'local',
+ linkedTask: null,
+ comment: '',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 1,
+ createdAt: 1,
+ updatedAt: 1
+ }
+ const runtimeFolder: FolderWorkspace = {
+ ...localFolder,
+ folderPath: '/remote/orca',
+ executionHostId: 'runtime:env-1'
+ }
+ useAppStore.setState({
+ folderWorkspaces: [localFolder, runtimeFolder],
+ activeWorktreeId: 'folder:folder-shared',
+ activeWorkspaceExecutionHostId: 'runtime:env-1'
+ })
+
+ const { result } = renderHook(() => useTerminalWorkspaceFoundation())
+ expect(result.current.workspaceSurfaces).toEqual([
+ { id: 'folder:folder-shared', path: '/remote/orca' }
+ ])
+
+ // Leaving the folder workspace drops the tie-break, so the projection must re-run
+ // and fall back to first-wins rather than mount the previous host's path.
+ act(() => {
+ useAppStore.setState({
+ activeWorktreeId: 'repo-1::/worktree-0',
+ activeWorkspaceExecutionHostId: null
+ })
+ })
+ expect(result.current.workspaceSurfaces).toEqual([
+ { id: 'folder:folder-shared', path: '/work/orca-local' }
+ ])
+ warn.mockRestore()
+ })
+
it('stops idle worktree writes from re-firing surface-keyed terminal effects', () => {
const repo = makeRepo()
const worktrees = Array.from({ length: 20 }, (_, index) =>
diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts
index 3c6a89fb323..fc44352c942 100644
--- a/src/renderer/src/components/use-terminal-watcher-effects.ts
+++ b/src/renderer/src/components/use-terminal-watcher-effects.ts
@@ -17,6 +17,10 @@ import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-l
import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle'
import type { TerminalColdActivationController } from './terminal-cold-activation'
+// Why shared: only a mounted workspace can park a tab, and the watcher sync only reads
+// this set, so every other surface would otherwise allocate its own empty one per fire.
+const NO_PARKED_TAB_IDS: ReadonlySet = new Set()
+
export function useTerminalWatcherEffects(controller: TerminalColdActivationController): void {
const {
activationDeferredMountTabIdsByWorktreeRef,
@@ -58,9 +62,11 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont
continue
}
const tabs = tabsByWorktree[workspaceId] ?? []
- const parkedTabIds = new Set()
+ let parkedTabIds: ReadonlySet = NO_PARKED_TAB_IDS
let deferredTabIds: ReadonlySet | null = null
if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspaceId)) {
+ const mountedParkedTabIds = new Set()
+ parkedTabIds = mountedParkedTabIds
const isVisible = activeView === 'terminal' && workspaceId === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspaceId)
@@ -75,7 +81,7 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont
tabId: tab.id
})
if (!activityTerminalPortal && !evictionExemptTerminalTabIds.has(tab.id)) {
- parkedTabIds.add(tab.id)
+ mountedParkedTabIds.add(tab.id)
}
}
}
@@ -83,14 +89,14 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont
for (const tab of tabs) {
if (
deferredTabIds?.has(tab.id) &&
- !parkedTabIds.has(tab.id) &&
+ !mountedParkedTabIds.has(tab.id) &&
canWatcherCoverParkedTerminalTab(workspaceId, tab) &&
!findActivityTerminalPortal(activityTerminalPortals, {
worktreeId: workspaceId,
tabId: tab.id
})
) {
- parkedTabIds.add(tab.id)
+ mountedParkedTabIds.add(tab.id)
}
}
}
diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts
index 61726fa9643..d68c5104da2 100644
--- a/src/renderer/src/components/use-terminal-workspace-foundation.ts
+++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts
@@ -37,15 +37,19 @@ export function useTerminalWorkspaceFoundation() {
parseWorkspaceKey(renderedActiveWorktreeId ?? '')?.type === 'folder'
? activeWorktreeDeferralHostId
: null
+ // Why gate the id on the host: the projection reads `activeWorkspaceId` only behind a
+ // truthy resolved host, so without one every active id yields the same surfaces — and a
+ // worktree switch must not re-project (and re-identify) every surface to rediscover that.
+ const activeFolderSurfaceId = activeFolderSurfaceHostId ? renderedActiveWorktreeId : null
const workspaceSurfaces = useMemo(
() =>
projectWorkspaceSurfaces({
worktreesById,
folderWorkspaces,
- activeWorkspaceId: renderedActiveWorktreeId,
+ activeWorkspaceId: activeFolderSurfaceId,
activeWorkspaceResolvedHostId: activeFolderSurfaceHostId
}),
- [worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId]
+ [worktreesById, folderWorkspaces, activeFolderSurfaceId, activeFolderSurfaceHostId]
)
// Why split the ids out: every mount/park/activation pass reads only `.id`, but
// the surface array is re-identified on any worktree write. Reusing the previous
diff --git a/src/renderer/src/components/workspace-surface-projection.test.ts b/src/renderer/src/components/workspace-surface-projection.test.ts
index d5226bc5662..7f7c1709ca9 100644
--- a/src/renderer/src/components/workspace-surface-projection.test.ts
+++ b/src/renderer/src/components/workspace-surface-projection.test.ts
@@ -118,6 +118,24 @@ describe('projectWorkspaceSurfaces', () => {
expect(surfaces).toEqual([{ id: 'folder:folder-shared', path: '/remote/orca' }])
})
+ it('ignores the active workspace id entirely when no resolved host disambiguates', () => {
+ // The terminal foundation memo relies on this: with no resolved folder host it passes
+ // `null` instead of the active id, so a worktree switch cannot change the projection.
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const folderWorkspaces = [localFolder, runtimeFolder]
+ const worktrees = [localWorktree]
+
+ const withoutActiveId = project({ worktrees, folderWorkspaces, activeWorkspaceId: null })
+ for (const activeWorkspaceId of [
+ 'folder:folder-shared',
+ 'folder:other-workspace',
+ SHARED_WORKTREE_ID
+ ]) {
+ expect(project({ worktrees, folderWorkspaces, activeWorkspaceId })).toEqual(withoutActiveId)
+ }
+ warn.mockRestore()
+ })
+
it('keeps the first row when no resolved host disambiguates the folder collision', () => {
const surfaces = project({
folderWorkspaces: [runtimeFolder, localFolder]
diff --git a/src/renderer/src/components/workspace-surface-projection.ts b/src/renderer/src/components/workspace-surface-projection.ts
index 4a3d28ea17f..9b0a5731be2 100644
--- a/src/renderer/src/components/workspace-surface-projection.ts
+++ b/src/renderer/src/components/workspace-surface-projection.ts
@@ -49,6 +49,7 @@ export function projectWorkspaceSurfaces({
}: {
worktreesById: ReadonlyMap>
folderWorkspaces: readonly FolderWorkspaceSurfaceRow[]
+ /** Read only when `activeWorkspaceResolvedHostId` is set; inert otherwise. */
activeWorkspaceId: string | null
/** Resolved (not user-selected) host of the active workspace; the folder tie-break. */
activeWorkspaceResolvedHostId: ExecutionHostId | null
From c84007c541d86c12616ea588c98ea0232fbf4406 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Fri, 11 Sep 2026 00:18:39 -0400
Subject: [PATCH 05/17] feat(rpc): generate a shared params catalog from the
host registry, gated on parse parity (#19961)
---
.gitattributes | 3 +
.github/workflows/pr.yml | 3 +
.oxlintrc.json | 1 +
.../scripts/generate-rpc-params-catalog.mjs | 249 ++++
config/scripts/oxc-cli-invocation.mjs | 23 +
config/scripts/oxc-cli-invocation.test.mjs | 40 +
config/scripts/oxlint-cli-invocation.mjs | 21 +-
...params-contract-type-only-boundary.test.ts | 144 ++
mobile/src/transport/rpc-params-contract.ts | 8 +
package.json | 4 +-
.../git/command-runner/git-exec-options.ts | 5 +-
src/main/runtime/rpc/methods/accounts.ts | 91 +-
src/main/runtime/rpc/methods/agent-hooks.ts | 15 +-
src/main/runtime/rpc/methods/agent-session.ts | 185 +--
src/main/runtime/rpc/methods/ai-vault.ts | 79 +-
src/main/runtime/rpc/methods/artifacts.ts | 49 +-
.../runtime/rpc/methods/automation-schemas.ts | 201 +--
src/main/runtime/rpc/methods/browser-core.ts | 7 +-
.../runtime/rpc/methods/browser-extras.ts | 15 +-
.../runtime/rpc/methods/browser-schemas.ts | 407 +-----
.../runtime/rpc/methods/browser-screencast.ts | 6 +-
.../rpc/methods/browser-tab-create-schema.ts | 27 +-
src/main/runtime/rpc/methods/client-events.ts | 9 +-
.../rpc/methods/client-settings-schemas.ts | 127 +-
.../runtime/rpc/methods/client-ui-schemas.ts | 247 +---
.../client-ui-workspace-filter-fields.ts | 12 +-
src/main/runtime/rpc/methods/clipboard.ts | 74 +-
.../runtime/rpc/methods/computer-schemas.ts | 241 +---
src/main/runtime/rpc/methods/computer.ts | 9 +-
src/main/runtime/rpc/methods/emulator.ts | 171 +--
.../rpc/methods/files-mutation-methods.ts | 94 +-
.../rpc/methods/files-target-schemas.ts | 16 +-
.../files-terminal-artifact-methods.ts | 23 +-
src/main/runtime/rpc/methods/files.ts | 114 +-
.../runtime/rpc/methods/folder-workspace.ts | 91 +-
.../rpc/methods/git-admission-tier-schema.ts | 12 +-
src/main/runtime/rpc/methods/git-params.ts | 296 +----
.../rpc/methods/github-issue-methods.ts | 33 +-
.../rpc/methods/github-issue-update-schema.ts | 14 +-
.../rpc/methods/github-project-methods.ts | 143 +-
.../methods/github-pull-request-methods.ts | 90 +-
.../github-pull-request-update-methods.ts | 88 +-
.../rpc/methods/github-repo-target-schemas.ts | 15 +-
.../methods/github-repo-work-item-methods.ts | 47 +-
src/main/runtime/rpc/methods/gitlab.ts | 169 +--
src/main/runtime/rpc/methods/hosted-review.ts | 52 +-
src/main/runtime/rpc/methods/jira.ts | 115 +-
.../rpc/methods/linear-agent-access.ts | 159 +--
.../linear-issue-attribute-filter-schema.ts | 36 +-
.../rpc/methods/linear-issue-list-method.ts | 40 +-
.../rpc/methods/linear-project-create.ts | 20 +-
src/main/runtime/rpc/methods/linear.ts | 141 +-
src/main/runtime/rpc/methods/native-chat.ts | 55 +-
src/main/runtime/rpc/methods/notifications.ts | 68 +-
.../federation/federation-control.ts | 25 +-
.../federation/federation-relay.ts | 52 +-
.../federation/federation-start-schema.ts | 32 +-
.../rpc/methods/orchestration/gates/gates.ts | 41 +-
.../runs/mutation-request-show.ts | 5 +-
.../rpc/methods/orchestration/runs/runs.ts | 28 +-
.../rpc/methods/orchestration/schemas.ts | 170 +--
.../orchestration/worker/worker-control.ts | 13 +-
.../worker/worker-release-schemas.ts | 30 +-
.../orchestration/worker/worker-release.ts | 13 +-
.../worker/worker-start-schema.ts | 65 +-
.../orchestration/worker/worker-stop.ts | 5 +-
src/main/runtime/rpc/methods/plugins.ts | 26 +-
src/main/runtime/rpc/methods/preflight.ts | 16 +-
.../methods/project-runtime-rpc-methods.ts | 103 +-
.../runtime/rpc/methods/repo-update-schema.ts | 80 +-
src/main/runtime/rpc/methods/repo.ts | 121 +-
.../methods/runtime-client-capabilities.ts | 8 +-
.../rpc/methods/session-tabs-schemas.ts | 243 +---
src/main/runtime/rpc/methods/session-tabs.ts | 8 +-
src/main/runtime/rpc/methods/skills.ts | 15 +-
src/main/runtime/rpc/methods/speech.ts | 55 +-
src/main/runtime/rpc/methods/ssh.ts | 6 +-
.../structured-agent-session-schemas.ts | 265 +---
.../rpc/methods/task-resume-state-schema.ts | 35 +-
.../runtime/rpc/methods/terminal-orphan.ts | 105 +-
.../terminal-quick-command-rpc-schema.ts | 74 +-
.../rpc/methods/terminal/stream-schemas.ts | 43 +-
.../terminal/terminal-viewport-methods.ts | 4 +-
.../rpc/methods/terminal/unary-schemas.ts | 244 +---
.../rpc/methods/terminal/viewport-schemas.ts | 57 +-
.../rpc/methods/ui-update-value-tolerance.ts | 29 +-
src/main/runtime/rpc/methods/updater.ts | 7 +-
.../methods/workspace-cleanup-ui-schema.ts | 31 +-
.../runtime/rpc/methods/workspace-ports.ts | 16 +-
.../rpc/methods/worktree-create-schemas.ts | 158 +--
.../runtime/rpc/methods/worktree-schemas.ts | 233 +---
.../worktree-visibility-defaults-schema.ts | 20 +-
src/main/runtime/rpc/schemas.ts | 96 +-
src/shared/rpc-contract/accounts-params.ts | 81 ++
src/shared/rpc-contract/agent-hooks-params.ts | 14 +
.../rpc-contract/agent-session-params.ts | 189 +++
src/shared/rpc-contract/ai-vault-params.ts | 73 ++
src/shared/rpc-contract/artifacts-params.ts | 43 +
src/shared/rpc-contract/automation-params.ts | 198 +++
.../rpc-contract/browser-core-params.ts | 5 +
.../rpc-contract/browser-extras-params.ts | 14 +
src/shared/rpc-contract/browser-params.ts | 355 +++++
.../rpc-contract/browser-screencast-params.ts | 5 +
.../rpc-contract/browser-tab-create-params.ts | 23 +
.../rpc-contract/client-events-params.ts | 8 +
.../rpc-contract/client-settings-params.ts | 123 ++
src/shared/rpc-contract/client-ui-params.ts | 257 ++++
...lient-ui-workspace-filter-fields-params.ts | 11 +
src/shared/rpc-contract/clipboard-params.ts | 67 +
src/shared/rpc-contract/computer-params.ts | 5 +
.../rpc-contract/computer-schemas-params.ts | 227 ++++
src/shared/rpc-contract/emulator-params.ts | 154 +++
.../rpc-contract/files-mutation-params.ts | 84 ++
src/shared/rpc-contract/files-params.ts | 99 ++
.../rpc-contract/files-target-params.ts | 15 +
.../files-terminal-artifact-params.ts | 19 +
.../rpc-contract/folder-workspace-params.ts | 85 ++
.../rpc-contract/git-admission-tier-params.ts | 14 +
src/shared/rpc-contract/git-params.ts | 271 ++++
.../rpc-contract/github-issue-params.ts | 27 +
.../github-issue-update-params.ts | 13 +
.../rpc-contract/github-project-params.ts | 128 ++
.../github-pull-request-params.ts | 79 ++
.../github-pull-request-update-params.ts | 76 ++
.../rpc-contract/github-repo-target-params.ts | 14 +
.../github-repo-work-item-params.ts | 39 +
src/shared/rpc-contract/gitlab-params.ts | 149 +++
.../rpc-contract/hosted-review-params.ts | 47 +
src/shared/rpc-contract/jira-params.ts | 101 ++
.../linear-agent-access-params.ts | 146 +++
.../linear-issue-attribute-filter-params.ts | 35 +
.../rpc-contract/linear-issue-list-params.ts | 38 +
src/shared/rpc-contract/linear-params.ts | 124 ++
.../linear-project-create-params.ts | 20 +
src/shared/rpc-contract/native-chat-params.ts | 50 +
.../rpc-contract/notifications-params.ts | 61 +
...orchestration-federation-control-params.ts | 22 +
.../orchestration-federation-relay-params.ts | 47 +
.../orchestration-federation-start-params.ts | 29 +
.../orchestration-gates-params.ts | 34 +
.../rpc-contract/orchestration-params.ts | 153 +++
...ation-runs-mutation-request-show-params.ts | 4 +
.../rpc-contract/orchestration-runs-params.ts | 23 +
.../orchestration-worker-control-params.ts | 11 +
.../orchestration-worker-release-params.ts | 12 +
...estration-worker-release-schemas-params.ts | 25 +
.../orchestration-worker-start-params.ts | 61 +
.../orchestration-worker-stop-params.ts | 4 +
src/shared/rpc-contract/plugins-params.ts | 20 +
src/shared/rpc-contract/preflight-params.ts | 13 +
.../rpc-contract/project-runtime-params.ts | 95 ++
src/shared/rpc-contract/repo-params.ts | 100 ++
src/shared/rpc-contract/repo-update-params.ts | 77 ++
.../rpc-contract/rpc-param-primitives.ts | 84 ++
.../rpc-params-catalog.generated.ts | 1167 +++++++++++++++++
.../runtime-client-capabilities-params.ts | 7 +
.../rpc-contract/session-tabs-params.ts | 7 +
.../session-tabs-schemas-params.ts | 230 ++++
src/shared/rpc-contract/skills-params.ts | 12 +
src/shared/rpc-contract/speech-params.ts | 54 +
src/shared/rpc-contract/ssh-params.ts | 5 +
.../structured-agent-session-params.ts | 246 ++++
.../rpc-contract/task-resume-state-params.ts | 32 +
.../rpc-contract/terminal-orphan-params.ts | 105 ++
.../terminal-quick-command-params.ts | 73 ++
.../rpc-contract/terminal-stream-params.ts | 40 +
.../rpc-contract/terminal-unary-params.ts | 222 ++++
.../terminal-viewport-methods-params.ts | 3 +
.../terminal-viewport-schemas-params.ts | 51 +
.../ui-update-value-tolerance-params.ts | 25 +
src/shared/rpc-contract/updater-params.ts | 6 +
.../workspace-cleanup-ui-params.ts | 28 +
.../rpc-contract/workspace-ports-params.ts | 12 +
.../rpc-contract/worktree-create-params.ts | 154 +++
src/shared/rpc-contract/worktree-params.ts | 215 +++
.../worktree-visibility-defaults-params.ts | 19 +
176 files changed, 8220 insertions(+), 5874 deletions(-)
create mode 100644 config/scripts/generate-rpc-params-catalog.mjs
create mode 100644 config/scripts/oxc-cli-invocation.mjs
create mode 100644 config/scripts/oxc-cli-invocation.test.mjs
create mode 100644 mobile/src/rpc-params-contract-type-only-boundary.test.ts
create mode 100644 mobile/src/transport/rpc-params-contract.ts
create mode 100644 src/shared/rpc-contract/accounts-params.ts
create mode 100644 src/shared/rpc-contract/agent-hooks-params.ts
create mode 100644 src/shared/rpc-contract/agent-session-params.ts
create mode 100644 src/shared/rpc-contract/ai-vault-params.ts
create mode 100644 src/shared/rpc-contract/artifacts-params.ts
create mode 100644 src/shared/rpc-contract/automation-params.ts
create mode 100644 src/shared/rpc-contract/browser-core-params.ts
create mode 100644 src/shared/rpc-contract/browser-extras-params.ts
create mode 100644 src/shared/rpc-contract/browser-params.ts
create mode 100644 src/shared/rpc-contract/browser-screencast-params.ts
create mode 100644 src/shared/rpc-contract/browser-tab-create-params.ts
create mode 100644 src/shared/rpc-contract/client-events-params.ts
create mode 100644 src/shared/rpc-contract/client-settings-params.ts
create mode 100644 src/shared/rpc-contract/client-ui-params.ts
create mode 100644 src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts
create mode 100644 src/shared/rpc-contract/clipboard-params.ts
create mode 100644 src/shared/rpc-contract/computer-params.ts
create mode 100644 src/shared/rpc-contract/computer-schemas-params.ts
create mode 100644 src/shared/rpc-contract/emulator-params.ts
create mode 100644 src/shared/rpc-contract/files-mutation-params.ts
create mode 100644 src/shared/rpc-contract/files-params.ts
create mode 100644 src/shared/rpc-contract/files-target-params.ts
create mode 100644 src/shared/rpc-contract/files-terminal-artifact-params.ts
create mode 100644 src/shared/rpc-contract/folder-workspace-params.ts
create mode 100644 src/shared/rpc-contract/git-admission-tier-params.ts
create mode 100644 src/shared/rpc-contract/git-params.ts
create mode 100644 src/shared/rpc-contract/github-issue-params.ts
create mode 100644 src/shared/rpc-contract/github-issue-update-params.ts
create mode 100644 src/shared/rpc-contract/github-project-params.ts
create mode 100644 src/shared/rpc-contract/github-pull-request-params.ts
create mode 100644 src/shared/rpc-contract/github-pull-request-update-params.ts
create mode 100644 src/shared/rpc-contract/github-repo-target-params.ts
create mode 100644 src/shared/rpc-contract/github-repo-work-item-params.ts
create mode 100644 src/shared/rpc-contract/gitlab-params.ts
create mode 100644 src/shared/rpc-contract/hosted-review-params.ts
create mode 100644 src/shared/rpc-contract/jira-params.ts
create mode 100644 src/shared/rpc-contract/linear-agent-access-params.ts
create mode 100644 src/shared/rpc-contract/linear-issue-attribute-filter-params.ts
create mode 100644 src/shared/rpc-contract/linear-issue-list-params.ts
create mode 100644 src/shared/rpc-contract/linear-params.ts
create mode 100644 src/shared/rpc-contract/linear-project-create-params.ts
create mode 100644 src/shared/rpc-contract/native-chat-params.ts
create mode 100644 src/shared/rpc-contract/notifications-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-federation-control-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-federation-relay-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-federation-start-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-gates-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-runs-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-worker-control-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-worker-release-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-worker-start-params.ts
create mode 100644 src/shared/rpc-contract/orchestration-worker-stop-params.ts
create mode 100644 src/shared/rpc-contract/plugins-params.ts
create mode 100644 src/shared/rpc-contract/preflight-params.ts
create mode 100644 src/shared/rpc-contract/project-runtime-params.ts
create mode 100644 src/shared/rpc-contract/repo-params.ts
create mode 100644 src/shared/rpc-contract/repo-update-params.ts
create mode 100644 src/shared/rpc-contract/rpc-param-primitives.ts
create mode 100644 src/shared/rpc-contract/rpc-params-catalog.generated.ts
create mode 100644 src/shared/rpc-contract/runtime-client-capabilities-params.ts
create mode 100644 src/shared/rpc-contract/session-tabs-params.ts
create mode 100644 src/shared/rpc-contract/session-tabs-schemas-params.ts
create mode 100644 src/shared/rpc-contract/skills-params.ts
create mode 100644 src/shared/rpc-contract/speech-params.ts
create mode 100644 src/shared/rpc-contract/ssh-params.ts
create mode 100644 src/shared/rpc-contract/structured-agent-session-params.ts
create mode 100644 src/shared/rpc-contract/task-resume-state-params.ts
create mode 100644 src/shared/rpc-contract/terminal-orphan-params.ts
create mode 100644 src/shared/rpc-contract/terminal-quick-command-params.ts
create mode 100644 src/shared/rpc-contract/terminal-stream-params.ts
create mode 100644 src/shared/rpc-contract/terminal-unary-params.ts
create mode 100644 src/shared/rpc-contract/terminal-viewport-methods-params.ts
create mode 100644 src/shared/rpc-contract/terminal-viewport-schemas-params.ts
create mode 100644 src/shared/rpc-contract/ui-update-value-tolerance-params.ts
create mode 100644 src/shared/rpc-contract/updater-params.ts
create mode 100644 src/shared/rpc-contract/workspace-cleanup-ui-params.ts
create mode 100644 src/shared/rpc-contract/workspace-ports-params.ts
create mode 100644 src/shared/rpc-contract/worktree-create-params.ts
create mode 100644 src/shared/rpc-contract/worktree-params.ts
create mode 100644 src/shared/rpc-contract/worktree-visibility-defaults-params.ts
diff --git a/.gitattributes b/.gitattributes
index 736d59473f6..1aa7969e805 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -34,3 +34,6 @@
# Generated runtime English subset: compared byte-for-byte by
# verify:localization-runtime-catalog, so a CRLF checkout would fail the gate.
/src/renderer/src/i18n/en-runtime-required.json linguist-generated=true text eol=lf
+# Generated method->params catalog: compared byte-for-byte by
+# verify:rpc-params-catalog, so a CRLF checkout would fail the gate.
+/src/shared/rpc-contract/rpc-params-catalog.generated.ts linguist-generated=true text eol=lf
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 268b6ad66e3..c49091ab148 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -203,6 +203,9 @@ jobs:
- name: Boot orcad and round-trip a terminal
run: pnpm run smoke:orcad-terminal
+ - name: Verify the generated RPC params catalog
+ run: pnpm run verify:rpc-params-catalog
+
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 03cc659f494..55758560478 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -180,6 +180,7 @@
}
],
"ignorePatterns": [
+ "src/shared/rpc-contract/rpc-params-catalog.generated.ts",
"**/node_modules",
"**/dist",
"**/out",
diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs
new file mode 100644
index 00000000000..acf31aae6a1
--- /dev/null
+++ b/config/scripts/generate-rpc-params-catalog.mjs
@@ -0,0 +1,249 @@
+// Why: the host registry is the only place that binds a method name to its params
+// schema. Reading it back — instead of hand-listing 600 methods — is what keeps the
+// shared catalog and the dispatcher from drifting apart.
+import { execFileSync } from 'node:child_process'
+import {
+ existsSync,
+ globSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync
+} from 'node:fs'
+import { createRequire } from 'node:module'
+import path from 'node:path'
+import process from 'node:process'
+import * as esbuild from 'esbuild'
+import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs'
+
+const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
+const SHARED_DIR = path.join(REPO_ROOT, 'src', 'shared')
+const CONTRACT_DIR = path.join(SHARED_DIR, 'rpc-contract')
+const RPC_DIR = path.join(REPO_ROOT, 'src', 'main', 'runtime', 'rpc')
+const REGISTRY_ENTRY = path.join(RPC_DIR, 'methods', 'index.ts')
+const OUTPUT_PATH = path.join(CONTRACT_DIR, 'rpc-params-catalog.generated.ts')
+
+// Why mkdirSync first: out/ is gitignored and absent on a fresh checkout, so
+// mkdtempSync threw ENOENT and took `pnpm lint` down with it. Why not os.tmpdir():
+// the bundle keeps its node_modules deps external and oxfmt reads .oxfmtrc.json by
+// walking up, so both scratch files have to sit under the repo to resolve at all.
+function scratchDir(prefix) {
+ const root = path.join(REPO_ROOT, 'out')
+ mkdirSync(root, { recursive: true })
+ return mkdtempSync(path.join(root, prefix))
+}
+
+const posix = (value) => value.split(path.sep).join('/')
+const repoPath = (absolute) => posix(path.relative(REPO_ROOT, absolute))
+
+// Every module the catalog may import from: the extracted params modules plus the
+// pre-existing src/shared schemas the RPC methods already bind directly.
+function indexableModules() {
+ const modules = new Set(
+ globSync('*.ts', { cwd: CONTRACT_DIR }).map((name) => path.join(CONTRACT_DIR, name))
+ )
+ modules.delete(OUTPUT_PATH)
+ for (const file of globSync('**/*.ts', { cwd: RPC_DIR })) {
+ if (file.endsWith('.test.ts')) {
+ continue
+ }
+ const source = readFileSync(path.join(RPC_DIR, file), 'utf8')
+ for (const [, specifier] of source.matchAll(/from\s+'(\.[^']+)'/g)) {
+ const resolved = `${path.resolve(path.dirname(path.join(RPC_DIR, file)), specifier)}.ts`
+ if (resolved.startsWith(`${SHARED_DIR}${path.sep}`) && existsSync(resolved)) {
+ modules.add(resolved)
+ }
+ }
+ }
+ return [...modules].sort()
+}
+
+// Why: one bundle keeps the registry and the shared modules on the same module
+// instances, so schema object identity is what maps a method to its export.
+function loadRegistryAndSchemas(modules) {
+ const buildDir = scratchDir('rpc-params-catalog-')
+ try {
+ const entry = path.join(buildDir, 'entry.ts')
+ const importOf = (file) => JSON.stringify(posix(path.relative(buildDir, file)))
+ writeFileSync(
+ entry,
+ [
+ `export { ALL_RPC_METHODS } from ${importOf(REGISTRY_ENTRY)}`,
+ 'export const SCHEMA_MODULES = {',
+ ...modules.map(
+ (file) => ` ${JSON.stringify(repoPath(file))}: require(${importOf(file)}),`
+ ),
+ '}'
+ ].join('\n')
+ )
+ const outfile = path.join(buildDir, 'bundle.cjs')
+ esbuild.buildSync({
+ entryPoints: [entry],
+ bundle: true,
+ platform: 'node',
+ format: 'cjs',
+ outfile,
+ logLevel: 'error',
+ packages: 'external'
+ })
+ const loaded = createRequire(import.meta.url)(outfile)
+ return { methods: loaded.ALL_RPC_METHODS, schemaModules: loaded.SCHEMA_MODULES }
+ } finally {
+ rmSync(buildDir, { recursive: true, force: true })
+ }
+}
+
+// Why: schema objects are compared by identity, not by shape — two structurally
+// identical schemas are still two different wire contracts.
+function buildSchemaIndex(schemaModules) {
+ const index = new Map()
+ for (const [modulePath, moduleExports] of Object.entries(schemaModules)) {
+ for (const [exportName, value] of Object.entries(moduleExports)) {
+ if (!value || typeof value !== 'object' || typeof value.safeParse !== 'function') {
+ continue
+ }
+ if (index.has(value)) {
+ continue
+ }
+ index.set(value, { modulePath, exportName })
+ }
+ }
+ return index
+}
+
+function localNameFor(origin, taken) {
+ if (!taken.has(origin.exportName)) {
+ return origin.exportName
+ }
+ const hint = path
+ .basename(origin.modulePath, '.ts')
+ .split('-')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join('')
+ let candidate = `${origin.exportName}Of${hint}`
+ let suffix = 2
+ while (taken.has(candidate)) {
+ candidate = `${origin.exportName}Of${hint}${suffix++}`
+ }
+ return candidate
+}
+
+function render({ methods, schemaModules }) {
+ const index = buildSchemaIndex(schemaModules)
+ const entries = []
+ const uncataloged = []
+ const imports = new Map()
+ const taken = new Set()
+
+ for (const method of [...methods].sort((left, right) => (left.name < right.name ? -1 : 1))) {
+ if (method.params === null) {
+ entries.push(` '${method.name}': null`)
+ continue
+ }
+ const origin = index.get(method.params)
+ if (!origin) {
+ uncataloged.push(method.name)
+ continue
+ }
+ const key = `${origin.modulePath}#${origin.exportName}`
+ let local = imports.get(key)
+ if (!local) {
+ local = localNameFor(origin, taken)
+ taken.add(local)
+ imports.set(key, local)
+ }
+ entries.push(` '${method.name}': ${local}`)
+ }
+
+ const byModule = new Map()
+ for (const [key, local] of imports) {
+ const [modulePath, exportName] = key.split('#')
+ if (!byModule.has(modulePath)) {
+ byModule.set(modulePath, [])
+ }
+ byModule.get(modulePath).push(local === exportName ? exportName : `${exportName} as ${local}`)
+ }
+ const importLines = [...byModule]
+ .sort(([left], [right]) => (left < right ? -1 : 1))
+ .map(([modulePath, names]) => {
+ let specifier = posix(path.relative(CONTRACT_DIR, path.join(REPO_ROOT, modulePath))).replace(
+ /\.ts$/,
+ ''
+ )
+ if (!specifier.startsWith('.')) {
+ specifier = `./${specifier}`
+ }
+ return `import { ${names.sort().join(', ')} } from '${specifier}'`
+ })
+
+ return `// GENERATED by config/scripts/generate-rpc-params-catalog.mjs. Do not edit;
+// run \`pnpm run generate:rpc-params-catalog\`.
+import type { z } from 'zod'
+${importLines.join('\n')}
+
+// Why: the host parses params with these schemas, so a client that matches this map
+// matches the dispatcher. Clients must import it for types only — parsing a params
+// schema client-side runs the coercing transforms and rewrites the wire bytes.
+export const RPC_PARAMS_BY_METHOD = {
+${entries.join(',\n')}
+} as const
+
+// Why: these methods bind a schema the shared contract cannot hold because its value
+// graph reaches into src/main. Listing them keeps the gap visible instead of absent.
+export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [
+${uncataloged.map((name) => ` '${name}'`).join(',\n')}
+]
+
+export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD
+
+// Why: z.output is the post-parse shape the handler receives. z.input is not a
+// send-side type here — requiredString is z.unknown().transform(...), so its input
+// admits any value and loses optional/default semantics.
+export type RpcParams =
+ (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType
+ ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]>
+ : void
+`
+}
+
+// Why: the drift gate compares bytes, so the generator must emit exactly what the
+// formatter would produce or every run would look like drift.
+function formatted(source) {
+ const buildDir = scratchDir('rpc-params-catalog-fmt-')
+ try {
+ const file = path.join(buildDir, 'rpc-params-catalog.generated.ts')
+ writeFileSync(file, source)
+ const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', REPO_ROOT)
+ execFileSync(command, [...prefixArgs, '--write', file], {
+ stdio: 'ignore',
+ windowsHide: true
+ })
+ return readFileSync(file, 'utf8')
+ } finally {
+ rmSync(buildDir, { recursive: true, force: true })
+ }
+}
+
+function main() {
+ const check = process.argv.includes('--check')
+ const generated = formatted(render(loadRegistryAndSchemas(indexableModules())))
+ const current = existsSync(OUTPUT_PATH) ? readFileSync(OUTPUT_PATH, 'utf8') : null
+ if (generated === current) {
+ if (!check) {
+ console.log(`rpc params catalog already up to date: ${repoPath(OUTPUT_PATH)}`)
+ }
+ return
+ }
+ if (check) {
+ console.error(
+ `${repoPath(OUTPUT_PATH)} is out of date. Run \`pnpm run generate:rpc-params-catalog\`.`
+ )
+ process.exitCode = 1
+ return
+ }
+ writeFileSync(OUTPUT_PATH, generated)
+ console.log(`wrote ${repoPath(OUTPUT_PATH)}`)
+}
+
+main()
diff --git a/config/scripts/oxc-cli-invocation.mjs b/config/scripts/oxc-cli-invocation.mjs
new file mode 100644
index 00000000000..4bf17b5c994
--- /dev/null
+++ b/config/scripts/oxc-cli-invocation.mjs
@@ -0,0 +1,23 @@
+import { createRequire } from 'node:module'
+import path from 'node:path'
+import process from 'node:process'
+
+// Why not `pnpm exec ` / `node_modules/.bin/.cmd`: both land on a Windows
+// .cmd shim, and Node >= 20 refuses to spawn one without `shell: true` (the
+// CVE-2024-27980 mitigation), so every gate that took that route died with EINVAL
+// before doing any work. The oxc bins are plain Node scripts, so run them under this
+// process's own node — no shim, no shell, no quoting question.
+export function resolveOxcCliInvocation(packageName, binName, root = process.cwd()) {
+ const requireFromRoot = createRequire(path.join(root, 'package.json'))
+ // The oxc packages' "exports" hide ./bin, so read the manifest and walk to its bin entry.
+ const manifestPath = requireFromRoot.resolve(`${packageName}/package.json`)
+ const binField = requireFromRoot(`${packageName}/package.json`).bin
+ const binEntry = typeof binField === 'string' ? binField : binField?.[binName]
+ if (!binEntry) {
+ throw new Error(`${packageName} package.json declares no "${binName}" bin entry.`)
+ }
+ return {
+ command: process.execPath,
+ prefixArgs: [path.resolve(path.dirname(manifestPath), binEntry)]
+ }
+}
diff --git a/config/scripts/oxc-cli-invocation.test.mjs b/config/scripts/oxc-cli-invocation.test.mjs
new file mode 100644
index 00000000000..5776580dbfe
--- /dev/null
+++ b/config/scripts/oxc-cli-invocation.test.mjs
@@ -0,0 +1,40 @@
+import { spawnSync } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import path from 'node:path'
+import process from 'node:process'
+import { describe, expect, it } from 'vitest'
+import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs'
+
+const repoRoot = path.resolve(import.meta.dirname, '..', '..')
+
+describe('resolveOxcCliInvocation', () => {
+ it('runs oxfmt under this process node, never through a shim', () => {
+ const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', repoRoot)
+
+ expect(command).toBe(process.execPath)
+ expect(prefixArgs).toHaveLength(1)
+ // The params-catalog generator spawned node_modules/.bin/oxfmt, which is a .cmd on
+ // Windows — Node >= 20 refuses it without shell:true and dies with EINVAL.
+ expect(prefixArgs[0]).not.toMatch(/\.(cmd|bat)$/i)
+ expect(existsSync(prefixArgs[0])).toBe(true)
+ })
+
+ it('spawns oxfmt without a shell', () => {
+ const { command, prefixArgs } = resolveOxcCliInvocation('oxfmt', 'oxfmt', repoRoot)
+ const result = spawnSync(command, [...prefixArgs, '--help'], {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ shell: false,
+ windowsHide: true
+ })
+
+ expect(result.error).toBeUndefined()
+ expect(result.stdout).toContain('oxfmt')
+ })
+
+ it('names the package and bin it could not find', () => {
+ expect(() => resolveOxcCliInvocation('oxfmt', 'nope', repoRoot)).toThrow(
+ 'oxfmt package.json declares no "nope" bin entry.'
+ )
+ })
+})
diff --git a/config/scripts/oxlint-cli-invocation.mjs b/config/scripts/oxlint-cli-invocation.mjs
index 605aa33c686..92e6f55fb95 100644
--- a/config/scripts/oxlint-cli-invocation.mjs
+++ b/config/scripts/oxlint-cli-invocation.mjs
@@ -1,23 +1,6 @@
-import { createRequire } from 'node:module'
-import path from 'node:path'
import process from 'node:process'
+import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs'
-// Why not `pnpm exec oxlint` / `node_modules/.bin/oxlint.cmd`: both land on a
-// Windows .cmd shim, and Node >= 20 refuses to spawn one without `shell: true`
-// (the CVE-2024-27980 mitigation), so every lint gate died with EINVAL before
-// linting anything. Oxlint's bin is a plain Node script, so run it under this
-// process's own node — no shim, no shell, no quoting question.
export function resolveOxlintInvocation(root = process.cwd()) {
- const requireFromRoot = createRequire(path.join(root, 'package.json'))
- // Oxlint's "exports" hides ./bin, so read the manifest and walk to its bin entry.
- const manifestPath = requireFromRoot.resolve('oxlint/package.json')
- const binField = requireFromRoot('oxlint/package.json').bin
- const binEntry = typeof binField === 'string' ? binField : binField?.oxlint
- if (!binEntry) {
- throw new Error('oxlint package.json declares no "oxlint" bin entry.')
- }
- return {
- command: process.execPath,
- prefixArgs: [path.resolve(path.dirname(manifestPath), binEntry)]
- }
+ return resolveOxcCliInvocation('oxlint', 'oxlint', root)
}
diff --git a/mobile/src/rpc-params-contract-type-only-boundary.test.ts b/mobile/src/rpc-params-contract-type-only-boundary.test.ts
new file mode 100644
index 00000000000..bc9088bea0b
--- /dev/null
+++ b/mobile/src/rpc-params-contract-type-only-boundary.test.ts
@@ -0,0 +1,144 @@
+import { readFileSync, readdirSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { extname, join, relative, resolve } from 'node:path'
+import ts from 'typescript'
+import { describe, expect, it } from 'vitest'
+
+// Why: src/shared/rpc-contract/*-params.ts hold the host's zod schemas. Bundling one
+// into the app would let client code call parse(), and requiredString is
+// z.unknown().transform(...) — it coerces a non-string to '' instead of rejecting it,
+// silently changing the bytes the phone puts on the wire. Types only, never values.
+const mobileRoot = fileURLToPath(new URL('..', import.meta.url))
+const contractRoot = resolve(mobileRoot, '..', 'src', 'shared', 'rpc-contract')
+const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory))
+const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx'])
+
+function sourceFiles(directory: string): string[] {
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(directory, entry.name)
+ if (entry.isDirectory()) {
+ return entry.name === 'node_modules' ? [] : sourceFiles(path)
+ }
+ return [path]
+ })
+}
+
+function targetsContract(path: string, specifier: string): boolean {
+ if (!specifier.startsWith('.')) {
+ return false
+ }
+ const resolved = resolve(path, '..', specifier)
+ return resolved === contractRoot || resolved.startsWith(`${contractRoot}/`)
+}
+
+function parse(path: string, source: string): ts.SourceFile {
+ const extension = extname(path)
+ return ts.createSourceFile(
+ path,
+ source,
+ ts.ScriptTarget.Latest,
+ true,
+ extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
+ )
+}
+
+// Returns the specifiers that would pull contract *values* into the bundle.
+export function contractValueImports(path: string, source: string): string[] {
+ const sourceFile = parse(path, source)
+ const offenders: string[] = []
+ const visit = (node: ts.Node): void => {
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
+ const specifier = node.moduleSpecifier.text
+ if (targetsContract(path, specifier)) {
+ const clause = node.importClause
+ const everyNamedIsType =
+ clause?.isTypeOnly === true ||
+ (clause?.namedBindings !== undefined &&
+ ts.isNamedImports(clause.namedBindings) &&
+ clause.namedBindings.elements.every((element) => element.isTypeOnly))
+ // A bare `import './x'` has no clause at all and still emits a require.
+ if (!everyNamedIsType) {
+ offenders.push(specifier)
+ }
+ }
+ }
+ if (
+ ts.isExportDeclaration(node) &&
+ node.moduleSpecifier &&
+ ts.isStringLiteral(node.moduleSpecifier)
+ ) {
+ const specifier = node.moduleSpecifier.text
+ if (targetsContract(path, specifier)) {
+ const everyNamedIsType =
+ node.isTypeOnly ||
+ (node.exportClause !== undefined &&
+ ts.isNamedExports(node.exportClause) &&
+ node.exportClause.elements.every((element) => element.isTypeOnly))
+ if (!everyNamedIsType) {
+ offenders.push(specifier)
+ }
+ }
+ }
+ if (ts.isCallExpression(node)) {
+ const callee = node.expression
+ const isDynamic = callee.kind === ts.SyntaxKind.ImportKeyword
+ const isRequire = ts.isIdentifier(callee) && callee.text === 'require'
+ const argument = node.arguments[0]
+ if (
+ (isDynamic || isRequire) &&
+ argument &&
+ ts.isStringLiteral(argument) &&
+ targetsContract(path, argument.text)
+ ) {
+ offenders.push(argument.text)
+ }
+ }
+ ts.forEachChild(node, visit)
+ }
+ visit(sourceFile)
+ return offenders
+}
+
+describe('RPC params contract boundary', () => {
+ it('flags every shape that would emit a runtime require', () => {
+ const path = join(mobileRoot, 'src', 'probe.ts')
+ const contract = '../../src/shared/rpc-contract/repo-params'
+ expect(contractValueImports(path, `import { RepoSelector } from '${contract}'`)).toEqual([
+ contract
+ ])
+ expect(contractValueImports(path, `import '${contract}'`)).toEqual([contract])
+ expect(contractValueImports(path, `export { RepoSelector } from '${contract}'`)).toEqual([
+ contract
+ ])
+ expect(contractValueImports(path, `const s = require('${contract}')`)).toEqual([contract])
+ expect(contractValueImports(path, `const s = await import('${contract}')`)).toEqual([contract])
+ expect(contractValueImports(path, `import type { RepoSelector } from '${contract}'`)).toEqual(
+ []
+ )
+ expect(contractValueImports(path, `import { type RepoSelector } from '${contract}'`)).toEqual(
+ []
+ )
+ expect(contractValueImports(path, `export type { RepoSelector } from '${contract}'`)).toEqual(
+ []
+ )
+ expect(
+ contractValueImports(
+ path,
+ `import type { GitHubWorkItem } from '../../src/shared/github/work-item-types'`
+ )
+ ).toEqual([])
+ })
+
+ it('keeps every mobile import of the params contract type-only', () => {
+ const offenders = scannedRoots
+ .flatMap(sourceFiles)
+ .filter((path) => sourceExtensions.has(extname(path)))
+ .flatMap((path) =>
+ contractValueImports(path, readFileSync(path, 'utf8')).map(
+ (specifier) => `${relative(mobileRoot, path)} -> ${specifier}`
+ )
+ )
+
+ expect(offenders).toEqual([])
+ })
+})
diff --git a/mobile/src/transport/rpc-params-contract.ts b/mobile/src/transport/rpc-params-contract.ts
new file mode 100644
index 00000000000..fcf3303e965
--- /dev/null
+++ b/mobile/src/transport/rpc-params-contract.ts
@@ -0,0 +1,8 @@
+// Why: mobile's only entry to the host's params contract, and type-only on purpose.
+// The schemas behind these types must never reach the bundle: requiredString is
+// z.unknown().transform(...), so a client-side parse coerces a non-string to ''
+// instead of rejecting it, silently changing the bytes on the wire.
+export type {
+ RpcMethodName,
+ RpcParams
+} from '../../../src/shared/rpc-contract/rpc-params-catalog.generated'
diff --git a/package.json b/package.json
index 0536f4fd606..9feaad74882 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src",
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",
"format": "oxfmt --write .",
- "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
+ "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -38,6 +38,8 @@
"smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad",
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
+ "generate:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs",
+ "verify:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs --check",
"generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write",
"verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check",
"generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write",
diff --git a/src/main/git/command-runner/git-exec-options.ts b/src/main/git/command-runner/git-exec-options.ts
index 4390d84658d..75ced0d3030 100644
--- a/src/main/git/command-runner/git-exec-options.ts
+++ b/src/main/git/command-runner/git-exec-options.ts
@@ -1,7 +1,10 @@
// Why: cap execFile output to prevent an uncatchable V8 string overflow; match relay MAX_GIT_BUFFER.
export const DEFAULT_GIT_MAX_BUFFER = 10 * 1024 * 1024
-export type GitAdmissionTier = 'interactive' | 'status' | 'background'
+// Why: the admission tier is a wire value, so it is declared with its params schema.
+import type { GitAdmissionTier } from '../../../shared/rpc-contract/git-admission-tier-params'
+
+export type { GitAdmissionTier }
export type GitExecOptions = {
cwd: string
diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts
index 41d82965d00..328276518d7 100644
--- a/src/main/runtime/rpc/methods/accounts.ts
+++ b/src/main/runtime/rpc/methods/accounts.ts
@@ -1,5 +1,14 @@
-import { z } from 'zod'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
+import {
+ AccountsUnsubscribeParams,
+ AddClaudeFromConfigDirParams,
+ AddCodexFromHomeParams,
+ ConsumeCodexResetCreditParams,
+ ListAccountsParams,
+ RemoveAccountParams,
+ SelectAccountParams,
+ SelectCodexAccountForTargetParams
+} from '../../../../shared/rpc-contract/accounts-params'
// Why: monotonically increasing per-process counter avoids the Date.now()
// collision that fired when two near-simultaneous accounts.subscribe calls
@@ -7,86 +16,6 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
// registerSubscriptionCleanup's existing-key eviction path.
let accountsSubscriptionSeq = 0
-const CodexResetTarget = z.discriminatedUnion('runtime', [
- z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(),
- // Why: reset scope must identify one exact WSL distro; null means all slots only for selection.
- z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict()
-])
-
-const CodexSelectionTarget = z.discriminatedUnion('runtime', [
- z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(),
- z
- .object({
- runtime: z.literal('wsl'),
- // A null distro intentionally means all WSL selection slots.
- wslDistro: z.string().trim().min(1).max(255).nullable()
- })
- .strict()
-])
-
-const SelectAccountParams = z.object({
- accountId: z
- .union([z.string().min(1, 'Missing accountId'), z.null()])
- .transform((v) => (v === null ? null : v))
-})
-
-const SelectCodexAccountForTargetParams = SelectAccountParams.extend({
- target: CodexSelectionTarget
-})
-
-const RemoveAccountParams = z.object({
- accountId: z.string().min(1, 'Missing accountId')
-})
-
-const CodexResetExpectedScope = z
- .object({
- target: CodexResetTarget,
- accountId: z.string().min(1, 'Missing accountId').max(512),
- accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
- offerRevision: z.string().startsWith('v1:', 'Invalid offerRevision').max(4_096)
- })
- .strict()
-
-const ConsumeCodexResetCreditParams = z
- .object({
- // Why: the phone owns the logical attempt key so a lost response can be
- // retried without spending a finite earned credit twice.
- idempotencyKey: z.uuid('Invalid idempotencyKey'),
- expectedScope: CodexResetExpectedScope
- })
- .strict()
-
-const AddClaudeFromConfigDirParams = z.object({
- configDir: z.string().min(1, 'Missing configDir'),
- runtime: z.enum(['host', 'wsl']).optional(),
- wslDistro: z.string().nullish(),
- previousLegacyCredentialsSha256: z
- .string()
- .regex(/^[a-f0-9]{64}$/, 'Invalid legacy credential digest')
- .nullable()
- .optional()
-})
-
-const AddCodexFromHomeParams = z.object({
- sourceHome: z.string().min(1, 'Missing sourceHome'),
- runtime: z.enum(['host', 'wsl']).optional(),
- wslDistro: z.string().nullish()
-})
-
-// Why: `orca account list` prints only emails and the active ids, so it opts out
-// of the forced all-provider usage refresh below — that lane bypasses the poll
-// throttle and Retry-After gate and costs one serial round-trip per account.
-const ListAccountsParams = z.object({
- refreshUsage: z.boolean().default(true)
-})
-
-const AccountsUnsubscribeParams = z.object({
- subscriptionId: z
- .unknown()
- .transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
- .pipe(z.string().min(1, 'Missing subscriptionId'))
-})
-
// Why: bridges the desktop ClaudeAccountService / CodexAccountService /
// RateLimitService into the WebSocket / local-socket RPC. Read + switch +
// remove for all clients; interactive add/re-auth flows spawn `claude login`
diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts
index b26b117bd32..e68c7df05df 100644
--- a/src/main/runtime/rpc/methods/agent-hooks.ts
+++ b/src/main/runtime/rpc/methods/agent-hooks.ts
@@ -1,19 +1,6 @@
-import { z } from 'zod'
import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight'
import { defineMethod, type RpcMethod } from '../core'
-
-const PrepareCodexForWslPaneParams = z
- .object({
- codexHome: z.string().max(4_096),
- orcaCodexHome: z.string().max(4_096),
- wslDistro: z
- .string()
- .trim()
- .min(1)
- .max(255)
- .regex(/^[^\\/\r\n]+$/)
- })
- .strict()
+import { PrepareCodexForWslPaneParams } from '../../../../shared/rpc-contract/agent-hooks-params'
export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts
index 08aaa91038e..79da783a939 100644
--- a/src/main/runtime/rpc/methods/agent-session.ts
+++ b/src/main/runtime/rpc/methods/agent-session.ts
@@ -1,9 +1,3 @@
-import { z } from 'zod'
-import {
- getAgentResumeArgv,
- hasUnsafeProviderSessionIdChars,
- RESUMABLE_TUI_AGENTS
-} from '../../../../shared/agent-session-resume'
import type {
RuntimeAgentSessionRpcCaller,
RuntimeCreateAgentSessionRequest,
@@ -15,182 +9,13 @@ import {
AGENT_SESSION_OPERATION_FUTURE_SKEW_MS,
parseAgentSessionOperationTimestamp
} from '../../../../shared/agent-session-host-authority'
-import { isTuiAgent } from '../../../../shared/tui-agent-config'
-import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { defineMethod, type RpcAnyMethod } from '../core'
-
-const MAX_WORKTREE_SELECTOR_LENGTH = 32_768
-const MAX_TRANSCRIPT_PATH_BYTES = 16 * 1024
-const MAX_PROMPT_BYTES = 256 * 1024
-const MAX_AGENT_ARGS_BYTES = 16 * 1024
-const MAX_LAUNCH_PREFERENCE_LENGTH = 512
-
-const StrictNonEmptyString = (max: number, message: string) =>
- z
- .string()
- .min(1, message)
- .max(max, message)
- .refine((value) => value === value.trim(), `${message}; surrounding whitespace is invalid`)
-
-const WorktreeSelector = StrictNonEmptyString(
- MAX_WORKTREE_SELECTOR_LENGTH,
- 'Invalid worktree selector'
-)
-
-const Presentation = z.enum(['background', 'focused'])
-
-const Placement = z
- .object({
- tabId: z
- .string()
- .min(1)
- .max(512)
- .refine(isValidTerminalTabId, 'Invalid terminal tab ID')
- .optional(),
- leafId: z.string().min(1).max(128).optional()
- })
- .strict()
- .refine((value) => value.tabId !== undefined || value.leafId !== undefined, {
- message: 'Placement must include a tab or leaf ID'
- })
-
-const LaunchPreferences = z
- .object({
- model: StrictNonEmptyString(
- MAX_LAUNCH_PREFERENCE_LENGTH,
- 'Invalid model preference'
- ).optional(),
- effort: StrictNonEmptyString(
- MAX_LAUNCH_PREFERENCE_LENGTH,
- 'Invalid effort preference'
- ).optional(),
- mode: StrictNonEmptyString(MAX_LAUNCH_PREFERENCE_LENGTH, 'Invalid mode preference').optional()
- })
- .strict()
-
-const PromptDelivery = z.enum(['auto-submit', 'draft'])
-
-const AgentArgs = z
- .string()
- .refine(
- (value) => Buffer.byteLength(value, 'utf8') <= MAX_AGENT_ARGS_BYTES,
- 'Agent arguments are too large'
- )
- .nullable()
-
-const OmpResumeFilePath = z
- .string()
- .min(1)
- .refine((value) => value === value.trim(), 'Invalid OMP resume path')
- .refine(
- (value) =>
- !hasUnsafeProviderSessionIdChars(value) &&
- Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES,
- 'Invalid OMP resume path'
- )
-
-const ProviderSession = z
- .object({
- key: z.enum(['session_id', 'conversation_id']),
- id: StrictNonEmptyString(512, 'Invalid provider session ID').refine(
- (value) => !value.startsWith('-') && !hasUnsafeProviderSessionIdChars(value),
- 'Invalid provider session ID'
- ),
- transcriptPath: z
- .string()
- .min(1)
- .refine((value) => value === value.trim(), 'Invalid transcript path')
- .refine(
- (value) =>
- !hasUnsafeProviderSessionIdChars(value) &&
- Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES,
- 'Invalid transcript path'
- )
- .optional()
- })
- .strict()
-
-const AutomaticEnsure = z
- .object({
- kind: z.literal('automatic'),
- sleepingCheckpointId: z
- .string()
- .min(32)
- .max(128)
- .regex(/^[A-Za-z0-9_-]+$/),
- presentation: Presentation.optional()
- })
- .strict()
-
-const ExplicitEnsure = z
- .object({
- kind: z.literal('explicit'),
- worktree: WorktreeSelector,
- agent: z.enum(RESUMABLE_TUI_AGENTS),
- providerSession: ProviderSession,
- ompResumeFilePath: OmpResumeFilePath.optional(),
- agentArgs: AgentArgs.optional(),
- launchPreferences: LaunchPreferences.optional(),
- presentation: Presentation.optional(),
- placement: Placement.optional()
- })
- .strict()
- .superRefine((value, context) => {
- if (value.ompResumeFilePath !== undefined && value.agent !== 'omp') {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['ompResumeFilePath'],
- message: 'OMP resume path requires the OMP agent'
- })
- }
- if (getAgentResumeArgv(value.agent, value.providerSession, value.ompResumeFilePath) === null) {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['providerSession'],
- message: 'Provider session is not resumable for this agent'
- })
- }
- })
-
-export const EnsureAgentSessionParams: z.ZodType =
- z.discriminatedUnion('kind', [AutomaticEnsure, ExplicitEnsure])
-
-export const CreateAgentSessionParams: z.ZodType = z
- .object({
- clientOperationId: z
- .string()
- .refine(
- (value) => parseAgentSessionOperationTimestamp(value) !== null,
- 'Invalid agent operation ID'
- ),
- worktree: WorktreeSelector,
- agent: z.string().refine(isTuiAgent, 'Unknown agent preset'),
- prompt: z
- .string()
- .refine(
- (value) => Buffer.byteLength(value, 'utf8') <= MAX_PROMPT_BYTES,
- 'Prompt is too large'
- )
- .optional(),
- promptDelivery: PromptDelivery.optional(),
- agentArgs: AgentArgs.optional(),
- launchPreferences: LaunchPreferences.optional(),
- startupCwd: z.string().min(1).max(MAX_WORKTREE_SELECTOR_LENGTH).optional(),
- presentation: Presentation.optional(),
- placement: Placement.optional(),
- viewMode: z.enum(['terminal', 'chat']).optional()
- })
- .strict()
- .superRefine((value, context) => {
- if (value.promptDelivery === 'draft' && !value.prompt?.trim()) {
- context.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['prompt'],
- message: 'Draft delivery requires a non-empty prompt'
- })
- }
- })
+import {
+ CreateAgentSessionParams,
+ EnsureAgentSessionParams
+} from '../../../../shared/rpc-contract/agent-session-params'
+export { CreateAgentSessionParams, EnsureAgentSessionParams }
type AgentSessionRuntime = OrcaRuntimeService & {
ensureAgentSession(
diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts
index c689165924d..d5f52bafded 100644
--- a/src/main/runtime/rpc/methods/ai-vault.ts
+++ b/src/main/runtime/rpc/methods/ai-vault.ts
@@ -1,84 +1,19 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalBoolean } from '../schemas'
import { restampAiVaultListResult } from '../../../ai-vault/session-list-results'
-import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types'
-import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title'
import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation'
-import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host'
+import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
assertLegacyAiVaultResumeAllowed,
projectStructuredAiVaultSessions
} from '../../../ai-vault/structured-session-ownership'
-
-// Why: bound limit + scopePaths so a client cannot force an unbounded scan.
-// Each scopePath is a host-local match prefix (validated/capped, never used for
-// traversal); the count/length caps mirror the worktree-schemas bounding style.
-const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
-const AI_VAULT_LIMIT_MAX = 2000
-
-const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => {
- const parsed = parseExecutionHostId(value)
- if (parsed?.kind === 'runtime') {
- return parsed.id
- }
- ctx.addIssue({
- code: 'custom',
- message: 'Invalid runtime execution host id'
- })
- return z.NEVER
-})
-
-export const AiVaultListSessionsParams = z
- .object({
- limit: z
- .unknown()
- .transform((value) =>
- typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
- )
- .pipe(z.union([z.number().int(), z.undefined()]))
- .optional(),
- unlimited: OptionalBoolean,
- force: OptionalBoolean,
- scopePaths: z
- .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH))
- // Why: clamp instead of reject — scope paths only ever widen discovery, and
- // rejecting would hard-break older/uncapped producers (web client, pre-cap
- // desktop parents) that send more than the bound.
- .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT))
- .optional(),
- // Why: desktop/web callers name the runtime host they are addressing; mobile
- // omits it. The scan itself is host-local either way, so the id must never
- // change what is scanned — it only restamps the shared cached result.
- executionHostId: executionHostIdSchema.optional()
- })
- .superRefine((params, ctx) => {
- if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) {
- ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' })
- }
- })
-
-export const AiVaultPrepareSessionResumeParams = z.object({
- agent: z.enum(AI_VAULT_AGENTS),
- sessionId: z.string().min(1).max(512).optional(),
- filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH),
- codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(),
- executionHostId: z.string().optional()
-})
-
-export const AiVaultSessionTitlesParams = z.object({
- requests: z
- .array(
- z.object({
- agent: z.enum(['claude', 'codex']),
- sessionId: z.string().min(1).max(512),
- transcriptPath: z.string().min(1).max(32_768).optional()
- })
- )
- .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)
-})
+import {
+ AiVaultListSessionsParams,
+ AiVaultPrepareSessionResumeParams,
+ AiVaultSessionTitlesParams
+} from '../../../../shared/rpc-contract/ai-vault-params'
+export { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams }
export const AI_VAULT_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts
index 4b5d7b5ab3a..c627380d612 100644
--- a/src/main/runtime/rpc/methods/artifacts.ts
+++ b/src/main/runtime/rpc/methods/artifacts.ts
@@ -1,45 +1,10 @@
-import { z } from 'zod'
-import {
- ARTIFACT_MAX_CONTENT_BYTES,
- ARTIFACT_MAX_REQUEST_BYTES,
- artifactContentByteLength,
- artifactWriteRequestByteLength
-} from '../../../../shared/artifacts'
import { defineMethod, type RpcAnyMethod } from '../core'
-
-const CloudOptions = {
- apiUrl: z.string().max(2_048).optional(),
- authToken: z.string().max(16_384).optional()
-}
-
-const ListOptions = z.object({
- ...CloudOptions,
- cursor: z.string().min(1).max(2_048).optional()
-})
-
-const SourceRequest = z.object({
- sourceKey: z.string().min(1).max(32_768),
- ...CloudOptions
-})
-
-const WriteRequest = z
- .object({
- sourceKey: z.string().min(1).max(32_768),
- content: z
- .string()
- .min(1)
- .max(ARTIFACT_MAX_CONTENT_BYTES)
- .refine((content) => artifactContentByteLength(content) <= ARTIFACT_MAX_CONTENT_BYTES, {
- message: 'Artifact content exceeds the 10 MiB limit.'
- }),
- contentType: z.enum(['text/html', 'text/markdown']),
- fileName: z.string().min(1).max(512),
- title: z.string().max(512).optional(),
- ...CloudOptions
- })
- .refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_MAX_REQUEST_BYTES, {
- message: 'Artifact request exceeds the supported size.'
- })
+import {
+ ArtifactsDeleteParams,
+ ListOptions,
+ SourceRequest,
+ WriteRequest
+} from '../../../../shared/rpc-contract/artifacts-params'
export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
defineMethod({
@@ -74,7 +39,7 @@ export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
}),
defineMethod({
name: 'artifacts.delete',
- params: z.object({ id: z.string().min(1), ...CloudOptions }),
+ params: ArtifactsDeleteParams,
handler: (params, { runtime }) => runtime.deleteArtifact(params.id, params)
})
]
diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts
index f2c829c1a9d..23962b84ccc 100644
--- a/src/main/runtime/rpc/methods/automation-schemas.ts
+++ b/src/main/runtime/rpc/methods/automation-schemas.ts
@@ -1,193 +1,10 @@
// Why: the automation method table stays readable only if its field-level validation lives beside it rather than inside it.
-import { z } from 'zod'
-import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing'
-import {
- MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
- normalizeAutomationPrecheckTimeoutSeconds
-} from '../../../../shared/automation-precheck'
-import { normalizeExecutionHostId } from '../../../../shared/execution-host'
-import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../../../../shared/task-source-context'
-import { isTuiAgent } from '../../../../shared/tui-agent-config'
-import {
- OptionalBoolean,
- OptionalPlainString,
- OptionalPositiveInt,
- OptionalString,
- requiredNumber,
- requiredString
-} from '../schemas'
-
-const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, {
- message: 'Unknown provider'
-})
-
-const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional()
-const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional()
-const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => {
- const hostId = normalizeExecutionHostId(value)
- if (!hostId) {
- ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
- return z.NEVER
- }
- return hostId
-})
-
-const AutomationSchedule = requiredString('Missing trigger').refine(isValidAutomationSchedule, {
- message: 'Invalid automation trigger'
-})
-
-const AutomationPrecheck = z
- .object({
- command: requiredString('Missing precheck command'),
- timeoutSeconds: OptionalPositiveInt.transform((value) =>
- normalizeAutomationPrecheckTimeoutSeconds(value)
- ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, {
- message: 'Precheck timeout is too large'
- })
- })
- .nullable()
- .optional()
-
-const OptionalNullablePlainString = z
- .unknown()
- .transform((value) => (value === null || typeof value === 'string' ? value : undefined))
- .pipe(z.union([z.string(), z.null(), z.undefined()]))
- .optional()
-
-const TaskProviderIdentity = z
- .custom(
- (value) =>
- value !== null &&
- typeof value === 'object' &&
- 'provider' in value &&
- ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider))
- )
- .optional()
- .nullable()
-
-const TaskSourceContext = z
- .object({
- kind: z.literal('task-source'),
- provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
- projectId: requiredString('Missing source project id'),
- hostId: ExecutionHostId,
- projectHostSetupId: OptionalNullablePlainString,
- repoId: OptionalNullablePlainString,
- providerIdentity: TaskProviderIdentity,
- accountLabel: OptionalNullablePlainString
- })
- .optional()
- .nullable()
-
-const WorkspaceRunContext = z
- .object({
- kind: z.literal('workspace-run'),
- projectId: requiredString('Missing run project id'),
- hostId: ExecutionHostId,
- projectHostSetupId: requiredString('Missing project host setup id'),
- repoId: requiredString('Missing repo id'),
- path: requiredString('Missing run path')
- })
- .optional()
- .nullable()
-
-const SshTargetGeneration = requiredNumber('Missing SSH target generation').refine(
- (value) => Number.isSafeInteger(value) && value >= 1,
- { message: 'Invalid SSH target generation' }
-)
-
-const OwnedSshSelector = z.object({
- kind: z.literal('ssh'),
- targetId: requiredString('Missing SSH target id'),
- targetGeneration: SshTargetGeneration
-})
-
-/** Orphan is accepted here, unlike a destination: a record with no executable host is still deletable. */
-const OwnerPreconditionSelector = z.discriminatedUnion('kind', [
- z.object({ kind: z.literal('self') }),
- OwnedSshSelector,
- z.object({ kind: z.literal('orphan') })
-])
-
-const DestinationSelector = z.discriminatedUnion('kind', [
- z.object({ kind: z.literal('self') }),
- OwnedSshSelector
-])
-
-export const ExpectedOwner = z.object({ selector: OwnerPreconditionSelector }).optional()
-export const Destination = z.object({ selector: DestinationSelector }).optional()
-
-const ListScopeSelector = z.discriminatedUnion('kind', [
- z.object({ kind: z.literal('self') }),
- z.object({
- kind: z.literal('ssh'),
- targetId: requiredString('Missing SSH target id'),
- expectedTargetGeneration: SshTargetGeneration
- }),
- z.object({ kind: z.literal('orphan') })
-])
-
-/** An omitted selector is the legacy request; old clients keep the authority's complete list. */
-export const AutomationList = z.object({ selector: ListScopeSelector.optional() })
-
-export const AutomationId = z.object({
- id: requiredString('Missing automation id'),
- expectedOwner: ExpectedOwner
-})
-
-export const AutomationRuns = z.object({
- automationId: OptionalString,
- expectedOwner: ExpectedOwner,
- limit: OptionalPositiveInt,
- cursor: OptionalString
-})
-
-export const AutomationCreate = z.object({
- creationKey: OptionalString,
- name: requiredString('Missing automation name'),
- prompt: requiredString('Missing automation prompt'),
- precheck: AutomationPrecheck,
- agentId: TuiAgent,
- runContext: WorkspaceRunContext,
- sourceContext: TaskSourceContext,
- repo: OptionalString,
- workspace: OptionalString,
- workspaceMode: AutomationWorkspaceMode,
- baseBranch: OptionalPlainString,
- setupDecision: SetupDecision,
- reuseSession: OptionalBoolean,
- timezone: OptionalString,
- rrule: AutomationSchedule,
- dtstart: requiredNumber('Missing trigger start time'),
- enabled: OptionalBoolean,
- missedRunGraceMinutes: OptionalPositiveInt,
- destination: Destination
-})
-
-const AutomationUpdateFields = z.object({
- name: OptionalString,
- prompt: OptionalString,
- precheck: AutomationPrecheck,
- agentId: TuiAgent.optional(),
- runContext: WorkspaceRunContext,
- sourceContext: TaskSourceContext,
- repo: OptionalString,
- workspace: OptionalString,
- workspaceMode: AutomationWorkspaceMode,
- // Why: update patches distinguish omitted from null so callers can clear a saved base branch.
- baseBranch: OptionalNullablePlainString,
- setupDecision: SetupDecision,
- reuseSession: OptionalBoolean,
- timezone: OptionalString,
- rrule: AutomationSchedule.optional(),
- dtstart: requiredNumber('Missing trigger start time').optional(),
- enabled: OptionalBoolean,
- missedRunGraceMinutes: OptionalPositiveInt
-})
-
-export const AutomationUpdate = z.object({
- id: requiredString('Missing automation id'),
- updates: AutomationUpdateFields,
- expectedOwner: ExpectedOwner,
- destination: Destination
-})
+export {
+ AutomationCreate,
+ AutomationId,
+ AutomationList,
+ AutomationRuns,
+ AutomationUpdate,
+ Destination,
+ ExpectedOwner
+} from '../../../../shared/rpc-contract/automation-params'
diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts
index 5e5fba227b6..596c30a31c8 100644
--- a/src/main/runtime/rpc/methods/browser-core.ts
+++ b/src/main/runtime/rpc/methods/browser-core.ts
@@ -1,5 +1,5 @@
import { defineMethod, type RpcMethod } from '../core'
-import { BrowserTarget, requiredString } from '../schemas'
+import { BrowserTarget } from '../schemas'
import {
Check,
Drag,
@@ -33,10 +33,7 @@ import {
} from './browser-schemas'
import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-schema'
import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods'
-
-const CertificateProceed = BrowserTarget.extend({
- challengeId: requiredString('Missing required challengeId')
-})
+import { CertificateProceed } from '../../../../shared/rpc-contract/browser-core-params'
export const BROWSER_CORE_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/browser-extras.ts b/src/main/runtime/rpc/methods/browser-extras.ts
index 692c5e19b7f..fe87bde950f 100644
--- a/src/main/runtime/rpc/methods/browser-extras.ts
+++ b/src/main/runtime/rpc/methods/browser-extras.ts
@@ -1,7 +1,6 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { assertRpcClipboardTextWriteWithinLimit } from '../rpc-clipboard-text-validation'
-import { BrowserTarget, OptionalFiniteNumber } from '../schemas'
+import { BrowserTarget } from '../schemas'
import {
ClipboardWrite,
CookieDelete,
@@ -22,17 +21,7 @@ import {
StorageKeyValue,
Viewport
} from './browser-schemas'
-
-const MouseModifiers = z
- .unknown()
- .transform((v) => (Array.isArray(v) ? v : undefined))
- .pipe(z.union([z.array(z.enum(['cmd', 'ctrl', 'alt', 'shift'])), z.undefined()]))
- .optional()
-
-const MouseClick = MouseXY.merge(MouseButton).extend({
- radius: OptionalFiniteNumber,
- modifiers: MouseModifiers
-})
+import { MouseClick } from '../../../../shared/rpc-contract/browser-extras-params'
export const BROWSER_EXTRA_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts
index 03872b61cc0..034fee898ad 100644
--- a/src/main/runtime/rpc/methods/browser-schemas.ts
+++ b/src/main/runtime/rpc/methods/browser-schemas.ts
@@ -1,356 +1,55 @@
// Why: browser schemas stay separate from handler registration so both sides
// remain under the line cap and dispatch wiring stays scannable.
-import { z } from 'zod'
-import {
- BrowserTarget,
- OptionalBoolean,
- OptionalFiniteNumber,
- OptionalPlainString,
- OptionalString,
- requiredStringAllowingEmpty,
- requiredString
-} from '../schemas'
-
-export const Element = BrowserTarget.extend({
- element: requiredString('Missing required --element')
-})
-
-export const Goto = BrowserTarget.extend({
- url: requiredString('Missing required --url')
-})
-
-export const Fill = BrowserTarget.extend({
- element: requiredString('Missing required --element'),
- value: requiredStringAllowingEmpty('Missing required --value')
-})
-
-export const Type = BrowserTarget.extend({
- input: requiredString('Missing required --input')
-})
-
-export const Select = BrowserTarget.extend({
- element: requiredString('Missing required --element'),
- value: z.custom((v) => typeof v === 'string', {
- message: 'Missing required --value'
- })
-})
-
-export const Scroll = BrowserTarget.extend({
- direction: z.custom<'up' | 'down'>((v) => v === 'up' || v === 'down', {
- message: 'Missing required --direction (up or down)'
- }),
- amount: z
- .unknown()
- .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined))
- .pipe(z.union([z.number(), z.undefined()]))
- .optional()
-})
-
-export const Screenshot = BrowserTarget.extend({
- format: z
- .unknown()
- .transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined))
- .pipe(z.union([z.enum(['png', 'jpeg']), z.undefined()]))
- .optional()
-})
-
-export const Screencast = BrowserTarget.extend({
- format: z
- .unknown()
- .optional()
- .transform((v) => (v === 'png' ? 'png' : 'jpeg'))
- .pipe(z.enum(['png', 'jpeg'])),
- quality: OptionalFiniteNumber,
- maxWidth: OptionalFiniteNumber,
- maxHeight: OptionalFiniteNumber,
- viewportWidth: OptionalFiniteNumber,
- viewportHeight: OptionalFiniteNumber,
- deviceScaleFactor: OptionalFiniteNumber,
- mobile: OptionalBoolean,
- everyNthFrame: OptionalFiniteNumber,
- minFrameIntervalMs: OptionalFiniteNumber
-})
-
-export const FullScreenshot = BrowserTarget.extend({
- format: z
- .unknown()
- .optional()
- .transform((v) => (v === 'jpeg' ? 'jpeg' : 'png'))
- .pipe(z.enum(['png', 'jpeg']))
-})
-
-export const Eval = BrowserTarget.extend({
- expression: requiredString('Missing required --expression')
-})
-
-export const TabList = z.object({ worktree: OptionalString })
-// Why: --index xor --page must be present. The refine guards that invariant
-// so the dispatcher surfaces a single legible error instead of either shape
-// leaking into the runtime.
-//
-// `focus` is opt-in: when true, the runtime sends `browser:pane-focus` to
-// the renderer after the switch lands. The renderer surfaces the browser
-// pane only if the user is already on the targeted worktree; otherwise it
-// pre-stages per-worktree state silently. This avoids cross-worktree screen
-// theft when multiple agents drive browsers in parallel worktrees.
-export const TabSwitch = BrowserTarget.extend({
- index: z
- .unknown()
- .transform((v) => (typeof v === 'number' ? v : undefined))
- .pipe(z.union([z.number(), z.undefined()]))
- .optional(),
- focus: z.boolean().optional()
-}).refine(
- (val) => {
- if (val.page !== undefined) {
- return true
- }
- return val.index !== undefined && Number.isInteger(val.index) && val.index >= 0
- },
- { message: 'Missing required --index (non-negative integer) or --page' }
-)
-
-export const TabShow = z.object({
- page: requiredString('Missing required --page'),
- worktree: OptionalString
-})
-
-export const TabCurrent = z.object({ worktree: OptionalString })
-
-export const TabClose = z.object({
- index: z
- .unknown()
- .transform((v) => (typeof v === 'number' ? v : undefined))
- .pipe(z.union([z.number(), z.undefined()]))
- .optional(),
- page: OptionalString,
- worktree: OptionalString
-})
-
-export const TabSetProfile = BrowserTarget.extend({
- profileId: requiredString('Missing required --profile')
-})
-
-export const TabProfileClone = BrowserTarget.extend({
- profileId: requiredString('Missing required --profile')
-})
-
-export const ProfileCreate = z.object({
- label: requiredString('Missing required --label'),
- // Strict enum so unknown scope values surface validation errors instead of being
- // silently coerced to 'isolated' (pr-bug-scan finding from #1397).
- scope: z.enum(['isolated', 'imported']),
- userAgentMode: z.enum(['clean', 'native']).optional()
-})
-
-export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') })
-
-export const ProfileImportFromBrowser = z.object({
- profileId: requiredString('Missing required --profile'),
- browserFamily: requiredString('Missing required --browser-family'),
- browserProfile: OptionalString,
- supportsPartitionSkippedCookies: z.literal(true).optional()
-})
-
-export const Drag = BrowserTarget.extend({
- from: requiredString('Missing required --from and --to element refs'),
- to: requiredString('Missing required --from and --to element refs')
-})
-
-export const Upload = BrowserTarget.extend({
- element: requiredString('Missing required --element and --files'),
- files: z.custom(
- (v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === 'string'),
- { message: 'Missing required --element and --files' }
- )
-})
-
-export const Wait = BrowserTarget.extend({
- selector: OptionalPlainString,
- timeout: z
- .unknown()
- .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined))
- .pipe(z.union([z.number(), z.undefined()]))
- .optional(),
- text: OptionalPlainString,
- url: OptionalPlainString,
- load: OptionalPlainString,
- fn: OptionalPlainString,
- state: OptionalPlainString
-})
-
-export const Check = BrowserTarget.extend({
- element: requiredString('Missing required --element'),
- checked: z
- .unknown()
- .optional()
- .transform((v) => (v === undefined ? true : v))
- .pipe(z.boolean())
-})
-
-export const Keypress = BrowserTarget.extend({
- key: requiredString('Missing required --key')
-})
-
-export const SelectorPath = BrowserTarget.extend({
- selector: requiredString('Missing required --selector and --path'),
- path: requiredString('Missing required --selector and --path')
-})
-
-export const Highlight = BrowserTarget.extend({
- selector: requiredString('Missing required --selector')
-})
-
-export const Exec = BrowserTarget.extend({
- command: requiredString('Missing required --command')
-})
-
-export const Get = BrowserTarget.extend({
- what: requiredString('Missing required --what'),
- selector: OptionalString
-})
-
-export const Is = BrowserTarget.extend({
- what: z.custom((v) => typeof v === 'string' && v.length > 0, {
- message: 'Missing required --what and --element'
- }),
- selector: z.custom((v) => typeof v === 'string' && v.length > 0, {
- message: 'Missing required --what and --element'
- })
-})
-
-export const KeyboardInsert = BrowserTarget.extend({
- text: requiredString('Missing required --text')
-})
-
-export const LimitParam = BrowserTarget.extend({
- limit: OptionalFiniteNumber
-})
-
-export const Find = BrowserTarget.extend({
- locator: requiredString('Missing required --locator, --value, and --action'),
- value: requiredString('Missing required --locator, --value, and --action'),
- action: requiredString('Missing required --locator, --value, and --action'),
- text: OptionalString
-})
-
-export const CookieGet = BrowserTarget.extend({
- url: OptionalPlainString
-})
-
-export const CookieSet = BrowserTarget.extend({
- name: z.custom((v) => typeof v === 'string' && v.length > 0, {
- message: 'Missing name or value'
- }),
- value: z.custom((v) => typeof v === 'string', {
- message: 'Missing name or value'
- }),
- domain: OptionalPlainString,
- path: OptionalPlainString,
- secure: OptionalBoolean,
- httpOnly: OptionalBoolean,
- sameSite: OptionalPlainString,
- expires: OptionalFiniteNumber
-})
-
-export const CookieDelete = BrowserTarget.extend({
- name: requiredString('Missing cookie name'),
- domain: OptionalPlainString,
- url: OptionalPlainString
-})
-
-export const Viewport = BrowserTarget.extend({
- width: z.custom((v) => typeof v === 'number' && v > 0, {
- message: 'Width and height must be positive numbers'
- }),
- height: z.custom((v) => typeof v === 'number' && v > 0, {
- message: 'Width and height must be positive numbers'
- }),
- deviceScaleFactor: OptionalFiniteNumber,
- mobile: OptionalBoolean
-})
-
-export const Geolocation = BrowserTarget.extend({
- latitude: z.custom((v) => typeof v === 'number', {
- message: 'Missing latitude or longitude'
- }),
- longitude: z.custom((v) => typeof v === 'number', {
- message: 'Missing latitude or longitude'
- }),
- accuracy: OptionalFiniteNumber
-})
-
-export const InterceptEnable = BrowserTarget.extend({
- patterns: z
- .unknown()
- .transform((v) => (Array.isArray(v) ? (v as string[]) : undefined))
- .pipe(z.union([z.array(z.string()), z.undefined()]))
- .optional()
-})
-
-export const MouseXY = BrowserTarget.extend({
- x: z.custom((v) => typeof v === 'number', {
- message: 'Missing required x and y coordinates'
- }),
- y: z.custom((v) => typeof v === 'number', {
- message: 'Missing required x and y coordinates'
- })
-})
-
-export const MouseButton = BrowserTarget.extend({
- button: OptionalPlainString
-})
-
-export const MouseWheel = BrowserTarget.extend({
- dy: z.custom((v) => typeof v === 'number', {
- message: 'Missing required --dy'
- }),
- dx: OptionalFiniteNumber
-})
-
-export const SetDevice = BrowserTarget.extend({
- name: requiredString('Missing required --name')
-})
-
-export const SetOffline = BrowserTarget.extend({
- state: OptionalPlainString
-})
-
-export const SetHeaders = BrowserTarget.extend({
- headers: requiredString('Missing required --headers (JSON string)')
-})
-
-export const SetCredentials = BrowserTarget.extend({
- user: z.custom((v) => typeof v === 'string' && v.length > 0, {
- message: 'Missing required --user and --pass'
- }),
- pass: z.custom((v) => typeof v === 'string', {
- message: 'Missing required --user and --pass'
- })
-})
-
-export const SetMedia = BrowserTarget.extend({
- colorScheme: OptionalPlainString,
- reducedMotion: OptionalPlainString
-})
-
-export const ClipboardWrite = BrowserTarget.extend({
- text: requiredString('Missing required --text')
-})
-
-export const DialogAccept = BrowserTarget.extend({
- text: OptionalPlainString
-})
-
-export const StorageKey = BrowserTarget.extend({
- key: requiredString('Missing required --key')
-})
-
-export const StorageKeyValue = BrowserTarget.extend({
- key: z.custom((v) => typeof v === 'string' && v.length > 0, {
- message: 'Missing required --key and --value'
- }),
- value: z.custom((v) => typeof v === 'string', {
- message: 'Missing required --key and --value'
- })
-})
+export {
+ Check,
+ ClipboardWrite,
+ CookieDelete,
+ CookieGet,
+ CookieSet,
+ DialogAccept,
+ Drag,
+ Element,
+ Eval,
+ Exec,
+ Fill,
+ Find,
+ FullScreenshot,
+ Geolocation,
+ Get,
+ Goto,
+ Highlight,
+ InterceptEnable,
+ Is,
+ KeyboardInsert,
+ Keypress,
+ LimitParam,
+ MouseButton,
+ MouseWheel,
+ MouseXY,
+ ProfileCreate,
+ ProfileDelete,
+ ProfileImportFromBrowser,
+ Screencast,
+ Screenshot,
+ Scroll,
+ Select,
+ SelectorPath,
+ SetCredentials,
+ SetDevice,
+ SetHeaders,
+ SetMedia,
+ SetOffline,
+ StorageKey,
+ StorageKeyValue,
+ TabClose,
+ TabCurrent,
+ TabList,
+ TabProfileClone,
+ TabSetProfile,
+ TabShow,
+ TabSwitch,
+ Type,
+ Upload,
+ Viewport,
+ Wait
+} from '../../../../shared/rpc-contract/browser-params'
diff --git a/src/main/runtime/rpc/methods/browser-screencast.ts b/src/main/runtime/rpc/methods/browser-screencast.ts
index ea965a2b44a..ed16e9d0edf 100644
--- a/src/main/runtime/rpc/methods/browser-screencast.ts
+++ b/src/main/runtime/rpc/methods/browser-screencast.ts
@@ -1,13 +1,9 @@
-import { z } from 'zod'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { Screencast } from './browser-schemas'
import { BrowserError } from '../../../browser/browser-error'
import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../../../shared/runtime-types'
import { runtimeBrowserCommandsFactoryIsAvailable } from '../../runtime-browser-commands-factory'
-
-const ScreencastUnsubscribe = z.object({
- subscriptionId: z.string().min(1, 'Missing required --subscription-id')
-})
+import { ScreencastUnsubscribe } from '../../../../shared/rpc-contract/browser-screencast-params'
export const BROWSER_SCREENCAST_METHODS: RpcAnyMethod[] = [
defineStreamingMethod({
diff --git a/src/main/runtime/rpc/methods/browser-tab-create-schema.ts b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts
index 799111b2a6b..ad7c0ee5b75 100644
--- a/src/main/runtime/rpc/methods/browser-tab-create-schema.ts
+++ b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts
@@ -1,23 +1,4 @@
-import { z } from 'zod'
-import { OptionalString } from '../schemas'
-import { BrowserPageCreationPlacement } from '../../../../shared/browser-client-host-placement'
-import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation'
-
-export const BrowserTabCreateParams = z.object({
- url: OptionalString,
- worktree: OptionalString,
- page: OptionalString,
- profileId: OptionalString,
- waitForRegistration: z.boolean().optional(),
- activate: z.boolean().optional(),
- // Why: `activate` says the caller wants the new tab selected; `navigation` says on whose screens.
- // Absent, a paired caller means 'caller' — one device's create must not steer every other UI.
- navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
- targetGroupId: OptionalString,
- placement: BrowserPageCreationPlacement.optional()
-})
-
-export const BrowserOpenUrlParams = z.object({
- url: z.url(),
- worktree: z.string().min(1)
-})
+export {
+ BrowserOpenUrlParams,
+ BrowserTabCreateParams
+} from '../../../../shared/rpc-contract/browser-tab-create-params'
diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts
index 0c3a079262f..6c23ed9f15d 100644
--- a/src/main/runtime/rpc/methods/client-events.ts
+++ b/src/main/runtime/rpc/methods/client-events.ts
@@ -1,17 +1,10 @@
-import { z } from 'zod'
import { getRegisteredSshState, listRegisteredSshTargets } from '../../../ssh/ssh-target-registry'
import { getPublicSshState } from '../../public-ssh-state'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
+import { ClientEventsUnsubscribeParams } from '../../../../shared/rpc-contract/client-events-params'
let clientEventSubscriptionSeq = 0
-const ClientEventsUnsubscribeParams = z.object({
- subscriptionId: z
- .unknown()
- .transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
- .pipe(z.string().min(1, 'Missing subscriptionId'))
-})
-
export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [
defineStreamingMethod({
name: 'runtime.clientEvents.subscribe',
diff --git a/src/main/runtime/rpc/methods/client-settings-schemas.ts b/src/main/runtime/rpc/methods/client-settings-schemas.ts
index e389ed9d12b..c8467636d9a 100644
--- a/src/main/runtime/rpc/methods/client-settings-schemas.ts
+++ b/src/main/runtime/rpc/methods/client-settings-schemas.ts
@@ -1,122 +1,5 @@
-import { z } from 'zod'
-import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides'
-import { isTaskProvider } from '../../../../shared/task-providers'
-import { isTuiAgent } from '../../../../shared/tui-agent-config'
-import {
- normalizeTuiAgentArgsRecord,
- normalizeTuiAgentEnvRecord
-} from '../../../../shared/tui-agent-launch-defaults'
-import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection'
-import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-schema'
-import type { TaskProvider } from '../../../../shared/task-providers'
-
-const TaskProviderParam = z.custom(isTaskProvider, {
- message: 'Unknown task provider'
-})
-
-export const PRBotAuthorOverrideUpdate = z
- .object({ author: z.string(), isBot: z.boolean() })
- .strict()
-
-const NativeChatSessionOptionPickBase = {
- modelId: z.string().trim().min(1).max(512),
- adoptModelAsLaunchDefault: z.boolean().optional()
-}
-
-const NativeChatSessionOptionPick = z.union([
- z
- .object({
- ...NativeChatSessionOptionPickBase,
- optionId: z.enum(['model', 'effort']),
- value: z.string().trim().min(1).max(512)
- })
- .strict(),
- z
- .object({
- ...NativeChatSessionOptionPickBase,
- optionId: z.enum(['fastMode', 'thinking']),
- value: z.boolean()
- })
- .strict()
-])
-
-export const NativeChatSessionOptionsMutation = z.discriminatedUnion('type', [
- z
- .object({
- type: z.literal('apply-picks'),
- agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']),
- picks: z.array(NativeChatSessionOptionPick).min(1).max(8)
- })
- .strict(),
- z
- .object({
- type: z.literal('clear-model-if-missing'),
- agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']),
- availableModelIds: z.array(z.string().trim().min(1).max(512)).min(1).max(256)
- })
- .strict()
-])
-
-const GitHubProjectRef = z
- .object({
- owner: z.string(),
- ownerType: z.enum(['organization', 'user']),
- number: z.number().int(),
- host: z.string().optional()
- })
- .strict()
-const GitHubProjectSettings = z
- .object({
- pinned: z.array(GitHubProjectRef),
- recent: z.array(
- GitHubProjectRef.extend({
- lastOpenedAt: z.string()
- }).strict()
- ),
- lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()),
- activeProject: GitHubProjectRef.nullable()
- })
- .strict()
-
-export const SettingsUpdate = z
- .object({
- worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(),
- defaultTuiAgent: z
- .unknown()
- .transform((value) =>
- value === null || value === 'blank' || isTuiAgent(value) ? value : undefined
- )
- .optional(),
- disabledTuiAgents: z
- .unknown()
- .transform((value) => normalizeDisabledTuiAgents(value))
- .optional(),
- agentDefaultArgs: z
- .unknown()
- .transform((value) => normalizeTuiAgentArgsRecord(value))
- .optional(),
- agentDefaultEnv: z
- .unknown()
- .transform((value) => normalizeTuiAgentEnvRecord(value))
- .optional(),
- defaultTaskSource: TaskProviderParam.optional(),
- visibleTaskProviders: z.array(TaskProviderParam).optional(),
- defaultTaskViewPreset: z
- .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all'])
- .optional(),
- experimentalNewWorktreeCardStyle: z.boolean().optional(),
- agentStatusHooksEnabled: z.boolean().optional(),
- defaultRepoSelection: z.array(z.string()).nullable().optional(),
- defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
- compactWorktreeCards: z.boolean().optional(),
- minimaxGroupId: z.string().optional(),
- minimaxUsageModels: z.string().optional(),
- minimaxEndpoint: z.enum(['overseas', 'cn']).optional(),
- githubProjects: GitHubProjectSettings.optional(),
- prBotAuthorOverrides: z
- .unknown()
- .transform((value) => normalizePRBotAuthorOverrides(value))
- .optional()
- })
- .strict()
- .default({})
+export {
+ NativeChatSessionOptionsMutation,
+ PRBotAuthorOverrideUpdate,
+ SettingsUpdate
+} from '../../../../shared/rpc-contract/client-settings-params'
diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts
index 32e62e105d7..f11bd7ca49c 100644
--- a/src/main/runtime/rpc/methods/client-ui-schemas.ts
+++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts
@@ -1,245 +1,8 @@
-import { z } from 'zod'
-import {
- isFeatureInteractionId,
- type FeatureInteractionId
-} from '../../../../shared/feature-interactions'
-import {
- ACTIVITY_GROUP_BY_VALUES,
- THREAD_READ_FILTER_VALUES
-} from '../../../../shared/agents-view-thread-filters'
-import { isFeatureTipId } from '../../../../shared/feature-tips'
-import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel'
-import {
- normalizeWorktreeCardProperties,
- WORKTREE_CARD_PROPERTIES
-} from '../../../../shared/worktree/card-properties'
-import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest'
-import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields'
-import { TaskResumeState } from './task-resume-state-schema'
-import { WorkspaceCleanup } from './workspace-cleanup-ui-schema'
-import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance'
-
-const NullableString = z.string().nullable()
-const StringArray = z.array(z.string())
-const FeatureTipIds = z.array(z.custom(isFeatureTipId, { message: 'Unknown feature tip id' }))
-const UnknownRecord = z.record(z.string(), z.unknown())
-const UnknownRecordArray = z.array(UnknownRecord)
-type StaticRightSidebarTab = (typeof STATIC_RIGHT_SIDEBAR_TABS)[number]
-// Derived from the shared union so a new card property cannot drift out of the
-// client schema — it previously omitted 'cli' and rejected the whole payload.
-const WorktreeCardPropertyParam = z.enum(WORKTREE_CARD_PROPERTIES)
-const WorktreeCardProperties = z
- .array(WorktreeCardPropertyParam)
- .transform((value) => normalizeWorktreeCardProperties(value))
-const STATIC_RIGHT_SIDEBAR_TABS = [
- 'explorer',
- 'search',
- 'vault',
- 'workspaces',
- 'pr-checks',
- 'source-control',
- 'checks',
- 'ports'
-] as const
-// Plugin panels are open-ended `plugin:./` keys, so the
-// schema validates their shape rather than enumerating them.
-const RightSidebarTabParam = z.custom(
- (value) =>
- typeof value === 'string' &&
- (STATIC_RIGHT_SIDEBAR_TABS.includes(value as StaticRightSidebarTab) ||
- isPluginPanelTabKey(value)),
- { message: 'Unknown right sidebar tab' }
-)
-const AgentActivityDisplayMode = z.enum(['compact', 'full'])
-const StatusBarItem = z.enum([
- 'claude',
- 'codex',
- 'gemini',
- 'antigravity',
- 'opencode-go',
- 'kimi',
- 'minimax',
- 'grok',
- 'ssh',
- 'resource-usage',
- 'ports'
-])
-const WorkspaceStatusDefinition = z.object({
- id: z.string(),
- label: z.string(),
- color: z.string().optional(),
- icon: z.string().optional()
-})
-const FeatureInteractionRecord = z
- .object({
- firstInteractedAt: z.number().finite().nonnegative(),
- interactionCount: z.number().int().positive().optional()
- })
- .strict()
-const FeatureInteractions = z
- .record(z.string(), FeatureInteractionRecord)
- .superRefine((value, ctx) => {
- for (const id of Object.keys(value)) {
- if (!isFeatureInteractionId(id)) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: `Unknown feature interaction id: ${id}`,
- path: [id]
- })
- }
- }
- })
-export const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, {
- message: 'Unknown feature interaction id'
-})
-const TopLevelViewSchema = z.enum([
- 'terminal',
- 'settings',
- 'tasks',
- 'activity',
- 'automations',
- 'space',
- 'skills',
- 'artifacts',
- 'mobile'
-])
-const UiUpdateFields = z
- .object({
- lastActiveRepoId: NullableString.optional(),
- lastActiveWorktreeId: NullableString.optional(),
- // Why: sync hydration ignores this persisted startup view, so paired windows stay put.
- activeView: TopLevelViewSchema.optional(),
- sidebarWidth: z.number().finite().optional(),
- rightSidebarOpen: z.boolean().optional(),
- rightSidebarTab: RightSidebarTabParam.optional(),
- rightSidebarExplorerView: z.enum(['files', 'search']).optional(),
- rightSidebarWidth: z.number().finite().optional(),
- markdownTocPanelWidth: z.number().finite().optional(),
- combinedDiffFileTreeWidth: z.number().finite().optional(),
- groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(),
- showWorkspaceLineage: z.boolean().optional(),
- sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(),
- projectOrderBy: z.enum(['manual', 'recent']).optional(),
- showActiveOnly: z.boolean().optional(),
- hideSleepingWorkspaces: z.boolean().optional(),
- showSleepingWorkspaces: z.boolean().optional(),
- showInactiveWorkspaces: z.boolean().optional(),
- workspaceHostScope: z.string().optional(),
- visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
- agentsVisibleHostIds: z.array(z.string()).nullable().optional(),
- agentsFilterRepoIds: StringArray.optional(),
- agentsShowChildAgents: z.boolean().optional(),
- agentsCompactMode: z.boolean().optional(),
- agentsShowSearch: z.boolean().optional(),
- agentsReadFilter: z.enum(THREAD_READ_FILTER_VALUES).optional(),
- agentsGroupBy: z.enum(ACTIVITY_GROUP_BY_VALUES).optional(),
- workspaceHostOrder: z.array(z.string()).optional(),
- automationHostFilter: z
- .union([
- z.object({ kind: z.literal('all') }).strict(),
- z.object({ kind: z.literal('host'), hostKey: z.string().min(1) }).strict()
- ])
- .optional(),
- manualRepoOrder: z
- .array(z.object({ hostId: z.string(), repoId: z.string() }).strict())
- .optional(),
- ...ClientUiWorkspaceFilterFields,
- // Why: rides App.tsx's debounced writer, so omitting it rejected that entire
- // payload (sidebar widths, filters, agent acks) for every paired client.
- showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(),
- collapsedGroups: StringArray.optional(),
- uiZoomLevel: z.number().finite().optional(),
- editorFontZoomLevel: z.number().finite().optional(),
- worktreeCardProperties: WorktreeCardProperties.optional(),
- _worktreeCardModeDefaulted: z.boolean().optional(),
- agentActivityDisplayMode: AgentActivityDisplayMode.optional(),
- workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(),
- workspaceBoardOpacity: z.number().finite().optional(),
- workspaceBoardColumnWidth: z.number().finite().optional(),
- syncTaskStatusFromWorkspaceBoard: z.boolean().optional(),
- _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(),
- _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(),
- _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(),
- _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(),
- statusBarItems: z.array(StatusBarItem).optional(),
- _portsStatusBarDefaultAdded: z.boolean().optional(),
- _kimiStatusBarDefaultAdded: z.boolean().optional(),
- _minimaxStatusBarDefaultAdded: z.boolean().optional(),
- _antigravityStatusBarDefaultAdded: z.boolean().optional(),
- _grokStatusBarDefaultAdded: z.boolean().optional(),
- statusBarVisible: z.boolean().optional(),
- usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
- statusBarUsageMode: z.enum(['verbose', 'compact']).optional(),
- dismissedUpdateVersion: NullableString.optional(),
- dismissedUnexpectedSignoutVersion: NullableString.optional(),
- lastUpdateCheckAt: z.number().finite().nullable().optional(),
- pendingUpdateNudgeId: NullableString.optional(),
- dismissedUpdateNudgeId: NullableString.optional(),
- // Why the predicate rather than an inline z.enum: an enum here is a copy of
- // RELEASE_CHANNELS, and a copy that drifts silently rejects the new
- // channel's override on its way here — the picker moves, nothing installs.
- releaseChannelOverride: z.custom(isReleaseChannel).nullable().optional(),
- notificationPermissionRequested: z.boolean().optional(),
- updateReassuranceSeen: z.boolean().optional(),
- osc52ClipboardDefaultOnNoticePending: z.boolean().optional(),
- acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
- activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(),
- manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
- browserDefaultUrl: NullableString.optional(),
- browserDefaultSearchEngine: z
- .enum(['google', 'duckduckgo', 'bing', 'kagi'])
- .nullable()
- .optional(),
- browserDefaultZoomLevel: z.number().finite().optional(),
- browserKagiSessionLink: NullableString.optional(),
- windowBounds: z
- .object({
- x: z.number().finite(),
- y: z.number().finite(),
- width: z.number().finite(),
- height: z.number().finite()
- })
- .nullable()
- .optional(),
- windowMaximized: z.boolean().optional(),
- _sortBySmartMigrated: z.boolean().optional(),
- _inlineAgentsDefaultedForExperiment: z.boolean().optional(),
- _inlineAgentsDefaultedForAllUsers: z.boolean().optional(),
- trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(),
- setupScriptPromptDismissedRepoIds: StringArray.optional(),
- // Why: one-shot dismissals the renderer writes through ui.set; each was a
- // whole-payload rejection for paired clients while unlisted.
- setupGuideSidebarDismissed: z.boolean().optional(),
- setupGuideBrowserMilestoneMigrated: z.boolean().optional(),
- setupGuideBrowserMilestoneLegacyComplete: z.boolean().optional(),
- browserImportHintHidden: z.boolean().optional(),
- mobileEmulatorTabIntroDismissed: z.boolean().optional(),
- mobileEmulatorAgentSetupDismissed: z.boolean().optional(),
- projectOrderManualDefaultNoticeDismissed: z.boolean().optional(),
- usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(),
- usageEmptyStateDismissed: z.boolean().optional(),
- petVisible: z.boolean().optional(),
- petId: z.string().optional(),
- customPets: UnknownRecordArray.optional(),
- petSize: z.number().finite().optional(),
- sidekickVisible: z.boolean().optional(),
- sidekickId: z.string().optional(),
- customSidekicks: UnknownRecordArray.optional(),
- sidekickSize: z.number().finite().optional(),
- taskResumeState: TaskResumeState.optional(),
- workspaceCleanup: WorkspaceCleanup.optional(),
- featureTipsSeenIds: FeatureTipIds.optional(),
- featureInteractions: FeatureInteractions.optional(),
- contextualToursSeenIds: StringArray.optional(),
- contextualToursAutoEligible: z.boolean().optional()
- })
- .strict()
-
-export const UiUpdate = z
- .object(tolerateUnknownValues(UiUpdateFields.shape))
- .strict()
- .default({})
- .transform(omitUndefinedValues)
+import type { UiUpdateFields } from '../../../../shared/rpc-contract/client-ui-params'
+export {
+ FeatureInteractionIdParam,
+ UiUpdate
+} from '../../../../shared/rpc-contract/client-ui-params'
// The key/value parity assertions over this live in ui-state-schema-parity-checks.ts.
export type UiUpdateFieldsSchema = typeof UiUpdateFields
diff --git a/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts
index d0239b03ec3..be6bc445f6e 100644
--- a/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts
+++ b/src/main/runtime/rpc/methods/client-ui-workspace-filter-fields.ts
@@ -1,11 +1 @@
-import { z } from 'zod'
-
-export const ClientUiWorkspaceFilterFields = {
- hideDefaultBranchWorkspace: z.boolean().optional(),
- hideAutomationGeneratedWorkspaces: z.boolean().optional(),
- hideCliCreatedWorkspaces: z.boolean().optional(),
- hideDetachedHeadWorkspaces: z.boolean().optional(),
- hideWorkspacesFromOtherDevices: z.boolean().optional(),
- alwaysShowDefaultBranchWorkspace: z.boolean().optional(),
- filterRepoIds: z.array(z.string()).optional()
-}
+export { ClientUiWorkspaceFilterFields } from '../../../../shared/rpc-contract/client-ui-workspace-filter-fields-params'
diff --git a/src/main/runtime/rpc/methods/clipboard.ts b/src/main/runtime/rpc/methods/clipboard.ts
index e6b487d7761..e27b1cf1607 100644
--- a/src/main/runtime/rpc/methods/clipboard.ts
+++ b/src/main/runtime/rpc/methods/clipboard.ts
@@ -1,18 +1,18 @@
-import { z } from 'zod'
import { defineMethod, type RpcContext, type RpcMethod } from '../core'
import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file'
import { randomUUID } from 'node:crypto'
-import {
- CLIPBOARD_IMAGE_MAX_BASE64_CHARS,
- CLIPBOARD_IMAGE_TOO_LARGE_ERROR
-} from '../../../../shared/clipboard-image'
import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance'
-
-const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS
-export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
+import {
+ AbortImageUpload,
+ AppendImageUploadChunk,
+ CommitImageUpload,
+ SaveImageAsTempFile,
+ StartImageUpload,
+ isValidBase64
+} from '../../../../shared/rpc-contract/clipboard-params'
+export { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS } from '../../../../shared/rpc-contract/clipboard-params'
export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8
const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000
-const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/
type ClipboardImageUpload = {
expectedBase64Length: number
@@ -26,10 +26,6 @@ type ClipboardImageUpload = {
const clipboardImageUploads = new Map()
-function isValidBase64(value: string): boolean {
- return value.length % 4 !== 1 && BASE64_PATTERN.test(value)
-}
-
function pruneExpiredUploads(now = Date.now()): void {
for (const [uploadId, upload] of clipboardImageUploads) {
if (upload.expiresAt <= now) {
@@ -99,58 +95,6 @@ function assertValidBase64Content(value: string): void {
}
}
-function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) {
- return z.unknown().transform((value, ctx): string => {
- if (typeof value !== 'string') {
- ctx.addIssue({ code: 'custom', message: 'Missing image content' })
- return z.NEVER
- }
- if (value.length > maxChars) {
- ctx.addIssue({ code: 'custom', message: tooLargeMessage })
- return z.NEVER
- }
- if (!isValidBase64(value)) {
- ctx.addIssue({ code: 'custom', message: 'Clipboard image content must be base64' })
- return z.NEVER
- }
- return value
- })
-}
-
-const SaveImageAsTempFile = z.object({
- contentBase64: clipboardImageBase64Payload(
- MAX_CLIPBOARD_IMAGE_BASE64_CHARS,
- CLIPBOARD_IMAGE_TOO_LARGE_ERROR
- ),
- connectionId: z.string().min(1).nullable().optional()
-})
-
-const StartImageUpload = z.object({
- expectedBase64Length: z
- .number()
- .int()
- .nonnegative()
- .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR),
- connectionId: z.string().min(1).nullable().optional()
-})
-
-const AppendImageUploadChunk = z.object({
- uploadId: z.string().min(1),
- offset: z.number().int().nonnegative(),
- contentBase64: clipboardImageBase64Payload(
- CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
- 'Clipboard image chunk is too large'
- )
-})
-
-const CommitImageUpload = z.object({
- uploadId: z.string().min(1)
-})
-
-const AbortImageUpload = z.object({
- uploadId: z.string().min(1)
-})
-
export const CLIPBOARD_METHODS: RpcMethod[] = [
defineMethod({
name: 'clipboard.saveImageAsTempFile',
diff --git a/src/main/runtime/rpc/methods/computer-schemas.ts b/src/main/runtime/rpc/methods/computer-schemas.ts
index e3ef7ca88f3..f949fa0459c 100644
--- a/src/main/runtime/rpc/methods/computer-schemas.ts
+++ b/src/main/runtime/rpc/methods/computer-schemas.ts
@@ -1,226 +1,15 @@
-import { z } from 'zod'
-import {
- computerUseClickModifiersValidationMessage,
- computerUseHotkeyValidationMessage,
- computerUsePressKeyValidationMessage
-} from '../../../../shared/computer-use-key-spec'
-import {
- OptionalBoolean,
- OptionalFiniteNumber,
- OptionalString,
- requiredString,
- requiredStringAllowingEmpty
-} from '../schemas'
-
-const OptionalNonNegativeInt = z.number().int().nonnegative().optional()
-const OptionalPositiveInt = z.number().int().positive().optional()
-
-const ComputerTarget = z.object({
- app: requiredString('Missing app'),
- session: OptionalString,
- worktree: OptionalString
-})
-
-const ComputerObserveTargetBase = ComputerTarget.extend({
- noScreenshot: OptionalBoolean,
- restoreWindow: OptionalBoolean,
- windowId: OptionalNonNegativeInt,
- windowIndex: OptionalNonNegativeInt
-})
-
-function validateWindowTarget(
- value: { windowId?: number; windowIndex?: number },
- ctx: z.RefinementCtx
-): void {
- if (value.windowId !== undefined && value.windowIndex !== undefined) {
- ctx.addIssue({
- code: 'custom',
- message: 'Window targeting accepts either --window-id or --window-index, not both'
- })
- }
-}
-
-function validateComputerTarget(
- value: { session?: string; worktree?: string; windowId?: number; windowIndex?: number },
- ctx: z.RefinementCtx
-): void {
- if (value.session !== undefined && value.worktree !== undefined) {
- ctx.addIssue({
- code: 'custom',
- message: 'Computer-use targeting accepts either session or worktree, not both'
- })
- }
- validateWindowTarget(value, ctx)
-}
-
-export const ComputerObserveTarget = ComputerObserveTargetBase.superRefine(validateComputerTarget)
-
-export const ListApps = z.object({}).strict()
-
-export const ListWindows = z
- .object({
- app: requiredString('Missing app')
- })
- .strict()
-
-export const Click = ComputerObserveTargetBase.extend({
- elementIndex: OptionalNonNegativeInt,
- x: OptionalFiniteNumber,
- y: OptionalFiniteNumber,
- clickCount: OptionalPositiveInt,
- mouseButton: z.enum(['left', 'right', 'middle']).optional(),
- modifiers: z.string().optional()
-}).superRefine((value, ctx) => {
- validateComputerTarget(value, ctx)
- const hasElement = value.elementIndex !== undefined
- const hasX = value.x !== undefined
- const hasY = value.y !== undefined
- if (!hasElement && !(hasX && hasY)) {
- ctx.addIssue({
- code: 'custom',
- message: 'Click requires --element-index or both --x and --y'
- })
- }
- if (hasX !== hasY) {
- ctx.addIssue({
- code: 'custom',
- message: 'Click coordinates require both --x and --y'
- })
- }
- if (hasElement && (hasX || hasY)) {
- ctx.addIssue({
- code: 'custom',
- message: 'Click accepts either --element-index or coordinate flags, not both'
- })
- }
- if (value.modifiers !== undefined) {
- const message = computerUseClickModifiersValidationMessage(value.modifiers)
- if (message) {
- ctx.addIssue({ code: 'custom', message })
- }
- }
-})
-
-export const PerformSecondaryAction = ComputerObserveTargetBase.extend({
- elementIndex: OptionalNonNegativeInt,
- action: requiredString('Missing action')
-}).superRefine((value, ctx) => {
- validateComputerTarget(value, ctx)
- if (value.elementIndex === undefined) {
- ctx.addIssue({ code: 'custom', message: 'Missing element index' })
- }
-})
-
-export const Scroll = ComputerObserveTargetBase.extend({
- elementIndex: OptionalNonNegativeInt,
- x: OptionalFiniteNumber,
- y: OptionalFiniteNumber,
- direction: z.enum(['up', 'down', 'left', 'right']),
- pages: z.number().positive().optional()
-}).superRefine((value, ctx) => {
- validateComputerTarget(value, ctx)
- const hasElement = value.elementIndex !== undefined
- const hasX = value.x !== undefined
- const hasY = value.y !== undefined
- if (!hasElement && !(hasX && hasY)) {
- ctx.addIssue({
- code: 'custom',
- message: 'Scroll requires --element-index or both --x and --y'
- })
- }
- if (hasX !== hasY) {
- ctx.addIssue({
- code: 'custom',
- message: 'Scroll coordinates require both --x and --y'
- })
- }
- if (hasElement && (hasX || hasY)) {
- ctx.addIssue({
- code: 'custom',
- message: 'Scroll accepts either --element-index or coordinate flags, not both'
- })
- }
-})
-
-export const Drag = ComputerObserveTargetBase.extend({
- fromElementIndex: OptionalNonNegativeInt,
- toElementIndex: OptionalNonNegativeInt,
- fromX: OptionalFiniteNumber,
- fromY: OptionalFiniteNumber,
- toX: OptionalFiniteNumber,
- toY: OptionalFiniteNumber
-}).superRefine((value, ctx) => {
- validateComputerTarget(value, ctx)
- const hasElementPair = value.fromElementIndex !== undefined && value.toElementIndex !== undefined
- const hasPartialElementPair =
- value.fromElementIndex !== undefined || value.toElementIndex !== undefined
- const coordinateKeys = [value.fromX, value.fromY, value.toX, value.toY]
- const hasCoordinatePair = coordinateKeys.every((coordinate) => coordinate !== undefined)
- const hasPartialCoordinatePair = coordinateKeys.some((coordinate) => coordinate !== undefined)
- if (hasElementPair && hasCoordinatePair) {
- ctx.addIssue({
- code: 'custom',
- message: 'Drag accepts either element indexes or coordinate flags, not both'
- })
- }
- if (!hasElementPair && !hasCoordinatePair) {
- ctx.addIssue({
- code: 'custom',
- message: 'Drag requires --from-element-index and --to-element-index, or all coordinate flags'
- })
- }
- if (hasPartialElementPair && !hasElementPair) {
- ctx.addIssue({
- code: 'custom',
- message: 'Drag element targeting requires both --from-element-index and --to-element-index'
- })
- }
- if (hasPartialCoordinatePair && !hasCoordinatePair) {
- ctx.addIssue({
- code: 'custom',
- message: 'Drag coordinates require --from-x, --from-y, --to-x, and --to-y'
- })
- }
-})
-
-export const TypeText = ComputerObserveTargetBase.extend({
- text: requiredString('Missing text')
-}).superRefine(validateComputerTarget)
-
-export const PressKey = ComputerObserveTargetBase.extend({
- key: requiredString('Missing key')
-}).superRefine((value, ctx) => {
- validateComputerTarget(value, ctx)
- const message = computerUsePressKeyValidationMessage(value.key)
- if (message) {
- ctx.addIssue({ code: 'custom', message })
- }
-})
-
-export const Hotkey = ComputerObserveTargetBase.extend({
- key: requiredString('Missing key')
-}).superRefine((value, ctx) => {
- validateComputerTarget(value, ctx)
- const message = computerUseHotkeyValidationMessage(value.key)
- if (message) {
- ctx.addIssue({ code: 'custom', message })
- }
-})
-
-export const ComputerPermissions = z.object({
- id: z.enum(['accessibility', 'screenshots']).optional()
-})
-
-export const PasteText = ComputerObserveTargetBase.extend({
- text: requiredString('Missing text')
-}).superRefine(validateComputerTarget)
-
-export const SetValue = ComputerObserveTargetBase.extend({
- elementIndex: OptionalNonNegativeInt,
- value: requiredStringAllowingEmpty('Missing value')
-}).superRefine((value, ctx) => {
- validateComputerTarget(value, ctx)
- if (value.elementIndex === undefined) {
- ctx.addIssue({ code: 'custom', message: 'Missing element index' })
- }
-})
+export {
+ Click,
+ ComputerObserveTarget,
+ ComputerPermissions,
+ Drag,
+ Hotkey,
+ ListApps,
+ ListWindows,
+ PasteText,
+ PerformSecondaryAction,
+ PressKey,
+ Scroll,
+ SetValue,
+ TypeText
+} from '../../../../shared/rpc-contract/computer-schemas-params'
diff --git a/src/main/runtime/rpc/methods/computer.ts b/src/main/runtime/rpc/methods/computer.ts
index 1f928b97afe..07459427bd4 100644
--- a/src/main/runtime/rpc/methods/computer.ts
+++ b/src/main/runtime/rpc/methods/computer.ts
@@ -1,4 +1,3 @@
-import { z } from 'zod'
import {
callComputerSidecarAction,
callComputerSidecarCapabilities,
@@ -23,6 +22,10 @@ import {
SetValue,
TypeText
} from './computer-schemas'
+import {
+ ComputerCapabilitiesParams,
+ ComputerPermissionsStatusParams
+} from '../../../../shared/rpc-contract/computer-params'
export function resetComputerSessionsForTest(): void {
resetComputerSidecarForTest()
@@ -31,7 +34,7 @@ export function resetComputerSessionsForTest(): void {
export const COMPUTER_METHODS: RpcMethod[] = [
defineMethod({
name: 'computer.capabilities',
- params: z.object({}),
+ params: ComputerCapabilitiesParams,
handler: async () => {
return await callComputerSidecarCapabilities()
}
@@ -54,7 +57,7 @@ export const COMPUTER_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'computer.permissionsStatus',
- params: z.object({}),
+ params: ComputerPermissionsStatusParams,
handler: async () => {
const { getComputerUsePermissionStatus } =
await import('../../../computer/macos-computer-use-permissions')
diff --git a/src/main/runtime/rpc/methods/emulator.ts b/src/main/runtime/rpc/methods/emulator.ts
index 50354f790e4..00c239658d9 100644
--- a/src/main/runtime/rpc/methods/emulator.ts
+++ b/src/main/runtime/rpc/methods/emulator.ts
@@ -1,66 +1,26 @@
import { defineMethod, type RpcMethod } from '../core'
import path from 'node:path'
import { z } from 'zod'
-
-// Minimal schemas for emulator commands (loose for initial testing; can be tightened like browser-schemas).
-const WorktreeParam = z.object({ worktree: z.string().optional() }).partial()
-
-const TapParams = z.object({
- x: z.number().min(0).max(1),
- y: z.number().min(0).max(1),
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
-
-const GesturePoint = z.object({
- edge: z.number().int().min(0).max(4).optional(),
- type: z.enum(['begin', 'move', 'end']),
- x: z.number().min(0).max(1),
- y: z.number().min(0).max(1)
-})
-
-const GestureParams = z.object({
- points: z.array(GesturePoint).min(2).max(64),
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
-
-const TypeParams = z.object({
- text: z.string(),
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
-
-const ButtonParams = z.object({
- name: z.string(),
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
-
-const RotateOrientation = z.enum([
- 'portrait',
- 'portrait_upside_down',
- 'landscape_left',
- 'landscape_right'
-])
-
-const RotateParams = z.object({
- orientation: RotateOrientation,
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
-
-const ExecParams = z.object({
- command: z.string(),
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
+import {
+ AttachParams,
+ AxParams,
+ ButtonParams,
+ EmulatorAvailabilityParams,
+ EmulatorListDevicesParams,
+ EmulatorListSimulatorsParams,
+ EmulatorUnregisterActiveParams,
+ ExecParams,
+ GestureParams,
+ KillParams,
+ LaunchParams,
+ ListParams,
+ LogcatParams,
+ PermissionsParams,
+ RotateParams,
+ ShutdownParams,
+ TapParams,
+ TypeParams
+} from '../../../../shared/rpc-contract/emulator-params'
const InstallParams = z.object({
path: z.string().refine((value) => path.isAbsolute(value), {
@@ -72,89 +32,6 @@ const InstallParams = z.object({
worktree: z.string().optional()
})
-const LaunchParams = z.object({
- package: z.string(),
- activity: z.string().optional(),
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
-
-const PermissionsParams = z
- .object({
- op: z.enum(['grant', 'revoke', 'reset']),
- package: z.string().optional(),
- permission: z.string().optional(),
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
- })
- .superRefine((value, ctx) => {
- if (value.op === 'reset') {
- if (value.package) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['package'],
- message: 'package is not allowed for reset'
- })
- }
- if (value.permission) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['permission'],
- message: 'permission is not allowed for reset'
- })
- }
- return
- }
- if (!value.package) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['package'],
- message: 'package is required for grant/revoke'
- })
- }
- if (!value.permission) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['permission'],
- message: 'permission is required for grant/revoke'
- })
- }
- })
-
-const AxParams = z.object({
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
-
-const LogcatParams = z.object({
- lines: z.number().int().positive().optional(),
- filters: z.array(z.string()).optional(),
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
-
-const AttachParams = z.object({
- device: z.string().optional(),
- worktree: z.string().optional(),
- focus: z.boolean().optional()
-})
-
-const KillParams = z.object({
- device: z.string().optional(),
- emulator: z.string().optional(),
- worktree: z.string().optional()
-})
-
-const ShutdownParams = KillParams.extend({
- managedOnly: z.boolean().optional()
-})
-
-const ListParams = WorktreeParam
-
export const EMULATOR_METHODS: RpcMethod[] = [
defineMethod({
name: 'emulator.list',
@@ -208,17 +85,17 @@ export const EMULATOR_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'emulator.listSimulators',
- params: z.object({ worktree: z.string().optional() }).partial(),
+ params: EmulatorListSimulatorsParams,
handler: async (params, { runtime }) => runtime.emulatorListSimulators(params)
}),
defineMethod({
name: 'emulator.availability',
- params: z.object({ worktree: z.string().optional() }).partial(),
+ params: EmulatorAvailabilityParams,
handler: async (params, { runtime }) => runtime.emulatorAvailability(params)
}),
defineMethod({
name: 'emulator.listDevices',
- params: z.object({ worktree: z.string().optional() }).partial(),
+ params: EmulatorListDevicesParams,
handler: async (params, { runtime }) => runtime.emulatorListDevices(params)
}),
defineMethod({
@@ -248,7 +125,7 @@ export const EMULATOR_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'emulator.unregisterActive',
- params: z.object({ worktree: z.string().optional() }).partial(),
+ params: EmulatorUnregisterActiveParams,
handler: async (params, { runtime }) => runtime.emulatorUnregisterActive(params)
})
]
diff --git a/src/main/runtime/rpc/methods/files-mutation-methods.ts b/src/main/runtime/rpc/methods/files-mutation-methods.ts
index 1add0232055..3c7e4231b96 100644
--- a/src/main/runtime/rpc/methods/files-mutation-methods.ts
+++ b/src/main/runtime/rpc/methods/files-mutation-methods.ts
@@ -1,14 +1,14 @@
-import { z } from 'zod'
import { defineMethod, type RpcAnyMethod } from '../core'
-import { FileOpen, WorktreeSelector } from './files-target-schemas'
-
-const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/
-
-function isValidRuntimeFileBase64(value: unknown): value is string {
- return (
- typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value)
- )
-}
+import {
+ FileCommitUpload,
+ FileCopy,
+ FileDelete,
+ FileMutationOpen,
+ FileRename,
+ FileWrite,
+ FileWriteBase64,
+ FileWriteBase64Chunk
+} from '../../../../shared/rpc-contract/files-mutation-params'
type SshMutationParams = {
expectedExecutionHostId?: string
@@ -33,80 +33,6 @@ function sshMutationArguments(
]
}
-const FileMutationOpen = FileOpen.extend({
- expectedExecutionHostId: z.string().min(1).optional(),
- expectedSshTargetId: z.string().min(1).optional(),
- expectedSshConnectionGeneration: z.number().int().nonnegative().optional()
-})
-
-// Why: write content must be a real string. Coercing a missing/non-string value
-// to '' silently truncated the target file to empty instead of erroring. An
-// explicit '' is still accepted (writing an empty file is legitimate).
-const FileWrite = FileMutationOpen.extend({
- content: z
- .unknown()
- .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
-})
-
-const FileWriteBase64 = FileMutationOpen.extend({
- contentBase64: z
- .unknown()
- .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
- // Why: Buffer.from(..., 'base64') accepts malformed input by dropping
- // invalid bytes, which can silently create empty or corrupt uploaded files.
- .refine(isValidRuntimeFileBase64, 'File content must be base64')
-})
-
-const FileWriteBase64Chunk = FileWriteBase64.extend({
- append: z.boolean().optional()
-})
-
-const FileRename = WorktreeSelector.extend({
- expectedExecutionHostId: z.string().min(1).optional(),
- expectedSshTargetId: z.string().min(1).optional(),
- expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
- oldRelativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing source path')),
- newRelativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing destination path'))
-})
-
-const FileCopy = WorktreeSelector.extend({
- expectedExecutionHostId: z.string().min(1).optional(),
- expectedSshTargetId: z.string().min(1).optional(),
- expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
- sourceRelativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing source path')),
- destinationRelativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing destination path'))
-})
-
-const FileCommitUpload = WorktreeSelector.extend({
- expectedExecutionHostId: z.string().min(1).optional(),
- expectedSshTargetId: z.string().min(1).optional(),
- expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
- tempRelativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing temporary path')),
- finalRelativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing final path'))
-})
-
-const FileDelete = FileMutationOpen.extend({
- recursive: z.boolean().optional()
-})
-
export const FILE_MUTATION_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'files.write',
diff --git a/src/main/runtime/rpc/methods/files-target-schemas.ts b/src/main/runtime/rpc/methods/files-target-schemas.ts
index 6c546b3605e..945d3c6aa85 100644
--- a/src/main/runtime/rpc/methods/files-target-schemas.ts
+++ b/src/main/runtime/rpc/methods/files-target-schemas.ts
@@ -1,15 +1 @@
-import { z } from 'zod'
-
-export const WorktreeSelector = z.object({
- worktree: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing worktree selector'))
-})
-
-export const FileOpen = WorktreeSelector.extend({
- relativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing relative path'))
-})
+export { FileOpen, WorktreeSelector } from '../../../../shared/rpc-contract/files-target-params'
diff --git a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts
index 08925e08689..cc343dd3ca1 100644
--- a/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts
+++ b/src/main/runtime/rpc/methods/files-terminal-artifact-methods.ts
@@ -1,24 +1,9 @@
-import { z } from 'zod'
import { defineMethod, type RpcAnyMethod } from '../core'
import { remoteFileContentBudget } from './files-remote-content-budget'
-import { WorktreeSelector } from './files-target-schemas'
-
-const TerminalArtifactFile = WorktreeSelector.extend({
- grantId: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing terminal artifact grant')),
- absolutePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing terminal artifact path'))
-})
-
-const TerminalArtifactFileWrite = TerminalArtifactFile.extend({
- content: z
- .unknown()
- .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
-})
+import {
+ TerminalArtifactFile,
+ TerminalArtifactFileWrite
+} from '../../../../shared/rpc-contract/files-terminal-artifact-params'
export const FILE_TERMINAL_ARTIFACT_METHODS: RpcAnyMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts
index ef349a22f84..6f5cdbe4b34 100644
--- a/src/main/runtime/rpc/methods/files.ts
+++ b/src/main/runtime/rpc/methods/files.ts
@@ -1,114 +1,26 @@
-import { z } from 'zod'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import { runFileWatchStream } from './file-watch-stream-lifecycle'
import { FILE_MUTATION_METHODS } from './files-mutation-methods'
import { remoteFileContentBudget } from './files-remote-content-budget'
-import {
- QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS,
- QUICK_OPEN_SEARCH_VERSION
-} from '../../../../shared/quick-open-path-search'
+import { QUICK_OPEN_SEARCH_VERSION } from '../../../../shared/quick-open-path-search'
import { limitQuickOpenSearchReplyBySerializedBytes } from '../../../../shared/quick-open-transport-budget'
import { FileOpen, WorktreeSelector } from './files-target-schemas'
import { FILE_TERMINAL_ARTIFACT_METHODS } from './files-terminal-artifact-methods'
+import {
+ DocPreviewFileRead,
+ FileListAll,
+ FileOpenDiff,
+ FilePathSearch,
+ FileReadChunk,
+ FileSearch,
+ FileTreePath,
+ FileUnwatch,
+ ResolveTerminalPath,
+ ServerDirectoryBrowse
+} from '../../../../shared/rpc-contract/files-params'
let filesWatchSubscriptionSeq = 0
-const FilePathSearch = WorktreeSelector.extend({
- query: z.string().max(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS).default(''),
- limit: z.number().int().positive().max(32).default(16),
- excludePaths: z.array(z.string()).optional(),
- mode: z.literal('quick-open').optional()
-})
-
-const ResolveTerminalPath = WorktreeSelector.extend({
- pathText: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing path text')),
- terminal: z
- .unknown()
- .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null))
- .nullable()
- .optional(),
- cwd: z
- .unknown()
- .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null))
- .nullable()
- .optional(),
- crossWorkspace: z
- .unknown()
- .transform((v) => v === true)
- .optional(),
- nativeChatContext: z
- .object({
- tabId: z.string().min(1),
- sessionId: z.string().min(1)
- })
- .optional()
-})
-
-const FileOpenDiff = FileOpen.extend({
- staged: z.boolean().optional()
-})
-
-const DocPreviewFileRead = FileOpen.extend({
- entryRelativePath: z.string().min(1),
- implicitRootRelativePath: z.string().nullable(),
- authorizedRootRelativePaths: z.array(z.string())
-})
-
-const FileTreePath = WorktreeSelector.extend({
- relativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string())
-})
-
-const ServerDirectoryBrowse = z.object({
- path: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string())
-})
-
-const FileReadChunk = FileOpen.extend({
- offset: z.number().int().nonnegative(),
- length: z
- .number()
- .int()
- .positive()
- .max(512 * 1024)
-})
-
-const FileSearch = WorktreeSelector.extend({
- query: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing search query')),
- caseSensitive: z.boolean().optional(),
- wholeWord: z.boolean().optional(),
- useRegex: z.boolean().optional(),
- includePattern: z.string().optional(),
- excludePattern: z.string().optional(),
- maxResults: z.number().int().positive().optional()
-})
-
-// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its
-// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page
-// means there is more" was true for desktop and merely incidental for web and mobile, which were
-// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`.
-const FileListAll = WorktreeSelector.extend({
- excludePaths: z.array(z.string()).optional(),
- maxResults: z.number().int().positive().optional()
-})
-
-const FileUnwatch = z.object({
- subscriptionId: z
- .unknown()
- .transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
- .pipe(z.string().min(1, 'Missing subscriptionId'))
-})
-
export const FILE_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'files.list',
diff --git a/src/main/runtime/rpc/methods/folder-workspace.ts b/src/main/runtime/rpc/methods/folder-workspace.ts
index aa39654d9f8..e0f780e710a 100644
--- a/src/main/runtime/rpc/methods/folder-workspace.ts
+++ b/src/main/runtime/rpc/methods/folder-workspace.ts
@@ -1,90 +1,11 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
-import { isTuiAgent } from '../../../../shared/tui-agent-config'
-import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema'
-import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema'
-import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context'
import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context'
-import { DiffCommentSchema } from '../../../../shared/diff-comment-schema'
-
-const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable()
-
-function assertLinkedTaskSourceContextMatch(
- value: {
- linkedTask?: z.infer
- linkedTaskSourceContext?: z.infer | null
- },
- ctx: z.RefinementCtx
-): void {
- if (
- value.linkedTask &&
- value.linkedTaskSourceContext &&
- !isWorkspaceLinkedItemSourceContextMatch(value.linkedTask, value.linkedTaskSourceContext)
- ) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Linked task and source context identities must match'
- })
- }
-}
-
-const FolderWorkspaceCreate = z
- .object({
- projectGroupId: requiredString('Missing project group id'),
- name: OptionalString,
- folderPath: OptionalString.nullable().optional(),
- connectionId: OptionalString.nullable().optional(),
- linkedTask: FolderWorkspaceLinkedTask.optional(),
- linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
- createdWithAgent: z.string().refine(isTuiAgent).optional(),
- pendingFirstAgentMessageRename: z.boolean().optional()
- })
- .superRefine(assertLinkedTaskSourceContextMatch)
-
-const FolderWorkspaceUpdate = z.object({
- folderWorkspaceId: requiredString('Missing folder workspace id'),
- updates: z
- .object({
- name: OptionalString,
- folderPath: OptionalString,
- linkedTask: FolderWorkspaceLinkedTask.optional(),
- linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
- comment: z.string().optional(),
- isArchived: z.boolean().optional(),
- isUnread: z.boolean().optional(),
- isPinned: z.boolean().optional(),
- sortOrder: OptionalFiniteNumber,
- manualOrder: OptionalFiniteNumber,
- workspaceStatus: OptionalString,
- createdWithAgent: z.string().refine(isTuiAgent).optional(),
- pendingFirstAgentMessageRename: z.boolean().optional(),
- firstAgentMessageRenameError: z.string().nullable().optional(),
- lastActivityAt: OptionalFiniteNumber,
- diffComments: z.array(DiffCommentSchema).optional()
- })
- .superRefine(assertLinkedTaskSourceContextMatch)
-})
-
-const FolderWorkspaceSelector = z.object({
- folderWorkspaceId: requiredString('Missing folder workspace id')
-})
-
-const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [
- z.object({
- scope: z.literal('folder-workspace'),
- folderWorkspaceId: requiredString('Missing folder workspace id')
- }),
- z.object({
- scope: z.literal('project-group'),
- projectGroupId: requiredString('Missing project group id')
- }),
- z.object({
- scope: z.literal('path'),
- path: requiredString('Missing folder path'),
- connectionId: OptionalString.nullable().optional()
- })
-])
+import {
+ FolderWorkspaceCreate,
+ FolderWorkspacePathStatus,
+ FolderWorkspaceSelector,
+ FolderWorkspaceUpdate
+} from '../../../../shared/rpc-contract/folder-workspace-params'
export const FOLDER_WORKSPACE_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/git-admission-tier-schema.ts b/src/main/runtime/rpc/methods/git-admission-tier-schema.ts
index 926aba5b6f9..c5366441b4b 100644
--- a/src/main/runtime/rpc/methods/git-admission-tier-schema.ts
+++ b/src/main/runtime/rpc/methods/git-admission-tier-schema.ts
@@ -1,11 +1 @@
-import { z } from 'zod'
-import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options'
-
-export const OptionalGitAdmissionTier = z
- .unknown()
- .optional()
- .transform((value): GitAdmissionTier | undefined => {
- return value === 'interactive' || value === 'status' || value === 'background'
- ? value
- : undefined
- })
+export { OptionalGitAdmissionTier } from '../../../../shared/rpc-contract/git-admission-tier-params'
diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts
index f69b01cd053..ad80955ea76 100644
--- a/src/main/runtime/rpc/methods/git-params.ts
+++ b/src/main/runtime/rpc/methods/git-params.ts
@@ -1,271 +1,25 @@
-import { z } from 'zod'
-import { OptionalGitAdmissionTier } from './git-admission-tier-schema'
-
-export const WorktreeSelector = z.object({
- worktree: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing worktree selector'))
-})
-
-export const GitStatusParams = WorktreeSelector.extend({
- admissionTier: OptionalGitAdmissionTier,
- includeIgnored: z.boolean().optional(),
- includeLineStats: z.boolean().optional(),
- bypassEffectiveUpstreamNegativeCache: z.boolean().optional(),
- reuseLineStats: z.boolean().optional(),
- // Shape is re-validated host-side before it reaches a git argv.
- branchLineTotalMergeBase: z.string().optional()
-})
-
-export const GitCheckIgnored = WorktreeSelector.extend({
- paths: z.array(z.string().min(1, 'Missing path')).max(2000)
-})
-
-export const GitSubmoduleStatus = WorktreeSelector.extend({
- submodulePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(
- z
- .string()
- .min(1, 'Missing submodule path')
- // Why: never let a submodule path be parsed as a git flag (arg injection).
- .refine((value) => !value.startsWith('-'), 'Submodule path must not start with -')
- ),
- // Why: submodule expansion is requested from a Source Control row; the row
- // area determines whether the gitlink range is HEAD->index or index->worktree.
- area: z.enum(['staged', 'unstaged', 'untracked']).optional()
-})
-
-export const GitFilePath = WorktreeSelector.extend({
- filePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing file path'))
-})
-
-export const GitDiff = GitFilePath.extend({
- staged: z.boolean(),
- compareAgainstHead: z.boolean().optional()
-})
-
-export const GitBranchCompare = WorktreeSelector.extend({
- admissionTier: OptionalGitAdmissionTier,
- baseRef: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(
- z
- .string()
- .min(1, 'Missing base ref')
- .refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
- )
-})
-
-const FullGitObjectId = z
- .string()
- .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id')
-
-export const GitCommitCompare = WorktreeSelector.extend({
- commitId: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(FullGitObjectId)
-})
-
-export const GitHistory = WorktreeSelector.extend({
- limit: z.number().int().min(1).max(200).optional(),
- baseRef: z.string().nullable().optional()
-})
-
-export const GitBranchDiff = GitFilePath.extend({
- compare: z.object({
- baseRef: z.string().optional(),
- baseOid: FullGitObjectId.optional(),
- headOid: FullGitObjectId,
- mergeBase: FullGitObjectId
- }),
- oldPath: z.string().optional()
-})
-
-export const GitCommitDiff = GitFilePath.extend({
- commitOid: FullGitObjectId,
- parentOid: FullGitObjectId.nullable().optional(),
- oldPath: z.string().optional()
-})
-
-export const GitCommit = WorktreeSelector.extend({
- message: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing commit message'))
-})
-
-const CommitMessageModelCapability = z.object({
- id: z.string(),
- label: z.string(),
- thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
- defaultThinkingLevel: z.string().optional()
-})
-
-const CommitMessageAiSettings = z.object({
- enabled: z.boolean(),
- agentId: z.string().nullable(),
- selectedModelByAgent: z.record(z.string(), z.string()),
- selectedModelByAgentByHost: z.record(z.string(), z.record(z.string(), z.string())).optional(),
- discoveredModelsByAgent: z.record(z.string(), z.array(CommitMessageModelCapability)).optional(),
- discoveredModelsByAgentByHost: z
- .record(z.string(), z.record(z.string(), z.array(CommitMessageModelCapability)))
- .optional(),
- selectedThinkingByModel: z.record(z.string(), z.string()),
- customPrompt: z.string(),
- customAgentCommand: z.string()
-})
-
-const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: true }).extend({
- actions: z
- .record(
- z.string(),
- z.object({
- agentId: z.string().nullable().optional(),
- commandInputTemplate: z.string().optional(),
- agentArgs: z.string().optional()
- })
- )
- .optional(),
- instructionsByOperation: z.record(z.string(), z.string()).optional(),
- modelOverridesByOperation: z
- .record(
- z.string(),
- z.object({
- selectedModelByAgent: z.record(z.string(), z.string()).optional(),
- selectedModelByAgentByHost: z
- .record(z.string(), z.record(z.string(), z.string()))
- .optional(),
- selectedThinkingByModel: z.record(z.string(), z.string()).optional()
- })
- )
- .optional(),
- prCreationDefaults: z
- .object({
- draft: z.boolean().optional(),
- useTemplate: z.boolean().optional(),
- generateDetailsOnOpen: z.boolean().optional(),
- openAfterCreate: z.boolean().optional()
- })
- .optional(),
- launchActionDefaults: z
- .record(
- z.string(),
- z.object({
- agentId: z.string().nullable().optional(),
- commandInputTemplate: z.string().optional(),
- agentArgs: z.string().optional()
- })
- )
- .optional()
-})
-
-const ResolvedSourceControlAiGenerationParams = z.object({
- agentId: z.string(),
- model: z.string(),
- thinkingLevel: z.string().optional(),
- customPrompt: z.string().optional(),
- commandInputTemplate: z.string().optional(),
- agentArgs: z.string().optional(),
- customAgentCommand: z.string().optional(),
- agentCommandOverride: z.string().optional()
-})
-
-export const GitGenerateCommitMessage = WorktreeSelector.extend({
- commitMessageAi: CommitMessageAiSettings.optional(),
- sourceControlAi: SourceControlAiSettings.optional(),
- sourceControlAiResolvedParams: ResolvedSourceControlAiGenerationParams.optional(),
- agentCmdOverrides: z.record(z.string(), z.string()).optional(),
- commitMessageDiscoveryHostKey: z.string().optional()
-})
-
-export const GitDiscoverCommitMessageModels = WorktreeSelector.extend({
- agentId: z.string().min(1, 'Missing agent id'),
- agentCmdOverrides: z.record(z.string(), z.string()).optional()
-})
-
-export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
- base: z.string().min(1, 'Missing base branch'),
- title: z.string(),
- body: z.string(),
- draft: z.boolean(),
- provider: z
- .enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported'])
- .optional(),
- useTemplate: z.boolean().optional()
-})
-
-export const GitBulkPaths = WorktreeSelector.extend({
- filePaths: z.array(z.string().min(1, 'Missing file path'))
-})
-
-const GitPushTargetParam = z.object({
- remoteName: z.string(),
- branchName: z.string(),
- remoteUrl: z.string().optional(),
- remoteCreated: z.boolean().optional()
-})
-
-export const GitPush = WorktreeSelector.extend({
- publish: z.boolean().optional(),
- forceWithLease: z.boolean().optional(),
- pushTarget: GitPushTargetParam.optional()
-})
-
-export const GitTargetedRemote = WorktreeSelector.extend({
- pushTarget: GitPushTargetParam.optional()
-})
-
-export const GitForkSync = WorktreeSelector.extend({
- expectedUpstream: z.object({
- owner: z.string().trim().min(1),
- repo: z.string().trim().min(1)
- })
-})
-
-export const GitRebaseFromBase = WorktreeSelector.extend({
- baseRef: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(
- z
- .string()
- .min(1, 'Missing base ref')
- .refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
- )
-})
-
-export const GitCheckout = WorktreeSelector.extend({
- branch: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(
- z
- .string()
- .min(1, 'Missing branch')
- // Why: never let a branch arg be parsed as a git flag (arg injection).
- .refine((value) => !value.startsWith('-'), 'Branch must not start with -')
- )
-})
-
-export const GitRemoteFileUrl = WorktreeSelector.extend({
- relativePath: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing relative path')),
- line: z.number().int().min(1)
-})
-
-export const GitRemoteCommitUrl = WorktreeSelector.extend({
- sha: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(FullGitObjectId)
-})
+export {
+ GitBranchCompare,
+ GitBranchDiff,
+ GitBulkPaths,
+ GitCheckIgnored,
+ GitCheckout,
+ GitCommit,
+ GitCommitCompare,
+ GitCommitDiff,
+ GitDiff,
+ GitDiscoverCommitMessageModels,
+ GitFilePath,
+ GitForkSync,
+ GitGenerateCommitMessage,
+ GitGeneratePullRequestFields,
+ GitHistory,
+ GitPush,
+ GitRebaseFromBase,
+ GitRemoteCommitUrl,
+ GitRemoteFileUrl,
+ GitStatusParams,
+ GitSubmoduleStatus,
+ GitTargetedRemote,
+ WorktreeSelector
+} from '../../../../shared/rpc-contract/git-params'
diff --git a/src/main/runtime/rpc/methods/github-issue-methods.ts b/src/main/runtime/rpc/methods/github-issue-methods.ts
index 75eb75c81b6..40ed162f28b 100644
--- a/src/main/runtime/rpc/methods/github-issue-methods.ts
+++ b/src/main/runtime/rpc/methods/github-issue-methods.ts
@@ -1,31 +1,10 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { requiredString } from '../schemas'
-import { IssueUpdate } from './github-issue-update-schema'
-import { RepoSelector, SlugRepo } from './github-repo-target-schemas'
-
-const Issue = RepoSelector.extend({
- number: z.number().int().positive()
-})
-
-const CreateIssue = RepoSelector.extend({
- title: requiredString('Missing title'),
- body: z.string(),
- labels: z.array(z.string()).optional(),
- assignees: z.array(z.string()).optional()
-})
-
-const UpdateIssue = RepoSelector.extend({
- number: z.number().int().positive(),
- updates: IssueUpdate
-})
-
-const IssueComment = RepoSelector.extend({
- number: z.number().int().positive(),
- body: requiredString('Comment body required'),
- type: z.enum(['issue', 'pr']).optional(),
- prRepo: SlugRepo.nullable().optional()
-})
+import {
+ CreateIssue,
+ Issue,
+ IssueComment,
+ UpdateIssue
+} from '../../../../shared/rpc-contract/github-issue-params'
export const GITHUB_ISSUE_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/github-issue-update-schema.ts b/src/main/runtime/rpc/methods/github-issue-update-schema.ts
index 7b3020e91b5..6a9f860113b 100644
--- a/src/main/runtime/rpc/methods/github-issue-update-schema.ts
+++ b/src/main/runtime/rpc/methods/github-issue-update-schema.ts
@@ -1,13 +1 @@
-import { z } from 'zod'
-import { OptionalString } from '../schemas'
-
-// Why: repo-selector and slug-addressed issue updates must accept the identical field set.
-export const IssueUpdate = z.object({
- state: z.enum(['open', 'closed']).optional(),
- title: OptionalString,
- body: OptionalString,
- addLabels: z.array(z.string()).optional(),
- removeLabels: z.array(z.string()).optional(),
- addAssignees: z.array(z.string()).optional(),
- removeAssignees: z.array(z.string()).optional()
-})
+export { IssueUpdate } from '../../../../shared/rpc-contract/github-issue-update-params'
diff --git a/src/main/runtime/rpc/methods/github-project-methods.ts b/src/main/runtime/rpc/methods/github-project-methods.ts
index ce70c200218..4cd2641b11a 100644
--- a/src/main/runtime/rpc/methods/github-project-methods.ts
+++ b/src/main/runtime/rpc/methods/github-project-methods.ts
@@ -1,135 +1,26 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalString, requiredString } from '../schemas'
-import { IssueUpdate } from './github-issue-update-schema'
import { SlugRepo } from './github-repo-target-schemas'
-
-const SlugAssignableUsers = SlugRepo.extend({
- seedLogins: z.array(z.string()).optional()
-})
-
-const ProjectOwnerType = z.enum(['organization', 'user'])
-
-const ProjectViewTable = z.object({
- owner: requiredString('Missing owner'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- ownerType: ProjectOwnerType,
- projectNumber: z.number().int().positive(),
- viewId: OptionalString,
- viewNumber: z.number().int().positive().optional(),
- viewName: OptionalString,
- queryOverride: OptionalString
-})
-
-const ProjectWorkItemDetailsBySlug = SlugRepo.extend({
- number: z.number().int().positive(),
- type: z.enum(['issue', 'pr'])
-})
-
-const ProjectRef = z.object({
- input: requiredString('Missing project reference'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString
-})
-
-const ProjectViews = z.object({
- owner: requiredString('Missing owner'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- ownerType: ProjectOwnerType,
- projectNumber: z.number().int().positive()
-})
-
-const ProjectItemField = z.object({
- projectId: requiredString('Missing project ID'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- itemId: requiredString('Missing item ID'),
- fieldId: requiredString('Missing field ID'),
- value: z.any()
-})
-
-const ClearProjectItemField = z.object({
- projectId: requiredString('Missing project ID'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- itemId: requiredString('Missing item ID'),
- fieldId: requiredString('Missing field ID')
-})
-
-const SlugIssueUpdate = z.object({
- owner: requiredString('Missing owner'),
- repo: requiredString('Missing repo'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- number: z.number().int().positive(),
- updates: IssueUpdate
-})
-
-const SlugPullRequestUpdate = z.object({
- owner: requiredString('Missing owner'),
- repo: requiredString('Missing repo'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- number: z.number().int().positive(),
- updates: z.object({
- state: z.enum(['open', 'closed']).optional(),
- title: OptionalString,
- body: OptionalString
- })
-})
-
-const SlugIssueTypeUpdate = z.object({
- owner: requiredString('Missing owner'),
- repo: requiredString('Missing repo'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- number: z.number().int().positive(),
- issueTypeId: z.string().nullable()
-})
-
-const SlugIssueComment = z.object({
- owner: requiredString('Missing owner'),
- repo: requiredString('Missing repo'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- number: z.number().int().positive(),
- body: requiredString('Comment body required')
-})
-
-const SlugIssueCommentEdit = z.object({
- owner: requiredString('Missing owner'),
- repo: requiredString('Missing repo'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- commentId: z.number().int().positive(),
- body: requiredString('Comment body required')
-})
-
-const SlugIssueCommentDelete = z.object({
- owner: requiredString('Missing owner'),
- repo: requiredString('Missing repo'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- commentId: z.number().int().positive()
-})
+import {
+ ClearProjectItemField,
+ GithubProjectListAccessibleParams,
+ ProjectItemField,
+ ProjectRef,
+ ProjectViewTable,
+ ProjectViews,
+ ProjectWorkItemDetailsBySlug,
+ SlugAssignableUsers,
+ SlugIssueComment,
+ SlugIssueCommentDelete,
+ SlugIssueCommentEdit,
+ SlugIssueTypeUpdate,
+ SlugIssueUpdate,
+ SlugPullRequestUpdate
+} from '../../../../shared/rpc-contract/github-project-params'
export const GITHUB_PROJECT_METHODS: RpcMethod[] = [
defineMethod({
name: 'github.project.listAccessible',
- params: z.object({ host: OptionalString }),
+ params: GithubProjectListAccessibleParams,
handler: async (params, { runtime }) => runtime.listGitHubProjects(params)
}),
defineMethod({
diff --git a/src/main/runtime/rpc/methods/github-pull-request-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-methods.ts
index 958e08c439a..f7e66d41008 100644
--- a/src/main/runtime/rpc/methods/github-pull-request-methods.ts
+++ b/src/main/runtime/rpc/methods/github-pull-request-methods.ts
@@ -1,83 +1,15 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalString, requiredString } from '../schemas'
-import { RepoSelector, SlugRepo } from './github-repo-target-schemas'
-import type { GitHubPRRefreshReason } from '../../../../shared/github/pull-request-refresh-types'
-
-const OptionalPRRefreshReason = z
- .unknown()
- .optional()
- .transform((value): GitHubPRRefreshReason | undefined => {
- return value === 'visible' ||
- value === 'active' ||
- value === 'post-push' ||
- value === 'manual' ||
- value === 'swr'
- ? value
- : undefined
- })
-
-const PrForBranch = RepoSelector.extend({
- branch: requiredString('Missing branch'),
- reason: OptionalPRRefreshReason,
- linkedPRNumber: z.number().int().positive().nullable().optional(),
- fallbackPRNumber: z.number().int().positive().nullable().optional(),
- acceptMergedFallbackPR: z.boolean().optional(),
- currentHeadOid: z.string().nullable().optional()
-})
-
-const PullRequest = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- noCache: z.boolean().optional(),
- prRepo: SlugRepo.nullable().optional()
-})
-
-const PRCommentReaction = RepoSelector.extend({
- reactionSubjectId: requiredString('Missing reaction subject ID'),
- content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']),
- reacted: z.boolean(),
- prRepo: SlugRepo.nullable().optional()
-})
-
-const PullRequestChecks = PullRequest.extend({
- headSha: OptionalString
-})
-
-const PullRequestCheckDetails = RepoSelector.extend({
- checkRunId: z.number().int().positive().optional(),
- workflowRunId: z.number().int().positive().optional(),
- checkName: OptionalString,
- url: OptionalString.nullable().optional(),
- prRepo: SlugRepo.nullable().optional()
-})
-
-const RerunPullRequestChecks = PullRequest.extend({
- headSha: OptionalString,
- failedOnly: z.boolean().optional()
-})
-
-const PullRequestFileContents = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- prRepo: SlugRepo.nullable().optional(),
- path: requiredString('Missing file path'),
- oldPath: OptionalString,
- status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']),
- headSha: requiredString('Missing head SHA'),
- baseSha: requiredString('Missing base SHA')
-})
-
-const PullRequestFileViewed = RepoSelector.extend({
- prRepo: SlugRepo.nullable().optional(),
- pullRequestId: requiredString('Missing pull request ID'),
- path: requiredString('Missing file path'),
- viewed: z.boolean()
-})
-
-const ReviewThread = RepoSelector.extend({
- prRepo: SlugRepo.nullable().optional(),
- threadId: requiredString('Missing thread ID'),
- resolve: z.boolean()
-})
+import {
+ PRCommentReaction,
+ PrForBranch,
+ PullRequest,
+ PullRequestCheckDetails,
+ PullRequestChecks,
+ PullRequestFileContents,
+ PullRequestFileViewed,
+ RerunPullRequestChecks,
+ ReviewThread
+} from '../../../../shared/rpc-contract/github-pull-request-params'
export const GITHUB_PULL_REQUEST_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts
index 4d34b89420e..ca5bdcabbab 100644
--- a/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts
+++ b/src/main/runtime/rpc/methods/github-pull-request-update-methods.ts
@@ -1,80 +1,16 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalString, requiredString } from '../schemas'
-import { RepoSelector, SlugRepo } from './github-repo-target-schemas'
-
-const UpdatePrTitle = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- title: requiredString('Missing title'),
- prRepo: SlugRepo.nullable().optional()
-})
-
-const UpdatePr = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- updates: z.object({
- title: OptionalString,
- body: z.string().optional()
- }),
- prRepo: SlugRepo.nullable().optional()
-})
-
-const MergePr = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- method: z.enum(['merge', 'squash', 'rebase']).optional(),
- prRepo: SlugRepo.nullable().optional()
-})
-
-const SetPrAutoMerge = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- enabled: z.boolean(),
- method: z.enum(['merge', 'squash', 'rebase']).optional(),
- prRepo: SlugRepo.nullable().optional()
-})
-
-const UpdatePrState = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- prRepo: SlugRepo.nullable().optional(),
- updates: z.object({
- state: z.enum(['open', 'closed'])
- })
-})
-
-const MarkPrReadyForReview = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- prRepo: SlugRepo.nullable().optional()
-})
-
-const RequestPrReviewers = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- prRepo: SlugRepo.nullable().optional(),
- reviewers: z.array(z.string()).min(1)
-})
-
-const RemovePrReviewers = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- prRepo: SlugRepo.nullable().optional(),
- reviewers: z.array(z.string()).min(1)
-})
-
-const PRReviewComment = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- prRepo: SlugRepo.nullable().optional(),
- commitId: requiredString('Missing PR head SHA'),
- path: requiredString('File path required'),
- line: z.number().int().positive(),
- startLine: z.number().int().positive().optional(),
- body: requiredString('Comment body required')
-})
-
-const PRReviewCommentReply = RepoSelector.extend({
- prNumber: z.number().int().positive(),
- commentId: z.number().int().positive(),
- body: requiredString('Comment body required'),
- threadId: OptionalString,
- path: OptionalString,
- line: z.number().int().positive().optional(),
- prRepo: SlugRepo.nullable().optional()
-})
+import {
+ MarkPrReadyForReview,
+ MergePr,
+ PRReviewComment,
+ PRReviewCommentReply,
+ RemovePrReviewers,
+ RequestPrReviewers,
+ SetPrAutoMerge,
+ UpdatePr,
+ UpdatePrState,
+ UpdatePrTitle
+} from '../../../../shared/rpc-contract/github-pull-request-update-params'
export const GITHUB_PULL_REQUEST_UPDATE_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/github-repo-target-schemas.ts b/src/main/runtime/rpc/methods/github-repo-target-schemas.ts
index 3ea8b688910..06d47d47d9b 100644
--- a/src/main/runtime/rpc/methods/github-repo-target-schemas.ts
+++ b/src/main/runtime/rpc/methods/github-repo-target-schemas.ts
@@ -1,14 +1 @@
-import { z } from 'zod'
-import { OptionalString, requiredString } from '../schemas'
-
-export const RepoSelector = z.object({
- repo: requiredString('Missing repo selector')
-})
-
-export const SlugRepo = z.object({
- owner: requiredString('Missing owner'),
- repo: requiredString('Missing repo'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString
-})
+export { RepoSelector, SlugRepo } from '../../../../shared/rpc-contract/github-repo-target-params'
diff --git a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts
index 9602ba6cb70..d4722ec1466 100644
--- a/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts
+++ b/src/main/runtime/rpc/methods/github-repo-work-item-methods.ts
@@ -1,43 +1,14 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { RepoSelector } from './github-repo-target-schemas'
-
-const WorkItemsList = RepoSelector.extend({
- limit: OptionalFiniteNumber,
- query: OptionalString,
- page: z.number().int().positive().optional(),
- noCache: z.boolean().optional()
-})
-
-const IssuesList = RepoSelector.extend({
- limit: OptionalFiniteNumber
-})
-
-const WorkItem = RepoSelector.extend({
- number: z.number().int().positive(),
- type: z.enum(['issue', 'pr']).optional()
-})
-
-const WorkItemByOwnerRepo = RepoSelector.extend({
- owner: requiredString('Missing owner'),
- ownerRepo: requiredString('Missing repo'),
- // Why: Enterprise host identity must survive RPC parsing; Zod strips
- // undeclared fields before the runtime can host-qualify gh requests.
- host: OptionalString,
- number: z.number().int().positive(),
- type: z.enum(['issue', 'pr'])
-})
-
-const WorkItemDetails = WorkItem
-
-const WorkItemsCount = RepoSelector.extend({
- query: OptionalString
-})
-
-const RateLimit = z.object({
- force: z.boolean().optional()
-})
+import {
+ IssuesList,
+ RateLimit,
+ WorkItem,
+ WorkItemByOwnerRepo,
+ WorkItemDetails,
+ WorkItemsCount,
+ WorkItemsList
+} from '../../../../shared/rpc-contract/github-repo-work-item-params'
export const GITHUB_REPO_WORK_ITEM_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/gitlab.ts b/src/main/runtime/rpc/methods/gitlab.ts
index ac8fcad6991..ba840447cfd 100644
--- a/src/main/runtime/rpc/methods/gitlab.ts
+++ b/src/main/runtime/rpc/methods/gitlab.ts
@@ -1,154 +1,27 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { normalizeGitLabIssueListArgs } from '../../../gitlab/gitlab-preload-args'
import { toGitLabJobLogExcerptResult } from '../../../../shared/gitlab-job-log-excerpt'
-
-const RepoSelector = z.object({
- repo: requiredString('Missing repo selector')
-})
-
-const EmptyParams = z.object({}).optional().default({})
-const GitLabRateLimit = z
- .object({
- force: z.boolean().optional(),
- host: OptionalString
- })
- .optional()
- .default({})
-
-// nullish, not optional: renderer callers normalise a missing ref to `null`
-// (`item.projectRef ?? null`), which a bare `.optional()` would reject outright.
-const GitLabProjectRef = z
- .object({
- host: requiredString('Missing GitLab host'),
- path: requiredString('Missing GitLab project path')
- })
- .nullish()
-
-const WorkItemsList = RepoSelector.extend({
- state: z.enum(['opened', 'merged', 'closed', 'all']).optional(),
- page: OptionalFiniteNumber,
- perPage: OptionalFiniteNumber,
- query: OptionalString
-})
-
-const IssuesList = RepoSelector.extend({
- state: z.unknown().optional(),
- assignee: OptionalString,
- limit: OptionalFiniteNumber,
- page: OptionalFiniteNumber
-})
-
-const CreateIssue = RepoSelector.extend({
- title: requiredString('Missing title'),
- body: z.string()
-})
-
-const IssueUpdate = z.object({
- state: z.enum(['opened', 'closed']).optional(),
- title: z.string().optional(),
- body: z.string().optional(),
- addLabels: z.array(z.string()).optional(),
- removeLabels: z.array(z.string()).optional(),
- addAssignees: z.array(z.string()).optional(),
- removeAssignees: z.array(z.string()).optional()
-})
-
-const UpdateIssue = RepoSelector.extend({
- number: z.number().int().positive(),
- updates: IssueUpdate,
- projectRef: GitLabProjectRef
-})
-
-const UpdateMrState = RepoSelector.extend({
- iid: z.number().int().positive(),
- state: z.enum(['opened', 'closed']),
- projectRef: GitLabProjectRef
-})
-
-const UpdateMr = RepoSelector.extend({
- iid: z.number().int().positive(),
- updates: z.object({
- title: z.string().optional(),
- body: z.string().optional(),
- addLabels: z.array(z.string()).optional(),
- removeLabels: z.array(z.string()).optional(),
- readyForReview: z.literal(true).optional()
- }),
- projectRef: GitLabProjectRef
-})
-
-const UpdateMrReviewers = RepoSelector.extend({
- iid: z.number().int().positive(),
- reviewerIds: z.array(z.number().int().nonnegative()),
- projectRef: GitLabProjectRef
-})
-
-const MergeMr = RepoSelector.extend({
- iid: z.number().int().positive(),
- method: z.enum(['merge', 'squash', 'rebase']).optional(),
- projectRef: GitLabProjectRef
-})
-
-const AddIssueComment = RepoSelector.extend({
- number: z.number().int().positive(),
- body: requiredString('Comment body is required'),
- projectRef: GitLabProjectRef
-})
-
-const AddMRComment = RepoSelector.extend({
- iid: z.number().int().positive(),
- body: requiredString('Comment body is required'),
- projectRef: GitLabProjectRef
-})
-
-const AddMRInlineComment = RepoSelector.extend({
- iid: z.number().int().positive(),
- input: z.object({
- body: requiredString('Comment body is required'),
- path: requiredString('File path is required'),
- oldPath: z.string().optional(),
- line: z.number().int().positive(),
- baseSha: requiredString('Base SHA is required'),
- startSha: requiredString('Start SHA is required'),
- headSha: requiredString('Head SHA is required')
- }),
- projectRef: GitLabProjectRef
-})
-
-const ResolveMRDiscussion = RepoSelector.extend({
- iid: z.number().int().positive(),
- discussionId: requiredString('Discussion id is required'),
- resolved: z.boolean(),
- projectRef: GitLabProjectRef
-})
-
-const JobTrace = RepoSelector.extend({
- jobId: z.number().int().positive(),
- projectRef: GitLabProjectRef,
- // Why: raw CI traces routinely exceed the 1 MB transport frame cap, so callers
- // that only render an excerpt ask main to bound it before it crosses the wire.
- logExcerpt: z.boolean().optional()
-})
-
-const RetryJob = RepoSelector.extend({
- jobId: z.number().int().positive(),
- projectRef: GitLabProjectRef
-})
-
-const WorkItemDetails = RepoSelector.extend({
- iid: z.number().int().positive(),
- type: z.enum(['issue', 'mr']),
- projectRef: GitLabProjectRef
-})
-
-const WorkItemByPath = RepoSelector.extend({
- host: requiredString('Missing GitLab host'),
- path: requiredString('Missing GitLab project path'),
- iid: z.number().int().positive(),
- type: z.enum(['issue', 'mr'])
-})
+import {
+ AddIssueComment,
+ AddMRComment,
+ AddMRInlineComment,
+ CreateIssue,
+ EmptyParams,
+ GitLabRateLimit,
+ IssuesList,
+ JobTrace,
+ MergeMr,
+ RepoSelector,
+ ResolveMRDiscussion,
+ RetryJob,
+ UpdateIssue,
+ UpdateMr,
+ UpdateMrReviewers,
+ UpdateMrState,
+ WorkItemByPath,
+ WorkItemDetails,
+ WorkItemsList
+} from '../../../../shared/rpc-contract/gitlab-params'
export const GITLAB_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts
index fae2e8eb162..84b297ffa4e 100644
--- a/src/main/runtime/rpc/methods/hosted-review.ts
+++ b/src/main/runtime/rpc/methods/hosted-review.ts
@@ -1,51 +1,9 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { requiredString } from '../schemas'
-import { OptionalGitAdmissionTier } from './git-admission-tier-schema'
-
-const HostedReviewForBranch = z.object({
- repo: requiredString('Missing repo selector'),
- branch: requiredString('Missing branch'),
- admissionTier: OptionalGitAdmissionTier,
- currentHeadOid: z.string().nullable().optional(),
- // Only the caller's selected worktree; the host caps how many earn the fast tier.
- active: z.boolean().optional(),
- linkedGitHubPR: z.number().int().positive().nullable().optional(),
- fallbackGitHubPR: z.number().int().positive().nullable().optional(),
- linkedGitLabMR: z.number().int().positive().nullable().optional(),
- linkedBitbucketPR: z.number().int().positive().nullable().optional(),
- linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(),
- linkedGiteaPR: z.number().int().positive().nullable().optional()
-})
-
-const HostedReviewCreationEligibility = z.object({
- repo: requiredString('Missing repo selector'),
- worktree: z.string().min(1, 'Missing worktree selector').optional(),
- branch: requiredString('Missing branch'),
- base: z.string().nullable().optional(),
- hasUncommittedChanges: z.boolean().optional(),
- hasUpstream: z.boolean().optional(),
- ahead: z.number().int().nonnegative().optional(),
- behind: z.number().int().nonnegative().optional(),
- linkedGitHubPR: z.number().int().positive().nullable().optional(),
- fallbackGitHubPR: z.number().int().positive().nullable().optional(),
- linkedGitLabMR: z.number().int().positive().nullable().optional(),
- linkedBitbucketPR: z.number().int().positive().nullable().optional(),
- linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(),
- linkedGiteaPR: z.number().int().positive().nullable().optional()
-})
-
-const HostedReviewCreate = z.object({
- repo: requiredString('Missing repo selector'),
- worktree: z.string().min(1, 'Missing worktree selector').optional(),
- provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']),
- base: requiredString('Missing base branch'),
- head: z.string().optional(),
- title: requiredString('Missing title'),
- body: z.string().optional(),
- draft: z.boolean().optional(),
- useTemplate: z.boolean().optional()
-})
+import {
+ HostedReviewCreate,
+ HostedReviewCreationEligibility,
+ HostedReviewForBranch
+} from '../../../../shared/rpc-contract/hosted-review-params'
export const HOSTED_REVIEW_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts
index 087aa25f36e..4463c969919 100644
--- a/src/main/runtime/rpc/methods/jira.ts
+++ b/src/main/runtime/rpc/methods/jira.ts
@@ -1,109 +1,24 @@
-import { z } from 'zod'
import {
JIRA_PAYLOAD_CHUNK_CHARS,
JIRA_PAYLOAD_MAX_CHARS
} from '../../../../shared/jira-payload-stream'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import {
- OptionalFiniteNumber,
- OptionalPlainString,
- OptionalString,
- requiredString
-} from '../schemas'
-
-const VALID_FILTERS = ['assigned', 'reported', 'all', 'done'] as const
-
-const SiteSelection = z
- .object({
- siteId: OptionalString
- })
- .optional()
-
-const Connect = z.object({
- siteUrl: requiredString('Site URL is required'),
- // Self-hosted PAT auth needs no email; connect() enforces it for Cloud.
- email: OptionalPlainString,
- apiToken: requiredString('API token is required'),
- authType: z.enum(['cloud', 'server']).optional()
-})
-
-const SelectSite = z.object({
- siteId: requiredString('Site ID is required')
-})
-
-const SearchIssues = z.object({
- jql: requiredString('Missing JQL'),
- limit: OptionalFiniteNumber,
- siteId: OptionalString
-})
-
-const ListIssues = z
- .object({
- filter: z.enum(VALID_FILTERS).optional(),
- limit: OptionalFiniteNumber,
- siteId: OptionalString
- })
- .optional()
-
-const IssueKey = z.object({
- key: requiredString('Issue key is required'),
- siteId: OptionalString
-})
-
-const CreateIssue = z.object({
- siteId: OptionalString,
- projectId: requiredString('Project is required'),
- issueTypeId: requiredString('Issue type is required'),
- title: requiredString('Title is required'),
- description: OptionalPlainString,
- customFields: z.record(z.string(), z.unknown()).optional(),
- userFieldKeys: z.array(z.string()).optional()
-})
-
-const IssueUpdate = z.object({
- key: requiredString('Issue key is required'),
- siteId: OptionalString,
- updates: z.object({
- title: OptionalString,
- labels: z.array(z.string()).optional(),
- assigneeAccountId: z.union([z.string(), z.null()]).optional(),
- priorityId: z.union([z.string(), z.null()]).optional(),
- transitionId: OptionalString
- })
-})
-
-const IssueComment = z.object({
- key: requiredString('Issue key is required'),
- body: requiredString('Comment body is required'),
- siteId: OptionalString
-})
-
-const ProjectIssueTypes = z.object({
- projectIdOrKey: requiredString('Project is required'),
- siteId: OptionalString
-})
-
-const ProjectIssueTypeFields = z.object({
- projectIdOrKey: requiredString('Project is required'),
- issueTypeId: requiredString('Issue type is required'),
- siteId: OptionalString
-})
-
-const AssignableUsers = z.object({
- key: requiredString('Issue key is required'),
- query: OptionalPlainString,
- siteId: OptionalString
-})
-
-const UserSearch = z.object({
- query: OptionalPlainString,
- siteId: OptionalString
-})
-
-const ProjectStatusOrder = z.object({
- projectKey: requiredString('Project key is required'),
- siteId: OptionalString
-})
+ AssignableUsers,
+ Connect,
+ CreateIssue,
+ IssueComment,
+ IssueKey,
+ IssueUpdate,
+ ListIssues,
+ ProjectIssueTypeFields,
+ ProjectIssueTypes,
+ ProjectStatusOrder,
+ SearchIssues,
+ SelectSite,
+ SiteSelection,
+ UserSearch
+} from '../../../../shared/rpc-contract/jira-params'
/** Emits a Jira result over RPC, normalizing it to the shape clients decode. */
function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void {
diff --git a/src/main/runtime/rpc/methods/linear-agent-access.ts b/src/main/runtime/rpc/methods/linear-agent-access.ts
index 50e7bfef3a2..e76c843dede 100644
--- a/src/main/runtime/rpc/methods/linear-agent-access.ts
+++ b/src/main/runtime/rpc/methods/linear-agent-access.ts
@@ -1,149 +1,22 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { linearError } from '../../../linear/issue-context-errors'
import { isLinearUuid } from '../../../../shared/linear/uuid'
-
-const LINEAR_DUE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
-const LinearDueDate = z.string().refine((value) => LINEAR_DUE_DATE_PATTERN.test(value), {
- message: 'Linear due dates must use YYYY-MM-DD'
-})
-const OptionalLinearDueDate = LinearDueDate.optional()
-const OptionalLinearDueDateOrClear = z.union([LinearDueDate, z.null()]).optional()
-
-const AgentSearchIssues = z.object({
- query: requiredString('Missing query'),
- limit: OptionalFiniteNumber,
- workspaceId: z.union([z.string(), z.literal('all')]).optional()
-})
-
-const LinearWorkspaceRead = z.object({
- workspaceId: z.union([z.string(), z.literal('all')]).optional()
-})
-
-const LinearTeamLookup = z.object({
- teamInput: requiredString('Missing team'),
- workspaceId: OptionalString.refine((value) => value !== 'all', {
- message: '--workspace all is only valid for team list'
- })
-})
-
-const LinearIssueList = z.object({
- filter: z.enum(['assigned', 'created', 'all', 'completed', 'open']).optional(),
- teamInput: OptionalString,
- limit: OptionalFiniteNumber,
- workspaceId: z.union([z.string(), z.literal('all')]).optional()
-})
-
-const LinearProjectList = z.object({
- query: OptionalString,
- limit: OptionalFiniteNumber,
- workspaceId: z.union([z.string(), z.literal('all')]).optional()
-})
-
-const LinearIncludeFlags = z.object({
- comments: z.boolean(),
- children: z.boolean(),
- attachments: z.boolean(),
- relations: z.boolean(),
- activity: z.boolean().default(false)
-})
-
-const LinearCurrentContext = z
- .object({
- worktreeId: OptionalString,
- terminalHandle: OptionalString,
- cwd: OptionalString,
- remote: z.boolean().optional()
- })
- .optional()
-
-const LinearWriteTarget = z.object({
- input: OptionalString,
- current: z.boolean().optional(),
- workspaceId: OptionalString.refine((value) => value !== 'all', {
- message: '--workspace all is not valid for Linear writes'
- }),
- context: LinearCurrentContext
-})
-
-const AgentIssueContext = z.object({
- input: OptionalString,
- current: z.boolean().optional(),
- workspaceId: OptionalString,
- include: LinearIncludeFlags,
- depth: z.number().int().min(0).max(5),
- context: LinearCurrentContext
-})
-
-const LinearIssueSetState = LinearWriteTarget.extend({
- to: requiredString('Missing target state')
-})
-
-const LinearIssueUpdateTask = LinearWriteTarget.extend({
- operation: z.enum(['assignee', 'priority', 'estimate', 'dueDate', 'labels']),
- assigneeId: z.string().nullable().optional(),
- assigneeMe: z.boolean().optional(),
- priority: z.number().int().min(0).max(4).optional(),
- estimate: z.number().int().min(0).nullable().optional(),
- dueDate: OptionalLinearDueDateOrClear,
- labelMode: z.enum(['add', 'remove', 'set']).optional(),
- labels: z.array(z.string()).optional()
-})
-
-const LinearIssueAddComment = LinearWriteTarget.extend({
- body: requiredString('Missing comment body'),
- replyTo: OptionalString,
- writeId: OptionalString
-})
-
-const LinearIssueRelationWrite = LinearWriteTarget.extend({
- relatedInput: requiredString('Missing related issue'),
- relationship: z.enum(['blocks', 'blockedBy', 'relatedTo', 'duplicateOf']),
- operation: z.enum(['add', 'remove'])
-})
-
-const LinearIssueAttachLink = LinearWriteTarget.extend({
- url: requiredString('Missing attachment URL'),
- title: OptionalString,
- writeId: OptionalString
-})
-
-const LinearIssueCreate = z.object({
- title: requiredString('Missing issue title'),
- body: OptionalString,
- teamInput: OptionalString,
- teamKey: OptionalString,
- state: OptionalString,
- assignee: OptionalString,
- priority: z.number().int().min(0).max(4).optional(),
- estimate: z.number().int().min(0).optional(),
- dueDate: OptionalLinearDueDate,
- labels: z.array(z.string()).optional(),
- projectInput: OptionalString,
- parentInput: OptionalString,
- parentCurrent: z.boolean().optional(),
- workspaceId: OptionalString.refine((value) => value !== 'all', {
- message: '--workspace all is not valid for Linear writes'
- }),
- writeId: OptionalString,
- context: LinearCurrentContext
-})
-
-const LinearSaveIssue = LinearWriteTarget.extend({
- team: OptionalString,
- title: OptionalString,
- description: z.string().optional(),
- state: OptionalString,
- assignee: z.string().nullable().optional(),
- priority: z.number().int().min(0).max(4).optional(),
- estimate: z.number().min(0).nullable().optional(),
- dueDate: OptionalLinearDueDateOrClear,
- labels: z.array(z.string()).optional(),
- project: z.string().nullable().optional(),
- parentId: z.string().nullable().optional(),
- writeId: OptionalString
-})
+import {
+ AgentIssueContext,
+ AgentSearchIssues,
+ LinearCurrentContext,
+ LinearIssueAddComment,
+ LinearIssueAttachLink,
+ LinearIssueCreate,
+ LinearIssueList,
+ LinearIssueRelationWrite,
+ LinearIssueSetState,
+ LinearIssueUpdateTask,
+ LinearProjectList,
+ LinearSaveIssue,
+ LinearTeamLookup,
+ LinearWorkspaceRead
+} from '../../../../shared/rpc-contract/linear-agent-access-params'
function parseLinearWriteId(writeId: string | undefined): string | undefined {
if (writeId === undefined) {
diff --git a/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts
index 7b7176f48b8..b369a01a15c 100644
--- a/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts
+++ b/src/main/runtime/rpc/methods/linear-issue-attribute-filter-schema.ts
@@ -1,35 +1 @@
-import { z } from 'zod'
-import {
- LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH,
- LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
- LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES,
- LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
-} from '../../../../shared/linear/issue-attribute-filter'
-
-// Why: keep ListIssues param validation co-located with shared limits without
-// pushing linear.ts past the max-lines ratchet.
-const LinearAttributeFilterId = z
- .string()
- .trim()
- .min(1)
- .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH)
-
-export const LinearIssueAttributeFilterSchema = z
- .object({
- stateIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS),
- priorities: z
- .array(z.number().int().min(0).max(4))
- .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES),
- assignee: z.union([
- z.object({ kind: z.literal('unassigned') }).strict(),
- z
- .object({
- kind: z.literal('user'),
- id: LinearAttributeFilterId
- })
- .strict(),
- z.null()
- ]),
- labelIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS)
- })
- .strict()
+export { LinearIssueAttributeFilterSchema } from '../../../../shared/rpc-contract/linear-issue-attribute-filter-params'
diff --git a/src/main/runtime/rpc/methods/linear-issue-list-method.ts b/src/main/runtime/rpc/methods/linear-issue-list-method.ts
index fa69b745377..9dd838c837a 100644
--- a/src/main/runtime/rpc/methods/linear-issue-list-method.ts
+++ b/src/main/runtime/rpc/methods/linear-issue-list-method.ts
@@ -1,42 +1,6 @@
-import { z } from 'zod'
+import type { z } from 'zod'
import { defineMethod } from '../core'
-import { OptionalFiniteNumber, OptionalString } from '../schemas'
-import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema'
-
-const LegacyListIssues = z
- .object({
- filter: z.enum(['assigned', 'created', 'all', 'completed']).optional(),
- limit: OptionalFiniteNumber,
- workspaceId: OptionalString,
- attributeFilter: LinearIssueAttributeFilterSchema.optional()
- })
- .strict()
- .optional()
-
-const McpListIssues = z
- .object({
- team: OptionalString,
- cycle: OptionalString,
- label: OptionalString,
- limit: z.number().int().min(1).max(250).optional(),
- query: OptionalString,
- state: OptionalString,
- cursor: OptionalString,
- orderBy: z.enum(['createdAt', 'updatedAt']).optional(),
- project: OptionalString,
- release: OptionalString,
- assignee: OptionalString,
- delegate: OptionalString,
- parentId: OptionalString,
- priority: z.number().int().min(0).max(4).optional(),
- createdAt: OptionalString,
- updatedAt: OptionalString,
- includeArchived: z.boolean().optional(),
- workspaceId: OptionalString
- })
- .strict()
-
-const ListIssues = z.union([McpListIssues, LegacyListIssues])
+import { ListIssues, McpListIssues } from '../../../../shared/rpc-contract/linear-issue-list-params'
export const LINEAR_ISSUE_LIST_METHOD = defineMethod({
name: 'linear.listIssues',
diff --git a/src/main/runtime/rpc/methods/linear-project-create.ts b/src/main/runtime/rpc/methods/linear-project-create.ts
index 026c585aba2..f2c020f6656 100644
--- a/src/main/runtime/rpc/methods/linear-project-create.ts
+++ b/src/main/runtime/rpc/methods/linear-project-create.ts
@@ -1,23 +1,5 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalString, requiredString } from '../schemas'
-
-const LinearPriority = z.number().int().min(0).max(4).optional()
-const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
-
-const CreateProject = z.object({
- name: requiredString('Project name is required'),
- description: OptionalString,
- content: OptionalString,
- workspaceId: OptionalString,
- teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'),
- leadId: z.union([z.string(), z.null()]).optional(),
- memberIds: z.array(requiredString('Invalid member ID')).optional(),
- labelIds: LinearLabelIds,
- priority: LinearPriority,
- startDate: OptionalString,
- targetDate: OptionalString
-})
+import { CreateProject } from '../../../../shared/rpc-contract/linear-project-create-params'
export const LINEAR_PROJECT_CREATE_METHOD: RpcMethod = defineMethod({
name: 'linear.createProject',
diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts
index 1f3e474e78d..f0ec2106830 100644
--- a/src/main/runtime/rpc/methods/linear.ts
+++ b/src/main/runtime/rpc/methods/linear.ts
@@ -1,124 +1,24 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create'
import { LINEAR_ISSUE_LIST_METHOD, LINEAR_MCP_ISSUE_LIST_METHOD } from './linear-issue-list-method'
-
-const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const
-const LinearPriority = z.number().int().min(0).max(4).optional()
-const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
-
-const Connect = z.object({
- apiKey: requiredString('Invalid API key')
-})
-
-const WorkspaceSelection = z
- .object({
- workspaceId: OptionalString
- })
- .optional()
-
-const ConcreteWorkspaceId = requiredString('Concrete Linear workspace ID is required').refine(
- (value) => value !== 'all',
- 'Concrete Linear workspace ID is required'
-)
-
-const SelectWorkspace = z.object({
- workspaceId: requiredString('Workspace ID is required')
-})
-
-const SearchIssues = z.object({
- query: requiredString('Missing query'),
- limit: OptionalFiniteNumber,
- workspaceId: OptionalString
-})
-
-const CreateIssue = z.object({
- teamId: requiredString('Team ID is required'),
- title: requiredString('Title is required'),
- description: OptionalString,
- workspaceId: OptionalString,
- parentIssueId: OptionalString,
- projectId: z.union([z.string(), z.null()]).optional(),
- stateId: OptionalString,
- priority: LinearPriority,
- assigneeId: z.union([z.string(), z.null()]).optional(),
- labelIds: LinearLabelIds
-})
-
-const IssueId = z.object({
- id: requiredString('Issue ID is required'),
- workspaceId: OptionalString
-})
-
-const IssueComment = z.object({
- issueId: requiredString('Issue ID is required'),
- body: requiredString('Comment body is required'),
- workspaceId: OptionalString
-})
-
-const ListProjects = z
- .object({
- query: OptionalString,
- limit: OptionalFiniteNumber,
- workspaceId: OptionalString,
- force: z.boolean().optional()
- })
- .optional()
-
-const ProjectId = z.object({
- id: requiredString('Project ID is required'),
- workspaceId: ConcreteWorkspaceId,
- force: z.boolean().optional()
-})
-
-const ProjectIssues = z.object({
- projectId: requiredString('Project ID is required'),
- limit: OptionalFiniteNumber,
- workspaceId: ConcreteWorkspaceId,
- force: z.boolean().optional()
-})
-
-const ListCustomViews = z.object({
- model: z.enum(VALID_CUSTOM_VIEW_MODELS),
- limit: OptionalFiniteNumber,
- workspaceId: OptionalString,
- force: z.boolean().optional()
-})
-
-const CustomViewId = z.object({
- viewId: requiredString('Custom view ID is required'),
- model: z.enum(VALID_CUSTOM_VIEW_MODELS),
- workspaceId: ConcreteWorkspaceId,
- force: z.boolean().optional()
-})
-
-const CustomViewContents = z.object({
- viewId: requiredString('Custom view ID is required'),
- limit: OptionalFiniteNumber,
- workspaceId: ConcreteWorkspaceId,
- force: z.boolean().optional()
-})
-
-const TeamId = z.object({
- teamId: requiredString('Team ID is required'),
- workspaceId: OptionalString
-})
-
-const IssueUpdate = z.object({
- id: requiredString('Issue ID is required'),
- workspaceId: OptionalString,
- updates: z.object({
- stateId: OptionalString,
- title: OptionalString,
- description: z.string().optional(),
- assigneeId: z.union([z.string(), z.null()]).optional(),
- estimate: z.union([z.number().int().min(0), z.null()]).optional(),
- priority: z.number().int().min(0).max(4).optional(),
- labelIds: z.array(z.string()).optional(),
- projectId: z.union([z.string(), z.null()]).optional()
- })
-})
+import {
+ Connect,
+ CreateIssue,
+ CustomViewContents,
+ CustomViewId,
+ IssueComment,
+ IssueId,
+ IssueUpdate,
+ LinearIssueCommentsParams,
+ ListCustomViews,
+ ListProjects,
+ ProjectId,
+ ProjectIssues,
+ SearchIssues,
+ SelectWorkspace,
+ TeamId,
+ WorkspaceSelection
+} from '../../../../shared/rpc-contract/linear-params'
export const LINEAR_METHODS: RpcMethod[] = [
defineMethod({
@@ -193,10 +93,7 @@ export const LINEAR_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'linear.issueComments',
- params: z.object({
- issueId: requiredString('Issue ID is required'),
- workspaceId: OptionalString
- }),
+ params: LinearIssueCommentsParams,
handler: async (params, { runtime }) =>
runtime.linearIssueComments(params.issueId.trim(), params.workspaceId)
}),
diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts
index e1a92dd52db..305e8adb771 100644
--- a/src/main/runtime/rpc/methods/native-chat.ts
+++ b/src/main/runtime/rpc/methods/native-chat.ts
@@ -1,5 +1,4 @@
-import { z } from 'zod'
-import type { NativeChatMessage, AgentType } from '../../../../shared/native-chat-types'
+import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import {
readNativeChatTranscriptTail,
subscribeNativeChatTranscript,
@@ -8,52 +7,11 @@ import {
} from '../../../native-chat/transcript-watch'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize'
-
-// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The
-// desktop reaches the readers via Electron IPC; mobile/web clients reach the
-// same pure readers through these runtime RPC methods so the native chat view
-// works over the paired connection, not just in the desktop renderer.
-
-const NativeChatSession = z.object({
- agent: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing agent'))
- .transform((v) => v as AgentType),
- sessionId: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing session id')),
- // How many of the most-recent messages to return. Clients start small for a
- // fast first paint and raise it to page older history in as the user scrolls.
- // Clamp (don't reject) a limit past the max window so a client paging beyond it
- // gets the capped tail and pagination stops cleanly — a hard `.max` rejection
- // would fail the read and stall "load earlier" at the boundary.
- limit: z
- .number()
- .int()
- .positive()
- .transform((value) => Math.min(value, MOBILE_NATIVE_CHAT_MAX_WINDOW))
- .optional(),
- // Optional client-supplied cleanup token. When present, the subscribe handler
- // keys the fs-watcher cleanup under it so registration and unsubscribe derive
- // from the SAME token (back-compat: falls back to `agent:sessionId` when absent,
- // which is exactly what existing mobile clients rely on).
- subscriptionId: z.string().min(1).optional(),
- // Authoritative transcript path from the agent hook (providerSession), used to
- // locate the file directly when the session id no longer names it (recent
- // Claude Code). Optional for back-compat with older clients.
- transcriptPath: z.string().min(1).optional(),
- // A pending snapshot is not authoritative transcript history. Only clients
- // that advertise this semantic may receive one; legacy clients treat it as a
- // settled empty read and can overwrite retention / unblock launch drafts.
- capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(),
- beforeOffset: z.number().int().nonnegative().optional()
-})
-
-const NativeChatUnsubscribe = z.object({
- subscriptionId: z.string().min(1).optional()
-})
+import {
+ MOBILE_NATIVE_CHAT_MAX_WINDOW,
+ NativeChatSession,
+ NativeChatUnsubscribe
+} from '../../../../shared/rpc-contract/native-chat-params'
// Why: a long agent session can hold thousands of turns (with full tool I/O).
// Shipping all of them over the paired connection and rendering them at once
@@ -63,7 +21,6 @@ const NativeChatUnsubscribe = z.object({
// Small first page for a fast initial paint; the client raises `limit` to load
// older history as the user scrolls back.
const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40
-const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
function sanitizeMessage(
message: NativeChatMessage,
diff --git a/src/main/runtime/rpc/methods/notifications.ts b/src/main/runtime/rpc/methods/notifications.ts
index a1adb84805a..2b9e05fb6f5 100644
--- a/src/main/runtime/rpc/methods/notifications.ts
+++ b/src/main/runtime/rpc/methods/notifications.ts
@@ -1,76 +1,22 @@
-import { z } from 'zod'
import { createNotificationStreamFilter } from './notification-stream-policy'
-import {
- MOBILE_PUSH_APNS_ENVIRONMENTS,
- MOBILE_PUSH_PLATFORMS
-} from '../../../../shared/mobile-push-contract'
import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core'
+import {
+ NotificationGetMissedSinceParams,
+ NotificationRegisterPushParams,
+ NotificationUnsubscribeParams,
+ NotificationsSubscribeParams
+} from '../../../../shared/rpc-contract/notifications-params'
// Why: monotonically increasing per-process counter eliminates the
// Date.now() collision that could fire when two near-simultaneous
// notifications.subscribe calls landed on the same millisecond.
let notificationsSubscriptionSeq = 0
-const NotificationUnsubscribeParams = z.object({
- subscriptionId: z
- .unknown()
- .transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
- .pipe(z.string().min(1, 'Missing subscriptionId'))
-})
-
-// Why: notifications.getMissedSince is the catch-up RPC for mobile reconnect
-// (#8129). The client passes the highest seq it has already delivered; the
-// runtime returns only notifications dispatched after that seq. Because the
-// desktop assigns a monotonic seq to every dispatched notification, the cut is
-// exact and idempotent — re-requesting with the same watermark can never
-// return an already-delivered event, so reconnects never duplicate local
-// pushes (the adversarial-review gate for #8129).
-// `epoch` names the counter lifetime lastSeenSeq came from (#8591). The desktop's
-// seq restarts at 0 on every launch while the client's watermark is persisted, so
-// without it a post-restart watermark silently cuts away everything. Optional: a
-// client that predates the field keeps the seq-only cut.
-const NotificationGetMissedSinceParams = z.object({
- lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'),
- epoch: z.string().optional(),
- includeDesktopSuppressed: z.boolean().optional(),
- deliveredPushes: z
- .array(
- z.object({
- notificationId: z.string().min(1).max(2048),
- notificationEpoch: z.string().min(1).max(128),
- notificationSeq: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)
- })
- )
- .max(256)
- .optional()
-})
-
-const NotificationPushFilterParams = z.object({
- onlyWhenDesktopAway: z.boolean().optional(),
- sound: z.boolean().optional()
-})
-
-const NotificationRegisterPushParams = z
- .object({
- platform: z.enum(MOBILE_PUSH_PLATFORMS),
- token: z.string().min(1).max(4096),
- apnsEnvironment: z.enum(MOBILE_PUSH_APNS_ENVIRONMENTS).optional(),
- filter: NotificationPushFilterParams
- })
- // Why strict: the device identity is added by the handler, so a caller-supplied
- // `deviceId` must be an error, not a key silently dropped.
- .strict()
- // Why: an APNs token is only routable against the environment it was minted in,
- // so a missing environment must fail loudly rather than default to production.
- .refine((params) => params.platform !== 'ios' || params.apnsEnvironment !== undefined, {
- message: 'apnsEnvironment is required for ios'
- })
-
// Legacy callers retain filtered socket alerts; push clients opt into the full event stream.
export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
defineStreamingMethod({
name: 'notifications.subscribe',
- params: z.object({ includeDesktopSuppressed: z.boolean().optional() }).optional(),
+ params: NotificationsSubscribeParams,
handler: async (params, { runtime, connectionId }, emit) => {
const shouldEmit = createNotificationStreamFilter(params?.includeDesktopSuppressed)
await new Promise((resolve) => {
diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts
index 6091c1080fa..0ab3cce34aa 100644
--- a/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts
+++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-control.ts
@@ -1,9 +1,6 @@
-import { z } from 'zod'
-import { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../../../shared/orchestration-worker-output'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import type { RemoteDispatchAttachmentRow } from '../../../../orchestration/types'
import { defineMethod, type RpcMethod } from '../../../core'
-import { OptionalFiniteNumber, requiredString } from '../../../schemas'
import { mapWithConcurrency } from '../../../../../../shared/map-with-concurrency'
import { readExactWorkerOutput } from '../worker/worker-output'
import { describeUnconfirmedAgentStop } from '../../../../../../shared/pty-liveness-verdict'
@@ -12,22 +9,12 @@ import {
readRemoteAttachmentArchive,
releaseRemoteAttachment
} from './federated-worker-release-host'
-
-const FederationDispatchParams = z.object({
- dispatchId: requiredString('Missing Dispatch ID')
-})
-const FederationReadParams = FederationDispatchParams.extend({
- cursor: OptionalFiniteNumber,
- limit: OptionalFiniteNumber
-})
-const FederationOutputReadParams = FederationDispatchParams.extend({
- cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(),
- limit: OptionalFiniteNumber,
- source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional()
-})
-const FederationFleetSnapshotParams = z.object({
- dispatchIds: z.array(requiredString('Missing Dispatch ID')).min(1).max(100)
-})
+import {
+ FederationDispatchParams,
+ FederationFleetSnapshotParams,
+ FederationOutputReadParams,
+ FederationReadParams
+} from '../../../../../../shared/rpc-contract/orchestration-federation-control-params'
export const ORCHESTRATION_FEDERATION_CONTROL_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts
index 8cb4e08f2f3..58561e5e044 100644
--- a/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts
+++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-relay.ts
@@ -1,4 +1,3 @@
-import { z } from 'zod'
import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../../../shared/protocol-version'
import { importFederatedControlMessage } from '../../../../orchestration/federation-control-message'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
@@ -8,52 +7,11 @@ import {
type FederatedLifecycleSettlement
} from '../../../../orchestration/federation-lifecycle-settlement'
import { defineMethod, type RpcMethod } from '../../../core'
-import { OptionalFiniteNumber, requiredString } from '../../../schemas'
-
-const FederationPullParams = z.object({
- dispatchId: requiredString('Missing Dispatch ID'),
- afterSequence: OptionalFiniteNumber,
- replayUnacknowledged: z.boolean().optional(),
- limit: OptionalFiniteNumber
-})
-
-const FederationAckParams = z.object({
- dispatchId: requiredString('Missing Dispatch ID'),
- throughSequence: z.number().int().nonnegative(),
- settlements: z
- .array(
- z.object({
- sequence: z.number().int().positive(),
- lifecycle: z.discriminatedUnion('action', [
- z.object({
- action: z.enum(['completed', 'failed']),
- authority: z.literal('run_home')
- }),
- z.object({
- action: z.literal('rejected'),
- code: z.string(),
- reason: z.string(),
- authority: z.literal('run_home')
- })
- ])
- })
- )
- .optional()
-})
-
-const FederationImportParams = z.object({
- dispatchId: requiredString('Missing Dispatch ID'),
- items: z.array(
- z.object({
- dispatch_id: requiredString('Missing item Dispatch ID'),
- direction: z.literal('to_worker'),
- sequence: z.number().int().positive(),
- message_id: requiredString('Missing relay message ID'),
- kind: requiredString('Missing relay kind'),
- payload: requiredString('Missing relay payload')
- })
- )
-})
+import {
+ FederationAckParams,
+ FederationImportParams,
+ FederationPullParams
+} from '../../../../../../shared/rpc-contract/orchestration-federation-relay-params'
export const ORCHESTRATION_FEDERATION_RELAY_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts
index 84ed57d58cc..89b55c86a4c 100644
--- a/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts
+++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-start-schema.ts
@@ -1,31 +1,5 @@
-import { z } from 'zod'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../schemas'
-import { OptionalWorkerLaunchPreference } from '../worker/worker-start-schema'
-
-export const FederationAttachStartParams = z.object({
- /** Omitted by v1.4.198 coordinators; the worker host then mints a stub home Run. */
- runId: OptionalString,
- dispatchId: requiredString('Missing Dispatch ID'),
- taskId: requiredString('Missing Task ID'),
- taskSpec: requiredString('Missing Task spec'),
- /** Depth stamped by the Run home; omitted by older clients and defaults to 1. */
- depth: z.number().int().min(1).optional(),
- protocolVersion: z.union([z.literal(1), z.literal(2), z.literal(3)]),
- worktree: requiredString('Missing remote worktree selector'),
- name: OptionalString,
- repo: OptionalString,
- baseBranch: OptionalString,
- displayName: OptionalString,
- displayNameKind: z.enum(['generated', 'user']).optional(),
- comment: OptionalString,
- setup: z.enum(['run', 'skip', 'inherit']).optional(),
- setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(),
- terminal: OptionalString,
- agent: OptionalString,
- model: OptionalWorkerLaunchPreference,
- effort: OptionalWorkerLaunchPreference,
- timeoutMs: OptionalFiniteNumber,
- devMode: z.boolean().optional()
-})
+import type { z } from 'zod'
+import { FederationAttachStartParams } from '../../../../../../shared/rpc-contract/orchestration-federation-start-params'
+export { FederationAttachStartParams }
export type FederationAttachStartInput = z.infer
diff --git a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts
index 76bfd23b76e..6bb8750628e 100644
--- a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts
+++ b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts
@@ -1,48 +1,21 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../../../core'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../schemas'
import type { GateStatus } from '../../../../orchestration/db'
import { Coordinator } from '../../../../orchestration/coordinator'
import { resolveRunScope } from '../runs/run-scope'
import { taskNotFoundError } from '../../../../orchestration/task-dispatch-refusal'
+import {
+ GateCreateParams,
+ GateListParams,
+ GateResolveParams,
+ RunParams,
+ RunStopParams
+} from '../../../../../../shared/rpc-contract/orchestration-gates-params'
// Why: the coordinator instance is stored at module scope so orchestration.runStop
// can signal it to halt. Only one coordinator can run at a time (enforced by
// the DB's active-run check), so a single reference suffices.
let activeCoordinator: Coordinator | null = null
-const RunParams = z.object({
- spec: requiredString('Missing --spec'),
- from: OptionalString,
- pollIntervalMs: OptionalFiniteNumber,
- maxConcurrent: OptionalFiniteNumber,
- worktree: OptionalString
-})
-
-const RunStopParams = z.object({})
-
-const GateCreateParams = z.object({
- task: requiredString('Missing --task'),
- question: requiredString('Missing --question'),
- options: OptionalString,
- from: OptionalString,
- run: OptionalString
-})
-
-const GateResolveParams = z.object({
- id: requiredString('Missing --id'),
- resolution: requiredString('Missing --resolution'),
- from: OptionalString,
- run: OptionalString
-})
-
-const GateListParams = z.object({
- task: OptionalString,
- status: z.enum(['pending', 'resolved', 'timeout']).optional(),
- from: OptionalString,
- run: OptionalString
-})
-
export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
// Why: Section 4.12 — orchestration.run returns immediately with a run ID.
// The coordinator loop runs in the background; progress is queried via
diff --git a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts
index 5dd72b61c6e..8ca5333f307 100644
--- a/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts
+++ b/src/main/runtime/rpc/methods/orchestration/runs/mutation-request-show.ts
@@ -3,10 +3,7 @@ import {
type OrchestrationMutationRequestShowResult
} from '../../../../../../shared/orchestration-mutation-request'
import { defineMethod, type RpcMethod } from '../../../core'
-import { requiredString } from '../../../schemas'
-import { z } from 'zod'
-
-const RequestShowParams = z.object({ request: requiredString('Missing --request') })
+import { RequestShowParams } from '../../../../../../shared/rpc-contract/orchestration-runs-mutation-request-show-params'
export const ORCHESTRATION_MUTATION_REQUEST_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts
index 77bcea4924c..dc0b112a168 100644
--- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts
+++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts
@@ -1,28 +1,14 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../../../core'
-import { OptionalBoolean, OptionalString, requiredString } from '../../../schemas'
-import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../../../shared/orchestration-run-pagination'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import { assertCallerHandleMatchesEvidence, resolveOrchestrationCaller } from './run-scope'
import { exposeRun } from './run-receipt'
-
-const RunCreateParams = z.object({
- objective: requiredString('Missing --objective'),
- from: requiredString('Missing coordinator terminal')
-})
-
-const RunUseParams = z.object({
- id: requiredString('Missing --id'),
- from: requiredString('Missing coordinator terminal'),
- takeoverLegacy: OptionalBoolean
-})
-
-const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') })
-const RunListParams = z.object({
- limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(),
- cursor: z.string().min(1).optional()
-})
-const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString })
+import {
+ RunCreateParams,
+ RunCurrentParams,
+ RunListParams,
+ RunShowParams,
+ RunUseParams
+} from '../../../../../../shared/rpc-contract/orchestration-runs-params'
export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/orchestration/schemas.ts b/src/main/runtime/rpc/methods/orchestration/schemas.ts
index 51b51137475..eae80849ffc 100644
--- a/src/main/runtime/rpc/methods/orchestration/schemas.ts
+++ b/src/main/runtime/rpc/methods/orchestration/schemas.ts
@@ -1,15 +1,26 @@
import { z } from 'zod'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
-import {
- OptionalFiniteNumber,
- OptionalString,
- OptionalBoolean,
- requiredString
-} from '../../schemas'
+import { OptionalString, OptionalBoolean, requiredString } from '../../schemas'
import type { TaskStatus } from '../../../orchestration/db'
import { isGroupAddress } from '../../../orchestration/groups'
import { MESSAGE_TYPES } from '../../../orchestration/types'
import { OrchestrationError } from '../../../orchestration/orchestration-error'
+import {
+ getLifecycleGroupRecipientError,
+ isDispatchMutationMessageType
+} from '../../../../../shared/rpc-contract/orchestration-params'
+export {
+ AskParams,
+ CheckParams,
+ DispatchParams,
+ DispatchShowParams,
+ InboxParams,
+ ReplyParams,
+ ResetParams,
+ TaskCreateParams,
+ TaskListParams
+} from '../../../../../shared/rpc-contract/orchestration-params'
+export { getLifecycleGroupRecipientError, isDispatchMutationMessageType }
export const TASK_STATUSES: TaskStatus[] = [
'pending',
@@ -44,27 +55,6 @@ const SEND_MESSAGE_TYPE_ERROR = [
'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .'
].join(' ')
-export type DispatchMutationMessageType =
- | 'worker_done'
- | 'heartbeat'
- | 'escalation'
- | 'decision_gate'
-
-export function isDispatchMutationMessageType(
- type: string | undefined
-): type is DispatchMutationMessageType {
- return (
- type === 'worker_done' ||
- type === 'heartbeat' ||
- type === 'escalation' ||
- type === 'decision_gate'
- )
-}
-
-export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string {
- return `${type} messages belong to one exact Dispatch and cannot target a group address.`
-}
-
export function parseRemoteWorkerPayload(payload: string | undefined): Record {
if (!payload) {
return {}
@@ -131,73 +121,6 @@ export const SendParams = z
})
})
-export const CheckParams = z
- .object({
- terminal: OptionalString,
- terminalPaneKey: OptionalString,
- unread: OptionalBoolean,
- peek: OptionalBoolean,
- // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3).
- all: OptionalBoolean,
- types: OptionalString,
- format: OptionalBoolean,
- // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected.
- inject: OptionalBoolean,
- ack: OptionalString,
- compatibilityAck: OptionalString,
- compatibilityQuestionAck: OptionalString,
- compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(),
- run: OptionalString,
- wait: OptionalBoolean,
- timeoutMs: OptionalFiniteNumber
- })
- .superRefine((params, ctx) => {
- // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict.
- const modes = [
- params.unread === true,
- params.peek === true,
- params.all === true || (params.unread === false && params.peek !== true)
- ].filter(Boolean)
- if (modes.length > 1) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Choose at most one message read mode: --unread, --peek, or --all.'
- })
- }
- })
-
-export const ReplyParams = z.object({
- id: requiredString('Missing --id'),
- body: requiredString('Missing --body'),
- from: OptionalString,
- run: OptionalString
-})
-
-export const InboxParams = z.object({
- limit: OptionalFiniteNumber,
- // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3).
- terminal: OptionalString
-})
-
-export const TaskCreateParams = z.object({
- spec: requiredString('Missing --spec'),
- taskTitle: OptionalString,
- displayName: OptionalString,
- deps: OptionalString,
- parent: OptionalString,
- callerTerminalHandle: OptionalString,
- run: OptionalString
-})
-
-export const TaskListParams = z.object({
- status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(),
- ready: OptionalBoolean,
- // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away.
- brief: OptionalBoolean,
- run: OptionalString,
- callerTerminalHandle: OptionalString
-})
-
export const TaskUpdateParams = z.object({
id: requiredString('Missing --id'),
status: z
@@ -217,61 +140,4 @@ export const TaskUpdateParams = z.object({
run: OptionalString,
callerTerminalHandle: OptionalString
})
-
-export const DispatchParams = z.object({
- task: requiredString('Missing --task'),
- // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work.
- to: OptionalString,
- from: OptionalString,
- inject: OptionalBoolean,
- dryRun: OptionalBoolean,
- returnPreamble: OptionalBoolean,
- devMode: OptionalBoolean,
- run: OptionalString
-})
-
-export const DispatchShowParams = z.object({
- task: OptionalString,
- preamble: OptionalBoolean,
- from: OptionalString,
- devMode: OptionalBoolean
-})
-
-export const AskParams = z
- .object({
- to: OptionalString,
- question: OptionalString,
- resume: OptionalString,
- options: OptionalString,
- timeoutMs: OptionalFiniteNumber,
- from: OptionalString,
- run: OptionalString,
- compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(),
- compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional()
- })
- .superRefine((params, ctx) => {
- if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Choose exactly one of --question or --resume.'
- })
- }
- })
-
-export const ResetParams = z
- .object({
- all: OptionalBoolean,
- tasks: OptionalBoolean,
- messages: OptionalBoolean
- })
- .superRefine((params, ctx) => {
- const selectedScopeCount = [params.all, params.tasks, params.messages].filter(
- (scope) => scope === true
- ).length
- if (selectedScopeCount !== 1) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Choose exactly one reset scope: --all, --tasks, or --messages.'
- })
- }
- })
+export type { DispatchMutationMessageType } from '../../../../../shared/rpc-contract/orchestration-params'
diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts
index cf08ce31f69..3e27e12eba0 100644
--- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts
+++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts
@@ -1,9 +1,6 @@
-import { z } from 'zod'
-import { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../../../shared/orchestration-worker-output'
import { contextOnlyAbandonWarning } from '../../../../orchestration/context-only-dispatch-release'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import { defineMethod, type RpcMethod } from '../../../core'
-import { OptionalFiniteNumber, requiredString } from '../../../schemas'
import {
exposeDispatchContext,
exposeObservation,
@@ -20,12 +17,10 @@ import { readExactWorkerOutput } from './worker-output'
import { exposeWorkerTerminalResource } from './worker-release-completion'
import { readFederatedWorkerOutput } from '../federation/federated-worker-read'
import { showFederatedWorker } from '../federation/federated-worker-show'
-const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') })
-const WorkerReadParams = WorkerDispatchParams.extend({
- cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(),
- limit: OptionalFiniteNumber,
- source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional()
-})
+import {
+ WorkerDispatchParams,
+ WorkerReadParams
+} from '../../../../../../shared/rpc-contract/orchestration-worker-control-params'
export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts
index 52310a2fd9b..66eaf2263f9 100644
--- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts
+++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release-schemas.ts
@@ -1,24 +1,6 @@
-import { z } from 'zod'
-import { ORCHESTRATION_FLEET_PAGE_MAX } from '../../../../../../shared/orchestration-fleet-projection'
-import { requiredString } from '../../../schemas'
-
-export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') })
-export const WorkerRetainParams = WorkerDispatchParams.strict()
-
-export const WORKER_TERMINAL_LIST_STATES = [
- 'active',
- 'reclaimable',
- 'retained',
- 'release_pending',
- 'release_unknown',
- 'released'
-] as const
-
-export const WorkerListParams = z.object({
- run: z.string().min(1).optional(),
- terminalState: z.enum(WORKER_TERMINAL_LIST_STATES).optional(),
- cursor: z.string().min(1).max(2_048).optional(),
- limit: z.number().int().min(1).max(ORCHESTRATION_FLEET_PAGE_MAX).optional(),
- includeRemote: z.boolean().optional(),
- paginate: z.boolean().optional()
-})
+export {
+ WORKER_TERMINAL_LIST_STATES,
+ WorkerDispatchParams,
+ WorkerListParams,
+ WorkerRetainParams
+} from '../../../../../../shared/rpc-contract/orchestration-worker-release-schemas-params'
diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts
index e121f1b3f25..236dc7cf76f 100644
--- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts
+++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts
@@ -1,4 +1,3 @@
-import { z } from 'zod'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import { defineMethod, type RpcMethod } from '../../../core'
import { releaseFederatedWorker } from '../federation/federated-worker-release'
@@ -10,6 +9,7 @@ import {
type WorkerReleaseReceipt
} from './worker-release-completion'
import { WorkerDispatchParams, WorkerRetainParams } from './worker-release-schemas'
+import { OrchestrationWorkerTerminalUserInputParams } from '../../../../../../shared/rpc-contract/orchestration-worker-release-params'
export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [
defineMethod({
@@ -135,16 +135,7 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [
// `sessionId` addresses a worker that IS a structured agent session. Its pane key is a random
// identity credential that never leaves main, so the caller names the session and the owning
// runtime resolves it — a renderer echoing the pane key back would make it learnable.
- params: z
- .object({
- paneKey: z.string().min(1).optional(),
- sessionId: z.string().min(1).optional(),
- terminal: z.string().min(1).optional()
- })
- .refine(
- (value) => Boolean(value.paneKey ?? value.sessionId ?? value.terminal),
- 'Missing paneKey, sessionId or terminal'
- ),
+ params: OrchestrationWorkerTerminalUserInputParams,
// Real user keystrokes durably relinquish orchestration ownership on the owning runtime, so
// restarts, SSH drops, remote viewing, and renderer remounts cannot erase the takeover.
handler: (params, { runtime }) => {
diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts
index 2f9d9456609..b0f5328801d 100644
--- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts
+++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-schema.ts
@@ -1,63 +1,6 @@
-import { z } from 'zod'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../../../schemas'
-
-export const OptionalWorkerLaunchPreference = z
- .string()
- .min(1)
- .max(512)
- .refine((value) => value === value.trim(), 'Surrounding whitespace is invalid')
- .optional()
-
-export const WorkerStartParams = z
- .object({
- task: OptionalString,
- spec: OptionalString,
- taskTitle: OptionalString,
- deps: OptionalString,
- parent: OptionalString,
- on: OptionalString,
- run: OptionalString,
- from: requiredString('Missing --from'),
- worktree: OptionalString,
- name: OptionalString,
- repo: OptionalString,
- baseBranch: OptionalString,
- displayName: OptionalString,
- comment: OptionalString,
- setup: z.enum(['run', 'skip', 'inherit']).optional(),
- terminal: OptionalString,
- agent: OptionalString,
- model: OptionalWorkerLaunchPreference,
- effort: OptionalWorkerLaunchPreference,
- retryOf: OptionalString,
- timeoutMs: OptionalFiniteNumber,
- devMode: z.boolean().optional()
- })
- .superRefine((params, ctx) => {
- if (!params.task && !params.spec) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['task'],
- message: 'Missing --task or --spec'
- })
- }
- if (params.task && params.spec) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['spec'],
- message: '--task and --spec are mutually exclusive'
- })
- }
- // Why: --spec creates a new Task, so a retry link to a prior Dispatch could never resolve and
- // the refusal named a Task id the caller never supplied.
- if (params.retryOf && params.spec) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['retryOf'],
- message:
- '--retry-of needs --task naming the failed Task; --spec creates a new one'
- })
- }
- })
+import type { z } from 'zod'
+import { WorkerStartParams } from '../../../../../../shared/rpc-contract/orchestration-worker-start-params'
+export { OptionalWorkerLaunchPreference } from '../../../../../../shared/rpc-contract/orchestration-worker-start-params'
+export { WorkerStartParams }
export type WorkerStartInput = z.infer
diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts
index 643323cf62e..79a51ca506b 100644
--- a/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts
+++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-stop.ts
@@ -1,7 +1,5 @@
-import { z } from 'zod'
import { OrchestrationError } from '../../../../orchestration/orchestration-error'
import { defineMethod, type RpcMethod } from '../../../core'
-import { requiredString } from '../../../schemas'
import { describeUnconfirmedAgentStop } from '../../../../../../shared/pty-liveness-verdict'
import { ORCHESTRATION_WORKER_STOP_VERDICT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version'
import type { RuntimeStatus } from '../../../../../../shared/runtime-types'
@@ -12,8 +10,7 @@ import {
stopStructuredWorker
} from '../../orchestration-structured-worker-lifecycle'
import { isStructuredWorkerHandle } from '../../../../structured-worker-identity'
-
-const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') })
+import { WorkerDispatchParams } from '../../../../../../shared/rpc-contract/orchestration-worker-stop-params'
export const ORCHESTRATION_WORKER_STOP_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts
index bb035b984eb..4d1e8d597ca 100644
--- a/src/main/runtime/rpc/methods/plugins.ts
+++ b/src/main/runtime/rpc/methods/plugins.ts
@@ -1,4 +1,3 @@
-import { z } from 'zod'
import { defineMethod, type RpcContext, type RpcMethod } from '../core'
import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge'
import { listPluginsForClients } from '../../../plugins/plugin-client-list'
@@ -8,7 +7,12 @@ import {
pluginConsentRequestSchema,
type PluginConsentRequest
} from '../../../../shared/plugins/plugin-consent-request'
-import { isQualifiedPluginKey } from '../../../../shared/plugins/plugin-manifest'
+import {
+ PluginInvokeCommandParams,
+ PluginReadPanelEntryParams,
+ PluginSetEnabledParams,
+ PluginsPanelActionParams
+} from '../../../../shared/rpc-contract/plugins-params'
/**
* Serve/headless parity surface: the same consent, enablement, panel-action,
@@ -45,22 +49,6 @@ function requirePluginService(): PluginService {
return pluginServiceForRpc
}
-const PluginSetEnabledParams = z.object({
- pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
- enabled: z.boolean()
-})
-
-const PluginReadPanelEntryParams = z.object({
- pluginKey: z.string().min(1),
- panelId: z.string().min(1)
-})
-
-const PluginInvokeCommandParams = z.object({
- pluginKey: z.string().min(1),
- commandId: z.string().min(1),
- args: z.unknown().optional()
-})
-
async function listForRpc(): Promise {
return listPluginsForClients(requirePluginService())
}
@@ -118,7 +106,7 @@ export const PLUGIN_METHODS: readonly RpcMethod[] = [
name: 'plugins.panelAction',
// Why: raw admission must run before strict schema parsing so malformed
// and oversized traffic cannot bypass the panel budget.
- params: z.unknown(),
+ params: PluginsPanelActionParams,
handler: async (params, context) => {
const service = requirePluginService()
await service.whenReady()
diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts
index f863a1941d1..9a5af8776f1 100644
--- a/src/main/runtime/rpc/methods/preflight.ts
+++ b/src/main/runtime/rpc/methods/preflight.ts
@@ -1,4 +1,3 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import {
detectRemoteAgents,
@@ -7,16 +6,11 @@ import {
refreshShellPathAndDetectAgents,
runPreflightCheck
} from '../../../preflight/agent-detection'
-
-const PreflightCheck = z.object({
- force: z.boolean().optional()
-})
-const PreflightDetectRemoteAgents = z.object({
- connectionId: z.string().min(1)
-})
-const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({
- connectionId: z.string().min(1)
-})
+import {
+ PreflightCheck,
+ PreflightDetectRemoteAgents,
+ PreflightDetectRemoteWindowsTerminalCapabilities
+} from '../../../../shared/rpc-contract/preflight-params'
export const PREFLIGHT_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts
index c25671d5bed..2ef04798813 100644
--- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts
+++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts
@@ -1,100 +1,13 @@
-import { z } from 'zod'
-import {
- LOCAL_EXECUTION_HOST_ID,
- normalizeExecutionHostId,
- parseExecutionHostId
-} from '../../../../shared/execution-host'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalString, requiredString } from '../schemas'
import { projectRepoResultVisibilityForClient } from '../repo-visibility-projection'
-
-const ProjectProviderIdentity = z.object({
- provider: z.literal('github'),
- owner: requiredString('Missing project owner'),
- repo: requiredString('Missing project repository'),
- host: OptionalString
-})
-
-// Why: `runtime:` ids are minted by the calling client's own pairing store
-// (addEnvironmentFromPairingCode -> randomUUID), so they name a machine only relative to that
-// client. A client sending one to this runtime is addressing *us*, and runtimes do not proxy
-// these calls onward, so the host it names is this machine. Persisting the caller's id verbatim
-// makes one machine look like a different host to every other client, hides its rows from them,
-// and defeats the (projectId, hostId) duplicate check. Store our own spelling instead: `local`.
-// Rows written before this normalization keep their client-minted stamp; readers still project
-// `local` back to `runtime:`, so the client-visible model is unchanged.
-const RequestedHostId = requiredString('Missing host ID').transform((value, ctx) => {
- const hostId = normalizeExecutionHostId(value)
- if (!hostId) {
- ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
- return z.NEVER
- }
- return parseExecutionHostId(hostId)?.kind === 'runtime' ? LOCAL_EXECUTION_HOST_ID : hostId
-})
-
-const ProjectHostSetupExistingFolder = z.object({
- projectId: requiredString('Missing project ID'),
- projectProviderIdentity: ProjectProviderIdentity.optional(),
- hostId: RequestedHostId,
- path: requiredString('Missing project path'),
- kind: z.enum(['git', 'folder']).optional(),
- displayName: OptionalString,
- setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional()
-})
-
-const ProjectHostSetupClone = z.object({
- projectId: requiredString('Missing project ID'),
- projectProviderIdentity: ProjectProviderIdentity.optional(),
- hostId: RequestedHostId,
- url: requiredString('Missing clone URL'),
- destination: requiredString('Missing clone destination'),
- displayName: OptionalString
-})
-
-const LocalWindowsRuntimePreference = z.discriminatedUnion('kind', [
- z.object({ kind: z.literal('inherit-global') }),
- z.object({ kind: z.literal('windows-host') }),
- z.object({ kind: z.literal('wsl'), distro: requiredString('Missing WSL distro') })
-])
-
-const ProjectUpdate = z.object({
- projectId: requiredString('Missing project ID'),
- updates: z.object({
- localWindowsRuntimePreference: LocalWindowsRuntimePreference.optional()
- })
-})
-
-const ProjectHostSetupCreate = z.object({
- projectId: requiredString('Missing project ID'),
- hostId: RequestedHostId,
- setupId: OptionalString,
- path: OptionalString,
- kind: z.enum(['git', 'folder']).optional(),
- displayName: OptionalString,
- worktreeBasePath: OptionalString,
- gitUsername: OptionalString,
- setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
- setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional()
-})
-
-const ProjectHostSetupUpdate = z.object({
- setupId: requiredString('Missing setup ID'),
- updates: z.object({
- displayName: OptionalString,
- path: OptionalString,
- worktreeBasePath: OptionalString,
- setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
- setupMethod: z
- .enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned'])
- .optional(),
- gitUsername: OptionalString,
- kind: z.enum(['git', 'folder']).optional()
- })
-})
-
-const ProjectHostSetupDelete = z.object({
- setupId: requiredString('Missing setup ID')
-})
+import {
+ ProjectHostSetupClone,
+ ProjectHostSetupCreate,
+ ProjectHostSetupDelete,
+ ProjectHostSetupExistingFolder,
+ ProjectHostSetupUpdate,
+ ProjectUpdate
+} from '../../../../shared/rpc-contract/project-runtime-params'
export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts
index b613b35d8ba..f4e189f84a7 100644
--- a/src/main/runtime/rpc/methods/repo-update-schema.ts
+++ b/src/main/runtime/rpc/methods/repo-update-schema.ts
@@ -1,76 +1,4 @@
-import { z } from 'zod'
-import { OptionalFiniteNumber, OptionalString } from '../schemas'
-import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
-import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
-import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
-import {
- normalizeCustomWorktreeVisibilitySources,
- normalizeWorktreeVisibilitySourcePreferences
-} from '../../../../shared/worktree/visibility-sources'
-
-export const RepoSourceControlAiOverrides = z
- .unknown()
- .optional()
- .transform((value) =>
- value === undefined
- ? undefined
- : value === null
- ? null
- : normalizeRepoSourceControlAiOverrides(value)
- )
-
-const RepoBadgeColor = z
- .unknown()
- .optional()
- .transform((value) =>
- value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined)
- )
-
-const RepoUpstream = z
- .object({
- owner: z.string().min(1),
- repo: z.string().min(1)
- })
- .nullable()
- .optional()
-
-export function createRepoUpdateSchema(
- selectorShape: T
-): z.ZodObject }> {
- return z.object({
- ...selectorShape,
- updates: z.object({
- displayName: OptionalString,
- badgeColor: RepoBadgeColor,
- repoIcon: z
- .unknown()
- .transform((value) => sanitizeRepoIcon(value))
- .optional(),
- upstream: RepoUpstream,
- hookSettings: z.unknown().optional(),
- worktreeBaseRef: OptionalString,
- worktreeBasePath: OptionalString,
- kind: z.enum(['git', 'folder']).optional(),
- symlinkPaths: z.array(z.string()).optional(),
- issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(),
- forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(),
- externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(),
- externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(),
- externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(),
- importedExternalWorktreePaths: z.array(z.string()).optional(),
- agentWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(),
- customWorktreeVisibilitySources: z
- .unknown()
- .transform((value) => normalizeCustomWorktreeVisibilitySources(value))
- .optional(),
- worktreeVisibilitySourcePreferences: z
- .unknown()
- .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value))
- .optional(),
- externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(),
- projectGroupId: OptionalString.nullable().optional(),
- projectGroupOrder: OptionalFiniteNumber,
- sourceControlAi: RepoSourceControlAiOverrides
- })
- }) as z.ZodObject }>
-}
+export {
+ RepoSourceControlAiOverrides,
+ createRepoUpdateSchema
+} from '../../../../shared/rpc-contract/repo-update-params'
diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts
index 7bf42922db8..31fc871e897 100644
--- a/src/main/runtime/rpc/methods/repo.ts
+++ b/src/main/runtime/rpc/methods/repo.ts
@@ -1,113 +1,28 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
-import { createRepoUpdateSchema } from './repo-update-schema'
+import { RepoSelector } from './github-repo-target-schemas'
import {
projectRepoResultVisibilityForClient,
projectRepoVisibilityForClient
} from '../repo-visibility-projection'
-
-const RepoSelector = z.object({
- repo: requiredString('Missing repo selector')
-})
-
-const RepoPath = z.object({
- path: requiredString('Missing repo path'),
- kind: z.enum(['git', 'folder']).optional(),
- displayName: OptionalString
-})
-
-const RepoCreate = z.object({
- parentPath: requiredString('Missing parent path'),
- name: requiredString('Missing repo name'),
- kind: z.enum(['git', 'folder']).optional()
-})
-
-const RepoClone = z.object({
- url: requiredString('Missing clone URL'),
- destination: requiredString('Missing clone destination')
-})
-
-const RepoSetBaseRef = z.object({
- repo: requiredString('Missing repo selector'),
- ref: requiredString('Missing base ref')
-})
-
-const RepoUpdate = createRepoUpdateSchema(RepoSelector.shape)
-
-const RepoSearchRefs = z.object({
- repo: requiredString('Missing repo selector'),
- query: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : undefined))
- .pipe(z.string({ message: 'Missing query' })),
- limit: OptionalFiniteNumber
-})
-
-const RepoReorder = z.object({
- orderedIds: z.array(z.string())
-})
-
-const ProjectGroupCreate = z.object({
- name: requiredString('Missing group name'),
- parentPath: OptionalString,
- connectionId: OptionalString.nullable().optional(),
- parentGroupId: OptionalString.nullable().optional(),
- createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
-})
-
-const ProjectGroupUpdate = z.object({
- groupId: requiredString('Missing group id'),
- updates: z.object({
- name: OptionalString,
- isCollapsed: z.boolean().optional(),
- tabOrder: OptionalFiniteNumber,
- color: OptionalString.nullable().optional()
- })
-})
-
-const ProjectGroupSelector = z.object({
- groupId: requiredString('Missing group id')
-})
-
-const ProjectGroupMoveProject = z.object({
- repo: requiredString('Missing repo selector'),
- groupId: OptionalString.nullable(),
- order: OptionalFiniteNumber
-})
-
-const ProjectGroupScanNested = z.object({
- path: requiredString('Missing folder path')
-})
-
-const ProjectGroupImportNested = z.discriminatedUnion('mode', [
- z.object({
- parentPath: requiredString('Missing parent path'),
- groupName: z.string().optional().default(''),
- projectPaths: z.array(z.string()),
- mode: z.literal('group')
- }),
- z.object({
- parentPath: requiredString('Missing parent path'),
- // Why: blank group names fall back to the scanned folder basename; separate
- // imports do not create a group but share the same renderer payload shape.
- groupName: z.string().optional().default(''),
- projectPaths: z.array(z.string()),
- mode: z.literal('separate')
- })
-])
-
-const RepoIssueCommandWrite = RepoSelector.extend({
- content: z.string()
-})
-
-const RepoSparsePresetSave = RepoSelector.extend({
- id: OptionalString,
- name: requiredString('Missing preset name'),
- directories: z.array(z.string())
-})
+import {
+ ProjectGroupCreate,
+ ProjectGroupImportNested,
+ ProjectGroupMoveProject,
+ ProjectGroupScanNested,
+ ProjectGroupSelector,
+ ProjectGroupUpdate,
+ RepoClone,
+ RepoCreate,
+ RepoIssueCommandWrite,
+ RepoPath,
+ RepoReorder,
+ RepoSearchRefs,
+ RepoSetBaseRef,
+ RepoSparsePresetSave,
+ RepoUpdate
+} from '../../../../shared/rpc-contract/repo-params'
export const REPO_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts
index a1ab53267b3..fe152fa6f27 100644
--- a/src/main/runtime/rpc/methods/runtime-client-capabilities.ts
+++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.ts
@@ -1,12 +1,6 @@
-import { z } from 'zod'
import type { RuntimeCapability } from '../../../../shared/protocol-version'
import { defineMethod, type RpcAnyMethod } from '../core'
-
-const ClientCapabilitiesUpdate = z
- .object({
- clientCapabilities: z.array(z.string().min(1).max(128)).max(64)
- })
- .strict()
+import { ClientCapabilitiesUpdate } from '../../../../shared/rpc-contract/runtime-client-capabilities-params'
export const RUNTIME_CLIENT_CAPABILITY_METHODS: RpcAnyMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts
index 1f44e17ea0b..ecd434f1e0a 100644
--- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts
+++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts
@@ -1,229 +1,14 @@
-import { z } from 'zod'
-import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../../../../shared/terminal-quick-commands'
-import { isTuiAgent } from '../../../../shared/tui-agent-config'
-import type { TuiAgent } from '../../../../shared/tui-agent'
-import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents'
-import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation'
-import { TAB_ACTIVATION_INTENTS } from '../../../../shared/tab-activation-intent'
-import { OptionalBoolean } from '../schemas'
-
-export const WorktreeTabSelector = z.object({
- worktree: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing worktree selector'))
-})
-
-export const SessionTabsUnsubscribe = WorktreeTabSelector.extend({
- subscriptionId: z.string().min(1).optional()
-})
-
-export const ActivateTab = WorktreeTabSelector.extend({
- tabId: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing tab id')),
- leafId: z.string().max(128).optional(),
- notifyClients: OptionalBoolean,
- navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
- // Why: absent means user intent, so clients that predate this field keep the
- // tab-open wake gesture. Only 'automatic' may be refused for a slept pane.
- intent: z.enum(TAB_ACTIVATION_INTENTS).optional()
-})
-
-export const CloseTab = ActivateTab.extend({
- // Why: optional preserves authenticated legacy user closes; lifecycle intent
- // uses the additive evidence-bearing method instead.
- reason: z.literal('user').optional()
-})
-
-export const CloseLifecycleTab = ActivateTab.extend({
- reason: z.enum(['pty-exit', 'cleanup']),
- publicationEpoch: z.string().min(1).max(128),
- terminal: z.string().min(1).max(256)
-})
-
-export type TerminalPaneLayoutNodeInput =
- | { type: 'leaf'; leafId: string }
- | {
- type: 'split'
- direction: 'horizontal' | 'vertical'
- first: TerminalPaneLayoutNodeInput
- second: TerminalPaneLayoutNodeInput
- ratio?: number
- }
-
-// Why: this schema parses UNTRUSTED remote-client input. A recursive zod parse
-// of a deeply-nested tree would overflow the main-process stack, so validate
-// iteratively with hard depth + node-count caps before building the typed value.
-const MAX_PANE_LAYOUT_DEPTH = 64
-const MAX_PANE_LAYOUT_NODES = 1024
-
-function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInput | null {
- // Iterative validate-then-build: first walk the raw tree with an explicit
- // stack (no recursion) enforcing caps, then build bottom-up.
- let nodeCount = 0
- const stack: { raw: unknown; depth: number }[] = [{ raw: value, depth: 0 }]
- while (stack.length > 0) {
- const { raw, depth } = stack.pop()!
- if (depth > MAX_PANE_LAYOUT_DEPTH || ++nodeCount > MAX_PANE_LAYOUT_NODES) {
- return null
- }
- if (typeof raw !== 'object' || raw === null) {
- return null
- }
- const node = raw as Record
- if (node.type === 'leaf') {
- if (typeof node.leafId !== 'string' || node.leafId.length < 1 || node.leafId.length > 128) {
- return null
- }
- continue
- }
- if (node.type === 'split') {
- if (node.direction !== 'horizontal' && node.direction !== 'vertical') {
- return null
- }
- if (
- node.ratio !== undefined &&
- (typeof node.ratio !== 'number' ||
- !Number.isFinite(node.ratio) ||
- node.ratio < 0 ||
- node.ratio > 1)
- ) {
- return null
- }
- stack.push({ raw: node.first, depth: depth + 1 }, { raw: node.second, depth: depth + 1 })
- continue
- }
- return null
- }
- return value as TerminalPaneLayoutNodeInput
-}
-
-export const TerminalPaneLayoutNodeSchema = z
- .unknown()
- .transform((value) => parseTerminalPaneLayoutNode(value))
- .pipe(
- z.custom((value) => value !== null, {
- message: 'Invalid or too-deep pane layout tree'
- })
- )
-
-export const UpdatePaneLayout = WorktreeTabSelector.extend({
- tabId: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing tab id')),
- root: z.union([z.null(), TerminalPaneLayoutNodeSchema]),
- expandedLeafId: z.string().max(128).nullable().optional(),
- titlesByLeafId: z.record(z.string(), z.string()).optional()
-})
-
-export const SetTabProps = WorktreeTabSelector.extend({
- tabId: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing tab id')),
- // undefined = leave unchanged; null = clear color / unset.
- color: z.string().max(64).nullable().optional(),
- isPinned: z.boolean().optional(),
- // undefined = leave unchanged; no "clear" semantic (absence means default 'terminal').
- viewMode: z.enum(['terminal', 'chat']).optional()
-})
-
-export const CreateTerminalTab = WorktreeTabSelector.extend({
- afterTabId: z.string().optional(),
- targetGroupId: z.string().optional(),
- command: z.string().optional(),
- cwd: z.string().min(1).optional(),
- env: z.record(z.string(), z.string()).optional(),
- envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(),
- startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
- launchConfig: sleepingAgentLaunchConfigSchema,
- launchToken: z.string().min(1).max(128).optional(),
- agent: z
- .custom(isTuiAgent, {
- message: 'Unknown agent preset'
- })
- .optional(),
- // Why: agent prompts must be quoted and injected for the host shell (native,
- // WSL, or SSH) instead of pasted from the mobile client before the TUI is ready.
- agentPrompt: z
- .string()
- .max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH)
- .refine((value) => value.trim().length > 0, { message: 'Agent prompt cannot be empty' })
- .optional(),
- // Why: `agent` is the legacy preset field; `launchAgent` is the launch-plan
- // identity used when preserving resume config across runtime boundaries.
- launchAgent: z
- .custom(isTuiAgent, {
- message: 'Unknown launch agent'
- })
- .optional(),
- viewMode: z.enum(['terminal', 'chat']).optional(),
- activate: z.boolean().optional(),
- select: z.boolean().optional(),
- navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
- // Why: idempotency key so a retried create (double-tap, reconnect replay)
- // returns the in-flight operation instead of spawning a duplicate terminal.
- clientMutationId: z.string().min(1).max(128).optional()
-}).superRefine((value, context) => {
- if (value.agentPrompt !== undefined && value.agent === undefined) {
- context.addIssue({
- code: 'custom',
- path: ['agentPrompt'],
- message: 'Agent prompt requires an agent preset'
- })
- }
- if (value.agentPrompt !== undefined && value.command !== undefined) {
- context.addIssue({
- code: 'custom',
- path: ['agentPrompt'],
- message: 'Agent prompt cannot be combined with a startup command'
- })
- }
-})
-
-const MoveTabBase = {
- worktree: WorktreeTabSelector.shape.worktree,
- tabId: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing tab id')),
- targetGroupId: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing target group id'))
-} as const
-
-export const MoveTab = z.discriminatedUnion('kind', [
- z
- .object({
- ...MoveTabBase,
- kind: z.literal('reorder'),
- tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order')
- })
- .strict(),
- z
- .object({
- ...MoveTabBase,
- kind: z.literal('move-to-group'),
- index: z.number().int().nonnegative().optional()
- })
- .strict(),
- z
- .object({
- ...MoveTabBase,
- kind: z.literal('split'),
- splitDirection: z.enum(['left', 'right', 'up', 'down'])
- })
- .strict()
-])
-
-export const SaveMarkdownTab = ActivateTab.extend({
- baseVersion: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing base version')),
- content: z.string()
-})
+export {
+ ActivateTab,
+ CloseLifecycleTab,
+ CloseTab,
+ CreateTerminalTab,
+ MoveTab,
+ SaveMarkdownTab,
+ SessionTabsUnsubscribe,
+ SetTabProps,
+ TerminalPaneLayoutNodeSchema,
+ UpdatePaneLayout,
+ WorktreeTabSelector
+} from '../../../../shared/rpc-contract/session-tabs-schemas-params'
+export type { TerminalPaneLayoutNodeInput } from '../../../../shared/rpc-contract/session-tabs-schemas-params'
diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts
index aa0e0b24939..1f4019bad7c 100644
--- a/src/main/runtime/rpc/methods/session-tabs.ts
+++ b/src/main/runtime/rpc/methods/session-tabs.ts
@@ -1,4 +1,3 @@
-import { z } from 'zod'
import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import {
@@ -18,6 +17,7 @@ import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { isStructuredNativeChatEnabled } from './structured-agent-session-policy'
import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership'
+import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params'
export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
defineMethod({
@@ -181,11 +181,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
}),
defineMethod({
name: 'session.tabs.unsubscribeAll',
- params: z
- .object({
- subscriptionId: z.string().min(1).optional()
- })
- .nullish(),
+ params: SessionTabsUnsubscribeAllParams,
handler: async (params, { runtime, connectionId }) => {
const cleanupPrefix = `session.tabs:${connectionId ?? 'local'}:*`
if (params?.subscriptionId) {
diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts
index 13ecbefc3c7..01caa64baa6 100644
--- a/src/main/runtime/rpc/methods/skills.ts
+++ b/src/main/runtime/rpc/methods/skills.ts
@@ -1,5 +1,5 @@
import { defineMethod, type RpcMethod } from '../core'
-import { z } from 'zod'
+import type { z } from 'zod'
import { getAppEnvironment } from '../../../../shared/app-environment'
import { SkillDeleteRequestSchema } from '../../../../shared/skill-delete-contract'
import {
@@ -7,7 +7,7 @@ import {
runSkillDeleteRequest,
type SkillDeleteRequestDependencies
} from '../../../skills/skill-delete/request-service'
-import { SkillDiscoveryTargetSchema } from '../../../../shared/skills'
+import type { SkillDiscoveryTargetSchema } from '../../../../shared/skills'
import {
SkillInstallPreviewRequestSchema,
SkillInstallRequestSchema,
@@ -33,6 +33,11 @@ import {
AgentSkillShareRequestSchema,
AgentSkillSharingError
} from '../../../../shared/agent-skill-sharing-contract'
+import {
+ SkillsCancelInstallParams,
+ SkillsDiscoverParams,
+ SkillsGetInstallProgressParams
+} from '../../../../shared/rpc-contract/skills-params'
/** Exported so the delete plan's root rebuild resolves its target exactly the
* way `skills.discover` resolved the scan's — including WSL. */
@@ -62,7 +67,7 @@ function skillDeleteDependencies(
export const SKILL_METHODS: RpcMethod[] = [
defineMethod({
name: 'skills.discover',
- params: SkillDiscoveryTargetSchema.default({}),
+ params: SkillsDiscoverParams,
handler: async (params, { runtime }) => {
// Why: the executing runtime owns WSL project preferences. Remote callers
// send worktree identity only; trusting their projectRuntime absence
@@ -146,14 +151,14 @@ export const SKILL_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'skills.cancelInstall',
- params: z.object({ operationId: z.string().min(1).max(128) }).strict(),
+ params: SkillsCancelInstallParams,
handler: (params, { runtime }) => ({
cancelled: runtime.cancelSharedSkillInstall(params.operationId)
})
}),
defineMethod({
name: 'skills.getInstallProgress',
- params: z.object({ operationId: z.string().min(1).max(128) }).strict(),
+ params: SkillsGetInstallProgressParams,
handler: (params, { runtime }) => {
const progress = runtime.getSharedSkillInstallProgress(params.operationId)
return progress ? SkillBundleInstallProgressSchema.parse(progress) : null
diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts
index d686475ab3f..8e5e8bdbd4d 100644
--- a/src/main/runtime/rpc/methods/speech.ts
+++ b/src/main/runtime/rpc/methods/speech.ts
@@ -1,52 +1,11 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalString, requiredString } from '../schemas'
-
-const AUDIO_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/
-const DICTATION_SAMPLE_RATE = 16_000
-const PCM_BYTES_PER_SAMPLE = 2
-const MAX_DICTATION_AUDIO_SECONDS = 5
-const MAX_DICTATION_AUDIO_CHUNK_BYTES =
- DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS
-const MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH = Math.ceil(MAX_DICTATION_AUDIO_CHUNK_BYTES / 3) * 4
-
-function isValidAudioBase64(value: string): boolean {
- return value.length % 4 !== 1 && AUDIO_BASE64_PATTERN.test(value)
-}
-
-const DictationStart = z.object({
- dictationId: requiredString('Missing dictation ID'),
- modelId: OptionalString
-})
-
-const DictationChunk = z.object({
- dictationId: requiredString('Missing dictation ID'),
- audioBase64: requiredString('Missing audio chunk')
- // Why: feedMobileDictation decodes into Buffer + Float32Array; reject
- // oversized chunks before allocation. This mirrors the mobile pending-audio budget.
- .refine(
- (value) => value.length <= MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH,
- 'Audio chunk is too large'
- )
- // Why: Buffer.from(..., 'base64') silently drops malformed bytes; reject
- // bad mobile audio chunks instead of feeding empty/corrupt PCM.
- .refine(isValidAudioBase64, 'Audio chunk must be base64'),
- sampleRate: z.number().finite().positive()
-})
-
-const DictationHandle = z.object({
- dictationId: requiredString('Missing dictation ID')
-})
-
-const SpeechModelAction = z.object({
- modelId: requiredString('Missing model ID')
-})
-
-const DictationSetup = z.object({
- enabled: z.boolean().optional(),
- modelId: OptionalString,
- dictationMode: z.enum(['toggle', 'hold']).optional()
-})
+import {
+ DictationChunk,
+ DictationHandle,
+ DictationSetup,
+ DictationStart,
+ SpeechModelAction
+} from '../../../../shared/rpc-contract/speech-params'
export const SPEECH_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts
index e6cb6b47b50..8988ab3a569 100644
--- a/src/main/runtime/rpc/methods/ssh.ts
+++ b/src/main/runtime/rpc/methods/ssh.ts
@@ -1,4 +1,3 @@
-import { z } from 'zod'
import {
connectRegisteredSshTarget,
getRegisteredSshState,
@@ -8,10 +7,7 @@ import {
import { defineMethod, type RpcMethod } from '../core'
import { getPublicSshError, getPublicSshState } from '../../public-ssh-state'
import type { SshTargetSummary } from '../../../../shared/ssh-types'
-
-const SshTarget = z.object({
- targetId: z.string().min(1)
-})
+import { SshTarget } from '../../../../shared/rpc-contract/ssh-params'
// Why: `generation` stays optional on the wire — an old server simply omits it and its rows key on target id alone.
function listRegisteredSshTargetSummaries(): SshTargetSummary[] {
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts
index 5c7f40d7f35..7725e70bded 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts
@@ -2,246 +2,25 @@
//
// Strict objects throughout: zod drops unknown keys, and a silently dropped key
// is how a newer client's field becomes a different effect on an older host.
-
-import { z } from 'zod'
-import { isAgentSessionId } from '../../../../shared/agent-session-record'
-import {
- AGENT_SESSION_HISTORY_DIRECTIONS,
- AGENT_SESSION_HISTORY_MAX_LIMIT
-} from '../../../../shared/agent-session-wire'
-import { normalizeExecutionHostId } from '../../../../shared/execution-host'
-
-const MAX_ID_LENGTH = 512
-// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded.
-const MAX_RESPONSE_OPTION_ID_LENGTH = 1024
-const MAX_PROMPT_BYTES = 256 * 1024
-const MAX_BLOCKS = 64
-const MAX_OPTION_LABEL = 512
-
-export const SessionId = z
- .string()
- .max(MAX_ID_LENGTH)
- .refine(isAgentSessionId, 'Invalid agent session id')
-
-const Identifier = (message: string, maxLength = MAX_ID_LENGTH) =>
- z
- .string()
- .min(1, message)
- .max(maxLength, message)
- .refine((value) => value === value.trim(), message)
-
-export const JournalCursor = z
- .object({
- epoch: Identifier('Invalid journal epoch'),
- sequence: z.number().int().nonnegative()
- })
- .strict()
-
-export const MutationEnvelope = z
- .object({
- sessionId: SessionId,
- clientOperationId: Identifier('Invalid client operation id'),
- /** Null is the "must not exist yet" case; every other call fences. */
- expectedRuntimeFence: z.number().int().positive().nullable(),
- payloadFingerprint: z
- .string()
- .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest')
- })
- .strict()
-
-const ProviderHandle = z.discriminatedUnion('kind', [
- z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(),
- z
- .object({
- kind: z.literal('claude'),
- sessionId: Identifier('Invalid provider session id'),
- leafUuid: Identifier('Invalid leaf uuid').nullable()
- })
- .strict()
-])
-
-const ExecutionHostId = z
- .string()
- .max(MAX_ID_LENGTH)
- .transform((value) => normalizeExecutionHostId(value))
- .refine((value): value is NonNullable => value !== null, {
- message: 'Invalid execution host id'
- })
-
-const ExecutionLocation = z
- .object({
- executionHostId: ExecutionHostId,
- wslDistro: Identifier('Invalid WSL distro').nullable(),
- workspaceId: Identifier('Invalid workspace id'),
- workspaceKind: z.enum(['git-worktree', 'folder'])
- })
- .strict()
-
-const AccountHome = z
- .object({
- variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']),
- path: z.string().min(1).max(4096)
- })
- .strict()
-
-export const AttachParams = z
- .object({
- envelope: MutationEnvelope,
- location: ExecutionLocation,
- provider: z.enum(['codex', 'claude']),
- agent: Identifier('Invalid agent'),
- accountHome: AccountHome,
- runtimeKind: z.enum(['native', 'tui']),
- providerHandle: ProviderHandle
- })
- .strict()
-
-/** An identity, and nothing the host would otherwise read off disk. A transcript path or account
- * home here would let a client choose which file this host imports and which credential directory
- * the provider child launches against; both are derived host-side from this id instead. */
-const ResumeSource = z
- .object({
- providerSessionId: Identifier('Invalid provider session id')
- })
- .strict()
-
-export const CreateIntentParams = z
- .object({
- envelope: MutationEnvelope,
- worktree: Identifier('Invalid worktree selector'),
- agent: z.enum(['claude', 'codex']),
- resumeFrom: ResumeSource.optional()
- })
- .strict()
-
-export const CreateParams = z.union([AttachParams, CreateIntentParams])
-
-export const CreateSupportParams = z
- .object({
- worktree: Identifier('Invalid worktree selector'),
- agent: z.enum(['claude', 'codex'])
- })
- .strict()
-
-/** Clients may only author user turns. Accepting an assistant or tool role here
- * would let one client write words into the agent's mouth in another's
- * timeline, and the provider — not the client — owns those. */
-const SendBlock = z.discriminatedUnion('type', [
- z.object({ type: z.literal('text'), text: z.string() }).strict(),
- z
- .object({
- type: z.literal('image-ref'),
- path: z.string().min(1).max(4096).optional(),
- url: z.string().min(1).max(4096).optional(),
- alt: z.string().max(MAX_OPTION_LABEL).optional()
- })
- .strict()
- .refine(
- (value) => Boolean(value.path) !== Boolean(value.url),
- 'Provide exactly one of path/url'
- )
-])
-
-export const SendParams = z
- .object({
- envelope: MutationEnvelope,
- retryUnknown: z.literal(true).optional(),
- body: z
- .object({
- kind: z.literal('message'),
- role: z.literal('user'),
- blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS)
- })
- .strict()
- .refine(
- (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES,
- 'Message is too large'
- )
- })
- .strict()
-
-export const CancelParams = z
- .object({
- envelope: MutationEnvelope,
- turnId: Identifier('Invalid turn id'),
- scope: z.literal('background-tasks').optional(),
- taskId: Identifier('Invalid task id').optional()
- })
- .strict()
- .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', {
- message: 'A task id requires background-task scope'
- })
-
-export const RespondParams = z
- .object({
- envelope: MutationEnvelope,
- itemId: Identifier('Invalid item id'),
- /** Compare-and-set: the revision the client had on screen. */
- expectedRevision: z.number().int().positive(),
- optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH)
- })
- .strict()
-
-export const SetOptionParams = z
- .object({
- envelope: MutationEnvelope,
- key: Identifier('Invalid option key'),
- value: z.string().max(MAX_OPTION_LABEL)
- })
- .strict()
-
-export const HandoffParams = z
- .object({
- envelope: MutationEnvelope,
- direction: z.enum(['to-tui', 'to-native']),
- mode: z.enum(['now', 'after-turn', 'stop-turn']),
- action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional()
- })
- .strict()
-
-export const OptionsParams = z.object({ sessionId: SessionId }).strict()
-
-export const ConversationCommandParams = z
- .object({
- envelope: MutationEnvelope,
- command: z.enum(['clear', 'compact'])
- })
- .strict()
-
-/** One surface's claim on one session. The id names the surface, not the client: two chat views
- * looking at the same session are two holders, and either leaving must not release
- * the other's. */
-export const HoldParams = z
- .object({ sessionId: SessionId, holderId: Identifier('Invalid holder id') })
- .strict()
-
-export const HistoryParams = z
- .object({
- sessionId: SessionId,
- direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS),
- cursor: JournalCursor.optional(),
- limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional()
- })
- .strict()
-
-export const SubscribeParams = z
- .object({ sessionId: SessionId, cursor: JournalCursor.optional() })
- .strict()
-
-export const UnsubscribeParams = z
- .object({
- sessionId: SessionId,
- subscriptionId: Identifier('Invalid subscription id').optional()
- })
- .strict()
-
-/** Read-only owner classification retained for restart safety; mutation handoff is separate. */
-export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict()
-
-export const RewindParams = z
- .object({
- envelope: MutationEnvelope,
- itemId: Identifier('Invalid item id', 4096),
- expectedEpoch: Identifier('Invalid journal epoch')
- })
- .strict()
+export {
+ AttachParams,
+ CancelParams,
+ ConversationCommandParams,
+ CreateIntentParams,
+ CreateParams,
+ CreateSupportParams,
+ HandoffParams,
+ HandoffStatusParams,
+ HistoryParams,
+ HoldParams,
+ JournalCursor,
+ MutationEnvelope,
+ OptionsParams,
+ RespondParams,
+ RewindParams,
+ SendParams,
+ SessionId,
+ SetOptionParams,
+ SubscribeParams,
+ UnsubscribeParams
+} from '../../../../shared/rpc-contract/structured-agent-session-params'
diff --git a/src/main/runtime/rpc/methods/task-resume-state-schema.ts b/src/main/runtime/rpc/methods/task-resume-state-schema.ts
index fc85cad6270..f923d3993b3 100644
--- a/src/main/runtime/rpc/methods/task-resume-state-schema.ts
+++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts
@@ -1,37 +1,8 @@
-import { z } from 'zod'
+import type { z } from 'zod'
import type { TaskResumeState as TaskResumeStateType } from '../../../../shared/ui-chrome-types'
import type { AssertNoMissingKeys } from './ui-state-schema-parity'
-
-/**
- * Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`.
- *
- * This object is `.strict()` and sits behind `ui.set`'s field-level `.catch`, so a key
- * a host predates makes that host drop the ENTIRE resume state — github and jira with
- * it — and report success. Only add a field here when clients must agree on it across
- * versions; per-device view preferences belong in client-local storage instead.
- */
-export const TaskResumeState = z
- .object({
- githubMode: z.enum(['items', 'project']).optional(),
- githubItemsPreset: z.string().nullable().optional(),
- githubItemsQuery: z.string().optional(),
- githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(),
- linearMode: z.enum(['issues', 'projects', 'views', 'in-orca']).optional(),
- linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(),
- linearQuery: z.string().optional(),
- linearContext: z
- .object({
- kind: z.enum(['project', 'view']),
- id: z.string(),
- workspaceId: z.string(),
- model: z.enum(['issue', 'project']).optional()
- })
- .strict()
- .optional(),
- jiraPreset: z.enum(['assigned', 'reported', 'all', 'done']).optional(),
- jiraQuery: z.string().optional()
- })
- .strict()
+import { TaskResumeState } from '../../../../shared/rpc-contract/task-resume-state-params'
+export { TaskResumeState }
const _taskResumeStateParity: AssertNoMissingKeys<
TaskResumeStateType,
diff --git a/src/main/runtime/rpc/methods/terminal-orphan.ts b/src/main/runtime/rpc/methods/terminal-orphan.ts
index 97296d0fac5..7ca7cd771b1 100644
--- a/src/main/runtime/rpc/methods/terminal-orphan.ts
+++ b/src/main/runtime/rpc/methods/terminal-orphan.ts
@@ -1,108 +1,5 @@
-import { z } from 'zod'
-import type { TabGroupLayoutNode } from '../../../../shared/tab-types'
-import { isPtyIncarnationId, type PtyIncarnationId } from '../../../../shared/pty-incarnation'
import { defineMethod, type RpcAnyMethod } from '../core'
-import { OptionalString, requiredString } from '../schemas'
-import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas'
-
-function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null {
- const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }]
- let count = 0
- while (stack.length > 0) {
- const current = stack.pop()!
- if (
- current.depth > 64 ||
- ++count > 1_024 ||
- !current.value ||
- typeof current.value !== 'object'
- ) {
- return null
- }
- const node = current.value as Record
- if (node.type === 'leaf') {
- if (
- typeof node.groupId !== 'string' ||
- node.groupId.length < 1 ||
- node.groupId.length > 256
- ) {
- return null
- }
- continue
- }
- if (
- node.type !== 'split' ||
- (node.direction !== 'horizontal' && node.direction !== 'vertical') ||
- (node.ratio !== undefined &&
- (typeof node.ratio !== 'number' ||
- !Number.isFinite(node.ratio) ||
- node.ratio < 0 ||
- node.ratio > 1))
- ) {
- return null
- }
- stack.push(
- { value: node.first, depth: current.depth + 1 },
- { value: node.second, depth: current.depth + 1 }
- )
- }
- return value as TabGroupLayoutNode
-}
-
-const TerminalOrphanGroupLayout = z
- .unknown()
- .transform(parseOrphanGroupLayout)
- .pipe(z.custom((value) => value !== null, 'Invalid orphan group layout'))
-
-const TerminalOrphanTopology = z.object({
- tabs: z
- .array(
- z.object({
- tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)),
- root: TerminalPaneLayoutNodeSchema,
- activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)),
- expandedLeafId: z.string().max(128).nullable()
- })
- )
- .min(1)
- .max(64),
- groups: z
- .array(
- z.object({
- id: z.string().min(1).max(256),
- activeTabId: z.string().min(1).max(256),
- tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64),
- recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional()
- })
- )
- .min(1)
- .max(64),
- groupLayout: TerminalOrphanGroupLayout.optional()
-})
-
-const TerminalOrphanIncarnationId = z.custom(
- isPtyIncarnationId,
- 'Invalid PTY incarnation'
-)
-
-const TerminalAdoptOrphans = z.object({
- worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)),
- expectedTopologyRevision: z.number().int().nonnegative(),
- claims: z
- .array(
- z.object({
- terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)),
- ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)),
- incarnationId: TerminalOrphanIncarnationId,
- tabId: requiredString('Missing tab id').pipe(z.string().max(256)),
- leafId: requiredString('Missing leaf id').pipe(z.string().max(128))
- })
- )
- .min(1)
- .max(64),
- activeTabId: OptionalString.pipe(z.string().max(256).optional()),
- activeGroupId: OptionalString.pipe(z.string().max(256).optional()),
- topology: TerminalOrphanTopology.optional()
-})
+import { TerminalAdoptOrphans } from '../../../../shared/rpc-contract/terminal-orphan-params'
export const TERMINAL_ORPHAN_METHODS: RpcAnyMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts
index 18661f10b57..9d4511bbd4d 100644
--- a/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts
+++ b/src/main/runtime/rpc/methods/terminal-quick-command-rpc-schema.ts
@@ -1,73 +1 @@
-import { z } from 'zod'
-import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types'
-import {
- MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
- MAX_QUICK_COMMAND_ID_LENGTH,
- MAX_QUICK_COMMAND_LABEL_LENGTH,
- MAX_QUICK_COMMAND_REPO_ID_LENGTH,
- MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
- normalizeTerminalQuickCommands,
- supportsTerminalAgentQuickCommand
-} from '../../../../shared/terminal-quick-commands'
-
-const TerminalQuickCommandScopeUpdate = z.discriminatedUnion('type', [
- z.object({ type: z.literal('global') }).strict(),
- z
- .object({
- type: z.literal('repo'),
- repoId: z.string().max(MAX_QUICK_COMMAND_REPO_ID_LENGTH)
- })
- .strict()
-])
-
-const TerminalQuickCommandUpdateItem = z.union([
- z
- .object({
- id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH),
- label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH),
- action: z.literal('terminal-command').optional(),
- command: z.string().max(MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH),
- appendEnter: z.boolean(),
- scope: TerminalQuickCommandScopeUpdate.optional()
- })
- .strict(),
- z
- .object({
- id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH),
- label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH),
- action: z.literal('agent-prompt'),
- agent: z.custom(supportsTerminalAgentQuickCommand, {
- message: 'Agent does not support prompt commands'
- }),
- prompt: z.string().max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH),
- scope: TerminalQuickCommandScopeUpdate.optional()
- })
- .strict()
-])
-
-export const TerminalQuickCommandsUpdate = z
- .object({
- // Why: a single host-side mutation preserves unrelated desktop/mobile edits
- // and avoids retransmitting the full ~240 KB list for every small change.
- mutation: z.union([
- z
- .object({
- type: z.literal('upsert'),
- command: TerminalQuickCommandUpdateItem.transform(
- (value) => normalizeTerminalQuickCommands([value])[0]
- ).pipe(
- z.custom((value) => value !== undefined, {
- message: 'Quick command cannot be normalized'
- })
- )
- })
- .strict(),
- z
- .object({
- type: z.literal('delete'),
- id: z.string().min(1).max(MAX_QUICK_COMMAND_ID_LENGTH)
- })
- .strict()
- ])
- })
- .strict()
+export { TerminalQuickCommandsUpdate } from '../../../../shared/rpc-contract/terminal-quick-command-params'
diff --git a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts
index 04a6990fe51..f3e6e3a7870 100644
--- a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts
+++ b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts
@@ -1,43 +1,12 @@
import { z } from 'zod'
import { requiredString } from '../../schemas'
import { TerminalViewport } from './unary-schemas'
-
-const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') })
-
-export const TerminalResizeForClient = z.discriminatedUnion('mode', [
- z.object({
- terminal: requiredString('Missing terminal handle'),
- mode: z.literal('mobile-fit'),
- cols: z.number().finite().positive(),
- rows: z.number().finite().positive(),
- clientId: requiredString('Missing client ID')
- }),
- z.object({
- terminal: requiredString('Missing terminal handle'),
- mode: z.literal('restore'),
- clientId: requiredString('Missing client ID')
- })
-])
-
-export const TerminalSubscribe = TerminalHandle.extend({
- client: z
- .object({
- id: requiredString('Missing client ID'),
- type: z.enum(['mobile', 'desktop']).default('desktop')
- })
- .optional(),
- viewport: TerminalViewport.optional(),
- capabilities: z
- .object({
- terminalBinaryStream: z.literal(1).optional(),
- desktopViewportClaims: z.literal(1).optional(),
- mobileInputLeaseOnly: z.literal(1).optional(),
- writeUnavailable: z.literal(1).optional()
- })
- .optional()
-})
-
-export const TerminalMultiplex = z.object({})
+import { TerminalHandle } from '../../../../../shared/rpc-contract/terminal-stream-params'
+export {
+ TerminalMultiplex,
+ TerminalResizeForClient,
+ TerminalSubscribe
+} from '../../../../../shared/rpc-contract/terminal-stream-params'
export const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({
streamId: z.number().int().min(1),
diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts
index 71feee4f24d..d71ac53e249 100644
--- a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts
+++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts
@@ -1,4 +1,3 @@
-import { z } from 'zod'
import { defineMethod, type RpcAnyMethod } from '../../core'
import { TerminalHandle } from './unary-schemas'
import {
@@ -8,6 +7,7 @@ import {
TerminalUpdateViewport
} from './viewport-schemas'
import { updateViewportForClient } from './terminal-viewport-update'
+import { TerminalGetAutoRestoreFitParams } from '../../../../../shared/rpc-contract/terminal-viewport-methods-params'
export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [
defineMethod({
@@ -105,7 +105,7 @@ export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS: RpcAnyMethod[] = [
}),
defineMethod({
name: 'terminal.getAutoRestoreFit',
- params: z.object({}),
+ params: TerminalGetAutoRestoreFitParams,
handler: async (_params, { runtime }) => ({
ms: runtime.getMobileAutoRestoreFitMs()
})
diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts
index 89928128e69..0b7f9d90408 100644
--- a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts
+++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts
@@ -1,222 +1,22 @@
-import { z } from 'zod'
-import { OptionalFiniteNumber, OptionalString, requiredString } from '../../schemas'
-import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../../shared/feature-education-telemetry'
-import { isTuiAgent } from '../../../../../shared/tui-agent-config'
-
-export const TerminalHandle = z.object({
- terminal: requiredString('Missing terminal handle'),
- // Additive fence understood by newer hosts; legacy hosts safely ignore it.
- expectedIncarnationId: requiredString('Missing PTY incarnation').optional()
-})
-
-export const TerminalFocus = TerminalHandle.extend({
- navigation: z.enum(['caller', 'host']).optional()
-})
-
-/**
- * `terminal.inspectProcess` carries one member the sibling handle methods must not: whether the
- * caller's answer decides something once, which is what licenses the host to pay for a process-table
- * read. Extended rather than added to `TerminalHandle` so `clearBuffer`/`agentStatus`/`isRunningAgent`
- * keep refusing an option they have no use for.
- */
-export const TerminalInspectProcess = TerminalHandle.extend({
- // Additive request member understood by newer hosts; legacy hosts safely ignore it.
- scanChildProcesses: z.boolean().optional()
-})
-
-export const TerminalListParams = z.object({
- worktree: OptionalString,
- limit: OptionalFiniteNumber,
- handles: z
- .array(requiredString('Missing terminal handle').pipe(z.string().max(256)))
- .max(64)
- .optional(),
- requireFreshPtyLiveness: z.boolean().optional(),
- // Why: layouts are ~31% of a large listing and only the human CLI formatter
- // reads them. Absent means "include" so pre-flag clients keep rendering them.
- includeVisualLayouts: z.boolean().optional()
-})
-
-export const TerminalResolveActive = z.object({
- worktree: OptionalString,
- /** Refuse instead of guessing when several leaves could be the caller's own terminal. */
- requireUnambiguous: z.boolean().optional()
-})
-
-export const TerminalResolvePane = z.object({
- paneKey: requiredString('Missing pane key'),
- worktreeId: OptionalString
-})
-
-export const TerminalRecoverPane = z.object({
- paneKey: requiredString('Missing pane key'),
- worktreeId: requiredString('Missing worktree ID'),
- expectedTerminal: requiredString('Missing expected terminal handle').optional()
-})
-
-export const TerminalRead = TerminalHandle.extend({
- cursor: z
- .unknown()
- .transform((value) => {
- if (value === undefined) {
- return undefined
- }
- if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
- return Number.NaN
- }
- return value
- })
- .pipe(
- z
- .number()
- .optional()
- .refine((v) => v === undefined || Number.isFinite(v), {
- message: 'Cursor must be a non-negative integer'
- })
- )
- .optional(),
- limit: OptionalFiniteNumber,
- // Why: optional so an older host that does not understand it simply drops the key and answers
- // with its usual stream read; the response's `source` is what tells the caller which it got.
- screen: z.literal(true).optional()
-}).refine((params) => !(params.screen === true && params.cursor !== undefined), {
- // Why: a cursor pages through accumulated output; a screen is the current frame with nothing
- // behind it. Honoring both would answer with rendered lines carrying the stream's pagination
- // metadata — two frames of reference in one payload, which is the confusion `source` exists to
- // remove. The CLI already refuses the pair, but the RPC is reachable without it.
- message: 'Cursor cannot be combined with a screen read'
-})
-
-// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct.
-export const TerminalRename = TerminalHandle.extend({
- title: z.custom((value) => value === null || typeof value === 'string', {
- message: 'Missing --title (pass empty string or null to reset)'
- })
-})
-
-export const TerminalSend = TerminalHandle.extend({
- text: OptionalString,
- enter: z.unknown().optional(),
- interrupt: z.unknown().optional(),
- // Why: older hosts strip this optional intent and retain their direct-send behavior.
- agentPrompt: z.literal(true).optional(),
- // Why: waiting observes the same prompt receipt; it never authorizes a second write.
- waitSubmitMs: z.number().int().min(0).max(3_600_000).optional(),
- resolvedLaunchDraft: z
- .object({
- text: z.string(),
- createdAt: z.number().finite()
- })
- .optional(),
- requireAgentStatus: z.enum(['sendable']).optional(),
- // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor.
- inputKind: z.enum(['query-reply']).optional(),
- // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md).
- client: z
- .object({
- id: requiredString('Missing client ID'),
- type: z.enum(['mobile', 'desktop']).default('desktop').optional()
- })
- .optional(),
- viewport: z
- .object({
- cols: z.number().int().min(1).max(1000),
- rows: z.number().int().min(1).max(500)
- })
- .optional(),
- claimViewport: z.literal(true).optional()
-})
-
-export const TerminalViewport = z.object({
- cols: z.number().int().min(1).max(1000),
- rows: z.number().int().min(1).max(500)
-})
-
-export const TerminalWait = TerminalHandle.extend({
- for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', {
- message: 'Invalid --for value. Supported: exit, tui-idle'
- }),
- timeoutMs: OptionalFiniteNumber
-})
-
-export const TerminalCreateParams = z.object({
- worktree: OptionalString,
- clientMutationId: z.string().min(1).max(128).optional(),
- reconcileExisting: z.boolean().optional(),
- command: OptionalString,
- startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
- env: z.record(z.string(), z.string()).optional(),
- envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(),
- launchConfig: z
- .object({
- agentCommand: z.string().optional(),
- agentArgs: z.string(),
- agentEnv: z.record(z.string(), z.string()),
- ompResumeFilePath: z
- .string()
- .min(1)
- .max(32 * 1024)
- .optional()
- })
- .optional(),
- resumeProviderSession: z
- .object({
- key: z.enum(['session_id', 'conversation_id']),
- id: z.string().min(1).max(512),
- transcriptPath: z.string().min(1).max(32_768).optional()
- })
- .optional(),
- launchToken: OptionalString,
- launchAgent: z.string().refine(isTuiAgent).optional(),
- terminalColorQueryReplies: z
- .object({
- foreground: z.string().max(128).optional(),
- background: z.string().max(128).optional()
- })
- .optional(),
- title: OptionalString,
- focus: z.unknown().optional(),
- rendererBacked: z.unknown().optional(),
- activate: z.unknown().optional(),
- presentation: z.enum(['background', 'focused']).optional(),
- tabId: OptionalString,
- leafId: OptionalString
-})
-
-export const TerminalSplit = TerminalHandle.extend({
- direction: z
- .unknown()
- .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
- .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()]))
- .optional(),
- command: OptionalString,
- env: z.record(z.string(), z.string()).optional(),
- telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional()
-})
-
-export const TerminalStop = z.object({
- worktree: requiredString('Missing worktree selector')
-})
-
-export const TerminalCloseAll = TerminalStop
-
-export const TerminalSleep = TerminalStop
-
-export const TerminalStopExact = TerminalStop.extend({
- expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1),
- keepHistory: z.boolean().optional(),
- targetOnly: z.boolean().optional()
-})
-
-export const AgentTeamsTmuxCompat = z.object({
- teamId: requiredString('Missing agent team ID'),
- token: requiredString('Missing agent team token'),
- envPane: requiredString('Missing tmux pane identity'),
- cwd: OptionalString,
- argv: z.array(z.string())
-})
-
-export const AgentTeamsPrepareLaunch = z.object({
- paneKey: requiredString('Missing pane key'),
- env: z.record(z.string(), z.string()).optional()
-})
+export {
+ AgentTeamsPrepareLaunch,
+ AgentTeamsTmuxCompat,
+ TerminalCloseAll,
+ TerminalCreateParams,
+ TerminalFocus,
+ TerminalHandle,
+ TerminalInspectProcess,
+ TerminalListParams,
+ TerminalRead,
+ TerminalRecoverPane,
+ TerminalRename,
+ TerminalResolveActive,
+ TerminalResolvePane,
+ TerminalSend,
+ TerminalSleep,
+ TerminalSplit,
+ TerminalStop,
+ TerminalStopExact,
+ TerminalViewport,
+ TerminalWait
+} from '../../../../../shared/rpc-contract/terminal-unary-params'
diff --git a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts
index d9b76d66ea1..70ecd9037d1 100644
--- a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts
+++ b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts
@@ -1,51 +1,6 @@
-import { z } from 'zod'
-import { requiredString } from '../../schemas'
-
-const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') })
-
-export const TerminalSetDisplayMode = TerminalHandle.extend({
- // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit.
- mode: z.enum(['auto', 'desktop']),
- // Why: identifies the caller for the driver state machine; optional for older mobile clients.
- client: z
- .object({
- id: requiredString('Missing client ID'),
- type: z.enum(['mobile', 'desktop']).default('desktop').optional()
- })
- .optional(),
- // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing.
- viewport: z
- .object({
- cols: z.number().int().positive(),
- rows: z.number().int().positive()
- })
- .optional()
-})
-
-export const TerminalUnsubscribe = z.object({
- subscriptionId: requiredString('Missing subscription ID'),
- // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md).
- client: z
- .object({
- id: requiredString('Missing client ID')
- })
- .optional()
-})
-
-// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md).
-export const TerminalUpdateViewport = TerminalHandle.extend({
- client: z.object({
- id: requiredString('Missing client ID'),
- type: z.enum(['mobile', 'desktop']).default('mobile').optional()
- }),
- viewport: z.object({
- cols: z.number().int().min(20).max(240),
- rows: z.number().int().min(8).max(120)
- }),
- claim: z.boolean().optional()
-})
-
-// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side.
-export const TerminalSetAutoRestoreFit = z.object({
- ms: z.number().nullable()
-})
+export {
+ TerminalSetAutoRestoreFit,
+ TerminalSetDisplayMode,
+ TerminalUnsubscribe,
+ TerminalUpdateViewport
+} from '../../../../../shared/rpc-contract/terminal-viewport-schemas-params'
diff --git a/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts b/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts
index 1b8ca0c2cd4..e03a9bfad06 100644
--- a/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts
+++ b/src/main/runtime/rpc/methods/ui-update-value-tolerance.ts
@@ -1,25 +1,4 @@
-import type { z } from 'zod'
-
-/**
- * `UiUpdate` rides App.tsx's debounced writer, so one drifted enum member used
- * to fail the WHOLE batch and silently drop sidebar widths, filters and agent
- * acks alongside it. Degrade instead: a value the schema cannot express is
- * dropped from the payload and the rest of the batch still lands. Unknown KEYS
- * stay a hard rejection — the parity assertions exist to catch those.
- */
-export function tolerateUnknownValues(shape: TShape): TShape {
- return Object.fromEntries(
- Object.entries(shape).map(([key, schema]) => [
- key,
- (schema as z.ZodType).catch(() => undefined)
- ])
- ) as unknown as TShape
-}
-
-/** Drops the `undefined` entries `tolerateUnknownValues` leaves behind, so a
- * rejected value reads as absent rather than as an explicit clear. */
-export function omitUndefinedValues>(value: TValue): TValue {
- return Object.fromEntries(
- Object.entries(value).filter(([, entry]) => entry !== undefined)
- ) as TValue
-}
+export {
+ omitUndefinedValues,
+ tolerateUnknownValues
+} from '../../../../shared/rpc-contract/ui-update-value-tolerance-params'
diff --git a/src/main/runtime/rpc/methods/updater.ts b/src/main/runtime/rpc/methods/updater.ts
index 1baa2aff53b..357f07a8c09 100644
--- a/src/main/runtime/rpc/methods/updater.ts
+++ b/src/main/runtime/rpc/methods/updater.ts
@@ -1,11 +1,11 @@
import { defineMethod, type RpcMethod } from '../core'
-import { z } from 'zod'
import {
checkRemoteServerUpdater,
downloadRemoteServerUpdater,
getRemoteServerUpdaterSnapshot,
installRemoteServerUpdater
} from '../../remote-server-updater'
+import { UpdaterCheckParams } from '../../../../shared/rpc-contract/updater-params'
export const UPDATER_METHODS: RpcMethod[] = [
defineMethod({
@@ -15,10 +15,7 @@ export const UPDATER_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'updater.check',
- params: z.object({
- includePrerelease: z.boolean().optional(),
- includePerfPrerelease: z.boolean().optional()
- }),
+ params: UpdaterCheckParams,
handler: (params, { runtime }) => checkRemoteServerUpdater(runtime.getRuntimeId(), params)
}),
defineMethod({
diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts
index 624a63ff44e..11e6cab4ee3 100644
--- a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts
+++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts
@@ -1,30 +1 @@
-import { z } from 'zod'
-import {
- normalizeWorkspaceCleanupBrowseState,
- type WorkspaceCleanupBrowseState
-} from '../../../../shared/workspace-cleanup-browse-state'
-
-const WorkspaceCleanupDismissal = z.object({
- worktreeId: z.string(),
- dismissedAt: z.number().finite(),
- fingerprint: z.string(),
- classifierVersion: z.number().finite(),
- executionHostId: z.string().min(1).optional()
-})
-
-/**
- * Deliberately unvalidated shape, then normalized: the filter groups must NOT be
- * strict or enumerated here. A newer client sends filters this build has never
- * heard of, and a per-field zod shape would reject the whole `ui.set` payload
- * instead of persisting the parts the host does understand. The shared
- * normalizer never throws and degrades field by field, so an older host narrows
- * the state rather than refusing it.
- */
-const WorkspaceCleanupBrowse = z
- .custom()
- .transform((value) => normalizeWorkspaceCleanupBrowseState(value))
-
-export const WorkspaceCleanup = z.object({
- dismissals: z.record(z.string(), WorkspaceCleanupDismissal),
- browse: WorkspaceCleanupBrowse.optional()
-})
+export { WorkspaceCleanup } from '../../../../shared/rpc-contract/workspace-cleanup-ui-params'
diff --git a/src/main/runtime/rpc/methods/workspace-ports.ts b/src/main/runtime/rpc/methods/workspace-ports.ts
index 9a6ff82764b..5778f25732f 100644
--- a/src/main/runtime/rpc/methods/workspace-ports.ts
+++ b/src/main/runtime/rpc/methods/workspace-ports.ts
@@ -1,16 +1,8 @@
-import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
-import { OptionalString, requiredNumber } from '../schemas'
-
-const WorkspacePortScanParams = z.object({
- repoId: OptionalString
-})
-
-const WorkspacePortKillParams = z.object({
- repoId: OptionalString,
- pid: requiredNumber('Missing process id'),
- port: requiredNumber('Missing port')
-})
+import {
+ WorkspacePortKillParams,
+ WorkspacePortScanParams
+} from '../../../../shared/rpc-contract/workspace-ports-params'
export const WORKSPACE_PORT_METHODS: RpcMethod[] = [
defineMethod({
diff --git a/src/main/runtime/rpc/methods/worktree-create-schemas.ts b/src/main/runtime/rpc/methods/worktree-create-schemas.ts
index 61f6eb65e35..659f0c87fcf 100644
--- a/src/main/runtime/rpc/methods/worktree-create-schemas.ts
+++ b/src/main/runtime/rpc/methods/worktree-create-schemas.ts
@@ -1,154 +1,4 @@
-import { z } from 'zod'
-import { isTuiAgent } from '../../../../shared/tui-agent-config'
-import { workspaceSourceSchema } from '../../../../shared/telemetry-events'
-import { sleepingAgentLaunchConfigSchema } from '../../../../shared/workspace-session-sleeping-agents'
-import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation'
-import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema'
-import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema'
-import {
- OptionalBoolean,
- OptionalFiniteNumber,
- OptionalString,
- TriStateLinkedIssue
-} from '../schemas'
-import {
- assertLinkedWorkItemSourceContextMatch,
- AutomationWorkspaceProvenanceRequest,
- CliWorkspaceProvenanceRequest,
- OptionalTuiAgent
-} from './worktree-schemas'
-
-export const WorktreeCreate = z
- .object({
- repo: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing repo selector')),
- name: OptionalString,
- /** Set by clients that fell back to a generated creature name. Absent means user-typed, so the
- * host neither skips a retired candidate nor retires the name it lands on. */
- nameWasGenerated: z.boolean().optional(),
- baseBranch: OptionalString,
- compareBaseRef: OptionalString,
- branchNameOverride: OptionalString,
- linkedIssue: TriStateLinkedIssue,
- linkedPR: TriStateLinkedIssue,
- linkedLinearIssue: z.string().optional(),
- linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(),
- linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(),
- linkedGitLabMR: TriStateLinkedIssue,
- linkedGitLabIssue: TriStateLinkedIssue,
- linkedBitbucketPR: TriStateLinkedIssue,
- linkedAzureDevOpsPR: TriStateLinkedIssue,
- linkedGiteaPR: TriStateLinkedIssue,
- linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(),
- linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
- comment: OptionalString,
- displayName: OptionalString,
- displayNameKind: z.enum(['generated', 'user']).optional(),
- telemetrySource: z
- .unknown()
- .transform((value) => {
- const parsed = workspaceSourceSchema.safeParse(value)
- return parsed.success ? parsed.data : undefined
- })
- .optional(),
- workspaceStatus: OptionalString,
- manualOrder: OptionalFiniteNumber,
- sparseCheckout: z
- .object({
- directories: z.array(z.string()),
- presetId: OptionalString
- })
- .optional(),
- pushTarget: z
- .object({
- remoteName: z.string(),
- branchName: z.string(),
- remoteUrl: OptionalString
- })
- .optional(),
- runHooks: OptionalBoolean,
- activate: OptionalBoolean,
- // Why: activation on create is view intent, so it is addressed like worktree.activate.
- // Contract: a paired desktop/web caller resolves to 'caller' and therefore receives NO
- // activateWorktree event — it must reveal from this call's result, which carries setup,
- // startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal.
- navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
- parentWorkspace: OptionalString,
- // Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag.
- // Absent keeps the CLI provenance older clients rely on.
- parentWorkspaceOrigin: z.literal('manual').optional(),
- envParentWorkspace: OptionalString,
- parentWorktree: OptionalString,
- cwdParentWorktree: OptionalString,
- noParent: OptionalBoolean,
- callerTerminalHandle: OptionalString,
- orchestrationContext: z
- .object({
- parentWorktreeId: OptionalString,
- orchestrationRunId: OptionalString,
- taskId: OptionalString,
- coordinatorHandle: OptionalString
- })
- .optional(),
- setupDecision: z
- .unknown()
- .transform((v) =>
- typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
- )
- .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()]))
- .optional(),
- // Why: some clients (e.g. desktop) pass a pre-built launch command so the
- // first terminal pane launches the selected agent instead of an idle shell.
- // Clients that can't quote for the host shell send `startupAgent` instead.
- startupCommand: OptionalString,
- startupEnv: z.record(z.string(), z.string()).optional(),
- startupLaunchConfig: sleepingAgentLaunchConfigSchema,
- startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
- // Why: CLI clients should not hardcode agent launch quoting because SSH
- // workspaces execute in a different shell than the client process.
- startupAgent: OptionalTuiAgent,
- startupPrompt: OptionalString,
- // Why: task-driven mobile creates need desktop parity: the host chooses
- // the same default/detected agent and drafts the linked issue/PR URL into it.
- startupDraft: OptionalString,
- createdWithAgent: z
- .unknown()
- .transform((value) => (isTuiAgent(value) ? value : undefined))
- .optional(),
- // Why: mobile retries a create interrupted by a connection migration with the
- // same key so the host dedupes instead of spawning a duplicate worktree.
- clientMutationId: z.string().min(1).max(128).optional(),
- automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional(),
- cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional()
- })
- .superRefine((params, ctx) => {
- assertLinkedWorkItemSourceContextMatch(params, ctx)
- if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Choose either one parent selector or --no-parent.'
- })
- }
- if (params.parentWorkspace && params.parentWorktree) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Choose either one parent selector or --no-parent.'
- })
- }
- if (params.startupPrompt !== undefined && params.startupAgent === undefined) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'startupPrompt requires startupAgent'
- })
- }
- })
-
-export const WorktreePrefetchCreateBase = z.object({
- repo: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing repo selector')),
- baseBranch: OptionalString
-})
+export {
+ WorktreeCreate,
+ WorktreePrefetchCreateBase
+} from '../../../../shared/rpc-contract/worktree-create-params'
diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts
index b7c3b9493a9..41d2c38fec7 100644
--- a/src/main/runtime/rpc/methods/worktree-schemas.ts
+++ b/src/main/runtime/rpc/methods/worktree-schemas.ts
@@ -1,215 +1,18 @@
-import { z } from 'zod'
-import { isTuiAgent } from '../../../../shared/tui-agent-config'
-import type { TuiAgent } from '../../../../shared/tui-agent'
-import { RUNTIME_NAVIGATION_TARGETS } from '../../../../shared/runtime-navigation'
-import {
- OptionalBoolean,
- OptionalFiniteNumber,
- OptionalPlainString,
- OptionalString,
- TriStateLinkedIssue
-} from '../schemas'
-import { TaskSourceContextSchema } from '../../../../shared/task-source-context-schema'
-import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema'
-import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context'
-import { normalizeExecutionHostId } from '../../../../shared/execution-host'
-
-const OptionalExecutionHostId = z
- .string()
- .transform((value, ctx) => {
- const hostId = normalizeExecutionHostId(value)
- if (!hostId) {
- ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
- return z.NEVER
- }
- return hostId
- })
- .optional()
-
-export const OptionalTuiAgent = z
- .unknown()
- .superRefine((value, ctx) => {
- if (value !== undefined && !isTuiAgent(value)) {
- ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' })
- }
- })
- .transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined))
- .optional()
-
-export const AutomationWorkspaceProvenanceRequest = z.object({
- automationId: z.string(),
- automationRunId: z.string(),
- dispatchToken: z.string(),
- createRequestId: z.string()
-})
-
-// Why no dispatch token (unlike automation provenance): this is a descriptive
-// origin marker for sidebar filtering, not an authority grant. The host stamps
-// createdAt itself so a client clock can't skew sort order.
-export const CliWorkspaceProvenanceRequest = z.object({
- callerTerminalHandle: OptionalString
-})
-
-export const WorktreeListParams = z.object({
- repo: OptionalString,
- limit: OptionalFiniteNumber
-})
-
-export const WorktreeDetectedListParams = z.object({
- repo: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing repo selector'))
-})
-
-export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({
- worktreeIds: z.array(z.string().min(1)).max(10_000),
- connectionId: z.string().nullable().optional()
-})
-
-export const WorktreePsParams = z.object({
- limit: OptionalFiniteNumber,
- afterSnapshotId: z.string().min(1).max(128).nullable().optional(),
- supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional()
-})
-
-export const WorktreeSortOrder = z.object({
- orderedIds: z.array(z.string())
-})
-
-export const WorktreeSelector = z.object({
- worktree: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing worktree selector'))
-})
-
-export const WorktreeActivate = WorktreeSelector.extend({
- notifyClients: OptionalBoolean,
- navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional()
-})
-
-/** Shared by WorktreeCreate and WorktreeSet so the two error messages cannot drift. */
-export function assertLinkedWorkItemSourceContextMatch(
- params: {
- linkedWorkItem?: z.infer | null
- linkedTaskSourceContext?: z.infer | null
- },
- ctx: z.RefinementCtx
-): void {
- if (
- params.linkedWorkItem &&
- params.linkedTaskSourceContext &&
- !isWorkspaceLinkedItemSourceContextMatch(params.linkedWorkItem, params.linkedTaskSourceContext)
- ) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Linked work item and source context identities must match'
- })
- }
-}
-
-export const WorktreeSet = WorktreeSelector.extend({
- // Why: '' is the blanking contract — "fall back to the branch/folder name".
- // OptionalString coerced it to undefined, so on remote/SSH hosts clearing the
- // name was dropped here and the old name came back on the next refresh.
- displayName: OptionalPlainString,
- // Why: empty comments are meaningful metadata updates, so use the plain
- // string parser instead of OptionalString's empty-as-undefined behavior.
- comment: OptionalPlainString,
- linkedIssue: TriStateLinkedIssue,
- linkedPR: TriStateLinkedIssue,
- suppressedGitHubPR: z.number().int().positive().nullable().optional(),
- linkedLinearIssue: z.union([z.string(), z.null()]).optional(),
- linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(),
- linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(),
- linkedGitLabMR: TriStateLinkedIssue,
- linkedGitLabIssue: TriStateLinkedIssue,
- linkedBitbucketPR: TriStateLinkedIssue,
- linkedAzureDevOpsPR: TriStateLinkedIssue,
- linkedGiteaPR: TriStateLinkedIssue,
- linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(),
- linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
- isArchived: OptionalBoolean,
- isUnread: OptionalBoolean,
- isPinned: OptionalBoolean,
- sortOrder: OptionalFiniteNumber,
- manualOrder: OptionalFiniteNumber,
- lastActivityAt: OptionalFiniteNumber,
- createdAt: OptionalFiniteNumber,
- sparseDirectories: z.array(z.string()).optional(),
- sparseBaseRef: OptionalString,
- sparsePresetId: OptionalString,
- baseRef: OptionalString,
- workspaceStatus: OptionalString,
- pushTarget: z
- .object({
- remoteName: z.string(),
- branchName: z.string(),
- remoteUrl: OptionalString
- })
- .nullable()
- .optional(),
- diffComments: z.array(z.unknown()).optional(),
- mobileDiffReview: z.unknown().optional(),
- parentWorktree: OptionalString,
- noParent: OptionalBoolean
-}).superRefine((params, ctx) => {
- assertLinkedWorkItemSourceContextMatch(params, ctx)
- if (params.parentWorktree && params.noParent === true) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Choose either --parent-worktree or --no-parent, not both.'
- })
- }
-})
-
-export const WorktreeRemove = WorktreeSelector.extend({
- hostId: OptionalExecutionHostId,
- force: OptionalBoolean,
- // Why (#11960): the CLI's --force is an unambiguous force affordance, but the
- // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop
- // waiver travels on its own field.
- allowUnverifiedPtyStop: OptionalBoolean,
- runHooks: OptionalBoolean
-})
-
-export const WorktreeForceDeleteBranch = WorktreeSelector.extend({
- hostId: OptionalExecutionHostId,
- branchName: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing branch name')),
- expectedHead: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing expected branch head'))
-})
-
-export const WorktreeResolvePrBase = z.object({
- repo: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing repo selector')),
- prNumber: z
- .unknown()
- .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
- .pipe(z.number().int().positive('Missing PR number')),
- headRefName: OptionalString,
- baseRefName: OptionalString,
- isCrossRepository: OptionalBoolean
-})
-
-export const WorktreeResolveMrBase = z.object({
- repo: z
- .unknown()
- .transform((v) => (typeof v === 'string' ? v : ''))
- .pipe(z.string().min(1, 'Missing repo selector')),
- mrIid: z
- .unknown()
- .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
- .pipe(z.number().int().positive('Missing MR number')),
- sourceBranch: OptionalString,
- targetBranch: OptionalString,
- isCrossRepository: OptionalBoolean
-})
+export {
+ AutomationWorkspaceProvenanceRequest,
+ CliWorkspaceProvenanceRequest,
+ OptionalTuiAgent,
+ WorktreeActivate,
+ WorktreeDetectedListParams,
+ WorktreeForceDeleteBranch,
+ WorktreeListParams,
+ WorktreePsParams,
+ WorktreeRemove,
+ WorktreeResolveMrBase,
+ WorktreeResolvePrBase,
+ WorktreeSelector,
+ WorktreeSet,
+ WorktreeSortOrder,
+ WorktreeTeardownMissingTerminalsParams,
+ assertLinkedWorkItemSourceContextMatch
+} from '../../../../shared/rpc-contract/worktree-params'
diff --git a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts
index 8bb7e1d081e..19a6dba90be 100644
--- a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts
+++ b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts
@@ -1,19 +1 @@
-import { z } from 'zod'
-import {
- normalizeCustomWorktreeVisibilitySources,
- normalizeWorktreeVisibilitySourcePreferences
-} from '../../../../shared/worktree/visibility-sources'
-
-export const WorktreeVisibilityDefaultsUpdate = z
- .object({
- external: z.enum(['hide', 'show']).optional(),
- customSources: z
- .unknown()
- .transform((value) => normalizeCustomWorktreeVisibilitySources(value))
- .optional(),
- sourcePreferences: z
- .unknown()
- .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value))
- .optional()
- })
- .strict()
+export { WorktreeVisibilityDefaultsUpdate } from '../../../../shared/rpc-contract/worktree-visibility-defaults-params'
diff --git a/src/main/runtime/rpc/schemas.ts b/src/main/runtime/rpc/schemas.ts
index fb7b09d8ceb..2c469341be5 100644
--- a/src/main/runtime/rpc/schemas.ts
+++ b/src/main/runtime/rpc/schemas.ts
@@ -3,87 +3,15 @@
// recur across domains (optional worktree selector, bounded limit, browser
// target envelope, etc.). Methods compose these to declare their real
// contract without repeating the same `typeof` gymnastics 90 times.
-import { z } from 'zod'
-
-// Why: the original handlers treated non-numeric/NaN limit values as "no
-// limit" rather than as errors. Preserve that forgiving behavior so CLI
-// callers passing stringified numbers or Infinity still reach the runtime.
-// The outer optional() is required for omitted keys in Zod v4; an optional
-// schema hidden behind pipe() still makes z.object require the property.
-export const OptionalFiniteNumber = z
- .unknown()
- .transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined))
- .pipe(z.union([z.number(), z.undefined()]))
- .optional()
-
-export const OptionalPositiveInt = z
- .unknown()
- .transform((value) =>
- typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
- )
- .pipe(z.union([z.number(), z.undefined()]))
- .optional()
-
-export const OptionalString = z
- .unknown()
- .transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined))
- .pipe(z.union([z.string(), z.undefined()]))
- .optional()
-
-export const OptionalPlainString = z
- .unknown()
- .transform((value) => (typeof value === 'string' ? value : undefined))
- .pipe(z.union([z.string(), z.undefined()]))
- .optional()
-
-export const OptionalBoolean = z
- .unknown()
- .transform((value) => (typeof value === 'boolean' ? value : undefined))
- .pipe(z.union([z.boolean(), z.undefined()]))
- .optional()
-
-// Why: runtime handlers accept `linkedIssue: number | null | undefined` with
-// distinct meanings — undefined means "no update", null means "clear", number
-// means "set". The ambient JSON decode produces all three shapes as-is.
-export const TriStateLinkedIssue = z
- .unknown()
- .transform((value) => {
- if (value === null) {
- return null
- }
- if (typeof value === 'number' && Number.isFinite(value)) {
- return value
- }
- return undefined
- })
- .pipe(z.union([z.number(), z.null(), z.undefined()]))
- .optional()
-
-// Why: the legacy extractBrowserTarget treated worktree as a plain-string
-// passthrough (empty string preserved) but `page` as non-empty-string. The
-// browser bridge uses worktree-as-empty-string to mean "any worktree", so
-// keep that asymmetry intact to avoid widening scope unexpectedly.
-export const BrowserTarget = z.object({
- worktree: OptionalPlainString,
- page: OptionalString
-})
-
-export function requiredString(message: string) {
- return z
- .unknown()
- .transform((value) => (typeof value === 'string' ? value : ''))
- .pipe(z.string().min(1, message))
-}
-
-export function requiredStringAllowingEmpty(message: string) {
- return z.unknown().refine((value): value is string => typeof value === 'string', { message })
-}
-
-export function requiredNumber(message: string) {
- return z
- .unknown()
- .transform((value) =>
- typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
- )
- .pipe(z.number().refine((v) => Number.isFinite(v), { message }))
-}
+export {
+ BrowserTarget,
+ OptionalBoolean,
+ OptionalFiniteNumber,
+ OptionalPlainString,
+ OptionalPositiveInt,
+ OptionalString,
+ TriStateLinkedIssue,
+ requiredNumber,
+ requiredString,
+ requiredStringAllowingEmpty
+} from '../../../shared/rpc-contract/rpc-param-primitives'
diff --git a/src/shared/rpc-contract/accounts-params.ts b/src/shared/rpc-contract/accounts-params.ts
new file mode 100644
index 00000000000..0a9559e9549
--- /dev/null
+++ b/src/shared/rpc-contract/accounts-params.ts
@@ -0,0 +1,81 @@
+import { z } from 'zod'
+
+export const CodexResetTarget = z.discriminatedUnion('runtime', [
+ z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(),
+ // Why: reset scope must identify one exact WSL distro; null means all slots only for selection.
+ z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict()
+])
+
+export const CodexSelectionTarget = z.discriminatedUnion('runtime', [
+ z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(),
+ z
+ .object({
+ runtime: z.literal('wsl'),
+ // A null distro intentionally means all WSL selection slots.
+ wslDistro: z.string().trim().min(1).max(255).nullable()
+ })
+ .strict()
+])
+
+export const SelectAccountParams = z.object({
+ accountId: z
+ .union([z.string().min(1, 'Missing accountId'), z.null()])
+ .transform((v) => (v === null ? null : v))
+})
+
+export const SelectCodexAccountForTargetParams = SelectAccountParams.extend({
+ target: CodexSelectionTarget
+})
+
+export const RemoveAccountParams = z.object({
+ accountId: z.string().min(1, 'Missing accountId')
+})
+
+export const CodexResetExpectedScope = z
+ .object({
+ target: CodexResetTarget,
+ accountId: z.string().min(1, 'Missing accountId').max(512),
+ accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
+ offerRevision: z.string().startsWith('v1:', 'Invalid offerRevision').max(4_096)
+ })
+ .strict()
+
+export const ConsumeCodexResetCreditParams = z
+ .object({
+ // Why: the phone owns the logical attempt key so a lost response can be
+ // retried without spending a finite earned credit twice.
+ idempotencyKey: z.uuid('Invalid idempotencyKey'),
+ expectedScope: CodexResetExpectedScope
+ })
+ .strict()
+
+export const AddClaudeFromConfigDirParams = z.object({
+ configDir: z.string().min(1, 'Missing configDir'),
+ runtime: z.enum(['host', 'wsl']).optional(),
+ wslDistro: z.string().nullish(),
+ previousLegacyCredentialsSha256: z
+ .string()
+ .regex(/^[a-f0-9]{64}$/, 'Invalid legacy credential digest')
+ .nullable()
+ .optional()
+})
+
+export const AddCodexFromHomeParams = z.object({
+ sourceHome: z.string().min(1, 'Missing sourceHome'),
+ runtime: z.enum(['host', 'wsl']).optional(),
+ wslDistro: z.string().nullish()
+})
+
+// Why: `orca account list` prints only emails and the active ids, so it opts out
+// of the forced all-provider usage refresh below — that lane bypasses the poll
+// throttle and Retry-After gate and costs one serial round-trip per account.
+export const ListAccountsParams = z.object({
+ refreshUsage: z.boolean().default(true)
+})
+
+export const AccountsUnsubscribeParams = z.object({
+ subscriptionId: z
+ .unknown()
+ .transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
+ .pipe(z.string().min(1, 'Missing subscriptionId'))
+})
diff --git a/src/shared/rpc-contract/agent-hooks-params.ts b/src/shared/rpc-contract/agent-hooks-params.ts
new file mode 100644
index 00000000000..bed616bc0a7
--- /dev/null
+++ b/src/shared/rpc-contract/agent-hooks-params.ts
@@ -0,0 +1,14 @@
+import { z } from 'zod'
+
+export const PrepareCodexForWslPaneParams = z
+ .object({
+ codexHome: z.string().max(4_096),
+ orcaCodexHome: z.string().max(4_096),
+ wslDistro: z
+ .string()
+ .trim()
+ .min(1)
+ .max(255)
+ .regex(/^[^\\/\r\n]+$/)
+ })
+ .strict()
diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts
new file mode 100644
index 00000000000..6a2aa23635d
--- /dev/null
+++ b/src/shared/rpc-contract/agent-session-params.ts
@@ -0,0 +1,189 @@
+import { z } from 'zod'
+import { isValidTerminalTabId } from '../terminal-tab-id'
+import {
+ RESUMABLE_TUI_AGENTS,
+ getAgentResumeArgv,
+ hasUnsafeProviderSessionIdChars
+} from '../agent-session-resume'
+import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority'
+import type {
+ RuntimeCreateAgentSessionRequest,
+ RuntimeEnsureAgentSessionRequest
+} from '../agent-session-host-authority'
+import { isTuiAgent } from '../tui-agent-config'
+
+export const MAX_WORKTREE_SELECTOR_LENGTH = 32_768
+
+export const MAX_TRANSCRIPT_PATH_BYTES = 16 * 1024
+
+export const MAX_PROMPT_BYTES = 256 * 1024
+
+export const MAX_AGENT_ARGS_BYTES = 16 * 1024
+
+export const MAX_LAUNCH_PREFERENCE_LENGTH = 512
+
+export const StrictNonEmptyString = (max: number, message: string) =>
+ z
+ .string()
+ .min(1, message)
+ .max(max, message)
+ .refine((value) => value === value.trim(), `${message}; surrounding whitespace is invalid`)
+
+export const WorktreeSelector = StrictNonEmptyString(
+ MAX_WORKTREE_SELECTOR_LENGTH,
+ 'Invalid worktree selector'
+)
+
+export const Presentation = z.enum(['background', 'focused'])
+
+export const Placement = z
+ .object({
+ tabId: z
+ .string()
+ .min(1)
+ .max(512)
+ .refine(isValidTerminalTabId, 'Invalid terminal tab ID')
+ .optional(),
+ leafId: z.string().min(1).max(128).optional()
+ })
+ .strict()
+ .refine((value) => value.tabId !== undefined || value.leafId !== undefined, {
+ message: 'Placement must include a tab or leaf ID'
+ })
+
+export const LaunchPreferences = z
+ .object({
+ model: StrictNonEmptyString(
+ MAX_LAUNCH_PREFERENCE_LENGTH,
+ 'Invalid model preference'
+ ).optional(),
+ effort: StrictNonEmptyString(
+ MAX_LAUNCH_PREFERENCE_LENGTH,
+ 'Invalid effort preference'
+ ).optional(),
+ mode: StrictNonEmptyString(MAX_LAUNCH_PREFERENCE_LENGTH, 'Invalid mode preference').optional()
+ })
+ .strict()
+
+export const PromptDelivery = z.enum(['auto-submit', 'draft'])
+
+export const AgentArgs = z
+ .string()
+ .refine(
+ (value) => Buffer.byteLength(value, 'utf8') <= MAX_AGENT_ARGS_BYTES,
+ 'Agent arguments are too large'
+ )
+ .nullable()
+
+export const OmpResumeFilePath = z
+ .string()
+ .min(1)
+ .refine((value) => value === value.trim(), 'Invalid OMP resume path')
+ .refine(
+ (value) =>
+ !hasUnsafeProviderSessionIdChars(value) &&
+ Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES,
+ 'Invalid OMP resume path'
+ )
+
+export const ProviderSession = z
+ .object({
+ key: z.enum(['session_id', 'conversation_id']),
+ id: StrictNonEmptyString(512, 'Invalid provider session ID').refine(
+ (value) => !value.startsWith('-') && !hasUnsafeProviderSessionIdChars(value),
+ 'Invalid provider session ID'
+ ),
+ transcriptPath: z
+ .string()
+ .min(1)
+ .refine((value) => value === value.trim(), 'Invalid transcript path')
+ .refine(
+ (value) =>
+ !hasUnsafeProviderSessionIdChars(value) &&
+ Buffer.byteLength(value, 'utf8') <= MAX_TRANSCRIPT_PATH_BYTES,
+ 'Invalid transcript path'
+ )
+ .optional()
+ })
+ .strict()
+
+export const AutomaticEnsure = z
+ .object({
+ kind: z.literal('automatic'),
+ sleepingCheckpointId: z
+ .string()
+ .min(32)
+ .max(128)
+ .regex(/^[A-Za-z0-9_-]+$/),
+ presentation: Presentation.optional()
+ })
+ .strict()
+
+export const ExplicitEnsure = z
+ .object({
+ kind: z.literal('explicit'),
+ worktree: WorktreeSelector,
+ agent: z.enum(RESUMABLE_TUI_AGENTS),
+ providerSession: ProviderSession,
+ ompResumeFilePath: OmpResumeFilePath.optional(),
+ agentArgs: AgentArgs.optional(),
+ launchPreferences: LaunchPreferences.optional(),
+ presentation: Presentation.optional(),
+ placement: Placement.optional()
+ })
+ .strict()
+ .superRefine((value, context) => {
+ if (value.ompResumeFilePath !== undefined && value.agent !== 'omp') {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['ompResumeFilePath'],
+ message: 'OMP resume path requires the OMP agent'
+ })
+ }
+ if (getAgentResumeArgv(value.agent, value.providerSession, value.ompResumeFilePath) === null) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['providerSession'],
+ message: 'Provider session is not resumable for this agent'
+ })
+ }
+ })
+
+export const EnsureAgentSessionParams: z.ZodType =
+ z.discriminatedUnion('kind', [AutomaticEnsure, ExplicitEnsure])
+
+export const CreateAgentSessionParams: z.ZodType = z
+ .object({
+ clientOperationId: z
+ .string()
+ .refine(
+ (value) => parseAgentSessionOperationTimestamp(value) !== null,
+ 'Invalid agent operation ID'
+ ),
+ worktree: WorktreeSelector,
+ agent: z.string().refine(isTuiAgent, 'Unknown agent preset'),
+ prompt: z
+ .string()
+ .refine(
+ (value) => Buffer.byteLength(value, 'utf8') <= MAX_PROMPT_BYTES,
+ 'Prompt is too large'
+ )
+ .optional(),
+ promptDelivery: PromptDelivery.optional(),
+ agentArgs: AgentArgs.optional(),
+ launchPreferences: LaunchPreferences.optional(),
+ startupCwd: z.string().min(1).max(MAX_WORKTREE_SELECTOR_LENGTH).optional(),
+ presentation: Presentation.optional(),
+ placement: Placement.optional(),
+ viewMode: z.enum(['terminal', 'chat']).optional()
+ })
+ .strict()
+ .superRefine((value, context) => {
+ if (value.promptDelivery === 'draft' && !value.prompt?.trim()) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['prompt'],
+ message: 'Draft delivery requires a non-empty prompt'
+ })
+ }
+ })
diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts
new file mode 100644
index 00000000000..d036af9177f
--- /dev/null
+++ b/src/shared/rpc-contract/ai-vault-params.ts
@@ -0,0 +1,73 @@
+import { z } from 'zod'
+import { parseExecutionHostId } from '../execution-host'
+import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../ai-vault-types'
+import { OptionalBoolean } from './rpc-param-primitives'
+import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../ai-vault-session-title'
+
+// Why: bound limit + scopePaths so a client cannot force an unbounded scan.
+// Each scopePath is a host-local match prefix (validated/capped, never used for
+// traversal); the count/length caps mirror the worktree-schemas bounding style.
+export const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
+
+export const AI_VAULT_LIMIT_MAX = 2000
+
+export const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => {
+ const parsed = parseExecutionHostId(value)
+ if (parsed?.kind === 'runtime') {
+ return parsed.id
+ }
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Invalid runtime execution host id'
+ })
+ return z.NEVER
+})
+
+export const AiVaultListSessionsParams = z
+ .object({
+ limit: z
+ .unknown()
+ .transform((value) =>
+ typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
+ )
+ .pipe(z.union([z.number().int(), z.undefined()]))
+ .optional(),
+ unlimited: OptionalBoolean,
+ force: OptionalBoolean,
+ scopePaths: z
+ .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH))
+ // Why: clamp instead of reject — scope paths only ever widen discovery, and
+ // rejecting would hard-break older/uncapped producers (web client, pre-cap
+ // desktop parents) that send more than the bound.
+ .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT))
+ .optional(),
+ // Why: desktop/web callers name the runtime host they are addressing; mobile
+ // omits it. The scan itself is host-local either way, so the id must never
+ // change what is scanned — it only restamps the shared cached result.
+ executionHostId: executionHostIdSchema.optional()
+ })
+ .superRefine((params, ctx) => {
+ if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) {
+ ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' })
+ }
+ })
+
+export const AiVaultPrepareSessionResumeParams = z.object({
+ agent: z.enum(AI_VAULT_AGENTS),
+ sessionId: z.string().min(1).max(512).optional(),
+ filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH),
+ codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(),
+ executionHostId: z.string().optional()
+})
+
+export const AiVaultSessionTitlesParams = z.object({
+ requests: z
+ .array(
+ z.object({
+ agent: z.enum(['claude', 'codex']),
+ sessionId: z.string().min(1).max(512),
+ transcriptPath: z.string().min(1).max(32_768).optional()
+ })
+ )
+ .max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)
+})
diff --git a/src/shared/rpc-contract/artifacts-params.ts b/src/shared/rpc-contract/artifacts-params.ts
new file mode 100644
index 00000000000..e5743485892
--- /dev/null
+++ b/src/shared/rpc-contract/artifacts-params.ts
@@ -0,0 +1,43 @@
+import { z } from 'zod'
+import {
+ ARTIFACT_MAX_CONTENT_BYTES,
+ ARTIFACT_MAX_REQUEST_BYTES,
+ artifactContentByteLength,
+ artifactWriteRequestByteLength
+} from '../artifacts'
+
+export const CloudOptions = {
+ apiUrl: z.string().max(2_048).optional(),
+ authToken: z.string().max(16_384).optional()
+}
+
+export const ListOptions = z.object({
+ ...CloudOptions,
+ cursor: z.string().min(1).max(2_048).optional()
+})
+
+export const SourceRequest = z.object({
+ sourceKey: z.string().min(1).max(32_768),
+ ...CloudOptions
+})
+
+export const WriteRequest = z
+ .object({
+ sourceKey: z.string().min(1).max(32_768),
+ content: z
+ .string()
+ .min(1)
+ .max(ARTIFACT_MAX_CONTENT_BYTES)
+ .refine((content) => artifactContentByteLength(content) <= ARTIFACT_MAX_CONTENT_BYTES, {
+ message: 'Artifact content exceeds the 10 MiB limit.'
+ }),
+ contentType: z.enum(['text/html', 'text/markdown']),
+ fileName: z.string().min(1).max(512),
+ title: z.string().max(512).optional(),
+ ...CloudOptions
+ })
+ .refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_MAX_REQUEST_BYTES, {
+ message: 'Artifact request exceeds the supported size.'
+ })
+
+export const ArtifactsDeleteParams = z.object({ id: z.string().min(1), ...CloudOptions })
diff --git a/src/shared/rpc-contract/automation-params.ts b/src/shared/rpc-contract/automation-params.ts
new file mode 100644
index 00000000000..fff5d5db615
--- /dev/null
+++ b/src/shared/rpc-contract/automation-params.ts
@@ -0,0 +1,198 @@
+import { z } from 'zod'
+import { isTuiAgent } from '../tui-agent-config'
+import {
+ OptionalBoolean,
+ OptionalPlainString,
+ OptionalPositiveInt,
+ OptionalString,
+ requiredNumber,
+ requiredString
+} from './rpc-param-primitives'
+import { normalizeExecutionHostId } from '../execution-host'
+import { isValidAutomationSchedule } from '../automation-schedule-parsing'
+import {
+ MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
+ normalizeAutomationPrecheckTimeoutSeconds
+} from '../automation-precheck'
+import type { TaskProviderIdentity as SharedTaskProviderIdentity } from '../task-source-context'
+
+export const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, {
+ message: 'Unknown provider'
+})
+
+export const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional()
+
+export const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional()
+
+export const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => {
+ const hostId = normalizeExecutionHostId(value)
+ if (!hostId) {
+ ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
+ return z.NEVER
+ }
+ return hostId
+})
+
+export const AutomationSchedule = requiredString('Missing trigger').refine(
+ isValidAutomationSchedule,
+ {
+ message: 'Invalid automation trigger'
+ }
+)
+
+export const AutomationPrecheck = z
+ .object({
+ command: requiredString('Missing precheck command'),
+ timeoutSeconds: OptionalPositiveInt.transform((value) =>
+ normalizeAutomationPrecheckTimeoutSeconds(value)
+ ).refine((value) => value <= MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, {
+ message: 'Precheck timeout is too large'
+ })
+ })
+ .nullable()
+ .optional()
+
+export const OptionalNullablePlainString = z
+ .unknown()
+ .transform((value) => (value === null || typeof value === 'string' ? value : undefined))
+ .pipe(z.union([z.string(), z.null(), z.undefined()]))
+ .optional()
+
+export const TaskProviderIdentity = z
+ .custom(
+ (value) =>
+ value !== null &&
+ typeof value === 'object' &&
+ 'provider' in value &&
+ ['github', 'gitlab', 'linear', 'jira'].includes(String(value.provider))
+ )
+ .optional()
+ .nullable()
+
+export const TaskSourceContext = z
+ .object({
+ kind: z.literal('task-source'),
+ provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
+ projectId: requiredString('Missing source project id'),
+ hostId: ExecutionHostId,
+ projectHostSetupId: OptionalNullablePlainString,
+ repoId: OptionalNullablePlainString,
+ providerIdentity: TaskProviderIdentity,
+ accountLabel: OptionalNullablePlainString
+ })
+ .optional()
+ .nullable()
+
+export const WorkspaceRunContext = z
+ .object({
+ kind: z.literal('workspace-run'),
+ projectId: requiredString('Missing run project id'),
+ hostId: ExecutionHostId,
+ projectHostSetupId: requiredString('Missing project host setup id'),
+ repoId: requiredString('Missing repo id'),
+ path: requiredString('Missing run path')
+ })
+ .optional()
+ .nullable()
+
+export const SshTargetGeneration = requiredNumber('Missing SSH target generation').refine(
+ (value) => Number.isSafeInteger(value) && value >= 1,
+ { message: 'Invalid SSH target generation' }
+)
+
+export const OwnedSshSelector = z.object({
+ kind: z.literal('ssh'),
+ targetId: requiredString('Missing SSH target id'),
+ targetGeneration: SshTargetGeneration
+})
+
+/** Orphan is accepted here, unlike a destination: a record with no executable host is still deletable. */
+export const OwnerPreconditionSelector = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('self') }),
+ OwnedSshSelector,
+ z.object({ kind: z.literal('orphan') })
+])
+
+export const DestinationSelector = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('self') }),
+ OwnedSshSelector
+])
+
+export const ExpectedOwner = z.object({ selector: OwnerPreconditionSelector }).optional()
+
+export const Destination = z.object({ selector: DestinationSelector }).optional()
+
+export const ListScopeSelector = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('self') }),
+ z.object({
+ kind: z.literal('ssh'),
+ targetId: requiredString('Missing SSH target id'),
+ expectedTargetGeneration: SshTargetGeneration
+ }),
+ z.object({ kind: z.literal('orphan') })
+])
+
+/** An omitted selector is the legacy request; old clients keep the authority's complete list. */
+export const AutomationList = z.object({ selector: ListScopeSelector.optional() })
+
+export const AutomationId = z.object({
+ id: requiredString('Missing automation id'),
+ expectedOwner: ExpectedOwner
+})
+
+export const AutomationRuns = z.object({
+ automationId: OptionalString,
+ expectedOwner: ExpectedOwner,
+ limit: OptionalPositiveInt,
+ cursor: OptionalString
+})
+
+export const AutomationCreate = z.object({
+ creationKey: OptionalString,
+ name: requiredString('Missing automation name'),
+ prompt: requiredString('Missing automation prompt'),
+ precheck: AutomationPrecheck,
+ agentId: TuiAgent,
+ runContext: WorkspaceRunContext,
+ sourceContext: TaskSourceContext,
+ repo: OptionalString,
+ workspace: OptionalString,
+ workspaceMode: AutomationWorkspaceMode,
+ baseBranch: OptionalPlainString,
+ setupDecision: SetupDecision,
+ reuseSession: OptionalBoolean,
+ timezone: OptionalString,
+ rrule: AutomationSchedule,
+ dtstart: requiredNumber('Missing trigger start time'),
+ enabled: OptionalBoolean,
+ missedRunGraceMinutes: OptionalPositiveInt,
+ destination: Destination
+})
+
+export const AutomationUpdateFields = z.object({
+ name: OptionalString,
+ prompt: OptionalString,
+ precheck: AutomationPrecheck,
+ agentId: TuiAgent.optional(),
+ runContext: WorkspaceRunContext,
+ sourceContext: TaskSourceContext,
+ repo: OptionalString,
+ workspace: OptionalString,
+ workspaceMode: AutomationWorkspaceMode,
+ // Why: update patches distinguish omitted from null so callers can clear a saved base branch.
+ baseBranch: OptionalNullablePlainString,
+ setupDecision: SetupDecision,
+ reuseSession: OptionalBoolean,
+ timezone: OptionalString,
+ rrule: AutomationSchedule.optional(),
+ dtstart: requiredNumber('Missing trigger start time').optional(),
+ enabled: OptionalBoolean,
+ missedRunGraceMinutes: OptionalPositiveInt
+})
+
+export const AutomationUpdate = z.object({
+ id: requiredString('Missing automation id'),
+ updates: AutomationUpdateFields,
+ expectedOwner: ExpectedOwner,
+ destination: Destination
+})
diff --git a/src/shared/rpc-contract/browser-core-params.ts b/src/shared/rpc-contract/browser-core-params.ts
new file mode 100644
index 00000000000..6cd0dccfbd3
--- /dev/null
+++ b/src/shared/rpc-contract/browser-core-params.ts
@@ -0,0 +1,5 @@
+import { BrowserTarget, requiredString } from './rpc-param-primitives'
+
+export const CertificateProceed = BrowserTarget.extend({
+ challengeId: requiredString('Missing required challengeId')
+})
diff --git a/src/shared/rpc-contract/browser-extras-params.ts b/src/shared/rpc-contract/browser-extras-params.ts
new file mode 100644
index 00000000000..421c8976608
--- /dev/null
+++ b/src/shared/rpc-contract/browser-extras-params.ts
@@ -0,0 +1,14 @@
+import { z } from 'zod'
+import { MouseButton, MouseXY } from './browser-params'
+import { OptionalFiniteNumber } from './rpc-param-primitives'
+
+export const MouseModifiers = z
+ .unknown()
+ .transform((v) => (Array.isArray(v) ? v : undefined))
+ .pipe(z.union([z.array(z.enum(['cmd', 'ctrl', 'alt', 'shift'])), z.undefined()]))
+ .optional()
+
+export const MouseClick = MouseXY.merge(MouseButton).extend({
+ radius: OptionalFiniteNumber,
+ modifiers: MouseModifiers
+})
diff --git a/src/shared/rpc-contract/browser-params.ts b/src/shared/rpc-contract/browser-params.ts
new file mode 100644
index 00000000000..141e9ffe8fd
--- /dev/null
+++ b/src/shared/rpc-contract/browser-params.ts
@@ -0,0 +1,355 @@
+import { z } from 'zod'
+import {
+ BrowserTarget,
+ OptionalBoolean,
+ OptionalFiniteNumber,
+ OptionalPlainString,
+ OptionalString,
+ requiredString,
+ requiredStringAllowingEmpty
+} from './rpc-param-primitives'
+
+export const Element = BrowserTarget.extend({
+ element: requiredString('Missing required --element')
+})
+
+export const Goto = BrowserTarget.extend({
+ url: requiredString('Missing required --url')
+})
+
+export const Fill = BrowserTarget.extend({
+ element: requiredString('Missing required --element'),
+ value: requiredStringAllowingEmpty('Missing required --value')
+})
+
+export const Type = BrowserTarget.extend({
+ input: requiredString('Missing required --input')
+})
+
+export const Select = BrowserTarget.extend({
+ element: requiredString('Missing required --element'),
+ value: z.custom((v) => typeof v === 'string', {
+ message: 'Missing required --value'
+ })
+})
+
+export const Scroll = BrowserTarget.extend({
+ direction: z.custom<'up' | 'down'>((v) => v === 'up' || v === 'down', {
+ message: 'Missing required --direction (up or down)'
+ }),
+ amount: z
+ .unknown()
+ .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined))
+ .pipe(z.union([z.number(), z.undefined()]))
+ .optional()
+})
+
+export const Screenshot = BrowserTarget.extend({
+ format: z
+ .unknown()
+ .transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined))
+ .pipe(z.union([z.enum(['png', 'jpeg']), z.undefined()]))
+ .optional()
+})
+
+export const Screencast = BrowserTarget.extend({
+ format: z
+ .unknown()
+ .optional()
+ .transform((v) => (v === 'png' ? 'png' : 'jpeg'))
+ .pipe(z.enum(['png', 'jpeg'])),
+ quality: OptionalFiniteNumber,
+ maxWidth: OptionalFiniteNumber,
+ maxHeight: OptionalFiniteNumber,
+ viewportWidth: OptionalFiniteNumber,
+ viewportHeight: OptionalFiniteNumber,
+ deviceScaleFactor: OptionalFiniteNumber,
+ mobile: OptionalBoolean,
+ everyNthFrame: OptionalFiniteNumber,
+ minFrameIntervalMs: OptionalFiniteNumber
+})
+
+export const FullScreenshot = BrowserTarget.extend({
+ format: z
+ .unknown()
+ .optional()
+ .transform((v) => (v === 'jpeg' ? 'jpeg' : 'png'))
+ .pipe(z.enum(['png', 'jpeg']))
+})
+
+export const Eval = BrowserTarget.extend({
+ expression: requiredString('Missing required --expression')
+})
+
+export const TabList = z.object({ worktree: OptionalString })
+
+// Why: --index xor --page must be present. The refine guards that invariant
+// so the dispatcher surfaces a single legible error instead of either shape
+// leaking into the runtime.
+//
+// `focus` is opt-in: when true, the runtime sends `browser:pane-focus` to
+// the renderer after the switch lands. The renderer surfaces the browser
+// pane only if the user is already on the targeted worktree; otherwise it
+// pre-stages per-worktree state silently. This avoids cross-worktree screen
+// theft when multiple agents drive browsers in parallel worktrees.
+export const TabSwitch = BrowserTarget.extend({
+ index: z
+ .unknown()
+ .transform((v) => (typeof v === 'number' ? v : undefined))
+ .pipe(z.union([z.number(), z.undefined()]))
+ .optional(),
+ focus: z.boolean().optional()
+}).refine(
+ (val) => {
+ if (val.page !== undefined) {
+ return true
+ }
+ return val.index !== undefined && Number.isInteger(val.index) && val.index >= 0
+ },
+ { message: 'Missing required --index (non-negative integer) or --page' }
+)
+
+export const TabShow = z.object({
+ page: requiredString('Missing required --page'),
+ worktree: OptionalString
+})
+
+export const TabCurrent = z.object({ worktree: OptionalString })
+
+export const TabClose = z.object({
+ index: z
+ .unknown()
+ .transform((v) => (typeof v === 'number' ? v : undefined))
+ .pipe(z.union([z.number(), z.undefined()]))
+ .optional(),
+ page: OptionalString,
+ worktree: OptionalString
+})
+
+export const TabSetProfile = BrowserTarget.extend({
+ profileId: requiredString('Missing required --profile')
+})
+
+export const TabProfileClone = BrowserTarget.extend({
+ profileId: requiredString('Missing required --profile')
+})
+
+export const ProfileCreate = z.object({
+ label: requiredString('Missing required --label'),
+ // Strict enum so unknown scope values surface validation errors instead of being
+ // silently coerced to 'isolated' (pr-bug-scan finding from #1397).
+ scope: z.enum(['isolated', 'imported']),
+ userAgentMode: z.enum(['clean', 'native']).optional()
+})
+
+export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') })
+
+export const ProfileImportFromBrowser = z.object({
+ profileId: requiredString('Missing required --profile'),
+ browserFamily: requiredString('Missing required --browser-family'),
+ browserProfile: OptionalString,
+ supportsPartitionSkippedCookies: z.literal(true).optional()
+})
+
+export const Drag = BrowserTarget.extend({
+ from: requiredString('Missing required --from and --to element refs'),
+ to: requiredString('Missing required --from and --to element refs')
+})
+
+export const Upload = BrowserTarget.extend({
+ element: requiredString('Missing required --element and --files'),
+ files: z.custom(
+ (v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === 'string'),
+ { message: 'Missing required --element and --files' }
+ )
+})
+
+export const Wait = BrowserTarget.extend({
+ selector: OptionalPlainString,
+ timeout: z
+ .unknown()
+ .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined))
+ .pipe(z.union([z.number(), z.undefined()]))
+ .optional(),
+ text: OptionalPlainString,
+ url: OptionalPlainString,
+ load: OptionalPlainString,
+ fn: OptionalPlainString,
+ state: OptionalPlainString
+})
+
+export const Check = BrowserTarget.extend({
+ element: requiredString('Missing required --element'),
+ checked: z
+ .unknown()
+ .optional()
+ .transform((v) => (v === undefined ? true : v))
+ .pipe(z.boolean())
+})
+
+export const Keypress = BrowserTarget.extend({
+ key: requiredString('Missing required --key')
+})
+
+export const SelectorPath = BrowserTarget.extend({
+ selector: requiredString('Missing required --selector and --path'),
+ path: requiredString('Missing required --selector and --path')
+})
+
+export const Highlight = BrowserTarget.extend({
+ selector: requiredString('Missing required --selector')
+})
+
+export const Exec = BrowserTarget.extend({
+ command: requiredString('Missing required --command')
+})
+
+export const Get = BrowserTarget.extend({
+ what: requiredString('Missing required --what'),
+ selector: OptionalString
+})
+
+export const Is = BrowserTarget.extend({
+ what: z.custom((v) => typeof v === 'string' && v.length > 0, {
+ message: 'Missing required --what and --element'
+ }),
+ selector: z.custom((v) => typeof v === 'string' && v.length > 0, {
+ message: 'Missing required --what and --element'
+ })
+})
+
+export const KeyboardInsert = BrowserTarget.extend({
+ text: requiredString('Missing required --text')
+})
+
+export const LimitParam = BrowserTarget.extend({
+ limit: OptionalFiniteNumber
+})
+
+export const Find = BrowserTarget.extend({
+ locator: requiredString('Missing required --locator, --value, and --action'),
+ value: requiredString('Missing required --locator, --value, and --action'),
+ action: requiredString('Missing required --locator, --value, and --action'),
+ text: OptionalString
+})
+
+export const CookieGet = BrowserTarget.extend({
+ url: OptionalPlainString
+})
+
+export const CookieSet = BrowserTarget.extend({
+ name: z.custom((v) => typeof v === 'string' && v.length > 0, {
+ message: 'Missing name or value'
+ }),
+ value: z.custom((v) => typeof v === 'string', {
+ message: 'Missing name or value'
+ }),
+ domain: OptionalPlainString,
+ path: OptionalPlainString,
+ secure: OptionalBoolean,
+ httpOnly: OptionalBoolean,
+ sameSite: OptionalPlainString,
+ expires: OptionalFiniteNumber
+})
+
+export const CookieDelete = BrowserTarget.extend({
+ name: requiredString('Missing cookie name'),
+ domain: OptionalPlainString,
+ url: OptionalPlainString
+})
+
+export const Viewport = BrowserTarget.extend({
+ width: z.custom((v) => typeof v === 'number' && v > 0, {
+ message: 'Width and height must be positive numbers'
+ }),
+ height: z.custom((v) => typeof v === 'number' && v > 0, {
+ message: 'Width and height must be positive numbers'
+ }),
+ deviceScaleFactor: OptionalFiniteNumber,
+ mobile: OptionalBoolean
+})
+
+export const Geolocation = BrowserTarget.extend({
+ latitude: z.custom((v) => typeof v === 'number', {
+ message: 'Missing latitude or longitude'
+ }),
+ longitude: z.custom((v) => typeof v === 'number', {
+ message: 'Missing latitude or longitude'
+ }),
+ accuracy: OptionalFiniteNumber
+})
+
+export const InterceptEnable = BrowserTarget.extend({
+ patterns: z
+ .unknown()
+ .transform((v) => (Array.isArray(v) ? (v as string[]) : undefined))
+ .pipe(z.union([z.array(z.string()), z.undefined()]))
+ .optional()
+})
+
+export const MouseXY = BrowserTarget.extend({
+ x: z.custom((v) => typeof v === 'number', {
+ message: 'Missing required x and y coordinates'
+ }),
+ y: z.custom((v) => typeof v === 'number', {
+ message: 'Missing required x and y coordinates'
+ })
+})
+
+export const MouseButton = BrowserTarget.extend({
+ button: OptionalPlainString
+})
+
+export const MouseWheel = BrowserTarget.extend({
+ dy: z.custom((v) => typeof v === 'number', {
+ message: 'Missing required --dy'
+ }),
+ dx: OptionalFiniteNumber
+})
+
+export const SetDevice = BrowserTarget.extend({
+ name: requiredString('Missing required --name')
+})
+
+export const SetOffline = BrowserTarget.extend({
+ state: OptionalPlainString
+})
+
+export const SetHeaders = BrowserTarget.extend({
+ headers: requiredString('Missing required --headers (JSON string)')
+})
+
+export const SetCredentials = BrowserTarget.extend({
+ user: z.custom((v) => typeof v === 'string' && v.length > 0, {
+ message: 'Missing required --user and --pass'
+ }),
+ pass: z.custom((v) => typeof v === 'string', {
+ message: 'Missing required --user and --pass'
+ })
+})
+
+export const SetMedia = BrowserTarget.extend({
+ colorScheme: OptionalPlainString,
+ reducedMotion: OptionalPlainString
+})
+
+export const ClipboardWrite = BrowserTarget.extend({
+ text: requiredString('Missing required --text')
+})
+
+export const DialogAccept = BrowserTarget.extend({
+ text: OptionalPlainString
+})
+
+export const StorageKey = BrowserTarget.extend({
+ key: requiredString('Missing required --key')
+})
+
+export const StorageKeyValue = BrowserTarget.extend({
+ key: z.custom((v) => typeof v === 'string' && v.length > 0, {
+ message: 'Missing required --key and --value'
+ }),
+ value: z.custom((v) => typeof v === 'string', {
+ message: 'Missing required --key and --value'
+ })
+})
diff --git a/src/shared/rpc-contract/browser-screencast-params.ts b/src/shared/rpc-contract/browser-screencast-params.ts
new file mode 100644
index 00000000000..016b7e0f83b
--- /dev/null
+++ b/src/shared/rpc-contract/browser-screencast-params.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod'
+
+export const ScreencastUnsubscribe = z.object({
+ subscriptionId: z.string().min(1, 'Missing required --subscription-id')
+})
diff --git a/src/shared/rpc-contract/browser-tab-create-params.ts b/src/shared/rpc-contract/browser-tab-create-params.ts
new file mode 100644
index 00000000000..1250b6ff2a3
--- /dev/null
+++ b/src/shared/rpc-contract/browser-tab-create-params.ts
@@ -0,0 +1,23 @@
+import { z } from 'zod'
+import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation'
+import { BrowserPageCreationPlacement } from '../browser-client-host-placement'
+import { OptionalString } from './rpc-param-primitives'
+
+export const BrowserTabCreateParams = z.object({
+ url: OptionalString,
+ worktree: OptionalString,
+ page: OptionalString,
+ profileId: OptionalString,
+ waitForRegistration: z.boolean().optional(),
+ activate: z.boolean().optional(),
+ // Why: `activate` says the caller wants the new tab selected; `navigation` says on whose screens.
+ // Absent, a paired caller means 'caller' — one device's create must not steer every other UI.
+ navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
+ targetGroupId: OptionalString,
+ placement: BrowserPageCreationPlacement.optional()
+})
+
+export const BrowserOpenUrlParams = z.object({
+ url: z.url(),
+ worktree: z.string().min(1)
+})
diff --git a/src/shared/rpc-contract/client-events-params.ts b/src/shared/rpc-contract/client-events-params.ts
new file mode 100644
index 00000000000..134c976e7d1
--- /dev/null
+++ b/src/shared/rpc-contract/client-events-params.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod'
+
+export const ClientEventsUnsubscribeParams = z.object({
+ subscriptionId: z
+ .unknown()
+ .transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
+ .pipe(z.string().min(1, 'Missing subscriptionId'))
+})
diff --git a/src/shared/rpc-contract/client-settings-params.ts b/src/shared/rpc-contract/client-settings-params.ts
new file mode 100644
index 00000000000..def24ddc703
--- /dev/null
+++ b/src/shared/rpc-contract/client-settings-params.ts
@@ -0,0 +1,123 @@
+import { z } from 'zod'
+import { isTaskProvider } from '../task-providers'
+import type { TaskProvider } from '../task-providers'
+import { isTuiAgent } from '../tui-agent-config'
+import { normalizeDisabledTuiAgents } from '../tui-agent-selection'
+import {
+ normalizeTuiAgentArgsRecord,
+ normalizeTuiAgentEnvRecord
+} from '../tui-agent-launch-defaults'
+import { normalizePRBotAuthorOverrides } from '../pr-bot-author-overrides'
+import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-params'
+
+export const TaskProviderParam = z.custom(isTaskProvider, {
+ message: 'Unknown task provider'
+})
+
+export const PRBotAuthorOverrideUpdate = z
+ .object({ author: z.string(), isBot: z.boolean() })
+ .strict()
+
+export const NativeChatSessionOptionPickBase = {
+ modelId: z.string().trim().min(1).max(512),
+ adoptModelAsLaunchDefault: z.boolean().optional()
+}
+
+export const NativeChatSessionOptionPick = z.union([
+ z
+ .object({
+ ...NativeChatSessionOptionPickBase,
+ optionId: z.enum(['model', 'effort']),
+ value: z.string().trim().min(1).max(512)
+ })
+ .strict(),
+ z
+ .object({
+ ...NativeChatSessionOptionPickBase,
+ optionId: z.enum(['fastMode', 'thinking']),
+ value: z.boolean()
+ })
+ .strict()
+])
+
+export const NativeChatSessionOptionsMutation = z.discriminatedUnion('type', [
+ z
+ .object({
+ type: z.literal('apply-picks'),
+ agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']),
+ picks: z.array(NativeChatSessionOptionPick).min(1).max(8)
+ })
+ .strict(),
+ z
+ .object({
+ type: z.literal('clear-model-if-missing'),
+ agent: z.enum(['claude', 'codex', 'gemini', 'cursor', 'grok']),
+ availableModelIds: z.array(z.string().trim().min(1).max(512)).min(1).max(256)
+ })
+ .strict()
+])
+
+export const GitHubProjectRef = z
+ .object({
+ owner: z.string(),
+ ownerType: z.enum(['organization', 'user']),
+ number: z.number().int(),
+ host: z.string().optional()
+ })
+ .strict()
+
+export const GitHubProjectSettings = z
+ .object({
+ pinned: z.array(GitHubProjectRef),
+ recent: z.array(
+ GitHubProjectRef.extend({
+ lastOpenedAt: z.string()
+ }).strict()
+ ),
+ lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()),
+ activeProject: GitHubProjectRef.nullable()
+ })
+ .strict()
+
+export const SettingsUpdate = z
+ .object({
+ worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(),
+ defaultTuiAgent: z
+ .unknown()
+ .transform((value) =>
+ value === null || value === 'blank' || isTuiAgent(value) ? value : undefined
+ )
+ .optional(),
+ disabledTuiAgents: z
+ .unknown()
+ .transform((value) => normalizeDisabledTuiAgents(value))
+ .optional(),
+ agentDefaultArgs: z
+ .unknown()
+ .transform((value) => normalizeTuiAgentArgsRecord(value))
+ .optional(),
+ agentDefaultEnv: z
+ .unknown()
+ .transform((value) => normalizeTuiAgentEnvRecord(value))
+ .optional(),
+ defaultTaskSource: TaskProviderParam.optional(),
+ visibleTaskProviders: z.array(TaskProviderParam).optional(),
+ defaultTaskViewPreset: z
+ .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all'])
+ .optional(),
+ experimentalNewWorktreeCardStyle: z.boolean().optional(),
+ agentStatusHooksEnabled: z.boolean().optional(),
+ defaultRepoSelection: z.array(z.string()).nullable().optional(),
+ defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
+ compactWorktreeCards: z.boolean().optional(),
+ minimaxGroupId: z.string().optional(),
+ minimaxUsageModels: z.string().optional(),
+ minimaxEndpoint: z.enum(['overseas', 'cn']).optional(),
+ githubProjects: GitHubProjectSettings.optional(),
+ prBotAuthorOverrides: z
+ .unknown()
+ .transform((value) => normalizePRBotAuthorOverrides(value))
+ .optional()
+ })
+ .strict()
+ .default({})
diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts
new file mode 100644
index 00000000000..df84a06a256
--- /dev/null
+++ b/src/shared/rpc-contract/client-ui-params.ts
@@ -0,0 +1,257 @@
+import { z } from 'zod'
+import { isFeatureTipId } from '../feature-tips'
+import {
+ WORKTREE_CARD_PROPERTIES,
+ normalizeWorktreeCardProperties
+} from '../worktree/card-properties'
+import { isPluginPanelTabKey } from '../plugins/plugin-manifest'
+import { isFeatureInteractionId } from '../feature-interactions'
+import type { FeatureInteractionId } from '../feature-interactions'
+import { ACTIVITY_GROUP_BY_VALUES, THREAD_READ_FILTER_VALUES } from '../agents-view-thread-filters'
+import { isReleaseChannel } from '../release-channel'
+import type { ReleaseChannel } from '../release-channel'
+import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields-params'
+import { TaskResumeState } from './task-resume-state-params'
+import { WorkspaceCleanup } from './workspace-cleanup-ui-params'
+import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance-params'
+
+export const NullableString = z.string().nullable()
+
+export const StringArray = z.array(z.string())
+
+export const FeatureTipIds = z.array(
+ z.custom(isFeatureTipId, { message: 'Unknown feature tip id' })
+)
+
+export const UnknownRecord = z.record(z.string(), z.unknown())
+
+export const UnknownRecordArray = z.array(UnknownRecord)
+
+export type StaticRightSidebarTab = (typeof STATIC_RIGHT_SIDEBAR_TABS)[number]
+
+// Derived from the shared union so a new card property cannot drift out of the
+// client schema — it previously omitted 'cli' and rejected the whole payload.
+export const WorktreeCardPropertyParam = z.enum(WORKTREE_CARD_PROPERTIES)
+
+export const WorktreeCardProperties = z
+ .array(WorktreeCardPropertyParam)
+ .transform((value) => normalizeWorktreeCardProperties(value))
+
+export const STATIC_RIGHT_SIDEBAR_TABS = [
+ 'explorer',
+ 'search',
+ 'vault',
+ 'workspaces',
+ 'pr-checks',
+ 'source-control',
+ 'checks',
+ 'ports'
+] as const
+
+// Plugin panels are open-ended `plugin:./` keys, so the
+// schema validates their shape rather than enumerating them.
+export const RightSidebarTabParam = z.custom(
+ (value) =>
+ typeof value === 'string' &&
+ (STATIC_RIGHT_SIDEBAR_TABS.includes(value as StaticRightSidebarTab) ||
+ isPluginPanelTabKey(value)),
+ { message: 'Unknown right sidebar tab' }
+)
+
+export const AgentActivityDisplayMode = z.enum(['compact', 'full'])
+
+export const StatusBarItem = z.enum([
+ 'claude',
+ 'codex',
+ 'gemini',
+ 'antigravity',
+ 'opencode-go',
+ 'kimi',
+ 'minimax',
+ 'grok',
+ 'ssh',
+ 'resource-usage',
+ 'ports'
+])
+
+export const WorkspaceStatusDefinition = z.object({
+ id: z.string(),
+ label: z.string(),
+ color: z.string().optional(),
+ icon: z.string().optional()
+})
+
+export const FeatureInteractionRecord = z
+ .object({
+ firstInteractedAt: z.number().finite().nonnegative(),
+ interactionCount: z.number().int().positive().optional()
+ })
+ .strict()
+
+export const FeatureInteractions = z
+ .record(z.string(), FeatureInteractionRecord)
+ .superRefine((value, ctx) => {
+ for (const id of Object.keys(value)) {
+ if (!isFeatureInteractionId(id)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Unknown feature interaction id: ${id}`,
+ path: [id]
+ })
+ }
+ }
+ })
+
+export const FeatureInteractionIdParam = z.custom(isFeatureInteractionId, {
+ message: 'Unknown feature interaction id'
+})
+
+export const TopLevelViewSchema = z.enum([
+ 'terminal',
+ 'settings',
+ 'tasks',
+ 'activity',
+ 'automations',
+ 'space',
+ 'skills',
+ 'artifacts',
+ 'mobile'
+])
+
+export const UiUpdateFields = z
+ .object({
+ lastActiveRepoId: NullableString.optional(),
+ lastActiveWorktreeId: NullableString.optional(),
+ // Why: sync hydration ignores this persisted startup view, so paired windows stay put.
+ activeView: TopLevelViewSchema.optional(),
+ sidebarWidth: z.number().finite().optional(),
+ rightSidebarOpen: z.boolean().optional(),
+ rightSidebarTab: RightSidebarTabParam.optional(),
+ rightSidebarExplorerView: z.enum(['files', 'search']).optional(),
+ rightSidebarWidth: z.number().finite().optional(),
+ markdownTocPanelWidth: z.number().finite().optional(),
+ combinedDiffFileTreeWidth: z.number().finite().optional(),
+ groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(),
+ showWorkspaceLineage: z.boolean().optional(),
+ sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(),
+ projectOrderBy: z.enum(['manual', 'recent']).optional(),
+ showActiveOnly: z.boolean().optional(),
+ hideSleepingWorkspaces: z.boolean().optional(),
+ showSleepingWorkspaces: z.boolean().optional(),
+ showInactiveWorkspaces: z.boolean().optional(),
+ workspaceHostScope: z.string().optional(),
+ visibleWorkspaceHostIds: z.array(z.string()).nullable().optional(),
+ agentsVisibleHostIds: z.array(z.string()).nullable().optional(),
+ agentsFilterRepoIds: StringArray.optional(),
+ agentsShowChildAgents: z.boolean().optional(),
+ agentsCompactMode: z.boolean().optional(),
+ agentsShowSearch: z.boolean().optional(),
+ agentsReadFilter: z.enum(THREAD_READ_FILTER_VALUES).optional(),
+ agentsGroupBy: z.enum(ACTIVITY_GROUP_BY_VALUES).optional(),
+ workspaceHostOrder: z.array(z.string()).optional(),
+ automationHostFilter: z
+ .union([
+ z.object({ kind: z.literal('all') }).strict(),
+ z.object({ kind: z.literal('host'), hostKey: z.string().min(1) }).strict()
+ ])
+ .optional(),
+ manualRepoOrder: z
+ .array(z.object({ hostId: z.string(), repoId: z.string() }).strict())
+ .optional(),
+ ...ClientUiWorkspaceFilterFields,
+ // Why: rides App.tsx's debounced writer, so omitting it rejected that entire
+ // payload (sidebar widths, filters, agent acks) for every paired client.
+ showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(),
+ collapsedGroups: StringArray.optional(),
+ uiZoomLevel: z.number().finite().optional(),
+ editorFontZoomLevel: z.number().finite().optional(),
+ worktreeCardProperties: WorktreeCardProperties.optional(),
+ _worktreeCardModeDefaulted: z.boolean().optional(),
+ agentActivityDisplayMode: AgentActivityDisplayMode.optional(),
+ workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(),
+ workspaceBoardOpacity: z.number().finite().optional(),
+ workspaceBoardColumnWidth: z.number().finite().optional(),
+ syncTaskStatusFromWorkspaceBoard: z.boolean().optional(),
+ _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(),
+ _workspaceStatusesReorderedDefaultRepaired: z.boolean().optional(),
+ _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(),
+ _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(),
+ statusBarItems: z.array(StatusBarItem).optional(),
+ _portsStatusBarDefaultAdded: z.boolean().optional(),
+ _kimiStatusBarDefaultAdded: z.boolean().optional(),
+ _minimaxStatusBarDefaultAdded: z.boolean().optional(),
+ _antigravityStatusBarDefaultAdded: z.boolean().optional(),
+ _grokStatusBarDefaultAdded: z.boolean().optional(),
+ statusBarVisible: z.boolean().optional(),
+ usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
+ statusBarUsageMode: z.enum(['verbose', 'compact']).optional(),
+ dismissedUpdateVersion: NullableString.optional(),
+ dismissedUnexpectedSignoutVersion: NullableString.optional(),
+ lastUpdateCheckAt: z.number().finite().nullable().optional(),
+ pendingUpdateNudgeId: NullableString.optional(),
+ dismissedUpdateNudgeId: NullableString.optional(),
+ // Why the predicate rather than an inline z.enum: an enum here is a copy of
+ // RELEASE_CHANNELS, and a copy that drifts silently rejects the new
+ // channel's override on its way here — the picker moves, nothing installs.
+ releaseChannelOverride: z.custom(isReleaseChannel).nullable().optional(),
+ notificationPermissionRequested: z.boolean().optional(),
+ updateReassuranceSeen: z.boolean().optional(),
+ osc52ClipboardDefaultOnNoticePending: z.boolean().optional(),
+ acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
+ activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(),
+ manuallyUnreadTurnsByPaneKey: z.record(z.string(), z.number().finite()).optional(),
+ browserDefaultUrl: NullableString.optional(),
+ browserDefaultSearchEngine: z
+ .enum(['google', 'duckduckgo', 'bing', 'kagi'])
+ .nullable()
+ .optional(),
+ browserDefaultZoomLevel: z.number().finite().optional(),
+ browserKagiSessionLink: NullableString.optional(),
+ windowBounds: z
+ .object({
+ x: z.number().finite(),
+ y: z.number().finite(),
+ width: z.number().finite(),
+ height: z.number().finite()
+ })
+ .nullable()
+ .optional(),
+ windowMaximized: z.boolean().optional(),
+ _sortBySmartMigrated: z.boolean().optional(),
+ _inlineAgentsDefaultedForExperiment: z.boolean().optional(),
+ _inlineAgentsDefaultedForAllUsers: z.boolean().optional(),
+ trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(),
+ setupScriptPromptDismissedRepoIds: StringArray.optional(),
+ // Why: one-shot dismissals the renderer writes through ui.set; each was a
+ // whole-payload rejection for paired clients while unlisted.
+ setupGuideSidebarDismissed: z.boolean().optional(),
+ setupGuideBrowserMilestoneMigrated: z.boolean().optional(),
+ setupGuideBrowserMilestoneLegacyComplete: z.boolean().optional(),
+ browserImportHintHidden: z.boolean().optional(),
+ mobileEmulatorTabIntroDismissed: z.boolean().optional(),
+ mobileEmulatorAgentSetupDismissed: z.boolean().optional(),
+ projectOrderManualDefaultNoticeDismissed: z.boolean().optional(),
+ usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(),
+ usageEmptyStateDismissed: z.boolean().optional(),
+ petVisible: z.boolean().optional(),
+ petId: z.string().optional(),
+ customPets: UnknownRecordArray.optional(),
+ petSize: z.number().finite().optional(),
+ sidekickVisible: z.boolean().optional(),
+ sidekickId: z.string().optional(),
+ customSidekicks: UnknownRecordArray.optional(),
+ sidekickSize: z.number().finite().optional(),
+ taskResumeState: TaskResumeState.optional(),
+ workspaceCleanup: WorkspaceCleanup.optional(),
+ featureTipsSeenIds: FeatureTipIds.optional(),
+ featureInteractions: FeatureInteractions.optional(),
+ contextualToursSeenIds: StringArray.optional(),
+ contextualToursAutoEligible: z.boolean().optional()
+ })
+ .strict()
+
+export const UiUpdate = z
+ .object(tolerateUnknownValues(UiUpdateFields.shape))
+ .strict()
+ .default({})
+ .transform(omitUndefinedValues)
diff --git a/src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts b/src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts
new file mode 100644
index 00000000000..d0239b03ec3
--- /dev/null
+++ b/src/shared/rpc-contract/client-ui-workspace-filter-fields-params.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod'
+
+export const ClientUiWorkspaceFilterFields = {
+ hideDefaultBranchWorkspace: z.boolean().optional(),
+ hideAutomationGeneratedWorkspaces: z.boolean().optional(),
+ hideCliCreatedWorkspaces: z.boolean().optional(),
+ hideDetachedHeadWorkspaces: z.boolean().optional(),
+ hideWorkspacesFromOtherDevices: z.boolean().optional(),
+ alwaysShowDefaultBranchWorkspace: z.boolean().optional(),
+ filterRepoIds: z.array(z.string()).optional()
+}
diff --git a/src/shared/rpc-contract/clipboard-params.ts b/src/shared/rpc-contract/clipboard-params.ts
new file mode 100644
index 00000000000..3f2b1948775
--- /dev/null
+++ b/src/shared/rpc-contract/clipboard-params.ts
@@ -0,0 +1,67 @@
+import { z } from 'zod'
+import {
+ CLIPBOARD_IMAGE_MAX_BASE64_CHARS,
+ CLIPBOARD_IMAGE_TOO_LARGE_ERROR
+} from '../clipboard-image'
+
+export const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS
+
+export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
+
+export const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/
+
+export function isValidBase64(value: string): boolean {
+ return value.length % 4 !== 1 && BASE64_PATTERN.test(value)
+}
+
+export function clipboardImageBase64Payload(maxChars: number, tooLargeMessage: string) {
+ return z.unknown().transform((value, ctx): string => {
+ if (typeof value !== 'string') {
+ ctx.addIssue({ code: 'custom', message: 'Missing image content' })
+ return z.NEVER
+ }
+ if (value.length > maxChars) {
+ ctx.addIssue({ code: 'custom', message: tooLargeMessage })
+ return z.NEVER
+ }
+ if (!isValidBase64(value)) {
+ ctx.addIssue({ code: 'custom', message: 'Clipboard image content must be base64' })
+ return z.NEVER
+ }
+ return value
+ })
+}
+
+export const SaveImageAsTempFile = z.object({
+ contentBase64: clipboardImageBase64Payload(
+ MAX_CLIPBOARD_IMAGE_BASE64_CHARS,
+ CLIPBOARD_IMAGE_TOO_LARGE_ERROR
+ ),
+ connectionId: z.string().min(1).nullable().optional()
+})
+
+export const StartImageUpload = z.object({
+ expectedBase64Length: z
+ .number()
+ .int()
+ .nonnegative()
+ .max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, CLIPBOARD_IMAGE_TOO_LARGE_ERROR),
+ connectionId: z.string().min(1).nullable().optional()
+})
+
+export const AppendImageUploadChunk = z.object({
+ uploadId: z.string().min(1),
+ offset: z.number().int().nonnegative(),
+ contentBase64: clipboardImageBase64Payload(
+ CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
+ 'Clipboard image chunk is too large'
+ )
+})
+
+export const CommitImageUpload = z.object({
+ uploadId: z.string().min(1)
+})
+
+export const AbortImageUpload = z.object({
+ uploadId: z.string().min(1)
+})
diff --git a/src/shared/rpc-contract/computer-params.ts b/src/shared/rpc-contract/computer-params.ts
new file mode 100644
index 00000000000..08ffd1a6cfa
--- /dev/null
+++ b/src/shared/rpc-contract/computer-params.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod'
+
+export const ComputerPermissionsStatusParams = z.object({})
+
+export const ComputerCapabilitiesParams = z.object({})
diff --git a/src/shared/rpc-contract/computer-schemas-params.ts b/src/shared/rpc-contract/computer-schemas-params.ts
new file mode 100644
index 00000000000..d1802de3eff
--- /dev/null
+++ b/src/shared/rpc-contract/computer-schemas-params.ts
@@ -0,0 +1,227 @@
+import { z } from 'zod'
+import {
+ OptionalBoolean,
+ OptionalFiniteNumber,
+ OptionalString,
+ requiredString,
+ requiredStringAllowingEmpty
+} from './rpc-param-primitives'
+import {
+ computerUseClickModifiersValidationMessage,
+ computerUseHotkeyValidationMessage,
+ computerUsePressKeyValidationMessage
+} from '../computer-use-key-spec'
+
+export const OptionalNonNegativeInt = z.number().int().nonnegative().optional()
+
+export const OptionalPositiveInt = z.number().int().positive().optional()
+
+export const ComputerTarget = z.object({
+ app: requiredString('Missing app'),
+ session: OptionalString,
+ worktree: OptionalString
+})
+
+export const ComputerObserveTargetBase = ComputerTarget.extend({
+ noScreenshot: OptionalBoolean,
+ restoreWindow: OptionalBoolean,
+ windowId: OptionalNonNegativeInt,
+ windowIndex: OptionalNonNegativeInt
+})
+
+export function validateWindowTarget(
+ value: { windowId?: number; windowIndex?: number },
+ ctx: z.RefinementCtx
+): void {
+ if (value.windowId !== undefined && value.windowIndex !== undefined) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Window targeting accepts either --window-id or --window-index, not both'
+ })
+ }
+}
+
+export function validateComputerTarget(
+ value: { session?: string; worktree?: string; windowId?: number; windowIndex?: number },
+ ctx: z.RefinementCtx
+): void {
+ if (value.session !== undefined && value.worktree !== undefined) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Computer-use targeting accepts either session or worktree, not both'
+ })
+ }
+ validateWindowTarget(value, ctx)
+}
+
+export const ComputerObserveTarget = ComputerObserveTargetBase.superRefine(validateComputerTarget)
+
+export const ListApps = z.object({}).strict()
+
+export const ListWindows = z
+ .object({
+ app: requiredString('Missing app')
+ })
+ .strict()
+
+export const Click = ComputerObserveTargetBase.extend({
+ elementIndex: OptionalNonNegativeInt,
+ x: OptionalFiniteNumber,
+ y: OptionalFiniteNumber,
+ clickCount: OptionalPositiveInt,
+ mouseButton: z.enum(['left', 'right', 'middle']).optional(),
+ modifiers: z.string().optional()
+}).superRefine((value, ctx) => {
+ validateComputerTarget(value, ctx)
+ const hasElement = value.elementIndex !== undefined
+ const hasX = value.x !== undefined
+ const hasY = value.y !== undefined
+ if (!hasElement && !(hasX && hasY)) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Click requires --element-index or both --x and --y'
+ })
+ }
+ if (hasX !== hasY) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Click coordinates require both --x and --y'
+ })
+ }
+ if (hasElement && (hasX || hasY)) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Click accepts either --element-index or coordinate flags, not both'
+ })
+ }
+ if (value.modifiers !== undefined) {
+ const message = computerUseClickModifiersValidationMessage(value.modifiers)
+ if (message) {
+ ctx.addIssue({ code: 'custom', message })
+ }
+ }
+})
+
+export const PerformSecondaryAction = ComputerObserveTargetBase.extend({
+ elementIndex: OptionalNonNegativeInt,
+ action: requiredString('Missing action')
+}).superRefine((value, ctx) => {
+ validateComputerTarget(value, ctx)
+ if (value.elementIndex === undefined) {
+ ctx.addIssue({ code: 'custom', message: 'Missing element index' })
+ }
+})
+
+export const Scroll = ComputerObserveTargetBase.extend({
+ elementIndex: OptionalNonNegativeInt,
+ x: OptionalFiniteNumber,
+ y: OptionalFiniteNumber,
+ direction: z.enum(['up', 'down', 'left', 'right']),
+ pages: z.number().positive().optional()
+}).superRefine((value, ctx) => {
+ validateComputerTarget(value, ctx)
+ const hasElement = value.elementIndex !== undefined
+ const hasX = value.x !== undefined
+ const hasY = value.y !== undefined
+ if (!hasElement && !(hasX && hasY)) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Scroll requires --element-index or both --x and --y'
+ })
+ }
+ if (hasX !== hasY) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Scroll coordinates require both --x and --y'
+ })
+ }
+ if (hasElement && (hasX || hasY)) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Scroll accepts either --element-index or coordinate flags, not both'
+ })
+ }
+})
+
+export const Drag = ComputerObserveTargetBase.extend({
+ fromElementIndex: OptionalNonNegativeInt,
+ toElementIndex: OptionalNonNegativeInt,
+ fromX: OptionalFiniteNumber,
+ fromY: OptionalFiniteNumber,
+ toX: OptionalFiniteNumber,
+ toY: OptionalFiniteNumber
+}).superRefine((value, ctx) => {
+ validateComputerTarget(value, ctx)
+ const hasElementPair = value.fromElementIndex !== undefined && value.toElementIndex !== undefined
+ const hasPartialElementPair =
+ value.fromElementIndex !== undefined || value.toElementIndex !== undefined
+ const coordinateKeys = [value.fromX, value.fromY, value.toX, value.toY]
+ const hasCoordinatePair = coordinateKeys.every((coordinate) => coordinate !== undefined)
+ const hasPartialCoordinatePair = coordinateKeys.some((coordinate) => coordinate !== undefined)
+ if (hasElementPair && hasCoordinatePair) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Drag accepts either element indexes or coordinate flags, not both'
+ })
+ }
+ if (!hasElementPair && !hasCoordinatePair) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Drag requires --from-element-index and --to-element-index, or all coordinate flags'
+ })
+ }
+ if (hasPartialElementPair && !hasElementPair) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Drag element targeting requires both --from-element-index and --to-element-index'
+ })
+ }
+ if (hasPartialCoordinatePair && !hasCoordinatePair) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'Drag coordinates require --from-x, --from-y, --to-x, and --to-y'
+ })
+ }
+})
+
+export const TypeText = ComputerObserveTargetBase.extend({
+ text: requiredString('Missing text')
+}).superRefine(validateComputerTarget)
+
+export const PressKey = ComputerObserveTargetBase.extend({
+ key: requiredString('Missing key')
+}).superRefine((value, ctx) => {
+ validateComputerTarget(value, ctx)
+ const message = computerUsePressKeyValidationMessage(value.key)
+ if (message) {
+ ctx.addIssue({ code: 'custom', message })
+ }
+})
+
+export const Hotkey = ComputerObserveTargetBase.extend({
+ key: requiredString('Missing key')
+}).superRefine((value, ctx) => {
+ validateComputerTarget(value, ctx)
+ const message = computerUseHotkeyValidationMessage(value.key)
+ if (message) {
+ ctx.addIssue({ code: 'custom', message })
+ }
+})
+
+export const ComputerPermissions = z.object({
+ id: z.enum(['accessibility', 'screenshots']).optional()
+})
+
+export const PasteText = ComputerObserveTargetBase.extend({
+ text: requiredString('Missing text')
+}).superRefine(validateComputerTarget)
+
+export const SetValue = ComputerObserveTargetBase.extend({
+ elementIndex: OptionalNonNegativeInt,
+ value: requiredStringAllowingEmpty('Missing value')
+}).superRefine((value, ctx) => {
+ validateComputerTarget(value, ctx)
+ if (value.elementIndex === undefined) {
+ ctx.addIssue({ code: 'custom', message: 'Missing element index' })
+ }
+})
diff --git a/src/shared/rpc-contract/emulator-params.ts b/src/shared/rpc-contract/emulator-params.ts
new file mode 100644
index 00000000000..401e1ee05aa
--- /dev/null
+++ b/src/shared/rpc-contract/emulator-params.ts
@@ -0,0 +1,154 @@
+import { z } from 'zod'
+
+// Minimal schemas for emulator commands (loose for initial testing; can be tightened like browser-schemas).
+export const WorktreeParam = z.object({ worktree: z.string().optional() }).partial()
+
+export const TapParams = z.object({
+ x: z.number().min(0).max(1),
+ y: z.number().min(0).max(1),
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const GesturePoint = z.object({
+ edge: z.number().int().min(0).max(4).optional(),
+ type: z.enum(['begin', 'move', 'end']),
+ x: z.number().min(0).max(1),
+ y: z.number().min(0).max(1)
+})
+
+export const GestureParams = z.object({
+ points: z.array(GesturePoint).min(2).max(64),
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const TypeParams = z.object({
+ text: z.string(),
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const ButtonParams = z.object({
+ name: z.string(),
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const RotateOrientation = z.enum([
+ 'portrait',
+ 'portrait_upside_down',
+ 'landscape_left',
+ 'landscape_right'
+])
+
+export const RotateParams = z.object({
+ orientation: RotateOrientation,
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const ExecParams = z.object({
+ command: z.string(),
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const LaunchParams = z.object({
+ package: z.string(),
+ activity: z.string().optional(),
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const PermissionsParams = z
+ .object({
+ op: z.enum(['grant', 'revoke', 'reset']),
+ package: z.string().optional(),
+ permission: z.string().optional(),
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+ })
+ .superRefine((value, ctx) => {
+ if (value.op === 'reset') {
+ if (value.package) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['package'],
+ message: 'package is not allowed for reset'
+ })
+ }
+ if (value.permission) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['permission'],
+ message: 'permission is not allowed for reset'
+ })
+ }
+ return
+ }
+ if (!value.package) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['package'],
+ message: 'package is required for grant/revoke'
+ })
+ }
+ if (!value.permission) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['permission'],
+ message: 'permission is required for grant/revoke'
+ })
+ }
+ })
+
+export const AxParams = z.object({
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const LogcatParams = z.object({
+ lines: z.number().int().positive().optional(),
+ filters: z.array(z.string()).optional(),
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const AttachParams = z.object({
+ device: z.string().optional(),
+ worktree: z.string().optional(),
+ focus: z.boolean().optional()
+})
+
+export const KillParams = z.object({
+ device: z.string().optional(),
+ emulator: z.string().optional(),
+ worktree: z.string().optional()
+})
+
+export const ShutdownParams = KillParams.extend({
+ managedOnly: z.boolean().optional()
+})
+
+export const ListParams = WorktreeParam
+
+export const EmulatorUnregisterActiveParams = z
+ .object({ worktree: z.string().optional() })
+ .partial()
+
+export const EmulatorListDevicesParams = z.object({ worktree: z.string().optional() }).partial()
+
+export const EmulatorAvailabilityParams = z.object({ worktree: z.string().optional() }).partial()
+
+export const EmulatorListSimulatorsParams = z.object({ worktree: z.string().optional() }).partial()
diff --git a/src/shared/rpc-contract/files-mutation-params.ts b/src/shared/rpc-contract/files-mutation-params.ts
new file mode 100644
index 00000000000..554d1f387e0
--- /dev/null
+++ b/src/shared/rpc-contract/files-mutation-params.ts
@@ -0,0 +1,84 @@
+import { z } from 'zod'
+import { FileOpen, WorktreeSelector } from './files-target-params'
+
+export const RUNTIME_FILE_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/
+
+export function isValidRuntimeFileBase64(value: unknown): value is string {
+ return (
+ typeof value === 'string' && value.length % 4 !== 1 && RUNTIME_FILE_BASE64_PATTERN.test(value)
+ )
+}
+
+export const FileMutationOpen = FileOpen.extend({
+ expectedExecutionHostId: z.string().min(1).optional(),
+ expectedSshTargetId: z.string().min(1).optional(),
+ expectedSshConnectionGeneration: z.number().int().nonnegative().optional()
+})
+
+// Why: write content must be a real string. Coercing a missing/non-string value
+// to '' silently truncated the target file to empty instead of erroring. An
+// explicit '' is still accepted (writing an empty file is legitimate).
+export const FileWrite = FileMutationOpen.extend({
+ content: z
+ .unknown()
+ .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
+})
+
+export const FileWriteBase64 = FileMutationOpen.extend({
+ contentBase64: z
+ .unknown()
+ .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
+ // Why: Buffer.from(..., 'base64') accepts malformed input by dropping
+ // invalid bytes, which can silently create empty or corrupt uploaded files.
+ .refine(isValidRuntimeFileBase64, 'File content must be base64')
+})
+
+export const FileWriteBase64Chunk = FileWriteBase64.extend({
+ append: z.boolean().optional()
+})
+
+export const FileRename = WorktreeSelector.extend({
+ expectedExecutionHostId: z.string().min(1).optional(),
+ expectedSshTargetId: z.string().min(1).optional(),
+ expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
+ oldRelativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing source path')),
+ newRelativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing destination path'))
+})
+
+export const FileCopy = WorktreeSelector.extend({
+ expectedExecutionHostId: z.string().min(1).optional(),
+ expectedSshTargetId: z.string().min(1).optional(),
+ expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
+ sourceRelativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing source path')),
+ destinationRelativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing destination path'))
+})
+
+export const FileCommitUpload = WorktreeSelector.extend({
+ expectedExecutionHostId: z.string().min(1).optional(),
+ expectedSshTargetId: z.string().min(1).optional(),
+ expectedSshConnectionGeneration: z.number().int().nonnegative().optional(),
+ tempRelativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing temporary path')),
+ finalRelativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing final path'))
+})
+
+export const FileDelete = FileMutationOpen.extend({
+ recursive: z.boolean().optional()
+})
diff --git a/src/shared/rpc-contract/files-params.ts b/src/shared/rpc-contract/files-params.ts
new file mode 100644
index 00000000000..6ae6267caa0
--- /dev/null
+++ b/src/shared/rpc-contract/files-params.ts
@@ -0,0 +1,99 @@
+import { z } from 'zod'
+import { QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS } from '../quick-open-path-search'
+import { FileOpen, WorktreeSelector } from './files-target-params'
+
+export const FilePathSearch = WorktreeSelector.extend({
+ query: z.string().max(QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS).default(''),
+ limit: z.number().int().positive().max(32).default(16),
+ excludePaths: z.array(z.string()).optional(),
+ mode: z.literal('quick-open').optional()
+})
+
+export const ResolveTerminalPath = WorktreeSelector.extend({
+ pathText: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing path text')),
+ terminal: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null))
+ .nullable()
+ .optional(),
+ cwd: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' && v.length > 0 ? v : null))
+ .nullable()
+ .optional(),
+ crossWorkspace: z
+ .unknown()
+ .transform((v) => v === true)
+ .optional(),
+ nativeChatContext: z
+ .object({
+ tabId: z.string().min(1),
+ sessionId: z.string().min(1)
+ })
+ .optional()
+})
+
+export const FileOpenDiff = FileOpen.extend({
+ staged: z.boolean().optional()
+})
+
+export const DocPreviewFileRead = FileOpen.extend({
+ entryRelativePath: z.string().min(1),
+ implicitRootRelativePath: z.string().nullable(),
+ authorizedRootRelativePaths: z.array(z.string())
+})
+
+export const FileTreePath = WorktreeSelector.extend({
+ relativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string())
+})
+
+export const ServerDirectoryBrowse = z.object({
+ path: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string())
+})
+
+export const FileReadChunk = FileOpen.extend({
+ offset: z.number().int().nonnegative(),
+ length: z
+ .number()
+ .int()
+ .positive()
+ .max(512 * 1024)
+})
+
+export const FileSearch = WorktreeSelector.extend({
+ query: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing search query')),
+ caseSensitive: z.boolean().optional(),
+ wholeWord: z.boolean().optional(),
+ useRegex: z.boolean().optional(),
+ includePattern: z.string().optional(),
+ excludePattern: z.string().optional(),
+ maxResults: z.number().int().positive().optional()
+})
+
+// Why: `maxResults` is a new optional field (wire rule 1) — an older host strips it and keeps its
+// own default. It existed only on the Electron IPC hop, so "the client names its cap and a full page
+// means there is more" was true for desktop and merely incidental for web and mobile, which were
+// saved by `remoteFileContentBudget` defaulting the cap inside `listRuntimeFiles`.
+export const FileListAll = WorktreeSelector.extend({
+ excludePaths: z.array(z.string()).optional(),
+ maxResults: z.number().int().positive().optional()
+})
+
+export const FileUnwatch = z.object({
+ subscriptionId: z
+ .unknown()
+ .transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
+ .pipe(z.string().min(1, 'Missing subscriptionId'))
+})
diff --git a/src/shared/rpc-contract/files-target-params.ts b/src/shared/rpc-contract/files-target-params.ts
new file mode 100644
index 00000000000..6c546b3605e
--- /dev/null
+++ b/src/shared/rpc-contract/files-target-params.ts
@@ -0,0 +1,15 @@
+import { z } from 'zod'
+
+export const WorktreeSelector = z.object({
+ worktree: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing worktree selector'))
+})
+
+export const FileOpen = WorktreeSelector.extend({
+ relativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing relative path'))
+})
diff --git a/src/shared/rpc-contract/files-terminal-artifact-params.ts b/src/shared/rpc-contract/files-terminal-artifact-params.ts
new file mode 100644
index 00000000000..2e7e4f8015b
--- /dev/null
+++ b/src/shared/rpc-contract/files-terminal-artifact-params.ts
@@ -0,0 +1,19 @@
+import { z } from 'zod'
+import { WorktreeSelector } from './files-target-params'
+
+export const TerminalArtifactFile = WorktreeSelector.extend({
+ grantId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing terminal artifact grant')),
+ absolutePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing terminal artifact path'))
+})
+
+export const TerminalArtifactFileWrite = TerminalArtifactFile.extend({
+ content: z
+ .unknown()
+ .refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
+})
diff --git a/src/shared/rpc-contract/folder-workspace-params.ts b/src/shared/rpc-contract/folder-workspace-params.ts
new file mode 100644
index 00000000000..7b416b0083c
--- /dev/null
+++ b/src/shared/rpc-contract/folder-workspace-params.ts
@@ -0,0 +1,85 @@
+import { z } from 'zod'
+import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema'
+import { TaskSourceContextSchema } from '../task-source-context-schema'
+import { isWorkspaceLinkedItemSourceContextMatch } from '../workspace-linked-item-source-context'
+import { isTuiAgent } from '../tui-agent-config'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+import { DiffCommentSchema } from '../diff-comment-schema'
+
+export const FolderWorkspaceLinkedTask = WorkspaceLinkedItemSchema.nullable()
+
+export function assertLinkedTaskSourceContextMatch(
+ value: {
+ linkedTask?: z.infer
+ linkedTaskSourceContext?: z.infer | null
+ },
+ ctx: z.RefinementCtx
+): void {
+ if (
+ value.linkedTask &&
+ value.linkedTaskSourceContext &&
+ !isWorkspaceLinkedItemSourceContextMatch(value.linkedTask, value.linkedTaskSourceContext)
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Linked task and source context identities must match'
+ })
+ }
+}
+
+export const FolderWorkspaceCreate = z
+ .object({
+ projectGroupId: requiredString('Missing project group id'),
+ name: OptionalString,
+ folderPath: OptionalString.nullable().optional(),
+ connectionId: OptionalString.nullable().optional(),
+ linkedTask: FolderWorkspaceLinkedTask.optional(),
+ linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
+ createdWithAgent: z.string().refine(isTuiAgent).optional(),
+ pendingFirstAgentMessageRename: z.boolean().optional()
+ })
+ .superRefine(assertLinkedTaskSourceContextMatch)
+
+export const FolderWorkspaceUpdate = z.object({
+ folderWorkspaceId: requiredString('Missing folder workspace id'),
+ updates: z
+ .object({
+ name: OptionalString,
+ folderPath: OptionalString,
+ linkedTask: FolderWorkspaceLinkedTask.optional(),
+ linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
+ comment: z.string().optional(),
+ isArchived: z.boolean().optional(),
+ isUnread: z.boolean().optional(),
+ isPinned: z.boolean().optional(),
+ sortOrder: OptionalFiniteNumber,
+ manualOrder: OptionalFiniteNumber,
+ workspaceStatus: OptionalString,
+ createdWithAgent: z.string().refine(isTuiAgent).optional(),
+ pendingFirstAgentMessageRename: z.boolean().optional(),
+ firstAgentMessageRenameError: z.string().nullable().optional(),
+ lastActivityAt: OptionalFiniteNumber,
+ diffComments: z.array(DiffCommentSchema).optional()
+ })
+ .superRefine(assertLinkedTaskSourceContextMatch)
+})
+
+export const FolderWorkspaceSelector = z.object({
+ folderWorkspaceId: requiredString('Missing folder workspace id')
+})
+
+export const FolderWorkspacePathStatus = z.discriminatedUnion('scope', [
+ z.object({
+ scope: z.literal('folder-workspace'),
+ folderWorkspaceId: requiredString('Missing folder workspace id')
+ }),
+ z.object({
+ scope: z.literal('project-group'),
+ projectGroupId: requiredString('Missing project group id')
+ }),
+ z.object({
+ scope: z.literal('path'),
+ path: requiredString('Missing folder path'),
+ connectionId: OptionalString.nullable().optional()
+ })
+])
diff --git a/src/shared/rpc-contract/git-admission-tier-params.ts b/src/shared/rpc-contract/git-admission-tier-params.ts
new file mode 100644
index 00000000000..e45173782f8
--- /dev/null
+++ b/src/shared/rpc-contract/git-admission-tier-params.ts
@@ -0,0 +1,14 @@
+import { z } from 'zod'
+
+// Why: the admission tier is part of the wire contract, so the literal union
+// lives with the schema; src/main re-exports it instead of redeclaring it.
+export type GitAdmissionTier = 'interactive' | 'status' | 'background'
+
+export const OptionalGitAdmissionTier = z
+ .unknown()
+ .optional()
+ .transform((value): GitAdmissionTier | undefined => {
+ return value === 'interactive' || value === 'status' || value === 'background'
+ ? value
+ : undefined
+ })
diff --git a/src/shared/rpc-contract/git-params.ts b/src/shared/rpc-contract/git-params.ts
new file mode 100644
index 00000000000..6e4cb35b8ef
--- /dev/null
+++ b/src/shared/rpc-contract/git-params.ts
@@ -0,0 +1,271 @@
+import { z } from 'zod'
+import { OptionalGitAdmissionTier } from './git-admission-tier-params'
+
+export const WorktreeSelector = z.object({
+ worktree: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing worktree selector'))
+})
+
+export const GitStatusParams = WorktreeSelector.extend({
+ admissionTier: OptionalGitAdmissionTier,
+ includeIgnored: z.boolean().optional(),
+ includeLineStats: z.boolean().optional(),
+ bypassEffectiveUpstreamNegativeCache: z.boolean().optional(),
+ reuseLineStats: z.boolean().optional(),
+ // Shape is re-validated host-side before it reaches a git argv.
+ branchLineTotalMergeBase: z.string().optional()
+})
+
+export const GitCheckIgnored = WorktreeSelector.extend({
+ paths: z.array(z.string().min(1, 'Missing path')).max(2000)
+})
+
+export const GitSubmoduleStatus = WorktreeSelector.extend({
+ submodulePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(
+ z
+ .string()
+ .min(1, 'Missing submodule path')
+ // Why: never let a submodule path be parsed as a git flag (arg injection).
+ .refine((value) => !value.startsWith('-'), 'Submodule path must not start with -')
+ ),
+ // Why: submodule expansion is requested from a Source Control row; the row
+ // area determines whether the gitlink range is HEAD->index or index->worktree.
+ area: z.enum(['staged', 'unstaged', 'untracked']).optional()
+})
+
+export const GitFilePath = WorktreeSelector.extend({
+ filePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing file path'))
+})
+
+export const GitDiff = GitFilePath.extend({
+ staged: z.boolean(),
+ compareAgainstHead: z.boolean().optional()
+})
+
+export const GitBranchCompare = WorktreeSelector.extend({
+ admissionTier: OptionalGitAdmissionTier,
+ baseRef: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(
+ z
+ .string()
+ .min(1, 'Missing base ref')
+ .refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
+ )
+})
+
+export const FullGitObjectId = z
+ .string()
+ .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id')
+
+export const GitCommitCompare = WorktreeSelector.extend({
+ commitId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(FullGitObjectId)
+})
+
+export const GitHistory = WorktreeSelector.extend({
+ limit: z.number().int().min(1).max(200).optional(),
+ baseRef: z.string().nullable().optional()
+})
+
+export const GitBranchDiff = GitFilePath.extend({
+ compare: z.object({
+ baseRef: z.string().optional(),
+ baseOid: FullGitObjectId.optional(),
+ headOid: FullGitObjectId,
+ mergeBase: FullGitObjectId
+ }),
+ oldPath: z.string().optional()
+})
+
+export const GitCommitDiff = GitFilePath.extend({
+ commitOid: FullGitObjectId,
+ parentOid: FullGitObjectId.nullable().optional(),
+ oldPath: z.string().optional()
+})
+
+export const GitCommit = WorktreeSelector.extend({
+ message: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing commit message'))
+})
+
+export const CommitMessageModelCapability = z.object({
+ id: z.string(),
+ label: z.string(),
+ thinkingLevels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
+ defaultThinkingLevel: z.string().optional()
+})
+
+export const CommitMessageAiSettings = z.object({
+ enabled: z.boolean(),
+ agentId: z.string().nullable(),
+ selectedModelByAgent: z.record(z.string(), z.string()),
+ selectedModelByAgentByHost: z.record(z.string(), z.record(z.string(), z.string())).optional(),
+ discoveredModelsByAgent: z.record(z.string(), z.array(CommitMessageModelCapability)).optional(),
+ discoveredModelsByAgentByHost: z
+ .record(z.string(), z.record(z.string(), z.array(CommitMessageModelCapability)))
+ .optional(),
+ selectedThinkingByModel: z.record(z.string(), z.string()),
+ customPrompt: z.string(),
+ customAgentCommand: z.string()
+})
+
+export const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: true }).extend({
+ actions: z
+ .record(
+ z.string(),
+ z.object({
+ agentId: z.string().nullable().optional(),
+ commandInputTemplate: z.string().optional(),
+ agentArgs: z.string().optional()
+ })
+ )
+ .optional(),
+ instructionsByOperation: z.record(z.string(), z.string()).optional(),
+ modelOverridesByOperation: z
+ .record(
+ z.string(),
+ z.object({
+ selectedModelByAgent: z.record(z.string(), z.string()).optional(),
+ selectedModelByAgentByHost: z
+ .record(z.string(), z.record(z.string(), z.string()))
+ .optional(),
+ selectedThinkingByModel: z.record(z.string(), z.string()).optional()
+ })
+ )
+ .optional(),
+ prCreationDefaults: z
+ .object({
+ draft: z.boolean().optional(),
+ useTemplate: z.boolean().optional(),
+ generateDetailsOnOpen: z.boolean().optional(),
+ openAfterCreate: z.boolean().optional()
+ })
+ .optional(),
+ launchActionDefaults: z
+ .record(
+ z.string(),
+ z.object({
+ agentId: z.string().nullable().optional(),
+ commandInputTemplate: z.string().optional(),
+ agentArgs: z.string().optional()
+ })
+ )
+ .optional()
+})
+
+export const ResolvedSourceControlAiGenerationParams = z.object({
+ agentId: z.string(),
+ model: z.string(),
+ thinkingLevel: z.string().optional(),
+ customPrompt: z.string().optional(),
+ commandInputTemplate: z.string().optional(),
+ agentArgs: z.string().optional(),
+ customAgentCommand: z.string().optional(),
+ agentCommandOverride: z.string().optional()
+})
+
+export const GitGenerateCommitMessage = WorktreeSelector.extend({
+ commitMessageAi: CommitMessageAiSettings.optional(),
+ sourceControlAi: SourceControlAiSettings.optional(),
+ sourceControlAiResolvedParams: ResolvedSourceControlAiGenerationParams.optional(),
+ agentCmdOverrides: z.record(z.string(), z.string()).optional(),
+ commitMessageDiscoveryHostKey: z.string().optional()
+})
+
+export const GitDiscoverCommitMessageModels = WorktreeSelector.extend({
+ agentId: z.string().min(1, 'Missing agent id'),
+ agentCmdOverrides: z.record(z.string(), z.string()).optional()
+})
+
+export const GitGeneratePullRequestFields = GitGenerateCommitMessage.extend({
+ base: z.string().min(1, 'Missing base branch'),
+ title: z.string(),
+ body: z.string(),
+ draft: z.boolean(),
+ provider: z
+ .enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported'])
+ .optional(),
+ useTemplate: z.boolean().optional()
+})
+
+export const GitBulkPaths = WorktreeSelector.extend({
+ filePaths: z.array(z.string().min(1, 'Missing file path'))
+})
+
+export const GitPushTargetParam = z.object({
+ remoteName: z.string(),
+ branchName: z.string(),
+ remoteUrl: z.string().optional(),
+ remoteCreated: z.boolean().optional()
+})
+
+export const GitPush = WorktreeSelector.extend({
+ publish: z.boolean().optional(),
+ forceWithLease: z.boolean().optional(),
+ pushTarget: GitPushTargetParam.optional()
+})
+
+export const GitTargetedRemote = WorktreeSelector.extend({
+ pushTarget: GitPushTargetParam.optional()
+})
+
+export const GitForkSync = WorktreeSelector.extend({
+ expectedUpstream: z.object({
+ owner: z.string().trim().min(1),
+ repo: z.string().trim().min(1)
+ })
+})
+
+export const GitRebaseFromBase = WorktreeSelector.extend({
+ baseRef: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(
+ z
+ .string()
+ .min(1, 'Missing base ref')
+ .refine((value) => !value.startsWith('-'), 'Base ref must not start with -')
+ )
+})
+
+export const GitCheckout = WorktreeSelector.extend({
+ branch: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(
+ z
+ .string()
+ .min(1, 'Missing branch')
+ // Why: never let a branch arg be parsed as a git flag (arg injection).
+ .refine((value) => !value.startsWith('-'), 'Branch must not start with -')
+ )
+})
+
+export const GitRemoteFileUrl = WorktreeSelector.extend({
+ relativePath: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing relative path')),
+ line: z.number().int().min(1)
+})
+
+export const GitRemoteCommitUrl = WorktreeSelector.extend({
+ sha: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(FullGitObjectId)
+})
diff --git a/src/shared/rpc-contract/github-issue-params.ts b/src/shared/rpc-contract/github-issue-params.ts
new file mode 100644
index 00000000000..47effe410ff
--- /dev/null
+++ b/src/shared/rpc-contract/github-issue-params.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod'
+import { RepoSelector, SlugRepo } from './github-repo-target-params'
+import { requiredString } from './rpc-param-primitives'
+import { IssueUpdate } from './github-issue-update-params'
+
+export const Issue = RepoSelector.extend({
+ number: z.number().int().positive()
+})
+
+export const CreateIssue = RepoSelector.extend({
+ title: requiredString('Missing title'),
+ body: z.string(),
+ labels: z.array(z.string()).optional(),
+ assignees: z.array(z.string()).optional()
+})
+
+export const UpdateIssue = RepoSelector.extend({
+ number: z.number().int().positive(),
+ updates: IssueUpdate
+})
+
+export const IssueComment = RepoSelector.extend({
+ number: z.number().int().positive(),
+ body: requiredString('Comment body required'),
+ type: z.enum(['issue', 'pr']).optional(),
+ prRepo: SlugRepo.nullable().optional()
+})
diff --git a/src/shared/rpc-contract/github-issue-update-params.ts b/src/shared/rpc-contract/github-issue-update-params.ts
new file mode 100644
index 00000000000..40eb7ab7d51
--- /dev/null
+++ b/src/shared/rpc-contract/github-issue-update-params.ts
@@ -0,0 +1,13 @@
+import { z } from 'zod'
+import { OptionalString } from './rpc-param-primitives'
+
+// Why: repo-selector and slug-addressed issue updates must accept the identical field set.
+export const IssueUpdate = z.object({
+ state: z.enum(['open', 'closed']).optional(),
+ title: OptionalString,
+ body: OptionalString,
+ addLabels: z.array(z.string()).optional(),
+ removeLabels: z.array(z.string()).optional(),
+ addAssignees: z.array(z.string()).optional(),
+ removeAssignees: z.array(z.string()).optional()
+})
diff --git a/src/shared/rpc-contract/github-project-params.ts b/src/shared/rpc-contract/github-project-params.ts
new file mode 100644
index 00000000000..1a7a0d76f0c
--- /dev/null
+++ b/src/shared/rpc-contract/github-project-params.ts
@@ -0,0 +1,128 @@
+import { z } from 'zod'
+import { SlugRepo } from './github-repo-target-params'
+import { OptionalString, requiredString } from './rpc-param-primitives'
+import { IssueUpdate } from './github-issue-update-params'
+
+export const SlugAssignableUsers = SlugRepo.extend({
+ seedLogins: z.array(z.string()).optional()
+})
+
+export const ProjectOwnerType = z.enum(['organization', 'user'])
+
+export const ProjectViewTable = z.object({
+ owner: requiredString('Missing owner'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ ownerType: ProjectOwnerType,
+ projectNumber: z.number().int().positive(),
+ viewId: OptionalString,
+ viewNumber: z.number().int().positive().optional(),
+ viewName: OptionalString,
+ queryOverride: OptionalString
+})
+
+export const ProjectWorkItemDetailsBySlug = SlugRepo.extend({
+ number: z.number().int().positive(),
+ type: z.enum(['issue', 'pr'])
+})
+
+export const ProjectRef = z.object({
+ input: requiredString('Missing project reference'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString
+})
+
+export const ProjectViews = z.object({
+ owner: requiredString('Missing owner'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ ownerType: ProjectOwnerType,
+ projectNumber: z.number().int().positive()
+})
+
+export const ProjectItemField = z.object({
+ projectId: requiredString('Missing project ID'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ itemId: requiredString('Missing item ID'),
+ fieldId: requiredString('Missing field ID'),
+ value: z.any()
+})
+
+export const ClearProjectItemField = z.object({
+ projectId: requiredString('Missing project ID'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ itemId: requiredString('Missing item ID'),
+ fieldId: requiredString('Missing field ID')
+})
+
+export const SlugIssueUpdate = z.object({
+ owner: requiredString('Missing owner'),
+ repo: requiredString('Missing repo'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ number: z.number().int().positive(),
+ updates: IssueUpdate
+})
+
+export const SlugPullRequestUpdate = z.object({
+ owner: requiredString('Missing owner'),
+ repo: requiredString('Missing repo'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ number: z.number().int().positive(),
+ updates: z.object({
+ state: z.enum(['open', 'closed']).optional(),
+ title: OptionalString,
+ body: OptionalString
+ })
+})
+
+export const SlugIssueTypeUpdate = z.object({
+ owner: requiredString('Missing owner'),
+ repo: requiredString('Missing repo'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ number: z.number().int().positive(),
+ issueTypeId: z.string().nullable()
+})
+
+export const SlugIssueComment = z.object({
+ owner: requiredString('Missing owner'),
+ repo: requiredString('Missing repo'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ number: z.number().int().positive(),
+ body: requiredString('Comment body required')
+})
+
+export const SlugIssueCommentEdit = z.object({
+ owner: requiredString('Missing owner'),
+ repo: requiredString('Missing repo'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ commentId: z.number().int().positive(),
+ body: requiredString('Comment body required')
+})
+
+export const SlugIssueCommentDelete = z.object({
+ owner: requiredString('Missing owner'),
+ repo: requiredString('Missing repo'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ commentId: z.number().int().positive()
+})
+
+export const GithubProjectListAccessibleParams = z.object({ host: OptionalString })
diff --git a/src/shared/rpc-contract/github-pull-request-params.ts b/src/shared/rpc-contract/github-pull-request-params.ts
new file mode 100644
index 00000000000..6762e8ea130
--- /dev/null
+++ b/src/shared/rpc-contract/github-pull-request-params.ts
@@ -0,0 +1,79 @@
+import { z } from 'zod'
+import type { GitHubPRRefreshReason } from '../github/pull-request-refresh-types'
+import { RepoSelector, SlugRepo } from './github-repo-target-params'
+import { OptionalString, requiredString } from './rpc-param-primitives'
+
+export const OptionalPRRefreshReason = z
+ .unknown()
+ .optional()
+ .transform((value): GitHubPRRefreshReason | undefined => {
+ return value === 'visible' ||
+ value === 'active' ||
+ value === 'post-push' ||
+ value === 'manual' ||
+ value === 'swr'
+ ? value
+ : undefined
+ })
+
+export const PrForBranch = RepoSelector.extend({
+ branch: requiredString('Missing branch'),
+ reason: OptionalPRRefreshReason,
+ linkedPRNumber: z.number().int().positive().nullable().optional(),
+ fallbackPRNumber: z.number().int().positive().nullable().optional(),
+ acceptMergedFallbackPR: z.boolean().optional(),
+ currentHeadOid: z.string().nullable().optional()
+})
+
+export const PullRequest = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ noCache: z.boolean().optional(),
+ prRepo: SlugRepo.nullable().optional()
+})
+
+export const PRCommentReaction = RepoSelector.extend({
+ reactionSubjectId: requiredString('Missing reaction subject ID'),
+ content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']),
+ reacted: z.boolean(),
+ prRepo: SlugRepo.nullable().optional()
+})
+
+export const PullRequestChecks = PullRequest.extend({
+ headSha: OptionalString
+})
+
+export const PullRequestCheckDetails = RepoSelector.extend({
+ checkRunId: z.number().int().positive().optional(),
+ workflowRunId: z.number().int().positive().optional(),
+ checkName: OptionalString,
+ url: OptionalString.nullable().optional(),
+ prRepo: SlugRepo.nullable().optional()
+})
+
+export const RerunPullRequestChecks = PullRequest.extend({
+ headSha: OptionalString,
+ failedOnly: z.boolean().optional()
+})
+
+export const PullRequestFileContents = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ prRepo: SlugRepo.nullable().optional(),
+ path: requiredString('Missing file path'),
+ oldPath: OptionalString,
+ status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']),
+ headSha: requiredString('Missing head SHA'),
+ baseSha: requiredString('Missing base SHA')
+})
+
+export const PullRequestFileViewed = RepoSelector.extend({
+ prRepo: SlugRepo.nullable().optional(),
+ pullRequestId: requiredString('Missing pull request ID'),
+ path: requiredString('Missing file path'),
+ viewed: z.boolean()
+})
+
+export const ReviewThread = RepoSelector.extend({
+ prRepo: SlugRepo.nullable().optional(),
+ threadId: requiredString('Missing thread ID'),
+ resolve: z.boolean()
+})
diff --git a/src/shared/rpc-contract/github-pull-request-update-params.ts b/src/shared/rpc-contract/github-pull-request-update-params.ts
new file mode 100644
index 00000000000..f5fcc38ef73
--- /dev/null
+++ b/src/shared/rpc-contract/github-pull-request-update-params.ts
@@ -0,0 +1,76 @@
+import { z } from 'zod'
+import { RepoSelector, SlugRepo } from './github-repo-target-params'
+import { OptionalString, requiredString } from './rpc-param-primitives'
+
+export const UpdatePrTitle = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ title: requiredString('Missing title'),
+ prRepo: SlugRepo.nullable().optional()
+})
+
+export const UpdatePr = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ updates: z.object({
+ title: OptionalString,
+ body: z.string().optional()
+ }),
+ prRepo: SlugRepo.nullable().optional()
+})
+
+export const MergePr = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ method: z.enum(['merge', 'squash', 'rebase']).optional(),
+ prRepo: SlugRepo.nullable().optional()
+})
+
+export const SetPrAutoMerge = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ enabled: z.boolean(),
+ method: z.enum(['merge', 'squash', 'rebase']).optional(),
+ prRepo: SlugRepo.nullable().optional()
+})
+
+export const UpdatePrState = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ prRepo: SlugRepo.nullable().optional(),
+ updates: z.object({
+ state: z.enum(['open', 'closed'])
+ })
+})
+
+export const MarkPrReadyForReview = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ prRepo: SlugRepo.nullable().optional()
+})
+
+export const RequestPrReviewers = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ prRepo: SlugRepo.nullable().optional(),
+ reviewers: z.array(z.string()).min(1)
+})
+
+export const RemovePrReviewers = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ prRepo: SlugRepo.nullable().optional(),
+ reviewers: z.array(z.string()).min(1)
+})
+
+export const PRReviewComment = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ prRepo: SlugRepo.nullable().optional(),
+ commitId: requiredString('Missing PR head SHA'),
+ path: requiredString('File path required'),
+ line: z.number().int().positive(),
+ startLine: z.number().int().positive().optional(),
+ body: requiredString('Comment body required')
+})
+
+export const PRReviewCommentReply = RepoSelector.extend({
+ prNumber: z.number().int().positive(),
+ commentId: z.number().int().positive(),
+ body: requiredString('Comment body required'),
+ threadId: OptionalString,
+ path: OptionalString,
+ line: z.number().int().positive().optional(),
+ prRepo: SlugRepo.nullable().optional()
+})
diff --git a/src/shared/rpc-contract/github-repo-target-params.ts b/src/shared/rpc-contract/github-repo-target-params.ts
new file mode 100644
index 00000000000..199e90ad388
--- /dev/null
+++ b/src/shared/rpc-contract/github-repo-target-params.ts
@@ -0,0 +1,14 @@
+import { z } from 'zod'
+import { OptionalString, requiredString } from './rpc-param-primitives'
+
+export const RepoSelector = z.object({
+ repo: requiredString('Missing repo selector')
+})
+
+export const SlugRepo = z.object({
+ owner: requiredString('Missing owner'),
+ repo: requiredString('Missing repo'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString
+})
diff --git a/src/shared/rpc-contract/github-repo-work-item-params.ts b/src/shared/rpc-contract/github-repo-work-item-params.ts
new file mode 100644
index 00000000000..ecee149054f
--- /dev/null
+++ b/src/shared/rpc-contract/github-repo-work-item-params.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod'
+import { RepoSelector } from './github-repo-target-params'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+
+export const WorkItemsList = RepoSelector.extend({
+ limit: OptionalFiniteNumber,
+ query: OptionalString,
+ page: z.number().int().positive().optional(),
+ noCache: z.boolean().optional()
+})
+
+export const IssuesList = RepoSelector.extend({
+ limit: OptionalFiniteNumber
+})
+
+export const WorkItem = RepoSelector.extend({
+ number: z.number().int().positive(),
+ type: z.enum(['issue', 'pr']).optional()
+})
+
+export const WorkItemByOwnerRepo = RepoSelector.extend({
+ owner: requiredString('Missing owner'),
+ ownerRepo: requiredString('Missing repo'),
+ // Why: Enterprise host identity must survive RPC parsing; Zod strips
+ // undeclared fields before the runtime can host-qualify gh requests.
+ host: OptionalString,
+ number: z.number().int().positive(),
+ type: z.enum(['issue', 'pr'])
+})
+
+export const WorkItemDetails = WorkItem
+
+export const WorkItemsCount = RepoSelector.extend({
+ query: OptionalString
+})
+
+export const RateLimit = z.object({
+ force: z.boolean().optional()
+})
diff --git a/src/shared/rpc-contract/gitlab-params.ts b/src/shared/rpc-contract/gitlab-params.ts
new file mode 100644
index 00000000000..4165f190968
--- /dev/null
+++ b/src/shared/rpc-contract/gitlab-params.ts
@@ -0,0 +1,149 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+
+export const RepoSelector = z.object({
+ repo: requiredString('Missing repo selector')
+})
+
+export const EmptyParams = z.object({}).optional().default({})
+
+export const GitLabRateLimit = z
+ .object({
+ force: z.boolean().optional(),
+ host: OptionalString
+ })
+ .optional()
+ .default({})
+
+// nullish, not optional: renderer callers normalise a missing ref to `null`
+// (`item.projectRef ?? null`), which a bare `.optional()` would reject outright.
+export const GitLabProjectRef = z
+ .object({
+ host: requiredString('Missing GitLab host'),
+ path: requiredString('Missing GitLab project path')
+ })
+ .nullish()
+
+export const WorkItemsList = RepoSelector.extend({
+ state: z.enum(['opened', 'merged', 'closed', 'all']).optional(),
+ page: OptionalFiniteNumber,
+ perPage: OptionalFiniteNumber,
+ query: OptionalString
+})
+
+export const IssuesList = RepoSelector.extend({
+ state: z.unknown().optional(),
+ assignee: OptionalString,
+ limit: OptionalFiniteNumber,
+ page: OptionalFiniteNumber
+})
+
+export const CreateIssue = RepoSelector.extend({
+ title: requiredString('Missing title'),
+ body: z.string()
+})
+
+export const IssueUpdate = z.object({
+ state: z.enum(['opened', 'closed']).optional(),
+ title: z.string().optional(),
+ body: z.string().optional(),
+ addLabels: z.array(z.string()).optional(),
+ removeLabels: z.array(z.string()).optional(),
+ addAssignees: z.array(z.string()).optional(),
+ removeAssignees: z.array(z.string()).optional()
+})
+
+export const UpdateIssue = RepoSelector.extend({
+ number: z.number().int().positive(),
+ updates: IssueUpdate,
+ projectRef: GitLabProjectRef
+})
+
+export const UpdateMrState = RepoSelector.extend({
+ iid: z.number().int().positive(),
+ state: z.enum(['opened', 'closed']),
+ projectRef: GitLabProjectRef
+})
+
+export const UpdateMr = RepoSelector.extend({
+ iid: z.number().int().positive(),
+ updates: z.object({
+ title: z.string().optional(),
+ body: z.string().optional(),
+ addLabels: z.array(z.string()).optional(),
+ removeLabels: z.array(z.string()).optional(),
+ readyForReview: z.literal(true).optional()
+ }),
+ projectRef: GitLabProjectRef
+})
+
+export const UpdateMrReviewers = RepoSelector.extend({
+ iid: z.number().int().positive(),
+ reviewerIds: z.array(z.number().int().nonnegative()),
+ projectRef: GitLabProjectRef
+})
+
+export const MergeMr = RepoSelector.extend({
+ iid: z.number().int().positive(),
+ method: z.enum(['merge', 'squash', 'rebase']).optional(),
+ projectRef: GitLabProjectRef
+})
+
+export const AddIssueComment = RepoSelector.extend({
+ number: z.number().int().positive(),
+ body: requiredString('Comment body is required'),
+ projectRef: GitLabProjectRef
+})
+
+export const AddMRComment = RepoSelector.extend({
+ iid: z.number().int().positive(),
+ body: requiredString('Comment body is required'),
+ projectRef: GitLabProjectRef
+})
+
+export const AddMRInlineComment = RepoSelector.extend({
+ iid: z.number().int().positive(),
+ input: z.object({
+ body: requiredString('Comment body is required'),
+ path: requiredString('File path is required'),
+ oldPath: z.string().optional(),
+ line: z.number().int().positive(),
+ baseSha: requiredString('Base SHA is required'),
+ startSha: requiredString('Start SHA is required'),
+ headSha: requiredString('Head SHA is required')
+ }),
+ projectRef: GitLabProjectRef
+})
+
+export const ResolveMRDiscussion = RepoSelector.extend({
+ iid: z.number().int().positive(),
+ discussionId: requiredString('Discussion id is required'),
+ resolved: z.boolean(),
+ projectRef: GitLabProjectRef
+})
+
+export const JobTrace = RepoSelector.extend({
+ jobId: z.number().int().positive(),
+ projectRef: GitLabProjectRef,
+ // Why: raw CI traces routinely exceed the 1 MB transport frame cap, so callers
+ // that only render an excerpt ask main to bound it before it crosses the wire.
+ logExcerpt: z.boolean().optional()
+})
+
+export const RetryJob = RepoSelector.extend({
+ jobId: z.number().int().positive(),
+ projectRef: GitLabProjectRef
+})
+
+export const WorkItemDetails = RepoSelector.extend({
+ iid: z.number().int().positive(),
+ type: z.enum(['issue', 'mr']),
+ projectRef: GitLabProjectRef
+})
+
+export const WorkItemByPath = RepoSelector.extend({
+ host: requiredString('Missing GitLab host'),
+ path: requiredString('Missing GitLab project path'),
+ iid: z.number().int().positive(),
+ type: z.enum(['issue', 'mr'])
+})
diff --git a/src/shared/rpc-contract/hosted-review-params.ts b/src/shared/rpc-contract/hosted-review-params.ts
new file mode 100644
index 00000000000..cb9ec7683cc
--- /dev/null
+++ b/src/shared/rpc-contract/hosted-review-params.ts
@@ -0,0 +1,47 @@
+import { z } from 'zod'
+import { requiredString } from './rpc-param-primitives'
+import { OptionalGitAdmissionTier } from './git-admission-tier-params'
+
+export const HostedReviewForBranch = z.object({
+ repo: requiredString('Missing repo selector'),
+ branch: requiredString('Missing branch'),
+ admissionTier: OptionalGitAdmissionTier,
+ currentHeadOid: z.string().nullable().optional(),
+ // Only the caller's selected worktree; the host caps how many earn the fast tier.
+ active: z.boolean().optional(),
+ linkedGitHubPR: z.number().int().positive().nullable().optional(),
+ fallbackGitHubPR: z.number().int().positive().nullable().optional(),
+ linkedGitLabMR: z.number().int().positive().nullable().optional(),
+ linkedBitbucketPR: z.number().int().positive().nullable().optional(),
+ linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(),
+ linkedGiteaPR: z.number().int().positive().nullable().optional()
+})
+
+export const HostedReviewCreationEligibility = z.object({
+ repo: requiredString('Missing repo selector'),
+ worktree: z.string().min(1, 'Missing worktree selector').optional(),
+ branch: requiredString('Missing branch'),
+ base: z.string().nullable().optional(),
+ hasUncommittedChanges: z.boolean().optional(),
+ hasUpstream: z.boolean().optional(),
+ ahead: z.number().int().nonnegative().optional(),
+ behind: z.number().int().nonnegative().optional(),
+ linkedGitHubPR: z.number().int().positive().nullable().optional(),
+ fallbackGitHubPR: z.number().int().positive().nullable().optional(),
+ linkedGitLabMR: z.number().int().positive().nullable().optional(),
+ linkedBitbucketPR: z.number().int().positive().nullable().optional(),
+ linkedAzureDevOpsPR: z.number().int().positive().nullable().optional(),
+ linkedGiteaPR: z.number().int().positive().nullable().optional()
+})
+
+export const HostedReviewCreate = z.object({
+ repo: requiredString('Missing repo selector'),
+ worktree: z.string().min(1, 'Missing worktree selector').optional(),
+ provider: z.enum(['github', 'gitlab', 'bitbucket', 'azure-devops', 'gitea', 'unsupported']),
+ base: requiredString('Missing base branch'),
+ head: z.string().optional(),
+ title: requiredString('Missing title'),
+ body: z.string().optional(),
+ draft: z.boolean().optional(),
+ useTemplate: z.boolean().optional()
+})
diff --git a/src/shared/rpc-contract/jira-params.ts b/src/shared/rpc-contract/jira-params.ts
new file mode 100644
index 00000000000..b550124fd7f
--- /dev/null
+++ b/src/shared/rpc-contract/jira-params.ts
@@ -0,0 +1,101 @@
+import { z } from 'zod'
+import {
+ OptionalFiniteNumber,
+ OptionalPlainString,
+ OptionalString,
+ requiredString
+} from './rpc-param-primitives'
+
+export const VALID_FILTERS = ['assigned', 'reported', 'all', 'done'] as const
+
+export const SiteSelection = z
+ .object({
+ siteId: OptionalString
+ })
+ .optional()
+
+export const Connect = z.object({
+ siteUrl: requiredString('Site URL is required'),
+ // Self-hosted PAT auth needs no email; connect() enforces it for Cloud.
+ email: OptionalPlainString,
+ apiToken: requiredString('API token is required'),
+ authType: z.enum(['cloud', 'server']).optional()
+})
+
+export const SelectSite = z.object({
+ siteId: requiredString('Site ID is required')
+})
+
+export const SearchIssues = z.object({
+ jql: requiredString('Missing JQL'),
+ limit: OptionalFiniteNumber,
+ siteId: OptionalString
+})
+
+export const ListIssues = z
+ .object({
+ filter: z.enum(VALID_FILTERS).optional(),
+ limit: OptionalFiniteNumber,
+ siteId: OptionalString
+ })
+ .optional()
+
+export const IssueKey = z.object({
+ key: requiredString('Issue key is required'),
+ siteId: OptionalString
+})
+
+export const CreateIssue = z.object({
+ siteId: OptionalString,
+ projectId: requiredString('Project is required'),
+ issueTypeId: requiredString('Issue type is required'),
+ title: requiredString('Title is required'),
+ description: OptionalPlainString,
+ customFields: z.record(z.string(), z.unknown()).optional(),
+ userFieldKeys: z.array(z.string()).optional()
+})
+
+export const IssueUpdate = z.object({
+ key: requiredString('Issue key is required'),
+ siteId: OptionalString,
+ updates: z.object({
+ title: OptionalString,
+ labels: z.array(z.string()).optional(),
+ assigneeAccountId: z.union([z.string(), z.null()]).optional(),
+ priorityId: z.union([z.string(), z.null()]).optional(),
+ transitionId: OptionalString
+ })
+})
+
+export const IssueComment = z.object({
+ key: requiredString('Issue key is required'),
+ body: requiredString('Comment body is required'),
+ siteId: OptionalString
+})
+
+export const ProjectIssueTypes = z.object({
+ projectIdOrKey: requiredString('Project is required'),
+ siteId: OptionalString
+})
+
+export const ProjectIssueTypeFields = z.object({
+ projectIdOrKey: requiredString('Project is required'),
+ issueTypeId: requiredString('Issue type is required'),
+ siteId: OptionalString
+})
+
+export const AssignableUsers = z.object({
+ key: requiredString('Issue key is required'),
+ query: OptionalPlainString,
+ siteId: OptionalString
+})
+
+export const UserSearch = z.object({
+ query: OptionalPlainString,
+ siteId: OptionalString
+})
+
+export const ProjectStatusOrder = z.object({
+ projectKey: requiredString('Project key is required'),
+ siteId: OptionalString
+})
diff --git a/src/shared/rpc-contract/linear-agent-access-params.ts b/src/shared/rpc-contract/linear-agent-access-params.ts
new file mode 100644
index 00000000000..ac1c627c6cd
--- /dev/null
+++ b/src/shared/rpc-contract/linear-agent-access-params.ts
@@ -0,0 +1,146 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+
+export const LINEAR_DUE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
+
+export const LinearDueDate = z.string().refine((value) => LINEAR_DUE_DATE_PATTERN.test(value), {
+ message: 'Linear due dates must use YYYY-MM-DD'
+})
+
+export const OptionalLinearDueDate = LinearDueDate.optional()
+
+export const OptionalLinearDueDateOrClear = z.union([LinearDueDate, z.null()]).optional()
+
+export const AgentSearchIssues = z.object({
+ query: requiredString('Missing query'),
+ limit: OptionalFiniteNumber,
+ workspaceId: z.union([z.string(), z.literal('all')]).optional()
+})
+
+export const LinearWorkspaceRead = z.object({
+ workspaceId: z.union([z.string(), z.literal('all')]).optional()
+})
+
+export const LinearTeamLookup = z.object({
+ teamInput: requiredString('Missing team'),
+ workspaceId: OptionalString.refine((value) => value !== 'all', {
+ message: '--workspace all is only valid for team list'
+ })
+})
+
+export const LinearIssueList = z.object({
+ filter: z.enum(['assigned', 'created', 'all', 'completed', 'open']).optional(),
+ teamInput: OptionalString,
+ limit: OptionalFiniteNumber,
+ workspaceId: z.union([z.string(), z.literal('all')]).optional()
+})
+
+export const LinearProjectList = z.object({
+ query: OptionalString,
+ limit: OptionalFiniteNumber,
+ workspaceId: z.union([z.string(), z.literal('all')]).optional()
+})
+
+export const LinearIncludeFlags = z.object({
+ comments: z.boolean(),
+ children: z.boolean(),
+ attachments: z.boolean(),
+ relations: z.boolean(),
+ activity: z.boolean().default(false)
+})
+
+export const LinearCurrentContext = z
+ .object({
+ worktreeId: OptionalString,
+ terminalHandle: OptionalString,
+ cwd: OptionalString,
+ remote: z.boolean().optional()
+ })
+ .optional()
+
+export const LinearWriteTarget = z.object({
+ input: OptionalString,
+ current: z.boolean().optional(),
+ workspaceId: OptionalString.refine((value) => value !== 'all', {
+ message: '--workspace all is not valid for Linear writes'
+ }),
+ context: LinearCurrentContext
+})
+
+export const AgentIssueContext = z.object({
+ input: OptionalString,
+ current: z.boolean().optional(),
+ workspaceId: OptionalString,
+ include: LinearIncludeFlags,
+ depth: z.number().int().min(0).max(5),
+ context: LinearCurrentContext
+})
+
+export const LinearIssueSetState = LinearWriteTarget.extend({
+ to: requiredString('Missing target state')
+})
+
+export const LinearIssueUpdateTask = LinearWriteTarget.extend({
+ operation: z.enum(['assignee', 'priority', 'estimate', 'dueDate', 'labels']),
+ assigneeId: z.string().nullable().optional(),
+ assigneeMe: z.boolean().optional(),
+ priority: z.number().int().min(0).max(4).optional(),
+ estimate: z.number().int().min(0).nullable().optional(),
+ dueDate: OptionalLinearDueDateOrClear,
+ labelMode: z.enum(['add', 'remove', 'set']).optional(),
+ labels: z.array(z.string()).optional()
+})
+
+export const LinearIssueAddComment = LinearWriteTarget.extend({
+ body: requiredString('Missing comment body'),
+ replyTo: OptionalString,
+ writeId: OptionalString
+})
+
+export const LinearIssueRelationWrite = LinearWriteTarget.extend({
+ relatedInput: requiredString('Missing related issue'),
+ relationship: z.enum(['blocks', 'blockedBy', 'relatedTo', 'duplicateOf']),
+ operation: z.enum(['add', 'remove'])
+})
+
+export const LinearIssueAttachLink = LinearWriteTarget.extend({
+ url: requiredString('Missing attachment URL'),
+ title: OptionalString,
+ writeId: OptionalString
+})
+
+export const LinearIssueCreate = z.object({
+ title: requiredString('Missing issue title'),
+ body: OptionalString,
+ teamInput: OptionalString,
+ teamKey: OptionalString,
+ state: OptionalString,
+ assignee: OptionalString,
+ priority: z.number().int().min(0).max(4).optional(),
+ estimate: z.number().int().min(0).optional(),
+ dueDate: OptionalLinearDueDate,
+ labels: z.array(z.string()).optional(),
+ projectInput: OptionalString,
+ parentInput: OptionalString,
+ parentCurrent: z.boolean().optional(),
+ workspaceId: OptionalString.refine((value) => value !== 'all', {
+ message: '--workspace all is not valid for Linear writes'
+ }),
+ writeId: OptionalString,
+ context: LinearCurrentContext
+})
+
+export const LinearSaveIssue = LinearWriteTarget.extend({
+ team: OptionalString,
+ title: OptionalString,
+ description: z.string().optional(),
+ state: OptionalString,
+ assignee: z.string().nullable().optional(),
+ priority: z.number().int().min(0).max(4).optional(),
+ estimate: z.number().min(0).nullable().optional(),
+ dueDate: OptionalLinearDueDateOrClear,
+ labels: z.array(z.string()).optional(),
+ project: z.string().nullable().optional(),
+ parentId: z.string().nullable().optional(),
+ writeId: OptionalString
+})
diff --git a/src/shared/rpc-contract/linear-issue-attribute-filter-params.ts b/src/shared/rpc-contract/linear-issue-attribute-filter-params.ts
new file mode 100644
index 00000000000..8d793f42ba1
--- /dev/null
+++ b/src/shared/rpc-contract/linear-issue-attribute-filter-params.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod'
+import {
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES,
+ LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS
+} from '../linear/issue-attribute-filter'
+
+// Why: keep ListIssues param validation co-located with shared limits without
+// pushing linear.ts past the max-lines ratchet.
+export const LinearAttributeFilterId = z
+ .string()
+ .trim()
+ .min(1)
+ .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_ID_MAX_LENGTH)
+
+export const LinearIssueAttributeFilterSchema = z
+ .object({
+ stateIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_STATE_IDS),
+ priorities: z
+ .array(z.number().int().min(0).max(4))
+ .max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_PRIORITIES),
+ assignee: z.union([
+ z.object({ kind: z.literal('unassigned') }).strict(),
+ z
+ .object({
+ kind: z.literal('user'),
+ id: LinearAttributeFilterId
+ })
+ .strict(),
+ z.null()
+ ]),
+ labelIds: z.array(LinearAttributeFilterId).max(LINEAR_ISSUE_ATTRIBUTE_FILTER_MAX_LABEL_IDS)
+ })
+ .strict()
diff --git a/src/shared/rpc-contract/linear-issue-list-params.ts b/src/shared/rpc-contract/linear-issue-list-params.ts
new file mode 100644
index 00000000000..ec4813f4186
--- /dev/null
+++ b/src/shared/rpc-contract/linear-issue-list-params.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, OptionalString } from './rpc-param-primitives'
+import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-params'
+
+export const LegacyListIssues = z
+ .object({
+ filter: z.enum(['assigned', 'created', 'all', 'completed']).optional(),
+ limit: OptionalFiniteNumber,
+ workspaceId: OptionalString,
+ attributeFilter: LinearIssueAttributeFilterSchema.optional()
+ })
+ .strict()
+ .optional()
+
+export const McpListIssues = z
+ .object({
+ team: OptionalString,
+ cycle: OptionalString,
+ label: OptionalString,
+ limit: z.number().int().min(1).max(250).optional(),
+ query: OptionalString,
+ state: OptionalString,
+ cursor: OptionalString,
+ orderBy: z.enum(['createdAt', 'updatedAt']).optional(),
+ project: OptionalString,
+ release: OptionalString,
+ assignee: OptionalString,
+ delegate: OptionalString,
+ parentId: OptionalString,
+ priority: z.number().int().min(0).max(4).optional(),
+ createdAt: OptionalString,
+ updatedAt: OptionalString,
+ includeArchived: z.boolean().optional(),
+ workspaceId: OptionalString
+ })
+ .strict()
+
+export const ListIssues = z.union([McpListIssues, LegacyListIssues])
diff --git a/src/shared/rpc-contract/linear-params.ts b/src/shared/rpc-contract/linear-params.ts
new file mode 100644
index 00000000000..313227c6029
--- /dev/null
+++ b/src/shared/rpc-contract/linear-params.ts
@@ -0,0 +1,124 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+
+export const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const
+
+export const LinearPriority = z.number().int().min(0).max(4).optional()
+
+export const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
+
+export const Connect = z.object({
+ apiKey: requiredString('Invalid API key')
+})
+
+export const WorkspaceSelection = z
+ .object({
+ workspaceId: OptionalString
+ })
+ .optional()
+
+export const ConcreteWorkspaceId = requiredString(
+ 'Concrete Linear workspace ID is required'
+).refine((value) => value !== 'all', 'Concrete Linear workspace ID is required')
+
+export const SelectWorkspace = z.object({
+ workspaceId: requiredString('Workspace ID is required')
+})
+
+export const SearchIssues = z.object({
+ query: requiredString('Missing query'),
+ limit: OptionalFiniteNumber,
+ workspaceId: OptionalString
+})
+
+export const CreateIssue = z.object({
+ teamId: requiredString('Team ID is required'),
+ title: requiredString('Title is required'),
+ description: OptionalString,
+ workspaceId: OptionalString,
+ parentIssueId: OptionalString,
+ projectId: z.union([z.string(), z.null()]).optional(),
+ stateId: OptionalString,
+ priority: LinearPriority,
+ assigneeId: z.union([z.string(), z.null()]).optional(),
+ labelIds: LinearLabelIds
+})
+
+export const IssueId = z.object({
+ id: requiredString('Issue ID is required'),
+ workspaceId: OptionalString
+})
+
+export const IssueComment = z.object({
+ issueId: requiredString('Issue ID is required'),
+ body: requiredString('Comment body is required'),
+ workspaceId: OptionalString
+})
+
+export const ListProjects = z
+ .object({
+ query: OptionalString,
+ limit: OptionalFiniteNumber,
+ workspaceId: OptionalString,
+ force: z.boolean().optional()
+ })
+ .optional()
+
+export const ProjectId = z.object({
+ id: requiredString('Project ID is required'),
+ workspaceId: ConcreteWorkspaceId,
+ force: z.boolean().optional()
+})
+
+export const ProjectIssues = z.object({
+ projectId: requiredString('Project ID is required'),
+ limit: OptionalFiniteNumber,
+ workspaceId: ConcreteWorkspaceId,
+ force: z.boolean().optional()
+})
+
+export const ListCustomViews = z.object({
+ model: z.enum(VALID_CUSTOM_VIEW_MODELS),
+ limit: OptionalFiniteNumber,
+ workspaceId: OptionalString,
+ force: z.boolean().optional()
+})
+
+export const CustomViewId = z.object({
+ viewId: requiredString('Custom view ID is required'),
+ model: z.enum(VALID_CUSTOM_VIEW_MODELS),
+ workspaceId: ConcreteWorkspaceId,
+ force: z.boolean().optional()
+})
+
+export const CustomViewContents = z.object({
+ viewId: requiredString('Custom view ID is required'),
+ limit: OptionalFiniteNumber,
+ workspaceId: ConcreteWorkspaceId,
+ force: z.boolean().optional()
+})
+
+export const TeamId = z.object({
+ teamId: requiredString('Team ID is required'),
+ workspaceId: OptionalString
+})
+
+export const IssueUpdate = z.object({
+ id: requiredString('Issue ID is required'),
+ workspaceId: OptionalString,
+ updates: z.object({
+ stateId: OptionalString,
+ title: OptionalString,
+ description: z.string().optional(),
+ assigneeId: z.union([z.string(), z.null()]).optional(),
+ estimate: z.union([z.number().int().min(0), z.null()]).optional(),
+ priority: z.number().int().min(0).max(4).optional(),
+ labelIds: z.array(z.string()).optional(),
+ projectId: z.union([z.string(), z.null()]).optional()
+ })
+})
+
+export const LinearIssueCommentsParams = z.object({
+ issueId: requiredString('Issue ID is required'),
+ workspaceId: OptionalString
+})
diff --git a/src/shared/rpc-contract/linear-project-create-params.ts b/src/shared/rpc-contract/linear-project-create-params.ts
new file mode 100644
index 00000000000..2eec8beb1fa
--- /dev/null
+++ b/src/shared/rpc-contract/linear-project-create-params.ts
@@ -0,0 +1,20 @@
+import { z } from 'zod'
+import { OptionalString, requiredString } from './rpc-param-primitives'
+
+export const LinearPriority = z.number().int().min(0).max(4).optional()
+
+export const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
+
+export const CreateProject = z.object({
+ name: requiredString('Project name is required'),
+ description: OptionalString,
+ content: OptionalString,
+ workspaceId: OptionalString,
+ teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'),
+ leadId: z.union([z.string(), z.null()]).optional(),
+ memberIds: z.array(requiredString('Invalid member ID')).optional(),
+ labelIds: LinearLabelIds,
+ priority: LinearPriority,
+ startDate: OptionalString,
+ targetDate: OptionalString
+})
diff --git a/src/shared/rpc-contract/native-chat-params.ts b/src/shared/rpc-contract/native-chat-params.ts
new file mode 100644
index 00000000000..5a88049e10f
--- /dev/null
+++ b/src/shared/rpc-contract/native-chat-params.ts
@@ -0,0 +1,50 @@
+import { z } from 'zod'
+import type { AgentType } from '../native-chat-types'
+
+// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The
+// desktop reaches the readers via Electron IPC; mobile/web clients reach the
+// same pure readers through these runtime RPC methods so the native chat view
+// works over the paired connection, not just in the desktop renderer.
+
+export const NativeChatSession = z.object({
+ agent: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing agent'))
+ .transform((v) => v as AgentType),
+ sessionId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing session id')),
+ // How many of the most-recent messages to return. Clients start small for a
+ // fast first paint and raise it to page older history in as the user scrolls.
+ // Clamp (don't reject) a limit past the max window so a client paging beyond it
+ // gets the capped tail and pagination stops cleanly — a hard `.max` rejection
+ // would fail the read and stall "load earlier" at the boundary.
+ limit: z
+ .number()
+ .int()
+ .positive()
+ .transform((value) => Math.min(value, MOBILE_NATIVE_CHAT_MAX_WINDOW))
+ .optional(),
+ // Optional client-supplied cleanup token. When present, the subscribe handler
+ // keys the fs-watcher cleanup under it so registration and unsubscribe derive
+ // from the SAME token (back-compat: falls back to `agent:sessionId` when absent,
+ // which is exactly what existing mobile clients rely on).
+ subscriptionId: z.string().min(1).optional(),
+ // Authoritative transcript path from the agent hook (providerSession), used to
+ // locate the file directly when the session id no longer names it (recent
+ // Claude Code). Optional for back-compat with older clients.
+ transcriptPath: z.string().min(1).optional(),
+ // A pending snapshot is not authoritative transcript history. Only clients
+ // that advertise this semantic may receive one; legacy clients treat it as a
+ // settled empty read and can overwrite retention / unblock launch drafts.
+ capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(),
+ beforeOffset: z.number().int().nonnegative().optional()
+})
+
+export const NativeChatUnsubscribe = z.object({
+ subscriptionId: z.string().min(1).optional()
+})
+
+export const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
diff --git a/src/shared/rpc-contract/notifications-params.ts b/src/shared/rpc-contract/notifications-params.ts
new file mode 100644
index 00000000000..6ae2cd45f5e
--- /dev/null
+++ b/src/shared/rpc-contract/notifications-params.ts
@@ -0,0 +1,61 @@
+import { z } from 'zod'
+import { MOBILE_PUSH_APNS_ENVIRONMENTS, MOBILE_PUSH_PLATFORMS } from '../mobile-push-contract'
+
+export const NotificationUnsubscribeParams = z.object({
+ subscriptionId: z
+ .unknown()
+ .transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
+ .pipe(z.string().min(1, 'Missing subscriptionId'))
+})
+
+// Why: notifications.getMissedSince is the catch-up RPC for mobile reconnect
+// (#8129). The client passes the highest seq it has already delivered; the
+// runtime returns only notifications dispatched after that seq. Because the
+// desktop assigns a monotonic seq to every dispatched notification, the cut is
+// exact and idempotent — re-requesting with the same watermark can never
+// return an already-delivered event, so reconnects never duplicate local
+// pushes (the adversarial-review gate for #8129).
+// `epoch` names the counter lifetime lastSeenSeq came from (#8591). The desktop's
+// seq restarts at 0 on every launch while the client's watermark is persisted, so
+// without it a post-restart watermark silently cuts away everything. Optional: a
+// client that predates the field keeps the seq-only cut.
+export const NotificationGetMissedSinceParams = z.object({
+ lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'),
+ epoch: z.string().optional(),
+ includeDesktopSuppressed: z.boolean().optional(),
+ deliveredPushes: z
+ .array(
+ z.object({
+ notificationId: z.string().min(1).max(2048),
+ notificationEpoch: z.string().min(1).max(128),
+ notificationSeq: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)
+ })
+ )
+ .max(256)
+ .optional()
+})
+
+export const NotificationPushFilterParams = z.object({
+ onlyWhenDesktopAway: z.boolean().optional(),
+ sound: z.boolean().optional()
+})
+
+export const NotificationRegisterPushParams = z
+ .object({
+ platform: z.enum(MOBILE_PUSH_PLATFORMS),
+ token: z.string().min(1).max(4096),
+ apnsEnvironment: z.enum(MOBILE_PUSH_APNS_ENVIRONMENTS).optional(),
+ filter: NotificationPushFilterParams
+ })
+ // Why strict: the device identity is added by the handler, so a caller-supplied
+ // `deviceId` must be an error, not a key silently dropped.
+ .strict()
+ // Why: an APNs token is only routable against the environment it was minted in,
+ // so a missing environment must fail loudly rather than default to production.
+ .refine((params) => params.platform !== 'ios' || params.apnsEnvironment !== undefined, {
+ message: 'apnsEnvironment is required for ios'
+ })
+
+export const NotificationsSubscribeParams = z
+ .object({ includeDesktopSuppressed: z.boolean().optional() })
+ .optional()
diff --git a/src/shared/rpc-contract/orchestration-federation-control-params.ts b/src/shared/rpc-contract/orchestration-federation-control-params.ts
new file mode 100644
index 00000000000..9bb5ada502f
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-federation-control-params.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, requiredString } from './rpc-param-primitives'
+import { ORCHESTRATION_WORKER_READ_SOURCES } from '../orchestration-worker-output'
+
+export const FederationDispatchParams = z.object({
+ dispatchId: requiredString('Missing Dispatch ID')
+})
+
+export const FederationReadParams = FederationDispatchParams.extend({
+ cursor: OptionalFiniteNumber,
+ limit: OptionalFiniteNumber
+})
+
+export const FederationOutputReadParams = FederationDispatchParams.extend({
+ cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(),
+ limit: OptionalFiniteNumber,
+ source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional()
+})
+
+export const FederationFleetSnapshotParams = z.object({
+ dispatchIds: z.array(requiredString('Missing Dispatch ID')).min(1).max(100)
+})
diff --git a/src/shared/rpc-contract/orchestration-federation-relay-params.ts b/src/shared/rpc-contract/orchestration-federation-relay-params.ts
new file mode 100644
index 00000000000..ef1608e8611
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-federation-relay-params.ts
@@ -0,0 +1,47 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, requiredString } from './rpc-param-primitives'
+
+export const FederationPullParams = z.object({
+ dispatchId: requiredString('Missing Dispatch ID'),
+ afterSequence: OptionalFiniteNumber,
+ replayUnacknowledged: z.boolean().optional(),
+ limit: OptionalFiniteNumber
+})
+
+export const FederationAckParams = z.object({
+ dispatchId: requiredString('Missing Dispatch ID'),
+ throughSequence: z.number().int().nonnegative(),
+ settlements: z
+ .array(
+ z.object({
+ sequence: z.number().int().positive(),
+ lifecycle: z.discriminatedUnion('action', [
+ z.object({
+ action: z.enum(['completed', 'failed']),
+ authority: z.literal('run_home')
+ }),
+ z.object({
+ action: z.literal('rejected'),
+ code: z.string(),
+ reason: z.string(),
+ authority: z.literal('run_home')
+ })
+ ])
+ })
+ )
+ .optional()
+})
+
+export const FederationImportParams = z.object({
+ dispatchId: requiredString('Missing Dispatch ID'),
+ items: z.array(
+ z.object({
+ dispatch_id: requiredString('Missing item Dispatch ID'),
+ direction: z.literal('to_worker'),
+ sequence: z.number().int().positive(),
+ message_id: requiredString('Missing relay message ID'),
+ kind: requiredString('Missing relay kind'),
+ payload: requiredString('Missing relay payload')
+ })
+ )
+})
diff --git a/src/shared/rpc-contract/orchestration-federation-start-params.ts b/src/shared/rpc-contract/orchestration-federation-start-params.ts
new file mode 100644
index 00000000000..24cf82223f6
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-federation-start-params.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+import { OptionalWorkerLaunchPreference } from './orchestration-worker-start-params'
+
+export const FederationAttachStartParams = z.object({
+ /** Omitted by v1.4.198 coordinators; the worker host then mints a stub home Run. */
+ runId: OptionalString,
+ dispatchId: requiredString('Missing Dispatch ID'),
+ taskId: requiredString('Missing Task ID'),
+ taskSpec: requiredString('Missing Task spec'),
+ /** Depth stamped by the Run home; omitted by older clients and defaults to 1. */
+ depth: z.number().int().min(1).optional(),
+ protocolVersion: z.union([z.literal(1), z.literal(2), z.literal(3)]),
+ worktree: requiredString('Missing remote worktree selector'),
+ name: OptionalString,
+ repo: OptionalString,
+ baseBranch: OptionalString,
+ displayName: OptionalString,
+ displayNameKind: z.enum(['generated', 'user']).optional(),
+ comment: OptionalString,
+ setup: z.enum(['run', 'skip', 'inherit']).optional(),
+ setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(),
+ terminal: OptionalString,
+ agent: OptionalString,
+ model: OptionalWorkerLaunchPreference,
+ effort: OptionalWorkerLaunchPreference,
+ timeoutMs: OptionalFiniteNumber,
+ devMode: z.boolean().optional()
+})
diff --git a/src/shared/rpc-contract/orchestration-gates-params.ts b/src/shared/rpc-contract/orchestration-gates-params.ts
new file mode 100644
index 00000000000..c7ea607b345
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-gates-params.ts
@@ -0,0 +1,34 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+
+export const RunParams = z.object({
+ spec: requiredString('Missing --spec'),
+ from: OptionalString,
+ pollIntervalMs: OptionalFiniteNumber,
+ maxConcurrent: OptionalFiniteNumber,
+ worktree: OptionalString
+})
+
+export const RunStopParams = z.object({})
+
+export const GateCreateParams = z.object({
+ task: requiredString('Missing --task'),
+ question: requiredString('Missing --question'),
+ options: OptionalString,
+ from: OptionalString,
+ run: OptionalString
+})
+
+export const GateResolveParams = z.object({
+ id: requiredString('Missing --id'),
+ resolution: requiredString('Missing --resolution'),
+ from: OptionalString,
+ run: OptionalString
+})
+
+export const GateListParams = z.object({
+ task: OptionalString,
+ status: z.enum(['pending', 'resolved', 'timeout']).optional(),
+ from: OptionalString,
+ run: OptionalString
+})
diff --git a/src/shared/rpc-contract/orchestration-params.ts b/src/shared/rpc-contract/orchestration-params.ts
new file mode 100644
index 00000000000..e58dca2143c
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-params.ts
@@ -0,0 +1,153 @@
+import { z } from 'zod'
+import {
+ OptionalBoolean,
+ OptionalFiniteNumber,
+ OptionalString,
+ requiredString
+} from './rpc-param-primitives'
+
+export type DispatchMutationMessageType =
+ | 'worker_done'
+ | 'heartbeat'
+ | 'escalation'
+ | 'decision_gate'
+
+export function isDispatchMutationMessageType(
+ type: string | undefined
+): type is DispatchMutationMessageType {
+ return (
+ type === 'worker_done' ||
+ type === 'heartbeat' ||
+ type === 'escalation' ||
+ type === 'decision_gate'
+ )
+}
+
+export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string {
+ return `${type} messages belong to one exact Dispatch and cannot target a group address.`
+}
+
+export const CheckParams = z
+ .object({
+ terminal: OptionalString,
+ terminalPaneKey: OptionalString,
+ unread: OptionalBoolean,
+ peek: OptionalBoolean,
+ // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3).
+ all: OptionalBoolean,
+ types: OptionalString,
+ format: OptionalBoolean,
+ // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected.
+ inject: OptionalBoolean,
+ ack: OptionalString,
+ compatibilityAck: OptionalString,
+ compatibilityQuestionAck: OptionalString,
+ compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(),
+ run: OptionalString,
+ wait: OptionalBoolean,
+ timeoutMs: OptionalFiniteNumber
+ })
+ .superRefine((params, ctx) => {
+ // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict.
+ const modes = [
+ params.unread === true,
+ params.peek === true,
+ params.all === true || (params.unread === false && params.peek !== true)
+ ].filter(Boolean)
+ if (modes.length > 1) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Choose at most one message read mode: --unread, --peek, or --all.'
+ })
+ }
+ })
+
+export const ReplyParams = z.object({
+ id: requiredString('Missing --id'),
+ body: requiredString('Missing --body'),
+ from: OptionalString,
+ run: OptionalString
+})
+
+export const InboxParams = z.object({
+ limit: OptionalFiniteNumber,
+ // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3).
+ terminal: OptionalString
+})
+
+export const TaskCreateParams = z.object({
+ spec: requiredString('Missing --spec'),
+ taskTitle: OptionalString,
+ displayName: OptionalString,
+ deps: OptionalString,
+ parent: OptionalString,
+ callerTerminalHandle: OptionalString,
+ run: OptionalString
+})
+
+export const TaskListParams = z.object({
+ status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(),
+ ready: OptionalBoolean,
+ // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away.
+ brief: OptionalBoolean,
+ run: OptionalString,
+ callerTerminalHandle: OptionalString
+})
+
+export const DispatchParams = z.object({
+ task: requiredString('Missing --task'),
+ // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work.
+ to: OptionalString,
+ from: OptionalString,
+ inject: OptionalBoolean,
+ dryRun: OptionalBoolean,
+ returnPreamble: OptionalBoolean,
+ devMode: OptionalBoolean,
+ run: OptionalString
+})
+
+export const DispatchShowParams = z.object({
+ task: OptionalString,
+ preamble: OptionalBoolean,
+ from: OptionalString,
+ devMode: OptionalBoolean
+})
+
+export const AskParams = z
+ .object({
+ to: OptionalString,
+ question: OptionalString,
+ resume: OptionalString,
+ options: OptionalString,
+ timeoutMs: OptionalFiniteNumber,
+ from: OptionalString,
+ run: OptionalString,
+ compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(),
+ compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional()
+ })
+ .superRefine((params, ctx) => {
+ if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Choose exactly one of --question or --resume.'
+ })
+ }
+ })
+
+export const ResetParams = z
+ .object({
+ all: OptionalBoolean,
+ tasks: OptionalBoolean,
+ messages: OptionalBoolean
+ })
+ .superRefine((params, ctx) => {
+ const selectedScopeCount = [params.all, params.tasks, params.messages].filter(
+ (scope) => scope === true
+ ).length
+ if (selectedScopeCount !== 1) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Choose exactly one reset scope: --all, --tasks, or --messages.'
+ })
+ }
+ })
diff --git a/src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts b/src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts
new file mode 100644
index 00000000000..496c1ea3827
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-runs-mutation-request-show-params.ts
@@ -0,0 +1,4 @@
+import { z } from 'zod'
+import { requiredString } from './rpc-param-primitives'
+
+export const RequestShowParams = z.object({ request: requiredString('Missing --request') })
diff --git a/src/shared/rpc-contract/orchestration-runs-params.ts b/src/shared/rpc-contract/orchestration-runs-params.ts
new file mode 100644
index 00000000000..30aad1e9eae
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-runs-params.ts
@@ -0,0 +1,23 @@
+import { z } from 'zod'
+import { OptionalBoolean, OptionalString, requiredString } from './rpc-param-primitives'
+import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../orchestration-run-pagination'
+
+export const RunCreateParams = z.object({
+ objective: requiredString('Missing --objective'),
+ from: requiredString('Missing coordinator terminal')
+})
+
+export const RunUseParams = z.object({
+ id: requiredString('Missing --id'),
+ from: requiredString('Missing coordinator terminal'),
+ takeoverLegacy: OptionalBoolean
+})
+
+export const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') })
+
+export const RunListParams = z.object({
+ limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(),
+ cursor: z.string().min(1).optional()
+})
+
+export const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString })
diff --git a/src/shared/rpc-contract/orchestration-worker-control-params.ts b/src/shared/rpc-contract/orchestration-worker-control-params.ts
new file mode 100644
index 00000000000..9aa097522f1
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-worker-control-params.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, requiredString } from './rpc-param-primitives'
+import { ORCHESTRATION_WORKER_READ_SOURCES } from '../orchestration-worker-output'
+
+export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') })
+
+export const WorkerReadParams = WorkerDispatchParams.extend({
+ cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(),
+ limit: OptionalFiniteNumber,
+ source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional()
+})
diff --git a/src/shared/rpc-contract/orchestration-worker-release-params.ts b/src/shared/rpc-contract/orchestration-worker-release-params.ts
new file mode 100644
index 00000000000..345a99e87b1
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-worker-release-params.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod'
+
+export const OrchestrationWorkerTerminalUserInputParams = z
+ .object({
+ paneKey: z.string().min(1).optional(),
+ sessionId: z.string().min(1).optional(),
+ terminal: z.string().min(1).optional()
+ })
+ .refine(
+ (value) => Boolean(value.paneKey ?? value.sessionId ?? value.terminal),
+ 'Missing paneKey, sessionId or terminal'
+ )
diff --git a/src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts b/src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts
new file mode 100644
index 00000000000..b2d75c46adc
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-worker-release-schemas-params.ts
@@ -0,0 +1,25 @@
+import { z } from 'zod'
+import { requiredString } from './rpc-param-primitives'
+import { ORCHESTRATION_FLEET_PAGE_MAX } from '../orchestration-fleet-projection'
+
+export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') })
+
+export const WorkerRetainParams = WorkerDispatchParams.strict()
+
+export const WORKER_TERMINAL_LIST_STATES = [
+ 'active',
+ 'reclaimable',
+ 'retained',
+ 'release_pending',
+ 'release_unknown',
+ 'released'
+] as const
+
+export const WorkerListParams = z.object({
+ run: z.string().min(1).optional(),
+ terminalState: z.enum(WORKER_TERMINAL_LIST_STATES).optional(),
+ cursor: z.string().min(1).max(2_048).optional(),
+ limit: z.number().int().min(1).max(ORCHESTRATION_FLEET_PAGE_MAX).optional(),
+ includeRemote: z.boolean().optional(),
+ paginate: z.boolean().optional()
+})
diff --git a/src/shared/rpc-contract/orchestration-worker-start-params.ts b/src/shared/rpc-contract/orchestration-worker-start-params.ts
new file mode 100644
index 00000000000..dc6432aa47a
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-worker-start-params.ts
@@ -0,0 +1,61 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+
+export const OptionalWorkerLaunchPreference = z
+ .string()
+ .min(1)
+ .max(512)
+ .refine((value) => value === value.trim(), 'Surrounding whitespace is invalid')
+ .optional()
+
+export const WorkerStartParams = z
+ .object({
+ task: OptionalString,
+ spec: OptionalString,
+ taskTitle: OptionalString,
+ deps: OptionalString,
+ parent: OptionalString,
+ on: OptionalString,
+ run: OptionalString,
+ from: requiredString('Missing --from'),
+ worktree: OptionalString,
+ name: OptionalString,
+ repo: OptionalString,
+ baseBranch: OptionalString,
+ displayName: OptionalString,
+ comment: OptionalString,
+ setup: z.enum(['run', 'skip', 'inherit']).optional(),
+ terminal: OptionalString,
+ agent: OptionalString,
+ model: OptionalWorkerLaunchPreference,
+ effort: OptionalWorkerLaunchPreference,
+ retryOf: OptionalString,
+ timeoutMs: OptionalFiniteNumber,
+ devMode: z.boolean().optional()
+ })
+ .superRefine((params, ctx) => {
+ if (!params.task && !params.spec) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['task'],
+ message: 'Missing --task or --spec'
+ })
+ }
+ if (params.task && params.spec) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['spec'],
+ message: '--task and --spec are mutually exclusive'
+ })
+ }
+ // Why: --spec creates a new Task, so a retry link to a prior Dispatch could never resolve and
+ // the refusal named a Task id the caller never supplied.
+ if (params.retryOf && params.spec) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['retryOf'],
+ message:
+ '--retry-of needs --task naming the failed Task; --spec creates a new one'
+ })
+ }
+ })
diff --git a/src/shared/rpc-contract/orchestration-worker-stop-params.ts b/src/shared/rpc-contract/orchestration-worker-stop-params.ts
new file mode 100644
index 00000000000..cb465c8808b
--- /dev/null
+++ b/src/shared/rpc-contract/orchestration-worker-stop-params.ts
@@ -0,0 +1,4 @@
+import { z } from 'zod'
+import { requiredString } from './rpc-param-primitives'
+
+export const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') })
diff --git a/src/shared/rpc-contract/plugins-params.ts b/src/shared/rpc-contract/plugins-params.ts
new file mode 100644
index 00000000000..8819f328d86
--- /dev/null
+++ b/src/shared/rpc-contract/plugins-params.ts
@@ -0,0 +1,20 @@
+import { z } from 'zod'
+import { isQualifiedPluginKey } from '../plugins/plugin-manifest'
+
+export const PluginSetEnabledParams = z.object({
+ pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
+ enabled: z.boolean()
+})
+
+export const PluginReadPanelEntryParams = z.object({
+ pluginKey: z.string().min(1),
+ panelId: z.string().min(1)
+})
+
+export const PluginInvokeCommandParams = z.object({
+ pluginKey: z.string().min(1),
+ commandId: z.string().min(1),
+ args: z.unknown().optional()
+})
+
+export const PluginsPanelActionParams = z.unknown()
diff --git a/src/shared/rpc-contract/preflight-params.ts b/src/shared/rpc-contract/preflight-params.ts
new file mode 100644
index 00000000000..3dc6a8d1ef2
--- /dev/null
+++ b/src/shared/rpc-contract/preflight-params.ts
@@ -0,0 +1,13 @@
+import { z } from 'zod'
+
+export const PreflightCheck = z.object({
+ force: z.boolean().optional()
+})
+
+export const PreflightDetectRemoteAgents = z.object({
+ connectionId: z.string().min(1)
+})
+
+export const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({
+ connectionId: z.string().min(1)
+})
diff --git a/src/shared/rpc-contract/project-runtime-params.ts b/src/shared/rpc-contract/project-runtime-params.ts
new file mode 100644
index 00000000000..38d9ebff0ba
--- /dev/null
+++ b/src/shared/rpc-contract/project-runtime-params.ts
@@ -0,0 +1,95 @@
+import { z } from 'zod'
+import { OptionalString, requiredString } from './rpc-param-primitives'
+import {
+ LOCAL_EXECUTION_HOST_ID,
+ normalizeExecutionHostId,
+ parseExecutionHostId
+} from '../execution-host'
+
+export const ProjectProviderIdentity = z.object({
+ provider: z.literal('github'),
+ owner: requiredString('Missing project owner'),
+ repo: requiredString('Missing project repository'),
+ host: OptionalString
+})
+
+// Why: `runtime:` ids are minted by the calling client's own pairing store
+// (addEnvironmentFromPairingCode -> randomUUID), so they name a machine only relative to that
+// client. A client sending one to this runtime is addressing *us*, and runtimes do not proxy
+// these calls onward, so the host it names is this machine. Persisting the caller's id verbatim
+// makes one machine look like a different host to every other client, hides its rows from them,
+// and defeats the (projectId, hostId) duplicate check. Store our own spelling instead: `local`.
+// Rows written before this normalization keep their client-minted stamp; readers still project
+// `local` back to `runtime:`, so the client-visible model is unchanged.
+export const RequestedHostId = requiredString('Missing host ID').transform((value, ctx) => {
+ const hostId = normalizeExecutionHostId(value)
+ if (!hostId) {
+ ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
+ return z.NEVER
+ }
+ return parseExecutionHostId(hostId)?.kind === 'runtime' ? LOCAL_EXECUTION_HOST_ID : hostId
+})
+
+export const ProjectHostSetupExistingFolder = z.object({
+ projectId: requiredString('Missing project ID'),
+ projectProviderIdentity: ProjectProviderIdentity.optional(),
+ hostId: RequestedHostId,
+ path: requiredString('Missing project path'),
+ kind: z.enum(['git', 'folder']).optional(),
+ displayName: OptionalString,
+ setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional()
+})
+
+export const ProjectHostSetupClone = z.object({
+ projectId: requiredString('Missing project ID'),
+ projectProviderIdentity: ProjectProviderIdentity.optional(),
+ hostId: RequestedHostId,
+ url: requiredString('Missing clone URL'),
+ destination: requiredString('Missing clone destination'),
+ displayName: OptionalString
+})
+
+export const LocalWindowsRuntimePreference = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('inherit-global') }),
+ z.object({ kind: z.literal('windows-host') }),
+ z.object({ kind: z.literal('wsl'), distro: requiredString('Missing WSL distro') })
+])
+
+export const ProjectUpdate = z.object({
+ projectId: requiredString('Missing project ID'),
+ updates: z.object({
+ localWindowsRuntimePreference: LocalWindowsRuntimePreference.optional()
+ })
+})
+
+export const ProjectHostSetupCreate = z.object({
+ projectId: requiredString('Missing project ID'),
+ hostId: RequestedHostId,
+ setupId: OptionalString,
+ path: OptionalString,
+ kind: z.enum(['git', 'folder']).optional(),
+ displayName: OptionalString,
+ worktreeBasePath: OptionalString,
+ gitUsername: OptionalString,
+ setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
+ setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional()
+})
+
+export const ProjectHostSetupUpdate = z.object({
+ setupId: requiredString('Missing setup ID'),
+ updates: z.object({
+ displayName: OptionalString,
+ path: OptionalString,
+ worktreeBasePath: OptionalString,
+ setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
+ setupMethod: z
+ .enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned'])
+ .optional(),
+ gitUsername: OptionalString,
+ kind: z.enum(['git', 'folder']).optional()
+ })
+})
+
+export const ProjectHostSetupDelete = z.object({
+ setupId: requiredString('Missing setup ID')
+})
diff --git a/src/shared/rpc-contract/repo-params.ts b/src/shared/rpc-contract/repo-params.ts
new file mode 100644
index 00000000000..e5d29174a03
--- /dev/null
+++ b/src/shared/rpc-contract/repo-params.ts
@@ -0,0 +1,100 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+import { createRepoUpdateSchema } from './repo-update-params'
+import { RepoSelector } from './github-repo-target-params'
+
+export const RepoPath = z.object({
+ path: requiredString('Missing repo path'),
+ kind: z.enum(['git', 'folder']).optional(),
+ displayName: OptionalString
+})
+
+export const RepoCreate = z.object({
+ parentPath: requiredString('Missing parent path'),
+ name: requiredString('Missing repo name'),
+ kind: z.enum(['git', 'folder']).optional()
+})
+
+export const RepoClone = z.object({
+ url: requiredString('Missing clone URL'),
+ destination: requiredString('Missing clone destination')
+})
+
+export const RepoSetBaseRef = z.object({
+ repo: requiredString('Missing repo selector'),
+ ref: requiredString('Missing base ref')
+})
+
+export const RepoUpdate = createRepoUpdateSchema(RepoSelector.shape)
+
+export const RepoSearchRefs = z.object({
+ repo: requiredString('Missing repo selector'),
+ query: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : undefined))
+ .pipe(z.string({ message: 'Missing query' })),
+ limit: OptionalFiniteNumber
+})
+
+export const RepoReorder = z.object({
+ orderedIds: z.array(z.string())
+})
+
+export const ProjectGroupCreate = z.object({
+ name: requiredString('Missing group name'),
+ parentPath: OptionalString,
+ connectionId: OptionalString.nullable().optional(),
+ parentGroupId: OptionalString.nullable().optional(),
+ createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
+})
+
+export const ProjectGroupUpdate = z.object({
+ groupId: requiredString('Missing group id'),
+ updates: z.object({
+ name: OptionalString,
+ isCollapsed: z.boolean().optional(),
+ tabOrder: OptionalFiniteNumber,
+ color: OptionalString.nullable().optional()
+ })
+})
+
+export const ProjectGroupSelector = z.object({
+ groupId: requiredString('Missing group id')
+})
+
+export const ProjectGroupMoveProject = z.object({
+ repo: requiredString('Missing repo selector'),
+ groupId: OptionalString.nullable(),
+ order: OptionalFiniteNumber
+})
+
+export const ProjectGroupScanNested = z.object({
+ path: requiredString('Missing folder path')
+})
+
+export const ProjectGroupImportNested = z.discriminatedUnion('mode', [
+ z.object({
+ parentPath: requiredString('Missing parent path'),
+ groupName: z.string().optional().default(''),
+ projectPaths: z.array(z.string()),
+ mode: z.literal('group')
+ }),
+ z.object({
+ parentPath: requiredString('Missing parent path'),
+ // Why: blank group names fall back to the scanned folder basename; separate
+ // imports do not create a group but share the same renderer payload shape.
+ groupName: z.string().optional().default(''),
+ projectPaths: z.array(z.string()),
+ mode: z.literal('separate')
+ })
+])
+
+export const RepoIssueCommandWrite = RepoSelector.extend({
+ content: z.string()
+})
+
+export const RepoSparsePresetSave = RepoSelector.extend({
+ id: OptionalString,
+ name: requiredString('Missing preset name'),
+ directories: z.array(z.string())
+})
diff --git a/src/shared/rpc-contract/repo-update-params.ts b/src/shared/rpc-contract/repo-update-params.ts
new file mode 100644
index 00000000000..179bb1994dc
--- /dev/null
+++ b/src/shared/rpc-contract/repo-update-params.ts
@@ -0,0 +1,77 @@
+import { z } from 'zod'
+import { normalizeRepoSourceControlAiOverrides } from '../source-control-ai'
+import { normalizeRepoBadgeColor } from '../repo-badge-color'
+import { sanitizeRepoIcon } from '../repo-icon'
+import {
+ normalizeCustomWorktreeVisibilitySources,
+ normalizeWorktreeVisibilitySourcePreferences
+} from '../worktree/visibility-sources'
+import { OptionalFiniteNumber, OptionalString } from './rpc-param-primitives'
+
+export const RepoSourceControlAiOverrides = z
+ .unknown()
+ .optional()
+ .transform((value) =>
+ value === undefined
+ ? undefined
+ : value === null
+ ? null
+ : normalizeRepoSourceControlAiOverrides(value)
+ )
+
+export const RepoBadgeColor = z
+ .unknown()
+ .optional()
+ .transform((value) =>
+ value === undefined ? undefined : (normalizeRepoBadgeColor(value) ?? undefined)
+ )
+
+export const RepoUpstream = z
+ .object({
+ owner: z.string().min(1),
+ repo: z.string().min(1)
+ })
+ .nullable()
+ .optional()
+
+// The return type is inferred on purpose: an explicit z.ZodObject<...z.ZodRawShape>
+// annotation widened `updates` to an open record, which erased all 24 named fields
+// from RpcParams<'repo.update'> for every typed caller.
+export function createRepoUpdateSchema(selectorShape: T) {
+ return z.object({
+ ...selectorShape,
+ updates: z.object({
+ displayName: OptionalString,
+ badgeColor: RepoBadgeColor,
+ repoIcon: z
+ .unknown()
+ .transform((value) => sanitizeRepoIcon(value))
+ .optional(),
+ upstream: RepoUpstream,
+ hookSettings: z.unknown().optional(),
+ worktreeBaseRef: OptionalString,
+ worktreeBasePath: OptionalString,
+ kind: z.enum(['git', 'folder']).optional(),
+ symlinkPaths: z.array(z.string()).optional(),
+ issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(),
+ forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(),
+ externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(),
+ externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(),
+ externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(),
+ importedExternalWorktreePaths: z.array(z.string()).optional(),
+ agentWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(),
+ customWorktreeVisibilitySources: z
+ .unknown()
+ .transform((value) => normalizeCustomWorktreeVisibilitySources(value))
+ .optional(),
+ worktreeVisibilitySourcePreferences: z
+ .unknown()
+ .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value))
+ .optional(),
+ externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(),
+ projectGroupId: OptionalString.nullable().optional(),
+ projectGroupOrder: OptionalFiniteNumber,
+ sourceControlAi: RepoSourceControlAiOverrides
+ })
+ })
+}
diff --git a/src/shared/rpc-contract/rpc-param-primitives.ts b/src/shared/rpc-contract/rpc-param-primitives.ts
new file mode 100644
index 00000000000..a22efbd2765
--- /dev/null
+++ b/src/shared/rpc-contract/rpc-param-primitives.ts
@@ -0,0 +1,84 @@
+import { z } from 'zod'
+
+// Why: the original handlers treated non-numeric/NaN limit values as "no
+// limit" rather than as errors. Preserve that forgiving behavior so CLI
+// callers passing stringified numbers or Infinity still reach the runtime.
+// The outer optional() is required for omitted keys in Zod v4; an optional
+// schema hidden behind pipe() still makes z.object require the property.
+export const OptionalFiniteNumber = z
+ .unknown()
+ .transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined))
+ .pipe(z.union([z.number(), z.undefined()]))
+ .optional()
+
+export const OptionalPositiveInt = z
+ .unknown()
+ .transform((value) =>
+ typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
+ )
+ .pipe(z.union([z.number(), z.undefined()]))
+ .optional()
+
+export const OptionalString = z
+ .unknown()
+ .transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined))
+ .pipe(z.union([z.string(), z.undefined()]))
+ .optional()
+
+export const OptionalPlainString = z
+ .unknown()
+ .transform((value) => (typeof value === 'string' ? value : undefined))
+ .pipe(z.union([z.string(), z.undefined()]))
+ .optional()
+
+export const OptionalBoolean = z
+ .unknown()
+ .transform((value) => (typeof value === 'boolean' ? value : undefined))
+ .pipe(z.union([z.boolean(), z.undefined()]))
+ .optional()
+
+// Why: runtime handlers accept `linkedIssue: number | null | undefined` with
+// distinct meanings — undefined means "no update", null means "clear", number
+// means "set". The ambient JSON decode produces all three shapes as-is.
+export const TriStateLinkedIssue = z
+ .unknown()
+ .transform((value) => {
+ if (value === null) {
+ return null
+ }
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value
+ }
+ return undefined
+ })
+ .pipe(z.union([z.number(), z.null(), z.undefined()]))
+ .optional()
+
+// Why: the legacy extractBrowserTarget treated worktree as a plain-string
+// passthrough (empty string preserved) but `page` as non-empty-string. The
+// browser bridge uses worktree-as-empty-string to mean "any worktree", so
+// keep that asymmetry intact to avoid widening scope unexpectedly.
+export const BrowserTarget = z.object({
+ worktree: OptionalPlainString,
+ page: OptionalString
+})
+
+export function requiredString(message: string) {
+ return z
+ .unknown()
+ .transform((value) => (typeof value === 'string' ? value : ''))
+ .pipe(z.string().min(1, message))
+}
+
+export function requiredStringAllowingEmpty(message: string) {
+ return z.unknown().refine((value): value is string => typeof value === 'string', { message })
+}
+
+export function requiredNumber(message: string) {
+ return z
+ .unknown()
+ .transform((value) =>
+ typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
+ )
+ .pipe(z.number().refine((v) => Number.isFinite(v), { message }))
+}
diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts
new file mode 100644
index 00000000000..15d04a5b655
--- /dev/null
+++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts
@@ -0,0 +1,1167 @@
+// GENERATED by config/scripts/generate-rpc-params-catalog.mjs. Do not edit;
+// run `pnpm run generate:rpc-params-catalog`.
+import type { z } from 'zod'
+import { AgentSkillShareRequestSchema } from '../agent-skill-sharing-contract'
+import {
+ BrowserClientFileChannelAbortParams,
+ BrowserClientFileChannelReadParams,
+ BrowserClientFileChannelWriteParams
+} from '../browser-client-file-channel-protocol'
+import {
+ BrowserClientHostAttachParams,
+ BrowserClientHostCommandResultParams,
+ BrowserNetworkTunnelAttachParams
+} from '../browser-client-host-protocol'
+import { BrowserClientPageMetadataParams } from '../browser-client-page-metadata-protocol'
+import {
+ PairingGetEndpointsParamsSchema,
+ PairingProvisionRelayParamsSchema
+} from '../mobile-relay-credential-contract'
+import { pluginConsentRequestSchema } from '../plugins/plugin-consent-request'
+import {
+ AccountsUnsubscribeParams,
+ AddClaudeFromConfigDirParams,
+ AddCodexFromHomeParams,
+ ConsumeCodexResetCreditParams,
+ ListAccountsParams,
+ RemoveAccountParams,
+ SelectAccountParams,
+ SelectCodexAccountForTargetParams
+} from './accounts-params'
+import { PrepareCodexForWslPaneParams } from './agent-hooks-params'
+import { CreateAgentSessionParams, EnsureAgentSessionParams } from './agent-session-params'
+import {
+ AiVaultListSessionsParams,
+ AiVaultPrepareSessionResumeParams,
+ AiVaultSessionTitlesParams
+} from './ai-vault-params'
+import { ArtifactsDeleteParams, ListOptions, SourceRequest, WriteRequest } from './artifacts-params'
+import {
+ AutomationCreate,
+ AutomationId,
+ AutomationList,
+ AutomationRuns,
+ AutomationUpdate
+} from './automation-params'
+import { CertificateProceed } from './browser-core-params'
+import { MouseClick } from './browser-extras-params'
+import {
+ Check,
+ ClipboardWrite,
+ CookieDelete,
+ CookieGet,
+ CookieSet,
+ DialogAccept,
+ Drag,
+ Element,
+ Eval,
+ Exec,
+ Fill,
+ Find,
+ FullScreenshot,
+ Geolocation,
+ Get,
+ Goto,
+ Highlight,
+ InterceptEnable,
+ Is,
+ KeyboardInsert,
+ Keypress,
+ LimitParam,
+ MouseButton,
+ MouseWheel,
+ MouseXY,
+ ProfileCreate,
+ ProfileDelete,
+ ProfileImportFromBrowser,
+ Screencast,
+ Screenshot,
+ Scroll,
+ Select,
+ SelectorPath,
+ SetCredentials,
+ SetDevice,
+ SetHeaders,
+ SetMedia,
+ SetOffline,
+ StorageKey,
+ StorageKeyValue,
+ TabClose,
+ TabCurrent,
+ TabList,
+ TabProfileClone,
+ TabSetProfile,
+ TabShow,
+ TabSwitch,
+ Type,
+ Upload,
+ Viewport,
+ Wait
+} from './browser-params'
+import { ScreencastUnsubscribe } from './browser-screencast-params'
+import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-params'
+import { ClientEventsUnsubscribeParams } from './client-events-params'
+import {
+ NativeChatSessionOptionsMutation,
+ PRBotAuthorOverrideUpdate,
+ SettingsUpdate
+} from './client-settings-params'
+import { FeatureInteractionIdParam, UiUpdate } from './client-ui-params'
+import {
+ AbortImageUpload,
+ AppendImageUploadChunk,
+ CommitImageUpload,
+ SaveImageAsTempFile,
+ StartImageUpload
+} from './clipboard-params'
+import { ComputerCapabilitiesParams, ComputerPermissionsStatusParams } from './computer-params'
+import {
+ Click,
+ ComputerObserveTarget,
+ ComputerPermissions,
+ Drag as DragOfComputerSchemasParams,
+ Hotkey,
+ ListApps,
+ ListWindows,
+ PasteText,
+ PerformSecondaryAction,
+ PressKey,
+ Scroll as ScrollOfComputerSchemasParams,
+ SetValue,
+ TypeText
+} from './computer-schemas-params'
+import {
+ AttachParams as AttachParamsOfEmulatorParams,
+ AxParams,
+ ButtonParams,
+ EmulatorAvailabilityParams,
+ EmulatorListDevicesParams,
+ EmulatorListSimulatorsParams,
+ EmulatorUnregisterActiveParams,
+ ExecParams,
+ GestureParams,
+ KillParams,
+ LaunchParams,
+ ListParams,
+ LogcatParams,
+ PermissionsParams,
+ RotateParams,
+ ShutdownParams,
+ TapParams,
+ TypeParams
+} from './emulator-params'
+import {
+ FileCommitUpload,
+ FileCopy,
+ FileDelete,
+ FileMutationOpen,
+ FileRename,
+ FileWrite,
+ FileWriteBase64,
+ FileWriteBase64Chunk
+} from './files-mutation-params'
+import {
+ DocPreviewFileRead,
+ FileListAll,
+ FileOpenDiff,
+ FilePathSearch,
+ FileReadChunk,
+ FileSearch,
+ FileTreePath,
+ FileUnwatch,
+ ResolveTerminalPath,
+ ServerDirectoryBrowse
+} from './files-params'
+import { FileOpen, WorktreeSelector } from './files-target-params'
+import { TerminalArtifactFile, TerminalArtifactFileWrite } from './files-terminal-artifact-params'
+import {
+ FolderWorkspaceCreate,
+ FolderWorkspacePathStatus,
+ FolderWorkspaceSelector,
+ FolderWorkspaceUpdate
+} from './folder-workspace-params'
+import {
+ GitBranchCompare,
+ GitBranchDiff,
+ GitBulkPaths,
+ GitCheckIgnored,
+ GitCheckout,
+ GitCommit,
+ GitCommitCompare,
+ GitCommitDiff,
+ GitDiff,
+ GitDiscoverCommitMessageModels,
+ GitFilePath,
+ GitForkSync,
+ GitGenerateCommitMessage,
+ GitGeneratePullRequestFields,
+ GitHistory,
+ GitPush,
+ GitRebaseFromBase,
+ GitRemoteCommitUrl,
+ GitRemoteFileUrl,
+ GitStatusParams,
+ GitSubmoduleStatus,
+ GitTargetedRemote,
+ WorktreeSelector as WorktreeSelectorOfGitParams
+} from './git-params'
+import { CreateIssue, Issue, IssueComment, UpdateIssue } from './github-issue-params'
+import {
+ ClearProjectItemField,
+ GithubProjectListAccessibleParams,
+ ProjectItemField,
+ ProjectRef,
+ ProjectViewTable,
+ ProjectViews,
+ ProjectWorkItemDetailsBySlug,
+ SlugAssignableUsers,
+ SlugIssueComment,
+ SlugIssueCommentDelete,
+ SlugIssueCommentEdit,
+ SlugIssueTypeUpdate,
+ SlugIssueUpdate,
+ SlugPullRequestUpdate
+} from './github-project-params'
+import {
+ PRCommentReaction,
+ PrForBranch,
+ PullRequest,
+ PullRequestCheckDetails,
+ PullRequestChecks,
+ PullRequestFileContents,
+ PullRequestFileViewed,
+ RerunPullRequestChecks,
+ ReviewThread
+} from './github-pull-request-params'
+import {
+ MarkPrReadyForReview,
+ MergePr,
+ PRReviewComment,
+ PRReviewCommentReply,
+ RemovePrReviewers,
+ RequestPrReviewers,
+ SetPrAutoMerge,
+ UpdatePr,
+ UpdatePrState,
+ UpdatePrTitle
+} from './github-pull-request-update-params'
+import { RepoSelector, SlugRepo } from './github-repo-target-params'
+import {
+ IssuesList,
+ RateLimit,
+ WorkItem,
+ WorkItemByOwnerRepo,
+ WorkItemsCount,
+ WorkItemsList
+} from './github-repo-work-item-params'
+import {
+ AddIssueComment,
+ AddMRComment,
+ AddMRInlineComment,
+ CreateIssue as CreateIssueOfGitlabParams,
+ EmptyParams,
+ GitLabRateLimit,
+ IssuesList as IssuesListOfGitlabParams,
+ JobTrace,
+ MergeMr,
+ RepoSelector as RepoSelectorOfGitlabParams,
+ ResolveMRDiscussion,
+ RetryJob,
+ UpdateIssue as UpdateIssueOfGitlabParams,
+ UpdateMr,
+ UpdateMrReviewers,
+ UpdateMrState,
+ WorkItemByPath,
+ WorkItemDetails,
+ WorkItemsList as WorkItemsListOfGitlabParams
+} from './gitlab-params'
+import {
+ HostedReviewCreate,
+ HostedReviewCreationEligibility,
+ HostedReviewForBranch
+} from './hosted-review-params'
+import {
+ AssignableUsers,
+ Connect,
+ CreateIssue as CreateIssueOfJiraParams,
+ IssueComment as IssueCommentOfJiraParams,
+ IssueKey,
+ IssueUpdate,
+ ListIssues,
+ ProjectIssueTypeFields,
+ ProjectIssueTypes,
+ ProjectStatusOrder,
+ SearchIssues,
+ SelectSite,
+ SiteSelection,
+ UserSearch
+} from './jira-params'
+import {
+ AgentIssueContext,
+ AgentSearchIssues,
+ LinearCurrentContext,
+ LinearIssueAddComment,
+ LinearIssueAttachLink,
+ LinearIssueCreate,
+ LinearIssueList,
+ LinearIssueRelationWrite,
+ LinearIssueSetState,
+ LinearIssueUpdateTask,
+ LinearProjectList,
+ LinearSaveIssue,
+ LinearTeamLookup,
+ LinearWorkspaceRead
+} from './linear-agent-access-params'
+import {
+ ListIssues as ListIssuesOfLinearIssueListParams,
+ McpListIssues
+} from './linear-issue-list-params'
+import {
+ Connect as ConnectOfLinearParams,
+ CreateIssue as CreateIssueOfLinearParams,
+ CustomViewContents,
+ CustomViewId,
+ IssueComment as IssueCommentOfLinearParams,
+ IssueId,
+ IssueUpdate as IssueUpdateOfLinearParams,
+ LinearIssueCommentsParams,
+ ListCustomViews,
+ ListProjects,
+ ProjectId,
+ ProjectIssues,
+ SearchIssues as SearchIssuesOfLinearParams,
+ SelectWorkspace,
+ TeamId,
+ WorkspaceSelection
+} from './linear-params'
+import { CreateProject } from './linear-project-create-params'
+import { NativeChatSession, NativeChatUnsubscribe } from './native-chat-params'
+import {
+ NotificationGetMissedSinceParams,
+ NotificationRegisterPushParams,
+ NotificationUnsubscribeParams,
+ NotificationsSubscribeParams
+} from './notifications-params'
+import {
+ FederationDispatchParams,
+ FederationFleetSnapshotParams,
+ FederationOutputReadParams,
+ FederationReadParams
+} from './orchestration-federation-control-params'
+import {
+ FederationAckParams,
+ FederationImportParams,
+ FederationPullParams
+} from './orchestration-federation-relay-params'
+import { FederationAttachStartParams } from './orchestration-federation-start-params'
+import {
+ GateCreateParams,
+ GateListParams,
+ GateResolveParams,
+ RunParams,
+ RunStopParams
+} from './orchestration-gates-params'
+import {
+ AskParams,
+ CheckParams,
+ DispatchParams,
+ DispatchShowParams,
+ InboxParams,
+ ReplyParams,
+ ResetParams,
+ TaskCreateParams,
+ TaskListParams
+} from './orchestration-params'
+import { RequestShowParams } from './orchestration-runs-mutation-request-show-params'
+import {
+ RunCreateParams,
+ RunCurrentParams,
+ RunListParams,
+ RunShowParams,
+ RunUseParams
+} from './orchestration-runs-params'
+import { WorkerDispatchParams, WorkerReadParams } from './orchestration-worker-control-params'
+import { OrchestrationWorkerTerminalUserInputParams } from './orchestration-worker-release-params'
+import {
+ WorkerDispatchParams as WorkerDispatchParamsOfOrchestrationWorkerReleaseSchemasParams,
+ WorkerListParams,
+ WorkerRetainParams
+} from './orchestration-worker-release-schemas-params'
+import { WorkerStartParams } from './orchestration-worker-start-params'
+import { WorkerDispatchParams as WorkerDispatchParamsOfOrchestrationWorkerStopParams } from './orchestration-worker-stop-params'
+import {
+ PluginInvokeCommandParams,
+ PluginReadPanelEntryParams,
+ PluginSetEnabledParams,
+ PluginsPanelActionParams
+} from './plugins-params'
+import {
+ PreflightCheck,
+ PreflightDetectRemoteAgents,
+ PreflightDetectRemoteWindowsTerminalCapabilities
+} from './preflight-params'
+import {
+ ProjectHostSetupClone,
+ ProjectHostSetupCreate,
+ ProjectHostSetupDelete,
+ ProjectHostSetupExistingFolder,
+ ProjectHostSetupUpdate,
+ ProjectUpdate
+} from './project-runtime-params'
+import {
+ ProjectGroupCreate,
+ ProjectGroupImportNested,
+ ProjectGroupMoveProject,
+ ProjectGroupScanNested,
+ ProjectGroupSelector,
+ ProjectGroupUpdate,
+ RepoClone,
+ RepoCreate,
+ RepoIssueCommandWrite,
+ RepoPath,
+ RepoReorder,
+ RepoSearchRefs,
+ RepoSetBaseRef,
+ RepoSparsePresetSave,
+ RepoUpdate
+} from './repo-params'
+import { BrowserTarget } from './rpc-param-primitives'
+import { ClientCapabilitiesUpdate } from './runtime-client-capabilities-params'
+import { SessionTabsUnsubscribeAllParams } from './session-tabs-params'
+import {
+ ActivateTab,
+ CloseLifecycleTab,
+ CloseTab,
+ CreateTerminalTab,
+ MoveTab,
+ SaveMarkdownTab,
+ SessionTabsUnsubscribe,
+ SetTabProps,
+ UpdatePaneLayout,
+ WorktreeTabSelector
+} from './session-tabs-schemas-params'
+import {
+ SkillsCancelInstallParams,
+ SkillsDiscoverParams,
+ SkillsGetInstallProgressParams
+} from './skills-params'
+import {
+ DictationChunk,
+ DictationHandle,
+ DictationSetup,
+ DictationStart,
+ SpeechModelAction
+} from './speech-params'
+import { SshTarget } from './ssh-params'
+import {
+ AttachParams,
+ CancelParams,
+ ConversationCommandParams,
+ CreateParams,
+ CreateSupportParams,
+ HandoffParams,
+ HandoffStatusParams,
+ HistoryParams,
+ HoldParams,
+ OptionsParams,
+ RespondParams,
+ RewindParams,
+ SendParams,
+ SetOptionParams,
+ SubscribeParams,
+ UnsubscribeParams
+} from './structured-agent-session-params'
+import { TerminalAdoptOrphans } from './terminal-orphan-params'
+import { TerminalQuickCommandsUpdate } from './terminal-quick-command-params'
+import {
+ TerminalMultiplex,
+ TerminalResizeForClient,
+ TerminalSubscribe
+} from './terminal-stream-params'
+import {
+ AgentTeamsPrepareLaunch,
+ AgentTeamsTmuxCompat,
+ TerminalCloseAll,
+ TerminalCreateParams,
+ TerminalFocus,
+ TerminalHandle,
+ TerminalInspectProcess,
+ TerminalListParams,
+ TerminalRead,
+ TerminalRecoverPane,
+ TerminalRename,
+ TerminalResolveActive,
+ TerminalResolvePane,
+ TerminalSend,
+ TerminalSplit,
+ TerminalStopExact,
+ TerminalWait
+} from './terminal-unary-params'
+import { TerminalGetAutoRestoreFitParams } from './terminal-viewport-methods-params'
+import {
+ TerminalSetAutoRestoreFit,
+ TerminalSetDisplayMode,
+ TerminalUnsubscribe,
+ TerminalUpdateViewport
+} from './terminal-viewport-schemas-params'
+import { UpdaterCheckParams } from './updater-params'
+import { WorkspacePortKillParams, WorkspacePortScanParams } from './workspace-ports-params'
+import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-params'
+import {
+ WorktreeActivate,
+ WorktreeDetectedListParams,
+ WorktreeForceDeleteBranch,
+ WorktreeListParams,
+ WorktreePsParams,
+ WorktreeRemove,
+ WorktreeResolveMrBase,
+ WorktreeResolvePrBase,
+ WorktreeSelector as WorktreeSelectorOfWorktreeParams,
+ WorktreeSet,
+ WorktreeSortOrder,
+ WorktreeTeardownMissingTerminalsParams
+} from './worktree-params'
+import { SkillBundleInstallRequestSchema } from '../skill-bundle-install-contract'
+import { SkillDeleteRequestSchema } from '../skill-delete-contract'
+import {
+ SkillInstallPreviewRequestSchema,
+ SkillInstallRequestSchema,
+ SkillRemoveRequestSchema
+} from '../skill-install-contract'
+import {
+ SkillUploadBeginRequestSchema,
+ SkillUploadChunkRequestSchema,
+ SkillUploadCommitRequestSchema
+} from '../skill-upload-session-contract'
+
+// Why: the host parses params with these schemas, so a client that matches this map
+// matches the dispatcher. Clients must import it for types only — parsing a params
+// schema client-side runs the coercing transforms and rewrites the wire bytes.
+export const RPC_PARAMS_BY_METHOD = {
+ 'accounts.addClaudeFromConfigDir': AddClaudeFromConfigDirParams,
+ 'accounts.addCodexFromHome': AddCodexFromHomeParams,
+ 'accounts.consumeCodexResetCredit': ConsumeCodexResetCreditParams,
+ 'accounts.list': ListAccountsParams,
+ 'accounts.removeClaude': RemoveAccountParams,
+ 'accounts.removeCodex': RemoveAccountParams,
+ 'accounts.selectClaude': SelectAccountParams,
+ 'accounts.selectCodex': SelectAccountParams,
+ 'accounts.selectCodexForTarget': SelectCodexAccountForTargetParams,
+ 'accounts.subscribe': null,
+ 'accounts.unsubscribe': AccountsUnsubscribeParams,
+ 'agentHooks.prepareCodexForWslPane': PrepareCodexForWslPaneParams,
+ 'agentSession.cancel': CancelParams,
+ 'agentSession.close': OptionsParams,
+ 'agentSession.commands': OptionsParams,
+ 'agentSession.conversationCommand': ConversationCommandParams,
+ 'agentSession.create': CreateParams,
+ 'agentSession.createSupport': CreateSupportParams,
+ 'agentSession.ensure': AttachParams,
+ 'agentSession.handoffStatus': HandoffStatusParams,
+ 'agentSession.history': HistoryParams,
+ 'agentSession.hold': HoldParams,
+ 'agentSession.options': OptionsParams,
+ 'agentSession.release': HoldParams,
+ 'agentSession.requestHandoff': HandoffParams,
+ 'agentSession.respondToApproval': RespondParams,
+ 'agentSession.respondToQuestion': RespondParams,
+ 'agentSession.reveal': OptionsParams,
+ 'agentSession.rewind': RewindParams,
+ 'agentSession.send': SendParams,
+ 'agentSession.setOption': SetOptionParams,
+ 'agentSession.subscribe': SubscribeParams,
+ 'agentSession.subscribeStatus': null,
+ 'agentSession.unsubscribe': UnsubscribeParams,
+ 'agentTeams.prepareLaunch': AgentTeamsPrepareLaunch,
+ 'agentTeams.tmuxCompat': AgentTeamsTmuxCompat,
+ 'aiVault.listSessions': AiVaultListSessionsParams,
+ 'aiVault.prepareSessionResume': AiVaultPrepareSessionResumeParams,
+ 'aiVault.resolveSessionTitles': AiVaultSessionTitlesParams,
+ 'artifacts.delete': ArtifactsDeleteParams,
+ 'artifacts.getPublishedLink': SourceRequest,
+ 'artifacts.list': ListOptions,
+ 'artifacts.publish': WriteRequest,
+ 'artifacts.share': WriteRequest,
+ 'artifacts.unshare': SourceRequest,
+ 'artifacts.update': WriteRequest,
+ 'automation.create': AutomationCreate,
+ 'automation.delete': AutomationId,
+ 'automation.list': AutomationList,
+ 'automation.runNow': AutomationId,
+ 'automation.runs': AutomationRuns,
+ 'automation.show': AutomationId,
+ 'automation.update': AutomationUpdate,
+ 'browser.back': BrowserTarget,
+ 'browser.capture.start': BrowserTarget,
+ 'browser.capture.stop': BrowserTarget,
+ 'browser.certificate.proceed': CertificateProceed,
+ 'browser.check': Check,
+ 'browser.clear': Element,
+ 'browser.click': Element,
+ 'browser.clientHost.attach': BrowserClientHostAttachParams,
+ 'browser.clientHost.commandResult': BrowserClientHostCommandResultParams,
+ 'browser.clientHost.fileChannel.abort': BrowserClientFileChannelAbortParams,
+ 'browser.clientHost.fileChannel.read': BrowserClientFileChannelReadParams,
+ 'browser.clientHost.fileChannel.write': BrowserClientFileChannelWriteParams,
+ 'browser.clientHost.pageMetadata': BrowserClientPageMetadataParams,
+ 'browser.clipboardRead': BrowserTarget,
+ 'browser.clipboardWrite': ClipboardWrite,
+ 'browser.console': LimitParam,
+ 'browser.cookie.delete': CookieDelete,
+ 'browser.cookie.get': CookieGet,
+ 'browser.cookie.set': CookieSet,
+ 'browser.dblclick': Element,
+ 'browser.dialogAccept': DialogAccept,
+ 'browser.dialogDismiss': BrowserTarget,
+ 'browser.download': SelectorPath,
+ 'browser.drag': Drag,
+ 'browser.eval': Eval,
+ 'browser.exec': Exec,
+ 'browser.fill': Fill,
+ 'browser.find': Find,
+ 'browser.focus': Element,
+ 'browser.forward': BrowserTarget,
+ 'browser.fullScreenshot': FullScreenshot,
+ 'browser.geolocation': Geolocation,
+ 'browser.get': Get,
+ 'browser.goto': Goto,
+ 'browser.highlight': Highlight,
+ 'browser.hover': Element,
+ 'browser.intercept.disable': BrowserTarget,
+ 'browser.intercept.enable': InterceptEnable,
+ 'browser.intercept.list': BrowserTarget,
+ 'browser.is': Is,
+ 'browser.keyboardInsertText': KeyboardInsert,
+ 'browser.keypress': Keypress,
+ 'browser.mouseClick': MouseClick,
+ 'browser.mouseDown': MouseButton,
+ 'browser.mouseMove': MouseXY,
+ 'browser.mouseUp': MouseButton,
+ 'browser.mouseWheel': MouseWheel,
+ 'browser.network': LimitParam,
+ 'browser.openUrl': BrowserOpenUrlParams,
+ 'browser.pdf': BrowserTarget,
+ 'browser.profileClearDefaultCookies': null,
+ 'browser.profileCreate': ProfileCreate,
+ 'browser.profileDelete': ProfileDelete,
+ 'browser.profileDetectBrowsers': null,
+ 'browser.profileImportFromBrowser': ProfileImportFromBrowser,
+ 'browser.profileList': null,
+ 'browser.reload': BrowserTarget,
+ 'browser.screencast': Screencast,
+ 'browser.screencast.unsubscribe': ScreencastUnsubscribe,
+ 'browser.screenshot': Screenshot,
+ 'browser.scroll': Scroll,
+ 'browser.scrollIntoView': Element,
+ 'browser.select': Select,
+ 'browser.selectAll': Element,
+ 'browser.setCredentials': SetCredentials,
+ 'browser.setDevice': SetDevice,
+ 'browser.setHeaders': SetHeaders,
+ 'browser.setMedia': SetMedia,
+ 'browser.setOffline': SetOffline,
+ 'browser.snapshot': BrowserTarget,
+ 'browser.storage.local.clear': BrowserTarget,
+ 'browser.storage.local.get': StorageKey,
+ 'browser.storage.local.set': StorageKeyValue,
+ 'browser.storage.session.clear': BrowserTarget,
+ 'browser.storage.session.get': StorageKey,
+ 'browser.storage.session.set': StorageKeyValue,
+ 'browser.tabClose': TabClose,
+ 'browser.tabCreate': BrowserTabCreateParams,
+ 'browser.tabCurrent': TabCurrent,
+ 'browser.tabList': TabList,
+ 'browser.tabProfileClone': TabProfileClone,
+ 'browser.tabProfileShow': TabShow,
+ 'browser.tabSetProfile': TabSetProfile,
+ 'browser.tabShow': TabShow,
+ 'browser.tabSwitch': TabSwitch,
+ 'browser.type': Type,
+ 'browser.upload': Upload,
+ 'browser.viewport': Viewport,
+ 'browser.wait': Wait,
+ 'clipboard.abortImageUpload': AbortImageUpload,
+ 'clipboard.appendImageUploadChunk': AppendImageUploadChunk,
+ 'clipboard.commitImageUpload': CommitImageUpload,
+ 'clipboard.saveImageAsTempFile': SaveImageAsTempFile,
+ 'clipboard.startImageUpload': StartImageUpload,
+ 'computer.capabilities': ComputerCapabilitiesParams,
+ 'computer.click': Click,
+ 'computer.drag': DragOfComputerSchemasParams,
+ 'computer.getAppState': ComputerObserveTarget,
+ 'computer.hotkey': Hotkey,
+ 'computer.listApps': ListApps,
+ 'computer.listWindows': ListWindows,
+ 'computer.pasteText': PasteText,
+ 'computer.performSecondaryAction': PerformSecondaryAction,
+ 'computer.permissions': ComputerPermissions,
+ 'computer.permissionsStatus': ComputerPermissionsStatusParams,
+ 'computer.pressKey': PressKey,
+ 'computer.scroll': ScrollOfComputerSchemasParams,
+ 'computer.setValue': SetValue,
+ 'computer.typeText': TypeText,
+ 'diagnostics.memory': null,
+ 'emulator.attach': AttachParamsOfEmulatorParams,
+ 'emulator.availability': EmulatorAvailabilityParams,
+ 'emulator.ax': AxParams,
+ 'emulator.button': ButtonParams,
+ 'emulator.exec': ExecParams,
+ 'emulator.gesture': GestureParams,
+ 'emulator.kill': KillParams,
+ 'emulator.launch': LaunchParams,
+ 'emulator.list': ListParams,
+ 'emulator.listDevices': EmulatorListDevicesParams,
+ 'emulator.listSimulators': EmulatorListSimulatorsParams,
+ 'emulator.logcat': LogcatParams,
+ 'emulator.permissions': PermissionsParams,
+ 'emulator.rotate': RotateParams,
+ 'emulator.shutdown': ShutdownParams,
+ 'emulator.tap': TapParams,
+ 'emulator.type': TypeParams,
+ 'emulator.unregisterActive': EmulatorUnregisterActiveParams,
+ 'files.browseServerDir': ServerDirectoryBrowse,
+ 'files.commitUpload': FileCommitUpload,
+ 'files.copy': FileCopy,
+ 'files.createDir': FileMutationOpen,
+ 'files.createDirNoClobber': FileMutationOpen,
+ 'files.createFile': FileMutationOpen,
+ 'files.delete': FileDelete,
+ 'files.list': WorktreeSelector,
+ 'files.listAll': FileListAll,
+ 'files.listMarkdownDocuments': WorktreeSelector,
+ 'files.open': FileOpen,
+ 'files.openDiff': FileOpenDiff,
+ 'files.read': FileOpen,
+ 'files.readChunk': FileReadChunk,
+ 'files.readDir': FileTreePath,
+ 'files.readDocPreview': DocPreviewFileRead,
+ 'files.readPreview': FileOpen,
+ 'files.readTerminalArtifact': TerminalArtifactFile,
+ 'files.readTerminalArtifactPreview': TerminalArtifactFile,
+ 'files.rename': FileRename,
+ 'files.resolveTerminalPath': ResolveTerminalPath,
+ 'files.search': FileSearch,
+ 'files.searchPaths': FilePathSearch,
+ 'files.stat': FileTreePath,
+ 'files.unwatch': FileUnwatch,
+ 'files.watch': WorktreeSelector,
+ 'files.write': FileWrite,
+ 'files.writeBase64': FileWriteBase64,
+ 'files.writeBase64Chunk': FileWriteBase64Chunk,
+ 'files.writeTerminalArtifact': TerminalArtifactFileWrite,
+ 'folderWorkspace.create': FolderWorkspaceCreate,
+ 'folderWorkspace.delete': FolderWorkspaceSelector,
+ 'folderWorkspace.getPathStatus': FolderWorkspacePathStatus,
+ 'folderWorkspace.list': null,
+ 'folderWorkspace.update': FolderWorkspaceUpdate,
+ 'git.abortMerge': WorktreeSelectorOfGitParams,
+ 'git.abortRebase': WorktreeSelectorOfGitParams,
+ 'git.branchCompare': GitBranchCompare,
+ 'git.branchDiff': GitBranchDiff,
+ 'git.bulkDiscard': GitBulkPaths,
+ 'git.bulkStage': GitBulkPaths,
+ 'git.bulkUnstage': GitBulkPaths,
+ 'git.cancelGenerateCommitMessage': WorktreeSelectorOfGitParams,
+ 'git.cancelGeneratePullRequestFields': WorktreeSelectorOfGitParams,
+ 'git.checkIgnored': GitCheckIgnored,
+ 'git.checkout': GitCheckout,
+ 'git.commit': GitCommit,
+ 'git.commitCompare': GitCommitCompare,
+ 'git.commitDiff': GitCommitDiff,
+ 'git.conflictOperation': WorktreeSelectorOfGitParams,
+ 'git.diff': GitDiff,
+ 'git.discard': GitFilePath,
+ 'git.discoverCommitMessageModels': GitDiscoverCommitMessageModels,
+ 'git.fastForward': GitTargetedRemote,
+ 'git.fetch': GitTargetedRemote,
+ 'git.forkSync': GitForkSync,
+ 'git.generateCommitMessage': GitGenerateCommitMessage,
+ 'git.generatePullRequestFields': GitGeneratePullRequestFields,
+ 'git.history': GitHistory,
+ 'git.localBranches': WorktreeSelectorOfGitParams,
+ 'git.pull': GitTargetedRemote,
+ 'git.push': GitPush,
+ 'git.rebaseFromBase': GitRebaseFromBase,
+ 'git.remoteCommitUrl': GitRemoteCommitUrl,
+ 'git.remoteFileUrl': GitRemoteFileUrl,
+ 'git.stage': GitFilePath,
+ 'git.status': GitStatusParams,
+ 'git.submoduleStatus': GitSubmoduleStatus,
+ 'git.unstage': GitFilePath,
+ 'git.upstreamStatus': GitTargetedRemote,
+ 'github.addIssueComment': IssueComment,
+ 'github.addPRReviewComment': PRReviewComment,
+ 'github.addPRReviewCommentReply': PRReviewCommentReply,
+ 'github.countWorkItems': WorkItemsCount,
+ 'github.createIssue': CreateIssue,
+ 'github.issue': Issue,
+ 'github.listAssignableUsers': RepoSelector,
+ 'github.listIssues': IssuesList,
+ 'github.listLabels': RepoSelector,
+ 'github.listWorkItems': WorkItemsList,
+ 'github.markPRReadyForReview': MarkPrReadyForReview,
+ 'github.mergePR': MergePr,
+ 'github.prCheckDetails': PullRequestCheckDetails,
+ 'github.prChecks': PullRequestChecks,
+ 'github.prComments': PullRequest,
+ 'github.prFileContents': PullRequestFileContents,
+ 'github.prForBranch': PrForBranch,
+ 'github.project.addIssueCommentBySlug': SlugIssueComment,
+ 'github.project.clearItemField': ClearProjectItemField,
+ 'github.project.deleteIssueCommentBySlug': SlugIssueCommentDelete,
+ 'github.project.listAccessible': GithubProjectListAccessibleParams,
+ 'github.project.listAssignableUsersBySlug': SlugAssignableUsers,
+ 'github.project.listIssueTypesBySlug': SlugRepo,
+ 'github.project.listLabelsBySlug': SlugRepo,
+ 'github.project.listViews': ProjectViews,
+ 'github.project.resolveRef': ProjectRef,
+ 'github.project.updateIssueBySlug': SlugIssueUpdate,
+ 'github.project.updateIssueCommentBySlug': SlugIssueCommentEdit,
+ 'github.project.updateIssueTypeBySlug': SlugIssueTypeUpdate,
+ 'github.project.updateItemField': ProjectItemField,
+ 'github.project.updatePullRequestBySlug': SlugPullRequestUpdate,
+ 'github.project.viewTable': ProjectViewTable,
+ 'github.project.workItemDetailsBySlug': ProjectWorkItemDetailsBySlug,
+ 'github.rateLimit': RateLimit,
+ 'github.removePRReviewers': RemovePrReviewers,
+ 'github.repoSlug': RepoSelector,
+ 'github.repoUpstream': RepoSelector,
+ 'github.requestPRReviewers': RequestPrReviewers,
+ 'github.rerunPRChecks': RerunPullRequestChecks,
+ 'github.resolveReviewThread': ReviewThread,
+ 'github.setPRAutoMerge': SetPrAutoMerge,
+ 'github.setPRCommentReaction': PRCommentReaction,
+ 'github.setPRFileViewed': PullRequestFileViewed,
+ 'github.updateIssue': UpdateIssue,
+ 'github.updatePR': UpdatePr,
+ 'github.updatePRState': UpdatePrState,
+ 'github.updatePRTitle': UpdatePrTitle,
+ 'github.workItem': WorkItem,
+ 'github.workItemByOwnerRepo': WorkItemByOwnerRepo,
+ 'github.workItemDetails': WorkItem,
+ 'gitlab.addIssueComment': AddIssueComment,
+ 'gitlab.addMRComment': AddMRComment,
+ 'gitlab.addMRInlineComment': AddMRInlineComment,
+ 'gitlab.createIssue': CreateIssueOfGitlabParams,
+ 'gitlab.diagnoseAuth': EmptyParams,
+ 'gitlab.jobTrace': JobTrace,
+ 'gitlab.listIssues': IssuesListOfGitlabParams,
+ 'gitlab.listLabels': RepoSelectorOfGitlabParams,
+ 'gitlab.listMRs': WorkItemsListOfGitlabParams,
+ 'gitlab.listWorkItems': WorkItemsListOfGitlabParams,
+ 'gitlab.mergeMR': MergeMr,
+ 'gitlab.rateLimit': GitLabRateLimit,
+ 'gitlab.resolveMRDiscussion': ResolveMRDiscussion,
+ 'gitlab.retryJob': RetryJob,
+ 'gitlab.todos': RepoSelectorOfGitlabParams,
+ 'gitlab.updateIssue': UpdateIssueOfGitlabParams,
+ 'gitlab.updateMR': UpdateMr,
+ 'gitlab.updateMRReviewers': UpdateMrReviewers,
+ 'gitlab.updateMRState': UpdateMrState,
+ 'gitlab.workItemByPath': WorkItemByPath,
+ 'gitlab.workItemDetails': WorkItemDetails,
+ 'host.gitBash.isAvailable': null,
+ 'host.platform': null,
+ 'host.pwsh.isAvailable': null,
+ 'host.wsl.isAvailable': null,
+ 'host.wsl.listDistros': null,
+ 'hostedReview.create': HostedReviewCreate,
+ 'hostedReview.createStacked': HostedReviewCreate,
+ 'hostedReview.forBranch': HostedReviewForBranch,
+ 'hostedReview.getCreationEligibility': HostedReviewCreationEligibility,
+ 'jira.addIssueComment': IssueCommentOfJiraParams,
+ 'jira.connect': Connect,
+ 'jira.createIssue': CreateIssueOfJiraParams,
+ 'jira.disconnect': SiteSelection,
+ 'jira.getIssue': IssueKey,
+ 'jira.getIssueStream': IssueKey,
+ 'jira.getProjectStatusOrder': ProjectStatusOrder,
+ 'jira.issueComments': IssueKey,
+ 'jira.issueCommentsStream': IssueKey,
+ 'jira.listAssignableUsers': AssignableUsers,
+ 'jira.listCreateFields': ProjectIssueTypeFields,
+ 'jira.listIssueTypes': ProjectIssueTypes,
+ 'jira.listIssues': ListIssues,
+ 'jira.listPriorities': SiteSelection,
+ 'jira.listProjects': SiteSelection,
+ 'jira.listTransitions': IssueKey,
+ 'jira.lookupIssueSummary': IssueKey,
+ 'jira.readStatus': null,
+ 'jira.searchIssues': SearchIssues,
+ 'jira.searchUsers': UserSearch,
+ 'jira.selectSite': SelectSite,
+ 'jira.status': null,
+ 'jira.testConnection': SiteSelection,
+ 'jira.updateIssue': IssueUpdate,
+ 'linear.addIssueComment': IssueCommentOfLinearParams,
+ 'linear.agentIssueList': LinearIssueList,
+ 'linear.agentProjectList': LinearProjectList,
+ 'linear.agentSearchIssues': AgentSearchIssues,
+ 'linear.agentTeamLabels': LinearTeamLookup,
+ 'linear.agentTeamList': LinearWorkspaceRead,
+ 'linear.agentTeamMembers': LinearTeamLookup,
+ 'linear.agentTeamStates': LinearTeamLookup,
+ 'linear.connect': ConnectOfLinearParams,
+ 'linear.createIssue': CreateIssueOfLinearParams,
+ 'linear.createProject': CreateProject,
+ 'linear.disconnect': WorkspaceSelection,
+ 'linear.getCustomView': CustomViewId,
+ 'linear.getIssue': IssueId,
+ 'linear.getProject': ProjectId,
+ 'linear.issueAddComment': LinearIssueAddComment,
+ 'linear.issueAttachLink': LinearIssueAttachLink,
+ 'linear.issueComments': LinearIssueCommentsParams,
+ 'linear.issueContext': AgentIssueContext,
+ 'linear.issueCreate': LinearIssueCreate,
+ 'linear.issueRelationWrite': LinearIssueRelationWrite,
+ 'linear.issueSetState': LinearIssueSetState,
+ 'linear.issueUpdateTask': LinearIssueUpdateTask,
+ 'linear.listCustomViewIssues': CustomViewContents,
+ 'linear.listCustomViewProjects': CustomViewContents,
+ 'linear.listCustomViews': ListCustomViews,
+ 'linear.listIssues': ListIssuesOfLinearIssueListParams,
+ 'linear.listProjectIssues': ProjectIssues,
+ 'linear.listProjects': ListProjects,
+ 'linear.listTeams': WorkspaceSelection,
+ 'linear.mcpListIssues': McpListIssues,
+ 'linear.resolveCurrentIssue': LinearCurrentContext,
+ 'linear.saveIssue': LinearSaveIssue,
+ 'linear.searchIssues': SearchIssuesOfLinearParams,
+ 'linear.selectWorkspace': SelectWorkspace,
+ 'linear.status': null,
+ 'linear.teamLabels': TeamId,
+ 'linear.teamMembers': TeamId,
+ 'linear.teamStates': TeamId,
+ 'linear.testConnection': WorkspaceSelection,
+ 'linear.updateIssue': IssueUpdateOfLinearParams,
+ 'markdown.readTab': ActivateTab,
+ 'markdown.saveTab': SaveMarkdownTab,
+ 'nativeChat.readSession': NativeChatSession,
+ 'nativeChat.subscribe': NativeChatSession,
+ 'nativeChat.unsubscribe': NativeChatUnsubscribe,
+ 'network.browserTunnel': BrowserNetworkTunnelAttachParams,
+ 'notifications.getMissedSince': NotificationGetMissedSinceParams,
+ 'notifications.registerPush': NotificationRegisterPushParams,
+ 'notifications.subscribe': NotificationsSubscribeParams,
+ 'notifications.unregisterPush': null,
+ 'notifications.unsubscribe': NotificationUnsubscribeParams,
+ 'orchestration.ask': AskParams,
+ 'orchestration.check': CheckParams,
+ 'orchestration.dispatch': DispatchParams,
+ 'orchestration.dispatchShow': DispatchShowParams,
+ 'orchestration.federationAck': FederationAckParams,
+ 'orchestration.federationAttachStart': FederationAttachStartParams,
+ 'orchestration.federationFleetSnapshot': FederationFleetSnapshotParams,
+ 'orchestration.federationImport': FederationImportParams,
+ 'orchestration.federationPull': FederationPullParams,
+ 'orchestration.federationRead': FederationReadParams,
+ 'orchestration.federationReadOutput': FederationOutputReadParams,
+ 'orchestration.federationRelease': FederationDispatchParams,
+ 'orchestration.federationShow': FederationDispatchParams,
+ 'orchestration.federationStop': FederationDispatchParams,
+ 'orchestration.gateCreate': GateCreateParams,
+ 'orchestration.gateList': GateListParams,
+ 'orchestration.gateResolve': GateResolveParams,
+ 'orchestration.inbox': InboxParams,
+ 'orchestration.reply': ReplyParams,
+ 'orchestration.requestShow': RequestShowParams,
+ 'orchestration.reset': ResetParams,
+ 'orchestration.run': RunParams,
+ 'orchestration.runCreate': RunCreateParams,
+ 'orchestration.runCurrent': RunCurrentParams,
+ 'orchestration.runList': RunListParams,
+ 'orchestration.runShow': RunShowParams,
+ 'orchestration.runStop': RunStopParams,
+ 'orchestration.runUse': RunUseParams,
+ 'orchestration.taskCreate': TaskCreateParams,
+ 'orchestration.taskList': TaskListParams,
+ 'orchestration.workerAbandon': WorkerDispatchParams,
+ 'orchestration.workerList': WorkerListParams,
+ 'orchestration.workerRead': WorkerReadParams,
+ 'orchestration.workerRelease': WorkerDispatchParamsOfOrchestrationWorkerReleaseSchemasParams,
+ 'orchestration.workerRetain': WorkerRetainParams,
+ 'orchestration.workerShow': WorkerDispatchParams,
+ 'orchestration.workerStart': WorkerStartParams,
+ 'orchestration.workerStop': WorkerDispatchParamsOfOrchestrationWorkerStopParams,
+ 'orchestration.workerTerminalUserInput': OrchestrationWorkerTerminalUserInputParams,
+ 'pairing.getEndpoints': PairingGetEndpointsParamsSchema,
+ 'pairing.provisionRelay': PairingProvisionRelayParamsSchema,
+ 'plugins.consent': pluginConsentRequestSchema,
+ 'plugins.invokeCommand': PluginInvokeCommandParams,
+ 'plugins.list': null,
+ 'plugins.panelAction': PluginsPanelActionParams,
+ 'plugins.readPanelEntry': PluginReadPanelEntryParams,
+ 'plugins.setEnabled': PluginSetEnabledParams,
+ 'preflight.check': PreflightCheck,
+ 'preflight.detectAgents': null,
+ 'preflight.detectRemoteAgents': PreflightDetectRemoteAgents,
+ 'preflight.detectRemoteWindowsTerminalCapabilities':
+ PreflightDetectRemoteWindowsTerminalCapabilities,
+ 'preflight.refreshAgents': null,
+ 'project.list': null,
+ 'project.update': ProjectUpdate,
+ 'projectGroup.create': ProjectGroupCreate,
+ 'projectGroup.delete': ProjectGroupSelector,
+ 'projectGroup.importNested': ProjectGroupImportNested,
+ 'projectGroup.list': null,
+ 'projectGroup.moveProject': ProjectGroupMoveProject,
+ 'projectGroup.scanNested': ProjectGroupScanNested,
+ 'projectGroup.update': ProjectGroupUpdate,
+ 'projectHostSetup.clone': ProjectHostSetupClone,
+ 'projectHostSetup.create': ProjectHostSetupCreate,
+ 'projectHostSetup.delete': ProjectHostSetupDelete,
+ 'projectHostSetup.list': null,
+ 'projectHostSetup.setupExistingFolder': ProjectHostSetupExistingFolder,
+ 'projectHostSetup.update': ProjectHostSetupUpdate,
+ 'repo.add': RepoPath,
+ 'repo.baseRefDefault': RepoSelector,
+ 'repo.clone': RepoClone,
+ 'repo.create': RepoCreate,
+ 'repo.gitAvailable': null,
+ 'repo.hooks': RepoSelector,
+ 'repo.hooksCheck': RepoSelector,
+ 'repo.issueCommandRead': RepoSelector,
+ 'repo.issueCommandWrite': RepoIssueCommandWrite,
+ 'repo.list': null,
+ 'repo.reorder': RepoReorder,
+ 'repo.rm': RepoSelector,
+ 'repo.saveSparsePreset': RepoSparsePresetSave,
+ 'repo.searchRefs': RepoSearchRefs,
+ 'repo.setBaseRef': RepoSetBaseRef,
+ 'repo.setupScriptImports': RepoSelector,
+ 'repo.show': RepoSelector,
+ 'repo.sparsePresets': RepoSelector,
+ 'repo.update': RepoUpdate,
+ 'runtime.clientCapabilities.update': ClientCapabilitiesUpdate,
+ 'runtime.clientEvents.subscribe': null,
+ 'runtime.clientEvents.unsubscribe': ClientEventsUnsubscribeParams,
+ 'session.tabs.activate': ActivateTab,
+ 'session.tabs.close': CloseTab,
+ 'session.tabs.closeLifecycle': CloseLifecycleTab,
+ 'session.tabs.createTerminal': CreateTerminalTab,
+ 'session.tabs.list': WorktreeTabSelector,
+ 'session.tabs.listAll': null,
+ 'session.tabs.move': MoveTab,
+ 'session.tabs.setTabProps': SetTabProps,
+ 'session.tabs.subscribe': WorktreeTabSelector,
+ 'session.tabs.subscribeAll': null,
+ 'session.tabs.unsubscribe': SessionTabsUnsubscribe,
+ 'session.tabs.unsubscribeAll': SessionTabsUnsubscribeAllParams,
+ 'session.tabs.updatePaneLayout': UpdatePaneLayout,
+ 'settings.get': null,
+ 'settings.getTerminalQuickCommands': null,
+ 'settings.mutateNativeChatSessionOptions': NativeChatSessionOptionsMutation,
+ 'settings.update': SettingsUpdate,
+ 'settings.updatePRBotAuthorOverride': PRBotAuthorOverrideUpdate,
+ 'settings.updateTerminalQuickCommands': TerminalQuickCommandsUpdate,
+ 'skills.beginUpload': SkillUploadBeginRequestSchema,
+ 'skills.cancelInstall': SkillsCancelInstallParams,
+ 'skills.cancelUpload': SkillUploadCommitRequestSchema,
+ 'skills.commitUpload': SkillUploadCommitRequestSchema,
+ 'skills.delete': SkillDeleteRequestSchema,
+ 'skills.discover': SkillsDiscoverParams,
+ 'skills.getInstallProgress': SkillsGetInstallProgressParams,
+ 'skills.install': SkillInstallRequestSchema,
+ 'skills.installBundle': SkillBundleInstallRequestSchema,
+ 'skills.listManagedInstalls': null,
+ 'skills.previewDelete': SkillDeleteRequestSchema,
+ 'skills.previewInstall': SkillInstallPreviewRequestSchema,
+ 'skills.removeInstall': SkillRemoveRequestSchema,
+ 'skills.share': AgentSkillShareRequestSchema,
+ 'skills.uploadChunk': SkillUploadChunkRequestSchema,
+ 'speech.dictation.cancel': DictationHandle,
+ 'speech.dictation.chunk': DictationChunk,
+ 'speech.dictation.finish': DictationHandle,
+ 'speech.dictation.setup': DictationSetup,
+ 'speech.dictation.start': DictationStart,
+ 'speech.models.delete': SpeechModelAction,
+ 'speech.models.download': SpeechModelAction,
+ 'speech.models.list': null,
+ 'ssh.connect': SshTarget,
+ 'ssh.getState': SshTarget,
+ 'ssh.listRemovedTargetLabels': null,
+ 'ssh.listTargetSummaries': null,
+ 'ssh.listTargets': null,
+ 'stats.summary': null,
+ 'status.get': null,
+ 'terminal.adoptOrphans': TerminalAdoptOrphans,
+ 'terminal.agentStatus': TerminalHandle,
+ 'terminal.clearBuffer': TerminalHandle,
+ 'terminal.close': TerminalHandle,
+ 'terminal.closeAll': TerminalCloseAll,
+ 'terminal.closeTab': TerminalHandle,
+ 'terminal.create': TerminalCreateParams,
+ 'terminal.createAgentSession': CreateAgentSessionParams,
+ 'terminal.ensureAgentSession': EnsureAgentSessionParams,
+ 'terminal.focus': TerminalFocus,
+ 'terminal.getAutoRestoreFit': TerminalGetAutoRestoreFitParams,
+ 'terminal.getDisplayMode': TerminalHandle,
+ 'terminal.inspectProcess': TerminalInspectProcess,
+ 'terminal.isRunningAgent': TerminalHandle,
+ 'terminal.list': TerminalListParams,
+ 'terminal.multiplex': TerminalMultiplex,
+ 'terminal.read': TerminalRead,
+ 'terminal.recoverPane': TerminalRecoverPane,
+ 'terminal.rename': TerminalRename,
+ 'terminal.resizeForClient': TerminalResizeForClient,
+ 'terminal.resolveActive': TerminalResolveActive,
+ 'terminal.resolveIdentity': TerminalHandle,
+ 'terminal.resolvePane': TerminalResolvePane,
+ 'terminal.restoreFit': TerminalHandle,
+ 'terminal.send': TerminalSend,
+ 'terminal.setAutoRestoreFit': TerminalSetAutoRestoreFit,
+ 'terminal.setDisplayMode': TerminalSetDisplayMode,
+ 'terminal.show': TerminalHandle,
+ 'terminal.sleep': TerminalCloseAll,
+ 'terminal.split': TerminalSplit,
+ 'terminal.stop': TerminalCloseAll,
+ 'terminal.stopExact': TerminalStopExact,
+ 'terminal.subscribe': TerminalSubscribe,
+ 'terminal.unsubscribe': TerminalUnsubscribe,
+ 'terminal.updateViewport': TerminalUpdateViewport,
+ 'terminal.wait': TerminalWait,
+ 'ui.get': null,
+ 'ui.recordFeatureInteraction': FeatureInteractionIdParam,
+ 'ui.set': UiUpdate,
+ 'updater.check': UpdaterCheckParams,
+ 'updater.download': null,
+ 'updater.getStatus': null,
+ 'updater.install': null,
+ 'workspacePorts.kill': WorkspacePortKillParams,
+ 'workspacePorts.scan': WorkspacePortScanParams,
+ 'worktree.activate': WorktreeActivate,
+ 'worktree.create': WorktreeCreate,
+ 'worktree.detectedList': WorktreeDetectedListParams,
+ 'worktree.forceDeleteBranch': WorktreeForceDeleteBranch,
+ 'worktree.lineageList': null,
+ 'worktree.list': WorktreeListParams,
+ 'worktree.listRetiredNames': WorktreeDetectedListParams,
+ 'worktree.persistSortOrder': WorktreeSortOrder,
+ 'worktree.prefetchCreateBase': WorktreePrefetchCreateBase,
+ 'worktree.ps': WorktreePsParams,
+ 'worktree.resolveMrBase': WorktreeResolveMrBase,
+ 'worktree.resolvePrBase': WorktreeResolvePrBase,
+ 'worktree.rm': WorktreeRemove,
+ 'worktree.set': WorktreeSet,
+ 'worktree.show': WorktreeSelectorOfWorktreeParams,
+ 'worktree.sleep': WorktreeSelectorOfWorktreeParams,
+ 'worktree.teardownMissingTerminals': WorktreeTeardownMissingTerminalsParams
+} as const
+
+// Why: these methods bind a schema the shared contract cannot hold because its value
+// graph reaches into src/main. Listing them keeps the gap visible instead of absent.
+export const RPC_METHODS_WITHOUT_SHARED_PARAMS: readonly string[] = [
+ 'emulator.install',
+ 'orchestration.send',
+ 'orchestration.taskUpdate'
+]
+
+export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD
+
+// Why: z.output is the post-parse shape the handler receives. z.input is not a
+// send-side type here — requiredString is z.unknown().transform(...), so its input
+// admits any value and loses optional/default semantics.
+export type RpcParams =
+ (typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType
+ ? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]>
+ : void
diff --git a/src/shared/rpc-contract/runtime-client-capabilities-params.ts b/src/shared/rpc-contract/runtime-client-capabilities-params.ts
new file mode 100644
index 00000000000..77f7914585b
--- /dev/null
+++ b/src/shared/rpc-contract/runtime-client-capabilities-params.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod'
+
+export const ClientCapabilitiesUpdate = z
+ .object({
+ clientCapabilities: z.array(z.string().min(1).max(128)).max(64)
+ })
+ .strict()
diff --git a/src/shared/rpc-contract/session-tabs-params.ts b/src/shared/rpc-contract/session-tabs-params.ts
new file mode 100644
index 00000000000..dfcdf60cca2
--- /dev/null
+++ b/src/shared/rpc-contract/session-tabs-params.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod'
+
+export const SessionTabsUnsubscribeAllParams = z
+ .object({
+ subscriptionId: z.string().min(1).optional()
+ })
+ .nullish()
diff --git a/src/shared/rpc-contract/session-tabs-schemas-params.ts b/src/shared/rpc-contract/session-tabs-schemas-params.ts
new file mode 100644
index 00000000000..d04f5443477
--- /dev/null
+++ b/src/shared/rpc-contract/session-tabs-schemas-params.ts
@@ -0,0 +1,230 @@
+import { z } from 'zod'
+import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation'
+import { TAB_ACTIVATION_INTENTS } from '../tab-activation-intent'
+import { OptionalBoolean } from './rpc-param-primitives'
+import { sleepingAgentLaunchConfigSchema } from '../workspace-session-sleeping-agents'
+import type { TuiAgent } from '../tui-agent'
+import { isTuiAgent } from '../tui-agent-config'
+import { MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH } from '../terminal-quick-commands'
+
+export const WorktreeTabSelector = z.object({
+ worktree: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing worktree selector'))
+})
+
+export const SessionTabsUnsubscribe = WorktreeTabSelector.extend({
+ subscriptionId: z.string().min(1).optional()
+})
+
+export const ActivateTab = WorktreeTabSelector.extend({
+ tabId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing tab id')),
+ leafId: z.string().max(128).optional(),
+ notifyClients: OptionalBoolean,
+ navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
+ // Why: absent means user intent, so clients that predate this field keep the
+ // tab-open wake gesture. Only 'automatic' may be refused for a slept pane.
+ intent: z.enum(TAB_ACTIVATION_INTENTS).optional()
+})
+
+export const CloseTab = ActivateTab.extend({
+ // Why: optional preserves authenticated legacy user closes; lifecycle intent
+ // uses the additive evidence-bearing method instead.
+ reason: z.literal('user').optional()
+})
+
+export const CloseLifecycleTab = ActivateTab.extend({
+ reason: z.enum(['pty-exit', 'cleanup']),
+ publicationEpoch: z.string().min(1).max(128),
+ terminal: z.string().min(1).max(256)
+})
+
+export type TerminalPaneLayoutNodeInput =
+ | { type: 'leaf'; leafId: string }
+ | {
+ type: 'split'
+ direction: 'horizontal' | 'vertical'
+ first: TerminalPaneLayoutNodeInput
+ second: TerminalPaneLayoutNodeInput
+ ratio?: number
+ }
+
+// Why: this schema parses UNTRUSTED remote-client input. A recursive zod parse
+// of a deeply-nested tree would overflow the main-process stack, so validate
+// iteratively with hard depth + node-count caps before building the typed value.
+export const MAX_PANE_LAYOUT_DEPTH = 64
+
+export const MAX_PANE_LAYOUT_NODES = 1024
+
+export function parseTerminalPaneLayoutNode(value: unknown): TerminalPaneLayoutNodeInput | null {
+ // Iterative validate-then-build: first walk the raw tree with an explicit
+ // stack (no recursion) enforcing caps, then build bottom-up.
+ let nodeCount = 0
+ const stack: { raw: unknown; depth: number }[] = [{ raw: value, depth: 0 }]
+ while (stack.length > 0) {
+ const { raw, depth } = stack.pop()!
+ if (depth > MAX_PANE_LAYOUT_DEPTH || ++nodeCount > MAX_PANE_LAYOUT_NODES) {
+ return null
+ }
+ if (typeof raw !== 'object' || raw === null) {
+ return null
+ }
+ const node = raw as Record
+ if (node.type === 'leaf') {
+ if (typeof node.leafId !== 'string' || node.leafId.length < 1 || node.leafId.length > 128) {
+ return null
+ }
+ continue
+ }
+ if (node.type === 'split') {
+ if (node.direction !== 'horizontal' && node.direction !== 'vertical') {
+ return null
+ }
+ if (
+ node.ratio !== undefined &&
+ (typeof node.ratio !== 'number' ||
+ !Number.isFinite(node.ratio) ||
+ node.ratio < 0 ||
+ node.ratio > 1)
+ ) {
+ return null
+ }
+ stack.push({ raw: node.first, depth: depth + 1 }, { raw: node.second, depth: depth + 1 })
+ continue
+ }
+ return null
+ }
+ return value as TerminalPaneLayoutNodeInput
+}
+
+export const TerminalPaneLayoutNodeSchema = z
+ .unknown()
+ .transform((value) => parseTerminalPaneLayoutNode(value))
+ .pipe(
+ z.custom((value) => value !== null, {
+ message: 'Invalid or too-deep pane layout tree'
+ })
+ )
+
+export const UpdatePaneLayout = WorktreeTabSelector.extend({
+ tabId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing tab id')),
+ root: z.union([z.null(), TerminalPaneLayoutNodeSchema]),
+ expandedLeafId: z.string().max(128).nullable().optional(),
+ titlesByLeafId: z.record(z.string(), z.string()).optional()
+})
+
+export const SetTabProps = WorktreeTabSelector.extend({
+ tabId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing tab id')),
+ // undefined = leave unchanged; null = clear color / unset.
+ color: z.string().max(64).nullable().optional(),
+ isPinned: z.boolean().optional(),
+ // undefined = leave unchanged; no "clear" semantic (absence means default 'terminal').
+ viewMode: z.enum(['terminal', 'chat']).optional()
+})
+
+export const CreateTerminalTab = WorktreeTabSelector.extend({
+ afterTabId: z.string().optional(),
+ targetGroupId: z.string().optional(),
+ command: z.string().optional(),
+ cwd: z.string().min(1).optional(),
+ env: z.record(z.string(), z.string()).optional(),
+ envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(),
+ startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
+ launchConfig: sleepingAgentLaunchConfigSchema,
+ launchToken: z.string().min(1).max(128).optional(),
+ agent: z
+ .custom(isTuiAgent, {
+ message: 'Unknown agent preset'
+ })
+ .optional(),
+ // Why: agent prompts must be quoted and injected for the host shell (native,
+ // WSL, or SSH) instead of pasted from the mobile client before the TUI is ready.
+ agentPrompt: z
+ .string()
+ .max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH)
+ .refine((value) => value.trim().length > 0, { message: 'Agent prompt cannot be empty' })
+ .optional(),
+ // Why: `agent` is the legacy preset field; `launchAgent` is the launch-plan
+ // identity used when preserving resume config across runtime boundaries.
+ launchAgent: z
+ .custom(isTuiAgent, {
+ message: 'Unknown launch agent'
+ })
+ .optional(),
+ viewMode: z.enum(['terminal', 'chat']).optional(),
+ activate: z.boolean().optional(),
+ select: z.boolean().optional(),
+ navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
+ // Why: idempotency key so a retried create (double-tap, reconnect replay)
+ // returns the in-flight operation instead of spawning a duplicate terminal.
+ clientMutationId: z.string().min(1).max(128).optional()
+}).superRefine((value, context) => {
+ if (value.agentPrompt !== undefined && value.agent === undefined) {
+ context.addIssue({
+ code: 'custom',
+ path: ['agentPrompt'],
+ message: 'Agent prompt requires an agent preset'
+ })
+ }
+ if (value.agentPrompt !== undefined && value.command !== undefined) {
+ context.addIssue({
+ code: 'custom',
+ path: ['agentPrompt'],
+ message: 'Agent prompt cannot be combined with a startup command'
+ })
+ }
+})
+
+export const MoveTabBase = {
+ worktree: WorktreeTabSelector.shape.worktree,
+ tabId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing tab id')),
+ targetGroupId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing target group id'))
+} as const
+
+export const MoveTab = z.discriminatedUnion('kind', [
+ z
+ .object({
+ ...MoveTabBase,
+ kind: z.literal('reorder'),
+ tabOrder: z.array(z.string().min(1)).min(1, 'Missing tab order')
+ })
+ .strict(),
+ z
+ .object({
+ ...MoveTabBase,
+ kind: z.literal('move-to-group'),
+ index: z.number().int().nonnegative().optional()
+ })
+ .strict(),
+ z
+ .object({
+ ...MoveTabBase,
+ kind: z.literal('split'),
+ splitDirection: z.enum(['left', 'right', 'up', 'down'])
+ })
+ .strict()
+])
+
+export const SaveMarkdownTab = ActivateTab.extend({
+ baseVersion: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing base version')),
+ content: z.string()
+})
diff --git a/src/shared/rpc-contract/skills-params.ts b/src/shared/rpc-contract/skills-params.ts
new file mode 100644
index 00000000000..53d7b769523
--- /dev/null
+++ b/src/shared/rpc-contract/skills-params.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod'
+import { SkillDiscoveryTargetSchema } from '../skills'
+
+export const SkillsGetInstallProgressParams = z
+ .object({ operationId: z.string().min(1).max(128) })
+ .strict()
+
+export const SkillsCancelInstallParams = z
+ .object({ operationId: z.string().min(1).max(128) })
+ .strict()
+
+export const SkillsDiscoverParams = SkillDiscoveryTargetSchema.default({})
diff --git a/src/shared/rpc-contract/speech-params.ts b/src/shared/rpc-contract/speech-params.ts
new file mode 100644
index 00000000000..8cd0ea00a49
--- /dev/null
+++ b/src/shared/rpc-contract/speech-params.ts
@@ -0,0 +1,54 @@
+import { z } from 'zod'
+import { OptionalString, requiredString } from './rpc-param-primitives'
+
+export const AUDIO_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/
+
+export const DICTATION_SAMPLE_RATE = 16_000
+
+export const PCM_BYTES_PER_SAMPLE = 2
+
+export const MAX_DICTATION_AUDIO_SECONDS = 5
+
+export const MAX_DICTATION_AUDIO_CHUNK_BYTES =
+ DICTATION_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_DICTATION_AUDIO_SECONDS
+
+export const MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH =
+ Math.ceil(MAX_DICTATION_AUDIO_CHUNK_BYTES / 3) * 4
+
+export function isValidAudioBase64(value: string): boolean {
+ return value.length % 4 !== 1 && AUDIO_BASE64_PATTERN.test(value)
+}
+
+export const DictationStart = z.object({
+ dictationId: requiredString('Missing dictation ID'),
+ modelId: OptionalString
+})
+
+export const DictationChunk = z.object({
+ dictationId: requiredString('Missing dictation ID'),
+ audioBase64: requiredString('Missing audio chunk')
+ // Why: feedMobileDictation decodes into Buffer + Float32Array; reject
+ // oversized chunks before allocation. This mirrors the mobile pending-audio budget.
+ .refine(
+ (value) => value.length <= MAX_DICTATION_AUDIO_CHUNK_BASE64_LENGTH,
+ 'Audio chunk is too large'
+ )
+ // Why: Buffer.from(..., 'base64') silently drops malformed bytes; reject
+ // bad mobile audio chunks instead of feeding empty/corrupt PCM.
+ .refine(isValidAudioBase64, 'Audio chunk must be base64'),
+ sampleRate: z.number().finite().positive()
+})
+
+export const DictationHandle = z.object({
+ dictationId: requiredString('Missing dictation ID')
+})
+
+export const SpeechModelAction = z.object({
+ modelId: requiredString('Missing model ID')
+})
+
+export const DictationSetup = z.object({
+ enabled: z.boolean().optional(),
+ modelId: OptionalString,
+ dictationMode: z.enum(['toggle', 'hold']).optional()
+})
diff --git a/src/shared/rpc-contract/ssh-params.ts b/src/shared/rpc-contract/ssh-params.ts
new file mode 100644
index 00000000000..77f336c991b
--- /dev/null
+++ b/src/shared/rpc-contract/ssh-params.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod'
+
+export const SshTarget = z.object({
+ targetId: z.string().min(1)
+})
diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts
new file mode 100644
index 00000000000..7d2c73afb66
--- /dev/null
+++ b/src/shared/rpc-contract/structured-agent-session-params.ts
@@ -0,0 +1,246 @@
+import { z } from 'zod'
+import { isAgentSessionId } from '../agent-session-record'
+import { normalizeExecutionHostId } from '../execution-host'
+import {
+ AGENT_SESSION_HISTORY_DIRECTIONS,
+ AGENT_SESSION_HISTORY_MAX_LIMIT
+} from '../agent-session-wire'
+
+export const MAX_ID_LENGTH = 512
+
+// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded.
+export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024
+
+export const MAX_PROMPT_BYTES = 256 * 1024
+
+export const MAX_BLOCKS = 64
+
+export const MAX_OPTION_LABEL = 512
+
+export const SessionId = z
+ .string()
+ .max(MAX_ID_LENGTH)
+ .refine(isAgentSessionId, 'Invalid agent session id')
+
+export const Identifier = (message: string, maxLength = MAX_ID_LENGTH) =>
+ z
+ .string()
+ .min(1, message)
+ .max(maxLength, message)
+ .refine((value) => value === value.trim(), message)
+
+export const JournalCursor = z
+ .object({
+ epoch: Identifier('Invalid journal epoch'),
+ sequence: z.number().int().nonnegative()
+ })
+ .strict()
+
+export const MutationEnvelope = z
+ .object({
+ sessionId: SessionId,
+ clientOperationId: Identifier('Invalid client operation id'),
+ /** Null is the "must not exist yet" case; every other call fences. */
+ expectedRuntimeFence: z.number().int().positive().nullable(),
+ payloadFingerprint: z
+ .string()
+ .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest')
+ })
+ .strict()
+
+export const ProviderHandle = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(),
+ z
+ .object({
+ kind: z.literal('claude'),
+ sessionId: Identifier('Invalid provider session id'),
+ leafUuid: Identifier('Invalid leaf uuid').nullable()
+ })
+ .strict()
+])
+
+export const ExecutionHostId = z
+ .string()
+ .max(MAX_ID_LENGTH)
+ .transform((value) => normalizeExecutionHostId(value))
+ .refine((value): value is NonNullable => value !== null, {
+ message: 'Invalid execution host id'
+ })
+
+export const ExecutionLocation = z
+ .object({
+ executionHostId: ExecutionHostId,
+ wslDistro: Identifier('Invalid WSL distro').nullable(),
+ workspaceId: Identifier('Invalid workspace id'),
+ workspaceKind: z.enum(['git-worktree', 'folder'])
+ })
+ .strict()
+
+export const AccountHome = z
+ .object({
+ variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']),
+ path: z.string().min(1).max(4096)
+ })
+ .strict()
+
+export const AttachParams = z
+ .object({
+ envelope: MutationEnvelope,
+ location: ExecutionLocation,
+ provider: z.enum(['codex', 'claude']),
+ agent: Identifier('Invalid agent'),
+ accountHome: AccountHome,
+ runtimeKind: z.enum(['native', 'tui']),
+ providerHandle: ProviderHandle
+ })
+ .strict()
+
+/** An identity, and nothing the host would otherwise read off disk. A transcript path or account
+ * home here would let a client choose which file this host imports and which credential directory
+ * the provider child launches against; both are derived host-side from this id instead. */
+export const ResumeSource = z
+ .object({
+ providerSessionId: Identifier('Invalid provider session id')
+ })
+ .strict()
+
+export const CreateIntentParams = z
+ .object({
+ envelope: MutationEnvelope,
+ worktree: Identifier('Invalid worktree selector'),
+ agent: z.enum(['claude', 'codex']),
+ resumeFrom: ResumeSource.optional()
+ })
+ .strict()
+
+export const CreateParams = z.union([AttachParams, CreateIntentParams])
+
+export const CreateSupportParams = z
+ .object({
+ worktree: Identifier('Invalid worktree selector'),
+ agent: z.enum(['claude', 'codex'])
+ })
+ .strict()
+
+/** Clients may only author user turns. Accepting an assistant or tool role here
+ * would let one client write words into the agent's mouth in another's
+ * timeline, and the provider — not the client — owns those. */
+export const SendBlock = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('text'), text: z.string() }).strict(),
+ z
+ .object({
+ type: z.literal('image-ref'),
+ path: z.string().min(1).max(4096).optional(),
+ url: z.string().min(1).max(4096).optional(),
+ alt: z.string().max(MAX_OPTION_LABEL).optional()
+ })
+ .strict()
+ .refine(
+ (value) => Boolean(value.path) !== Boolean(value.url),
+ 'Provide exactly one of path/url'
+ )
+])
+
+export const SendParams = z
+ .object({
+ envelope: MutationEnvelope,
+ retryUnknown: z.literal(true).optional(),
+ body: z
+ .object({
+ kind: z.literal('message'),
+ role: z.literal('user'),
+ blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS)
+ })
+ .strict()
+ .refine(
+ (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES,
+ 'Message is too large'
+ )
+ })
+ .strict()
+
+export const CancelParams = z
+ .object({
+ envelope: MutationEnvelope,
+ turnId: Identifier('Invalid turn id'),
+ scope: z.literal('background-tasks').optional(),
+ taskId: Identifier('Invalid task id').optional()
+ })
+ .strict()
+ .refine((value) => value.taskId === undefined || value.scope === 'background-tasks', {
+ message: 'A task id requires background-task scope'
+ })
+
+export const RespondParams = z
+ .object({
+ envelope: MutationEnvelope,
+ itemId: Identifier('Invalid item id'),
+ /** Compare-and-set: the revision the client had on screen. */
+ expectedRevision: z.number().int().positive(),
+ optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH)
+ })
+ .strict()
+
+export const SetOptionParams = z
+ .object({
+ envelope: MutationEnvelope,
+ key: Identifier('Invalid option key'),
+ value: z.string().max(MAX_OPTION_LABEL)
+ })
+ .strict()
+
+export const HandoffParams = z
+ .object({
+ envelope: MutationEnvelope,
+ direction: z.enum(['to-tui', 'to-native']),
+ mode: z.enum(['now', 'after-turn', 'stop-turn']),
+ action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional()
+ })
+ .strict()
+
+export const OptionsParams = z.object({ sessionId: SessionId }).strict()
+
+export const ConversationCommandParams = z
+ .object({
+ envelope: MutationEnvelope,
+ command: z.enum(['clear', 'compact'])
+ })
+ .strict()
+
+/** One surface's claim on one session. The id names the surface, not the client: two chat views
+ * looking at the same session are two holders, and either leaving must not release
+ * the other's. */
+export const HoldParams = z
+ .object({ sessionId: SessionId, holderId: Identifier('Invalid holder id') })
+ .strict()
+
+export const HistoryParams = z
+ .object({
+ sessionId: SessionId,
+ direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS),
+ cursor: JournalCursor.optional(),
+ limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional()
+ })
+ .strict()
+
+export const SubscribeParams = z
+ .object({ sessionId: SessionId, cursor: JournalCursor.optional() })
+ .strict()
+
+export const UnsubscribeParams = z
+ .object({
+ sessionId: SessionId,
+ subscriptionId: Identifier('Invalid subscription id').optional()
+ })
+ .strict()
+
+/** Read-only owner classification retained for restart safety; mutation handoff is separate. */
+export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict()
+
+export const RewindParams = z
+ .object({
+ envelope: MutationEnvelope,
+ itemId: Identifier('Invalid item id', 4096),
+ expectedEpoch: Identifier('Invalid journal epoch')
+ })
+ .strict()
diff --git a/src/shared/rpc-contract/task-resume-state-params.ts b/src/shared/rpc-contract/task-resume-state-params.ts
new file mode 100644
index 00000000000..56ab022eb92
--- /dev/null
+++ b/src/shared/rpc-contract/task-resume-state-params.ts
@@ -0,0 +1,32 @@
+import { z } from 'zod'
+
+/**
+ * Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`.
+ *
+ * This object is `.strict()` and sits behind `ui.set`'s field-level `.catch`, so a key
+ * a host predates makes that host drop the ENTIRE resume state — github and jira with
+ * it — and report success. Only add a field here when clients must agree on it across
+ * versions; per-device view preferences belong in client-local storage instead.
+ */
+export const TaskResumeState = z
+ .object({
+ githubMode: z.enum(['items', 'project']).optional(),
+ githubItemsPreset: z.string().nullable().optional(),
+ githubItemsQuery: z.string().optional(),
+ githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(),
+ linearMode: z.enum(['issues', 'projects', 'views', 'in-orca']).optional(),
+ linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(),
+ linearQuery: z.string().optional(),
+ linearContext: z
+ .object({
+ kind: z.enum(['project', 'view']),
+ id: z.string(),
+ workspaceId: z.string(),
+ model: z.enum(['issue', 'project']).optional()
+ })
+ .strict()
+ .optional(),
+ jiraPreset: z.enum(['assigned', 'reported', 'all', 'done']).optional(),
+ jiraQuery: z.string().optional()
+ })
+ .strict()
diff --git a/src/shared/rpc-contract/terminal-orphan-params.ts b/src/shared/rpc-contract/terminal-orphan-params.ts
new file mode 100644
index 00000000000..2a14c2b72c3
--- /dev/null
+++ b/src/shared/rpc-contract/terminal-orphan-params.ts
@@ -0,0 +1,105 @@
+import { z } from 'zod'
+import type { TabGroupLayoutNode } from '../tab-types'
+import { OptionalString, requiredString } from './rpc-param-primitives'
+import { TerminalPaneLayoutNodeSchema } from './session-tabs-schemas-params'
+import { isPtyIncarnationId } from '../pty-incarnation'
+import type { PtyIncarnationId } from '../pty-incarnation'
+
+export function parseOrphanGroupLayout(value: unknown): TabGroupLayoutNode | null {
+ const stack: { value: unknown; depth: number }[] = [{ value, depth: 0 }]
+ let count = 0
+ while (stack.length > 0) {
+ const current = stack.pop()!
+ if (
+ current.depth > 64 ||
+ ++count > 1_024 ||
+ !current.value ||
+ typeof current.value !== 'object'
+ ) {
+ return null
+ }
+ const node = current.value as Record
+ if (node.type === 'leaf') {
+ if (
+ typeof node.groupId !== 'string' ||
+ node.groupId.length < 1 ||
+ node.groupId.length > 256
+ ) {
+ return null
+ }
+ continue
+ }
+ if (
+ node.type !== 'split' ||
+ (node.direction !== 'horizontal' && node.direction !== 'vertical') ||
+ (node.ratio !== undefined &&
+ (typeof node.ratio !== 'number' ||
+ !Number.isFinite(node.ratio) ||
+ node.ratio < 0 ||
+ node.ratio > 1))
+ ) {
+ return null
+ }
+ stack.push(
+ { value: node.first, depth: current.depth + 1 },
+ { value: node.second, depth: current.depth + 1 }
+ )
+ }
+ return value as TabGroupLayoutNode
+}
+
+export const TerminalOrphanGroupLayout = z
+ .unknown()
+ .transform(parseOrphanGroupLayout)
+ .pipe(z.custom((value) => value !== null, 'Invalid orphan group layout'))
+
+export const TerminalOrphanTopology = z.object({
+ tabs: z
+ .array(
+ z.object({
+ tabId: requiredString('Missing topology tab id').pipe(z.string().max(256)),
+ root: TerminalPaneLayoutNodeSchema,
+ activeLeafId: requiredString('Missing active leaf id').pipe(z.string().max(128)),
+ expandedLeafId: z.string().max(128).nullable()
+ })
+ )
+ .min(1)
+ .max(64),
+ groups: z
+ .array(
+ z.object({
+ id: z.string().min(1).max(256),
+ activeTabId: z.string().min(1).max(256),
+ tabOrder: z.array(z.string().min(1).max(256)).min(1).max(64),
+ recentTabIds: z.array(z.string().min(1).max(256)).max(64).optional()
+ })
+ )
+ .min(1)
+ .max(64),
+ groupLayout: TerminalOrphanGroupLayout.optional()
+})
+
+export const TerminalOrphanIncarnationId = z.custom(
+ isPtyIncarnationId,
+ 'Invalid PTY incarnation'
+)
+
+export const TerminalAdoptOrphans = z.object({
+ worktree: requiredString('Missing worktree selector').pipe(z.string().max(32_768)),
+ expectedTopologyRevision: z.number().int().nonnegative(),
+ claims: z
+ .array(
+ z.object({
+ terminal: requiredString('Missing terminal handle').pipe(z.string().max(256)),
+ ptyId: requiredString('Missing PTY id').pipe(z.string().max(8_192)),
+ incarnationId: TerminalOrphanIncarnationId,
+ tabId: requiredString('Missing tab id').pipe(z.string().max(256)),
+ leafId: requiredString('Missing leaf id').pipe(z.string().max(128))
+ })
+ )
+ .min(1)
+ .max(64),
+ activeTabId: OptionalString.pipe(z.string().max(256).optional()),
+ activeGroupId: OptionalString.pipe(z.string().max(256).optional()),
+ topology: TerminalOrphanTopology.optional()
+})
diff --git a/src/shared/rpc-contract/terminal-quick-command-params.ts b/src/shared/rpc-contract/terminal-quick-command-params.ts
new file mode 100644
index 00000000000..2a38bf28d8f
--- /dev/null
+++ b/src/shared/rpc-contract/terminal-quick-command-params.ts
@@ -0,0 +1,73 @@
+import { z } from 'zod'
+import {
+ MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH,
+ MAX_QUICK_COMMAND_ID_LENGTH,
+ MAX_QUICK_COMMAND_LABEL_LENGTH,
+ MAX_QUICK_COMMAND_REPO_ID_LENGTH,
+ MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH,
+ normalizeTerminalQuickCommands,
+ supportsTerminalAgentQuickCommand
+} from '../terminal-quick-commands'
+import type { TerminalQuickCommand } from '../terminal-quick-command-types'
+
+export const TerminalQuickCommandScopeUpdate = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('global') }).strict(),
+ z
+ .object({
+ type: z.literal('repo'),
+ repoId: z.string().max(MAX_QUICK_COMMAND_REPO_ID_LENGTH)
+ })
+ .strict()
+])
+
+export const TerminalQuickCommandUpdateItem = z.union([
+ z
+ .object({
+ id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH),
+ label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH),
+ action: z.literal('terminal-command').optional(),
+ command: z.string().max(MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH),
+ appendEnter: z.boolean(),
+ scope: TerminalQuickCommandScopeUpdate.optional()
+ })
+ .strict(),
+ z
+ .object({
+ id: z.string().max(MAX_QUICK_COMMAND_ID_LENGTH),
+ label: z.string().max(MAX_QUICK_COMMAND_LABEL_LENGTH),
+ action: z.literal('agent-prompt'),
+ agent: z.custom(supportsTerminalAgentQuickCommand, {
+ message: 'Agent does not support prompt commands'
+ }),
+ prompt: z.string().max(MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH),
+ scope: TerminalQuickCommandScopeUpdate.optional()
+ })
+ .strict()
+])
+
+export const TerminalQuickCommandsUpdate = z
+ .object({
+ // Why: a single host-side mutation preserves unrelated desktop/mobile edits
+ // and avoids retransmitting the full ~240 KB list for every small change.
+ mutation: z.union([
+ z
+ .object({
+ type: z.literal('upsert'),
+ command: TerminalQuickCommandUpdateItem.transform(
+ (value) => normalizeTerminalQuickCommands([value])[0]
+ ).pipe(
+ z.custom((value) => value !== undefined, {
+ message: 'Quick command cannot be normalized'
+ })
+ )
+ })
+ .strict(),
+ z
+ .object({
+ type: z.literal('delete'),
+ id: z.string().min(1).max(MAX_QUICK_COMMAND_ID_LENGTH)
+ })
+ .strict()
+ ])
+ })
+ .strict()
diff --git a/src/shared/rpc-contract/terminal-stream-params.ts b/src/shared/rpc-contract/terminal-stream-params.ts
new file mode 100644
index 00000000000..88506f63b47
--- /dev/null
+++ b/src/shared/rpc-contract/terminal-stream-params.ts
@@ -0,0 +1,40 @@
+import { z } from 'zod'
+import { requiredString } from './rpc-param-primitives'
+import { TerminalViewport } from './terminal-unary-params'
+
+export const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') })
+
+export const TerminalResizeForClient = z.discriminatedUnion('mode', [
+ z.object({
+ terminal: requiredString('Missing terminal handle'),
+ mode: z.literal('mobile-fit'),
+ cols: z.number().finite().positive(),
+ rows: z.number().finite().positive(),
+ clientId: requiredString('Missing client ID')
+ }),
+ z.object({
+ terminal: requiredString('Missing terminal handle'),
+ mode: z.literal('restore'),
+ clientId: requiredString('Missing client ID')
+ })
+])
+
+export const TerminalSubscribe = TerminalHandle.extend({
+ client: z
+ .object({
+ id: requiredString('Missing client ID'),
+ type: z.enum(['mobile', 'desktop']).default('desktop')
+ })
+ .optional(),
+ viewport: TerminalViewport.optional(),
+ capabilities: z
+ .object({
+ terminalBinaryStream: z.literal(1).optional(),
+ desktopViewportClaims: z.literal(1).optional(),
+ mobileInputLeaseOnly: z.literal(1).optional(),
+ writeUnavailable: z.literal(1).optional()
+ })
+ .optional()
+})
+
+export const TerminalMultiplex = z.object({})
diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts
new file mode 100644
index 00000000000..e86bdf1fdc1
--- /dev/null
+++ b/src/shared/rpc-contract/terminal-unary-params.ts
@@ -0,0 +1,222 @@
+import { z } from 'zod'
+import { OptionalFiniteNumber, OptionalString, requiredString } from './rpc-param-primitives'
+import { isTuiAgent } from '../tui-agent-config'
+import { TERMINAL_PANE_SPLIT_SOURCES } from '../feature-education-telemetry'
+
+export const TerminalHandle = z.object({
+ terminal: requiredString('Missing terminal handle'),
+ // Additive fence understood by newer hosts; legacy hosts safely ignore it.
+ expectedIncarnationId: requiredString('Missing PTY incarnation').optional()
+})
+
+export const TerminalFocus = TerminalHandle.extend({
+ navigation: z.enum(['caller', 'host']).optional()
+})
+
+/**
+ * `terminal.inspectProcess` carries one member the sibling handle methods must not: whether the
+ * caller's answer decides something once, which is what licenses the host to pay for a process-table
+ * read. Extended rather than added to `TerminalHandle` so `clearBuffer`/`agentStatus`/`isRunningAgent`
+ * keep refusing an option they have no use for.
+ */
+export const TerminalInspectProcess = TerminalHandle.extend({
+ // Additive request member understood by newer hosts; legacy hosts safely ignore it.
+ scanChildProcesses: z.boolean().optional()
+})
+
+export const TerminalListParams = z.object({
+ worktree: OptionalString,
+ limit: OptionalFiniteNumber,
+ handles: z
+ .array(requiredString('Missing terminal handle').pipe(z.string().max(256)))
+ .max(64)
+ .optional(),
+ requireFreshPtyLiveness: z.boolean().optional(),
+ // Why: layouts are ~31% of a large listing and only the human CLI formatter
+ // reads them. Absent means "include" so pre-flag clients keep rendering them.
+ includeVisualLayouts: z.boolean().optional()
+})
+
+export const TerminalResolveActive = z.object({
+ worktree: OptionalString,
+ /** Refuse instead of guessing when several leaves could be the caller's own terminal. */
+ requireUnambiguous: z.boolean().optional()
+})
+
+export const TerminalResolvePane = z.object({
+ paneKey: requiredString('Missing pane key'),
+ worktreeId: OptionalString
+})
+
+export const TerminalRecoverPane = z.object({
+ paneKey: requiredString('Missing pane key'),
+ worktreeId: requiredString('Missing worktree ID'),
+ expectedTerminal: requiredString('Missing expected terminal handle').optional()
+})
+
+export const TerminalRead = TerminalHandle.extend({
+ cursor: z
+ .unknown()
+ .transform((value) => {
+ if (value === undefined) {
+ return undefined
+ }
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
+ return Number.NaN
+ }
+ return value
+ })
+ .pipe(
+ z
+ .number()
+ .optional()
+ .refine((v) => v === undefined || Number.isFinite(v), {
+ message: 'Cursor must be a non-negative integer'
+ })
+ )
+ .optional(),
+ limit: OptionalFiniteNumber,
+ // Why: optional so an older host that does not understand it simply drops the key and answers
+ // with its usual stream read; the response's `source` is what tells the caller which it got.
+ screen: z.literal(true).optional()
+}).refine((params) => !(params.screen === true && params.cursor !== undefined), {
+ // Why: a cursor pages through accumulated output; a screen is the current frame with nothing
+ // behind it. Honoring both would answer with rendered lines carrying the stream's pagination
+ // metadata — two frames of reference in one payload, which is the confusion `source` exists to
+ // remove. The CLI already refuses the pair, but the RPC is reachable without it.
+ message: 'Cursor cannot be combined with a screen read'
+})
+
+// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct.
+export const TerminalRename = TerminalHandle.extend({
+ title: z.custom((value) => value === null || typeof value === 'string', {
+ message: 'Missing --title (pass empty string or null to reset)'
+ })
+})
+
+export const TerminalSend = TerminalHandle.extend({
+ text: OptionalString,
+ enter: z.unknown().optional(),
+ interrupt: z.unknown().optional(),
+ // Why: older hosts strip this optional intent and retain their direct-send behavior.
+ agentPrompt: z.literal(true).optional(),
+ // Why: waiting observes the same prompt receipt; it never authorizes a second write.
+ waitSubmitMs: z.number().int().min(0).max(3_600_000).optional(),
+ resolvedLaunchDraft: z
+ .object({
+ text: z.string(),
+ createdAt: z.number().finite()
+ })
+ .optional(),
+ requireAgentStatus: z.enum(['sendable']).optional(),
+ // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor.
+ inputKind: z.enum(['query-reply']).optional(),
+ // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md).
+ client: z
+ .object({
+ id: requiredString('Missing client ID'),
+ type: z.enum(['mobile', 'desktop']).default('desktop').optional()
+ })
+ .optional(),
+ viewport: z
+ .object({
+ cols: z.number().int().min(1).max(1000),
+ rows: z.number().int().min(1).max(500)
+ })
+ .optional(),
+ claimViewport: z.literal(true).optional()
+})
+
+export const TerminalViewport = z.object({
+ cols: z.number().int().min(1).max(1000),
+ rows: z.number().int().min(1).max(500)
+})
+
+export const TerminalWait = TerminalHandle.extend({
+ for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', {
+ message: 'Invalid --for value. Supported: exit, tui-idle'
+ }),
+ timeoutMs: OptionalFiniteNumber
+})
+
+export const TerminalCreateParams = z.object({
+ worktree: OptionalString,
+ clientMutationId: z.string().min(1).max(128).optional(),
+ reconcileExisting: z.boolean().optional(),
+ command: OptionalString,
+ startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
+ env: z.record(z.string(), z.string()).optional(),
+ envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(),
+ launchConfig: z
+ .object({
+ agentCommand: z.string().optional(),
+ agentArgs: z.string(),
+ agentEnv: z.record(z.string(), z.string()),
+ ompResumeFilePath: z
+ .string()
+ .min(1)
+ .max(32 * 1024)
+ .optional()
+ })
+ .optional(),
+ resumeProviderSession: z
+ .object({
+ key: z.enum(['session_id', 'conversation_id']),
+ id: z.string().min(1).max(512),
+ transcriptPath: z.string().min(1).max(32_768).optional()
+ })
+ .optional(),
+ launchToken: OptionalString,
+ launchAgent: z.string().refine(isTuiAgent).optional(),
+ terminalColorQueryReplies: z
+ .object({
+ foreground: z.string().max(128).optional(),
+ background: z.string().max(128).optional()
+ })
+ .optional(),
+ title: OptionalString,
+ focus: z.unknown().optional(),
+ rendererBacked: z.unknown().optional(),
+ activate: z.unknown().optional(),
+ presentation: z.enum(['background', 'focused']).optional(),
+ tabId: OptionalString,
+ leafId: OptionalString
+})
+
+export const TerminalSplit = TerminalHandle.extend({
+ direction: z
+ .unknown()
+ .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
+ .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()]))
+ .optional(),
+ command: OptionalString,
+ env: z.record(z.string(), z.string()).optional(),
+ telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional()
+})
+
+export const TerminalStop = z.object({
+ worktree: requiredString('Missing worktree selector')
+})
+
+export const TerminalCloseAll = TerminalStop
+
+export const TerminalSleep = TerminalStop
+
+export const TerminalStopExact = TerminalStop.extend({
+ expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1),
+ keepHistory: z.boolean().optional(),
+ targetOnly: z.boolean().optional()
+})
+
+export const AgentTeamsTmuxCompat = z.object({
+ teamId: requiredString('Missing agent team ID'),
+ token: requiredString('Missing agent team token'),
+ envPane: requiredString('Missing tmux pane identity'),
+ cwd: OptionalString,
+ argv: z.array(z.string())
+})
+
+export const AgentTeamsPrepareLaunch = z.object({
+ paneKey: requiredString('Missing pane key'),
+ env: z.record(z.string(), z.string()).optional()
+})
diff --git a/src/shared/rpc-contract/terminal-viewport-methods-params.ts b/src/shared/rpc-contract/terminal-viewport-methods-params.ts
new file mode 100644
index 00000000000..f0aba484f63
--- /dev/null
+++ b/src/shared/rpc-contract/terminal-viewport-methods-params.ts
@@ -0,0 +1,3 @@
+import { z } from 'zod'
+
+export const TerminalGetAutoRestoreFitParams = z.object({})
diff --git a/src/shared/rpc-contract/terminal-viewport-schemas-params.ts b/src/shared/rpc-contract/terminal-viewport-schemas-params.ts
new file mode 100644
index 00000000000..aac38a1858e
--- /dev/null
+++ b/src/shared/rpc-contract/terminal-viewport-schemas-params.ts
@@ -0,0 +1,51 @@
+import { z } from 'zod'
+import { requiredString } from './rpc-param-primitives'
+
+export const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') })
+
+export const TerminalSetDisplayMode = TerminalHandle.extend({
+ // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit.
+ mode: z.enum(['auto', 'desktop']),
+ // Why: identifies the caller for the driver state machine; optional for older mobile clients.
+ client: z
+ .object({
+ id: requiredString('Missing client ID'),
+ type: z.enum(['mobile', 'desktop']).default('desktop').optional()
+ })
+ .optional(),
+ // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing.
+ viewport: z
+ .object({
+ cols: z.number().int().positive(),
+ rows: z.number().int().positive()
+ })
+ .optional()
+})
+
+export const TerminalUnsubscribe = z.object({
+ subscriptionId: requiredString('Missing subscription ID'),
+ // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md).
+ client: z
+ .object({
+ id: requiredString('Missing client ID')
+ })
+ .optional()
+})
+
+// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md).
+export const TerminalUpdateViewport = TerminalHandle.extend({
+ client: z.object({
+ id: requiredString('Missing client ID'),
+ type: z.enum(['mobile', 'desktop']).default('mobile').optional()
+ }),
+ viewport: z.object({
+ cols: z.number().int().min(20).max(240),
+ rows: z.number().int().min(8).max(120)
+ }),
+ claim: z.boolean().optional()
+})
+
+// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side.
+export const TerminalSetAutoRestoreFit = z.object({
+ ms: z.number().nullable()
+})
diff --git a/src/shared/rpc-contract/ui-update-value-tolerance-params.ts b/src/shared/rpc-contract/ui-update-value-tolerance-params.ts
new file mode 100644
index 00000000000..1b8ca0c2cd4
--- /dev/null
+++ b/src/shared/rpc-contract/ui-update-value-tolerance-params.ts
@@ -0,0 +1,25 @@
+import type { z } from 'zod'
+
+/**
+ * `UiUpdate` rides App.tsx's debounced writer, so one drifted enum member used
+ * to fail the WHOLE batch and silently drop sidebar widths, filters and agent
+ * acks alongside it. Degrade instead: a value the schema cannot express is
+ * dropped from the payload and the rest of the batch still lands. Unknown KEYS
+ * stay a hard rejection — the parity assertions exist to catch those.
+ */
+export function tolerateUnknownValues(shape: TShape): TShape {
+ return Object.fromEntries(
+ Object.entries(shape).map(([key, schema]) => [
+ key,
+ (schema as z.ZodType).catch(() => undefined)
+ ])
+ ) as unknown as TShape
+}
+
+/** Drops the `undefined` entries `tolerateUnknownValues` leaves behind, so a
+ * rejected value reads as absent rather than as an explicit clear. */
+export function omitUndefinedValues>(value: TValue): TValue {
+ return Object.fromEntries(
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
+ ) as TValue
+}
diff --git a/src/shared/rpc-contract/updater-params.ts b/src/shared/rpc-contract/updater-params.ts
new file mode 100644
index 00000000000..8020f126712
--- /dev/null
+++ b/src/shared/rpc-contract/updater-params.ts
@@ -0,0 +1,6 @@
+import { z } from 'zod'
+
+export const UpdaterCheckParams = z.object({
+ includePrerelease: z.boolean().optional(),
+ includePerfPrerelease: z.boolean().optional()
+})
diff --git a/src/shared/rpc-contract/workspace-cleanup-ui-params.ts b/src/shared/rpc-contract/workspace-cleanup-ui-params.ts
new file mode 100644
index 00000000000..ebe35ff4826
--- /dev/null
+++ b/src/shared/rpc-contract/workspace-cleanup-ui-params.ts
@@ -0,0 +1,28 @@
+import { z } from 'zod'
+import { normalizeWorkspaceCleanupBrowseState } from '../workspace-cleanup-browse-state'
+import type { WorkspaceCleanupBrowseState } from '../workspace-cleanup-browse-state'
+
+export const WorkspaceCleanupDismissal = z.object({
+ worktreeId: z.string(),
+ dismissedAt: z.number().finite(),
+ fingerprint: z.string(),
+ classifierVersion: z.number().finite(),
+ executionHostId: z.string().min(1).optional()
+})
+
+/**
+ * Deliberately unvalidated shape, then normalized: the filter groups must NOT be
+ * strict or enumerated here. A newer client sends filters this build has never
+ * heard of, and a per-field zod shape would reject the whole `ui.set` payload
+ * instead of persisting the parts the host does understand. The shared
+ * normalizer never throws and degrades field by field, so an older host narrows
+ * the state rather than refusing it.
+ */
+export const WorkspaceCleanupBrowse = z
+ .custom()
+ .transform((value) => normalizeWorkspaceCleanupBrowseState(value))
+
+export const WorkspaceCleanup = z.object({
+ dismissals: z.record(z.string(), WorkspaceCleanupDismissal),
+ browse: WorkspaceCleanupBrowse.optional()
+})
diff --git a/src/shared/rpc-contract/workspace-ports-params.ts b/src/shared/rpc-contract/workspace-ports-params.ts
new file mode 100644
index 00000000000..300f0e84fb1
--- /dev/null
+++ b/src/shared/rpc-contract/workspace-ports-params.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod'
+import { OptionalString, requiredNumber } from './rpc-param-primitives'
+
+export const WorkspacePortScanParams = z.object({
+ repoId: OptionalString
+})
+
+export const WorkspacePortKillParams = z.object({
+ repoId: OptionalString,
+ pid: requiredNumber('Missing process id'),
+ port: requiredNumber('Missing port')
+})
diff --git a/src/shared/rpc-contract/worktree-create-params.ts b/src/shared/rpc-contract/worktree-create-params.ts
new file mode 100644
index 00000000000..7c0b1f55fad
--- /dev/null
+++ b/src/shared/rpc-contract/worktree-create-params.ts
@@ -0,0 +1,154 @@
+import { z } from 'zod'
+import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema'
+import { TaskSourceContextSchema } from '../task-source-context-schema'
+import { workspaceSourceSchema } from '../telemetry-events'
+import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation'
+import { sleepingAgentLaunchConfigSchema } from '../workspace-session-sleeping-agents'
+import { isTuiAgent } from '../tui-agent-config'
+import {
+ OptionalBoolean,
+ OptionalFiniteNumber,
+ OptionalString,
+ TriStateLinkedIssue
+} from './rpc-param-primitives'
+import {
+ AutomationWorkspaceProvenanceRequest,
+ CliWorkspaceProvenanceRequest,
+ OptionalTuiAgent,
+ assertLinkedWorkItemSourceContextMatch
+} from './worktree-params'
+
+export const WorktreeCreate = z
+ .object({
+ repo: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing repo selector')),
+ name: OptionalString,
+ /** Set by clients that fell back to a generated creature name. Absent means user-typed, so the
+ * host neither skips a retired candidate nor retires the name it lands on. */
+ nameWasGenerated: z.boolean().optional(),
+ baseBranch: OptionalString,
+ compareBaseRef: OptionalString,
+ branchNameOverride: OptionalString,
+ linkedIssue: TriStateLinkedIssue,
+ linkedPR: TriStateLinkedIssue,
+ linkedLinearIssue: z.string().optional(),
+ linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(),
+ linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(),
+ linkedGitLabMR: TriStateLinkedIssue,
+ linkedGitLabIssue: TriStateLinkedIssue,
+ linkedBitbucketPR: TriStateLinkedIssue,
+ linkedAzureDevOpsPR: TriStateLinkedIssue,
+ linkedGiteaPR: TriStateLinkedIssue,
+ linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(),
+ linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
+ comment: OptionalString,
+ displayName: OptionalString,
+ displayNameKind: z.enum(['generated', 'user']).optional(),
+ telemetrySource: z
+ .unknown()
+ .transform((value) => {
+ const parsed = workspaceSourceSchema.safeParse(value)
+ return parsed.success ? parsed.data : undefined
+ })
+ .optional(),
+ workspaceStatus: OptionalString,
+ manualOrder: OptionalFiniteNumber,
+ sparseCheckout: z
+ .object({
+ directories: z.array(z.string()),
+ presetId: OptionalString
+ })
+ .optional(),
+ pushTarget: z
+ .object({
+ remoteName: z.string(),
+ branchName: z.string(),
+ remoteUrl: OptionalString
+ })
+ .optional(),
+ runHooks: OptionalBoolean,
+ activate: OptionalBoolean,
+ // Why: activation on create is view intent, so it is addressed like worktree.activate.
+ // Contract: a paired desktop/web caller resolves to 'caller' and therefore receives NO
+ // activateWorktree event — it must reveal from this call's result, which carries setup,
+ // startup and defaultTabs. Pass an explicit target to opt into an all-surface reveal.
+ navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional(),
+ parentWorkspace: OptionalString,
+ // Why: an app-selected parent is a manual action, not the CLI's `--parent-workspace` flag.
+ // Absent keeps the CLI provenance older clients rely on.
+ parentWorkspaceOrigin: z.literal('manual').optional(),
+ envParentWorkspace: OptionalString,
+ parentWorktree: OptionalString,
+ cwdParentWorktree: OptionalString,
+ noParent: OptionalBoolean,
+ callerTerminalHandle: OptionalString,
+ orchestrationContext: z
+ .object({
+ parentWorktreeId: OptionalString,
+ orchestrationRunId: OptionalString,
+ taskId: OptionalString,
+ coordinatorHandle: OptionalString
+ })
+ .optional(),
+ setupDecision: z
+ .unknown()
+ .transform((v) =>
+ typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
+ )
+ .pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()]))
+ .optional(),
+ // Why: some clients (e.g. desktop) pass a pre-built launch command so the
+ // first terminal pane launches the selected agent instead of an idle shell.
+ // Clients that can't quote for the host shell send `startupAgent` instead.
+ startupCommand: OptionalString,
+ startupEnv: z.record(z.string(), z.string()).optional(),
+ startupLaunchConfig: sleepingAgentLaunchConfigSchema,
+ startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
+ // Why: CLI clients should not hardcode agent launch quoting because SSH
+ // workspaces execute in a different shell than the client process.
+ startupAgent: OptionalTuiAgent,
+ startupPrompt: OptionalString,
+ // Why: task-driven mobile creates need desktop parity: the host chooses
+ // the same default/detected agent and drafts the linked issue/PR URL into it.
+ startupDraft: OptionalString,
+ createdWithAgent: z
+ .unknown()
+ .transform((value) => (isTuiAgent(value) ? value : undefined))
+ .optional(),
+ // Why: mobile retries a create interrupted by a connection migration with the
+ // same key so the host dedupes instead of spawning a duplicate worktree.
+ clientMutationId: z.string().min(1).max(128).optional(),
+ automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional(),
+ cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional()
+ })
+ .superRefine((params, ctx) => {
+ assertLinkedWorkItemSourceContextMatch(params, ctx)
+ if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Choose either one parent selector or --no-parent.'
+ })
+ }
+ if (params.parentWorkspace && params.parentWorktree) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Choose either one parent selector or --no-parent.'
+ })
+ }
+ if (params.startupPrompt !== undefined && params.startupAgent === undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'startupPrompt requires startupAgent'
+ })
+ }
+ })
+
+export const WorktreePrefetchCreateBase = z.object({
+ repo: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing repo selector')),
+ baseBranch: OptionalString
+})
diff --git a/src/shared/rpc-contract/worktree-params.ts b/src/shared/rpc-contract/worktree-params.ts
new file mode 100644
index 00000000000..00ed7627f9d
--- /dev/null
+++ b/src/shared/rpc-contract/worktree-params.ts
@@ -0,0 +1,215 @@
+import { z } from 'zod'
+import { normalizeExecutionHostId } from '../execution-host'
+import { isTuiAgent } from '../tui-agent-config'
+import type { TuiAgent } from '../tui-agent'
+import {
+ OptionalBoolean,
+ OptionalFiniteNumber,
+ OptionalPlainString,
+ OptionalString,
+ TriStateLinkedIssue
+} from './rpc-param-primitives'
+import { RUNTIME_NAVIGATION_TARGETS } from '../runtime-navigation'
+import { WorkspaceLinkedItemSchema } from '../workspace-linked-item-schema'
+import { TaskSourceContextSchema } from '../task-source-context-schema'
+import { isWorkspaceLinkedItemSourceContextMatch } from '../workspace-linked-item-source-context'
+
+export const OptionalExecutionHostId = z
+ .string()
+ .transform((value, ctx) => {
+ const hostId = normalizeExecutionHostId(value)
+ if (!hostId) {
+ ctx.addIssue({ code: 'custom', message: 'Invalid host id' })
+ return z.NEVER
+ }
+ return hostId
+ })
+ .optional()
+
+export const OptionalTuiAgent = z
+ .unknown()
+ .superRefine((value, ctx) => {
+ if (value !== undefined && !isTuiAgent(value)) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' })
+ }
+ })
+ .transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined))
+ .optional()
+
+export const AutomationWorkspaceProvenanceRequest = z.object({
+ automationId: z.string(),
+ automationRunId: z.string(),
+ dispatchToken: z.string(),
+ createRequestId: z.string()
+})
+
+// Why no dispatch token (unlike automation provenance): this is a descriptive
+// origin marker for sidebar filtering, not an authority grant. The host stamps
+// createdAt itself so a client clock can't skew sort order.
+export const CliWorkspaceProvenanceRequest = z.object({
+ callerTerminalHandle: OptionalString
+})
+
+export const WorktreeListParams = z.object({
+ repo: OptionalString,
+ limit: OptionalFiniteNumber
+})
+
+export const WorktreeDetectedListParams = z.object({
+ repo: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing repo selector'))
+})
+
+export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({
+ worktreeIds: z.array(z.string().min(1)).max(10_000),
+ connectionId: z.string().nullable().optional()
+})
+
+export const WorktreePsParams = z.object({
+ limit: OptionalFiniteNumber,
+ afterSnapshotId: z.string().min(1).max(128).nullable().optional(),
+ supportsWorktreeVisibilitySourceDefaults: z.literal(true).optional()
+})
+
+export const WorktreeSortOrder = z.object({
+ orderedIds: z.array(z.string())
+})
+
+export const WorktreeSelector = z.object({
+ worktree: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing worktree selector'))
+})
+
+export const WorktreeActivate = WorktreeSelector.extend({
+ notifyClients: OptionalBoolean,
+ navigation: z.enum(RUNTIME_NAVIGATION_TARGETS).optional()
+})
+
+/** Shared by WorktreeCreate and WorktreeSet so the two error messages cannot drift. */
+export function assertLinkedWorkItemSourceContextMatch(
+ params: {
+ linkedWorkItem?: z.infer | null
+ linkedTaskSourceContext?: z.infer | null
+ },
+ ctx: z.RefinementCtx
+): void {
+ if (
+ params.linkedWorkItem &&
+ params.linkedTaskSourceContext &&
+ !isWorkspaceLinkedItemSourceContextMatch(params.linkedWorkItem, params.linkedTaskSourceContext)
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Linked work item and source context identities must match'
+ })
+ }
+}
+
+export const WorktreeSet = WorktreeSelector.extend({
+ // Why: '' is the blanking contract — "fall back to the branch/folder name".
+ // OptionalString coerced it to undefined, so on remote/SSH hosts clearing the
+ // name was dropped here and the old name came back on the next refresh.
+ displayName: OptionalPlainString,
+ // Why: empty comments are meaningful metadata updates, so use the plain
+ // string parser instead of OptionalString's empty-as-undefined behavior.
+ comment: OptionalPlainString,
+ linkedIssue: TriStateLinkedIssue,
+ linkedPR: TriStateLinkedIssue,
+ suppressedGitHubPR: z.number().int().positive().nullable().optional(),
+ linkedLinearIssue: z.union([z.string(), z.null()]).optional(),
+ linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(),
+ linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(),
+ linkedGitLabMR: TriStateLinkedIssue,
+ linkedGitLabIssue: TriStateLinkedIssue,
+ linkedBitbucketPR: TriStateLinkedIssue,
+ linkedAzureDevOpsPR: TriStateLinkedIssue,
+ linkedGiteaPR: TriStateLinkedIssue,
+ linkedWorkItem: WorkspaceLinkedItemSchema.nullable().optional(),
+ linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
+ isArchived: OptionalBoolean,
+ isUnread: OptionalBoolean,
+ isPinned: OptionalBoolean,
+ sortOrder: OptionalFiniteNumber,
+ manualOrder: OptionalFiniteNumber,
+ lastActivityAt: OptionalFiniteNumber,
+ createdAt: OptionalFiniteNumber,
+ sparseDirectories: z.array(z.string()).optional(),
+ sparseBaseRef: OptionalString,
+ sparsePresetId: OptionalString,
+ baseRef: OptionalString,
+ workspaceStatus: OptionalString,
+ pushTarget: z
+ .object({
+ remoteName: z.string(),
+ branchName: z.string(),
+ remoteUrl: OptionalString
+ })
+ .nullable()
+ .optional(),
+ diffComments: z.array(z.unknown()).optional(),
+ mobileDiffReview: z.unknown().optional(),
+ parentWorktree: OptionalString,
+ noParent: OptionalBoolean
+}).superRefine((params, ctx) => {
+ assertLinkedWorkItemSourceContextMatch(params, ctx)
+ if (params.parentWorktree && params.noParent === true) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Choose either --parent-worktree or --no-parent, not both.'
+ })
+ }
+})
+
+export const WorktreeRemove = WorktreeSelector.extend({
+ hostId: OptionalExecutionHostId,
+ force: OptionalBoolean,
+ // Why (#11960): the CLI's --force is an unambiguous force affordance, but the
+ // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop
+ // waiver travels on its own field.
+ allowUnverifiedPtyStop: OptionalBoolean,
+ runHooks: OptionalBoolean
+})
+
+export const WorktreeForceDeleteBranch = WorktreeSelector.extend({
+ hostId: OptionalExecutionHostId,
+ branchName: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing branch name')),
+ expectedHead: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing expected branch head'))
+})
+
+export const WorktreeResolvePrBase = z.object({
+ repo: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing repo selector')),
+ prNumber: z
+ .unknown()
+ .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
+ .pipe(z.number().int().positive('Missing PR number')),
+ headRefName: OptionalString,
+ baseRefName: OptionalString,
+ isCrossRepository: OptionalBoolean
+})
+
+export const WorktreeResolveMrBase = z.object({
+ repo: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing repo selector')),
+ mrIid: z
+ .unknown()
+ .transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
+ .pipe(z.number().int().positive('Missing MR number')),
+ sourceBranch: OptionalString,
+ targetBranch: OptionalString,
+ isCrossRepository: OptionalBoolean
+})
diff --git a/src/shared/rpc-contract/worktree-visibility-defaults-params.ts b/src/shared/rpc-contract/worktree-visibility-defaults-params.ts
new file mode 100644
index 00000000000..c03199d78db
--- /dev/null
+++ b/src/shared/rpc-contract/worktree-visibility-defaults-params.ts
@@ -0,0 +1,19 @@
+import { z } from 'zod'
+import {
+ normalizeCustomWorktreeVisibilitySources,
+ normalizeWorktreeVisibilitySourcePreferences
+} from '../worktree/visibility-sources'
+
+export const WorktreeVisibilityDefaultsUpdate = z
+ .object({
+ external: z.enum(['hide', 'show']).optional(),
+ customSources: z
+ .unknown()
+ .transform((value) => normalizeCustomWorktreeVisibilitySources(value))
+ .optional(),
+ sourcePreferences: z
+ .unknown()
+ .transform((value) => normalizeWorktreeVisibilitySourcePreferences(value))
+ .optional()
+ })
+ .strict()
From 9b83f976f95b00c907de61026a0544d14dcd4254 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 10 Sep 2026 21:24:22 -0700
Subject: [PATCH 06/17] feat(native-chat): describe slash commands from the
provider's own report (#19928)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(native-chat): describe slash commands from the provider's own report
The Claude session reports a description and argument hint for every
command it can run, but the catalog kept only the name, so the `/` picker
described the handful of commands our curated map covers and left the rest
— `/goal` included — with a blank row.
Carry `description`/`argumentHint` through the catalog and the session wire
(both optional, so mixed-version hosts are unaffected), and let a reported
description win over the curated one, which stays as the fallback for the
name-only report shape. The curated maps are untouched, so structured
dispatch still claims exactly the commands it claimed before.
* feat(native-chat): show the reported argument hint in the slash picker
`argumentHint` was carried to the renderer but nothing read it. Show it
beside the command token — `/goal ` over the description — so a
row says how the command is invoked, not just what it does.
It sits at the row's existing 11px muted tier, subordinate to the
description, and truncates in a min-width-0 flex row; the picker also caps
the hint at 80 characters, so a provider cannot swamp the row.
* fix(native-chat): normalize slash command descriptors consistently
---------
Co-authored-by: Merge Sim
---
.../claude-slash-command-catalog.test.ts | 147 +++++++++++++++++-
.../claude/claude-slash-command-catalog.ts | 111 ++++++++++---
...claude-structured-session-commands.test.ts | 13 +-
.../NativeChatAutocompleteMenus.test.tsx | 41 +++++
.../NativeChatAutocompleteMenus.tsx | 9 +-
.../native-chat/native-chat-picker-items.ts | 5 +
src/shared/agent-session-wire.ts | 4 +
src/shared/native-chat-slash-commands.test.ts | 35 +++++
src/shared/native-chat-slash-commands.ts | 12 +-
9 files changed, 346 insertions(+), 31 deletions(-)
diff --git a/src/main/claude/claude-slash-command-catalog.test.ts b/src/main/claude/claude-slash-command-catalog.test.ts
index 20d79f9f53d..f4374e52e4f 100644
--- a/src/main/claude/claude-slash-command-catalog.test.ts
+++ b/src/main/claude/claude-slash-command-catalog.test.ts
@@ -86,8 +86,8 @@ it('accepts descriptor reloads, removing old skills while retaining terminal fil
}
expect(catalog.observe(reload)).toBe(true)
expect(catalog.commands).toEqual([
- { name: 'clear', kind: 'command' },
- { name: 'new-skill', kind: 'skill' }
+ { name: 'clear', kind: 'command', description: 'Clear' },
+ { name: 'new-skill', kind: 'skill', description: 'New' }
])
expect(catalog.observe(reload)).toBe(false)
expect(catalog.observe({ ...reload, commands: [] })).toBe(true)
@@ -130,3 +130,146 @@ it('publishes classification becoming authoritative even when the name and kind
expect(catalog.observe(init({ slash_commands: ['clear'], skills: [] }))).toBe(true)
expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }])
})
+
+it('keeps the description and argument hint a descriptor report authored', () => {
+ const catalog = new ClaudeSlashCommandCatalog(undefined, {
+ commands: [
+ { name: 'goal', description: 'Set or view the goal', argumentHint: '' },
+ { name: 'quiet', description: '', argumentHint: '' }
+ ]
+ })
+ expect(catalog.commands).toEqual([
+ {
+ name: 'goal',
+ kind: 'command',
+ kindUnspecified: true,
+ description: 'Set or view the goal',
+ argumentHint: ''
+ },
+ { name: 'quiet', kind: 'command', kindUnspecified: true }
+ ])
+})
+
+it('bounds the row text a provider can put in the picker', () => {
+ const catalog = new ClaudeSlashCommandCatalog(undefined, {
+ commands: [
+ { name: 'long', description: 'x'.repeat(201), argumentHint: 'y'.repeat(101) },
+ { name: 'wrong-type', description: 42, argumentHint: { text: 'no' } },
+ { name: 'blank', description: ' ' },
+ { name: 'long-whitespace', description: `Visible${' '.repeat(201)}` },
+ { name: 'wrapped', description: 'first line\n second line' }
+ ]
+ })
+ expect(catalog.commands).toEqual([
+ { name: 'long', kind: 'command', kindUnspecified: true },
+ { name: 'wrong-type', kind: 'command', kindUnspecified: true },
+ { name: 'blank', kind: 'command', kindUnspecified: true },
+ { name: 'long-whitespace', kind: 'command', kindUnspecified: true },
+ {
+ name: 'wrapped',
+ kind: 'command',
+ kindUnspecified: true,
+ description: 'first line second line'
+ }
+ ])
+})
+
+it('does not let malformed descriptor names consume the command detail budget', () => {
+ const catalog = new ClaudeSlashCommandCatalog(undefined, {
+ commands: [
+ ...Array.from({ length: 512 }, (_, index) => ({
+ name: `invalid name ${index}`,
+ description: 'Rejected with its name'
+ })),
+ { name: 'goal', description: 'Set or view the goal', argumentHint: '' }
+ ]
+ })
+ expect(catalog.commands).toEqual([
+ {
+ name: 'goal',
+ kind: 'command',
+ kindUnspecified: true,
+ description: 'Set or view the goal',
+ argumentHint: ''
+ }
+ ])
+})
+
+it('combines non-empty fields from duplicate descriptors without discarding earlier text', () => {
+ const catalog = new ClaudeSlashCommandCatalog(undefined, {
+ commands: [
+ { name: 'goal', description: 'Set or view the goal' },
+ { name: 'goal', argumentHint: '' }
+ ]
+ })
+ expect(catalog.commands).toEqual([
+ {
+ name: 'goal',
+ kind: 'command',
+ kindUnspecified: true,
+ description: 'Set or view the goal',
+ argumentHint: ''
+ }
+ ])
+})
+
+it('carries descriptor text across the name-only stream init that classifies it', () => {
+ const catalog = new ClaudeSlashCommandCatalog(undefined, {
+ commands: [
+ { name: 'clear', description: 'Clear conversation' },
+ { name: 'ref-oss', description: 'A skill' }
+ ]
+ })
+ expect(catalog.observe(init({ slash_commands: ['clear', 'ref-oss'], skills: ['ref-oss'] }))).toBe(
+ true
+ )
+ expect(catalog.commands).toEqual([
+ { name: 'clear', kind: 'command', description: 'Clear conversation' },
+ { name: 'ref-oss', kind: 'skill', description: 'A skill' }
+ ])
+})
+
+it('reports a description-only change and lets a later report drop the text', () => {
+ const catalog = new ClaudeSlashCommandCatalog(init({ slash_commands: ['clear'], skills: [] }))
+ expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }])
+ const changed = {
+ type: 'system',
+ subtype: 'commands_changed',
+ commands: [{ name: 'clear', description: 'Clear conversation history' }]
+ }
+ expect(catalog.observe(changed)).toBe(true)
+ expect(catalog.commands).toEqual([
+ { name: 'clear', kind: 'command', description: 'Clear conversation history' }
+ ])
+ expect(catalog.observe(changed)).toBe(false)
+ expect(catalog.observe({ ...changed, commands: [{ name: 'clear' }] })).toBe(true)
+ expect(catalog.commands).toEqual([{ name: 'clear', kind: 'command' }])
+})
+
+it('describes nothing when the session reported names only', () => {
+ expect(readClaudeSlashCommands(init())).toEqual([
+ { name: 'clear', kind: 'command' },
+ { name: 'ref-oss', kind: 'skill' },
+ { name: 'opsx:apply', kind: 'command' }
+ ])
+ expect(new ClaudeSlashCommandCatalog(init()).commands).toEqual([
+ { name: 'clear', kind: 'command' },
+ { name: 'ref-oss', kind: 'skill' },
+ { name: 'opsx:apply', kind: 'command' }
+ ])
+})
+
+it('still hides terminal-only names however well the provider describes them', () => {
+ const catalog = new ClaudeSlashCommandCatalog(init())
+ expect(
+ catalog.observe({
+ type: 'system',
+ subtype: 'commands_changed',
+ commands: [
+ { name: 'doctor', description: 'Diagnose the CLI install' },
+ { name: 'ref-oss', description: 'A skill' }
+ ]
+ })
+ ).toBe(true)
+ expect(catalog.commands).toEqual([{ name: 'ref-oss', kind: 'skill', description: 'A skill' }])
+})
diff --git a/src/main/claude/claude-slash-command-catalog.ts b/src/main/claude/claude-slash-command-catalog.ts
index b1f65d93d50..2a4d91260f0 100644
--- a/src/main/claude/claude-slash-command-catalog.ts
+++ b/src/main/claude/claude-slash-command-catalog.ts
@@ -3,6 +3,16 @@ import type { AgentSessionSlashCommand } from '../../shared/agent-session-wire'
// Stream init carries name arrays; control initialization and reloads carry descriptors.
const MAX_COMMANDS = 512
const MAX_NAME_LENGTH = 200
+const MAX_DESCRIPTION_LENGTH = 200
+const MAX_ARGUMENT_HINT_LENGTH = 100
+
+/** The provider's own row text for one command, absent when it reported none. */
+type CommandDetail = Pick
+
+function commandName(value: unknown): string | undefined {
+ const name = typeof value === 'string' ? value.trim() : ''
+ return name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name) ? name : undefined
+}
function names(value: unknown): string[] {
if (!Array.isArray(value)) {
@@ -13,20 +23,61 @@ function names(value: unknown): string[] {
if (seen.size >= MAX_COMMANDS) {
break
}
- const name = typeof entry === 'string' ? entry.trim() : ''
- if (name.length > 0 && name.length <= MAX_NAME_LENGTH && !/\s/u.test(name)) {
+ const name = commandName(entry)
+ if (name !== undefined) {
seen.add(name)
}
}
return [...seen]
}
-function descriptorNames(value: unknown): string[] {
- return names(
- Array.isArray(value)
- ? value.map((entry) => (entry !== null && typeof entry === 'object' ? entry.name : undefined))
- : []
- )
+/** A single picker row's worth of provider text: unusable values are dropped, not truncated. */
+function rowText(value: unknown, maxLength: number): string | undefined {
+ if (typeof value !== 'string' || value.length > maxLength) {
+ return undefined
+ }
+ const collapsed = value.replace(/\s+/gu, ' ').trim()
+ return collapsed.length > 0 && collapsed.length <= maxLength ? collapsed : undefined
+}
+
+function descriptorCatalog(value: unknown): {
+ names: string[]
+ details: Map
+} {
+ const names: string[] = []
+ const seen = new Set()
+ const details = new Map()
+ if (!Array.isArray(value)) {
+ return { names, details }
+ }
+ for (const entry of value) {
+ if (seen.size >= MAX_COMMANDS) {
+ break
+ }
+ if (entry === null || typeof entry !== 'object') {
+ continue
+ }
+ const name = commandName(entry.name)
+ if (name === undefined) {
+ continue
+ }
+ if (!seen.has(name)) {
+ seen.add(name)
+ names.push(name)
+ }
+ const previous = details.get(name)
+ const description = previous?.description ?? rowText(entry.description, MAX_DESCRIPTION_LENGTH)
+ const argumentHint =
+ previous?.argumentHint ?? rowText(entry.argumentHint, MAX_ARGUMENT_HINT_LENGTH)
+ if (description === undefined && argumentHint === undefined) {
+ continue
+ }
+ details.set(name, {
+ ...(description === undefined ? {} : { description }),
+ ...(argumentHint === undefined ? {} : { argumentHint })
+ })
+ }
+ return { names, details }
}
function carriesCommandCatalog(message: Record): boolean {
@@ -56,6 +107,7 @@ export class ClaudeSlashCommandCatalog {
private hasSkillClassification = false
private hidden = new Set()
private commandNames = new Set()
+ private details = new Map()
constructor(initMessage?: Record, initialization?: unknown) {
// SessionStart can prove acquisition before the first stream init exists.
@@ -65,11 +117,15 @@ export class ClaudeSlashCommandCatalog {
'commands' in initialization &&
Array.isArray(initialization.commands)
) {
- this.entries = descriptorNames(initialization.commands).map((name) => ({
- name,
- kind: 'command',
- kindUnspecified: true
- }))
+ const catalog = descriptorCatalog(initialization.commands)
+ this.details = catalog.details
+ this.entries = this.describe(
+ catalog.names.map((name) => ({
+ name,
+ kind: 'command',
+ kindUnspecified: true
+ }))
+ )
}
if (initMessage) {
this.observe(initMessage)
@@ -80,13 +136,18 @@ export class ClaudeSlashCommandCatalog {
return this.entries
}
+ /** Provider row text, carried across the name-only frames that never restate it. */
+ private describe(entries: AgentSessionSlashCommand[]): AgentSessionSlashCommand[] {
+ return entries.map((entry) => ({ ...entry, ...this.details.get(entry.name) }))
+ }
+
/** True when this frame replaced the catalog with a different one. */
observe(message: Record): boolean {
let next: AgentSessionSlashCommand[]
if (carriesCommandCatalog(message)) {
this.hasSkillClassification = true
this.hidden = new Set(names(message.terminal_slash_commands))
- next = readClaudeSlashCommands(message)
+ next = this.describe(readClaudeSlashCommands(message))
this.commandNames = new Set(
next.filter((entry) => entry.kind === 'command').map((entry) => entry.name)
)
@@ -95,13 +156,17 @@ export class ClaudeSlashCommandCatalog {
message.subtype === 'commands_changed' &&
Array.isArray(message.commands)
) {
- next = descriptorNames(message.commands)
- .filter((name) => !this.hidden.has(name))
- .map((name) =>
- this.hasSkillClassification
- ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' }
- : { name, kind: 'command', kindUnspecified: true }
- )
+ const catalog = descriptorCatalog(message.commands)
+ this.details = catalog.details
+ next = this.describe(
+ catalog.names
+ .filter((name) => !this.hidden.has(name))
+ .map((name) =>
+ this.hasSkillClassification
+ ? { name, kind: this.commandNames.has(name) ? 'command' : 'skill' }
+ : { name, kind: 'command', kindUnspecified: true }
+ )
+ )
} else {
return false
}
@@ -112,7 +177,9 @@ export class ClaudeSlashCommandCatalog {
(entry, index) =>
entry.name === this.entries?.[index]?.name &&
entry.kind === this.entries?.[index]?.kind &&
- entry.kindUnspecified === this.entries?.[index]?.kindUnspecified
+ entry.kindUnspecified === this.entries?.[index]?.kindUnspecified &&
+ entry.description === this.entries?.[index]?.description &&
+ entry.argumentHint === this.entries?.[index]?.argumentHint
)
) {
return false
diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts
index 1b2fb6bde31..29f2bcf4aa7 100644
--- a/src/main/claude/claude-structured-session-commands.test.ts
+++ b/src/main/claude/claude-structured-session-commands.test.ts
@@ -55,8 +55,14 @@ it.each([
const adapter = adapterFor(claude)
try {
await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' })
+ const described = commands[0]?.description
expect(adapter.readCommands('session-1')).toEqual(
- commands.map(({ name }) => ({ name, kind: 'command', kindUnspecified: true }))
+ commands.map(({ name, description }) => ({
+ name,
+ kind: 'command',
+ kindUnspecified: true,
+ ...(description ? { description } : {})
+ }))
)
expect(claude.connections[0].sent).toEqual([])
expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([
@@ -70,7 +76,10 @@ it.each([
slash_commands: ['project:check'],
skills: ['project:check']
})
- expect(adapter.readCommands('session-1')).toEqual([{ name: 'project:check', kind: 'skill' }])
+ // The stream init classifies the name; the control seed's text survives it.
+ expect(adapter.readCommands('session-1')).toEqual([
+ { name: 'project:check', kind: 'skill', ...(described ? { description: described } : {}) }
+ ])
} finally {
await adapter.closeSession('session-1')
}
diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx
index 88506c51838..60c70baee66 100644
--- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx
@@ -3,6 +3,8 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatPickerMenu } from './NativeChatAutocompleteMenus'
+import { buildNativeChatPickerItems } from './native-chat-picker-items'
+import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat-slash-commands'
import type { ComposerAutocomplete } from './native-chat-composer-state'
function autocomplete(
@@ -128,6 +130,45 @@ describe('NativeChatPickerMenu', () => {
expect(screen.getAllByText('No matching commands')).toHaveLength(2)
})
+ it('shows the argument hint the provider reported beside the command token', () => {
+ render(
+ '
+ },
+ { name: 'clear', kind: 'command' },
+ { name: 'wordy', kind: 'command', argumentHint: `<${'a'.repeat(200)}>` }
+ ]),
+ [],
+ '',
+ '/'
+ )
+ })}
+ activeIndex={0}
+ listboxId="picker"
+ onChoose={vi.fn()}
+ onRetry={vi.fn()}
+ />
+ )
+ const goal = screen.getByRole('option', { name: /goal/i })
+ expect(goal.textContent).toContain('')
+ expect(goal.textContent).toContain('Set a goal and keep working until it is met')
+ // A command the report left hintless renders its row unchanged.
+ expect(screen.getByRole('option', { name: /clear/i }).textContent).toBe(
+ '/clearClear conversation history'
+ )
+ // A hint long enough to swamp the row is capped before it reaches the DOM.
+ expect(screen.getByRole('option', { name: /wordy/i }).textContent).toBe(
+ `/wordy<${'a'.repeat(79)}`
+ )
+ })
+
it('announces a successful empty skill result distinctly from loading', () => {
render(
) : null}
- {item.token}
+
+ {item.token}
+ {item.kind === 'command' && item.argumentHint ? (
+
+ {item.argumentHint}
+
+ ) : null}
+
{item.description ? (
{item.description}
) : null}
diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts
index 1a21a2461c6..4786fcbecb2 100644
--- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts
+++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts
@@ -21,6 +21,8 @@ export type NativeChatPickerItem =
/** Exactly what a pick inserts — the form the agent invokes. */
token: string
description?: string
+ /** How the provider says the command is invoked, e.g. ``. */
+ argumentHint?: string
skillCollision: boolean
}
| {
@@ -80,6 +82,9 @@ export function buildNativeChatPickerItems(
name: command.name,
token: `/${command.name}`,
description: command.description ? sanitizePickerText(command.description, 240) : undefined,
+ argumentHint: command.argumentHint
+ ? sanitizePickerText(command.argumentHint, 80)
+ : undefined,
skillCollision: sharedSigil && skillNames.has(command.name)
},
stableOrder: index
diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts
index 546f995456c..b4ef57a6b91 100644
--- a/src/shared/agent-session-wire.ts
+++ b/src/shared/agent-session-wire.ts
@@ -311,6 +311,10 @@ export type AgentSessionSlashCommand = {
kind: 'command' | 'skill'
/** Membership is authoritative, but this provider report did not classify the name. */
kindUnspecified?: true
+ /** Provider-authored row text; absent when the report carried names only. */
+ description?: string
+ /** Provider-authored argument sketch, e.g. ``. */
+ argumentHint?: string
}
/** The provider's own command surface, read per session. Additive read-only
diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts
index 32d3d2d8876..33219d8f7db 100644
--- a/src/shared/native-chat-slash-commands.test.ts
+++ b/src/shared/native-chat-slash-commands.test.ts
@@ -89,4 +89,39 @@ describe('a session that reports its own command surface', () => {
it('splits skills out for the picker to group on its own', () => {
expect(sessionReportedSkillNames(reported)).toEqual(['ref-oss'])
})
+
+ it('prefers the description the session reported over the curated one', () => {
+ expect(
+ sessionSlashCommandSuggestions('claude', [
+ { name: 'clear', kind: 'command', description: 'Wipe the transcript' },
+ { name: 'goal', kind: 'command', description: 'Set or view the goal' },
+ { name: 'compact', kind: 'command' }
+ ])
+ ).toEqual([
+ { name: 'clear', description: 'Wipe the transcript' },
+ { name: 'goal', description: 'Set or view the goal' },
+ { name: 'compact', description: 'Summarize and compact the conversation' }
+ ])
+ })
+
+ it('keeps a reported description and argument hint the curated catalog never claims', () => {
+ expect(
+ sessionSlashCommandSuggestions('codex', [
+ {
+ name: 'opsx:apply',
+ kind: 'command',
+ description: 'Apply the plan',
+ argumentHint: '',
+ kindUnspecified: true
+ }
+ ])
+ ).toEqual([
+ {
+ name: 'opsx:apply',
+ description: 'Apply the plan',
+ argumentHint: '',
+ kindUnspecified: true
+ }
+ ])
+ })
})
diff --git a/src/shared/native-chat-slash-commands.ts b/src/shared/native-chat-slash-commands.ts
index 9337e9c78f7..6360ca05f83 100644
--- a/src/shared/native-chat-slash-commands.ts
+++ b/src/shared/native-chat-slash-commands.ts
@@ -12,6 +12,8 @@ export type SlashCommandSuggestion = {
name: string
/** Optional one-line description for the suggestion row. */
description?: string
+ /** Provider-authored argument sketch, e.g. ``. */
+ argumentHint?: string
kindUnspecified?: true
}
@@ -93,9 +95,10 @@ export function getAgentSlashCommands(agent: AgentType): readonly SlashCommandSu
}
/** The command rows for a session that reports its own `/` surface. The report
- * is the authority on WHICH commands exist; the curated catalog above is kept
- * only as the description source for the names both know about. Skills are
- * excluded — they render in the picker's own skills group. */
+ * is the authority on WHICH commands exist and, when it carries one, on how a
+ * command is described; the curated catalog above only covers the names whose
+ * report is text-free. Skills are excluded — they render in the picker's own
+ * skills group. */
export function sessionSlashCommandSuggestions(
agent: AgentType,
reported: readonly AgentSessionSlashCommand[]
@@ -106,10 +109,11 @@ export function sessionSlashCommandSuggestions(
return reported
.filter((entry) => entry.kind === 'command')
.map((entry) => {
- const description = described.get(entry.name)
+ const description = entry.description ?? described.get(entry.name)
return {
name: entry.name,
...(description ? { description } : {}),
+ ...(entry.argumentHint ? { argumentHint: entry.argumentHint } : {}),
...(entry.kindUnspecified ? { kindUnspecified: true as const } : {})
}
})
From 3b99a59ea7606589e3af44ade61c07e151d83051 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 10 Sep 2026 21:30:12 -0700
Subject: [PATCH 07/17] perf(terminal): stop spending reveal-restore frames on
panes that replay nothing (#19972)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The hidden-output restore queue drains one entry per 16 ms frame. An entry
whose pane has since gone hidden, been disposed, or had its restore superseded
hits a guard and returns without replaying anything — but it still consumed the
frame, pushing the next on-screen pane back 16 ms per dead entry.
The scheduled callback now reports whether it started a replay, and the drain
walks past entries that report false within the same tick. Order is unchanged
(strict FIFO) and the one-real-replay-per-frame pacing is unchanged; only the
no-op entries stop costing a frame.
---
.../hidden-output-restore-scheduler.test.ts | 83 +++++++++++++++++--
.../hidden-output-restore-scheduler.ts | 24 ++++--
.../hidden-output-restore-request.ts | 8 +-
3 files changed, 99 insertions(+), 16 deletions(-)
diff --git a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts
index 354fff67009..f2cbc042a20 100644
--- a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts
+++ b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Mock } from 'vitest'
import {
cancelScheduledHiddenOutputRestore,
@@ -6,6 +7,11 @@ import {
scheduleHiddenOutputRestore
} from './hidden-output-restore-scheduler'
+/** A pane that actually replays scrollback when its turn comes. */
+const replaying = (): Mock<() => boolean> => vi.fn(() => true)
+/** A pane whose guards decline — hidden, disposed, or superseded restore. */
+const declining = (): Mock<() => boolean> => vi.fn(() => false)
+
describe('hidden output restore scheduler', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -19,7 +25,7 @@ describe('hidden output restore scheduler', () => {
it('runs active restores immediately', () => {
const target = {}
- const requestRestore = vi.fn()
+ const requestRestore = replaying()
scheduleHiddenOutputRestore(target, requestRestore, 'active')
@@ -27,8 +33,8 @@ describe('hidden output restore scheduler', () => {
})
it('spreads inactive restores across timer ticks', () => {
- const firstRestore = vi.fn()
- const secondRestore = vi.fn()
+ const firstRestore = replaying()
+ const secondRestore = replaying()
scheduleHiddenOutputRestore({}, firstRestore, 'inactive')
scheduleHiddenOutputRestore({}, secondRestore, 'inactive')
@@ -46,8 +52,8 @@ describe('hidden output restore scheduler', () => {
it('cancels pending inactive restore when a target is promoted', () => {
const target = {}
- const inactiveRestore = vi.fn()
- const activeRestore = vi.fn()
+ const inactiveRestore = replaying()
+ const activeRestore = replaying()
scheduleHiddenOutputRestore(target, inactiveRestore, 'inactive')
scheduleHiddenOutputRestore(target, activeRestore, 'active')
@@ -59,7 +65,7 @@ describe('hidden output restore scheduler', () => {
it('can cancel pending inactive restores', () => {
const target = {}
- const requestRestore = vi.fn()
+ const requestRestore = replaying()
scheduleHiddenOutputRestore(target, requestRestore, 'inactive')
cancelScheduledHiddenOutputRestore(target)
@@ -67,4 +73,69 @@ describe('hidden output restore scheduler', () => {
expect(requestRestore).not.toHaveBeenCalled()
})
+
+ it('does not charge a frame to panes that replay nothing', () => {
+ const hiddenPanes = [declining(), declining(), declining()]
+ const visibleRestore = replaying()
+
+ for (const hiddenPane of hiddenPanes) {
+ scheduleHiddenOutputRestore({}, hiddenPane, 'inactive')
+ }
+ scheduleHiddenOutputRestore({}, visibleRestore, 'inactive')
+
+ vi.advanceTimersByTime(16)
+
+ for (const hiddenPane of hiddenPanes) {
+ expect(hiddenPane).toHaveBeenCalledTimes(1)
+ }
+ expect(visibleRestore).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps one replay per frame once a queued pane replays', () => {
+ const firstRestore = replaying()
+ const declined = declining()
+ const secondRestore = replaying()
+
+ scheduleHiddenOutputRestore({}, firstRestore, 'inactive')
+ scheduleHiddenOutputRestore({}, declined, 'inactive')
+ scheduleHiddenOutputRestore({}, secondRestore, 'inactive')
+
+ vi.advanceTimersByTime(16)
+ expect(firstRestore).toHaveBeenCalledTimes(1)
+ expect(declined).not.toHaveBeenCalled()
+ expect(secondRestore).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(16)
+ expect(declined).toHaveBeenCalledTimes(1)
+ expect(secondRestore).toHaveBeenCalledTimes(1)
+ })
+
+ it('drops declining panes instead of retrying them', () => {
+ const declined = declining()
+
+ scheduleHiddenOutputRestore({}, declined, 'inactive')
+ vi.advanceTimersByTime(16)
+ vi.advanceTimersByTime(160)
+
+ expect(declined).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('does not re-enter a pane queued by a restore that ran in the same drain', () => {
+ const requeued = declining()
+ const target = {}
+ const reschedulingRestore = vi.fn(() => {
+ scheduleHiddenOutputRestore(target, requeued, 'inactive')
+ return false
+ })
+
+ scheduleHiddenOutputRestore({}, reschedulingRestore, 'inactive')
+ vi.advanceTimersByTime(16)
+
+ expect(reschedulingRestore).toHaveBeenCalledTimes(1)
+ expect(requeued).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(16)
+ expect(requeued).toHaveBeenCalledTimes(1)
+ })
})
diff --git a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts
index 909dfe29d59..df22453e59d 100644
--- a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts
+++ b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts
@@ -1,6 +1,7 @@
type HiddenOutputRestorePriority = 'active' | 'inactive'
-type HiddenOutputRestoreRequest = () => void
+/** Returns whether the pane actually started a replay; a guard-only return is free. */
+type HiddenOutputRestoreRequest = () => boolean
type HiddenOutputRestoreEntry = {
requestRestore: HiddenOutputRestoreRequest
@@ -30,13 +31,22 @@ function scheduleInactiveRestoreDrain(): void {
function drainInactiveRestoreQueue(): void {
inactiveRestoreTimer = null
- const next = inactiveRestoreQueue.entries().next()
- if (next.done) {
- return
+ // Why the loop: an entry whose pane went hidden, was disposed, or had its restore
+ // superseded replays nothing, so charging it a whole frame only delays the next
+ // on-screen pane. Still at most one real replay per frame; the skips are guard reads.
+ let remaining = inactiveRestoreQueue.size
+ while (remaining > 0) {
+ remaining -= 1
+ const next = inactiveRestoreQueue.entries().next()
+ if (next.done) {
+ break
+ }
+ const [target, entry] = next.value
+ inactiveRestoreQueue.delete(target)
+ if (entry.requestRestore()) {
+ break
+ }
}
- const [target, entry] = next.value
- inactiveRestoreQueue.delete(target)
- entry.requestRestore()
scheduleInactiveRestoreDrain()
}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts
index f245c9c16f2..08946421c5d 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-request.ts
@@ -68,7 +68,7 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
// Why: resume can reveal many split panes at once; spread inactive replays across frames so xterm scrollback replay doesn't block return.
scheduleHiddenOutputRestore(
session.pane.terminal,
- () => {
+ (): boolean => {
session.hiddenOutputRestoreScheduled = false
if (
session.disposed ||
@@ -80,9 +80,11 @@ export function bindHiddenOutputRestoreRequest(session: ConnectPanePtySession):
session.hiddenOutputRestorePendingChunks.length === 0) ||
!shouldWritePtyOutputForeground(session.deps.isVisibleRef.current)
) {
- return
+ // Why report false: nothing replayed here, so the scheduler can spend
+ // this frame on the next queued pane instead of on a hidden/stale one.
+ return false
}
- session.requestHiddenOutputRestoreIfNeeded({ bypassScheduler: true })
+ return session.requestHiddenOutputRestoreIfNeeded({ bypassScheduler: true }) === true
},
priority
)
From 6c1580aebad6c0bf12637b5c8d45eb5d12fe7e16 Mon Sep 17 00:00:00 2001
From: OrcaWin
Date: Thu, 10 Sep 2026 21:42:05 -0700
Subject: [PATCH 08/17] i18n: Make file reveal labels translatable (#20010)
Convert hardcoded "Reveal in Finder", "Reveal in File Explorer", and
"Open Containing Folder" labels to use i18n.translate() in three menu
components. Add corresponding English locale entries so these
platform-specific labels are now part of the translation system instead
of untranslated strings.
Co-authored-by: m4air
---
.../editor/EditorPanelHeaderPath.tsx | 20 +++++++++++-----
.../file-explorer-row-context-menu.tsx | 20 +++++++++++-----
.../tab-bar/EditorFileTabContextMenu.tsx | 23 ++++++++++++++-----
src/renderer/src/i18n/locales/en.json | 15 +++++++++---
4 files changed, 57 insertions(+), 21 deletions(-)
diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx
index f1d0ae5e7b6..eaa5db71209 100644
--- a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx
+++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx
@@ -20,11 +20,19 @@ const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
/** Platform-appropriate label: macOS -> Finder, Windows -> File Explorer, Linux -> Files */
-const revealLabel = isMac
- ? 'Reveal in Finder'
- : isLinux
- ? 'Open Containing Folder'
- : 'Reveal in File Explorer'
+function getRevealLabel(): string {
+ return isMac
+ ? translate('auto.components.editor.EditorPanelHeader.revealInFinder', 'Reveal in Finder')
+ : isLinux
+ ? translate(
+ 'auto.components.editor.EditorPanelHeader.openContainingFolder',
+ 'Open Containing Folder'
+ )
+ : translate(
+ 'auto.components.editor.EditorPanelHeader.revealInFileExplorer',
+ 'Reveal in File Explorer'
+ )
+}
type EditorPanelHeaderPathProps = {
activeFile: OpenFile
@@ -196,7 +204,7 @@ export function EditorPanelHeaderPath({
{!isVirtualEditorTab && (
- {revealLabel}
+ {getRevealLabel()}
)}
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx
index d669b53bf79..a9be313a436 100644
--- a/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx
+++ b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx
@@ -43,11 +43,19 @@ const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
/** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */
-const revealLabel = isMac
- ? 'Reveal in Finder'
- : isLinux
- ? 'Open Containing Folder'
- : 'Reveal in File Explorer'
+function getRevealLabel(): string {
+ return isMac
+ ? translate('auto.components.right.sidebar.FileExplorerRow.revealInFinder', 'Reveal in Finder')
+ : isLinux
+ ? translate(
+ 'auto.components.right.sidebar.FileExplorerRow.openContainingFolder',
+ 'Open Containing Folder'
+ )
+ : translate(
+ 'auto.components.right.sidebar.FileExplorerRow.revealInFileExplorer',
+ 'Reveal in File Explorer'
+ )
+}
function stopRightButtonMenuSelection(event: React.PointerEvent): void {
if (event.button !== 2) {
@@ -290,7 +298,7 @@ export function FileExplorerRowContextMenu({
}}
>
- {revealLabel}
+ {getRevealLabel()}
onStartRename(node)}>
diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx
index 1813265573f..a6df437feac 100644
--- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx
+++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx
@@ -32,11 +32,22 @@ const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
/** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */
-const revealLabel = isMac
- ? 'Reveal in Finder'
- : isLinux
- ? 'Open Containing Folder'
- : 'Reveal in File Explorer'
+function getRevealLabel(): string {
+ return isMac
+ ? translate(
+ 'auto.components.tab.bar.EditorFileTabContextMenu.revealInFinder',
+ 'Reveal in Finder'
+ )
+ : isLinux
+ ? translate(
+ 'auto.components.tab.bar.EditorFileTabContextMenu.openContainingFolder',
+ 'Open Containing Folder'
+ )
+ : translate(
+ 'auto.components.tab.bar.EditorFileTabContextMenu.revealInFileExplorer',
+ 'Reveal in File Explorer'
+ )
+}
type EditorFileTabContextMenuProps = {
open: boolean
@@ -251,7 +262,7 @@ export function EditorFileTabContextMenu({
}}
>
- {revealLabel}
+ {getRevealLabel()}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 607359d1b71..fb1f37eab9b 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -3368,7 +3368,10 @@
"1d04b1630b": "Split Down",
"6b3efb106e": "Split Up",
"fdd29eb669": "Pin Tab",
- "8e9d603a09": "Unpin Tab"
+ "8e9d603a09": "Unpin Tab",
+ "revealInFinder": "Reveal in Finder",
+ "openContainingFolder": "Open Containing Folder",
+ "revealInFileExplorer": "Reveal in File Explorer"
},
"QuickLaunchButton": {
"348a04c1ad": "Agent settings…",
@@ -11976,7 +11979,10 @@
"3161c4e425": "folder",
"e887fa4b2e": "Open in Terminal",
"1d8e182c32": "View File",
- "clipboardStagingUnavailable": "Could not copy the file because Orca's temporary storage is unavailable"
+ "clipboardStagingUnavailable": "Could not copy the file because Orca's temporary storage is unavailable",
+ "revealInFinder": "Reveal in Finder",
+ "openContainingFolder": "Open Containing Folder",
+ "revealInFileExplorer": "Reveal in File Explorer"
},
"FileExplorerToolbar": {
"d238264654": "Show Git Ignored Files",
@@ -14676,7 +14682,10 @@
"f0fd4174b5": "Open file tab to use rich markdown editing",
"a10d9b8337": "Open file",
"2076ecfc9c": "Previous change",
- "631dab0df3": "Next change"
+ "631dab0df3": "Next change",
+ "revealInFinder": "Reveal in Finder",
+ "openContainingFolder": "Open Containing Folder",
+ "revealInFileExplorer": "Reveal in File Explorer"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "Export as PDF",
From 1798786d4e846e23582918aae645963a8ffed792 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 10 Sep 2026 21:52:21 -0700
Subject: [PATCH 09/17] perf(native-chat): mount only the transcript rows near
the viewport (#19869)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor(native-chat): share one row-content derivation between row and list
Windowing needs the list and the row to agree on which messages draw
nothing: a row the list counts but the row declines to render would
reserve estimated height for an empty slot.
Extracts the block derivation out of NativeChatMessageRow into a module
cached on the block array, so a streaming turn pays for it once per
revision rather than once per consumer.
* refactor(native-chat): keep an opened tool run open past its row's lifetime
A tool run, tool line or diff card the reader opened is state they created, but
it lives in the component's own `useState`. That is fine while every row is
mounted forever. It stops being fine the moment rows can be unmounted: the run
silently re-collapses behind the reader's back.
Rows now read their disclosure from a transcript-level map when one is provided
and fall back to their own state when they are rendered standalone. The controls
that re-sync a run — the toolbar's expand-all, a turn's disclosure, a diff
reveal — are folded into the key the choice is remembered under, so a control
flip reads as "nothing recorded yet" and the new default stands without a
mid-render write to a map an ancestor owns.
`ToolLine` moves to its own file; the run was over the line cap with it.
* perf(native-chat): mount only the transcript rows near the viewport
A settled transcript mounts every row it has ever loaded, so the cost of opening
a conversation grows with its length even though only a screenful is legible.
Rows near the viewport are now the only ones in the document; the rest are
reserved as estimated height and measured when they arrive.
Four things had to change for that to be safe:
- `zoom` moves from the transcript column onto the scroll container. Item
measurements are in the zoomed content's pixels while `scrollTop` is not, so
with the two split across the boundary the window's arithmetic was off by
exactly the font scale — correct at the top of a transcript and blank deep
inside it. The column's padding moves to a new inner element to keep the
layout it had. This does mean the scrollbar itself zooms with the text.
- The three siblings that made up a row — the message, the turn status, the
turn's diff rollup — move into one wrapper that carries the spacing they used
to take from the column. The spacing between rows is the window's `gap`, never
the height estimate, which would otherwise be counted twice.
- Messages that draw nothing no longer take a slot. Counted but undrawn, each
one would reserve estimated height for a row that never appears.
- Paging in older history is driven by scroll events alone. Every row that
resolves its real height moves the content and re-fires the size observers, so
the old "am I near the top?" test would have asked for another page once per
measurement. It now also requires the view to have moved upwards and requires
new items since the last request.
Anchoring is the virtualizer's: `anchorTo: 'end'` re-resolves the row at the
current offset across a count change, which replaces the hand-rolled prepend
anchor, and `followOnAppend` keeps a reader at the bottom pinned there. The
document-level bottom pin stays, because the typing indicator, the activity line
and the column's end padding all live past the last row.
Revealing a diff from a turn rollup can target a row that isn't mounted, so that
row is pinned into the window and the card still reports its own position — a
turn that touched four files lands on the one that was asked for.
* fix(native-chat): let a pinned row reach the mounted window
Two faults the windowing tests turned up, plus the handles they needed.
The virtualizer memoizes its mounted index list on the range extractor's
identity. Holding that identity stable — which is right for the measurement
memo, and was the reason it was written that way — meant a row pinned after the
fact was never picked up: revealing a diff in a row the window had left behind
pointed at a row that stayed unmounted. The extractor now changes identity with
the pinned set, which is not a dependency of the measurement memo, so nothing
expensive is rebuilt.
The offset a row sits at is read off the `offsetParent` chain, with a rect-based
fallback for the case where there is none. Using that fallback for the window's
own scroll margin was wrong in kind: with no layout to measure, it returns the
scroll position itself, so the margin tracked the offset and the window sat at
the top of the transcript wherever the reader scrolled. The margin now takes the
offset chain or nothing; the fallback stays where it belongs, on the reveal.
The scroll root and the window's spacer are named, so measurement can find the
scroll root without depending on which utility class makes it scroll, and so a
test can tell a window from a whole transcript.
* test(native-chat): cover the windowed transcript, and prove the window engaged
The integration harness stubs `offsetHeight` — on the scroll root and on every
row — because that is what the virtualizer measures with, and a DOM without
layout answers zero to all of it. Rows report the height their own estimate
predicted, which keeps the reserved totals exact no matter which rows have been
mounted long enough to be measured.
Every case reads the window through one helper that refuses to pass when there
is no window. Without that, raising the usability gate would send all of them
down the whole-transcript path, where "fewer rows mounted than messages" is
false but every other assertion still holds — and they would go on reporting
green while covering nothing. Reserved height is asserted as an exact total
rather than "greater than zero", which a degenerate empty window also satisfies,
and the mounted range is asserted to bracket the offset rather than merely to be
smaller than the transcript.
Covered: the window mounts a subset and moves with the reader; the newest row
and a reveal's target stay mounted from outside it; an opened tool run is still
open when its row comes back; a message that draws nothing takes no slot; and
the scroll root with no usable height still renders every row as a direct child
of the transcript column.
What the environment cannot show is stated where it matters rather than faked:
its ResizeObserver never fires and a scroll assignment emits no event, so
measurement settling, the bottom pin under a streaming turn, prepend anchoring
and smooth scrolling are covered as pure decisions — height estimation, the
pinned set, range extraction, and whether a position should page in older
history — and left to a real renderer as behaviour.
* docs(native-chat): say that one offset path does read rects
* test(native-chat): pin the window against a row that grows in place
Whole-message appends were covered; a row being replaced by a taller
version of itself — what a streaming reply is — was not. The existing
windowing harness gains two things it needs to see that: a scroll root
with a real document (a height, a viewport, and a scrollTop that clamps),
and a resize observer that delivers when a target's height actually
changed, since happy-dom's never fires and nothing re-measures without it.
Frame by frame, while one row grows from 24px to 6358px: the view stays
0px from the bottom, the row stays mounted, and the reserved total tracks
the measurement rather than the estimate. A reader who scrolls up mid
growth keeps the exact offset they chose for the rest of it.
* test(native-chat): guard history prepend anchoring
* test(native-chat): strengthen prepend anchor contract
* fix(native-chat): preserve provider tool call identity
* fix(native-chat): harden transcript windowing lifecycle
* test(native-chat): install virtualizer viewport for turn timing
* fix(native-chat): reject blank tool call identities
---------
Co-authored-by: Merge Sim
---
.../claude-structured-item-translation.ts | 1 +
...ude-structured-journal-translation.test.ts | 3 +
.../codex-structured-item-translation.test.ts | 13 +
.../codex-structured-item-translation.ts | 4 +
.../transcript-line-decoders-codex.ts | 5 +-
...anscript-reader-codex-history-mode.test.ts | 2 +-
.../native-chat/transcript-reader.test.ts | 5 +-
.../native-chat/transcript-record-blocks.ts | 3 +-
.../native-chat/NativeChatDiffCard.tsx | 19 +-
...hatMessageList.stream-render.perf.test.tsx | 8 +-
...eChatMessageList.task-list-frames.test.tsx | 8 +-
.../NativeChatMessageList.test.tsx | 8 +-
...eChatMessageList.tool-stream-cost.test.tsx | 8 +-
.../native-chat/NativeChatMessageList.tsx | 383 +++++------
...ativeChatMessageList.turn-history.test.tsx | 8 +-
...NativeChatMessageList.turn-timing.test.tsx | 11 +-
.../NativeChatMessageList.windowing.test.tsx | 640 ++++++++++++++++++
.../native-chat/NativeChatMessageRow.tsx | 48 +-
.../native-chat/NativeChatToolLine.tsx | 139 ++++
.../NativeChatToolRun.identity.test.tsx | 109 ++-
.../native-chat/NativeChatToolRun.tsx | 193 ++----
.../native-chat/NativeChatTranscriptItems.tsx | 50 ++
.../native-chat/NativeChatTranscriptRow.tsx | 86 +++
.../native-chat-autoscroll.test.ts | 45 ++
.../native-chat/native-chat-autoscroll.ts | 35 +
.../native-chat-disclosure-store.ts | 71 ++
.../native-chat-message-list-test-viewport.ts | 26 +
.../native-chat-pinned-rows.test.ts | 46 ++
.../native-chat/native-chat-pinned-rows.ts | 57 ++
.../native-chat/native-chat-row-content.ts | 61 ++
.../native-chat-row-height-estimate.test.ts | 147 ++++
.../native-chat-row-height-estimate.ts | 126 ++++
.../native-chat-transcript-slots.test.ts | 111 +++
.../native-chat-transcript-slots.ts | 119 ++++
.../use-native-chat-transcript-scroll.ts | 145 ++++
...ve-chat-transcript-window.options.test.tsx | 121 ++++
.../use-native-chat-transcript-window.ts | 230 +++++++
.../agent-session-journal-schemas.test.ts | 23 +-
src/shared/agent-session-journal-schemas.ts | 7 +
src/shared/agent-session-journal-types.ts | 2 +
src/shared/native-chat-types.ts | 2 +
...tructured-agent-session-projection.test.ts | 1 +
.../structured-agent-session-projection.ts | 1 +
...native-chat-history-prepend-anchor.spec.ts | 204 ++++++
44 files changed, 2912 insertions(+), 422 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx
create mode 100644 src/renderer/src/components/native-chat/NativeChatToolLine.tsx
create mode 100644 src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx
create mode 100644 src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx
create mode 100644 src/renderer/src/components/native-chat/native-chat-disclosure-store.ts
create mode 100644 src/renderer/src/components/native-chat/native-chat-message-list-test-viewport.ts
create mode 100644 src/renderer/src/components/native-chat/native-chat-pinned-rows.test.ts
create mode 100644 src/renderer/src/components/native-chat/native-chat-pinned-rows.ts
create mode 100644 src/renderer/src/components/native-chat/native-chat-row-content.ts
create mode 100644 src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts
create mode 100644 src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts
create mode 100644 src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts
create mode 100644 src/renderer/src/components/native-chat/native-chat-transcript-slots.ts
create mode 100644 src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts
create mode 100644 src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx
create mode 100644 src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts
create mode 100644 tests/e2e/native-chat-history-prepend-anchor.spec.ts
diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts
index 1c1673b59cd..0fdb231f6ef 100644
--- a/src/main/claude/claude-structured-item-translation.ts
+++ b/src/main/claude/claude-structured-item-translation.ts
@@ -179,6 +179,7 @@ export function claudeToolBody(input: {
kind: 'tool-call',
name: input.tool.name,
input: input.tool.input,
+ callId: input.tool.id,
state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running',
...(input.result
? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded }
diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts
index 050fccc42b6..44d7e3a1251 100644
--- a/src/main/claude/claude-structured-journal-translation.test.ts
+++ b/src/main/claude/claude-structured-journal-translation.test.ts
@@ -528,6 +528,7 @@ describe('Claude structured journal translation', () => {
expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({
kind: 'tool-call',
name: 'Bash',
+ callId: 'tool-1',
state: 'completed',
output: { head: 'a.ts\nb.ts', truncated: false }
})
@@ -546,6 +547,7 @@ describe('Claude structured journal translation', () => {
expect(state.items.at(-1)?.body).toMatchObject({
kind: 'tool-call',
name: 'tool',
+ callId: 'tool-1',
input: null,
output: { head: 'done again' }
})
@@ -606,6 +608,7 @@ describe('Claude structured journal translation', () => {
])
expect(state.items[0]?.body).toMatchObject({
kind: 'tool-call',
+ callId: 'tool-1',
state: 'completed',
output: { head: 'done' }
})
diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts
index 201df58bc90..3f8ec524002 100644
--- a/src/main/codex/codex-structured-item-translation.test.ts
+++ b/src/main/codex/codex-structured-item-translation.test.ts
@@ -201,6 +201,7 @@ describe('codex item bodies', () => {
expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({
kind: 'tool-call',
name: 'shell',
+ callId: 'item-2',
input: { command: 'ls', cwd: '/tmp' },
exitCode: 0,
state: 'completed',
@@ -229,6 +230,7 @@ describe('codex item bodies', () => {
expect(body).toEqual({
kind: 'tool-call',
name: 'read',
+ callId: 'item-read',
// `name` is the target's basename, which `path` already carries and no
// label ever reads, so it stays out of the bounded journal payload.
input: { command: "sed -n '1,200p' notes.txt", cwd: '/repo', path: '/repo/notes.txt' },
@@ -257,6 +259,7 @@ describe('codex item bodies', () => {
).toEqual({
kind: 'tool-call',
name: 'search',
+ callId: 'item-search',
input: { command: 'rg -n --no-heading beta .', cwd: '/repo', query: 'beta', directory: '.' },
state: 'running'
})
@@ -276,6 +279,7 @@ describe('codex item bodies', () => {
).toEqual({
kind: 'tool-call',
name: 'search',
+ callId: 'item-search-bare',
input: { command: 'rg beta', cwd: '/repo' },
exitCode: 0,
state: 'completed'
@@ -296,6 +300,7 @@ describe('codex item bodies', () => {
expect(body).toEqual({
kind: 'tool-call',
name: 'list',
+ callId: 'item-list',
input: { command: 'ls', cwd: '/repo' },
exitCode: 0,
state: 'completed'
@@ -326,6 +331,7 @@ describe('codex item bodies', () => {
).toEqual({
kind: 'tool-call',
name: 'shell',
+ callId: 'item-mixed',
input: { command: 'cat a.txt && ls src', cwd: '/repo' },
exitCode: 0,
state: 'completed'
@@ -349,6 +355,7 @@ describe('codex item bodies', () => {
).toEqual({
kind: 'tool-call',
name: 'read',
+ callId: 'item-two-reads',
input: { command: 'cat a.ts && cat b.ts', cwd: '/repo' },
exitCode: 0,
state: 'completed'
@@ -424,6 +431,7 @@ describe('codex item bodies', () => {
).toEqual({
kind: 'tool-call',
name: 'read',
+ callId: 'item-read-null',
input: { command: 'cat', cwd: '/repo' },
exitCode: 0,
state: 'completed'
@@ -451,6 +459,7 @@ describe('codex item bodies', () => {
const shellRow = {
kind: 'tool-call',
name: 'shell',
+ callId: 'item-fallback',
input: { command: 'ls', cwd: '/tmp' },
exitCode: 0,
state: 'completed'
@@ -624,6 +633,7 @@ describe('codex item bodies', () => {
// Server-qualified, and the arguments stay top level so the row label can
// read `query`/`command`/`file_path` out of them.
name: 'weather/get_forecast',
+ callId: 'mcp-1',
mcpIdentity: { server: 'weather', tool: 'get_forecast' },
input: { city: 'Oslo' },
state: 'completed',
@@ -672,6 +682,7 @@ describe('codex item bodies', () => {
expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: {} })).toEqual({
kind: 'tool-call',
name: 't',
+ callId: 'm',
input: null,
state: 'running'
})
@@ -719,6 +730,7 @@ describe('codex item bodies', () => {
expect(codexItemBody({ type: 'webSearch', id: 'w', query: '', action: null })).toEqual({
kind: 'tool-call',
name: 'web_search',
+ callId: 'w',
input: null,
state: 'running'
})
@@ -733,6 +745,7 @@ describe('codex item bodies', () => {
).toEqual({
kind: 'tool-call',
name: 'web_search',
+ callId: 'w',
input: {
query: 'orca release notes',
description: 'search',
diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts
index 576f3fb19ec..52d7d5ae47f 100644
--- a/src/main/codex/codex-structured-item-translation.ts
+++ b/src/main/codex/codex-structured-item-translation.ts
@@ -93,6 +93,7 @@ function commandItem(item: CodexThreadItem): CodexJournalItem {
body: {
kind: 'tool-call',
name: parsed?.name ?? 'shell',
+ callId: item.id,
// Raw command and cwd stay so the expanded view still shows what ran.
input: boundToolInput(
{ command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields },
@@ -120,6 +121,7 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
body: {
kind: 'tool-call',
name: 'apply_patch',
+ callId: item.id,
input: boundToolInput({ changes: item.changes ?? null }, DEFAULT_JOURNAL_PAYLOAD_LIMITS),
state: commandState(item)
},
@@ -171,6 +173,7 @@ function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem {
body: {
kind: 'tool-call',
name: mcpToolCallName(item),
+ callId: item.id,
...(server && tool ? { mcpIdentity: { server, tool } } : {}),
input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS),
state: failure === null ? commandState(item) : 'failed',
@@ -213,6 +216,7 @@ function webSearchItem(item: CodexThreadItem): CodexJournalItem {
body: {
kind: 'tool-call',
name: 'web_search',
+ callId: item.id,
...(results.length > 0 ? { webSearchResults: results } : {}),
input: boundToolInput(webSearchInput(item), DEFAULT_JOURNAL_PAYLOAD_LIMITS),
state: item.action === null || item.action === undefined ? 'running' : 'completed',
diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts
index 229ace2a461..ddc748dde03 100644
--- a/src/main/native-chat/transcript-line-decoders-codex.ts
+++ b/src/main/native-chat/transcript-line-decoders-codex.ts
@@ -92,10 +92,13 @@ function codexResponseItem(
payload.type === 'custom_tool_call'
) {
const name = extractString(payload.name) ?? 'tool'
+ const callId = extractString(payload.call_id)
return {
id,
role: 'assistant',
- blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }],
+ blocks: [
+ { type: 'tool-call', name, input: codexCallInput(payload), ...(callId ? { callId } : {}) }
+ ],
timestamp,
source: 'transcript'
}
diff --git a/src/main/native-chat/transcript-reader-codex-history-mode.test.ts b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts
index 5d18bec8c85..9a9b2b76094 100644
--- a/src/main/native-chat/transcript-reader-codex-history-mode.test.ts
+++ b/src/main/native-chat/transcript-reader-codex-history-mode.test.ts
@@ -240,7 +240,7 @@ describe('Codex transcript history modes', () => {
expect(call).toMatchObject({
id: 'call-1',
role: 'assistant',
- blocks: [{ type: 'tool-call', name: 'exec', input: 'pwd' }]
+ blocks: [{ type: 'tool-call', name: 'exec', input: 'pwd', callId: 'durable-call-1' }]
})
expect(output).toMatchObject({
id: 'fallback-output',
diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts
index ff48548804d..eb333cd8fda 100644
--- a/src/main/native-chat/transcript-reader.test.ts
+++ b/src/main/native-chat/transcript-reader.test.ts
@@ -67,7 +67,7 @@ describe('readNativeChatTranscript (claude)', () => {
timestamp: '2026-06-01T10:05:00.000Z',
message: {
role: 'assistant',
- content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }]
+ content: [{ type: 'tool_use', id: 'tool-call-1', name: 'Bash', input: { command: 'ls' } }]
}
})
records.push({
@@ -98,7 +98,8 @@ describe('readNativeChatTranscript (claude)', () => {
expect(toolCall?.blocks[0]).toEqual({
type: 'tool-call',
name: 'Bash',
- input: { command: 'ls' }
+ input: { command: 'ls' },
+ callId: 'tool-call-1'
})
const toolResult = result.messages.at(-1)
diff --git a/src/main/native-chat/transcript-record-blocks.ts b/src/main/native-chat/transcript-record-blocks.ts
index 6355df277e5..b82ff9672ca 100644
--- a/src/main/native-chat/transcript-record-blocks.ts
+++ b/src/main/native-chat/transcript-record-blocks.ts
@@ -81,7 +81,8 @@ function claudeContentBlock(record: Record): NativeChatBlock |
}
case 'tool_use': {
const name = extractString(record.name) ?? 'tool'
- return { type: 'tool-call', name, input: record.input }
+ const callId = extractString(record.id)
+ return { type: 'tool-call', name, input: record.input, ...(callId ? { callId } : {}) }
}
case 'tool_result':
return toolResultBlock(record)
diff --git a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
index a69ae1252f1..91d113f15e0 100644
--- a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
@@ -1,4 +1,5 @@
-import { useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { useLayoutEffect, useMemo, useRef } from 'react'
+import { useNativeChatDisclosure } from './native-chat-disclosure-store'
import { ChevronRight, FilePlus2, FileMinus2, FilePen } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -113,21 +114,29 @@ export function NativeChatDiffCard({
file,
revealSignal,
onReveal,
- initiallyExpanded = false
+ initiallyExpanded = false,
+ disclosureKey
}: {
file: NativeChatEditFile
revealSignal?: number
onReveal?: (element: HTMLElement) => void
initiallyExpanded?: boolean
+ /** Identity this card's open state is remembered under while it is unmounted. */
+ disclosureKey?: string
}): React.JSX.Element {
- const [expanded, setExpanded] = useState(initiallyExpanded)
+ const { open: expanded, setOpen: setExpanded } = useNativeChatDisclosure(
+ disclosureKey,
+ initiallyExpanded
+ )
const cardRef = useRef(null)
useLayoutEffect(() => {
if (revealSignal && cardRef.current) {
setExpanded(true)
+ // Reported from the card, not the row: a turn that touched four files must
+ // land on the one that was asked for, and only the card knows where it is.
onReveal?.(cardRef.current)
}
- }, [revealSignal, onReveal])
+ }, [revealSignal, onReveal, setExpanded])
// Joining every row to seed the copy button is the card's most expensive
// work, and a collapsed card renders none of those rows.
const copyText = useMemo(() => patchText(file.lines), [file.lines])
@@ -141,7 +150,7 @@ export function NativeChatDiffCard({