fix(native-chat): route transcripts by PTY provenance

This commit is contained in:
OrcaWin
2026-08-12 02:10:34 -07:00
parent d59e1a49d7
commit 4d1b021f86
29 changed files with 1136 additions and 206 deletions
@@ -100,12 +100,15 @@ function createSender(id: number): SenderHarness {
}
}
function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void {
function subscribe(sender: SenderHarness['sender'], subscriptionId: string, ptyId?: string): void {
const listener = listeners.get('nativeChat:subscribe')
if (!listener) {
throw new Error('subscribe listener not registered')
}
listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` })
listener(
{ sender },
{ subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}`, ptyId }
)
}
type InitialSnapshotCallback = (
@@ -116,6 +119,42 @@ type InitialSnapshotCallback = (
lifecycle?: NativeChatTurnLifecycle
) => void
it('resolves PTY provenance before installing the desktop watcher', async () => {
clearNativeChatSubscriptions()
handlers.clear()
listeners.clear()
const resolveTranscriptHost = vi.fn(() => ({ kind: 'wsl' as const, distro: 'Ubuntu' }))
registerNativeChatHandlers({ resolveTranscriptHost })
subscribeTranscript.mockResolvedValue({ unsubscribe: vi.fn(), watching: true })
const sender = createSender(99)
subscribe(sender.sender, 'pty-route', 'pty-1')
await vi.waitFor(() => expect(subscribeTranscript).toHaveBeenCalledOnce())
expect(resolveTranscriptHost).toHaveBeenCalledWith('pty-1')
expect(subscribeTranscript.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ transcriptHost: { kind: 'wsl', distro: 'Ubuntu' } })
)
})
it('fails closed without installing a watcher for a missing PTY identity', async () => {
clearNativeChatSubscriptions()
handlers.clear()
listeners.clear()
registerNativeChatHandlers({ resolveTranscriptHost: () => null })
const sender = createSender(100)
subscribe(sender.sender, 'missing-route', 'missing-pty')
await vi.waitFor(() => expect(sender.sender.send).toHaveBeenCalledOnce())
expect(subscribeTranscript).not.toHaveBeenCalled()
expect(sender.sender.send.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
frame: expect.objectContaining({ error: 'Transcript unavailable' })
})
)
})
// The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript
// call; transcript-watch fires it during setup, so tests invoke it directly.
function initialSnapshot(callIndex: number): InitialSnapshotCallback {
+31 -7
View File
@@ -54,13 +54,17 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void
}
}
async function invokeReadSession(args: {
agent: string
sessionId: string
limit?: number
transcriptPath?: string
}): Promise<unknown> {
registerNativeChatHandlers()
async function invokeReadSession(
args: {
agent: string
sessionId: string
limit?: number
transcriptPath?: string
ptyId?: string
},
deps?: Parameters<typeof registerNativeChatHandlers>[0]
): Promise<unknown> {
registerNativeChatHandlers(deps)
const handler = handlers.get('nativeChat:readSession')
if (!handler) {
throw new Error('handler not registered')
@@ -69,6 +73,26 @@ async function invokeReadSession(args: {
}
describe('nativeChat:readSession handler', () => {
it('asks the PTY owner for transcript provenance', async () => {
const resolveTranscriptHost = vi.fn(() => ({ kind: 'host' as const }))
await invokeReadSession(
{ agent: 'claude', sessionId: 'missing-session', ptyId: 'pty-1' },
{ resolveTranscriptHost }
)
expect(resolveTranscriptHost).toHaveBeenCalledWith('pty-1')
})
it('fails closed when a supplied PTY identity is missing', async () => {
const result = (await invokeReadSession(
{ agent: 'claude', sessionId: 'same-id', ptyId: 'missing-pty' },
{ resolveTranscriptHost: () => null }
)) as { error?: string; notFound?: true }
expect(result).toEqual({ error: 'Transcript unavailable' })
})
it('preserves notFound so a just-created session stays in retry/loading', async () => {
const result = (await invokeReadSession({
agent: 'claude',
+47 -5
View File
@@ -12,6 +12,7 @@ import {
type NativeChatTranscriptSubscription,
type SubscribeNativeChatTranscriptArgs
} from '../native-chat/transcript-watch'
import type { NativeChatTranscriptHost } from '../native-chat/transcript-host'
// Re-export so existing test imports of `clearNativeChatTranscriptCache` from
// this module keep working after the cache moved to transcript-read-cache.ts.
@@ -26,20 +27,41 @@ export type NativeChatReadSessionArgs = {
/** Authoritative transcript path from the agent hook (providerSession), used to
* locate the file when the session id no longer names it (recent Claude Code). */
transcriptPath?: string
/** PTY whose host owns this transcript. */
ptyId?: string
}
type NativeChatHandlerDeps = {
resolveTranscriptHost?: (ptyId: string) => NativeChatTranscriptHost | null | undefined
}
function requestedTranscriptHost(
ptyId: string | undefined,
deps: NativeChatHandlerDeps
): NativeChatTranscriptHost | null | undefined {
return ptyId ? deps.resolveTranscriptHost?.(ptyId) : undefined
}
// Why: render and parse only the recent window so long transcripts do not stall
// either the main process or the message list. Pagination raises this limit.
const DESKTOP_READ_WINDOW = 300
async function readSession(args: NativeChatReadSessionArgs): Promise<ReadTranscriptResult> {
async function readSession(
args: NativeChatReadSessionArgs,
deps: NativeChatHandlerDeps
): Promise<ReadTranscriptResult> {
const { agent, sessionId } = args
// Clamp to a positive window; default to the desktop window for the first page.
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
const transcriptHost = requestedTranscriptHost(args.ptyId, deps)
if (transcriptHost === null) {
return { error: 'Transcript unavailable' }
}
return readNativeChatTranscriptTail({
agent,
sessionId,
transcriptPath: args.transcriptPath,
transcriptHost,
limit
})
}
@@ -52,6 +74,7 @@ export type NativeChatSubscribeArgs = {
sessionId: string
/** Authoritative transcript path from the agent hook (providerSession). */
transcriptPath?: string
ptyId?: string
limit?: number
}
@@ -165,7 +188,11 @@ function takePendingSubscription(
return true
}
async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise<void> {
async function handleSubscribe(
event: IpcMainEvent,
args: NativeChatSubscribeArgs,
deps: NativeChatHandlerDeps
): Promise<void> {
const sender = event.sender
if (sender.isDestroyed()) {
return
@@ -175,11 +202,26 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
// Replace any prior subscription under the same id (session change/resubscribe).
const pending = beginPendingSubscription(sender.id, subscriptionId)
registerSenderCleanup(sender)
const transcriptHost = requestedTranscriptHost(args.ptyId, deps)
if (transcriptHost === null) {
takePendingSubscription(sender.id, subscriptionId, pending)
sender.send('nativeChat:appended', {
subscriptionId,
frame: {
type: 'snapshot',
messages: [],
hasMore: false,
error: 'Transcript unavailable'
}
} satisfies NativeChatAppendedPayload)
return
}
const subscribeArgs: SubscribeNativeChatTranscriptArgs = {
agent,
sessionId,
transcriptPath,
transcriptHost,
initialLimit: limit,
onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => {
if (sender.isDestroyed()) {
@@ -287,12 +329,12 @@ export function _getNativeChatPendingSubscriptionCountForTest(): number {
return count
}
export function registerNativeChatHandlers(): void {
export function registerNativeChatHandlers(deps: NativeChatHandlerDeps = {}): void {
ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) =>
readSession(args)
readSession(args, deps)
)
ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => {
void handleSubscribe(event, args)
void handleSubscribe(event, args, deps)
})
ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => {
teardownSubscription(event.sender.id, args.subscriptionId)
+3 -1
View File
@@ -227,7 +227,9 @@ export function registerCoreHandlers(
prepareRuntimeSessionResume: async (environmentId, args) =>
prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args)
})
registerNativeChatHandlers()
registerNativeChatHandlers({
resolveTranscriptHost: (ptyId) => runtime.resolveNativeChatTranscriptHost(ptyId)
})
registerClipboardHandlers(store)
registerUpdaterHandlers(store)
registerSpeechHandlers(store)
@@ -6,7 +6,9 @@ import {
resetHostReadableTranscriptPathCacheForTests,
toHostReadableTranscriptPath,
wslClaudeProjectsDirs,
wslCodexSessionsDirs
wslClaudeProjectsDirsForDistro,
wslCodexSessionsDirs,
wslCodexSessionsDirsForDistro
} from './host-readable-transcript-path'
const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada'
@@ -53,6 +55,100 @@ describe('toHostReadableTranscriptPath', () => {
).resolves.toBe(ROLLOUT_UNC)
})
it('translates through only the authoritative WSL distro', async () => {
const listWslHomeDirs = vi.fn(async () => [DEBIAN_HOME])
const pathExists = vi.fn(async (candidate: string) => candidate === ROLLOUT_UNC)
await expect(
toHostReadableTranscriptPath(ROLLOUT_LINUX, {
platform: 'win32',
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
listWslHomeDirs,
pathExists
})
).resolves.toBe(ROLLOUT_UNC)
expect(listWslHomeDirs).not.toHaveBeenCalled()
expect(pathExists).toHaveBeenCalledTimes(1)
expect(pathExists).toHaveBeenCalledWith(ROLLOUT_UNC)
})
it('does not reinterpret a guest path when the PTY belongs to the host', async () => {
const listWslHomeDirs = vi.fn(async () => [UBUNTU_HOME])
const pathExists = vi.fn(async () => true)
await expect(
toHostReadableTranscriptPath(ROLLOUT_LINUX, {
platform: 'win32',
transcriptHost: { kind: 'host' },
listWslHomeDirs,
pathExists
})
).resolves.toBeNull()
expect(listWslHomeDirs).not.toHaveBeenCalled()
expect(pathExists).not.toHaveBeenCalled()
})
it('rejects exact paths that contradict authoritative provenance', async () => {
const pathExists = vi.fn(async () => true)
await expect(
toHostReadableTranscriptPath(ROLLOUT_UNC, {
platform: 'win32',
transcriptHost: { kind: 'host' },
pathExists
})
).resolves.toBeNull()
await expect(
toHostReadableTranscriptPath(ROLLOUT_UNC, {
platform: 'win32',
transcriptHost: { kind: 'wsl', distro: 'Debian' },
pathExists
})
).resolves.toBeNull()
await expect(
toHostReadableTranscriptPath('C:\\Users\\ada\\rollout.jsonl', {
platform: 'win32',
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
pathExists
})
).resolves.toBeNull()
expect(pathExists).not.toHaveBeenCalled()
})
it('classifies extended WSL UNC paths before authoritative probing', async () => {
const extendedUnc =
'\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl'
const pathExists = vi.fn(async () => true)
await expect(
toHostReadableTranscriptPath(extendedUnc, {
platform: 'win32',
transcriptHost: { kind: 'host' },
pathExists
})
).resolves.toBeNull()
await expect(
toHostReadableTranscriptPath(extendedUnc, {
platform: 'win32',
transcriptHost: { kind: 'wsl', distro: 'Debian' },
pathExists
})
).resolves.toBeNull()
await expect(
toHostReadableTranscriptPath(extendedUnc, {
platform: 'win32',
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
pathExists
})
).resolves.toBe(extendedUnc)
expect(pathExists).toHaveBeenCalledOnce()
expect(pathExists).toHaveBeenCalledWith(extendedUnc)
})
it('never probes the bare guest path on Windows', async () => {
// Why: Win32 resolves `/home/...` against the current drive (`C:\home\...`),
// so probing first could bind chat to a local look-alike file.
@@ -177,6 +273,57 @@ describe('wslCodexSessionsDirs', () => {
).resolves.toEqual([])
})
it('loads only the requested distro for targeted resolution', async () => {
const getWslHomeDir = vi.fn(async () => UBUNTU_HOME)
await expect(
wslCodexSessionsDirsForDistro('Ubuntu', { platform: 'win32', getWslHomeDir })
).resolves.toEqual([
`${UBUNTU_HOME}\\.local\\share\\orca\\codex-runtime-home\\home\\sessions`,
`${UBUNTU_HOME}\\.codex\\sessions`
])
expect(getWslHomeDir).toHaveBeenCalledWith('Ubuntu')
})
it('coalesces concurrent targeted home probes across agents', async () => {
let finish!: (home: string | null) => void
const pending = new Promise<string | null>((resolve) => (finish = resolve))
const getWslHomeDir = vi.fn(() => pending)
const codex = wslCodexSessionsDirsForDistro('Ubuntu', {
platform: 'win32',
getWslHomeDir
})
const claude = wslClaudeProjectsDirsForDistro('ubuntu', {
platform: 'win32',
getWslHomeDir
})
expect(getWslHomeDir).toHaveBeenCalledTimes(1)
finish(UBUNTU_HOME)
await expect(codex).resolves.toHaveLength(2)
await expect(claude).resolves.toEqual([`${UBUNTU_HOME}\\.claude\\projects`])
})
it('negatively caches a failed targeted home probe for the retry window', async () => {
vi.useFakeTimers()
try {
const getWslHomeDir = vi.fn(async () => null)
const load = (): Promise<string[]> =>
wslCodexSessionsDirsForDistro('Ubuntu', { platform: 'win32', getWslHomeDir })
await expect(load()).resolves.toEqual([])
await expect(load()).resolves.toEqual([])
expect(getWslHomeDir).toHaveBeenCalledTimes(1)
vi.setSystemTime(Date.now() + 30_001)
await expect(load()).resolves.toEqual([])
expect(getWslHomeDir).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('lists the managed and system Codex roots per distro home', async () => {
await expect(
wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs: async () => [UBUNTU_HOME] })
@@ -202,4 +349,13 @@ describe('wslClaudeProjectsDirs', () => {
})
).resolves.toEqual([`${UBUNTU_HOME}\\.claude\\projects`, `${DEBIAN_HOME}\\.claude\\projects`])
})
it('loads only the requested distro for targeted resolution', async () => {
const getWslHomeDir = vi.fn(async () => DEBIAN_HOME)
await expect(
wslClaudeProjectsDirsForDistro('Debian', { platform: 'win32', getWslHomeDir })
).resolves.toEqual([`${DEBIAN_HOME}\\.claude\\projects`])
expect(getWslHomeDir).toHaveBeenCalledWith('Debian')
})
})
@@ -5,6 +5,7 @@ import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/ws
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate'
import type { NativeChatTranscriptHost } from './transcript-host'
/**
* True for guest-absolute Linux paths that Win32 cannot open as-is.
@@ -34,8 +35,11 @@ export type HostReadableTranscriptPathDeps = {
platform?: NodeJS.Platform
pathExists?: (path: string) => Promise<boolean>
signal?: AbortSignal
/** PTY-owned execution host. Undefined preserves legacy discovery. */
transcriptHost?: NativeChatTranscriptHost
/** Each installed WSL distro's `$HOME` as a Windows UNC path. */
listWslHomeDirs?: () => Promise<string[]>
getWslHomeDir?: (distro: string) => Promise<string | null>
}
// Why: candidates are `\\wsl.localhost` UNC paths served over 9P. A sync probe
@@ -69,6 +73,8 @@ const WSL_HOME_DIRS_TTL_MS = 5 * 60_000
let cachedWslHomeDirs: string[] | null = null
let cachedWslHomeDirsExpiresAt = 0
let inflightWslHomeDirs: Promise<string[]> | null = null
const cachedWslHomeByDistro = new Map<string, { home: string | null; expiresAt: number }>()
const inflightWslHomeByDistro = new Map<string, Promise<string | null>>()
async function defaultListWslHomeDirs(): Promise<string[]> {
const homes = await Promise.all(
@@ -100,6 +106,8 @@ export function resetHostReadableTranscriptPathCacheForTests(): void {
cachedWslHomeDirs = null
cachedWslHomeDirsExpiresAt = 0
inflightWslHomeDirs = null
cachedWslHomeByDistro.clear()
inflightWslHomeByDistro.clear()
}
/**
@@ -126,6 +134,21 @@ export async function toHostReadableTranscriptPath(
const pathExists =
deps.pathExists ?? ((candidate: string) => pathExistsAsync(candidate, deps.signal))
const platform = deps.platform ?? process.platform
if (platform === 'win32' && deps.transcriptHost) {
const unc = parseWslUncPath(path)
if (unc) {
if (
deps.transcriptHost.kind === 'host' ||
unc.distro.toLowerCase() !== deps.transcriptHost.distro.trim().toLowerCase()
) {
return null
}
return (await pathExists(path)) ? path : null
}
if (deps.transcriptHost.kind === 'wsl' && !needsWslHostTranslation(path, platform)) {
return null
}
}
// Why: classify BEFORE probing — Win32 resolves a bare `/home/…` against the
// current drive (`C:\home\…`), so a probe first could bind chat to a local
// look-alike file instead of the real WSL transcript.
@@ -133,6 +156,18 @@ export async function toHostReadableTranscriptPath(
return (await pathExists(path)) ? path : null
}
if (deps.transcriptHost?.kind === 'host') {
return null
}
if (deps.transcriptHost?.kind === 'wsl') {
const distro = deps.transcriptHost.distro.trim()
if (!distro) {
return null
}
const uncPath = toWindowsWslPath(path, distro)
return (await pathExists(uncPath)) ? uncPath : null
}
const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs)
// Sequential on purpose: the ranked order picks the owning distro, and probing
// every distro at once would fan out 9P calls to ones the user left stopped.
@@ -182,6 +217,20 @@ export async function wslCodexSessionsDirs(
])
}
/** Codex roots for one PTY-proven WSL distro, without enumerating others. */
export async function wslCodexSessionsDirsForDistro(
distro: string,
deps: Pick<HostReadableTranscriptPathDeps, 'platform' | 'getWslHomeDir'> = {}
): Promise<string[]> {
const home = await wslHomeDirForDistro(distro, deps)
return home
? [
joinUnderWslHome(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS, 'sessions'),
joinUnderWslHome(home, '.codex', 'sessions')
]
: []
}
/** Each WSL distro's Claude projects root, searched after the host root misses. */
export async function wslClaudeProjectsDirs(
deps: Pick<HostReadableTranscriptPathDeps, 'platform' | 'listWslHomeDirs'> = {}
@@ -194,6 +243,50 @@ export async function wslClaudeProjectsDirs(
return homeDirs.map((home) => joinUnderWslHome(home, '.claude', 'projects'))
}
/** Claude projects root for one PTY-proven WSL distro. */
export async function wslClaudeProjectsDirsForDistro(
distro: string,
deps: Pick<HostReadableTranscriptPathDeps, 'platform' | 'getWslHomeDir'> = {}
): Promise<string[]> {
const home = await wslHomeDirForDistro(distro, deps)
return home ? [joinUnderWslHome(home, '.claude', 'projects')] : []
}
async function wslHomeDirForDistro(
distro: string,
deps: Pick<HostReadableTranscriptPathDeps, 'platform' | 'getWslHomeDir'>
): Promise<string | null> {
if ((deps.platform ?? process.platform) !== 'win32') {
return null
}
const normalizedDistro = distro.trim()
if (!normalizedDistro) {
return null
}
const key = normalizedDistro.toLowerCase()
const cached = cachedWslHomeByDistro.get(key)
if (cached && Date.now() < cached.expiresAt) {
return cached.home
}
const inflight = inflightWslHomeByDistro.get(key)
if (inflight) {
return inflight
}
const load = deps.getWslHomeDir ?? getWslHomeAsync
const request = load(normalizedDistro)
.catch(() => null)
.then((home) => {
cachedWslHomeByDistro.set(key, {
home,
expiresAt: Date.now() + (home ? WSL_HOME_DIRS_TTL_MS : WSL_HOME_DIRS_EMPTY_RETRY_MS)
})
return home
})
.finally(() => inflightWslHomeByDistro.delete(key))
inflightWslHomeByDistro.set(key, request)
return request
}
// Why: node:path.join is posix-flavoured off Windows and would mangle the
// `\\wsl.localhost\` share prefix these roots must keep.
function joinUnderWslHome(home: string, ...segments: string[]): string {
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from 'vitest'
import { toHostReadableTranscriptPath } from './host-readable-transcript-path'
const GUEST_PATH = '/home/ada/.codex/sessions/rollout.jsonl'
const DEBIAN_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\rollout.jsonl'
const UBUNTU_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl'
const HOMES = ['\\\\wsl.localhost\\Debian\\home\\ada', '\\\\wsl.localhost\\Ubuntu\\home\\ada']
describe('native-chat PTY provenance oracle', () => {
it('routes known WSL provenance directly to its owning distro', async () => {
const enumerate = vi.fn(async () => HOMES)
const probe = vi.fn(async () => true)
await expect(
toHostReadableTranscriptPath(GUEST_PATH, {
platform: 'win32',
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
listWslHomeDirs: enumerate,
pathExists: probe
})
).resolves.toBe(UBUNTU_PATH)
expect(enumerate).not.toHaveBeenCalled()
expect(probe).toHaveBeenCalledWith(UBUNTU_PATH)
})
it('keeps host provenance out of WSL', async () => {
const enumerate = vi.fn(async () => HOMES)
const probe = vi.fn(async () => true)
await expect(
toHostReadableTranscriptPath(GUEST_PATH, {
platform: 'win32',
transcriptHost: { kind: 'host' },
listWslHomeDirs: enumerate,
pathExists: probe
})
).resolves.toBeNull()
expect(enumerate).not.toHaveBeenCalled()
expect(probe).not.toHaveBeenCalled()
})
it('preserves broad recovery only when provenance is unavailable', async () => {
await expect(
toHostReadableTranscriptPath(GUEST_PATH, {
platform: 'win32',
listWslHomeDirs: async () => HOMES,
pathExists: async () => true
})
).resolves.toBe(DEBIAN_PATH)
})
it('rejects a WSL device path for host provenance before probing', async () => {
const extended = '\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl'
const probe = vi.fn(async () => true)
await expect(
toHostReadableTranscriptPath(extended, {
platform: 'win32',
transcriptHost: { kind: 'host' },
pathExists: probe
})
).resolves.toBeNull()
expect(probe).not.toHaveBeenCalled()
})
})
@@ -92,6 +92,79 @@ afterEach(() => {
})
describe('resolveSessionFilePath on a Windows host with WSL', () => {
it('routes an exact hook path through only the authoritative PTY distro', async () => {
await expect(
resolveSessionFilePath('codex', 'ignored', {
transcriptPath: ROLLOUT_LINUX,
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }
})
).resolves.toBe(ROLLOUT_UNC)
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled()
})
it('does not search WSL when authoritative PTY provenance says host', async () => {
const options = { transcriptHost: { kind: 'host' as const } }
await expect(resolveSessionFilePath('claude', 'missing', options)).resolves.toBeNull()
expect(scanned.dirs).toHaveLength(1)
expect(scanned.dirs[0]).not.toMatch(/^\\\\wsl/)
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled()
})
it('searches only the authoritative PTY distro for a WSL Claude session', async () => {
vi.mocked(listWslDistrosAsync).mockResolvedValueOnce(['Missing', 'Ubuntu'])
scanned.wslClaudeTranscriptDir = WSL_CLAUDE_PROJECTS_DIR
const options = { transcriptHost: { kind: 'wsl' as const, distro: 'Ubuntu' } }
await expect(resolveSessionFilePath('claude', 'claude-wsl-sess', options)).resolves.toBe(
WSL_CLAUDE_TRANSCRIPT
)
expect(scanned.dirs).toEqual([WSL_CLAUDE_PROJECTS_DIR])
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
expect(vi.mocked(getWslHomeAsync)).toHaveBeenCalledWith('Ubuntu')
})
it('applies authoritative PTY routing to Codex without broad discovery', async () => {
await expect(
resolveSessionFilePath('codex', 'missing', {
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }
})
).resolves.toBeNull()
expect(scanned.dirs).toEqual([
`${UBUNTU_HOME}\\.local\\share\\orca\\codex-runtime-home\\home\\sessions`,
`${UBUNTU_HOME}\\.codex\\sessions`
])
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
})
it('does not search WSL for a host-owned Codex miss', async () => {
await expect(
resolveSessionFilePath('codex', 'missing', { transcriptHost: { kind: 'host' } })
).resolves.toBeNull()
expect(scanned.dirs.some((dir) => dir.startsWith('\\\\wsl.localhost\\'))).toBe(false)
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
})
it.each(['grok', 'omp'] as const)(
'does not bind a host %s transcript to a WSL-owned PTY',
async (agent) => {
await expect(
resolveSessionFilePath(agent, 'same-id', {
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }
})
).resolves.toBeNull()
expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled()
}
)
it('resolves a Claude transcript from WSL when no hook path is known', async () => {
scanned.wslClaudeTranscriptDir = WSL_CLAUDE_PROJECTS_DIR
+42 -4
View File
@@ -14,10 +14,13 @@ import {
import {
toHostReadableTranscriptPath,
wslClaudeProjectsDirs,
wslCodexSessionsDirs
wslClaudeProjectsDirsForDistro,
wslCodexSessionsDirs,
wslCodexSessionsDirsForDistro
} from './host-readable-transcript-path'
import { findWslCodexSessionPath } from './wsl-codex-session-path-scan'
import { findWslSessionPath } from './wsl-session-path-scan'
import type { NativeChatTranscriptHost } from './transcript-host'
// Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads
// run in the main process against the runtime's own home directory; over SSH
@@ -73,6 +76,8 @@ export type ResolveSessionFileOptions = {
* directly — recent Claude Code names the transcript with a UUID that differs
* from the hook session_id, so the id-based glob below would miss it. */
transcriptPath?: string
/** PTY-owned execution host. Undefined preserves legacy discovery. */
transcriptHost?: NativeChatTranscriptHost
}
/**
@@ -102,7 +107,10 @@ export async function resolveSessionFilePath(
// stale/missing paths fall through to the id-based search.
const hookPath = options.transcriptPath?.trim()
if (hookPath && extname(hookPath) === '.jsonl') {
const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal })
const hostReadable = await toHostReadableTranscriptPath(hookPath, {
signal,
transcriptHost: options.transcriptHost
})
if (hostReadable) {
return hostReadable
}
@@ -115,33 +123,63 @@ export async function resolveSessionFilePath(
if (transcriptAgent === 'claude') {
const overrideDir = options.claudeProjectsDir
if (!overrideDir && options.transcriptHost?.kind === 'wsl') {
const projectsDirs = await wslClaudeProjectsDirsForDistro(options.transcriptHost.distro)
signal?.throwIfAborted()
return resolveClaudeSessionFileInDirs(trimmedId, projectsDirs, signal)
}
return resolveClaudeSessionFile(
trimmedId,
overrideDir ?? claudeProjectsDir(),
overrideDir ? undefined : wslClaudeProjectsDirs,
overrideDir || options.transcriptHost?.kind === 'host' ? undefined : wslClaudeProjectsDirs,
signal
)
}
if (transcriptAgent === 'codex') {
const overrideDirs = options.codexSessionsDirs
if (!overrideDirs && options.transcriptHost?.kind === 'wsl') {
const sessionsDirs = await wslCodexSessionsDirsForDistro(options.transcriptHost.distro)
signal?.throwIfAborted()
return findCodexRolloutInDirs(trimmedId, sessionsDirs, signal)
}
return resolveCodexSessionFile(
trimmedId,
overrideDirs ?? codexSessionsDirs(),
// Why: enumerating WSL homes spawns wsl.exe per distro, which boots ones the
// user left stopped. Only pay that after this host's own Codex roots miss.
overrideDirs ? undefined : wslCodexSessionsDirs,
overrideDirs || options.transcriptHost?.kind === 'host' ? undefined : wslCodexSessionsDirs,
signal
)
}
if (transcriptAgent === 'grok') {
if (options.transcriptHost?.kind === 'wsl') {
return null
}
return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir(), signal)
}
if (transcriptAgent === 'omp') {
if (options.transcriptHost?.kind === 'wsl') {
return null
}
return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal)
}
return null
}
async function resolveClaudeSessionFileInDirs(
sessionId: string,
projectsDirs: string[],
signal?: AbortSignal
): Promise<string | null> {
for (const projectsDir of projectsDirs) {
const hit = await findClaudeTranscript(projectsDir, sessionId, signal)
if (hit) {
return hit
}
}
return null
}
async function resolveClaudeSessionFile(
sessionId: string,
projectsDir: string,
+1
View File
@@ -0,0 +1 @@
export type NativeChatTranscriptHost = { kind: 'host' } | { kind: 'wsl'; distro: string }
@@ -75,12 +75,17 @@ describe('native chat transcript resolve polling', () => {
agent: 'codex',
sessionId: 'session-id',
transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl',
transcriptHost: { kind: 'wsl', distro: 'Ubuntu' },
resolvePollIntervalMs: 10,
onAppend: () => {}
})
await vi.advanceTimersByTimeAsync(100)
expect(mocks.toHostReadable).toHaveBeenCalledTimes(1)
expect(mocks.toHostReadable).toHaveBeenCalledWith(
'/home/ada/.codex/sessions/rollout-session-id.jsonl',
expect.objectContaining({ transcriptHost: { kind: 'wsl', distro: 'Ubuntu' } })
)
expect(mocks.install.mock.calls.some(([filePath]) => String(filePath).startsWith('/'))).toBe(
false
)
+2 -1
View File
@@ -122,7 +122,8 @@ function subscribeViaResolvePoll(
// the current drive (`C:\home\…`) and bind chat to a look-alike file.
lastWslTranslateAt = Date.now()
hostReadableExactPath = await toHostReadableTranscriptPath(exactPath, {
signal: resolveController.signal
signal: resolveController.signal,
transcriptHost: args.transcriptHost
})
}
}
+31
View File
@@ -10983,6 +10983,37 @@ describe('OrcaRuntimeService', () => {
expect(cwds.get('pty-debian')).toBe('\\\\wsl.localhost\\Debian\\home\\me\\repo')
})
it('resolves native-chat transcript hosts from authoritative PTY context', () => {
const runtime = new OrcaRuntimeService(store)
runtime.preparePtyExecutionContext('pty-ubuntu', 'Ubuntu', { resetIncarnation: true })
runtime.registerPty('pty-ubuntu', TEST_WORKTREE_ID, null, undefined, true)
runtime.registerPty('pty-host', TEST_WORKTREE_ID, null, undefined, false)
runtime.registerPty('pty-unknown', TEST_WORKTREE_ID)
runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-1', undefined, false)
expect(runtime.resolveNativeChatTranscriptHost('pty-ubuntu')).toEqual({
kind: 'wsl',
distro: 'Ubuntu'
})
expect(runtime.resolveNativeChatTranscriptHost('pty-host')).toEqual({ kind: 'host' })
expect(runtime.resolveNativeChatTranscriptHost('pty-unknown')).toBeUndefined()
expect(runtime.resolveNativeChatTranscriptHost('pty-ssh')).toBeUndefined()
expect(runtime.resolveNativeChatTranscriptHost('missing')).toBeNull()
})
it('resolves paired-runtime terminal handles before transcript provenance', () => {
const runtime = new OrcaRuntimeService(store)
const handle = runtime.preAllocateHandleForPty('pty-ubuntu')
runtime.preparePtyExecutionContext('pty-ubuntu', 'Ubuntu', { resetIncarnation: true })
runtime.registerPty('pty-ubuntu', TEST_WORKTREE_ID, null, undefined, true)
expect(runtime.resolveNativeChatTranscriptHostForHandle(handle)).toEqual({
kind: 'wsl',
distro: 'Ubuntu'
})
expect(runtime.resolveNativeChatTranscriptHostForHandle('term_missing')).toBeNull()
})
it('does not retain WSL context when a PTY id is reused', () => {
setPlatform('win32')
const runtime = new OrcaRuntimeService(store)
+23
View File
@@ -1062,6 +1062,7 @@ import {
resolveNestedRepoSelection
} from '../project-groups/nested-repo-import'
import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target'
import type { NativeChatTranscriptHost } from '../native-chat/transcript-host'
function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string {
console.warn(`[project-groups] ${context}`, error)
@@ -3392,6 +3393,28 @@ export class OrcaRuntimeService {
return this.getLocalProviderFn ? this.getLocalProviderFn() : null
}
resolveNativeChatTranscriptHost(ptyId: string): NativeChatTranscriptHost | null | undefined {
const pty = this.ptysById.get(ptyId)
const distro = this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? null
if (distro) {
return { kind: 'wsl', distro }
}
if (pty?.connectionId === null && pty.isWsl === false) {
return { kind: 'host' }
}
return pty ? undefined : null
}
resolveNativeChatTranscriptHostForHandle(
handle: string
): NativeChatTranscriptHost | null | undefined {
const ptyId =
this.getLivePtyForHandle(handle)?.pty.ptyId ??
this.resolveLiveLeafForHandle(handle)?.ptyId ??
null
return ptyId ? this.resolveNativeChatTranscriptHost(ptyId) : null
}
private async stopPtysForDestructiveWorktreeRemoval(
worktreeId: string,
options: { connectionId?: string; allowUnverifiedStop?: boolean } = {}
@@ -0,0 +1,118 @@
import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types'
import type { RpcContext } from '../core'
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
export const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40
export const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
const MOBILE_BLOCK_CHAR_CAP = 4000
const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000
const MOBILE_TOOL_INPUT_ITEMS_CAP = 20
const MOBILE_TOOL_INPUT_NODE_CAP = 100
const TRUNCATION_MARKER = '\n… (truncated)'
function clip(text: string, cap: number): string {
return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text
}
function sanitizeBlock(
block: NativeChatBlock,
clientKind: RpcContext['clientKind']
): NativeChatBlock {
if (block.type === 'image-ref') {
return sanitizeNativeChatRpcImageBlock(block)
}
if (clientKind !== 'mobile') {
return block
}
if (block.type === 'text') {
return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP
? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) }
: block
}
if (block.type === 'tool-result') {
return block.output.length > MOBILE_BLOCK_CHAR_CAP
? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) }
: block
}
if (block.type === 'tool-call') {
const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP }
return { ...block, input: sanitizeToolInput(block.input, budget, 0) }
}
return block
}
function sanitizeToolInput(
value: unknown,
budget: { remaining: number; nodes: number },
depth: number
): unknown {
budget.nodes--
if (budget.nodes < 0 || budget.remaining <= 0) {
return '… (truncated)'
}
if (typeof value === 'string') {
const length = Math.min(value.length, budget.remaining)
budget.remaining -= length
return length < value.length ? `${value.slice(0, length)}… (truncated)` : value
}
if (!value || typeof value !== 'object' || depth >= 5) {
return value && typeof value === 'object' ? '… (truncated)' : value
}
if (Array.isArray(value)) {
const result = value
.slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP)
.map((item) => sanitizeToolInput(item, budget, depth + 1))
if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) {
result.push('… (truncated)')
}
return result
}
const result: Record<string, unknown> = {}
let count = 0
for (const key in value) {
if (!Object.hasOwn(value, key)) {
continue
}
if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) {
result['…'] = 'truncated'
break
}
let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128))
// Why: sibling keys sharing a truncated prefix must not silently collapse.
if (Object.hasOwn(result, boundedKey)) {
boundedKey = `${boundedKey}~${count}`
}
budget.remaining -= boundedKey.length
result[boundedKey] = sanitizeToolInput(
(value as Record<string, unknown>)[key],
budget,
depth + 1
)
count++
}
return result
}
function sanitizeMessage(
message: NativeChatMessage,
clientKind: RpcContext['clientKind']
): NativeChatMessage {
return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) }
}
export function sanitizeAppendForClient(
messages: readonly NativeChatMessage[],
clientKind: RpcContext['clientKind']
): NativeChatMessage[] {
return messages.map((message) => sanitizeMessage(message, clientKind))
}
export function windowForClient(
messages: readonly NativeChatMessage[],
clientKind: RpcContext['clientKind'],
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
): NativeChatMessage[] {
const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW)
const windowed = messages.length > window ? messages.slice(-window) : messages.slice()
return windowed.map((message) => sanitizeMessage(message, clientKind))
}
@@ -21,9 +21,13 @@ const cachedResult = vi.hoisted(() => ({
}
}
}))
const tailRead = vi.hoisted(() => ({ signal: undefined as AbortSignal | undefined }))
const tailRead = vi.hoisted(() => ({
args: undefined as undefined | { transcriptHost?: unknown },
signal: undefined as AbortSignal | undefined
}))
const watcher = vi.hoisted(() => ({
args: null as null | {
transcriptHost?: unknown
onInitialSnapshot?: (
messages: NativeChatMessage[],
hasMore: boolean,
@@ -63,7 +67,12 @@ const watcher = vi.hoisted(() => ({
unsubscribe: vi.fn()
}))
vi.mock('../../../native-chat/transcript-watch', () => ({
readNativeChatTranscriptTail: ({ limit }: { limit: number }, signal?: AbortSignal) => {
readNativeChatTranscriptTail: (
args: { limit: number; transcriptHost?: unknown },
signal?: AbortSignal
) => {
const { limit } = args
tailRead.args = args
tailRead.signal = signal
const messages = cachedResult.value.messages
return Promise.resolve({
@@ -140,7 +149,12 @@ function streamingContext(clientKind: RpcContext['clientKind']): RpcContext {
}
function ctxWith(clientKind: RpcContext['clientKind']): RpcContext {
return { runtime: {} as RpcContext['runtime'], clientKind }
return {
runtime: {
resolveNativeChatTranscriptHostForHandle: vi.fn()
} as unknown as RpcContext['runtime'],
clientKind
}
}
function firstOutput(result: unknown): string {
@@ -165,6 +179,27 @@ describe('nativeChat.readSession clientKind truncation gating', () => {
expect(tailRead.signal).toBe(controller.signal)
})
it('resolves terminal-handle provenance on the transcript-owning runtime', async () => {
const context = ctxWith('runtime')
const resolveHost = vi
.mocked(context.runtime.resolveNativeChatTranscriptHostForHandle)
.mockReturnValue({ kind: 'wsl', distro: 'Ubuntu' })
await readSessionHandler()({ agent: 'claude', sessionId: 's', ptyId: 'term-1' }, context)
expect(resolveHost).toHaveBeenCalledWith('term-1')
expect(tailRead.args?.transcriptHost).toEqual({ kind: 'wsl', distro: 'Ubuntu' })
})
it('fails closed when a supplied terminal handle is stale', async () => {
const context = ctxWith('runtime')
vi.mocked(context.runtime.resolveNativeChatTranscriptHostForHandle).mockReturnValue(null)
await expect(
readSessionHandler()({ agent: 'claude', sessionId: 'same-id', ptyId: 'term-stale' }, context)
).resolves.toEqual({ error: 'Transcript unavailable' })
})
it('clips oversized tool output for mobile clients', async () => {
cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
const result = await readSessionHandler()(
@@ -416,6 +451,39 @@ describe('nativeChat.readSession clientKind truncation gating', () => {
})
describe('nativeChat.subscribe initial snapshot', () => {
it('uses the same PTY provenance for the live watcher', async () => {
const context = streamingContext('runtime')
const resolveHost = vi.fn(() => ({ kind: 'wsl' as const, distro: 'Ubuntu' }))
context.runtime.resolveNativeChatTranscriptHostForHandle = resolveHost
await subscribeHandler()(
{ agent: 'claude', sessionId: 's', subscriptionId: 'sub-pty', ptyId: 'term-1' },
context,
vi.fn()
)
expect(resolveHost).toHaveBeenCalledWith('term-1')
expect(activeWatcherArgs().transcriptHost).toEqual({ kind: 'wsl', distro: 'Ubuntu' })
})
it('emits an error without watching when a terminal handle is stale', async () => {
const context = streamingContext('runtime')
context.runtime.resolveNativeChatTranscriptHostForHandle = vi.fn(() => null)
const emitted: unknown[] = []
watcher.args = null
await subscribeHandler()(
{ agent: 'claude', sessionId: 'same-id', ptyId: 'term-stale' },
context,
(value) => emitted.push(value)
)
expect(watcher.args).toBeNull()
expect(emitted).toEqual([
expect.objectContaining({ type: 'snapshot', error: 'Transcript unavailable' })
])
})
it('preserves long assistant text across mobile stream frame types', async () => {
watcher.watching = true
watcher.args = null
+32 -153
View File
@@ -1,17 +1,18 @@
import { z } from 'zod'
import type {
NativeChatBlock,
NativeChatMessage,
AgentType
} from '../../../../shared/native-chat-types'
import type { AgentType } from '../../../../shared/native-chat-types'
import {
readNativeChatTranscriptTail,
subscribeNativeChatTranscript,
type NativeChatTranscriptSubscription,
type SubscribeNativeChatTranscriptArgs
} from '../../../native-chat/transcript-watch'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
import {
MOBILE_NATIVE_CHAT_DEFAULT_WINDOW,
MOBILE_NATIVE_CHAT_MAX_WINDOW,
sanitizeAppendForClient,
windowForClient
} from './native-chat-rpc-payload'
// 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
@@ -48,6 +49,9 @@ const NativeChatSession = z.object({
// 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(),
// Optional for mixed-version compatibility; the host, not the client,
// resolves this PTY identity into filesystem provenance.
ptyId: z.string().min(1).optional(),
beforeOffset: z.number().int().nonnegative().optional()
})
@@ -55,162 +59,24 @@ const NativeChatUnsubscribe = z.object({
subscriptionId: z.string().min(1).optional()
})
// 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
// freezes the mobile app, so the runtime RPC windows to the most recent slice —
// the conversation tail is what the chat view shows first. The desktop IPC path
// is unaffected (it reads locally with a virtualized list).
// 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
// Why: a single tool result (a big file read, a long diff) can be hundreds of KB.
// The mobile view only previews tool block bodies, so truncate them on the wire
// to keep the payload small; the marker tells the user content was clipped.
const MOBILE_BLOCK_CHAR_CAP = 4000
// Why: text blocks are the message body itself, rendered in full by the chat
// view — a preview-sized cap cut long assistant replies mid-sentence with no way
// to read on (STA-3230). Keep only a generous safety ceiling: a transcript
// record can legally reach 2MB, and shipping that much markdown in one block
// would freeze the phone.
const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000
const MOBILE_TOOL_INPUT_ITEMS_CAP = 20
const MOBILE_TOOL_INPUT_NODE_CAP = 100
const TRUNCATION_MARKER = '\n… (truncated)'
function clip(text: string, cap: number): string {
return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text
}
function sanitizeBlock(
block: NativeChatBlock,
clientKind: RpcContext['clientKind']
): NativeChatBlock {
if (block.type === 'image-ref') {
return sanitizeNativeChatRpcImageBlock(block)
}
if (clientKind !== 'mobile') {
return block
}
if (block.type === 'text') {
return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP
? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) }
: block
}
if (block.type === 'tool-result') {
return block.output.length > MOBILE_BLOCK_CHAR_CAP
? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) }
: block
}
if (block.type === 'tool-call') {
const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP }
return { ...block, input: sanitizeToolInput(block.input, budget, 0) }
}
return block
}
function sanitizeToolInput(
value: unknown,
budget: { remaining: number; nodes: number },
depth: number
): unknown {
budget.nodes--
if (budget.nodes < 0 || budget.remaining <= 0) {
return '… (truncated)'
}
if (typeof value === 'string') {
const length = Math.min(value.length, budget.remaining)
budget.remaining -= length
return length < value.length ? `${value.slice(0, length)}… (truncated)` : value
}
if (!value || typeof value !== 'object' || depth >= 5) {
return value && typeof value === 'object' ? '… (truncated)' : value
}
if (Array.isArray(value)) {
const result = value
.slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP)
.map((item) => sanitizeToolInput(item, budget, depth + 1))
if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) {
result.push('… (truncated)')
}
return result
}
const result: Record<string, unknown> = {}
let count = 0
for (const key in value) {
if (!Object.hasOwn(value, key)) {
continue
}
if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) {
result['…'] = 'truncated'
break
}
let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128))
// Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse
// to the same bounded key; suffix collisions so neither field is silently lost.
if (Object.hasOwn(result, boundedKey)) {
boundedKey = `${boundedKey}~${count}`
}
budget.remaining -= boundedKey.length
result[boundedKey] = sanitizeToolInput(
(value as Record<string, unknown>)[key],
budget,
depth + 1
)
count++
}
return result
}
function sanitizeMessage(
message: NativeChatMessage,
clientKind: RpcContext['clientKind']
): NativeChatMessage {
return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) }
}
function sanitizeAppendForClient(
messages: readonly NativeChatMessage[],
clientKind: RpcContext['clientKind']
): NativeChatMessage[] {
return messages.map((message) => sanitizeMessage(message, clientKind))
}
/** Window a transcript to its most recent `limit` messages so a long session
* can't freeze the client. Windowing by count applies to ALL RPC clients —
* shipping thousands of turns over the paired link is bad for web and mobile
* alike. Char-clipping (the mobile-only payload diet) is applied separately. */
function windowTranscript(
messages: readonly NativeChatMessage[],
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
): NativeChatMessage[] {
const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW)
return messages.length > window ? messages.slice(-window) : messages.slice()
}
/** Apply the windowed slice and keep inline image bytes off every RPC transport.
* Mobile clients additionally receive bounded text and tool bodies; runtime
* clients keep those bodies intact. */
function windowForClient(
messages: readonly NativeChatMessage[],
clientKind: RpcContext['clientKind'],
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
): NativeChatMessage[] {
const windowed = windowTranscript(messages, limit)
return windowed.map((message) => sanitizeMessage(message, clientKind))
}
export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
defineMethod({
name: 'nativeChat.readSession',
params: NativeChatSession,
handler: async (params, { clientKind, signal }) => {
handler: async (params, { runtime, clientKind, signal }) => {
const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
const transcriptHost = params.ptyId
? runtime.resolveNativeChatTranscriptHostForHandle(params.ptyId)
: undefined
if (transcriptHost === null) {
return { error: 'Transcript unavailable' }
}
const result = await readNativeChatTranscriptTail(
{
agent: params.agent,
sessionId: params.sessionId,
transcriptPath: params.transcriptPath,
transcriptHost,
limit,
beforeOffset: params.beforeOffset
},
@@ -246,6 +112,18 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}`
const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}`
const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
const transcriptHost = params.ptyId
? runtime.resolveNativeChatTranscriptHostForHandle(params.ptyId)
: undefined
if (transcriptHost === null) {
emit({
type: 'snapshot',
messages: [],
hasMore: false,
error: 'Transcript unavailable'
})
return
}
const cleanup = (): void => {
if (closed) {
return
@@ -272,6 +150,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
agent: params.agent,
sessionId: params.sessionId,
transcriptPath: params.transcriptPath,
transcriptHost,
initialLimit: limit,
onInitialSnapshot: (messages, hasMore, beforeOffset, error, lifecycle) => {
if (closed) {
+4 -1
View File
@@ -911,6 +911,8 @@ export type NativeChatSubscribeArgs = {
sessionId: string
/** Authoritative transcript path from the agent hook (providerSession). */
transcriptPath?: string
/** PTY identity resolved to transcript provenance by its owning host. */
ptyId?: string
/** First snapshot size; later readSession calls grow this for pagination. */
limit?: number
}
@@ -922,7 +924,8 @@ export type NativeChatApi = {
agent: AgentType,
sessionId: string,
limit?: number,
transcriptPath?: string
transcriptPath?: string,
ptyId?: string
) => Promise<NativeChatReadSessionResult>
/** Live-tail a transcript. The first frame is a bounded race-safe snapshot;
* later frames contain only newly appended messages. */
+10 -2
View File
@@ -4276,9 +4276,16 @@ const api = {
agent: AgentType,
sessionId: string,
limit?: number,
transcriptPath?: string
transcriptPath?: string,
ptyId?: string
): Promise<NativeChatReadSessionResult> =>
ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }),
ipcRenderer.invoke('nativeChat:readSession', {
agent,
sessionId,
limit,
transcriptPath,
...(ptyId ? { ptyId } : {})
}),
/** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */
subscribe: (
args: {
@@ -4286,6 +4293,7 @@ const api = {
agent: AgentType
sessionId: string
transcriptPath?: string
ptyId?: string
limit?: number
},
onFrame: (frame: NativeChatSubscriptionFrame) => void
@@ -127,6 +127,7 @@ function NativeChatResolvedView({
agent,
sessionId,
transcriptPath,
ptyId: targetPtyId,
runtimeEnvironmentId,
enabled: isVisible
})
@@ -11,6 +11,7 @@ import {
RUNTIME_NATIVE_CHAT_RECONNECT_MS,
toRuntimeNativeChatErrorMessage
} from './native-chat-session-transport'
import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
const nativeChatReadSession = vi.fn()
const nativeChatSubscribe = vi.fn()
@@ -66,6 +67,15 @@ describe('getNativeChatSessionTransport — selection', () => {
expect(runtimeEnvironmentsCall).not.toHaveBeenCalled()
})
it('forwards PTY provenance identity through the local adapter', async () => {
nativeChatReadSession.mockResolvedValue({ messages: [] })
const transport = getNativeChatSessionTransport(null)
await transport.readSession('claude', 'sess-1', 40, undefined, 'pty-1')
expect(nativeChatReadSession).toHaveBeenCalledWith('claude', 'sess-1', 40, undefined, 'pty-1')
})
it('returns the runtime adapter for an owner on the desktop client', async () => {
markRuntimeEnvironmentCompatible(ENV)
runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] }))
@@ -82,6 +92,47 @@ describe('getNativeChatSessionTransport — selection', () => {
expect(nativeChatReadSession).not.toHaveBeenCalled()
})
it('forwards PTY provenance identity to the owning runtime', async () => {
markRuntimeEnvironmentCompatible(ENV)
runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] }))
const transport = getNativeChatSessionTransport(ENV)
await transport.readSession(
'claude',
'sess-1',
40,
undefined,
toRemoteRuntimePtyId('pty-host-1', ENV)
)
expect(runtimeEnvironmentsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ ptyId: 'pty-host-1' })
})
)
})
it('routes a scoped PTY to its encoded owner during an owner flip', async () => {
markRuntimeEnvironmentCompatible('env-a')
runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] }))
const transport = getNativeChatSessionTransport('env-b')
await transport.readSession(
'claude',
'sess-1',
40,
undefined,
toRemoteRuntimePtyId('term-a', 'env-a')
)
expect(runtimeEnvironmentsCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'env-a',
params: expect.objectContaining({ ptyId: 'term-a' })
})
)
})
it('validates lifecycle metadata on runtime read responses', async () => {
markRuntimeEnvironmentCompatible(ENV)
const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const
@@ -206,6 +257,51 @@ describe('runtime subscribe', () => {
})
})
it('forwards PTY provenance identity to the runtime stream', async () => {
markRuntimeEnvironmentCompatible(ENV)
stubSubscribe()
const transport = getNativeChatSessionTransport(ENV)
transport.subscribe(
{
subscriptionId: 's-pty',
agent: 'claude',
sessionId: 'sess-1',
ptyId: toRemoteRuntimePtyId('pty-host-1', ENV)
},
vi.fn()
)
await Promise.resolve()
expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ ptyId: 'pty-host-1' })
}),
expect.any(Object)
)
})
it('subscribes against the scoped PTY owner during an owner flip', async () => {
stubSubscribe()
const transport = getNativeChatSessionTransport('env-b')
transport.subscribe(
{
subscriptionId: 's-owner-flip',
agent: 'claude',
sessionId: 'sess-1',
ptyId: toRemoteRuntimePtyId('term-a', 'env-a')
},
vi.fn()
)
await Promise.resolve()
expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-a' }),
expect.any(Object)
)
})
it('validates lifecycle metadata on runtime stream frames', async () => {
markRuntimeEnvironmentCompatible(ENV)
const { deliver } = stubSubscribe()
@@ -1,16 +1,13 @@
import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types'
import { isWebClientLocation } from '@/lib/web-client-location'
import {
callRuntimeRpc,
RuntimeRpcCallError,
type RuntimeClientTarget
} from '@/runtime/runtime-rpc-client'
import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client'
import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat'
import {
parseRuntimeNativeChatReadSessionResult,
parseRuntimeNativeChatTurnLifecycle,
RUNTIME_NATIVE_CHAT_READ_ERROR
} from './native-chat-runtime-contract'
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
/** The read/subscribe surface the live-session hook needs, decoupled from where
* the transcript actually lives. Same shape as `window.api.nativeChat`, so the
@@ -43,21 +40,39 @@ export function toRuntimeNativeChatErrorMessage(err: unknown): string {
* using this adapter (R3). Preserves whatever `subscribe` returns (sync fn on
* desktop, promise on the web bridge) — the hook's teardown handles both (R6). */
const localNativeChatTransport: NativeChatSessionTransport = {
readSession: (agent, sessionId, limit, transcriptPath) =>
window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath),
readSession: (agent, sessionId, limit, transcriptPath, ptyId) =>
ptyId
? window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath, ptyId)
: window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath),
subscribe: (args, onFrame) => window.api.nativeChat.subscribe(args, onFrame)
}
function createRuntimeNativeChatTransport(environmentId: string): NativeChatSessionTransport {
const target: RuntimeClientTarget = { kind: 'environment', environmentId }
const route = (
ptyId: string | undefined
): { target: { kind: 'environment'; environmentId: string }; hostPtyId?: string } => {
const parsed = ptyId ? parseRemoteRuntimePtyId(ptyId) : null
const targetEnvironmentId = parsed?.environmentId?.trim() || environmentId
return {
target: { kind: 'environment', environmentId: targetEnvironmentId },
...(ptyId ? { hostPtyId: parsed?.handle || ptyId } : {})
}
}
return {
readSession: async (agent, sessionId, limit, transcriptPath) => {
readSession: async (agent, sessionId, limit, transcriptPath, ptyId) => {
try {
const { target, hostPtyId } = route(ptyId)
const result = await callRuntimeRpc<unknown>(
target,
'nativeChat.readSession',
{ agent, sessionId, limit, transcriptPath },
{
agent,
sessionId,
limit,
transcriptPath,
...(hostPtyId ? { ptyId: hostPtyId } : {})
},
{ timeoutMs: 15_000 }
)
return parseRuntimeNativeChatReadSessionResult(result)
@@ -66,7 +81,9 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
}
},
subscribe: (args, onFrame) => {
const { subscriptionId, agent, sessionId, transcriptPath, limit } = args
const { subscriptionId, agent, sessionId, transcriptPath, ptyId, limit } = args
const { target, hostPtyId } = route(ptyId)
const targetEnvironmentId = target.environmentId
let cancelled = false
let receivedInitial = false
let handleUnsubscribe: (() => void) | null = null
@@ -103,9 +120,16 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
void window.api.runtimeEnvironments
.subscribe(
{
selector: environmentId,
selector: targetEnvironmentId,
method: 'nativeChat.subscribe',
params: { subscriptionId, agent, sessionId, transcriptPath, limit },
params: {
subscriptionId,
agent,
sessionId,
transcriptPath,
...(hostPtyId ? { ptyId: hostPtyId } : {}),
limit
},
timeoutMs: 15_000
},
{
@@ -137,6 +137,41 @@ describe('useNativeChatLiveSession — transport routing', () => {
expect(transport.subscribe).toHaveBeenCalledOnce()
})
it('rebinds reads and subscriptions when the owning PTY changes', async () => {
const root = await render({
paneKey: PANE,
agent: AGENT,
sessionId: SESSION,
ptyId: 'pty-1',
runtimeEnvironmentId: 'env-1'
})
const transport = getMockTransport('env-1')
expect(transport.readSession).toHaveBeenLastCalledWith(
AGENT,
SESSION,
NATIVE_CHAT_INITIAL_LIMIT,
undefined,
'pty-1'
)
expect(transport.subscribe.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ ptyId: 'pty-1' })
)
await rerender(root, {
paneKey: PANE,
agent: AGENT,
sessionId: SESSION,
ptyId: 'pty-2',
runtimeEnvironmentId: 'env-1'
})
expect(transport.unsubscribe).toHaveBeenCalledOnce()
expect(transport.subscribe.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ ptyId: 'pty-2' })
)
})
it('re-subscribes against the new owner on an owner flip (R5)', async () => {
const root = await render({
paneKey: PANE,
@@ -31,6 +31,8 @@ export type UseNativeChatLiveSessionArgs = {
sessionId: string | null
/** Authoritative transcript path from the hook, preferred over reconstructing it from sessionId. Null when not reported. */
transcriptPath?: string | null
/** PTY identity used by the owning host to resolve transcript provenance. */
ptyId?: string | null
/** Runtime owner (Model B): non-null routes read/subscribe to the remote host; null keeps the local IPC path. */
runtimeEnvironmentId?: string | null
/** False suspends transcript IO while retaining the last committed session. */
@@ -122,7 +124,8 @@ export function useNativeChatLiveSession(
runtimeEnvironmentId ?? null,
agent,
sessionId,
transcriptPath ?? null
transcriptPath ?? null,
args.ptyId ?? null
])
const retainedSourceKeyRef = useRef(sourceKey)
@@ -175,7 +178,13 @@ export function useNativeChatLiveSession(
return
}
void transport
.readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined)
.readSession(
agent,
activeSessionId,
limitRef.current,
transcriptPath ?? undefined,
args.ptyId ?? undefined
)
.then((result) => {
if (cancelled || !latestEnabled.current || frameArrived) {
return
@@ -211,6 +220,7 @@ export function useNativeChatLiveSession(
agent,
sessionId,
transcriptPath: transcriptPath ?? undefined,
ptyId: args.ptyId ?? undefined,
limit: limitRef.current
},
(frame) => {
@@ -246,7 +256,16 @@ export function useNativeChatLiveSession(
closeStream()
}
// `transport` identity changes on an owner flip, re-running this effect to re-subscribe against the new host.
}, [agent, enabled, sessionId, sourceKey, transcriptPath, transport, transcriptLifecycleControl])
}, [
agent,
enabled,
args.ptyId,
sessionId,
sourceKey,
transcriptPath,
transport,
transcriptLifecycleControl
])
const loadEarlier = useCallback(() => {
if (
@@ -263,7 +282,13 @@ export function useNativeChatLiveSession(
const lifecycleRevision = transcriptLifecycleControl.revision()
setLoadingEarlier(true)
void transport
.readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined)
.readSession(
agent,
sessionId,
nextLimit,
transcriptPath ?? undefined,
args.ptyId ?? undefined
)
.then((result) => {
// Ignore a stale resolve from a swapped session or flipped owner — either would paint the wrong host's history.
if (
@@ -296,6 +321,7 @@ export function useNativeChatLiveSession(
agent,
sessionId,
transcriptPath,
args.ptyId,
transport,
hasMore,
loadingEarlier,
@@ -19,7 +19,8 @@ export function useNativeChatRetainedSession(
args.runtimeEnvironmentId ?? null,
args.agent,
args.sessionId,
args.transcriptPath ?? null
args.transcriptPath ?? null,
args.ptyId ?? null
])
const activeIdentityRef = useRef(identity)
const retentionRef = useRef(createNativeChatTranscriptRetention())
@@ -1536,6 +1536,62 @@ describe('web native chat preload API', () => {
}
])
})
it('sends the host PTY handle instead of the client-scoped runtime id', async () => {
const requests: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params: unknown): Promise<RuntimeRpcResponse<unknown>> {
requests.push({ method, params })
return Promise.resolve({
id: 'read-pty',
ok: true,
result: { messages: [] },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await globals.window.api.nativeChat.readSession(
'claude',
'session-1',
40,
undefined,
'remote:web-env-1@@pty-host-1'
)
expect(requests).toEqual([
{
method: 'nativeChat.readSession',
params: expect.objectContaining({ ptyId: 'pty-host-1' })
}
])
})
it('fails closed when a scoped PTY names another runtime owner', async () => {
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage, 'web-env-1')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(
globals.window.api.nativeChat.readSession(
'claude',
'same-id',
40,
undefined,
'remote:other-env@@term-1'
)
).rejects.toThrow('Unknown Orca runtime environment: other-env')
})
})
describe('web MiniMax preload API', () => {
+25 -7
View File
@@ -77,6 +77,7 @@ import {
} from '../../../shared/execution-host'
import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector'
import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call'
import { parseRemoteRuntimePtyId } from '../runtime/runtime-terminal-stream'
import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection'
import {
normalizeTuiAgentArgsRecord,
@@ -1242,15 +1243,27 @@ function createWebKeybindingsApi(): WebKeybindingsApi {
// Why: web has no IPC for native-chat transcripts, so route readSession/subscribe through runtime RPC (as mobile does).
function createNativeChatApi(): NativeChatApi {
return {
readSession: async (agent, sessionId, limit, transcriptPath) =>
parseRuntimeNativeChatReadSessionResult(
await callRuntimeResult<unknown>('nativeChat.readSession', {
readSession: async (agent, sessionId, limit, transcriptPath, ptyId) => {
const parsedPty = ptyId ? parseRemoteRuntimePtyId(ptyId) : null
const environment = parsedPty?.environmentId
? resolveEnvironment(parsedPty.environmentId)
: requireActiveEnvironment()
const response = await callEnvironmentEnvelope<unknown>(
environment.id,
'nativeChat.readSession',
{
agent,
sessionId,
limit,
transcriptPath
})
),
transcriptPath,
...(ptyId ? { ptyId: parsedPty?.handle || ptyId } : {})
}
)
if (!response.ok) {
throw new Error(response.error.message)
}
return parseRuntimeNativeChatReadSessionResult(response.result)
},
subscribe: (args, onFrame) => {
// No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed.
const environment = requireActiveEnvironmentOrNull()
@@ -1266,10 +1279,14 @@ function createNativeChatApi(): NativeChatApi {
})
return () => {}
}
const parsedPty = args.ptyId ? parseRemoteRuntimePtyId(args.ptyId) : null
const targetEnvironment = parsedPty?.environmentId
? resolveEnvironment(parsedPty.environmentId)
: environment
let handle: { unsubscribe: () => void } | null = null
let cancelled = false
let receivedInitial = false
void getClientForEnvironment(environment)
void getClientForEnvironment(targetEnvironment)
.subscribe(
'nativeChat.subscribe',
{
@@ -1277,6 +1294,7 @@ function createNativeChatApi(): NativeChatApi {
sessionId: args.sessionId,
subscriptionId: args.subscriptionId,
transcriptPath: args.transcriptPath,
...(args.ptyId ? { ptyId: parsedPty?.handle || args.ptyId } : {}),
limit: args.limit
},
{
+4
View File
@@ -16,6 +16,10 @@ describe('wsl path helpers', () => {
distro: 'Debian',
linuxPath: '/home/jin'
})
expect(parseWslUncPath('\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\jin')).toEqual({
distro: 'Ubuntu',
linuxPath: '/home/jin'
})
})
it('rejects ordinary Windows and POSIX paths', () => {
+1 -1
View File
@@ -5,7 +5,7 @@ export type WslUncPathInfo = {
export function parseWslUncPath(path: string): WslUncPathInfo | null {
const normalized = path.replace(/\\/g, '/')
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i)
const match = normalized.match(/^(?:\/\/|\/\/\?\/UNC\/)(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i)
if (!match) {
return null
}