From 258d3b375aca3578663333b146dfc116594cb159 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:03:30 +0000 Subject: [PATCH 01/29] perf(mobile): brotli-compress the page build (#40532) --- .../build-mobile-web-app-bundle.mjs | 9 +- .../mobile-web-bundle-fixture-tree.mjs | 9 +- .../mobile-web-bundle-manifest.mjs | 45 ++++++- .../verify-packaged-mobile-web-bundle.cjs | 52 ++++++-- ...verify-packaged-mobile-web-bundle.test.mjs | 24 +++- .../orca-gzip-utf8-codec/android/build.gradle | 2 + .../orcagziputf8codec/BoundedBrotli.kt | 31 +++++ .../OrcaGzipUtf8CodecModule.kt | 5 + .../ios/BoundedBrotli.swift | 28 +++++ .../ios/OrcaGzipUtf8Codec.podspec | 4 +- .../ios/OrcaGzipUtf8CodecModule.swift | 5 + .../modules/orca-gzip-utf8-codec/src/index.ts | 2 + .../paired-e2ee-native-codec.test.ts | 4 +- .../encryption/paired-e2ee-native-codec.ts | 6 +- .../mobile-web-bundle-brotli-fetch.test.ts | 116 ++++++++++++++++++ .../web-bundle/mobile-web-bundle-fetch.ts | 66 +++++++--- .../mobile-web-bundle-operations.ts | 11 ++ .../mobile-web-bundle-reply-schemas.ts | 16 ++- .../mobile-web-bundle-window-checks.ts | 7 +- .../mobile-web-bundle-window-reader.ts | 82 +++++++++++-- .../mobile-web-bundle-asset-reader.ts | 2 +- .../mobile-web-bundle-range.test.ts | 69 ++++++++++- .../mobile-web-bundle.test-fixture.ts | 14 ++- .../mobile-delivery/mobile-web-bundle.ts | 50 ++++++-- ...ntime-rpc-mobile-paired-desktop-methods.ts | 1 + .../mobile-web-bundle/bundle-rpc-contract.ts | 16 ++- .../mobile-web-bundle/manifest-contract.ts | 15 ++- src/shared/orcad-artifacts.test.ts | 11 ++ src/shared/orcad-artifacts.ts | 11 +- .../rpc-params-catalog.generated.ts | 1 + 30 files changed, 641 insertions(+), 73 deletions(-) create mode 100644 mobile/modules/orca-gzip-utf8-codec/android/src/main/java/expo/modules/orcagziputf8codec/BoundedBrotli.kt create mode 100644 mobile/modules/orca-gzip-utf8-codec/ios/BoundedBrotli.swift create mode 100644 mobile/src/transport/web-bundle/mobile-web-bundle-brotli-fetch.test.ts diff --git a/config/scripts/build/mobile-web-app/build-mobile-web-app-bundle.mjs b/config/scripts/build/mobile-web-app/build-mobile-web-app-bundle.mjs index f5e4c5d7ae7..605d2ac7816 100644 --- a/config/scripts/build/mobile-web-app/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build/mobile-web-app/build-mobile-web-app-bundle.mjs @@ -578,7 +578,9 @@ export function resolveMobileWebPageRoutes(routeKeys, declared = MOBILE_WEB_PAGE export async function buildMobileWebAppBundle({ appDir, outDir = defaultOutDir, - pageRoutes = MOBILE_WEB_PAGE_ROUTES + pageRoutes = MOBILE_WEB_PAGE_ROUTES, + // Only the shipped tree pays for brotli-11; tests and verify's scratch builds read raw assets. + brotli = false } = {}) { const [ desktopVersion, @@ -624,7 +626,8 @@ export async function buildMobileWebAppBundle({ written: [indexAsset, ...written], desktopVersion, protocolWindow, - routes: resolveMobileWebPageRoutes(routeKeys, pageRoutes) + routes: resolveMobileWebPageRoutes(routeKeys, pageRoutes), + brotli }) return { manifest, @@ -649,7 +652,7 @@ if (isDirectInvocation(import.meta.url, process.argv[1])) { } try { const { manifest, outDir, routeKeys, entryStaticBytes, chunkCount } = - await buildMobileWebAppBundle() + await buildMobileWebAppBundle({ brotli: true }) console.log( `[build-mobile-web-app-bundle] OK — ${String(routeKeys.length)} route(s), ` + `${String(chunkCount)} chunk(s), ${String(entryStaticBytes)} bytes before the first route, ` + diff --git a/config/scripts/build/mobile-web-app/mobile-web-bundle-fixture-tree.mjs b/config/scripts/build/mobile-web-app/mobile-web-bundle-fixture-tree.mjs index 18070c95660..71903291664 100644 --- a/config/scripts/build/mobile-web-app/mobile-web-bundle-fixture-tree.mjs +++ b/config/scripts/build/mobile-web-app/mobile-web-bundle-fixture-tree.mjs @@ -15,7 +15,11 @@ const PIXEL_PNG = Buffer.from( 'base64' ) -export async function writeMobileWebBundleFixtureTree({ outDir, body = 'fixture' }) { +export async function writeMobileWebBundleFixtureTree({ + outDir, + body = 'fixture', + brotli = false +}) { const [desktopVersion, protocolWindow] = await Promise.all([ readDesktopVersion(), readProtocolWindow() @@ -49,6 +53,7 @@ export async function writeMobileWebBundleFixtureTree({ outDir, body = 'fixture' image ], desktopVersion, - protocolWindow + protocolWindow, + brotli }) } diff --git a/config/scripts/build/mobile-web-app/mobile-web-bundle-manifest.mjs b/config/scripts/build/mobile-web-app/mobile-web-bundle-manifest.mjs index 60f9d55d091..4504d345fd4 100644 --- a/config/scripts/build/mobile-web-app/mobile-web-bundle-manifest.mjs +++ b/config/scripts/build/mobile-web-app/mobile-web-bundle-manifest.mjs @@ -3,10 +3,13 @@ import { readFile, mkdir, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { brotliCompress, constants as zlibConstants } from 'node:zlib' const { darkBuildMarkers } = createRequire(import.meta.url)('../../../dark-build-switches.cjs') const projectDir = fileURLToPath(new URL('../../../..', import.meta.url)) +const brotliCompressAsync = promisify(brotliCompress) export const MOBILE_WEB_BUNDLE_SCHEMA_VERSION = 1 export const MOBILE_WEB_BUNDLE_ENTRYPOINT = 'index.html' @@ -101,6 +104,31 @@ export async function readDesktopVersion() { return packageJson.version } +// Mirrors mobileWebBundleBrotliPath in src/shared/mobile-web-bundle/manifest-contract.ts. +export function mobileWebBundleBrotliPath(assetPath) { + return `${assetPath}.br` +} + +// Largest first so the slowest file starts first on zlib's thread pool; keep only copies that shrink. +async function compressWithBrotli(written) { + const largestFirst = [...written].sort((left, right) => right.byteLength - left.byteLength) + const copies = await Promise.all( + largestFirst.map((asset) => + brotliCompressAsync(asset.bytes, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY, + [zlibConstants.BROTLI_PARAM_SIZE_HINT]: asset.byteLength + } + }) + ) + ) + return new Map( + largestFirst.flatMap((asset, index) => + copies[index].byteLength < asset.byteLength ? [[asset.path, copies[index]]] : [] + ) + ) +} + // Single manifest producer keeps builder/contract/packaging guard shape aligned. export async function writeMobileWebBundleTree({ outDir, @@ -109,10 +137,22 @@ export async function writeMobileWebBundleTree({ protocolWindow, // Empty route list makes shell render every screen natively. routes = [], + // Whole-file brotli copies for phones that inflate natively. + brotli = false, env = process.env }) { + const brotliCopies = brotli ? await compressWithBrotli(written) : new Map() const assets = written - .map(({ path, sha256, byteLength, contentType }) => ({ path, sha256, byteLength, contentType })) + .map(({ path, sha256, byteLength, contentType }) => { + const copy = brotliCopies.get(path) + return { + path, + sha256, + byteLength, + contentType, + ...(copy === undefined ? {} : { brotliByteLength: copy.byteLength }) + } + }) .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) const manifest = { schemaVersion: MOBILE_WEB_BUNDLE_SCHEMA_VERSION, @@ -133,6 +173,9 @@ export async function writeMobileWebBundleTree({ for (const asset of written) { await writeFile(join(outDir, asset.path), asset.bytes) } + for (const [path, copy] of brotliCopies) { + await writeFile(join(outDir, mobileWebBundleBrotliPath(path)), copy) + } await writeFile(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') // Keep switch metadata outside manifest so compiled page bytes/buildId remain unchanged. for (const marker of darkBuildMarkers(env)) { diff --git a/config/scripts/desktop-distribution/runtime-verification/verify-packaged-mobile-web-bundle.cjs b/config/scripts/desktop-distribution/runtime-verification/verify-packaged-mobile-web-bundle.cjs index fb2a9b14615..769e1c90235 100644 --- a/config/scripts/desktop-distribution/runtime-verification/verify-packaged-mobile-web-bundle.cjs +++ b/config/scripts/desktop-distribution/runtime-verification/verify-packaged-mobile-web-bundle.cjs @@ -1,6 +1,7 @@ const { createHash } = require('node:crypto') const { readFileSync, readdirSync, statSync } = require('node:fs') const { join, resolve } = require('node:path') +const { brotliDecompressSync } = require('node:zlib') const { darkBuildMarkerFileNames } = require('../../../dark-build-switches.cjs') @@ -98,6 +99,12 @@ function parseManifest(bundleDir) { if (typeof asset.contentType !== 'string' || asset.contentType.length === 0) { throw failure(`manifest asset ${asset.path} has no contentType`) } + if (asset.brotliByteLength !== undefined) { + assertInteger(asset.brotliByteLength, `assets[${asset.path}].brotliByteLength`) + if (asset.brotliByteLength >= asset.byteLength) { + throw failure(`manifest asset ${asset.path} declares a brotli copy no smaller than itself`) + } + } } if (!manifest.assets.some((asset) => asset.path === manifest.entrypoint)) { throw failure(`manifest entrypoint ${manifest.entrypoint} is not one of its assets`) @@ -132,13 +139,33 @@ function listBundleFiles(directory, prefix = '') { return found } +// Mirrors mobileWebBundleBrotliPath in src/shared/mobile-web-bundle/manifest-contract.ts. +function brotliPath(asset) { + return `${asset.path}.br` +} + +function brotliCopies(manifest) { + return manifest.assets.filter((asset) => asset.brotliByteLength !== undefined) +} + +function statSize(bundleDir, path) { + try { + return statSync(join(bundleDir, path)).size + } catch (error) { + throw failure( + `manifest lists ${path}, which is missing from ${bundleDir} (${error.code ?? error.message}). ${REMEDY}` + ) + } +} + // Reject unlisted on-disk assets: content-addressed stale files survive interrupted builds and would ship unreachable/unverified in asar. function assertNoUnlistedFiles(bundleDir, manifest) { // A dark build's marker is accounted for here; packaging refuses it through assertNotDarkBuild. const listed = new Set([ 'manifest.json', ...darkBuildMarkerFileNames(), - ...manifest.assets.map((asset) => asset.path) + ...manifest.assets.map((asset) => asset.path), + ...brotliCopies(manifest).map((asset) => brotliPath(asset)) ]) const strays = listBundleFiles(bundleDir).filter((path) => !listed.has(path)) if (strays.length > 0) { @@ -155,14 +182,7 @@ function assertMobileWebBundleBuilt(bundleDir = MOBILE_WEB_BUNDLE_DIR) { assertNoUnlistedFiles(bundleDir, manifest) for (const asset of manifest.assets) { const assetPath = join(bundleDir, asset.path) - let size - try { - size = statSync(assetPath).size - } catch (error) { - throw failure( - `manifest lists ${asset.path}, which is missing from ${bundleDir} (${error.code ?? error.message}). ${REMEDY}` - ) - } + const size = statSize(bundleDir, asset.path) if (size !== asset.byteLength) { throw failure( `${asset.path} is ${String(size)} bytes on disk, manifest says ${String(asset.byteLength)}. ${REMEDY}` @@ -175,6 +195,20 @@ function assertMobileWebBundleBuilt(bundleDir = MOBILE_WEB_BUNDLE_DIR) { ) } } + // Phones verify only the inflated bytes, so a copy that inflates to anything else fails every download. + for (const asset of brotliCopies(manifest)) { + const path = brotliPath(asset) + const size = statSize(bundleDir, path) + if (size !== asset.brotliByteLength) { + throw failure( + `${path} is ${String(size)} bytes on disk, manifest says ${String(asset.brotliByteLength)}. ${REMEDY}` + ) + } + const inflated = brotliDecompressSync(readFileSync(join(bundleDir, path))) + if (createHash('sha256').update(inflated).digest('hex') !== asset.sha256) { + throw failure(`${path} does not inflate to ${asset.path}. ${REMEDY}`) + } + } console.log( `[verify-packaged-mobile-web-bundle] OK — buildId ${manifest.buildId}, ` + `${String(manifest.assets.length)} asset(s), ${String(manifest.totalBytes)} bytes` diff --git a/config/scripts/desktop-distribution/runtime-verification/verify-packaged-mobile-web-bundle.test.mjs b/config/scripts/desktop-distribution/runtime-verification/verify-packaged-mobile-web-bundle.test.mjs index b745dff2402..e916dfc216a 100644 --- a/config/scripts/desktop-distribution/runtime-verification/verify-packaged-mobile-web-bundle.test.mjs +++ b/config/scripts/desktop-distribution/runtime-verification/verify-packaged-mobile-web-bundle.test.mjs @@ -2,6 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { brotliCompressSync } from 'node:zlib' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { writeMobileWebBundleFixtureTree } from '../../build/mobile-web-app/mobile-web-bundle-fixture-tree.mjs' @@ -10,11 +11,11 @@ const { assertMobileWebBundleBuilt } = require('./verify-packaged-mobile-web-bun const electronBuilderConfig = require('../../../electron-builder.config.cjs') const { darkBuildMarkers } = require('../../../dark-build-switches.cjs') -async function withBundle(run) { +async function withBundle(run, { brotli = false } = {}) { const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-')) const bundleDir = join(scratch, 'mobile-web') try { - const { manifest } = await writeMobileWebBundleFixtureTree({ outDir: bundleDir }) + const { manifest } = await writeMobileWebBundleFixtureTree({ outDir: bundleDir, brotli }) await run({ bundleDir, manifest }) } finally { await rm(scratch, { recursive: true, force: true }) @@ -65,6 +66,25 @@ describe('assertMobileWebBundleBuilt', () => { }) }) + it('accepts the brotli copies the manifest lists, and fails one that inflates to other bytes', async () => { + await withBundle( + async ({ bundleDir, manifest }) => { + const asset = manifest.assets.find((entry) => entry.brotliByteLength !== undefined) + expect(() => assertMobileWebBundleBuilt(bundleDir)).not.toThrow() + const forged = brotliCompressSync(Buffer.from('not the asset')) + await writeFile(join(bundleDir, `${asset.path}.br`), forged) + await rewriteManifest(bundleDir, (rewritten) => { + rewritten.assets.find((entry) => entry.path === asset.path).brotliByteLength = + forged.byteLength + }) + expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow( + `${asset.path}.br does not inflate to ${asset.path}` + ) + }, + { brotli: true } + ) + }) + it('refuses an asset path that escapes the bundle directory', async () => { await withBundle(async ({ bundleDir }) => { await rewriteManifest(bundleDir, (manifest) => { diff --git a/mobile/modules/orca-gzip-utf8-codec/android/build.gradle b/mobile/modules/orca-gzip-utf8-codec/android/build.gradle index 35667d48346..59674abee85 100644 --- a/mobile/modules/orca-gzip-utf8-codec/android/build.gradle +++ b/mobile/modules/orca-gzip-utf8-codec/android/build.gradle @@ -15,6 +15,8 @@ android { } dependencies { + // Android ships no brotli decoder; Google's pure-Java one, as okhttp-brotli uses. + implementation 'org.brotli:dec:0.1.2' testImplementation 'junit:junit:4.13.2' // The android.jar used by JVM unit tests stubs org.json; the shared vectors are JSON. testImplementation 'org.json:json:20240303' diff --git a/mobile/modules/orca-gzip-utf8-codec/android/src/main/java/expo/modules/orcagziputf8codec/BoundedBrotli.kt b/mobile/modules/orca-gzip-utf8-codec/android/src/main/java/expo/modules/orcagziputf8codec/BoundedBrotli.kt new file mode 100644 index 00000000000..ea7ace90661 --- /dev/null +++ b/mobile/modules/orca-gzip-utf8-codec/android/src/main/java/expo/modules/orcagziputf8codec/BoundedBrotli.kt @@ -0,0 +1,31 @@ +package expo.modules.orcagziputf8codec + +import java.io.ByteArrayInputStream +import java.io.IOException +import org.brotli.dec.BrotliInputStream + +class BoundedBrotliException(message: String) : Exception(message) + +/** Inflate one brotli stream into at most outputLimit bytes; callers pass one byte beyond the length they + * accept and check the result's length and hash, so truncated or overlong input fails there. */ +fun brotliDecompressBounded(input: ByteArray, outputLimit: Int): ByteArray { + if (outputLimit <= 0 || input.isEmpty()) { + throw BoundedBrotliException("nothing to inflate") + } + val output = ByteArray(outputLimit) + var produced = 0 + try { + BrotliInputStream(ByteArrayInputStream(input)).use { stream -> + while (produced < outputLimit) { + val count = stream.read(output, produced, outputLimit - produced) + if (count < 0) { + break + } + produced += count + } + } + } catch (error: IOException) { + throw BoundedBrotliException(error.message ?: "invalid brotli data") + } + return if (produced == outputLimit) output else output.copyOf(produced) +} diff --git a/mobile/modules/orca-gzip-utf8-codec/android/src/main/java/expo/modules/orcagziputf8codec/OrcaGzipUtf8CodecModule.kt b/mobile/modules/orca-gzip-utf8-codec/android/src/main/java/expo/modules/orcagziputf8codec/OrcaGzipUtf8CodecModule.kt index f46cd77562e..e9f8edf4a4c 100644 --- a/mobile/modules/orca-gzip-utf8-codec/android/src/main/java/expo/modules/orcagziputf8codec/OrcaGzipUtf8CodecModule.kt +++ b/mobile/modules/orca-gzip-utf8-codec/android/src/main/java/expo/modules/orcagziputf8codec/OrcaGzipUtf8CodecModule.kt @@ -15,5 +15,10 @@ class OrcaGzipUtf8CodecModule : Module() { Function("decodeUtf8") { input: ByteArray -> decodeStrictUtf8(input) } + + // Async: a whole bundle asset can take tens of ms, too long to hold the JS thread. + AsyncFunction("brotliDecompress") { input: ByteArray, outputLimit: Int -> + brotliDecompressBounded(input, outputLimit) + } } } diff --git a/mobile/modules/orca-gzip-utf8-codec/ios/BoundedBrotli.swift b/mobile/modules/orca-gzip-utf8-codec/ios/BoundedBrotli.swift new file mode 100644 index 00000000000..3e7c3c359e9 --- /dev/null +++ b/mobile/modules/orca-gzip-utf8-codec/ios/BoundedBrotli.swift @@ -0,0 +1,28 @@ +import Compression +import Foundation + +struct BoundedBrotliError: Error, CustomStringConvertible { + let description: String +} + +/// Inflate one brotli stream into at most outputLimit bytes; callers pass one byte beyond the length they +/// accept and check the result's length and hash. Compression returns nothing for a stream that is invalid, +/// truncated or longer than the buffer, so all three throw here. +func brotliDecompressBounded(_ input: Data, outputLimit: Int) throws -> Data { + guard outputLimit > 0, !input.isEmpty else { + throw BoundedBrotliError(description: "nothing to inflate") + } + var output = Data(count: outputLimit) + let produced = input.withUnsafeBytes { (inBytes: UnsafeRawBufferPointer) -> Int in + output.withUnsafeMutableBytes { (outBytes: UnsafeMutableRawBufferPointer) -> Int in + compression_decode_buffer( + outBytes.bindMemory(to: UInt8.self).baseAddress!, outputLimit, + inBytes.bindMemory(to: UInt8.self).baseAddress!, inBytes.count, + nil, COMPRESSION_BROTLI) + } + } + guard produced > 0 else { + throw BoundedBrotliError(description: "not a whole brotli stream within the output limit") + } + return produced == outputLimit ? output : output.prefix(produced) +} diff --git a/mobile/modules/orca-gzip-utf8-codec/ios/OrcaGzipUtf8Codec.podspec b/mobile/modules/orca-gzip-utf8-codec/ios/OrcaGzipUtf8Codec.podspec index 747a17b46d5..61ddefce0c5 100644 --- a/mobile/modules/orca-gzip-utf8-codec/ios/OrcaGzipUtf8Codec.podspec +++ b/mobile/modules/orca-gzip-utf8-codec/ios/OrcaGzipUtf8Codec.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'OrcaGzipUtf8Codec' s.version = '0.0.1' - s.summary = 'Bounded gunzip and strict UTF-8 decode' + s.summary = 'Bounded gunzip and brotli inflate, and strict UTF-8 decode' s.description = s.summary s.license = { :type => 'MIT' } s.author = 'Orca' @@ -11,6 +11,6 @@ Pod::Spec.new do |s| s.swift_version = '5.9' s.static_framework = true s.dependency 'ExpoModulesCore' - s.libraries = 'z' + s.libraries = 'z', 'compression' s.source_files = '**/*.swift' end diff --git a/mobile/modules/orca-gzip-utf8-codec/ios/OrcaGzipUtf8CodecModule.swift b/mobile/modules/orca-gzip-utf8-codec/ios/OrcaGzipUtf8CodecModule.swift index a3d5c24c168..d13edb8135a 100644 --- a/mobile/modules/orca-gzip-utf8-codec/ios/OrcaGzipUtf8CodecModule.swift +++ b/mobile/modules/orca-gzip-utf8-codec/ios/OrcaGzipUtf8CodecModule.swift @@ -12,5 +12,10 @@ public class OrcaGzipUtf8CodecModule: Module { Function("decodeUtf8") { (input: Data) -> String? in decodeStrictUtf8(input) } + + // Async: a whole bundle asset can take tens of ms, too long to hold the JS thread. + AsyncFunction("brotliDecompress") { (input: Data, outputLimit: Int) throws -> Data in + try brotliDecompressBounded(input, outputLimit: outputLimit) + } } } diff --git a/mobile/modules/orca-gzip-utf8-codec/src/index.ts b/mobile/modules/orca-gzip-utf8-codec/src/index.ts index 7eea6332bbc..e97477f6637 100644 --- a/mobile/modules/orca-gzip-utf8-codec/src/index.ts +++ b/mobile/modules/orca-gzip-utf8-codec/src/index.ts @@ -5,6 +5,8 @@ export type OrcaGzipUtf8CodecModule = { gunzip(input: Uint8Array, outputLimit: number): Uint8Array /** Well-formed UTF-8 with no leading BOM, decoded; null otherwise. */ decodeUtf8(input: Uint8Array): string | null + /** Inflate one brotli stream into at most outputLimit bytes, off the JS thread; throws on bad input. */ + brotliDecompress(input: Uint8Array, outputLimit: number): Promise> } /** Null in a binary built before the module existed. */ diff --git a/mobile/src/transport/encryption/paired-e2ee-native-codec.test.ts b/mobile/src/transport/encryption/paired-e2ee-native-codec.test.ts index 8b5479e7752..41e233f03a6 100644 --- a/mobile/src/transport/encryption/paired-e2ee-native-codec.test.ts +++ b/mobile/src/transport/encryption/paired-e2ee-native-codec.test.ts @@ -1,4 +1,4 @@ -import { gunzipSync } from 'node:zlib' +import { brotliDecompressSync, gunzipSync } from 'node:zlib' import sodium from 'react-native-libsodium' import { beforeAll, describe, expect, it, vi } from 'vitest' @@ -38,6 +38,8 @@ function libsodiumBindings(): PairedE2EENativeBindings { }, codec: { gunzip: (input, outputLimit) => new Uint8Array(gunzipSync(input)).slice(0, outputLimit), + brotliDecompress: async (input, outputLimit) => + new Uint8Array(brotliDecompressSync(input)).slice(0, outputLimit), decodeUtf8: (input) => { if (input[0] === 0xef && input[1] === 0xbb && input[2] === 0xbf) { return null diff --git a/mobile/src/transport/encryption/paired-e2ee-native-codec.ts b/mobile/src/transport/encryption/paired-e2ee-native-codec.ts index 3c6f7c4f328..412a5335327 100644 --- a/mobile/src/transport/encryption/paired-e2ee-native-codec.ts +++ b/mobile/src/transport/encryption/paired-e2ee-native-codec.ts @@ -1,4 +1,4 @@ -// Move frame seal/decode and bundle gunzip off Hermes’s JS thread when native bindings are available. +// Move frame seal/decode and bundle gunzip/brotli off Hermes’s JS thread when native bindings are available. export type PairedE2EENativeCodec = { /** Canonical padded base64 only; null for anything else, including what `atob` forgives. */ decodeBase64(value: string): Uint8Array | null @@ -10,6 +10,8 @@ export type PairedE2EENativeCodec = { sealSecretbox(message: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array /** Throws on a body that is not one well-formed gzip member. See orca-gzip-utf8-codec. */ gunzip(input: Uint8Array, outputLimit: number): Uint8Array + /** Off the JS thread; Hermes has no brotli, so there is no JS fallback. */ + brotliDecompress(input: Uint8Array, outputLimit: number): Promise> /** Null where only TextDecoder's replacement or BOM rules give the answer, or native failed. */ decodeUtf8(input: Uint8Array): string | null } @@ -25,6 +27,7 @@ export type PairedE2EENativeBindings = { codec: { gunzip(input: Uint8Array, outputLimit: number): Uint8Array decodeUtf8(input: Uint8Array): string | null + brotliDecompress(input: Uint8Array, outputLimit: number): Promise> } sodium: LibsodiumBindings } @@ -96,6 +99,7 @@ function detectPairedE2EENativeCodec(): PairedE2EENativeCodec | null { sodium.secretboxSeal(wholeBuffer(message), wholeBuffer(nonce), wholeBuffer(key)) ), gunzip: (input, outputLimit) => module.gunzip(input, outputLimit), + brotliDecompress: (input, outputLimit) => module.brotliDecompress(input, outputLimit), decodeUtf8: (input) => { // Why: the frame is already open and the counter advanced, so a throw here must not drop it. try { diff --git a/mobile/src/transport/web-bundle/mobile-web-bundle-brotli-fetch.test.ts b/mobile/src/transport/web-bundle/mobile-web-bundle-brotli-fetch.test.ts new file mode 100644 index 00000000000..d88a73b0ad5 --- /dev/null +++ b/mobile/src/transport/web-bundle/mobile-web-bundle-brotli-fetch.test.ts @@ -0,0 +1,116 @@ +import { sha256 } from '@noble/hashes/sha256' +import { brotliCompressSync, brotliDecompressSync } from 'node:zlib' +import { describe, expect, it, vi } from 'vitest' +import { computeMobileWebBundleId } from '../../../../src/shared/mobile-web-bundle/manifest-contract' +import { fetchMobileWebBundle, type MobileWebBundleFetchProgress } from './mobile-web-bundle-fetch' + +import type { RpcClient } from '../rpc/rpc-client-contract' +import type { RpcResponse } from '../rpc/rpc-wire-types' + +vi.mock('expo-crypto', async () => { + const { sha256: hash } = await import('@noble/hashes/sha256') + return { + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + digest: async (_algorithm: string, data: Uint8Array) => hash(data).buffer + } +}) + +// The native codec as a phone binary has it; node's brotli stands in for the platform decoder. +vi.mock('../encryption/paired-e2ee-native-codec', () => ({ + pairedE2EENativeCodec: () => ({ + decodeBase64: (value: string) => new Uint8Array(Buffer.from(value, 'base64')), + brotliDecompress: async (input: Uint8Array, outputLimit: number) => + new Uint8Array(brotliDecompressSync(input)).slice(0, outputLimit) + }) +})) + +const toHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') + +// Small enough that the 46-byte brotli copy spans three windows. +const RANGE_BYTES = 16 +const SCRIPT = 'export const greeting = "hello";\n'.repeat(40) +const MARK = 'a short mark' + +function brotliHost() { + const files = new Map([ + ['assets/script.js', new TextEncoder().encode(SCRIPT)], + ['index.html', new TextEncoder().encode(MARK)] + ]) + const copy = new Uint8Array(brotliCompressSync(files.get('assets/script.js')!)) + const assets = [...files].map(([path, content]) => ({ + path, + sha256: toHex(sha256(content)), + byteLength: content.byteLength, + contentType: 'text/plain', + ...(path === 'assets/script.js' ? { brotliByteLength: copy.byteLength } : {}) + })) + const buildId = computeMobileWebBundleId(assets) + const manifest = { + schemaVersion: 1, + buildId, + desktopVersion: '1.4.300', + minCompatibleRuntimeProtocolVersion: 2, + runtimeProtocolVersion: 2, + entrypoint: 'index.html', + totalBytes: assets.reduce((total, entry) => total + entry.byteLength, 0), + assets + } + const methods: string[] = [] + const answer = (method: string, params: Record): unknown => { + if (method === 'mobileWeb.bundle.manifest') { + return { manifest, chunkBytes: RANGE_BYTES, rangeBytes: RANGE_BYTES } + } + const path = String(params.path) + const offset = Number(params.offset) + const content = files.get(path)! + const stream = method === 'mobileWeb.bundle.brotliRange' ? copy : content + const slice = stream.subarray(offset, offset + RANGE_BYTES) + return { + buildId, + path, + offset, + assetByteLength: content.byteLength, + sha256: toHex(sha256(content)), + ...(method === 'mobileWeb.bundle.range' ? { encoding: 'identity' } : {}), + dataBase64: Buffer.from(slice).toString('base64'), + eof: offset + slice.byteLength >= stream.byteLength + } + } + const client: RpcClient = { + sendRequest: vi.fn(async (method: string, params?: unknown): Promise => { + methods.push(method) + const fields: Record = Object(params) + return { id: 'rpc-1', ok: true, result: answer(method, fields), _meta: { runtimeId: 'r' } } + }), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => 1, + onStateChange: () => () => {}, + notifyForeground: vi.fn(), + close: vi.fn() + } + return { client, methods } +} + +describe('fetchMobileWebBundle from a host that ships brotli copies', () => { + it('reads the copy where the manifest lists one, inflates it, and reports progress in asset bytes', async () => { + const host = brotliHost() + const progress: MobileWebBundleFetchProgress[] = [] + + const fetched = await fetchMobileWebBundle({ + client: host.client, + onProgress: (update) => progress.push(update) + }) + + expect(new TextDecoder().decode(fetched.assets.get('assets/script.js'))).toBe(SCRIPT) + expect(new TextDecoder().decode(fetched.assets.get('index.html'))).toBe(MARK) + expect(host.methods.filter((method) => method === 'mobileWeb.bundle.brotliRange')).toHaveLength( + 3 + ) + expect(host.methods.filter((method) => method === 'mobileWeb.bundle.range')).toHaveLength(1) + expect(progress.at(-1)).toMatchObject({ receivedBytes: 1332, totalBytes: 1332 }) + }) +}) diff --git a/mobile/src/transport/web-bundle/mobile-web-bundle-fetch.ts b/mobile/src/transport/web-bundle/mobile-web-bundle-fetch.ts index 2d30443088d..abd4440ef64 100644 --- a/mobile/src/transport/web-bundle/mobile-web-bundle-fetch.ts +++ b/mobile/src/transport/web-bundle/mobile-web-bundle-fetch.ts @@ -10,11 +10,15 @@ import type { RpcClient } from '../rpc/rpc-client-contract' import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal' import { markRpcDeliveryUnknown } from '../rpc/rpc-delivery-ambiguity' import { runRpcOperation } from '../rpc/rpc-operation' -import { mobileWebBundleWindowReader } from './mobile-web-bundle-window-reader' +import { + mobileWebBundleWindowReader, + type MobileWebBundleWindowReader +} from './mobile-web-bundle-window-reader' import { assertWindowDescribesAsset, assertWindowFillsItsSlot, - windowSlotBytes + windowSlotBytes, + type MobileWebBundleWindowStream } from './mobile-web-bundle-window-checks' // The host admits four window reads per connection; one reader per asset would serialize large scripts. @@ -56,6 +60,7 @@ export type MobileWebBundleFetchResult = { type AssetReassembly = { readonly entry: MobileWebBundleAssetRead + readonly stream: MobileWebBundleWindowStream readonly whole: Uint8Array outstandingWindows: number } @@ -154,9 +159,9 @@ export async function fetchMobileWebBundle(args: { return } assertWindowDescribesAsset(reply.header, asset.entry, manifest.buildId, offset) - const bytes = reply.bytes(windowSlotBytes(asset.entry, offset, reader.windowBytes)) + const bytes = reply.bytes(windowSlotBytes(asset.stream, offset, reader.windowBytes)) assertWindowFillsItsSlot( - asset.entry, + asset.stream, offset, bytes.byteLength, reply.header.eof, @@ -165,7 +170,7 @@ export async function fetchMobileWebBundle(args: { asset.whole.set(bytes, offset) asset.outstandingWindows -= 1 if (asset.outstandingWindows === 0) { - const verified = await verifyReassembledAsset(asset) + const verified = await verifyReassembledAsset(asset, reader) throwIfCallerAborted(args.signal) // A sibling failed while this hashed; the fetch already rejected with that failure. if (stopped.signal.aborted) { @@ -175,12 +180,12 @@ export async function fetchMobileWebBundle(args: { downloadedAssets += 1 } // Per window, not per asset: the largest asset goes first, so asset completions bunch at the end. - receivedBytes += bytes.byteLength + receivedBytes += assetBytesCredited(asset, offset, bytes.byteLength) reportProgress() } const readWindows = async (entries: readonly MobileWebBundleAssetRead[]): Promise => { - const queue = planWindowReads(entries, reader.windowBytes) + const queue = planWindowReads(entries, reader) if (queue.length === 0) { return } @@ -270,30 +275,61 @@ export async function fetchMobileWebBundle(args: { // Plan all grid offsets largest-asset first so its tail cannot serialize the end; still validate eof replies. function planWindowReads( entries: readonly MobileWebBundleAssetRead[], - windowBytes: number + reader: MobileWebBundleWindowReader ): WindowRead[] { - const largestFirst = [...entries].sort((left, right) => right.byteLength - left.byteLength) - return largestFirst.flatMap((entry) => { - const count = Math.max(1, Math.ceil(entry.byteLength / windowBytes)) + const { windowBytes } = reader + const streams = entries.map((entry) => ({ + entry, + stream: { path: entry.path, byteLength: reader.streamLength(entry) } + })) + const largestFirst = streams.sort( + (left, right) => right.stream.byteLength - left.stream.byteLength + ) + return largestFirst.flatMap(({ entry, stream }) => { + const count = Math.max(1, Math.ceil(stream.byteLength / windowBytes)) const asset: AssetReassembly = { entry, - whole: new Uint8Array(entry.byteLength), + stream, + whole: new Uint8Array(stream.byteLength), outstandingWindows: count } return Array.from({ length: count }, (_, index) => ({ asset, offset: index * windowBytes })) }) } -async function verifyReassembledAsset(asset: AssetReassembly): Promise { +// Progress stays in asset bytes, the unit of the session's total: a window of a brotli copy counts for its +// share of the asset, and an asset's windows add up to exactly its byteLength. +function assetBytesCredited(asset: AssetReassembly, offset: number, byteLength: number): number { + const streamBytes = asset.stream.byteLength + if (streamBytes === asset.entry.byteLength) { + return byteLength + } + const at = (position: number): number => + Math.floor((asset.entry.byteLength * position) / streamBytes) + return at(offset + byteLength) - at(offset) +} + +async function verifyReassembledAsset( + asset: AssetReassembly, + reader: MobileWebBundleWindowReader +): Promise { + const whole = await reader.inflate(asset.entry, asset.whole) + const { path, byteLength } = asset.entry + if (whole.byteLength !== byteLength) { + throw new MobileWebBundleFetchError( + whole.byteLength > byteLength ? 'asset-overlong' : 'asset-short', + `bundle asset ${path} inflated to ${whole.byteLength} of ${byteLength} declared bytes` + ) + } // Native: hashing in JS held the JS thread for seconds, and React's progress renders waited on it. - const hashed = toHex(new Uint8Array(await digest(CryptoDigestAlgorithm.SHA256, asset.whole))) + const hashed = toHex(new Uint8Array(await digest(CryptoDigestAlgorithm.SHA256, whole))) if (hashed !== asset.entry.sha256) { throw new MobileWebBundleFetchError( 'asset-checksum-mismatch', `bundle asset ${asset.entry.path} hashed ${hashed}, not ${asset.entry.sha256}` ) } - return asset.whole + return whole } /** Only the caller's abort surfaces as `fetch-stopped`; an internal stop rejects with the failure diff --git a/mobile/src/transport/web-bundle/mobile-web-bundle-operations.ts b/mobile/src/transport/web-bundle/mobile-web-bundle-operations.ts index 9116b76ae0d..8a717c84255 100644 --- a/mobile/src/transport/web-bundle/mobile-web-bundle-operations.ts +++ b/mobile/src/transport/web-bundle/mobile-web-bundle-operations.ts @@ -1,11 +1,13 @@ import { MobileWebBundleErrorCodeSchema, + MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD, MOBILE_WEB_BUNDLE_CHUNK_METHOD, MOBILE_WEB_BUNDLE_MANIFEST_METHOD, MOBILE_WEB_BUNDLE_RANGE_METHOD, type MobileWebBundleErrorCode } from '../../../../src/shared/mobile-web-bundle/bundle-rpc-contract' import { + MobileWebBundleBrotliRangeReplySchema, MobileWebBundleChunkReplySchema, MobileWebBundleManifestReplySchema, MobileWebBundleRangeReplySchema @@ -43,6 +45,15 @@ export const mobileWebBundleRangeRead = defineRpcOperation({ read: rpcResultVariant('mobile-web-bundle-range', MobileWebBundleRangeReplySchema) }) +// Only for assets whose manifest entry names a brotli copy, so only hosts that ship one are asked. +export const mobileWebBundleBrotliRangeRead = defineRpcOperation({ + name: 'mobileWeb.bundle-brotli-range', + method: MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD, + acceptance: 'require-result-or-throw', + barrier: 'on-settle', + read: rpcResultVariant('mobile-web-bundle-brotli-range', MobileWebBundleBrotliRangeReplySchema) +}) + /** A code is a bare snake_case token, so only the two positions one can occupy are read. */ const LEADING_CODE_TOKEN = /^[a-z][a-z0-9_]*/ diff --git a/mobile/src/transport/web-bundle/mobile-web-bundle-reply-schemas.ts b/mobile/src/transport/web-bundle/mobile-web-bundle-reply-schemas.ts index b76f7c4f6aa..602d3d5d4ea 100644 --- a/mobile/src/transport/web-bundle/mobile-web-bundle-reply-schemas.ts +++ b/mobile/src/transport/web-bundle/mobile-web-bundle-reply-schemas.ts @@ -38,7 +38,15 @@ const assetSchema = z.looseObject({ path: MobileWebBundleAssetPathSchema, sha256: z.string().regex(SHA256_PATTERN), byteLength: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), - contentType: z.string().min(1) + contentType: z.string().min(1), + // An unreadable length means no brotli copy, never a refused bundle; the reader also requires it under byteLength. + brotliByteLength: z + .number() + .int() + .nonnegative() + .max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES) + .optional() + .catch(undefined) }) // Read unknown schema versions as numbers so compatibility can name the required update. @@ -115,6 +123,12 @@ export const MobileWebBundleRangeReplySchema = z.looseObject({ dataBase64: z.string().max(MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH) }) +// Windows of the brotli copy are its raw bytes; the header still names the inflated asset. +export const MobileWebBundleBrotliRangeReplySchema = z.looseObject({ + ...windowHeaderFields, + dataBase64: z.string().max(MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH) +}) + export type MobileWebBundleManifestReply = z.output export type MobileWebBundleManifestRead = MobileWebBundleManifestReply['manifest'] export type MobileWebBundleAssetRead = MobileWebBundleManifestRead['assets'][number] diff --git a/mobile/src/transport/web-bundle/mobile-web-bundle-window-checks.ts b/mobile/src/transport/web-bundle/mobile-web-bundle-window-checks.ts index e59fe72b6fa..bf2aea9dc55 100644 --- a/mobile/src/transport/web-bundle/mobile-web-bundle-window-checks.ts +++ b/mobile/src/transport/web-bundle/mobile-web-bundle-window-checks.ts @@ -2,8 +2,11 @@ import type { MobileWebBundleAssetRead } from './mobile-web-bundle-reply-schemas import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal' import type { MobileWebBundleWindowHeader } from './mobile-web-bundle-window-reader' +/** The file the windows are cut from: the asset, or its brotli copy. */ +export type MobileWebBundleWindowStream = Pick + export function windowSlotBytes( - entry: MobileWebBundleAssetRead, + entry: MobileWebBundleWindowStream, offset: number, windowBytes: number ): number { @@ -12,7 +15,7 @@ export function windowSlotBytes( /** Offsets are planned, so a reply is accepted only if it fills exactly its slot of the grid. */ export function assertWindowFillsItsSlot( - entry: MobileWebBundleAssetRead, + entry: MobileWebBundleWindowStream, offset: number, byteLength: number, eof: boolean, diff --git a/mobile/src/transport/web-bundle/mobile-web-bundle-window-reader.ts b/mobile/src/transport/web-bundle/mobile-web-bundle-window-reader.ts index 51e439a24a3..7a27848b680 100644 --- a/mobile/src/transport/web-bundle/mobile-web-bundle-window-reader.ts +++ b/mobile/src/transport/web-bundle/mobile-web-bundle-window-reader.ts @@ -1,8 +1,17 @@ +import { errorMessage } from '../../../../src/shared/error-message' import { decodeBase64Bytes } from '../encryption/base64-byte-codec' import { pairedE2EENativeCodec } from '../encryption/paired-e2ee-native-codec' -import { mobileWebBundleChunkRead, mobileWebBundleRangeRead } from './mobile-web-bundle-operations' +import { + mobileWebBundleBrotliRangeRead, + mobileWebBundleChunkRead, + mobileWebBundleRangeRead +} from './mobile-web-bundle-operations' import { decodeMobileWebBundleRange } from './mobile-web-bundle-range-decode' -import type { MobileWebBundleManifestReply } from './mobile-web-bundle-reply-schemas' +import type { + MobileWebBundleAssetRead, + MobileWebBundleManifestReply +} from './mobile-web-bundle-reply-schemas' +import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal' import type { RpcClient } from '../rpc/rpc-client-contract' import { runRpcOperation } from '../rpc/rpc-operation' import type { SendRequestOptions } from '../rpc/unvalidated-rpc-request-port' @@ -24,6 +33,8 @@ export type MobileWebBundleWindowReply = { export type MobileWebBundleWindowReader = { readonly windowBytes: number + /** What this reader moves for the asset: its brotli copy where it reads one, otherwise the asset itself. */ + readonly streamLength: (asset: MobileWebBundleAssetRead) => number read( window: { buildId: string @@ -32,31 +43,48 @@ export type MobileWebBundleWindowReader = { }, options?: SendRequestOptions ): Promise + /** The asset's bytes from its reassembled stream, before the caller checks their hash. */ + readonly inflate: ( + asset: MobileWebBundleAssetRead, + stream: Uint8Array + ) => Promise> } // Choose ranges only from the manifest’s grid; older hosts still serve chunks. export function mobileWebBundleWindowReader( client: RpcClient, - opened: Pick + opened: Pick ): MobileWebBundleWindowReader { const { rangeBytes } = opened if (rangeBytes === undefined) { return { windowBytes: opened.chunkBytes, - read: async (window, options) => { - const { dataBase64, ...header } = await runRpcOperation( - client, - mobileWebBundleChunkRead, - window, - options - ) - return { header, bytes: () => decodeBase64(dataBase64) } - } + streamLength: (asset) => asset.byteLength, + read: async (window, options) => + rawWindowReply(await runRpcOperation(client, mobileWebBundleChunkRead, window, options)), + inflate: async (_asset, stream) => stream } } + const native = pairedE2EENativeCodec() + // Hermes has no brotli, so without the native codec every asset stays on gzip ranges. + const brotliLengths = new Map( + native === null + ? [] + : opened.manifest.assets.flatMap((asset) => + asset.brotliByteLength !== undefined && asset.brotliByteLength < asset.byteLength + ? [[asset.path, asset.brotliByteLength] as const] + : [] + ) + ) return { windowBytes: rangeBytes, + streamLength: (asset) => brotliLengths.get(asset.path) ?? asset.byteLength, read: async (window, options) => { + if (brotliLengths.has(window.path)) { + return rawWindowReply( + await runRpcOperation(client, mobileWebBundleBrotliRangeRead, window, options) + ) + } const { dataBase64, encoding, ...header } = await runRpcOperation( client, mobileWebBundleRangeRead, @@ -72,7 +100,35 @@ export function mobileWebBundleWindowReader( expected ) } - } + }, + inflate: async (asset, stream) => + native === null || !brotliLengths.has(asset.path) + ? stream + : inflateBrotli(native.brotliDecompress, asset, stream) + } +} + +// Chunks and brotli windows carry their bytes as they are. +function rawWindowReply({ + dataBase64, + ...header +}: MobileWebBundleWindowHeader & { readonly dataBase64: string }): MobileWebBundleWindowReply { + return { header, bytes: () => decodeBase64(dataBase64) } +} + +// One byte past the asset bounds a bomb; the caller rejects any length but the asset's. +async function inflateBrotli( + brotliDecompress: (input: Uint8Array, outputLimit: number) => Promise>, + asset: MobileWebBundleAssetRead, + stream: Uint8Array +): Promise> { + try { + return await brotliDecompress(stream, asset.byteLength + 1) + } catch (error) { + throw new MobileWebBundleFetchError( + 'range-undecodable', + `bundle asset ${asset.path} is not valid brotli: ${errorMessage(error)}` + ) } } diff --git a/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle-asset-reader.ts b/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle-asset-reader.ts index 7a9e56e679a..e429c63e881 100644 --- a/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle-asset-reader.ts +++ b/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle-asset-reader.ts @@ -48,7 +48,7 @@ async function hashAsset(root: string, asset: MobileWebBundleAsset): Promise, offset: number, length: number ): Promise { diff --git a/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle-range.test.ts b/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle-range.test.ts index 8ce5846116c..5553b263030 100644 --- a/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle-range.test.ts +++ b/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle-range.test.ts @@ -2,11 +2,13 @@ import { randomBytes } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { gunzipSync } from 'node:zlib' +import { brotliDecompressSync, gunzipSync } from 'node:zlib' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD, MOBILE_WEB_BUNDLE_CHUNK_BYTES as RANGE_BYTES, MOBILE_WEB_BUNDLE_RANGE_METHOD, + MobileWebBundleBrotliRangeResultSchema, MobileWebBundleRangeResultSchema, type MobileWebBundleRangeResult } from '../../../../../shared/mobile-web-bundle/bundle-rpc-contract' @@ -140,3 +142,68 @@ describe('mobileWeb.bundle.range on an install that carries a bundle', () => { ).toBe(true) }) }) + +describe('mobileWeb.bundle.brotliRange', () => { + // Base64 text shrinks only to about 3/4, so its copy spans several windows. + const SCRIPT: SyntheticAsset = (() => { + const bytes = Buffer.from(randomBytes(RANGE_BYTES * 2).toString('base64')) + return { + path: `assets/${sha256Hex(bytes)}.js`, + bytes, + contentType: 'text/javascript', + brotli: true + } + })() + let bundle: SyntheticMobileWebBundle + + beforeEach(() => { + bundle = writeSyntheticMobileWebBundle(join(scratch, 'out', 'mobile-web'), 1, [ + SCRIPT, + INCOMPRESSIBLE + ]) + }) + + it('pages the brotli copy on the range grid, and the pages inflate to the asset', async () => { + const entry = bundle.assets.find((asset) => asset.path === SCRIPT.path) + const copyLength = entry?.brotliByteLength ?? 0 + expect(copyLength).toBeGreaterThan(0) + const pieces: Buffer[] = [] + for (let offset = 0; ; offset += RANGE_BYTES) { + const response = await call(MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD, { + buildId: bundle.buildId, + path: SCRIPT.path, + offset + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = MobileWebBundleBrotliRangeResultSchema.parse(response.result) + expect(result).toMatchObject({ + path: SCRIPT.path, + offset, + assetByteLength: SCRIPT.bytes.byteLength, + sha256: sha256Hex(SCRIPT.bytes), + eof: offset + RANGE_BYTES >= copyLength + }) + pieces.push(Buffer.from(result.dataBase64, 'base64')) + if (result.eof) { + break + } + } + expect(pieces.length).toBe(Math.ceil(copyLength / RANGE_BYTES)) + expect(pieces.length).toBeGreaterThan(1) + expect(Buffer.concat(pieces).byteLength).toBe(copyLength) + expect(brotliDecompressSync(Buffer.concat(pieces)).equals(SCRIPT.bytes)).toBe(true) + }) + + it('refuses an asset the manifest gives no brotli copy', async () => { + const response = await call(MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD, { + buildId: bundle.buildId, + path: INCOMPRESSIBLE.path, + offset: 0 + }) + + expect(response.ok).toBe(false) + expect(response.ok ? null : response.error.message).toBe('mobile_web_bundle_asset_unknown') + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle.test-fixture.ts b/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle.test-fixture.ts index 1782d55d158..ff015a62f71 100644 --- a/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle.test-fixture.ts +++ b/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle.test-fixture.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { brotliCompressSync } from 'node:zlib' import { installFakeAppEnvironment } from '../../../../../../config/scripts/test-runtime/environment/vitest-host-ports-setup' import type { OrcaRuntimeService } from '../../../orca-runtime' import { RpcDispatcher } from '../../dispatcher' @@ -8,6 +9,7 @@ import { MOBILE_WEB_BUNDLE_METHODS } from './mobile-web-bundle' import { MOBILE_WEB_BUNDLE_CHUNK_BYTES } from '../../../../../shared/mobile-web-bundle/bundle-rpc-contract' import { computeMobileWebBundleId, + mobileWebBundleBrotliPath, type MobileWebBundleAsset } from '../../../../../shared/mobile-web-bundle/manifest-contract' @@ -22,7 +24,8 @@ export function mobileWebBundleFiller(byteLength: number, seed: number): Buffer return bytes } -export type SyntheticAsset = { path: string; bytes: Buffer; contentType: string } +/** `brotli` writes the asset's brotli copy and lists its length, as the shipped build does. */ +export type SyntheticAsset = { path: string; bytes: Buffer; contentType: string; brotli?: boolean } /** Use synthetic multi-chunk assets because the current builder emits smaller files and CI unit jobs do not build the bundle. */ function syntheticAssets(seed: number): SyntheticAsset[] { @@ -58,15 +61,22 @@ export function writeSyntheticMobileWebBundle( ): SyntheticMobileWebBundle { mkdirSync(join(root, 'assets'), { recursive: true }) const written = [...syntheticAssets(seed), ...extraAssets] + const copies = new Map() for (const asset of written) { writeFileSync(join(root, asset.path), asset.bytes) + if (asset.brotli === true) { + const copy = brotliCompressSync(asset.bytes) + writeFileSync(join(root, mobileWebBundleBrotliPath(asset.path)), copy) + copies.set(asset.path, copy) + } } const assets = written .map((asset) => ({ path: asset.path, sha256: sha256Hex(asset.bytes), byteLength: asset.bytes.byteLength, - contentType: asset.contentType + contentType: asset.contentType, + ...(copies.has(asset.path) ? { brotliByteLength: copies.get(asset.path)?.byteLength } : {}) })) .sort((left, right) => (left.path < right.path ? -1 : 1)) const buildId = computeMobileWebBundleId(assets) diff --git a/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle.ts b/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle.ts index ddcea5db11c..1d700f41cf2 100644 --- a/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle.ts +++ b/src/main/runtime/rpc/methods/mobile-delivery/mobile-web-bundle.ts @@ -2,19 +2,24 @@ * Never proxy to SSH/relay execution hosts: the phone paired with this desktop. * Verify/read failures map to asset-changed without exposing host paths. */ import { + MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD, MOBILE_WEB_BUNDLE_CHUNK_BYTES, MOBILE_WEB_BUNDLE_CHUNK_METHOD, MOBILE_WEB_BUNDLE_MANIFEST_METHOD, MOBILE_WEB_BUNDLE_RANGE_METHOD, MobileWebBundleChunkParamsSchema, MobileWebBundleRangeParamsSchema, + type MobileWebBundleBrotliRangeResult, type MobileWebBundleChunkParams, type MobileWebBundleChunkResult, type MobileWebBundleErrorCode, type MobileWebBundleManifestResult, type MobileWebBundleRangeResult } from '../../../../../shared/mobile-web-bundle/bundle-rpc-contract' -import type { MobileWebBundleAsset } from '../../../../../shared/mobile-web-bundle/manifest-contract' +import { + mobileWebBundleBrotliPath, + type MobileWebBundleAsset +} from '../../../../../shared/mobile-web-bundle/manifest-contract' import { loadBundledMobileWebBundle, type BundledMobileWebBundle @@ -72,16 +77,27 @@ function findAsset(bundle: BundledMobileWebBundle, path: string): MobileWebBundl function assertOffsetAddressesAWindow( offset: number, windowBytes: number, - asset: MobileWebBundleAsset + file: WindowSource ): void { if (offset % windowBytes !== 0) { throw bundleError('mobile_web_bundle_offset_invalid') } - if (offset > 0 && offset >= asset.byteLength) { + if (offset > 0 && offset >= file.byteLength) { throw bundleError('mobile_web_bundle_offset_invalid') } } +/** The file a window is cut from: the asset itself, or its brotli copy. */ +type WindowSource = Pick + +/** Only assets the manifest gives a copy have one; asking for another is asking for an unlisted file. */ +function brotliCopyOf(asset: MobileWebBundleAsset): WindowSource { + if (asset.brotliByteLength === undefined) { + throw bundleError('mobile_web_bundle_asset_unknown') + } + return { path: mobileWebBundleBrotliPath(asset.path), byteLength: asset.brotliByteLength } +} + /** The self-description every chunk or range reply carries, plus the raw bytes of its window. */ type VerifiedWindow = { header: Omit @@ -93,7 +109,8 @@ async function readVerifiedWindow( ctx: RpcContext, params: MobileWebBundleChunkParams, windowBytes: number, - encode: (window: VerifiedWindow) => Promise + encode: (window: VerifiedWindow) => Promise, + sourceOf: (asset: MobileWebBundleAsset) => WindowSource = (asset) => asset ): Promise { const bundle = requireBundle() // Check build before path: auto-update means restart from the manifest, not missing-asset. @@ -101,7 +118,8 @@ async function readVerifiedWindow( throw bundleError('mobile_web_bundle_build_changed') } const asset = findAsset(bundle, params.path) - assertOffsetAddressesAWindow(params.offset, windowBytes, asset) + const source = sourceOf(asset) + assertOffsetAddressesAWindow(params.offset, windowBytes, source) const release = acquireMobileWebBundleReadSlot(mobileWebBundleReadBucket(ctx)) if (!release) { @@ -115,7 +133,7 @@ async function readVerifiedWindow( abortIfDisconnected(ctx) const data = await readMobileWebBundleAssetWindow( bundle.root, - asset, + source, params.offset, windowBytes ) @@ -127,7 +145,7 @@ async function readVerifiedWindow( offset: params.offset, assetByteLength: asset.byteLength, sha256: asset.sha256, - eof: params.offset + data.byteLength >= asset.byteLength + eof: params.offset + data.byteLength >= source.byteLength }, data }) @@ -138,6 +156,11 @@ async function readVerifiedWindow( } } +/** Chunks and brotli windows go out as read: chunks predate encodings, and brotli output does not shrink again. */ +async function rawWindowReply(window: VerifiedWindow): Promise { + return { ...window.header, dataBase64: window.data.toString('base64') } +} + export const MOBILE_WEB_BUNDLE_METHODS = [ defineMethod({ name: MOBILE_WEB_BUNDLE_MANIFEST_METHOD, @@ -155,10 +178,7 @@ export const MOBILE_WEB_BUNDLE_METHODS = [ permission: 'workspace', params: MobileWebBundleChunkParamsSchema, handler: (params, ctx): Promise => - readVerifiedWindow(ctx, params, MOBILE_WEB_BUNDLE_CHUNK_BYTES, async (window) => ({ - ...window.header, - dataBase64: window.data.toString('base64') - })) + readVerifiedWindow(ctx, params, MOBILE_WEB_BUNDLE_CHUNK_BYTES, rawWindowReply) }), defineMethod({ name: MOBILE_WEB_BUNDLE_RANGE_METHOD, @@ -173,5 +193,13 @@ export const MOBILE_WEB_BUNDLE_METHODS = [ dataBase64: encoded.bytes.toString('base64') } }) + }), + defineMethod({ + name: MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD, + permission: 'workspace', + params: MobileWebBundleRangeParamsSchema, + handler: (params, ctx): Promise => + // The raw asset's hash still gates every window of its copy. + readVerifiedWindow(ctx, params, MOBILE_WEB_BUNDLE_CHUNK_BYTES, rawWindowReply, brotliCopyOf) }) ] diff --git a/src/main/runtime/rpc/server/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/rpc/server/runtime-rpc-mobile-paired-desktop-methods.ts index 4949cef589f..333dd8868ed 100644 --- a/src/main/runtime/rpc/server/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/rpc/server/runtime-rpc-mobile-paired-desktop-methods.ts @@ -3,6 +3,7 @@ import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../shared/sidebar-inputs-s /** Phone methods the relaying Mac always answers itself, even when the call names a server. */ export const PAIRED_DESKTOP_METHODS = [ // Device and pairing concerns of the phone's own pairing. + 'mobileWeb.bundle.brotliRange', 'mobileWeb.bundle.chunk', 'mobileWeb.bundle.manifest', 'mobileWeb.bundle.range', diff --git a/src/shared/mobile-web-bundle/bundle-rpc-contract.ts b/src/shared/mobile-web-bundle/bundle-rpc-contract.ts index 629c99efad8..8d6ec418f71 100644 --- a/src/shared/mobile-web-bundle/bundle-rpc-contract.ts +++ b/src/shared/mobile-web-bundle/bundle-rpc-contract.ts @@ -19,6 +19,8 @@ export const MOBILE_WEB_BUNDLE_MANIFEST_METHOD = 'mobileWeb.bundle.manifest' export const MOBILE_WEB_BUNDLE_CHUNK_METHOD = 'mobileWeb.bundle.chunk' /** Separate method because released hosts parse chunk params strictly and released phones may parse replies strictly; neither can add encoding. */ export const MOBILE_WEB_BUNDLE_RANGE_METHOD = 'mobileWeb.bundle.range' +/** Windows of an asset's whole-file brotli copy; phones call it only for assets whose manifest entry has `brotliByteLength`. */ +export const MOBILE_WEB_BUNDLE_BROTLI_RANGE_METHOD = 'mobileWeb.bundle.brotliRange' export const MOBILE_WEB_BUNDLE_RANGE_ENCODINGS = ['gzip', 'identity'] as const export type MobileWebBundleRangeEncoding = (typeof MOBILE_WEB_BUNDLE_RANGE_ENCODINGS)[number] @@ -67,7 +69,7 @@ const windowParamsFields = { /** Host validates offset alignment against advertised chunkBytes, which may be smaller than the constant; no fixed multipleOf. */ export const MobileWebBundleChunkParamsSchema = z.object(windowParamsFields).strict() -/** The chunk params exactly, on the `rangeBytes` grid the manifest reply advertised. */ +/** The chunk params exactly, on the `rangeBytes` grid the manifest reply advertised (over the brotli copy for brotliRange). */ export const MobileWebBundleRangeParamsSchema = MobileWebBundleChunkParamsSchema /** What every chunk or range reply restates about the window it answers. */ @@ -95,8 +97,20 @@ export const MobileWebBundleRangeResultSchema = z }) .strict() +/** Raw window bytes of the brotli copy: `assetByteLength` and `sha256` still describe the inflated asset, + * while `eof` and the decoded length of `dataBase64` follow `brotliByteLength`. */ +export const MobileWebBundleBrotliRangeResultSchema = z + .object({ + ...windowHeaderFields, + dataBase64: z.string().max(MOBILE_WEB_BUNDLE_RANGE_MAX_DATA_BASE64_LENGTH) + }) + .strict() + export type MobileWebBundleManifestResult = z.infer export type MobileWebBundleChunkParams = z.infer export type MobileWebBundleChunkResult = z.infer export type MobileWebBundleRangeParams = z.infer export type MobileWebBundleRangeResult = z.infer +export type MobileWebBundleBrotliRangeResult = z.infer< + typeof MobileWebBundleBrotliRangeResultSchema +> diff --git a/src/shared/mobile-web-bundle/manifest-contract.ts b/src/shared/mobile-web-bundle/manifest-contract.ts index 7524c2fd29f..19462a4906c 100644 --- a/src/shared/mobile-web-bundle/manifest-contract.ts +++ b/src/shared/mobile-web-bundle/manifest-contract.ts @@ -65,12 +65,25 @@ export const MobileWebBundleAssetSchema = z path: MobileWebBundleAssetPathSchema, sha256: z.string().regex(SHA256_PATTERN), byteLength: z.number().int().nonnegative().max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES), - contentType: z.string().min(1).max(MAX_CONTENT_TYPE_LENGTH).regex(CONTENT_TYPE_PATTERN) + contentType: z.string().min(1).max(MAX_CONTENT_TYPE_LENGTH).regex(CONTENT_TYPE_PATTERN), + /** Length of the brotli copy at `mobileWebBundleBrotliPath(path)`, present only where it is smaller. + * Outside buildId, so phones keep caching and reusing by the raw bytes. */ + brotliByteLength: z + .number() + .int() + .nonnegative() + .max(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES) + .optional() }) .strict() export type MobileWebBundleAsset = z.infer +/** The build writes each brotli copy next to its asset under this name. */ +export function mobileWebBundleBrotliPath(assetPath: string): string { + return `${assetPath}.br` +} + /** Code-unit order, not `localeCompare`: the sort feeds a content hash, so it must not vary. */ function compareAssetPaths(left: MobileWebBundleAsset, right: MobileWebBundleAsset): number { if (left.path === right.path) { diff --git a/src/shared/orcad-artifacts.test.ts b/src/shared/orcad-artifacts.test.ts index 73ec0d51b58..46f258e88ec 100644 --- a/src/shared/orcad-artifacts.test.ts +++ b/src/shared/orcad-artifacts.test.ts @@ -13,4 +13,15 @@ describe('standalone runtime artifacts', () => { ORCAD_MOBILE_WEB_MANIFEST ) }) + + it('ships the brotli copy of every asset whose entry lists one', () => { + const manifest = { + assets: [{ path: 'index.html', brotliByteLength: 120 }, { path: 'assets/icon.png' }] + } + expect(orcadMobileWebAssetFilenames(JSON.stringify(manifest))).toEqual([ + 'out/mobile-web/assets/icon.png', + 'out/mobile-web/index.html', + 'out/mobile-web/index.html.br' + ]) + }) }) diff --git a/src/shared/orcad-artifacts.ts b/src/shared/orcad-artifacts.ts index e685f297384..c87ae582132 100644 --- a/src/shared/orcad-artifacts.ts +++ b/src/shared/orcad-artifacts.ts @@ -189,7 +189,7 @@ export function isOrcadMobileWebAssetPath(path: string): boolean { ) } -/** Slot paths of every asset `manifestText` lists, sorted; throws rather than ship a partial page. */ +/** Slot paths of every asset `manifestText` lists and their brotli copies, sorted; throws rather than ship a partial page. */ export function orcadMobileWebAssetFilenames(manifestText: string): string[] { const manifest: unknown = JSON.parse(manifestText) const assets = @@ -197,12 +197,15 @@ export function orcadMobileWebAssetFilenames(manifestText: string): string[] { if (!Array.isArray(assets) || assets.length === 0) { throw new Error(`${ORCAD_MOBILE_WEB_MANIFEST} lists no assets`) } - const filenames = assets.map((asset: unknown) => { - const path = asset && typeof asset === 'object' && 'path' in asset ? asset.path : undefined + const filenames = assets.flatMap((asset: unknown) => { + const entry = asset && typeof asset === 'object' ? asset : {} + const path = 'path' in entry ? entry.path : undefined if (typeof path !== 'string' || !isOrcadMobileWebAssetPath(path)) { throw new Error(`${ORCAD_MOBILE_WEB_MANIFEST} names an unsafe asset path: ${String(path)}`) } - return `${ORCAD_MOBILE_WEB_DIR}/${path}` + const slotPath = `${ORCAD_MOBILE_WEB_DIR}/${path}` + // The asset's brotli copy, served to phones that inflate it; mirrors mobileWebBundleBrotliPath. + return 'brotliByteLength' in entry ? [slotPath, `${slotPath}.br`] : [slotPath] }) return filenames.sort(compareCodeUnits) } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 01f53b90139..996590d4caa 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -1320,6 +1320,7 @@ export const RPC_PARAMS_BY_METHOD = { 'mobileRelay.hosts.list': null, 'mobileRelay.hosts.worktrees': MobileRelayHostWorktreesParamsSchema, 'mobileRelay.projectSections': null, + 'mobileWeb.bundle.brotliRange': MobileWebBundleChunkParamsSchema, 'mobileWeb.bundle.chunk': MobileWebBundleChunkParamsSchema, 'mobileWeb.bundle.manifest': null, 'mobileWeb.bundle.range': MobileWebBundleChunkParamsSchema, From 533a31b742e86ab5a8c756e2290022986312a6d1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:06:43 -0500 Subject: [PATCH 02/29] fix(agent-launch): restarted agents keep the launch they ran with (#38998) * feat(agent-session): keep the permission mode a Claude session reports Claude names its live permission mode on prompt, tool and Stop hooks. Keep the root agent's latest value on the provider session, next to effort, so a resume without a recorded launch can tell whether the session ran in bypass. Ports the capture from #36359 by Nelef. * fix(agent-launch): resume an unrecorded launch bare unless it reported bypass Without a recorded launch, every restart path now resumes with no settings Arguments, env or permission default; the configured Command still runs. A session whose root agent last reported bypassPermissions keeps its bypass flag, so a hand-typed claude --dangerously-skip-permissions keeps it while a bare hand-typed claude never gains one (#29898). Account-switch restart used the shared rule only for Claude; it now uses it for every agent. Thanks to JustinCMR (#37304) for finding the account-switch and cold-restore gaps. * fix(terminal): keep the launch recipe of a window-started agent in main A window spawn hands main its recipe, but main kept only the token and agent, so restarts that main runs (workspace sleep capture, closed-worker reopen) found no recipe and resumed bare. Keep it with the launch authority, the same gate that admits the token. * fix(orchestration): reopen a closed worker with the recipe it launched with A closed worker's pane record goes away with its tab, so orchestration send reopened it bare (#37693). The worker row now keeps the recipe Orca launched the agent with, keeps it when a later capture of the same launch arrives without one (main loses it on a daemon reattach), and a reopen prefers it over a pane record that may hold another launch of the conversation. Adapted from #37757 by Pr1p, without a new column. * fix(terminal): keep a slept pane's earlier recipe only for the same launch A host sleep capture without a recipe reused the pane's earlier record for the same conversation. That is right after a daemon reattach, when Orca's launch still runs but main lost its recipe, and wrong for an agent typed into the shell that resumed the conversation: it never ran that launch. * fix(agent-session): restore a paired pane with the recipe its host recorded A client restoring a pane on a paired host sends only its recorded Arguments, and the host filled the rest from its current settings. A saved restore now replays the host's own record for that pane, or, with none at all, resumes bare and keeps a bypass only when this host's hooks saw it reported. History and other user-chosen resumes keep current settings. * fix(terminal): replay a saved note only for the launch main still holds A note belongs to the agent in a pane only while main holds that launch's token: main admits it at spawn and retires it when the launch command finishes, and a daemon reattach restores neither. Worker, host sleep and hook/quit captures now take a note only from that attested launch, and no longer copy an earlier record's note by agent or conversation match, which a typed resume in the same shell also satisfies. With no attested launch the unrecorded rule applies. Replaces the inferred orcaLaunched / recipeLostPaneKeys flags and drops the mock-only reopen and fixture-echo worker tests. * fix(agent-status): take a pane's note from the launch main attests The window carried a pane's saved note into each new record by agent and conversation match, so an agent typed into the shell that resumed the same conversation inherited an earlier launch's bypass. Main now sends the note of the launch it still holds with each status, and the window records only that, its own launch registration, or, for a restored replay of the very status a record was written from, that record's note. /clear and compact keep the note because main still holds the same launch. * fix(orchestration): reopen a worker with its own recipe or none A worker without its own note took the note of whichever pane record held the same conversation. Its reopen now always passes a complete recipe: its own note, or the unrecorded rule. The unrecorded bypass is also scoped to Claude, whose hooks are the ones that report a root agent's permission mode. * fix(agent-session): restore a paired pane from its source pane, never current settings A saved restore on a paired host looked up the destination pane, which a Wake opens as a new tab, and fell back to the host's current settings whenever the client sent its partial Arguments. The client now names the source pane (to hosts advertising agent-session.restore-source-pane.v1), and the host replays its note for that pane or, with none, resumes bare keeping only a bypass its own hooks saw there. Older hosts keep today's behavior. * fix(terminal): re-arm a launch across a main restart while its agent still runs The daemon outlives main, so it now keeps main's admitted launch (token and note) and drops it when that launch's command finishes. On reattach main re-arms the launch only when the same agent still runs in the foreground: nothing can be typed into a daemon PTY while main is down, so a running agent there is the one the unretired note launched. A shell foreground clears the daemon's note; an unknown read, including every Windows read, declines. Older daemons keep no note, so restore declines there as before. * fix(terminal): the daemon retires a launch note when its command finishes A launch can finish while main is down, so main's own retirement never sees it and a hand-typed agent in that pane would later pass the reattach foreground check. * fix(agent-status): a fresh Claude start never inherits the last process's permission mode A hand-typed resume of the same conversation may run without the bypass the previous process had, so only /clear and compact carry the mode forward. * fix(agent-session): name a restore's source pane in the host's own keys A mirrored pane's key carries the desktop tab prefix, so the host's exact lookup missed its saved note. * fix(terminal): retire a daemon launch note by its token from the one retirement owner A delayed reattach result or a late retire could clear a newer launch's note. Retires now name the token they retire, the reattach checks its PTY before clearing, and re-arming the same launch keeps the daemon's completion scanner. * fix(agent-status): only the launch owner's statement changes a pane's note Main now states the attested note, or null for none, on every hook status. An OSC or display update makes no statement, so after a completed turn it no longer erases an Orca launch's note; a typed resume still drops it through main's null. * fix(terminal): an attach leaves alone a launch main already holds The renderer attaches a pane the CLI just launched before its agent starts, so the attach saw the shell and retired the new launch's daemon note. * fix(agent-status): a paired client keeps a pane's note only on its host owner's word The host's session-tabs frame now says whether the Orca launch it admitted still runs on the published incarnation, and the client applies that fact whichever writer wins the status. With an owner's word the conversation no longer decides; an older host that says nothing keeps the same-conversation rule. * refactor(agent-status): one decision picks a pane's note for every reader The live entry, quit capture, provider-session updates, account restart and cold restore now ask the same owner-aware decision. An owner's none is final and, when it turns, ends the pane's registry entry and saved note, locally and from a paired host's frame. Cold restore and account restart take that answer for a live invocation and read the saved record only when none is live. Inlines single-use wrappers. * refactor(agent-status): a paired retirement test reaches the cold-restore command; trim comments Replaces the projection and continuity tests with one transition: host frame retires the launch, the next OSC keeps no note, and the cold-restore command has no old bypass. * fix(agent-status): an owner's none retires the pane's matched launch registry entry * chore: satisfy changed-code gates (type guards, SAFETY notes, fixed test clock) * fix(agent-launch): a teams launch keeps its note; a legacy leaf id restores without throwing * refactor(daemon): keep the session launch note on the process base so session.ts stays in budget * fix(agent-launch): a restarted terminal main can't vouch for keeps main's earlier launch rule * fix(agent-launch): read the pane's current terminal; a finished launch settles an undecided pane * fix(agent-launch): undecided and held launches end with their process A finished command, a fresh launch or a replacement process settles an undecided pane; a newer attach or finish supersedes a foreground read still in flight; a legacy relay reattach without an incarnation stays undecided; a held launch applies only to the process it was recorded for. * refactor(terminal): watch and arm a created terminal's launch in one call Keeps terminal-create.ts within the 300-line budget after main's growth. * refactor(terminal): keep main's launch evidence in one immutable record Replace the undecided flag, the reattach check map and the re-arm's writes to main's orchestration launch fields with one launchEvidence object (unknown, gone for an incarnation, or held with its recipe), owned by launch-evidence.ts. - A fresh admitted launch is held; any other fresh process is gone. - A finished command or confirmed exit is gone, sticky for its process: no reattach, legacy or noted, can revive it. - A replacement retires its predecessor's evidence without deciding the newcomer, which may still prove its own launch through a reattach. - A reattach note is held only after a foreground read sees its agent; the evidence object fences that read. - A gone launch clears its recipe and blocks both refill sites; a deliberate sleep snapshot keeps its recipe past the launch it captured. - launchToken, launchIncarnationId and launchAgent writes match main. * fix(terminal): keep launch evidence through a first incarnation and sleep snapshots - Learning a process's first incarnation binds its held or gone evidence to it, so a later attach can neither revive a finished launch nor drop a running one; a known X->Y replacement is fenced as before and now also clears the predecessor's recipe. - A status from no Orca launch leaves a sleep snapshot of its session for the wake and never takes that snapshot's recipe into a live or quit record. * fix(terminal): record a runtime split's fresh shell as running no launch * chore: rerun CI on main with the typecheck fix --- .../claude-structured-session-adapter.ts | 12 +- ...laude-structured-session-exit-lifecycle.ts | 16 ++ .../daemon-create-or-attach-result.ts | 6 + .../protocol/daemon-launch-note-protocol.ts | 62 +++++ .../daemon/protocol/daemon-rpc-contract.ts | 2 + .../adapter/daemon-pty-process-inspection.ts | 6 + .../adapter/daemon-spawn-result-identity.ts | 5 +- .../daemon/server/daemon-request-router.ts | 8 + .../server/daemon-terminal-admission.ts | 1 + .../daemon/sessions/session-launch-note.ts | 37 +++ .../sessions/session-process-inspection.ts | 12 +- src/main/daemon/sessions/session.ts | 5 +- .../testing/session-launch-note.test.ts | 26 ++ .../terminal-host-create-contract.ts | 2 + .../daemon/terminal-host/terminal-host.ts | 5 + src/main/ipc/agent-status-ipc-boundary.ts | 5 +- src/main/ipc/pty/ipc/spawn-commit.ts | 3 +- .../ipc/pty/pane/complete-pane-adoption.ts | 5 +- src/main/ipc/pty/pane/launch-authority.ts | 20 +- src/main/ipc/pty/runtime/controller.ts | 8 +- src/main/ipc/pty/runtime/spawn-commit.ts | 4 +- src/main/ipc/pty/spawn-commit-assembly.ts | 17 +- src/main/providers/pty-provider-contract.ts | 3 + src/main/providers/pty-spawn-result.ts | 3 + .../daemon-launch-note-reattach.test.ts | 250 ++++++++++++++++++ .../orca-runtime-saved-restore-launch.test.ts | 102 +++++++ .../worker-follow-up-lineage.ts | 20 +- .../worker/worker-follow-up-placement.ts | 7 + .../get-agent-session-execution-namespace.ts | 48 +++- .../saved-restore-launch-recipe.ts | 35 +++ .../projection/frame-agent-status.ts | 10 +- .../resume-agent-session-in-workspace.ts | 14 +- .../runtime-mobile-session-projection.ts | 3 +- .../identity/terminal-identity-source.ts | 2 + .../terminal/launch/launch-evidence.test.ts | 21 ++ .../terminal/launch/launch-evidence.ts | 118 +++++++++ .../launch/mobile-terminal-owned-create.ts | 7 +- .../launch/mobile-terminal-startup.ts | 4 +- .../terminal/launch/terminal-create.ts | 4 +- .../terminal/launch/terminal-launch-result.ts | 10 +- .../terminal/launch/terminal-split-in-main.ts | 4 + .../terminal/launch/terminal-split-surface.ts | 4 + .../terminal/lifecycle/daemon-launch-note.ts | 90 +++++++ .../terminal/lifecycle/pty-registration.ts | 33 ++- .../runtime-pty-controller-contract.ts | 2 + .../terminal-orchestration-authority.ts | 2 +- .../records/runtime-terminal-state-records.ts | 3 + .../stop/hook-status-resume-records.ts | 55 +++- .../launch-evidence-resume-records.test.ts | 115 ++++++++ .../stop/worktree-sleep-resume-recording.ts | 30 +-- .../stop/worktree-sleep-resume-records.ts | 31 ++- .../wait/terminal-interactive-wait.ts | 8 +- src/main/startup/main-window-agent-status.ts | 3 + .../pty-connection/pane-transport-options.ts | 3 + .../startup/cold-restore-resume-startup.ts | 11 +- .../pty-transport/pty-transport-types.ts | 2 + .../remote-runtime-pty-transport.ts | 11 +- .../agent-status-partial-updates.ts | 8 +- .../normalize-agent-status-event.ts | 9 +- .../accounts/agent-account-restart-startup.ts | 38 +-- .../resume/sleeping-agent-session-launch.ts | 4 +- .../agent-session-ensure-provider-session.ts | 26 +- .../agent-session-restore-source-pane.test.ts | 17 ++ .../agent-status-patch.ts | 30 ++- .../agent-status-primitives.ts | 3 +- .../state-equality-core.ts | 1 + .../runtime/web-session-tabs-sync/state.ts | 1 + .../agent-status/agent-status-contract.ts | 8 +- .../agent-status-launch-config.ts | 103 ++++++-- .../agent-status-live-entry-builder.ts | 32 +-- .../agent-status/agent-status-live-reducer.ts | 7 +- ...nt-status-paired-launch-retirement.test.ts | 85 ++++++ .../agent-status-provider-session-actions.ts | 20 +- ...ok-listener-claude-permission-mode.test.ts | 29 ++ .../pane-provider-session.ts | 25 +- .../choices/resume-launch-recipe.test.ts | 26 ++ .../choices/resume-launch-recipe.ts | 27 ++ .../choices/tui-agent-launch-defaults.ts | 16 -- .../agent-session-host-authority.ts | 2 + .../agent-session-runtime-capabilities.ts | 4 + .../agent-resume-record-equivalence.ts | 3 +- .../lifecycle/agent-session-resume.ts | 15 +- .../provider-session-live-settings.ts | 6 + .../contracts/agent-status-ipc-payload.ts | 7 +- .../contracts/agent-status-types.ts | 2 + .../workers/orchestration-worker-output.ts | 7 +- .../rpc-contract/agent-session-params.ts | 2 + .../runtime-mobile-session-tab-contracts.ts | 2 + .../workspace-session-sleeping-agents.ts | 3 +- .../terminal-agent-pane-launch.unit.test.ts | 6 +- 90 files changed, 1687 insertions(+), 222 deletions(-) create mode 100644 src/main/daemon/protocol/daemon-launch-note-protocol.ts create mode 100644 src/main/daemon/sessions/session-launch-note.ts create mode 100644 src/main/daemon/sessions/testing/session-launch-note.test.ts create mode 100644 src/main/runtime/daemon-launch-note-reattach.test.ts create mode 100644 src/main/runtime/orca-runtime-saved-restore-launch.test.ts create mode 100644 src/main/runtime/session-construction/agent-session/saved-restore-launch-recipe.ts create mode 100644 src/main/runtime/terminal/launch/launch-evidence.test.ts create mode 100644 src/main/runtime/terminal/launch/launch-evidence.ts create mode 100644 src/main/runtime/terminal/lifecycle/daemon-launch-note.ts create mode 100644 src/main/runtime/terminal/stop/launch-evidence-resume-records.test.ts create mode 100644 src/renderer/src/runtime/hosted-terminals/creation/agent-launch/agent-session-restore-source-pane.test.ts create mode 100644 src/renderer/src/store/slices/agent-status/agent-status-paired-launch-retirement.test.ts create mode 100644 src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-permission-mode.test.ts create mode 100644 src/shared/agent-launch/choices/resume-launch-recipe.test.ts create mode 100644 src/shared/agent-launch/choices/resume-launch-recipe.ts create mode 100644 src/shared/agent-session/lifecycle/provider-session-live-settings.ts diff --git a/src/main/claude/structured/claude-structured-session-adapter.ts b/src/main/claude/structured/claude-structured-session-adapter.ts index 01c999c9dee..53d4d7a7df8 100644 --- a/src/main/claude/structured/claude-structured-session-adapter.ts +++ b/src/main/claude/structured/claude-structured-session-adapter.ts @@ -30,6 +30,7 @@ import { claudeStoppedRequestEndWait } from '../claude-request-end-wait' import { finishClaudeCloseAfterExit, drainClaudeObservedExits, + liveClaudeSession, observeClaudeSessionExit, settleClaudeUnexpectedExit, type ClaudeExitLifecycle @@ -296,15 +297,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } private session(sessionId: string): ClaudeSession { - const session = this.sessions.get(sessionId) - if (!session) { - // A child that just exited is named by its own diagnostic, not by its absence. - throw ( - this.exits.get(sessionId)?.error ?? - this.settledExitErrors.get(sessionId) ?? - new Error(`no live claude stream-json session for ${sessionId}`) - ) - } - return session + return liveClaudeSession(this.exitLifecycle, sessionId) } } diff --git a/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts b/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts index f8ca501b502..a2e09a80453 100644 --- a/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/structured/claude-structured-session-exit-lifecycle.ts @@ -255,3 +255,19 @@ export function finishClaudeCloseAfterExit(input: { }) ) } + +export function liveClaudeSession( + lifecycle: ClaudeExitLifecycle, + sessionId: string +): ClaudeSession { + const session = lifecycle.sessions.get(sessionId) + if (!session) { + // A child that just exited is named by its own diagnostic, not by its absence. + throw ( + lifecycle.exits.get(sessionId)?.error ?? + lifecycle.settledExitErrors.get(sessionId) ?? + new Error(`no live claude stream-json session for ${sessionId}`) + ) + } + return session +} diff --git a/src/main/daemon/protocol/daemon-create-or-attach-result.ts b/src/main/daemon/protocol/daemon-create-or-attach-result.ts index f6403d313f1..3a98790f081 100644 --- a/src/main/daemon/protocol/daemon-create-or-attach-result.ts +++ b/src/main/daemon/protocol/daemon-create-or-attach-result.ts @@ -1,6 +1,7 @@ import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent' import type { ShellReadyState } from './daemon-rpc-contract' import type { TerminalSnapshot } from './terminal-snapshot' +import type { DaemonLaunchNote } from './daemon-launch-note-protocol' import type { AgentSessionClaimedSpawnResult } from '../../../shared/agent-session/agent-session-host-authority' import type { PtyIncarnationId } from '../../../shared/terminal/pty/lifecycle/pty-incarnation' @@ -11,6 +12,8 @@ export type DaemonCreateOrAttachResult = { shellState: ShellReadyState historySeeded?: boolean launchAgent?: TuiAgent + /** The launch main armed and has not retired, null for none; older daemons omit it. */ + launchNote?: DaemonLaunchNote | null /** Undefined only when talking to a daemon predating WSL session context. */ wslDistro?: string | null agentSessionEnsure?: AgentSessionClaimedSpawnResult @@ -23,15 +26,18 @@ export type DaemonCreateOrAttachResult = { export function getDaemonSessionResultMetadata(session: { launchAgent: TuiAgent | null + launchNote: { current: DaemonLaunchNote | null } historySeeded: boolean | undefined wslDistro: string | null }): { launchAgent?: TuiAgent + launchNote: DaemonLaunchNote | null historySeeded?: boolean wslDistro: string | null } { return { ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), + launchNote: session.launchNote.current, ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), // Null identifies native sessions; only older daemons omit the field. wslDistro: session.wslDistro diff --git a/src/main/daemon/protocol/daemon-launch-note-protocol.ts b/src/main/daemon/protocol/daemon-launch-note-protocol.ts new file mode 100644 index 00000000000..d00fbb1970d --- /dev/null +++ b/src/main/daemon/protocol/daemon-launch-note-protocol.ts @@ -0,0 +1,62 @@ +import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session/lifecycle/agent-session-resume' + +/** The Orca launch running in a session, kept by the daemon so it outlives a main restart. */ +export type DaemonLaunchNote = { launchToken: string; launchConfig: SleepingAgentLaunchConfig } + +/** Main arms the launch it admits and retires it by token, so a stale retire cannot clear a newer launch. */ +export type DaemonLaunchNoteChange = { arm: DaemonLaunchNote } | { retire: string } + +export type SetLaunchNoteRequest = { + id: string + type: 'setLaunchNote' + payload: { sessionId: string; change: DaemonLaunchNoteChange } +} + +const isString = (value: unknown): value is string => typeof value === 'string' +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null +const isStringRecord = (value: unknown): value is Record => + isRecord(value) && Object.values(value).every(isString) + +export function parseDaemonLaunchNoteChange(raw: unknown): DaemonLaunchNoteChange | null { + if (!isRecord(raw)) { + return null + } + const { arm, retire } = raw + if (isString(retire)) { + return { retire } + } + const note = parseDaemonLaunchNote(arm) + return note ? { arm: note } : null +} + +function parseDaemonLaunchNote(raw: unknown): DaemonLaunchNote | null { + if (!isRecord(raw)) { + return null + } + const { launchToken, launchConfig: config } = raw + if (!isString(launchToken) || launchToken.length === 0 || launchToken.length > 128) { + return null + } + if (!isRecord(config)) { + return null + } + const { agentArgs, agentEnv, agentCommand, ompResumeFilePath } = config + if ( + !isString(agentArgs) || + !isStringRecord(agentEnv) || + (agentCommand !== undefined && !isString(agentCommand)) || + (ompResumeFilePath !== undefined && !isString(ompResumeFilePath)) + ) { + return null + } + return { + launchToken, + launchConfig: { + agentArgs, + agentEnv: { ...agentEnv }, + ...(agentCommand ? { agentCommand } : {}), + ...(ompResumeFilePath ? { ompResumeFilePath } : {}) + } + } +} diff --git a/src/main/daemon/protocol/daemon-rpc-contract.ts b/src/main/daemon/protocol/daemon-rpc-contract.ts index 3515886b2e6..16d9f117b96 100644 --- a/src/main/daemon/protocol/daemon-rpc-contract.ts +++ b/src/main/daemon/protocol/daemon-rpc-contract.ts @@ -25,6 +25,7 @@ import type { import type * as HistorySeedProtocol from '../history/seed-transfer/terminal-history-seed-transfer-protocol' import type * as PtyOwnerQueryProtocol from './daemon-pty-owner-query-protocol' import type { TerminalSnapshot } from './terminal-snapshot' +import type { SetLaunchNoteRequest } from './daemon-launch-note-protocol' export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' @@ -270,6 +271,7 @@ export type DaemonRequest = | TakePendingOutputRequest | PtyOwnerQueryProtocol.CloseStartupQueryAuthorityRequest | PtyOwnerQueryProtocol.SetColorQueryReplyColorsRequest + | SetLaunchNoteRequest export type RpcResponseOk = { id: string diff --git a/src/main/daemon/providers/adapter/daemon-pty-process-inspection.ts b/src/main/daemon/providers/adapter/daemon-pty-process-inspection.ts index ea457dab4a8..fa6bce3f717 100644 --- a/src/main/daemon/providers/adapter/daemon-pty-process-inspection.ts +++ b/src/main/daemon/providers/adapter/daemon-pty-process-inspection.ts @@ -1,3 +1,4 @@ +import type { DaemonLaunchNoteChange } from '../../protocol/daemon-launch-note-protocol' import { PTY_FOREGROUND_OBSERVATION_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, @@ -90,6 +91,11 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot } } + setLaunchNote(id: string, change: DaemonLaunchNoteChange): void { + // Why fire and forget: an older daemon rejects the request and keeps no note, so restore declines. + void this.client.request('setLaunchNote', { sessionId: id, change }).catch(() => undefined) + } + async confirmShellForeground(id: string): Promise { try { const result = await this.client.request<{ confirmed: boolean }>('confirmShellForeground', { diff --git a/src/main/daemon/providers/adapter/daemon-spawn-result-identity.ts b/src/main/daemon/providers/adapter/daemon-spawn-result-identity.ts index 406706d0da9..2a8b1377504 100644 --- a/src/main/daemon/providers/adapter/daemon-spawn-result-identity.ts +++ b/src/main/daemon/providers/adapter/daemon-spawn-result-identity.ts @@ -3,10 +3,11 @@ import type { DaemonCreateOrAttachResult as CreateOrAttachResult } from '../../p export function daemonSpawnResultIdentity( result: CreateOrAttachResult -): Pick { +): Pick { return { ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), - ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}) + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + ...(result.launchNote !== undefined ? { launchNote: result.launchNote } : {}) } } diff --git a/src/main/daemon/server/daemon-request-router.ts b/src/main/daemon/server/daemon-request-router.ts index 3955908162e..137bcd04de0 100644 --- a/src/main/daemon/server/daemon-request-router.ts +++ b/src/main/daemon/server/daemon-request-router.ts @@ -1,3 +1,4 @@ +import { parseDaemonLaunchNoteChange } from '../protocol/daemon-launch-note-protocol' import { handleDaemonTerminalKill } from './daemon-terminal-kill' import { handleDaemonFencedTermination } from './daemon-fenced-termination' import { performance } from 'node:perf_hooks' @@ -146,6 +147,13 @@ export class DaemonRequestRouter { return { confirmed: await this.options.host.confirmShellForeground(request.payload.sessionId) } + case 'setLaunchNote': { + const change = parseDaemonLaunchNoteChange(request.payload.change) + if (change) { + this.options.host.setLaunchNote(request.payload.sessionId, change) + } + return {} + } case 'clearScrollback': this.options.host.clearScrollback(request.payload.sessionId) return {} diff --git a/src/main/daemon/server/daemon-terminal-admission.ts b/src/main/daemon/server/daemon-terminal-admission.ts index d304e29ea9c..39812c791a1 100644 --- a/src/main/daemon/server/daemon-terminal-admission.ts +++ b/src/main/daemon/server/daemon-terminal-admission.ts @@ -183,6 +183,7 @@ export class DaemonTerminalAdmission { shellState: result.shellState, incarnationId: result.incarnationId, ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + ...(result.launchNote !== undefined ? { launchNote: result.launchNote } : {}), wslDistro: result.wslDistro, ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), diff --git a/src/main/daemon/sessions/session-launch-note.ts b/src/main/daemon/sessions/session-launch-note.ts new file mode 100644 index 00000000000..c8be96ad8df --- /dev/null +++ b/src/main/daemon/sessions/session-launch-note.ts @@ -0,0 +1,37 @@ +import { + createOsc133CommandFinishedScanner, + type Osc133CommandFinishedScanner +} from '../../../shared/terminal/output/terminal-osc133-command-finished' +import type { + DaemonLaunchNote, + DaemonLaunchNoteChange +} from '../protocol/daemon-launch-note-protocol' + +/** Main's live launch here; retired on command-finished too, since it can finish while main is down. */ +export class SessionLaunchNote { + current: DaemonLaunchNote | null = null + private retirement: Osc133CommandFinishedScanner | null = null + + scan(data: string): void { + this.retirement?.scan(data) + } + + change(change: DaemonLaunchNoteChange): void { + const token = this.current?.launchToken + if ('retire' in change) { + if (change.retire === token) { + this.current = null + this.retirement = null + } + return + } + // Re-arming the same launch keeps the scanner, whose carry may hold a split marker. + const note = change.arm + if (note.launchToken !== token) { + this.current = note + this.retirement = createOsc133CommandFinishedScanner(() => + this.change({ retire: note.launchToken }) + ) + } + } +} diff --git a/src/main/daemon/sessions/session-process-inspection.ts b/src/main/daemon/sessions/session-process-inspection.ts index 513e282b496..8460a1540cf 100644 --- a/src/main/daemon/sessions/session-process-inspection.ts +++ b/src/main/daemon/sessions/session-process-inspection.ts @@ -1,8 +1,18 @@ import type { ChildProcessVerdictRead, SubprocessHandle } from './session-subprocess-handle' import type { TerminalForegroundSample } from '../../../shared/terminal/pty/lifecycle/terminal-foreground-observation' +import { SessionLaunchNote } from './session-launch-note' export class SessionProcessInspection { - constructor(protected readonly subprocess: SubprocessHandle) {} + /** Main's launch note for this process; Session feeds it the output that can retire it. */ + readonly launchNote = new SessionLaunchNote() + + readonly processNameIsSpawnFile: boolean + readonly shellIsRootChild: boolean + + constructor(protected readonly subprocess: SubprocessHandle) { + this.processNameIsSpawnFile = subprocess.processNameIsSpawnFile === true + this.shellIsRootChild = subprocess.shellIsRootChild === true + } inspectChildProcesses(fresh?: boolean, ignoreDetached?: boolean): ChildProcessVerdictRead { return this.subprocess.inspectChildProcesses?.(fresh, ignoreDetached) ?? 'unverifiable' diff --git a/src/main/daemon/sessions/session.ts b/src/main/daemon/sessions/session.ts index 0463d39dcca..38c89f0a845 100644 --- a/src/main/daemon/sessions/session.ts +++ b/src/main/daemon/sessions/session.ts @@ -28,8 +28,6 @@ export class Session extends SessionProcessInspection { readonly terminalHandle: string | null readonly launchAgent: TuiAgent | null readonly wslDistro: string | null - readonly processNameIsSpawnFile: boolean - readonly shellIsRootChild: boolean private _state: SessionState = 'running' private _exitCode: number | null = null exitCause: TerminalExitCause | undefined @@ -51,8 +49,6 @@ export class Session extends SessionProcessInspection { this.terminalHandle = opts.terminalHandle ?? null this.launchAgent = opts.launchAgent ?? null this.wslDistro = opts.wslDistro ?? null - this.processNameIsSpawnFile = opts.subprocess.processNameIsSpawnFile === true - this.shellIsRootChild = opts.subprocess.shellIsRootChild === true this.onSessionExit = opts.onExit const isAlive = () => !this._disposed && this._state !== 'exited' const pipeline = createSessionOutputPipeline(opts, isAlive) @@ -80,6 +76,7 @@ export class Session extends SessionProcessInspection { try { this.subprocess.onData((data) => { if (!this._disposed) { + this.launchNote.scan(data) this.shellReady.ingestSubprocessData(data) } }) diff --git a/src/main/daemon/sessions/testing/session-launch-note.test.ts b/src/main/daemon/sessions/testing/session-launch-note.test.ts new file mode 100644 index 00000000000..a2daee9b086 --- /dev/null +++ b/src/main/daemon/sessions/testing/session-launch-note.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { SessionLaunchNote } from '../session-launch-note' + +const NOTE = { launchToken: 'launch-1', launchConfig: { agentArgs: '--verbose', agentEnv: {} } } +const NEXT = { launchToken: 'launch-2', launchConfig: { agentArgs: '', agentEnv: {} } } + +// The launch can finish while main is down, so the daemon must retire the note without main. +describe('a daemon session launch note', () => { + it('retires when the launched command finishes, even across a same-launch re-arm', () => { + const note = new SessionLaunchNote() + note.change({ arm: NOTE }) + note.scan('agent output\x1b]133;') + note.change({ arm: NOTE }) + expect(note.current).toEqual(NOTE) + note.scan('D;143\x07') + expect(note.current).toBeNull() + }) + + it('ignores a retire for an earlier launch', () => { + const note = new SessionLaunchNote() + note.change({ arm: NOTE }) + note.change({ arm: NEXT }) + note.change({ retire: 'launch-1' }) + expect(note.current).toEqual(NEXT) + }) +}) diff --git a/src/main/daemon/terminal-host/terminal-host-create-contract.ts b/src/main/daemon/terminal-host/terminal-host-create-contract.ts index 55bb45c6697..c90d5109dc4 100644 --- a/src/main/daemon/terminal-host/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host/terminal-host-create-contract.ts @@ -1,3 +1,4 @@ +import type { DaemonLaunchNote } from '../protocol/daemon-launch-note-protocol' import type { StartupCommandDelivery } from '../../../shared/agent-launch/startup/codex-startup-delivery' import type { TuiAgent } from '../../../shared/agent-launch/catalog/tui-agent' import type { ShellReadyState } from '../protocol/daemon-rpc-contract' @@ -52,6 +53,7 @@ export type CreateOrAttachResult = { shellState: ShellReadyState historySeeded?: boolean launchAgent?: TuiAgent + launchNote?: DaemonLaunchNote | null wslDistro: string | null attachToken: symbol incarnationId: PtyIncarnationId diff --git a/src/main/daemon/terminal-host/terminal-host.ts b/src/main/daemon/terminal-host/terminal-host.ts index 6e4934f514f..970d51c97fc 100644 --- a/src/main/daemon/terminal-host/terminal-host.ts +++ b/src/main/daemon/terminal-host/terminal-host.ts @@ -1,3 +1,4 @@ +import type { DaemonLaunchNoteChange } from '../protocol/daemon-launch-note-protocol' import { terminalSessionExitCode } from './terminal-host-returned-process' import { runTerminalCreation, type PendingTerminalCreation } from './terminal-creation-work' import { beginTerminalHostFencedTermination } from './terminal-host-fenced-termination' @@ -261,6 +262,10 @@ export class TerminalHost { ) } + setLaunchNote(sessionId: string, change: DaemonLaunchNoteChange): void { + this.sessions.get(sessionId)?.launchNote.change(change) + } + clearScrollback(sessionId: string): void { getAliveTerminalHostSession(this.sessions, sessionId).clearScrollback() } diff --git a/src/main/ipc/agent-status-ipc-boundary.ts b/src/main/ipc/agent-status-ipc-boundary.ts index ed1528e6aff..6c314baf534 100644 --- a/src/main/ipc/agent-status-ipc-boundary.ts +++ b/src/main/ipc/agent-status-ipc-boundary.ts @@ -12,7 +12,8 @@ export type AgentStatusRuntimeEnrichment = Pick< | 'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey' | 'getTerminalProcessIncarnation' -> +> & + Partial> const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160 @@ -94,9 +95,11 @@ export function enrichAgentStatusIpcPayload( } const terminalHandle = runtime.getAgentStatusTerminalHandleForPaneKey(data.paneKey) const orchestration = runtime.getAgentStatusOrchestrationContextForPaneKey(data.paneKey) + const launchConfig = runtime.getLaunchStatementForPaneKey?.(data.paneKey, data.agentType) return { ...data, ...(terminalHandle ? { terminalHandle } : {}), + ...(launchConfig !== undefined ? { launchConfig } : {}), ...(orchestration ? { orchestration } : {}) } } diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 0c9885cc9b7..c6452b61af3 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -42,7 +42,8 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise inspectProcessFromRuntimeController(ptyId, options), confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), confirmShellForeground: (ptyId) => confirmShellForegroundFromRuntimeController(ptyId), + // Why local only: the daemon that outlives main is this host's. + setLaunchNote: (ptyId, change) => { + if (hasPtyProviderForInspection(ptyId)) { + getProviderForPty(ptyId).setLaunchNote?.(ptyId, change) + } + }, getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 77166e31909..0af230216e9 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -32,7 +32,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { worktreeId: args.worktreeId, shellOverride: ctx.daemonShellOverride, rendererLaunchAuthority: null, - launchedAgent: null + launchedAgent: null, + // The runtime's own create records its launch once the spawn commits. + freshSpawn: false }, preAllocatedHandle: args.preAllocatedHandle, // Runtime spawns bypass renderer IPC; publish reverse pane lookup for hooks and cleanup. diff --git a/src/main/ipc/pty/spawn-commit-assembly.ts b/src/main/ipc/pty/spawn-commit-assembly.ts index d2048c4b8ab..09df9060ebe 100644 --- a/src/main/ipc/pty/spawn-commit-assembly.ts +++ b/src/main/ipc/pty/spawn-commit-assembly.ts @@ -19,7 +19,11 @@ import { } from './host-env/codex-home' import { commitAttachedPtySize } from './delivery/attached-pty-size' import { seedTerminalRestoreRecordsFromSpawnResult } from './pane/agent-session-owners' -import { admitProviderReattachLaunchIdentity } from './pane/launch-authority' +import { + admitProviderReattachLaunchIdentity, + reattachLaunchBinding, + type admitRendererAgentLaunchAuthority +} from './pane/launch-authority' import type { ReplacedPaneOwner } from './pane/replaced-pane-owner' import { admitPtySpawnOwnership, registerPersistedPtySpawn } from './pane/spawn-registration' import type { PtySpawnOutcome } from './spawn-outcome' @@ -90,9 +94,11 @@ export type PtySpawnCommitInput = { store: Store | undefined worktreeId: string | null | undefined shellOverride: string | undefined - rendererLaunchAuthority: { launchToken: string; launchAgent: TuiAgent } | null + rendererLaunchAuthority: ReturnType /** The agent a fresh spawn's launch command starts; null for reattaches, adoptions and shells. */ launchedAgent: TuiAgent | null + /** Neither a reattach nor an adoption of a live pane's process. */ + freshSpawn: boolean } preAllocatedHandle: string | null | undefined spawnTiming?: PtySpawnTiming @@ -162,7 +168,9 @@ export async function commitPtySpawn(input: PtySpawnCommitInput): Promise Promise /** Fresh execution-host proof that the spawned shell owns the PTY foreground. */ confirmShellForeground?: (id: string) => Promise + /** Persist main's live launch where the PTY outlives main; only the daemon keeps one. */ + setLaunchNote?: (id: string, change: DaemonLaunchNoteChange) => void serialize(ids: string[]): Promise revive(state: string): Promise listProcesses(opts?: { diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 9a949cd87f6..ced9ec130a2 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -1,3 +1,4 @@ +import type { DaemonLaunchNote } from '../daemon/protocol/daemon-launch-note-protocol' import type { TerminalExitCause } from '../../shared/terminal/pty/lifecycle/terminal-exit-cause' import type { TerminalLaunchResult } from '../../shared/agent-session/lifecycle/agent-session-resume' import type { TerminalOscLinkRange } from '../../shared/terminal/links/terminal-osc-link-ranges' @@ -25,6 +26,8 @@ export type PtySpawnResult = TerminalLaunchResult & { pid?: number | null /** Minimal allowlisted launch ownership returned by daemon reattach. */ launchAgent?: TuiAgent + /** The unretired launch main armed before a restart, returned by daemon reattach. */ + launchNote?: DaemonLaunchNote | null /** Local WSL context: null is native; undefined is unavailable/legacy. */ wslDistro?: string | null /** Daemon reattach ANSI screen restore for xterm. */ diff --git a/src/main/runtime/daemon-launch-note-reattach.test.ts b/src/main/runtime/daemon-launch-note-reattach.test.ts new file mode 100644 index 00000000000..66185017d8f --- /dev/null +++ b/src/main/runtime/daemon-launch-note-reattach.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { makePaneKey } from '../../shared/workspace-layout/stable-pane-id' + +const PTY_ID = 'pty-reattached' +const TAB_ID = '11111111-1111-4111-8111-111111111111' +const LEAF_ID = '22222222-2222-4222-8222-222222222222' +const INCARNATION = 'reattach-incarnation' +const LAUNCH_CONFIG = { + agentArgs: '--dangerously-skip-permissions --verbose', + agentEnv: { K: 'v' } +} + +const NOTE = { launchToken: 'launch-1', launchConfig: LAUNCH_CONFIG } + +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) + +function attach( + runtime: OrcaRuntimeService, + launchNote: typeof NOTE | null | undefined, + incarnationId = INCARNATION +) { + runtime.registerPty(PTY_ID, 'worktree-1', null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId, + providerReattachLaunchIdentity: { incarnationId, launchAgent: 'claude' }, + reattach: launchNote === undefined ? {} : { launchNote } + }) +} + +function holdLaunch(runtime: OrcaRuntimeService) { + runtime.registerPty(PTY_ID, 'worktree-1', null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION, + agentLaunchAuthority: { + launchToken: 'launch-1', + launchAgent: 'claude', + launchConfig: LAUNCH_CONFIG + } + }) +} + +// Some hosts register a spawn before they report its incarnation. +async function launchedBeforeItsIncarnation(foreground: 'agent' | 'unknown') { + const runtime = new OrcaRuntimeService(null) + Object.assign(runtime, { + resolveTerminalWorkspaceLaunchScope: async () => ({ + id: 'worktree-1', + path: '/tmp/worktree-1', + connectionId: null + }) + }) + runtime.setPtyController({ + write: vi.fn(() => true), + kill: vi.fn(), + getForegroundProcess: async () => null, + setLaunchNote: vi.fn(), + spawn: async () => { + runtime.registerPty(PTY_ID, 'worktree-1', null, { tabId: TAB_ID, leafId: LEAF_ID }) + return { id: PTY_ID } + } + }) + vi.spyOn(runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) + await runtime.createTerminal('id:worktree-1', { + tabId: TAB_ID, + leafId: LEAF_ID, + command: 'claude', + launchAgent: 'claude', + launchConfig: LAUNCH_CONFIG, + presentation: 'background' + }) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toEqual(LAUNCH_CONFIG) + return runtime +} + +function makeRuntime() { + const runtime = new OrcaRuntimeService(null) + const setLaunchNote = vi.fn() + runtime.setPtyController({ + write: vi.fn(() => true), + kill: vi.fn(), + getForegroundProcess: async () => null, + setLaunchNote + }) + return { runtime, setLaunchNote } +} + +async function reattach( + foreground: 'agent' | 'shell' | 'unknown', + mainHoldsLaunch = false, + launchNote: typeof NOTE | null | 'omitted' = NOTE, + finishedOnReattach = false +) { + const { runtime, setLaunchNote } = makeRuntime() + vi.spyOn(runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground) + if (mainHoldsLaunch) { + holdLaunch(runtime) + } + attach(runtime, launchNote === 'omitted' ? undefined : launchNote) + await new Promise((resolve) => setTimeout(resolve, 0)) + if (finishedOnReattach) { + attach(runtime, null) + } + return { + config: runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude'), + retired: setLaunchNote.mock.calls.some(([, change]) => 'retire' in change) + } +} + +// Nothing reaches a daemon PTY while main is down, so only the same agent still running keeps the note. +describe('a daemon reattach after a main restart', () => { + it('re-arms the unretired launch while its agent still runs', async () => { + expect(await reattach('agent')).toEqual({ config: LAUNCH_CONFIG, retired: false }) + }) + + it('says no launch runs when the foreground is the shell', async () => { + expect(await reattach('shell')).toEqual({ config: null, retired: true }) + }) + + it('says no launch runs when the daemon retired its note', async () => { + expect((await reattach('agent', false, null)).config).toBeNull() + }) + + // Undecided: readers keep their earlier same-session rule rather than drop the settings. + it('makes no statement when the foreground is unreadable', async () => { + expect((await reattach('unknown')).config).toBeUndefined() + }) + + it('settles an undecided pane when a later attach reports its launch finished', async () => { + expect((await reattach('unknown', false, NOTE, true)).config).toBeNull() + }) + + it('makes no statement for a daemon that keeps no launch notes', async () => { + expect((await reattach('agent', false, 'omitted')).config).toBeUndefined() + }) + + it('settles an undecided pane once main sees its command finish', async () => { + const { runtime } = makeRuntime() + vi.spyOn(runtime, 'readLaunchedAgentForeground').mockResolvedValue('unknown') + attach(runtime, NOTE) + await new Promise((resolve) => setTimeout(resolve, 0)) + runtime.emitDaemonPtyTransientFact(PTY_ID, { kind: 'command-finished', exitCode: 0 }) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toBeNull() + }) + + it('does not let an earlier foreground read revive a launch a later attach saw finish', async () => { + const { runtime } = makeRuntime() + let answer: (foreground: 'agent') => void = () => {} + vi.spyOn(runtime, 'readLaunchedAgentForeground').mockReturnValue( + new Promise((resolve) => (answer = resolve)) + ) + attach(runtime, NOTE) + attach(runtime, null) + answer('agent') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toBeNull() + }) + + it('makes no statement for an older relay that reports no incarnation', () => { + const { runtime } = makeRuntime() + runtime.registerPty(PTY_ID, 'worktree-1', 'ssh-1', { + tabId: TAB_ID, + leafId: LEAF_ID, + reattach: {} + }) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toBeUndefined() + }) + + it('starts a replacement process decided even when its spawn is announced first', () => { + const { runtime } = makeRuntime() + const binding = { tabId: TAB_ID, leafId: LEAF_ID } + attach(runtime, undefined) + runtime.onPtySpawned(PTY_ID, 'next-incarnation') + runtime.registerPty(PTY_ID, 'worktree-1', null, { + ...binding, + incarnationId: 'next-incarnation' + }) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toBeNull() + }) + + it('drops a held launch when a new process replaces it in the same terminal', () => { + const { runtime } = makeRuntime() + const binding = { tabId: TAB_ID, leafId: LEAF_ID } + holdLaunch(runtime) + runtime.onPtySpawned(PTY_ID, 'next-incarnation') + runtime.registerPty(PTY_ID, 'worktree-1', null, { + ...binding, + incarnationId: 'next-incarnation' + }) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toBeNull() + }) + + // A finished launch stays finished for its process; only a fresh launch supersedes it. + it.each([ + ['a daemon that keeps no notes', undefined], + ['a note the daemon has not retired', NOTE] + ] as const)('keeps a finished launch gone when %s reattaches', async (_, launchNote) => { + const { runtime } = makeRuntime() + vi.spyOn(runtime, 'readLaunchedAgentForeground').mockResolvedValue('agent') + holdLaunch(runtime) + runtime.emitDaemonPtyTransientFact(PTY_ID, { kind: 'command-finished', exitCode: 0 }) + attach(runtime, launchNote) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toBeNull() + }) + + it('lets a replacement process prove its own launch through a reattach', async () => { + const { runtime } = makeRuntime() + vi.spyOn(runtime, 'readLaunchedAgentForeground').mockResolvedValue('agent') + attach(runtime, undefined) + attach(runtime, NOTE, 'next-incarnation') + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toEqual(LAUNCH_CONFIG) + }) + + // Learning the process's first incarnation is no replacement: what main knew still holds. + it.each([ + ['keeps no notes', undefined], + ['still has the note', NOTE] + ] as const)( + 'keeps a launch that finished before its first incarnation gone when a host that %s attaches', + async (_, launchNote) => { + const runtime = await launchedBeforeItsIncarnation('agent') + runtime.emitDaemonPtyTransientFact(PTY_ID, { kind: 'command-finished', exitCode: 0 }) + attach(runtime, launchNote) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toBeNull() + } + ) + + it.each([ + ['keeps no notes', undefined], + ['sends the note late', NOTE] + ] as const)( + 'keeps a running launch when its first incarnation arrives from a host that %s', + async (_, launchNote) => { + const runtime = await launchedBeforeItsIncarnation('unknown') + attach(runtime, launchNote) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(runtime.getLaunchStatementForPaneKey(PANE_KEY, 'claude')).toEqual(LAUNCH_CONFIG) + } + ) + + // The renderer attaches a pane the CLI launched before its agent has started. + it('leaves a launch main already holds when the attach still sees its shell', async () => { + expect(await reattach('shell', true)).toEqual({ config: LAUNCH_CONFIG, retired: false }) + }) +}) diff --git a/src/main/runtime/orca-runtime-saved-restore-launch.test.ts b/src/main/runtime/orca-runtime-saved-restore-launch.test.ts new file mode 100644 index 00000000000..fd03c943bd2 --- /dev/null +++ b/src/main/runtime/orca-runtime-saved-restore-launch.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { EnsureAgentSessionParams } from '../../shared/rpc-contract/agent-session-params' +import type { AgentStatusIpcPayload } from '../../shared/agent-status/contracts/agent-status-ipc-payload' +import { OrcaRuntimeService } from './orca-runtime' +import { partialRuntimeStore } from './orca-runtime-tests/runtime-store-partial-fixture' + +const TAB = '11111111-1111-4111-8111-111111111111' +const SOURCE_PANE = `${TAB}:22222222-2222-4222-8222-222222222222` +const NEW_TAB = '33333333-3333-4333-8333-333333333333' +const NEW_LEAF = '44444444-4444-4444-8444-444444444444' +const SESSION = { key: 'session_id', id: 'conversation-1' } as const + +function restoreOnHost(args: { + recorded?: { agentArgs: string; agentEnv: Record } + hookMode?: string +}) { + const runtime = new OrcaRuntimeService( + partialRuntimeStore({ + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: { claude: '--model current' }, + agentDefaultEnv: { claude: { CURRENT: '1' } } + }) + }) + ) + const record = { + paneKey: SOURCE_PANE, + worktreeId: 'worktree-1', + agent: 'claude' as const, + providerSession: SESSION, + prompt: '', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1, + ...(args.recorded ? { launchConfig: args.recorded } : {}) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the host's restore reads only the row's pane, agent and provider session. + const hookRow = { + paneKey: SOURCE_PANE, + agentType: 'claude', + providerSession: { ...SESSION, permissionMode: args.hookMode } + } as AgentStatusIpcPayload + Object.assign(runtime, { + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({ + id: 'worktree-1', + path: '/tmp/worktree-1', + connectionId: null + })), + getWorkspaceSessionForWorktree: () => ({ + sleepingAgentSessionsByPaneKey: { [SOURCE_PANE]: record } + }), + getAgentProviderSessionSnapshotFn: () => [hookRow] + }) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_restore', + tabId: NEW_TAB, + paneKey: `${NEW_TAB}:${NEW_LEAF}`, + ptyId: 'pty-restore', + worktreeId: 'worktree-1', + title: null, + surface: 'background' + }) + return { + restore: () => + runtime.ensureAgentSession( + EnsureAgentSessionParams.parse({ + kind: 'explicit', + resumeOrigin: 'saved', + restoreSourcePaneKey: SOURCE_PANE, + worktree: 'id:worktree-1', + agent: 'claude', + providerSession: SESSION, + agentArgs: '--model client', + placement: { tabId: NEW_TAB, leafId: NEW_LEAF } + }) + ), + launched: () => createTerminal.mock.calls[0]?.[1] + } +} + +// A Wake opens a new tab: the host must replay the pane that ran, not the new pane or its own settings. +describe('a saved restore into a new pane on its host', () => { + it("replays the source pane's note", async () => { + const host = restoreOnHost({ + recorded: { agentArgs: '--verbose', agentEnv: { RECORDED: '1' } } + }) + await host.restore() + expect(host.launched()?.command).toContain('--verbose') + expect(host.launched()?.command).not.toContain('--model') + expect(host.launched()?.env).toMatchObject({ RECORDED: '1' }) + expect(host.launched()?.env).not.toHaveProperty('CURRENT') + }) + + it("without a note, keeps only the bypass the host's hooks saw, never current settings", async () => { + const host = restoreOnHost({ hookMode: 'bypassPermissions' }) + await host.restore() + expect(host.launched()?.command).toContain('--dangerously-skip-permissions') + expect(host.launched()?.command).not.toContain('--model') + expect(host.launched()?.env).not.toHaveProperty('CURRENT') + }) +}) diff --git a/src/main/runtime/orchestration/db/dispatch-context/worker-follow-up-lineage.ts b/src/main/runtime/orchestration/db/dispatch-context/worker-follow-up-lineage.ts index 58e7314c0a6..2e753dad16e 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/worker-follow-up-lineage.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/worker-follow-up-lineage.ts @@ -1,8 +1,10 @@ +import { sleepingAgentLaunchConfigSchema } from '../../../../../shared/workspace-session/workspace-session-sleeping-agents' import { resolveWorkerLineageFacts, FOLLOW_UP_MESSAGE_TYPES } from './worker-follow-up-plan' import { normalizeAgentProviderSession, - type AgentProviderSessionMetadata + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig } from '../../../../../shared/agent-session/lifecycle/agent-session-resume' import { getConnectionExecutionHostId, @@ -23,6 +25,8 @@ export type WorkerResumeSession = { agent: TuiAgent providerSession: AgentProviderSessionMetadata captureHostId: ExecutionHostId + /** The recipe the worker launched with; its pane's own record is gone once it closes (#37693). */ + launchConfig?: SleepingAgentLaunchConfig } export function getFollowUpDispatch( @@ -84,10 +88,18 @@ export function readWorkerResumeSession( const providerSession = normalizeAgentProviderSession( 'providerSession' in parsed ? parsed.providerSession : undefined ) + const launchConfig = sleepingAgentLaunchConfigSchema.parse( + 'launchConfig' in parsed ? parsed.launchConfig : undefined + ) return isTuiAgent(agent) && providerSession && (connectionId === null || typeof connectionId === 'string') - ? { agent, providerSession, captureHostId: getConnectionExecutionHostId(connectionId) } + ? { + agent, + providerSession, + captureHostId: getConnectionExecutionHostId(connectionId), + ...(launchConfig ? { launchConfig } : {}) + } : null } catch { return null @@ -107,10 +119,12 @@ export function recordWorkerResumeSession( const connectionId = isLocalHookConnectionId(session.connectionId) ? null : (session.connectionId ?? null) + const { launchConfig } = session const value = JSON.stringify({ agent: session.agent, providerSession: session.providerSession, - connectionId + connectionId, + ...(launchConfig ? { launchConfig } : {}) }) // A repeat of the stored conversation writes nothing. db.db diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-follow-up-placement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-follow-up-placement.ts index f21da18e24b..606dabfc297 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-follow-up-placement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-follow-up-placement.ts @@ -1,3 +1,4 @@ +import { resolveResumeLaunchRecipe } from '../../../../../../shared/agent-launch/choices/resume-launch-recipe' import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db/orchestration-db' import { OrchestrationError } from '../../../../orchestration/orchestration-error' @@ -50,6 +51,12 @@ export async function reopenWorkerTerminal( ): Promise { const resumed = await runtime.resumeAgentSessionInWorkspace(worktreeId, { ...session, + // Why always a recipe: without its own note the worker resumes unrecorded, never with another pane's. + launchConfig: resolveResumeLaunchRecipe( + session.launchConfig, + session.agent, + session.providerSession + ), // A retry of this follow-up reconciles the pane it may have started. mutationId: dispatchId }) diff --git a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts index cbf30467955..f8d82f45512 100644 --- a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts +++ b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts @@ -18,6 +18,10 @@ import { agentLaunchQuotingShell } from '../../../../shared/agent-launch/startup import { OrchestrationError } from '../../orchestration/orchestration-error' import { withAgentSessionAccountEnv } from '../../agent-session/agent-session-account-env' import { resolveWslHostAgentResumeInputs } from '../../terminal/launch/wsl-host-agent-terminal-launch' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session/lifecycle/agent-session-resume' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import { resolveSavedRestoreLaunchRecipe } from './saved-restore-launch-recipe' +import { worktreeIdsEqual } from '../../../../shared/worktree/id' import { resolveTuiAgentLaunchRawArgs, resolveTuiAgentLaunchEnv @@ -86,6 +90,34 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O return Object.keys(options).length > 0 ? options : undefined } + /** A saved restore replays the source pane as this host ran it; History and other user resumes keep current settings. */ + private savedRestoreRecipe( + worktreeId: string, + request: Extract + ): SleepingAgentLaunchConfig { + // Why the source pane: a Wake opens a new tab, so `placement` names a pane that never ran it. + const { tabId, leafId } = request.placement ?? {} + const sourcePaneKey = + request.restoreSourcePaneKey ?? + // A legacy non-UUID leaf names no saved record, so it gets the no-note rule instead of throwing. + (tabId && !tabId.includes(':') && leafId && isTerminalLeafId(leafId) + ? makePaneKey(tabId, leafId) + : null) + const record = sourcePaneKey + ? this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[ + sourcePaneKey + ] + : undefined + return resolveSavedRestoreLaunchRecipe({ + agent: request.agent, + providerSession: request.providerSession, + record: record && worktreeIdsEqual(record.worktreeId, worktreeId) ? record : undefined, + liveRow: sourcePaneKey + ? this.getAgentProviderSessionSnapshotFn?.().find((row) => row.paneKey === sourcePaneKey) + : undefined + }) + } + async ensureAgentSession( request: RuntimeEnsureAgentSessionRequest, _caller: RuntimeAgentSessionRpcCaller = {} @@ -134,17 +166,21 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O const shell = agentLaunchQuotingShell(launchRuntime) // Use the resolved PTY route, not display repo metadata that may belong to another host. const isRemote = Boolean(workspace.connectionId) + const restored = + request.resumeOrigin === 'saved' ? this.savedRestoreRecipe(workspace.id, request) : undefined const startup = buildAgentResumeStartupPlan( resolveWslHostAgentResumeInputs({ agent: request.agent, providerSession: identity.providerSession, cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: - request.agentArgs !== undefined - ? request.agentArgs - : resolveTuiAgentLaunchRawArgs(request.agent, settings, undefined, shell), - agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings), - ompResumeFilePath: request.ompResumeFilePath, + ...(restored ?? { + agentArgs: + request.agentArgs !== undefined + ? request.agentArgs + : resolveTuiAgentLaunchRawArgs(request.agent, settings, undefined, shell), + agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings) + }), + ompResumeFilePath: restored?.ompResumeFilePath ?? request.ompResumeFilePath, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), platform, diff --git a/src/main/runtime/session-construction/agent-session/saved-restore-launch-recipe.ts b/src/main/runtime/session-construction/agent-session/saved-restore-launch-recipe.ts new file mode 100644 index 00000000000..098541f2132 --- /dev/null +++ b/src/main/runtime/session-construction/agent-session/saved-restore-launch-recipe.ts @@ -0,0 +1,35 @@ +import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' +import { resolveResumeLaunchRecipe } from '../../../../shared/agent-launch/choices/resume-launch-recipe' +import { + agentProviderSessionsEqual, + normalizeAgentProviderSession, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session/lifecycle/agent-session-resume' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload' + +/** The source pane's note on this host, else the unrecorded rule; never current settings or client Arguments. */ +export function resolveSavedRestoreLaunchRecipe(args: { + agent: TuiAgent + providerSession: AgentProviderSessionMetadata + /** The source pane's record and latest status on this host. */ + record: SleepingAgentSessionRecord | undefined + liveRow: AgentStatusIpcPayload | undefined +}): SleepingAgentLaunchConfig { + const { agent } = args + const sameSession = (session: AgentProviderSessionMetadata | null | undefined) => + session && agentProviderSessionsEqual(agent, session, args.providerSession) ? session : null + const recorded = + args.record?.agent === agent && sameSession(args.record.providerSession) + ? args.record + : undefined + if (recorded?.launchConfig) { + return resolveResumeLaunchRecipe(recorded.launchConfig, agent, recorded.providerSession) + } + const live = + args.liveRow?.agentType === agent + ? sameSession(normalizeAgentProviderSession(args.liveRow.providerSession)) + : null + return resolveResumeLaunchRecipe(undefined, agent, live ?? recorded?.providerSession) +} diff --git a/src/main/runtime/session-lifecycle/projection/frame-agent-status.ts b/src/main/runtime/session-lifecycle/projection/frame-agent-status.ts index afcb22ef8e3..a3288f132ae 100644 --- a/src/main/runtime/session-lifecycle/projection/frame-agent-status.ts +++ b/src/main/runtime/session-lifecycle/projection/frame-agent-status.ts @@ -1,18 +1,22 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types' +import { launchStatement, type PtyLaunchFacts } from '../../terminal/launch/launch-evidence' /** A pane's projected agent status as a session-tabs frame publishes it. The frame names its * worktree on every agent: a restored row without a live PTY names none, and clients decide row - * ownership (and retraction) by worktree. The turn stamp travels on the tab, not the status. */ + * ownership (and retraction) by worktree. The turn stamp and owner's launch fact travel on the tab. */ export function frameAgentStatus( projected: (AgentStatusEntry & { turnCompletedAt?: number }) | undefined, - worktreeId: string -): { agentStatus?: AgentStatusEntry; projectedTurnCompletedAt?: number } { + worktreeId: string, + pty?: PtyLaunchFacts | null +): { agentStatus?: AgentStatusEntry; projectedTurnCompletedAt?: number; launchAttested?: boolean } { if (!projected) { return {} } const { turnCompletedAt, ...fields } = projected + const statement = pty ? launchStatement(pty, fields.agentType) : undefined return { agentStatus: { ...fields, worktreeId }, + ...(statement !== undefined ? { launchAttested: statement !== null } : {}), ...(turnCompletedAt !== undefined ? { projectedTurnCompletedAt: turnCompletedAt } : {}) } } diff --git a/src/main/runtime/session-lifecycle/projection/resume-agent-session-in-workspace.ts b/src/main/runtime/session-lifecycle/projection/resume-agent-session-in-workspace.ts index 8d513434fe8..d7e55a61796 100644 --- a/src/main/runtime/session-lifecycle/projection/resume-agent-session-in-workspace.ts +++ b/src/main/runtime/session-lifecycle/projection/resume-agent-session-in-workspace.ts @@ -3,7 +3,8 @@ import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' import { agentProviderSessionsEqual, normalizeAgentProviderSession, - type AgentProviderSessionMetadata + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig } from '../../../../shared/agent-session/lifecycle/agent-session-resume' import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' import { @@ -46,6 +47,8 @@ export abstract class OrcaRuntimeWithResumeAgentSessionInWorkspace extends OrcaR captureHostId: ExecutionHostId /** Names the new pane, so a retry of one request reconciles the pane it may have started. */ mutationId: string + /** The caller's complete recipe; without one, the conversation's own pane record supplies it. */ + launchConfig?: SleepingAgentLaunchConfig signal?: AbortSignal } ): Promise { @@ -91,7 +94,12 @@ export abstract class OrcaRuntimeWithResumeAgentSessionInWorkspace extends OrcaR private async resumeClaimedAgentSession( worktreeId: string, session: ResumedAgentSession, - request: { captureHostId: ExecutionHostId; mutationId: string; signal?: AbortSignal }, + request: { + captureHostId: ExecutionHostId + mutationId: string + launchConfig?: SleepingAgentLaunchConfig + signal?: AbortSignal + }, create: { identity: { tabId: string; leafId: string } onPtySpawnDispatched: () => void @@ -145,7 +153,7 @@ export abstract class OrcaRuntimeWithResumeAgentSessionInWorkspace extends OrcaR try { resume = this.resolveMobileSessionResumeCommand(workspace, { ...session, - launchConfig: record?.launchConfig + launchConfig: request.launchConfig ?? record?.launchConfig }) } catch (error) { if (error instanceof WslHostWindowsOnlyAgentError) { diff --git a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection.ts b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection.ts index 50d798486ab..b03a60a4f42 100644 --- a/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection.ts +++ b/src/main/runtime/session-lifecycle/projection/runtime-mobile-session-projection.ts @@ -270,7 +270,8 @@ export function projectRuntimeMobileSessionTabs( ) const { projectedTurnCompletedAt, ...clientAgentStatus } = frameAgentStatus( projectedAgentStatus.agentStatus, - snapshot.worktree + snapshot.worktree, + terminalPty ) const rawTurnCompletedAt = hookAgentStatus?.live?.payload.turnCompletedAt ?? diff --git a/src/main/runtime/terminal/identity/terminal-identity-source.ts b/src/main/runtime/terminal/identity/terminal-identity-source.ts index 657319a8f16..dfcd5dcaf90 100644 --- a/src/main/runtime/terminal/identity/terminal-identity-source.ts +++ b/src/main/runtime/terminal/identity/terminal-identity-source.ts @@ -6,6 +6,7 @@ import type { import type { TerminalHandleRecord, PtyIncarnationHandleRecord } from './terminal-handle-records' import { TerminalIncarnationCandidates } from './terminal-incarnation-candidates' +import { retireReplacedLaunch } from '../launch/launch-evidence' type Mutable = { -readonly [K in keyof T]: T[K] } /** Canonical terminal facts; semantic operations retain selection, callbacks and failure policy. */ @@ -82,6 +83,7 @@ export class TerminalIdentitySource { } setPtyIncarnationId(record: RuntimePtyWorktreeRecord, value: string | null): void { + retireReplacedLaunch(record, value) this.setPtyField(record, 'incarnationId', value) } diff --git a/src/main/runtime/terminal/launch/launch-evidence.test.ts b/src/main/runtime/terminal/launch/launch-evidence.test.ts new file mode 100644 index 00000000000..b9f60dce680 --- /dev/null +++ b/src/main/runtime/terminal/launch/launch-evidence.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { heldLaunchConfig, type PtyLaunchFacts } from './launch-evidence' + +const config = { agentArgs: '--dangerously-skip-permissions', agentEnv: { FOO: '1' } } + +function heldAs(agent: 'claude' | 'claude-agent-teams'): PtyLaunchFacts { + return { + incarnationId: 'p1', + launchEvidence: { kind: 'held', incarnationId: 'p1', agent, token: 'token', config } + } +} + +describe('heldLaunchConfig', () => { + it('keeps a Claude agent teams launch note when its hook reports claude', () => { + expect(heldLaunchConfig(heldAs('claude-agent-teams'), 'claude')).toEqual(config) + }) + + it('gives a different agent typed into the pane no note', () => { + expect(heldLaunchConfig(heldAs('claude'), 'codex')).toBeUndefined() + }) +}) diff --git a/src/main/runtime/terminal/launch/launch-evidence.ts b/src/main/runtime/terminal/launch/launch-evidence.ts new file mode 100644 index 00000000000..0a0667d9788 --- /dev/null +++ b/src/main/runtime/terminal/launch/launch-evidence.ts @@ -0,0 +1,118 @@ +import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session/lifecycle/agent-session-resume' +import { sameAgentIdentity } from '../../../../shared/agent-status/title/agent-title-owner' +import type { PtyIncarnationId } from '../../../../shared/terminal/pty/lifecycle/pty-incarnation' +import type { DaemonLaunchNote } from '../../../daemon/protocol/daemon-launch-note-protocol' + +/** Main's word on whether a PTY's process still runs an Orca launch. Always replaced whole, so its + * identity fences an async check; never persisted, so a main restart starts unknown. */ +export type LaunchEvidence = + | { readonly kind: 'unknown' } + | { readonly kind: 'gone'; readonly incarnationId: PtyIncarnationId | null } + | { + readonly kind: 'held' + readonly incarnationId: PtyIncarnationId | null + readonly agent: TuiAgent + readonly token: string + readonly config: SleepingAgentLaunchConfig + } + +export type PtyLaunchFacts = { + incarnationId: PtyIncarnationId | null + launchEvidence?: LaunchEvidence + launchConfig?: SleepingAgentLaunchConfig | null +} + +type HeldLaunch = { agent: TuiAgent; token: string; config: SleepingAgentLaunchConfig } + +/** The recipe of the Orca launch running in this PTY's process as `agent`; a typed agent gets none. + * A wrapper launch (Claude agent teams) reports its wrapped agent, which is still that launch. */ +export function heldLaunchConfig( + pty: PtyLaunchFacts | null | undefined, + agent: string | null | undefined +): SleepingAgentLaunchConfig | undefined { + const evidence = pty?.launchEvidence + return evidence?.kind === 'held' && + // A launch recorded before its process id was known belongs to the process until a replacement. + (evidence.incarnationId === null || evidence.incarnationId === pty?.incarnationId) && + agent && + sameAgentIdentity(evidence.agent, agent) + ? evidence.config + : undefined +} + +/** Main's word on this PTY's launch as `agent`: its recipe, null when none runs, undefined when main + * can't tell, so readers keep their earlier rule. */ +export function launchStatement( + pty: PtyLaunchFacts | null | undefined, + agent: string | null | undefined +): SleepingAgentLaunchConfig | null | undefined { + const kind = pty?.launchEvidence?.kind ?? 'unknown' + return kind === 'unknown' ? undefined : (heldLaunchConfig(pty, agent) ?? null) +} + +export function holdLaunch( + pty: PtyLaunchFacts, + incarnationId: PtyIncarnationId | null, + launch: HeldLaunch +): void { + pty.launchEvidence = { kind: 'held', incarnationId, ...launch } +} + +/** A freshly started process: its own launch is held, and any other process runs none. */ +export function recordFreshLaunch(pty: PtyLaunchFacts, launch: Partial = {}): void { + const { agent, token, config } = launch + pty.launchEvidence = + agent && token && config + ? { kind: 'held', incarnationId: pty.incarnationId, agent, token, config } + : { kind: 'gone', incarnationId: pty.incarnationId } +} + +/** A finished command or confirmed exit; sticky for this process until a fresh launch. */ +export function retireLaunch( + pty: PtyLaunchFacts, + incarnationId: PtyIncarnationId | null = pty.incarnationId +): void { + pty.launchEvidence = { kind: 'gone', incarnationId } + pty.launchConfig = null +} + +/** Learning the first id of the same process binds its evidence; a new process can't still run its + * predecessor's launch, and the newcomer establishes its own. */ +export function retireReplacedLaunch(pty: PtyLaunchFacts, next: PtyIncarnationId | null): void { + const previous = pty.incarnationId + const evidence = pty.launchEvidence + if (!evidence || !next || previous === next) { + return + } + if (!previous) { + if (evidence.kind !== 'unknown' && evidence.incarnationId === null) { + pty.launchEvidence = { ...evidence, incarnationId: next } + } + return + } + if (evidence.kind === 'unknown' || evidence.incarnationId !== next) { + retireLaunch(pty, previous) + } +} + +/** A reattach to process `incarnationId` can't revive or replace what main proved about it. Else a + * retired note proves the launch gone; anything else is unknown, returned when a foreground check + * of the note may settle it. */ +export function recordReattachedLaunch( + pty: PtyLaunchFacts, + incarnationId: PtyIncarnationId | undefined, + note: DaemonLaunchNote | null | undefined +): LaunchEvidence | null { + const current = pty.launchEvidence + if (current && current.kind !== 'unknown' && current.incarnationId === (incarnationId ?? null)) { + return null + } + if (note === null && incarnationId) { + retireLaunch(pty, incarnationId) + return null + } + const pending: LaunchEvidence = { kind: 'unknown' } + pty.launchEvidence = pending + return note && incarnationId ? pending : null +} diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index 137fdc576ee..5f2c779aee6 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -181,7 +181,12 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext } if (recipe) { livePty.pty.launchAgent ??= recipe.launchAgent ?? null - if (!livePty.pty.launchConfig && recipe.launchConfig) { + // A finished launch's recipe must not return to the shell left behind. + if ( + !livePty.pty.launchConfig && + recipe.launchConfig && + livePty.pty.launchEvidence?.kind !== 'gone' + ) { livePty.pty.launchConfig = copySleepingAgentLaunchConfig(recipe.launchConfig) } } diff --git a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts index c22235ecf6a..b9d77875ad8 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-startup.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-startup.ts @@ -11,7 +11,7 @@ import { import { isTuiAgentEnabled } from '../../../../shared/agent-launch/catalog/tui-agent-selection' import { buildAgentResumeStartupPlan } from '../../../../shared/agent-launch/startup/tui-agent-resume-startup' import { buildAgentStartupPlan } from '../../../../shared/agent-launch/startup/tui-agent-startup' -import { resolveResumeLaunchRecipe } from '../../../../shared/agent-launch/choices/tui-agent-launch-defaults' +import { resolveResumeLaunchRecipe } from '../../../../shared/agent-launch/choices/resume-launch-recipe' import { resolveAgentStartupPlanInputs } from '../../../../shared/agent-launch/startup/agent-startup-plan-inputs' import { resolveWslHostAgentLaunchInputs, @@ -91,7 +91,7 @@ export abstract class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends agent: record.agent, providerSession, cmdOverrides: settings.agentCmdOverrides ?? {}, - ...resolveResumeLaunchRecipe(launchConfig), + ...resolveResumeLaunchRecipe(launchConfig, record.agent, providerSession), platform: launchRuntime.platform, shell: agentLaunchQuotingShell(launchRuntime), isRemote: Boolean(workspace.connectionId), diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index 30e41793000..ecb46eb52ee 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -243,8 +243,8 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi this.setPtyManagementTitleFromObservedTitle(pty, title, at) ) } - this.watchLaunchComposer( - result.id, + this.watchLaunchedAgent( + pty, recordTerminalLaunchMetadata(pty, result, launch, launchOpts.command) ) if (!launch.adoptedStablePane) { diff --git a/src/main/runtime/terminal/launch/terminal-launch-result.ts b/src/main/runtime/terminal/launch/terminal-launch-result.ts index f58ffddb42b..d62b1cd7ac2 100644 --- a/src/main/runtime/terminal/launch/terminal-launch-result.ts +++ b/src/main/runtime/terminal/launch/terminal-launch-result.ts @@ -4,6 +4,7 @@ import type { RuntimePtyController } from '../lifecycle/runtime-pty-controller-c import type { TerminalCreateOptions } from './terminal-create-options' import type { RuntimePtyWorktreeRecord } from '../records/runtime-terminal-state-records' import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' +import { recordFreshLaunch } from './launch-evidence' import { copySleepingAgentLaunchConfig, resolveLaunchCommandPaneAgent, @@ -54,8 +55,8 @@ export function recordTerminalLaunchMetadata( command: string | undefined ): TuiAgent | null { if (launch.adoptedStablePane) { - // A lower owner can supply facts before this runtime has a launch record. - if (!pty.launchConfig && result.launchConfig) { + // A lower owner can supply facts before this runtime has a launch record, unless its launch ended. + if (!pty.launchConfig && result.launchConfig && pty.launchEvidence?.kind !== 'gone') { pty.launchConfig = copySleepingAgentLaunchConfig(result.launchConfig) } pty.launchToken ??= result.launchToken ?? null @@ -69,6 +70,11 @@ export function recordTerminalLaunchMetadata( pty.launchToken = launch.launchToken ?? null pty.launchIncarnationId = launch.launchToken ? pty.incarnationId : null pty.launchAgent = launch.launchAgent ?? null + recordFreshLaunch(pty, { + agent: pty.launchAgent ?? undefined, + token: pty.launchToken ?? undefined, + config: pty.launchConfig ?? undefined + }) if (result.agentResumeRefused) { pty.launchCommandAgent = null return null diff --git a/src/main/runtime/terminal/launch/terminal-split-in-main.ts b/src/main/runtime/terminal/launch/terminal-split-in-main.ts index dc6d2908246..c4bb3129b04 100644 --- a/src/main/runtime/terminal/launch/terminal-split-in-main.ts +++ b/src/main/runtime/terminal/launch/terminal-split-in-main.ts @@ -14,6 +14,7 @@ import { isTerminalLeafId, makePaneKey } from '../../../../shared/workspace-layo import { randomUUID } from 'node:crypto' import { paneStartFailure } from '../surface/pane-start-statuses' import { collectLeafIds } from '../../../../shared/workspace-layout/terminal-pane-layout-tree' +import { recordFreshLaunch } from './launch-evidence' export type SplitPaneSource = { worktreeId: string; tabId: string; leafId: string } @@ -138,6 +139,9 @@ export abstract class OrcaRuntimeWithSplitPaneInMain extends OrcaRuntimeWithSpli if (!createdPty) { throw new Error('terminal_exited') } + if (!result.isReattach) { + recordFreshLaunch(createdPty) + } recordPtySurface( this.terminalIdentity, createdPty, diff --git a/src/main/runtime/terminal/launch/terminal-split-surface.ts b/src/main/runtime/terminal/launch/terminal-split-surface.ts index b5abf1ed4a8..04ff13386d7 100644 --- a/src/main/runtime/terminal/launch/terminal-split-surface.ts +++ b/src/main/runtime/terminal/launch/terminal-split-surface.ts @@ -14,6 +14,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import { spawnInAdmittedPane, withdrawRuntimePane } from './runtime-pane-admission' import { UNMEASURED_PTY_SIZE } from '../../../ipc/pty/pty-first-size' import { resolveLaunchCommandPaneAgent } from './runtime-agent-launch-resolution' +import { recordFreshLaunch } from './launch-evidence' // Rejected splits need only a quick teardown verdict before fallback kill; unreachable hosts must not stall rejection. const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000 @@ -129,6 +130,9 @@ export abstract class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeW ) createdPty.runtimeSessionOwned = pty.runtimeSessionOwned createdPty.launchCommandAgent = resolveLaunchCommandPaneAgent(opts.command) + if (!result.isReattach) { + recordFreshLaunch(createdPty) + } this.watchLaunchComposer(result.id, createdPty.launchCommandAgent) this.setPairedRendererSessionOwnership( createdPty.ptyId, diff --git a/src/main/runtime/terminal/lifecycle/daemon-launch-note.ts b/src/main/runtime/terminal/lifecycle/daemon-launch-note.ts new file mode 100644 index 00000000000..59d596a4379 --- /dev/null +++ b/src/main/runtime/terminal/lifecycle/daemon-launch-note.ts @@ -0,0 +1,90 @@ +import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' +import type { PtyIncarnationId } from '../../../../shared/terminal/pty/lifecycle/pty-incarnation' +import type { DaemonLaunchNote } from '../../../daemon/protocol/daemon-launch-note-protocol' +import type { LaunchedAgentForeground } from '../wait/launched-agent-foreground' +import type { RuntimePtyWorktreeRecord } from '../records/runtime-terminal-state-records' +import { + holdLaunch, + recordReattachedLaunch, + retireLaunch, + type LaunchEvidence +} from '../launch/launch-evidence' +import { OrcaRuntimeWithInvalidateAllHandlesForPty } from './pty-handle-invalidation' + +/** Main's admitted launch, kept in the daemon so a reattach after a main restart can re-arm it. */ +export abstract class OrcaRuntimeWithDaemonLaunchNote extends OrcaRuntimeWithInvalidateAllHandlesForPty { + abstract readLaunchedAgentForeground( + ptyId: string, + agent: TuiAgent + ): Promise + + protected abstract watchLaunchComposer(ptyId: string, agent: TuiAgent | null): void + + /** Watch a launched agent's composer and keep its launch in the daemon. */ + protected watchLaunchedAgent(pty: RuntimePtyWorktreeRecord, agent: TuiAgent | null): void { + this.watchLaunchComposer(pty.ptyId, agent) + this.armDaemonLaunchNote(pty) + } + + /** Keep main's held launch in the daemon, which outlives main. */ + protected armDaemonLaunchNote(pty: RuntimePtyWorktreeRecord): void { + const evidence = pty.launchEvidence + if (evidence?.kind === 'held') { + this.ptyController?.setLaunchNote?.(pty.ptyId, { + arm: { launchToken: evidence.token, launchConfig: evidence.config } + }) + } + } + + /** A finished command or confirmed exit; the daemon outlives main, so a reattach must not re-arm it. */ + protected retireHeldLaunch(ptyId: string): RuntimePtyWorktreeRecord | undefined { + const pty = this.ptysById.get(ptyId) + if (pty?.launchEvidence?.kind === 'held') { + this.ptyController?.setLaunchNote?.(ptyId, { retire: pty.launchEvidence.token }) + } + if (pty) { + retireLaunch(pty) + } + return pty + } + + /** An unretired note is held only while its agent still holds the foreground. Main can't tell when + * the host keeps no notes (an older daemon) or the foreground is unreadable (Windows). */ + protected recordReattachedLaunch( + pty: RuntimePtyWorktreeRecord, + incarnationId: PtyIncarnationId | undefined, + note: DaemonLaunchNote | null | undefined, + agent: TuiAgent | undefined + ): void { + const pending = recordReattachedLaunch(pty, incarnationId, note) + if (pending && note && incarnationId && agent) { + void this.checkReattachedLaunch(pty, pending, incarnationId, note, agent) + } + } + + private async checkReattachedLaunch( + pty: RuntimePtyWorktreeRecord, + pending: LaunchEvidence, + incarnationId: PtyIncarnationId, + note: DaemonLaunchNote, + agent: TuiAgent + ): Promise { + const foreground = await this.readLaunchedAgentForeground(pty.ptyId, agent).catch( + () => 'unknown' as const + ) + // Newer evidence (a later attach, a finished command, a fresh launch) outranks this read. + if ( + pty.launchEvidence !== pending || + this.ptysById.get(pty.ptyId) !== pty || + pty.incarnationId !== incarnationId + ) { + return + } + if (foreground === 'shell') { + this.ptyController?.setLaunchNote?.(pty.ptyId, { retire: note.launchToken }) + retireLaunch(pty) + } else if (foreground === 'agent' && pty.launchAgent === agent) { + holdLaunch(pty, incarnationId, { agent, token: note.launchToken, config: note.launchConfig }) + } + } +} diff --git a/src/main/runtime/terminal/lifecycle/pty-registration.ts b/src/main/runtime/terminal/lifecycle/pty-registration.ts index 95240176d04..7c2238096f5 100644 --- a/src/main/runtime/terminal/lifecycle/pty-registration.ts +++ b/src/main/runtime/terminal/lifecycle/pty-registration.ts @@ -3,9 +3,12 @@ import { terminalExitError } from '../../../../shared/terminal/pty/lifecycle/terminal-start-failure' import type { PtySpawnResult } from '../../../providers/pty-spawn-result' -import { OrcaRuntimeWithInvalidateAllHandlesForPty } from './pty-handle-invalidation' import type { PtyIncarnationId } from '../../../../shared/terminal/pty/lifecycle/pty-incarnation' import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session/lifecycle/agent-session-resume' +import { recordFreshLaunch, retireLaunch } from '../launch/launch-evidence' +import type { DaemonLaunchNote } from '../../../daemon/protocol/daemon-launch-note-protocol' +import { OrcaRuntimeWithDaemonLaunchNote } from './daemon-launch-note' import { isValidTerminalTabId } from '../../../../shared/workspace-layout/terminal-tab-id' import { isTerminalLeafId, makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' @@ -20,7 +23,7 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switc import { mainTerminalRegistered } from '../surface/main-terminal-surfaces' import { assertPaneBoundAtRegistration, isRuntimeHostedWorkspace } from './bind-first-registration' -export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHandlesForPty { +export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithDaemonLaunchNote { protected abstract ensurePtyBackedMobileSurfaceForRendererTab( worktreeId: string, tabId: string @@ -42,13 +45,21 @@ export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalida incarnationId?: PtyIncarnationId /** Handle allocated for the replacement incarnation, when one is known. */ terminalHandle?: string - agentLaunchAuthority?: { launchToken: string; launchAgent: TuiAgent } + agentLaunchAuthority?: { + launchToken: string + launchAgent: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + } /** The agent a fresh spawn's launch command starts, whose submits wait for its composer. */ launchedAgent?: TuiAgent providerReattachLaunchIdentity?: { incarnationId: PtyIncarnationId launchAgent: TuiAgent } + /** A newly started process, which runs no launch but an admitted one. */ + freshSpawn?: true + /** A reattach, with its host's launch note; none when the host keeps no notes (an older daemon, SSH). */ + reattach?: { launchNote?: DaemonLaunchNote | null } }, isWsl?: boolean ): void { @@ -153,9 +164,18 @@ export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalida pty.launchToken = agentLaunchAuthority.launchToken pty.launchIncarnationId = binding.incarnationId pty.launchAgent = agentLaunchAuthority.launchAgent + recordFreshLaunch(pty, { + agent: agentLaunchAuthority.launchAgent, + token: agentLaunchAuthority.launchToken, + config: agentLaunchAuthority.launchConfig + }) + this.armDaemonLaunchNote(pty) + } else if (binding?.freshSpawn && pty.incarnationId === (binding.incarnationId ?? null)) { + retireLaunch(pty) } this.watchLaunchComposer(ptyId, binding?.launchedAgent ?? null) const providerReattachLaunchIdentity = binding?.providerReattachLaunchIdentity + let reattachedAgent: TuiAgent | undefined if ( providerReattachLaunchIdentity && paneKey && @@ -165,7 +185,12 @@ export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalida isTuiAgent(providerReattachLaunchIdentity.launchAgent) ) { // Why: daemon metadata owns the surviving process; its incarnation fence restores identity without minting renderer launch authority. - pty.launchAgent = providerReattachLaunchIdentity.launchAgent + pty.launchAgent = reattachedAgent = providerReattachLaunchIdentity.launchAgent + } + // An older relay reports no incarnation; that still can't prove the launch gone. + if (binding?.reattach && (pty.incarnationId ?? undefined) === binding.incarnationId) { + const { launchNote } = binding.reattach + this.recordReattachedLaunch(pty, binding.incarnationId, launchNote, reattachedAgent) } const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId)?.incarnationId if ( diff --git a/src/main/runtime/terminal/lifecycle/runtime-pty-controller-contract.ts b/src/main/runtime/terminal/lifecycle/runtime-pty-controller-contract.ts index 6e9f11b90ac..f05674811c6 100644 --- a/src/main/runtime/terminal/lifecycle/runtime-pty-controller-contract.ts +++ b/src/main/runtime/terminal/lifecycle/runtime-pty-controller-contract.ts @@ -1,3 +1,4 @@ +import type { DaemonLaunchNoteChange } from '../../../daemon/protocol/daemon-launch-note-protocol' import type { TerminalLimitStatus, TerminalLimitUpdate @@ -172,6 +173,7 @@ export type RuntimePtyController = { supportsForegroundObservation?(ptyId: string): boolean confirmForegroundProcess?(ptyId: string): Promise confirmShellForeground?(ptyId: string): Promise + setLaunchNote?(ptyId: string, change: DaemonLaunchNoteChange): void hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resetInputModes?(ptyId: string): Promise diff --git a/src/main/runtime/terminal/output/terminal-orchestration-authority.ts b/src/main/runtime/terminal/output/terminal-orchestration-authority.ts index a399c440b34..7c350e70396 100644 --- a/src/main/runtime/terminal/output/terminal-orchestration-authority.ts +++ b/src/main/runtime/terminal/output/terminal-orchestration-authority.ts @@ -183,7 +183,7 @@ export abstract class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends O } protected retirePtyAgentLaunchAuthority(ptyId: string): void { - const pty = this.ptysById.get(ptyId) + const pty = this.retireHeldLaunch(ptyId) if (!pty) { return } diff --git a/src/main/runtime/terminal/records/runtime-terminal-state-records.ts b/src/main/runtime/terminal/records/runtime-terminal-state-records.ts index c16c1868f3b..0fdfbcbff13 100644 --- a/src/main/runtime/terminal/records/runtime-terminal-state-records.ts +++ b/src/main/runtime/terminal/records/runtime-terminal-state-records.ts @@ -18,6 +18,7 @@ import type { RetainedTailRedrawCursor } from '../output/tail/terminal-tail-redr import type { TerminalTailWaitState } from '../wait/terminal-wait-tail-state' import type { TerminalCommandPaint } from '../input/terminal-command-paint' import type { PtyShellOwnershipMirror } from '../query/pty-shell-ownership-mirror' +import type { LaunchEvidence } from '../launch/launch-evidence' import type { TerminalExitCause } from '../../../../shared/terminal/pty/lifecycle/terminal-exit-cause' import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session/agent-session-host-authority' @@ -92,6 +93,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { launchToken: string | null launchIncarnationId: PtyIncarnationId | null launchAgent: TuiAgent | null + /** Whether the process still runs an Orca launch; missing when main can't tell. */ + launchEvidence?: LaunchEvidence /** Identify flagged unmanaged --command panes before foreground reads; null for adopted/restored panes. */ launchCommandAgent: TuiAgent | null agentSessionOwners: AgentSessionOwnerBinding[] diff --git a/src/main/runtime/terminal/stop/hook-status-resume-records.ts b/src/main/runtime/terminal/stop/hook-status-resume-records.ts index 41c2635557c..e218127c9d1 100644 --- a/src/main/runtime/terminal/stop/hook-status-resume-records.ts +++ b/src/main/runtime/terminal/stop/hook-status-resume-records.ts @@ -9,6 +9,7 @@ import { type ResumeRecordTab } from '../../../../shared/agent-session/lifecycle/agent-resume-record-from-status' import { + isPaneSleepRecord, normalizeAgentProviderSession, type SleepingAgentLaunchConfig, type SleepingAgentSessionRecord @@ -22,11 +23,38 @@ import type { WorkspaceSessionState } from '../../../../shared/workspace-session /** The part of a pane's previous status the next one may inherit its provider session from. */ export type PreviousHookStatus = Pick -/** A pane's agent launch recipe, when its terminal was launched as `agent`. */ +/** Main's word on a pane's launch as `agent`: its note, null for none, undefined when main can't tell. */ export type PaneLaunchConfigOf = ( paneKey: string, agent: ResumeRecordStatus['agentType'] -) => SleepingAgentLaunchConfig | undefined +) => SleepingAgentLaunchConfig | null | undefined + +/** Main's word on the launch, else the pane's earlier record of this session when main can't tell. */ +function statusLaunchConfig( + statement: SleepingAgentLaunchConfig | null | undefined, + existing: SleepingAgentSessionRecord | undefined, + status: ResumeRecordStatus, + keepsEarlier = true +): SleepingAgentLaunchConfig | undefined { + return statement === undefined && keepsEarlier + ? recordLaunchConfigForStatus(existing, status) + : (statement ?? undefined) +} + +/** A sleep's snapshot of this session waits for its wake; a status from no Orca launch neither + * replaces it nor inherits its recipe. */ +function keepsSleepSnapshot( + statement: SleepingAgentLaunchConfig | null | undefined, + existing: SleepingAgentSessionRecord | undefined, + status: ResumeRecordStatus +): boolean { + return ( + statement === null && + existing !== undefined && + isPaneSleepRecord(existing) && + recordLaunchConfigForStatus(existing, status) !== undefined + ) +} /** A hook row as the window's status entry reads it; null for rows that are not a pane's status. */ export function resumeStatusFromHookRow( @@ -80,17 +108,22 @@ export function hookStatusResumeCommand(args: { } const existing = session.sleepingAgentSessionsByPaneKey?.[status.paneKey] const retainsResumableIdentity = statusRetainsResumableIdentity(status, status.providerSession) + const statement = args.launchConfigOf(status.paneKey, status.agentType) + if (keepsSleepSnapshot(statement, existing, status)) { + return null + } const live = liveResumeRecord({ entry: status, retainsResumableIdentity, worktreeIdOf: () => worktreeId, tabOf: () => tab, capturedAt: status.updatedAt, - launchConfig: - args.launchConfigOf(status.paneKey, status.agentType) ?? - (status.state !== 'done' || retainsResumableIdentity - ? recordLaunchConfigForStatus(existing, status) - : undefined) + launchConfig: statusLaunchConfig( + statement, + existing, + status, + status.state !== 'done' || retainsResumableIdentity + ) }) const change = liveResumeRecordChange(existing, live) if (!change) { @@ -112,6 +145,10 @@ export function quitResumeRecords(args: { }): SleepingAgentSessionRecord[] { return args.statuses.flatMap((status) => { const existing = args.session.sleepingAgentSessionsByPaneKey?.[status.paneKey] + const statement = args.launchConfigOf(status.paneKey, status.agentType) + if (keepsSleepSnapshot(statement, existing, status)) { + return [] + } const tabFor = tabOf(args.session, status.paneKey) const record = capturedResumeRecord({ entry: status, @@ -120,9 +157,7 @@ export function quitResumeRecords(args: { capturedAt: args.capturedAt, worktreeIdOf: () => status.worktreeId ?? args.worktreeIdOf(status.paneKey), tabOf: tabFor, - launchConfigOf: () => - args.launchConfigOf(status.paneKey, status.agentType) ?? - recordLaunchConfigForStatus(existing, status) + launchConfigOf: () => statusLaunchConfig(statement, existing, status) }) // Only a pane this partition holds keeps a record here. return record && tabFor(record.worktreeId) ? [record] : [] diff --git a/src/main/runtime/terminal/stop/launch-evidence-resume-records.test.ts b/src/main/runtime/terminal/stop/launch-evidence-resume-records.test.ts new file mode 100644 index 00000000000..a6783edbad3 --- /dev/null +++ b/src/main/runtime/terminal/stop/launch-evidence-resume-records.test.ts @@ -0,0 +1,115 @@ +// A pane's saved recipe follows main's word on its launch, not the session the agent names. +import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-resume' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { LayoutApplyOutcome } from '../../../../shared/workspace-layout/workspace-layout-command-types' +import { applyCommandToSession } from '../../../persistence/terminal-topology/terminal-pane-admission' + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +// Preserve side-effect order: mocks, lifecycle, fixtures. +await import('../../orca-runtime-tests/runtime-test-module-registration.spec') +const { OrcaRuntimeService } = await import('../../orca-runtime') +await import('../../orca-runtime-tests/orca-runtime-test-lifecycle.spec') +const { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} = await import('../../orca-runtime-tests/orca-runtime-test-fixtures.spec') + +const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}` +const PTY_ID = 'persisted-pty' +const SESSION = { key: 'session_id' as const, id: 'claude-session-1' } +const RECIPE = { agentArgs: '--dangerously-skip-permissions', agentEnv: {} } + +function savedRecord(origin: SleepingAgentSessionRecord['origin']): SleepingAgentSessionRecord { + return { + paneKey: PANE_KEY, + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + agent: 'claude', + providerSession: SESSION, + prompt: '', + state: 'done', + capturedAt: 5, + updatedAt: 5, + origin, + connectionId: null, + launchConfig: RECIPE + } +} + +function claudeRow(): AgentStatusIpcPayload { + return { + paneKey: PANE_KEY, + worktreeId: TEST_WORKTREE_ID, + agentType: 'claude', + providerSession: SESSION, + state: 'working', + prompt: 'keep going', + receivedAt: 10, + stateStartedAt: 10, + connectionId: null + } +} + +function makeRuntime(saved: SleepingAgentSessionRecord) { + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + sleepingAgentSessionsByPaneKey: { [PANE_KEY]: saved } + }) + ) + const applyLayoutCommand = vi.fn( + async ( + command: Parameters[2], + hostIds: ExecutionHostId[] + ): Promise => { + for (const hostId of hostIds) { + const change = applyCommandToSession(hostId, getSession(), command) + if (change.session) { + setSession(change.session) + } + } + return { ok: true, result: {} } + } + ) + const runtime = new OrcaRuntimeService(Object.assign(runtimeStore, { applyLayoutCommand })) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const binding = { tabId: 'host-tab', leafId: HEADLESS_LEAF_ID, incarnationId: 'p1' } + return { + runtime, + binding, + getSession, + savedAfterStatus: async () => { + runtime.recordHookStatusResume(claudeRow()) + await runtime['hookStatusResumeWrites'] + return getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY] + } + } +} + +describe('a pane saved recipe and the launch running in it', () => { + it('gives an agent typed into a fresh shell none of the session earlier recipe', async () => { + const { runtime, binding, savedAfterStatus } = makeRuntime(savedRecord('live')) + runtime.registerPty(PTY_ID, TEST_WORKTREE_ID, null, { ...binding, freshSpawn: true }) + const saved = await savedAfterStatus() + expect(saved?.state).toBe('working') + expect(saved?.launchConfig).toBeUndefined() + }) + + it('keeps a sleep snapshot for its wake without lending its recipe to a typed resume', async () => { + const { runtime, binding, getSession, savedAfterStatus } = makeRuntime( + savedRecord('worktree-sleep') + ) + runtime.registerPty(PTY_ID, TEST_WORKTREE_ID, null, { + ...binding, + agentLaunchAuthority: { launchToken: 'launch-1', launchAgent: 'claude', launchConfig: RECIPE } + }) + runtime.emitDaemonPtyTransientFact(PTY_ID, { kind: 'command-finished', exitCode: 0 }) + expect(await savedAfterStatus()).toEqual(savedRecord('worktree-sleep')) + Object.assign(runtime, { getAgentProviderSessionSnapshotFn: () => [claudeRow()] }) + expect(runtime.quitResumeRecordsFor(getSession())).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts index 37ec1fbce3a..9b7d7761ff4 100644 --- a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts +++ b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts @@ -1,5 +1,7 @@ import { sleepingRecordsEquivalentIgnoringCaptureTime } from '../../../../shared/agent-session/lifecycle/agent-resume-record-equivalence' import { OrcaRuntimeWithStopTerminalsForWorktree } from './worktree-terminal-stop' +import { launchStatement } from '../launch/launch-evidence' +import type { RuntimePtyWorktreeRecord } from '../records/runtime-terminal-state-records' import { applyWorktreeSleepResumeRecords, captureWorktreeSleepResumeRecords, @@ -96,12 +98,7 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu paneKeysByPtyId: new Map( [...ptyIds].map((ptyId) => [ptyId, this.collectAgentStatusPaneKeysForPty(ptyId)]) ), - launchByPtyId: new Map( - [...ptyIds].map((ptyId) => { - const pty = this.ptysById.get(ptyId) - return [ptyId, { agent: pty?.launchAgent ?? null, config: pty?.launchConfig ?? null }] - }) - ), + launchByPtyId: new Map([...ptyIds].map((ptyId) => [ptyId, this.ptysById.get(ptyId)])), readRows: () => this.getAgentProviderSessionSnapshotFn?.() ?? [], checkAgentPresence: this.checkHookAgentPresenceFn, capturedAt: Date.now() @@ -129,18 +126,11 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu >() private hookStatusResumeWrites: Promise = Promise.resolve() - private readonly paneLaunchConfigOf: PaneLaunchConfigOf = (paneKey, agent) => { - for (const pty of this.ptysById.values()) { - if ( - pty.launchConfig && - pty.launchAgent === agent && - this.paneOfPty(pty).paneKey === paneKey - ) { - return pty.launchConfig - } - } - return undefined - } + protected abstract getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null + + /** Main's word on the Orca launch running in this pane now; the one check every saved note passes. */ + getLaunchStatementForPaneKey: PaneLaunchConfigOf = (paneKey, agent) => + launchStatement(this.getPtyRecordForPaneKey(paneKey), agent) // Main records accepted status for its panes even without windows, preserving window resume rules; serialize writes so statuses read prior records. recordHookStatusResume(row: AgentStatusIpcPayload): void { @@ -207,7 +197,7 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu status, session: this.store.getWorkspaceSession(hostId), worktreeId, - launchConfigOf: this.paneLaunchConfigOf + launchConfigOf: this.getLaunchStatementForPaneKey }) if (command) { // Not this.applyLayoutCommand: this runs inside the resume-record queue, which that would await. @@ -225,7 +215,7 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu statuses, session, worktreeIdOf: (paneKey) => this.getTerminalWorktreeIdForPaneKey(paneKey), - launchConfigOf: this.paneLaunchConfigOf, + launchConfigOf: this.getLaunchStatementForPaneKey, capturedAt: Date.now() }) } diff --git a/src/main/runtime/terminal/stop/worktree-sleep-resume-records.ts b/src/main/runtime/terminal/stop/worktree-sleep-resume-records.ts index 3102e1b32a7..4b2841543d0 100644 --- a/src/main/runtime/terminal/stop/worktree-sleep-resume-records.ts +++ b/src/main/runtime/terminal/stop/worktree-sleep-resume-records.ts @@ -2,7 +2,6 @@ import { agentProviderSessionsEqual, isResumableTuiAgent, normalizeAgentProviderSession, - type SleepingAgentLaunchConfig, type SleepingAgentSessionRecord } from '../../../../shared/agent-session/lifecycle/agent-session-resume' import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload' @@ -11,11 +10,17 @@ import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id import { isLocalHookConnectionId } from '../../../../shared/wsl-hook-relay-contract' import type { WorkspaceSessionState } from '../../../../shared/workspace-session/workspace-session-state-types' import { isWorkspaceSleepRecord } from '../../../../shared/workspace-sleep' +import { launchStatement, type PtyLaunchFacts } from '../launch/launch-evidence' /** Per slept PTY: the resume records captured, and the panes the hook store proves moved on. */ export type WorktreeSleepResumeCapture = Map< string, - { retiredPaneKeys: string[]; records: SleepingAgentSessionRecord[] } + { + retiredPaneKeys: string[] + records: SleepingAgentSessionRecord[] + /** Main can't tell this PTY's launch, so a record keeps an earlier capture's same-session note. */ + launchUnknown?: boolean + } > /** Captures before a host-driven sleep stops its PTYs and persists for the ones proven stopped. */ @@ -32,11 +37,8 @@ export type WorktreeSleepResumeRecorder = { export async function captureWorktreeSleepResumeRecords(args: { worktreeId: string paneKeysByPtyId: ReadonlyMap> - /** The agent each PTY was launched as, with its recipe; resume must reuse a custom binary/args. */ - launchByPtyId: ReadonlyMap< - string, - { agent: string | null; config: SleepingAgentLaunchConfig | null } - > + /** Each PTY's launch facts; only a launch main still holds supplies its recipe. */ + launchByPtyId: ReadonlyMap /** Read after each presence probe, so a conversation started during it is the one captured. */ readRows: () => readonly AgentStatusIpcPayload[] checkAgentPresence: @@ -53,6 +55,7 @@ export async function captureWorktreeSleepResumeRecords(args: { const records: SleepingAgentSessionRecord[] = [] const retiredPaneKeys: string[] = [] const launch = args.launchByPtyId.get(ptyId) + let launchUnknown = false for (const { paneKey, tabId } of panes) { // Why: the one holder rule — an agent the hook store proved exited left a plain shell. if ((await args.checkAgentPresence?.(paneKey, 0).catch(() => null)) === 'exited') { @@ -70,6 +73,9 @@ export async function captureWorktreeSleepResumeRecords(args: { retiredPaneKeys.push(paneKey) continue } + const statement = launchStatement(launch, agent) + launchUnknown ||= statement === undefined + const launchConfig = statement ?? undefined records.push({ paneKey, tabId, @@ -85,10 +91,14 @@ export async function captureWorktreeSleepResumeRecords(args: { restoreOnTabOpenOnly: true, // Why: the SSH host boundary keeps the id off another machine; a WSL relay tag is local transport. connectionId: isLocalHookConnectionId(row.connectionId) ? null : row.connectionId, - ...(launch?.config && launch.agent === agent ? { launchConfig: launch.config } : {}) + ...(launchConfig ? { launchConfig } : {}) }) } - capture.set(ptyId, { retiredPaneKeys, records }) + capture.set(ptyId, { + retiredPaneKeys, + records, + ...(launchUnknown ? { launchUnknown } : {}) + }) } return capture } @@ -111,9 +121,10 @@ export function applyWorktreeSleepResumeRecords( } } for (const record of entry?.records ?? []) { + // Only a terminal main can't tell about keeps an earlier capture's recipe for the same session. const previous = next[record.paneKey] - // Reattach can lose launch recipes; retain an earlier capture's config for the same session. const keptLaunchConfig = + entry?.launchUnknown && !record.launchConfig && previous?.launchConfig && previous.agent === record.agent && diff --git a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts index 175354115b7..3938aa9e98e 100644 --- a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts +++ b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts @@ -1,3 +1,4 @@ +import { heldLaunchConfig } from '../launch/launch-evidence' import type { TerminalIdentityReadContext } from '../query/terminal-identity-read-context' import { getRepoRoutingConnectionId } from '../../../../shared/execution-host' import { OrcaRuntimeWithAdoptTerminalOrphansFromInventory } from '../adoption/adopt-terminal-orphans-from-inventory' @@ -198,9 +199,10 @@ export abstract class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRunt let connectionId: string | null | undefined let launchToken: string | null | undefined let wslDistro: string | undefined + let pty: RuntimePtyWorktreeRecord | undefined try { const ptyId = this.getTerminalAgentStatusPtyId(handle) - const pty = this.ptysById.get(ptyId) + pty = this.ptysById.get(ptyId) connectionId = pty?.connectionId ?? null launchToken = pty?.launchToken ?? null // A WSL pane's PTY is local, so its hook events only match once the distro is supplied. @@ -210,7 +212,7 @@ export abstract class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRunt } catch { // Exact worker validation rejects this in production; test/legacy providers may not expose PTY metadata. } - return selectExactWorkerProviderSession({ + const session = selectExactWorkerProviderSession({ paneKey, processIncarnation, connectionId, @@ -219,6 +221,8 @@ export abstract class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRunt observedAfter, statuses: this.getAgentStatusSnapshotFn?.() ?? [] }) + const launchConfig = session ? heldLaunchConfig(pty, session.agent) : undefined + return session && launchConfig ? { ...session, launchConfig } : session } resolveOrchestrationAgentLauncher( diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 23409edbe1f..e4e0b994d4e 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -45,12 +45,14 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt if (structuredHost) { return } + const launchConfig = state.runtime?.getLaunchStatementForPaneKey(paneKey, payload.agentType) if (providerSessionOnly) { // Idle Pi session_start only refreshes resume identity; skip titles, telemetry and status UI. state.mainWindow?.webContents.send('agentStatus:set', { ...payload, paneKey, ...(launchToken ? { launchToken } : {}), + ...(launchConfig !== undefined ? { launchConfig } : {}), tabId, worktreeId, connectionId, @@ -71,6 +73,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt ...payload, paneKey, ...(launchToken ? { launchToken } : {}), + ...(launchConfig !== undefined ? { launchConfig } : {}), ...(terminalHandle ? { terminalHandle } : {}), tabId, worktreeId, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts index 7732f310854..d92fb7a9ffb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts @@ -135,6 +135,9 @@ export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPt ...(session.paneStartup?.resumeOrigin ? { resumeOrigin: session.paneStartup.resumeOrigin } : {}), + ...(session.paneStartup?.resumeSource?.record?.paneKey + ? { resumeSourcePaneKey: session.paneStartup.resumeSource.record.paneKey } + : {}), ...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt) ? { agentPrompt: diff --git a/src/renderer/src/components/terminal-pane/pty-connection/startup/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/startup/cold-restore-resume-startup.ts index 983d4b9c96f..99e7d26d8d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/startup/cold-restore-resume-startup.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/startup/cold-restore-resume-startup.ts @@ -17,7 +17,7 @@ import { agentResumeOriginNamesAnotherExecutionHost, sleepingRecordNamesAnotherExecutionHost } from '@/lib/agent-session/resume/sleeping-record-execution-host-scope' -import { resolveResumeLaunchRecipe } from '../../../../../../shared/agent-launch/choices/tui-agent-launch-defaults' +import { resolveResumeLaunchRecipe } from '../../../../../../shared/agent-launch/choices/resume-launch-recipe' import { agentProviderSessionsEqual, isResumableTuiAgent, @@ -78,9 +78,10 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe agentProviderSessionsEqual(agent, sleepingRecord.providerSession, providerSession) ? sleepingRecord : undefined - const launchConfig = - (useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? - matchingSleepingRecord?.launchConfig + // A live invocation takes the decision's answer, none included; only otherwise the saved record. + const launchConfig = entry + ? state.getAgentLaunchConfigForStatusEntry(entry) + : matchingSleepingRecord?.launchConfig // Quote resume for the live pane’s effective Windows shell rather than the platform’s PowerShell default. // Why: a nested-SSH pane on a server runs on its SSH target; the pane's host names a worktree // copy, while a folder's own record carries its connection. @@ -113,7 +114,7 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe agent, providerSession, cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - ...resolveResumeLaunchRecipe(launchConfig), + ...resolveResumeLaunchRecipe(launchConfig, agent, providerSession), platform: resumeTarget.platform, agentHostPlatform: resumeTarget.agentHostPlatform, shell: resumeTarget.shell, diff --git a/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts index 1f864f77b0f..324787a93ad 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts @@ -309,6 +309,8 @@ export type IpcPtyTransportOptions = { launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata resumeOrigin?: 'saved' + /** The pane a saved restore resumes, when it opens in another pane. */ + resumeSourcePaneKey?: string agentPrompt?: string agentPromptDelivery?: AgentPromptDelivery freshAgentLaunch?: FreshWorkspaceAgentLaunch diff --git a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts index 7d9b3a08f37..84ff3ef267c 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts @@ -23,7 +23,10 @@ import { runtimeEnvironmentSupportsCapability } from '@/runtime/rpc/compatibilit import { readTerminalSpawnDeferral } from '../../../../../../shared/terminal/pty/admission/spawn-admission-contract' import { remoteRestoreSpawnIntentParams } from '../../../../runtime/hosted-terminals/restoration/remote-restore-spawn-intent' import { createAgentSessionKeyboardOptions } from '@/runtime/hosted-terminals/creation/agent-launch/agent-session-keyboard-capability' -import { agentProviderSessionForHostEnsure } from '@/runtime/hosted-terminals/creation/agent-launch/agent-session-ensure-provider-session' +import { + agentProviderSessionForHostEnsure, + restoreSourcePaneOptions +} from '@/runtime/hosted-terminals/creation/agent-launch/agent-session-ensure-provider-session' import { createRemoteRuntimeDisconnectedInputGrace } from './input/remote-runtime-disconnected-input-grace' import { createRemoteRuntimeRecoveryInputHold, @@ -249,6 +252,7 @@ export function createRemoteRuntimePtyTransport( launchConfig, resumeProviderSession, resumeOrigin, + resumeSourcePaneKey, launchToken, launchAgent, terminalColorQueryReplies, @@ -3773,6 +3777,10 @@ export function createRemoteRuntimePtyTransport( resumeProviderSessionToSend ) : undefined + const sourcePaneOptions = + ensureProviderSession && originOptions.resumeOrigin && resumeSourcePaneKey + ? await restoreSourcePaneOptions(createEnvironmentId, resumeSourcePaneKey) + : {} const result = await createWithUnknownOutcomeRecovery( 'agent-session', (timeoutMs) => @@ -3784,6 +3792,7 @@ export function createRemoteRuntimePtyTransport( kind: 'explicit', ...keyboardOptions, ...originOptions, + ...sourcePaneOptions, worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, providerSession: ensureProviderSession, diff --git a/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts index 8057cd75852..d3e4026a349 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts @@ -60,7 +60,13 @@ export function applyProviderSessionOnlyUpdate(args: { providerSession: data.providerSession, timing: { updatedAt: data.receivedAt }, routing, - metadata: data.launchToken ? { launchToken: data.launchToken } : undefined + metadata: + data.launchToken || data.launchConfig !== undefined + ? { + ...(data.launchToken ? { launchToken: data.launchToken } : {}), + ...(data.launchConfig !== undefined ? { launchConfig: data.launchConfig } : {}) + } + : undefined } if (batch) { return batch.transaction.apply(update) ? 'applied' : 'dropped' diff --git a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts index edf96a7f1b2..01abb784203 100644 --- a/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts +++ b/src/renderer/src/hooks/ipc-events/normalize-agent-status-event.ts @@ -31,13 +31,20 @@ export function normalizeAgentStatusMetadata( data: AgentStatusIpcPayload, authorityRestartId?: string ): AgentStatusMetadata | undefined { - if (!data.providerSession && !data.launchToken && !authorityRestartId && !data.paneOwner) { + if ( + !data.providerSession && + !data.launchToken && + data.launchConfig === undefined && + !authorityRestartId && + !data.paneOwner + ) { return undefined } return { ...(authorityRestartId ? { authorityRestartId } : {}), ...(data.providerSession ? { providerSession: data.providerSession } : {}), ...(data.launchToken ? { launchToken: data.launchToken } : {}), + ...(data.launchConfig !== undefined ? { launchConfig: data.launchConfig } : {}), ...(data.paneOwner ? { paneOwner: data.paneOwner } : {}) } } diff --git a/src/renderer/src/lib/agent-session/accounts/agent-account-restart-startup.ts b/src/renderer/src/lib/agent-session/accounts/agent-account-restart-startup.ts index de459f5ae58..07d4ae894b7 100644 --- a/src/renderer/src/lib/agent-session/accounts/agent-account-restart-startup.ts +++ b/src/renderer/src/lib/agent-session/accounts/agent-account-restart-startup.ts @@ -20,10 +20,7 @@ import { type AgentProviderSessionMetadata, type SleepingAgentLaunchConfig } from '../../../../../shared/agent-session/lifecycle/agent-session-resume' -import { - resolveTuiAgentLaunchRawArgs, - resolveTuiAgentLaunchEnv -} from '../../../../../shared/agent-launch/choices/tui-agent-launch-defaults' +import { resolveResumeLaunchRecipe } from '../../../../../shared/agent-launch/choices/resume-launch-recipe' import type { AccountRestartAgent } from '@/store/terminals/terminal-contracts' import type { ProjectExecutionRuntimeResolution } from '../../../../../shared/execution-host/project-execution-runtime' import { resolveAgentLaunchCommand } from '../../../../../shared/agent-launch/startup/tui-agent-launch-command' @@ -55,8 +52,6 @@ export function resolveAgentAccountRestartResumeStartup( agent: AccountRestartAgent, args: AccountRestartPane ): AgentAccountRestartStartup | null { - // Why: a hand-started Claude must not come back with configured defaults such as a permission bypass. - const unrecordedLaunchUsesDefaults = agent !== 'claude' const state = useAppStore.getState() const paneKey = makePaneKey(args.tabId, args.leafId) const entry = state.agentStatusByPaneKey[paneKey] @@ -80,35 +75,18 @@ export function resolveAgentAccountRestartResumeStartup( ) { return null } - const launchConfig = - (entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? - (sleeping?.agent === agent ? sleeping.launchConfig : undefined) + // A live invocation takes the one decision's answer, none included. + const launchConfig = entry + ? state.getAgentLaunchConfigForStatusEntry(entry) + : sleeping?.agent === agent + ? sleeping.launchConfig + : undefined const resumeTarget = resolveAccountRestartLaunchTarget(state, args, projectRuntime) const startupPlan = buildAgentResumeStartupPlan({ agent, providerSession, cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - agentArgs: - launchConfig !== undefined - ? launchConfig.agentArgs - : unrecordedLaunchUsesDefaults - ? resolveTuiAgentLaunchRawArgs( - agent, - state.settings, - undefined, - resolveStartupShell(resumeTarget.platform, resumeTarget.shell) - ) - : null, - agentEnv: - launchConfig !== undefined - ? launchConfig.agentEnv - : unrecordedLaunchUsesDefaults - ? resolveTuiAgentLaunchEnv(agent, state.settings, resumeTarget.platform) - : null, - ...(launchConfig?.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), - ...(launchConfig?.ompResumeFilePath - ? { ompResumeFilePath: launchConfig.ompResumeFilePath } - : {}), + ...resolveResumeLaunchRecipe(launchConfig, agent, providerSession), platform: resumeTarget.platform, agentHostPlatform: resumeTarget.agentHostPlatform, shell: resumeTarget.shell, diff --git a/src/renderer/src/lib/agent-session/resume/sleeping-agent-session-launch.ts b/src/renderer/src/lib/agent-session/resume/sleeping-agent-session-launch.ts index 1800abbfff3..2b4c7c008d2 100644 --- a/src/renderer/src/lib/agent-session/resume/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/agent-session/resume/sleeping-agent-session-launch.ts @@ -15,7 +15,7 @@ import { import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' import { runtimeEnvironmentIdUnlessTwin } from '@/lib/workspace/ownership/resolve-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { resolveResumeLaunchRecipe } from '../../../../../shared/agent-launch/choices/tui-agent-launch-defaults' +import { resolveResumeLaunchRecipe } from '../../../../../shared/agent-launch/choices/resume-launch-recipe' import type { SleepingAgentSessionRecord } from '../../../../../shared/agent-session/lifecycle/agent-session-resume' import { translate } from '@/i18n/i18n' @@ -65,7 +65,7 @@ export function launchSleepingAgentSession( agent: record.agent, providerSession: record.providerSession, cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - ...resolveResumeLaunchRecipe(launchConfig), + ...resolveResumeLaunchRecipe(launchConfig, record.agent, record.providerSession), platform: resumeTarget.platform, agentHostPlatform: resumeTarget.agentHostPlatform, shell: resumeTarget.shell, diff --git a/src/renderer/src/runtime/hosted-terminals/creation/agent-launch/agent-session-ensure-provider-session.ts b/src/renderer/src/runtime/hosted-terminals/creation/agent-launch/agent-session-ensure-provider-session.ts index d0bf42f1c44..9b4a4678414 100644 --- a/src/renderer/src/runtime/hosted-terminals/creation/agent-launch/agent-session-ensure-provider-session.ts +++ b/src/renderer/src/runtime/hosted-terminals/creation/agent-launch/agent-session-ensure-provider-session.ts @@ -1,14 +1,16 @@ import type { AgentProviderSessionMetadata } from '../../../../../../shared/agent-session/lifecycle/agent-session-resume' import { AGENT_SESSION_RESUME_EFFORT_RUNTIME_CAPABILITY } from '../../../../../../shared/agent-session/agent-session-create-capabilities' +import { AGENT_SESSION_RESTORE_SOURCE_PANE_RUNTIME_CAPABILITY } from '../../../../../../shared/agent-session/agent-session-runtime-capabilities' import { runtimeEnvironmentSupportsCapability } from '../../../rpc/compatibility/runtime-capability-proof' import { isRuntimeCompatBlockError } from '../../../rpc/compatibility/runtime-protocol-compat' +import { toHostSessionPaneKey } from '../../../../../../shared/workspace-layout/terminal-surface-id' -/** The provider session an ensureAgentSession call may send to this host; older hosts' strict schema rejects `cwd` and `effort`. */ +/** The provider session an ensureAgentSession call may send to this host; older hosts' strict schema rejects `cwd` and `permissionMode`, and older hosts' also `effort`. */ export async function agentProviderSessionForHostEnsure( environmentId: string, providerSession: AgentProviderSessionMetadata ): Promise { - const { cwd: _cwd, effort, ...identity } = providerSession + const { cwd: _cwd, effort, permissionMode: _permissionMode, ...identity } = providerSession return effort && (await hostAcceptsResumeEffort(environmentId)) ? { ...identity, effort } : identity @@ -27,3 +29,23 @@ async function hostAcceptsResumeEffort(environmentId: string): Promise return false } } + +/** The source pane a saved restore names, in the host's own keys, for hosts whose strict schema accepts it. */ +export async function restoreSourcePaneOptions( + environmentId: string, + paneKey: string +): Promise<{ restoreSourcePaneKey?: string }> { + try { + return (await runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_SESSION_RESTORE_SOURCE_PANE_RUNTIME_CAPABILITY + )) + ? { restoreSourcePaneKey: toHostSessionPaneKey(paneKey) } + : {} + } catch (error) { + if (isRuntimeCompatBlockError(error)) { + throw error + } + return {} + } +} diff --git a/src/renderer/src/runtime/hosted-terminals/creation/agent-launch/agent-session-restore-source-pane.test.ts b/src/renderer/src/runtime/hosted-terminals/creation/agent-launch/agent-session-restore-source-pane.test.ts new file mode 100644 index 00000000000..b39d35092ab --- /dev/null +++ b/src/renderer/src/runtime/hosted-terminals/creation/agent-launch/agent-session-restore-source-pane.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it, vi } from 'vitest' +import { restoreSourcePaneOptions } from './agent-session-ensure-provider-session' + +vi.mock('../../../rpc/compatibility/runtime-capability-proof', () => ({ + runtimeEnvironmentSupportsCapability: async () => true +})) + +const LEAF = '11111111-1111-4111-8111-111111111111' + +// The host keys its saved notes by its own tab, never the desktop mirror's. +describe('a saved restore source pane sent to its host', () => { + it('names the host tab, not the mirror tab', async () => { + expect(await restoreSourcePaneOptions('env-1', `web-terminal-host-tab:${LEAF}`)).toEqual({ + restoreSourcePaneKey: `host-tab:${LEAF}` + }) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index 1ed7ad99008..9d8ab7da175 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -25,6 +25,10 @@ import { remapHostAgentStatus, updateBatchAgentPaneKey } from './agent-status-primitives' +import { + retirePaneLaunchNote, + retiresLaunch +} from '../../store/slices/agent-status/agent-status-launch-config' import { agentStatusEntryEqual, isAgentStatusFresh, @@ -32,6 +36,14 @@ import { writableWebSessionTabsRecord } from './state-equality-core' +type MirroredAgentStatusPatch = Pick< + WebSessionTabsSyncState, + 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'sortEpoch' +> & + Partial< + Pick + > + export function buildMirroredAgentStatusPatch( state: WebSessionTabsSyncState, currentTerminalTabs: readonly TerminalTab[], @@ -42,7 +54,7 @@ export function buildMirroredAgentStatusPatch( retractedTabIds: ReadonlySet, now: number, batchContext?: WebSessionTabsBatchContext -): Pick | null { +): MirroredAgentStatusPatch | null { const mirroredTabIds = new Set() for (const tab of currentTerminalTabs) { if (isWebTerminalSurfaceTabId(tab.id)) { @@ -123,8 +135,14 @@ export function buildMirroredAgentStatusPatch( : entry.lastAssistantMessageIsToolOutput } : entry + // The host owns the launch fact whichever writer wins presentation. const accepted = - nextEntry === entry ? entry : withHostProgramStatus(nextEntry, entry.programStatus) + nextEntry === entry + ? entry + : { + ...withHostProgramStatus(nextEntry, entry.programStatus), + ...(entry.launchAttested !== undefined ? { launchAttested: entry.launchAttested } : {}) + } const retained = existing ? undefined : state.retainedAgentsByPaneKey?.[entry.paneKey] nextByPaneKey.set(entry.paneKey, { ...accepted, @@ -175,8 +193,13 @@ export function buildMirroredAgentStatusPatch( sortRelevantChange = true } + let retirement: ReturnType = {} for (const [paneKey, entry] of nextByPaneKey) { const existing = nextAgentStatusByPaneKey[paneKey] + // The host's retirement of this pane's launch ends the client's copies of its note. + if (retiresLaunch(existing, entry)) { + retirement = { ...retirement, ...retirePaneLaunchNote({ ...state, ...retirement }, paneKey) } + } if ( agentStatusEntryEqual(existing, entry) && existing?.firstStateStartedAt === entry.firstStateStartedAt @@ -226,7 +249,8 @@ export function buildMirroredAgentStatusPatch( return { agentStatusByPaneKey: nextAgentStatusByPaneKey, agentStatusEpoch: aggregateRelevantChange ? state.agentStatusEpoch + 1 : state.agentStatusEpoch, - sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch + sortEpoch: sortRelevantChange ? state.sortEpoch + 1 : state.sortEpoch, + ...retirement } } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index b5972ddf426..bacdb7ebe97 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -67,7 +67,8 @@ export function remapHostAgentStatus( ...entry, worktreeId: entry.worktreeId || frameWorktreeId, paneKey, - tabId: toWebTerminalSurfaceTabId(surface.parentTabId) + tabId: toWebTerminalSurfaceTabId(surface.parentTabId), + ...(surface.launchAttested !== undefined ? { launchAttested: surface.launchAttested } : {}) } } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts index 9f674872ad4..f9a3ca8b49a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts @@ -66,6 +66,7 @@ export function agentStatusEntryEqual( mainAgentStatusEqual(a.mainAgent, b.mainAgent) && a.promptInteractionKey === b.promptInteractionKey && a.restoredUnconfirmed === b.restoredUnconfirmed && + a.launchAttested === b.launchAttested && programStatusFacetsEqual(a.programStatus, b.programStatus) && agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && sameAgentStateHistory(a.stateHistory, b.stateHistory) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index 4f4b23bcb34..86dbdd29547 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -243,6 +243,7 @@ export type WebSessionTabsSyncState = Pick< | 'localOnlyScrollbackByTabId' | 'pendingStartupByTabId' | 'runtimePaneTitlesByTabId' + | 'sleepingAgentSessionsByPaneKey' > > diff --git a/src/renderer/src/store/slices/agent-status/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status/agent-status-contract.ts index cc1a993eebe..04e9c1d617c 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-contract.ts @@ -106,7 +106,8 @@ export type AgentStatusMetadata = { /** Structured status rows remain fresh while the host owns the session; cleared on feed loss. */ structuredHostOwned?: true providerSession?: AgentProviderSessionMetadata - launchConfig?: SleepingAgentLaunchConfig + /** Null is a host statement that no Orca launch runs; absent says nothing. */ + launchConfig?: SleepingAgentLaunchConfig | null launchToken?: string terminalResumeEligible?: false paneOwner?: { agent: AgentType; session: string } @@ -130,7 +131,10 @@ export type AgentProviderSessionRouting = { connectionId?: string | null } -export type AgentProviderSessionRecordMetadata = { launchToken?: string } +export type AgentProviderSessionRecordMetadata = { + launchToken?: string + launchConfig?: SleepingAgentLaunchConfig | null +} export type AgentProviderSessionUpdate = { kind: 'providerSession' diff --git a/src/renderer/src/store/slices/agent-status/agent-status-launch-config.ts b/src/renderer/src/store/slices/agent-status/agent-status-launch-config.ts index fe5e8607e50..1deae45487c 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-launch-config.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-launch-config.ts @@ -115,31 +115,88 @@ export function getLaunchConfigForEntry( entry: AgentStatusEntry ): SleepingAgentLaunchConfig | undefined { const registryEntry = state.agentLaunchConfigByPaneKey[entry.paneKey] - const registryLaunchConfig = registryEntryMatchesStatus({ - entry: registryEntry, - paneKey: entry.paneKey, + return invocationLaunchConfig({ + launchAttested: entry.launchAttested, + registry: registryEntryMatchesStatus({ + entry: registryEntry, + paneKey: entry.paneKey, + agentType: entry.agentType, + tabId: entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? undefined, + terminalHandle: entry.terminalHandle, + launchToken: undefined, + providerSession: entry.providerSession, + existingProviderSession: entry.providerSession, + providerSessionChanged: false + }) + ? registryEntry?.launchConfig + : undefined, + record: state.sleepingAgentSessionsByPaneKey[entry.paneKey], agentType: entry.agentType, - tabId: entry.tabId ?? getTabIdFromPaneKey(entry.paneKey) ?? undefined, - terminalHandle: entry.terminalHandle, - launchToken: undefined, - providerSession: entry.providerSession, - existingProviderSession: entry.providerSession, - providerSessionChanged: false + providerSession: entry.providerSession }) - ? registryEntry?.launchConfig - : undefined - if (registryLaunchConfig) { - return registryLaunchConfig +} + +/** Every reader's note for a pane's invocation: the owner's word decides; without one, the same conversation. */ +export function invocationLaunchConfig(args: { + statement?: SleepingAgentLaunchConfig | null + launchAttested: boolean | undefined + registry: SleepingAgentLaunchConfig | undefined + record: AppState['sleepingAgentSessionsByPaneKey'][string] | undefined + agentType: AgentStatusEntry['agentType'] + providerSession: AgentProviderSessionMetadata | undefined +}): SleepingAgentLaunchConfig | undefined { + const { record, agentType, providerSession } = args + if (args.statement !== undefined || args.launchAttested === false) { + return args.statement ?? undefined } - const sleepingRecord = state.sleepingAgentSessionsByPaneKey[entry.paneKey] - return sleepingRecord?.launchConfig && - sleepingRecord.agent === entry.agentType && - entry.providerSession && - agentProviderSessionsEqual( - entry.agentType, - sleepingRecord.providerSession, - entry.providerSession - ) - ? sleepingRecord.launchConfig + if (args.registry || !record?.launchConfig || record.agent !== agentType) { + return args.registry + } + return args.launchAttested || + (!!providerSession && + agentProviderSessionsEqual(agentType, record.providerSession, providerSession)) + ? record.launchConfig : undefined } + +/** A statement (note or null) replaces the owner's word; anything else keeps the same agent's. */ +export function ownerLaunchAttested( + statement: SleepingAgentLaunchConfig | null | undefined, + existing: AgentStatusEntry | undefined, + agentType: AgentStatusEntry['agentType'] +): boolean | undefined { + if (statement !== undefined) { + return statement !== null + } + return existing && existing.agentType === agentType ? existing.launchAttested : undefined +} + +/** The owner's word turned to none: a later none (an inherited fact) must not erase a newer launch. */ +export function retiresLaunch( + existing: Pick | undefined, + next: Pick +): boolean { + return next.launchAttested === false && existing?.launchAttested !== false +} + +type PaneNoteCopies = Partial< + Pick +> + +/** An accepted retirement ends the pane's note: its launch registry entry and its saved record's note. */ +export function retirePaneLaunchNote(state: PaneNoteCopies, paneKey: string): PaneNoteCopies { + const patch: PaneNoteCopies = {} + if (state.agentLaunchConfigByPaneKey?.[paneKey]) { + const { [paneKey]: _retired, ...rest } = state.agentLaunchConfigByPaneKey + patch.agentLaunchConfigByPaneKey = rest + } + const record = state.sleepingAgentSessionsByPaneKey?.[paneKey] + if (record?.launchConfig) { + const { launchConfig: _note, ...withoutNote } = record + patch.sleepingAgentSessionsByPaneKey = { + ...state.sleepingAgentSessionsByPaneKey, + [paneKey]: withoutNote + } + } + return patch +} diff --git a/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts index 2731d267f38..c547984ea35 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts @@ -26,14 +26,17 @@ import type { AgentStatusRouting, AgentStatusTiming } from './agent-status-contract' -import { registryEntryMatchesStatus } from './agent-status-launch-config' +import { + invocationLaunchConfig, + ownerLaunchAttested, + registryEntryMatchesStatus +} from './agent-status-launch-config' import { findAgentPaneWorktreeId, getTabIdFromPaneKey } from './agent-status-pane-key-tab-binding' import { mergeCurrentOrchestrationContext } from './agent-status-orchestration-context' import { deriveAgentStatusLiveFacts } from './agent-status-live-facts' import { liveEntryChildFields } from './agent-status-live-entry-children' import { canReuseStatusProviderSession, - recordLaunchConfigForStatus, statusRetainsResumableIdentity } from '../../../../../shared/agent-session/lifecycle/agent-resume-record-from-status' @@ -191,24 +194,20 @@ export function buildAgentStatusLiveEntry( providerSessionChanged }) const matchedRegistryLaunchConfig = registryMatched ? registryEntry?.launchConfig : undefined - const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey] const retainsResumableRecoveryIdentity = statusRetainsResumableIdentity( { agentType: identity.agentType, state: payload.state }, providerSession ) - const matchedSleepingLaunchConfig = - payload.state !== 'done' || retainsResumableRecoveryIdentity - ? recordLaunchConfigForStatus(existingSleepingRecord, { - agentType: identity.agentType, - providerSession - }) - : undefined - const launchConfigSource = - (payload.state !== 'done' && !providerSessionChanged && metadata?.launchToken - ? metadata?.launchConfig - : undefined) ?? - matchedRegistryLaunchConfig ?? - matchedSleepingLaunchConfig + // Local main's note-or-null or a paired host's frame fact; an OSC update says nothing. + const launchAttested = ownerLaunchAttested(metadata?.launchConfig, existing, identity.agentType) + const launchConfigSource = invocationLaunchConfig({ + statement: metadata?.launchConfig, + launchAttested, + registry: matchedRegistryLaunchConfig, + record: state.sleepingAgentSessionsByPaneKey[paneKey], + agentType: identity.agentType, + providerSession + }) const mainAgent = resolveAgentStatusLiveEntryMainAgent(existing, payload, identity.agentType) const programStatus = payload.programStatus === undefined ? existing?.programStatus : payload.programStatus @@ -258,6 +257,7 @@ export function buildAgentStatusLiveEntry( ...liveEntryChildFields(existing, payload), ...(mainAgent ? { mainAgent } : {}), ...(providerSession ? { providerSession } : {}), + ...(launchAttested !== undefined ? { launchAttested } : {}), ...(metadata?.terminalResumeEligible === false ? { terminalResumeEligible: false as const } : {}), diff --git a/src/renderer/src/store/slices/agent-status/agent-status-live-reducer.ts b/src/renderer/src/store/slices/agent-status/agent-status-live-reducer.ts index 508b8d95933..ed413e542e8 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-live-reducer.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-live-reducer.ts @@ -11,6 +11,7 @@ import { removePaneKeys } from './agent-status-pane-keyed-records' import type { AgentStatusLiveEntryBuild } from './agent-status-live-entry-builder' import { agentProviderSessionsEqual } from '../../../../../shared/agent-session/lifecycle/agent-session-resume' import { liveResumeRecordChange } from '../../../../../shared/agent-session/lifecycle/agent-resume-record-from-status' +import { retiresLaunch } from './agent-status-launch-config' export type AgentStatusLiveUpdateReduction = { patch: Partial @@ -66,9 +67,11 @@ export function reduceAgentStatusLiveUpdate( [paneKey]: { ...registryEntry, identity: { ...registryEntry.identity, providerSession } } } } - // Launch tokens authorize only the session they started; a completed turn or changed provider id consumes them. + // A completed turn, changed provider id or the owner retiring the launch consumes its token. if ( - (providerSessionChanged || (entry.state === 'done' && entry.sessionBoundary !== true)) && + (providerSessionChanged || + (entry.state === 'done' && entry.sessionBoundary !== true) || + retiresLaunch(state.agentStatusByPaneKey[paneKey], entry)) && paneKey in state.agentLaunchConfigByPaneKey ) { nextLaunchConfigs = { ...state.agentLaunchConfigByPaneKey } diff --git a/src/renderer/src/store/slices/agent-status/agent-status-paired-launch-retirement.test.ts b/src/renderer/src/store/slices/agent-status/agent-status-paired-launch-retirement.test.ts new file mode 100644 index 00000000000..652cf96d224 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status/agent-status-paired-launch-retirement.test.ts @@ -0,0 +1,85 @@ +import { expect, it } from 'vitest' + +import type { AppState } from '../../types' +import './agent-status' +import { createTestStore } from '../../testing/app-store-test-fixture' +import { buildMirroredAgentStatusPatch } from '../../../runtime/web-session-tabs-sync/agent-status-patch' +import type { WebSessionTabsSyncState } from '../../../runtime/web-session-tabs-sync/state' +import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../../../shared/workspace-layout/terminal-surface-id' +import { resolveResumeLaunchRecipe } from '../../../../../shared/agent-launch/choices/resume-launch-recipe' +import { buildAgentResumeStartupPlan } from '../../../../../shared/agent-launch/startup/tui-agent-resume-startup' + +const LEAF = '11111111-1111-4111-8111-111111111111' +const TAB = toWebTerminalSurfaceTabId('host-tab') +const PANE = makePaneKey(TAB, LEAF) +const SESSION = { key: 'session_id' as const, id: 'conversation-1', permissionMode: 'default' } +const NOTE = { agentArgs: '--dangerously-skip-permissions --verbose', agentEnv: { OLD: '1' } } + +// A hand-typed resume in a paired pane must not replay the retired launch's bypass. +it('a paired host retiring a launch ends its note for OSC updates and cold restore', () => { + const store = createTestStore() + let clock = 1_700_000_000_000 + const tick = () => (clock += 1000) + const osc = () => + store + .getState() + .setAgentStatus( + PANE, + { state: 'working', prompt: 'task', agentType: 'claude' }, + 'Claude', + { updatedAt: tick(), stateStartedAt: 10 }, + { tabId: TAB, worktreeId: 'wt' }, + { providerSession: SESSION, launchToken: 'launch-1' } + ) + const frame = (launchAttested: boolean) => { + const agentStatus = { ...store.getState().agentStatusByPaneKey[PANE]!, updatedAt: tick() } + const surface = { + type: 'terminal' as const, + id: `host-tab::${LEAF}`, + title: 'Claude', + parentTabId: 'host-tab', + leafId: LEAF, + isActive: true, + status: 'ready' as const, + terminal: 'terminal-1', + launchAttested, + agentStatus + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: AppState holds every sync-state field the patch reads. + const state = store.getState() as unknown as WebSessionTabsSyncState + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the patch's fields are AppState fields. + store.setState({ + ...buildMirroredAgentStatusPatch(state, [], [surface], [], 'env', 'wt', new Set(), tick()) + } as Partial) + } + const note = () => store.getState().sleepingAgentSessionsByPaneKey[PANE]?.launchConfig + + store.getState().registerAgentLaunchConfig(PANE, NOTE, { + agentType: 'claude', + launchToken: 'launch-1', + tabId: TAB, + leafId: LEAF + }) + osc() + frame(true) + osc() + expect(note()).toEqual(NOTE) + + frame(false) + osc() + expect(note()).toBeUndefined() + const state = store.getState() + const coldRestore = buildAgentResumeStartupPlan({ + agent: 'claude', + providerSession: SESSION, + cmdOverrides: {}, + ...resolveResumeLaunchRecipe( + state.getAgentLaunchConfigForStatusEntry(state.agentStatusByPaneKey[PANE]!), + 'claude', + SESSION + ), + platform: 'linux' + }) + expect(coldRestore?.launchCommand).toBe("claude '--resume' 'conversation-1'") +}) diff --git a/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts index 8f0a36faa76..c87c3be1167 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-provider-session-actions.ts @@ -19,7 +19,11 @@ import { isRecentlyClosedAgentStatusTab } from './agent-status-pane-key-tab-binding' import { removePaneKeys } from './agent-status-pane-keyed-records' -import { registryEntryMatchesStatus } from './agent-status-launch-config' +import { + invocationLaunchConfig, + ownerLaunchAttested, + registryEntryMatchesStatus +} from './agent-status-launch-config' import { copyLaunchConfig } from '../../../../../shared/agent-session/lifecycle/agent-resume-record-from-status' import { agentVerdictFields } from '../../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' import { settleAttentionRemovedBy } from '../workspace-attention/attention-removal-follow-up' @@ -90,9 +94,14 @@ export function createAgentStatusProviderSessionActions( // Why: an explicit quit capture must remain the resume handle until a new provider session replaces it. const preservesQuitOrigin = existingRecordMatchesProviderSession && existingRecord?.origin === 'quit' - const launchConfig = - (registryMatches ? registryEntry?.launchConfig : undefined) ?? - (existingRecordMatchesProviderSession ? existingRecord.launchConfig : undefined) + const launchConfig = invocationLaunchConfig({ + statement: metadata?.launchConfig, + launchAttested: ownerLaunchAttested(metadata?.launchConfig, existingStatus, agent), + registry: registryMatches ? registryEntry?.launchConfig : undefined, + record: existingRecord, + agentType: agent, + providerSession + }) const record: SleepingAgentSessionRecord = { paneKey, ...(tabId ? { tabId } : {}), @@ -136,7 +145,8 @@ export function createAgentStatusProviderSessionActions( const retiredPaneKeys = new Set([paneKey]) // Identity mismatch clears sleeping launch config; clear registry too or returning old identity reuses stale args/env. let nextLaunchConfigs = s.agentLaunchConfigByPaneKey - if (registryMatches && registryEntry) { + // An owner's none ends the launch, so its registry entry goes rather than re-stamped. + if (registryMatches && registryEntry && metadata?.launchConfig !== null) { nextLaunchConfigs = { ...nextLaunchConfigs, [paneKey]: { diff --git a/src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-permission-mode.test.ts b/src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-permission-mode.test.ts new file mode 100644 index 00000000000..f0fac65b262 --- /dev/null +++ b/src/shared/agent-hook-listener/normalization/agent-hook-listener-claude-permission-mode.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createHookListenerState, type HookListenerState } from '../listener-state' +import { normalizeAndAccept } from './agent-hook-listener-test-harness' + +describe("Claude's permission mode on the pane's provider session", () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + function post(payload: Record): string | undefined { + return normalizeAndAccept(state, 'claude', { + session_id: '6f1f0b5e-2c3d-4e5f-8a9b-0c1d2e3f4a5b', + ...payload + })?.providerSession?.permissionMode + } + + // A hand-typed resume of the same conversation may drop the bypass the last process had. + it('keeps the mode across /clear but not into a fresh resume of the same conversation', () => { + post({ + hook_event_name: 'UserPromptSubmit', + prompt: 'hi', + permission_mode: 'bypassPermissions' + }) + expect(post({ hook_event_name: 'SessionStart', source: 'clear' })).toBe('bypassPermissions') + expect(post({ hook_event_name: 'SessionStart', source: 'resume' })).toBeUndefined() + }) +}) diff --git a/src/shared/agent-hook-listener/pane-provider-session.ts b/src/shared/agent-hook-listener/pane-provider-session.ts index 76210f806ce..2c2cdc3bc69 100644 --- a/src/shared/agent-hook-listener/pane-provider-session.ts +++ b/src/shared/agent-hook-listener/pane-provider-session.ts @@ -1,5 +1,6 @@ import type { AgentHookSource } from './transport/agent-hook-relay' import { readAgentEffortLevel } from '../agent-launch/choices/agent-effort-level' +import { readClaudePermissionMode } from '../agent-session/lifecycle/provider-session-live-settings' import { extractAgentProviderSession, readPathFromKeys, @@ -96,10 +97,12 @@ function readPaneProviderSession( ) const cwd = readAgentCwd(source, hookPayload, continued) const effort = readClaudeEffort(source, hookPayload, continued) + const permissionMode = readClaudeLivePermissionMode(source, hookPayload, continued) return { ...providerSession, ...(cwd ? { cwd } : {}), - ...(effort ? { effort } : {}) + ...(effort ? { effort } : {}), + ...(permissionMode ? { permissionMode } : {}) } } @@ -167,6 +170,26 @@ function readClaudeEffort( return reported ?? continued?.effort } +/** Claude names its permission mode on prompt, tool and Stop hooks, not SessionStart, so later events of one process keep the last one. */ +function readClaudeLivePermissionMode( + source: AgentHookSource, + hookPayload: Record, + continued: AgentProviderSessionMetadata | undefined +): string | undefined { + if (source !== 'claude') { + return undefined + } + // Why skip subagents: their hooks name the parent session but report the subagent's own mode. + const reported = readString(hookPayload, 'agent_id') + ? undefined + : readClaudePermissionMode(hookPayload.permission_mode) + // A fresh process may run with other flags than the last one on this conversation. + const freshStart = + hookPayload.hook_event_name === 'SessionStart' && + (hookPayload.source === 'startup' || hookPayload.source === 'resume') + return reported ?? (freshStart ? undefined : continued?.permissionMode) +} + /** A status correlation may reuse an established owner without publishing a new restore locator. */ function readHookStatusSession(incoming: AgentHookEventPayload): string | undefined { return ( diff --git a/src/shared/agent-launch/choices/resume-launch-recipe.test.ts b/src/shared/agent-launch/choices/resume-launch-recipe.test.ts new file mode 100644 index 00000000000..526f93a022a --- /dev/null +++ b/src/shared/agent-launch/choices/resume-launch-recipe.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { resolveResumeLaunchRecipe } from './resume-launch-recipe' + +const session = { key: 'session_id', id: 'abc' } as const + +describe('resolveResumeLaunchRecipe without a recorded launch', () => { + it('resumes a Claude that reported bypass with the bypass flag', () => { + expect( + resolveResumeLaunchRecipe(undefined, 'claude', { + ...session, + permissionMode: 'bypassPermissions' + }) + ).toEqual({ agentArgs: '--dangerously-skip-permissions', agentEnv: {} }) + }) + + it('resumes bare when the session reported another mode or none', () => { + for (const permissionMode of ['default', undefined]) { + expect( + resolveResumeLaunchRecipe(undefined, 'claude', { + ...session, + ...(permissionMode ? { permissionMode } : {}) + }) + ).toEqual({ agentArgs: '', agentEnv: {} }) + } + }) +}) diff --git a/src/shared/agent-launch/choices/resume-launch-recipe.ts b/src/shared/agent-launch/choices/resume-launch-recipe.ts new file mode 100644 index 00000000000..5e6e8226fe9 --- /dev/null +++ b/src/shared/agent-launch/choices/resume-launch-recipe.ts @@ -0,0 +1,27 @@ +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../agent-session/lifecycle/agent-session-resume' +import type { TuiAgent } from '../catalog/tui-agent' +import { YOLO_TUI_AGENT_ARGS } from '../permissions/tui-agent-permissions' + +const CLAUDE_BYPASS_ARGS = YOLO_TUI_AGENT_ARGS.claude ?? '' + +/** Replay the recorded recipe; without one, no settings Arguments/env, only a reported bypass (#29898). */ +export function resolveResumeLaunchRecipe( + launchConfig: SleepingAgentLaunchConfig | undefined, + agent: TuiAgent, + providerSession: AgentProviderSessionMetadata | null | undefined +): SleepingAgentLaunchConfig { + if (!launchConfig) { + // Why Claude only: its hooks are the ones that report a root agent's permission mode. + const bypass = agent === 'claude' && providerSession?.permissionMode === 'bypassPermissions' + return { agentArgs: bypass ? CLAUDE_BYPASS_ARGS : '', agentEnv: {} } + } + return { + agentArgs: launchConfig.agentArgs, + agentEnv: launchConfig.agentEnv, + ...(launchConfig.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), + ...(launchConfig.ompResumeFilePath ? { ompResumeFilePath: launchConfig.ompResumeFilePath } : {}) + } +} diff --git a/src/shared/agent-launch/choices/tui-agent-launch-defaults.ts b/src/shared/agent-launch/choices/tui-agent-launch-defaults.ts index 6573b4106bf..bdf7433bd22 100644 --- a/src/shared/agent-launch/choices/tui-agent-launch-defaults.ts +++ b/src/shared/agent-launch/choices/tui-agent-launch-defaults.ts @@ -3,7 +3,6 @@ import { agentPermissionHostPlatform, permissionEnvNameMatches } from '../permissions/agent-permission-env' -import type { SleepingAgentLaunchConfig } from '../../agent-session/lifecycle/agent-session-resume' import type { GlobalSettings } from '../../global-settings-types' import { isTuiAgent } from '../catalog/tui-agent-config' import { YOLO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ENV } from '../permissions/tui-agent-permissions' @@ -307,18 +306,3 @@ export function composeAgentLaunchProfile( ) } } - -/** Resume only the recorded recipe; defaults would grant bypass to unrecorded manual launches that never used it. */ -export function resolveResumeLaunchRecipe( - launchConfig: SleepingAgentLaunchConfig | undefined -): SleepingAgentLaunchConfig { - if (!launchConfig) { - return { agentArgs: '', agentEnv: {} } - } - return { - agentArgs: launchConfig.agentArgs, - agentEnv: launchConfig.agentEnv, - ...(launchConfig.agentCommand ? { agentCommand: launchConfig.agentCommand } : {}), - ...(launchConfig.ompResumeFilePath ? { ompResumeFilePath: launchConfig.ompResumeFilePath } : {}) - } -} diff --git a/src/shared/agent-session/agent-session-host-authority.ts b/src/shared/agent-session/agent-session-host-authority.ts index d91f2244239..e1b36af2ea0 100644 --- a/src/shared/agent-session/agent-session-host-authority.ts +++ b/src/shared/agent-session/agent-session-host-authority.ts @@ -105,6 +105,8 @@ export type RuntimeEnsureAgentSessionRequest = | { kind: 'explicit' resumeOrigin?: 'saved' + /** The pane a saved restore resumes; `placement` names the pane it opens in. */ + restoreSourcePaneKey?: string worktree: string agent: ResumableTuiAgent providerSession: AgentProviderSessionMetadata diff --git a/src/shared/agent-session/agent-session-runtime-capabilities.ts b/src/shared/agent-session/agent-session-runtime-capabilities.ts index e8f66bae90e..0ec3a1394d6 100644 --- a/src/shared/agent-session/agent-session-runtime-capabilities.ts +++ b/src/shared/agent-session/agent-session-runtime-capabilities.ts @@ -121,6 +121,9 @@ export const AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY = 'agent-session.background-task-row-stop.v1' as const // Strict subscribe params refuse `capabilities` on older hosts; a client sends it only when advertised. export const AGENT_SESSION_TEXT_APPEND_RUNTIME_CAPABILITY = 'agent-session.text-append.v1' as const +// Why: a saved restore into a new tab names the pane that ran it; older hosts' strict schema rejects it. +export const AGENT_SESSION_RESTORE_SOURCE_PANE_RUNTIME_CAPABILITY = + 'agent-session.restore-source-pane.v1' as const // Hosts with only text-append.v1 drop `toolOutputAppend`; a reader tracking tool bases could evict a reply base they hold. export const AGENT_SESSION_TOOL_OUTPUT_APPEND_RUNTIME_CAPABILITY = 'agent-session.tool-output-append.v1' as const @@ -131,6 +134,7 @@ export const AGENT_SESSION_PR_EVENTS_RUNTIME_CAPABILITY = 'agent-session.pr-even // Individual client flags must not retain the unused host capability aggregate. export const AGENT_SESSION_RUNTIME_CAPABILITIES = /* @__PURE__ */ (() => [ + AGENT_SESSION_RESTORE_SOURCE_PANE_RUNTIME_CAPABILITY, AGENT_SESSION_TEXT_ATTACHMENT_READ_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, ...AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES, diff --git a/src/shared/agent-session/lifecycle/agent-resume-record-equivalence.ts b/src/shared/agent-session/lifecycle/agent-resume-record-equivalence.ts index 4059f76f63a..e2aed8c4c31 100644 --- a/src/shared/agent-session/lifecycle/agent-resume-record-equivalence.ts +++ b/src/shared/agent-session/lifecycle/agent-resume-record-equivalence.ts @@ -64,8 +64,9 @@ export function recoveryRecordMatches( existing.state === next.state && agentMainAgentVerdict(existing) === agentMainAgentVerdict(next) && agentProviderSessionsEqual(existing.agent, existing.providerSession, next.providerSession) && - // Why: effort is not session identity, but a stale level would resume at the wrong effort. + // Why: effort and permission mode are not session identity, but a stale value would resume wrong. existing.providerSession.effort === next.providerSession.effort && + existing.providerSession.permissionMode === next.providerSession.permissionMode && launchConfigsEqual(existing.launchConfig, next.launchConfig) ) } diff --git a/src/shared/agent-session/lifecycle/agent-session-resume.ts b/src/shared/agent-session/lifecycle/agent-session-resume.ts index 942dbfcc404..84839e49af8 100644 --- a/src/shared/agent-session/lifecycle/agent-session-resume.ts +++ b/src/shared/agent-session/lifecycle/agent-session-resume.ts @@ -1,4 +1,5 @@ import { readAgentEffortLevel } from '../../agent-launch/choices/agent-effort-level' +import { readClaudePermissionMode } from './provider-session-live-settings' import type { AgentHookSource } from '../../agent-hook-listener/transport/agent-hook-relay' import type { AgentStatusCacheIdentity, @@ -50,6 +51,8 @@ export type AgentProviderSessionMetadata = { cwd?: string /** Claude's live effort level; resume replays it because Claude restores only its saved per-model default. */ effort?: string + /** Claude's live permission mode; a resume with no recorded recipe takes its permission from it. */ + permissionMode?: string } export type SleepingAgentLaunchConfig = { @@ -176,24 +179,22 @@ export function normalizeAgentProviderSession(raw: unknown): AgentProviderSessio return null } const record = raw as Record - const key = record.key - if (key !== 'session_id' && key !== 'conversation_id') { - return null - } const id = normalizeSessionId(record.id) - if (!id) { + if ((record.key !== 'session_id' && record.key !== 'conversation_id') || !id) { return null } // Persisted/relay paths cross trust boundaries too; reject controls as for hook paths. const transcriptPath = readPathFromKeys(record, ['transcriptPath']) const cwd = readPathFromKeys(record, ['cwd']) const effort = readAgentEffortLevel(record.effort) + const permissionMode = readClaudePermissionMode(record.permissionMode) return { - key, + key: record.key, id, ...(transcriptPath ? { transcriptPath } : {}), ...(cwd ? { cwd } : {}), - ...(effort ? { effort } : {}) + ...(effort ? { effort } : {}), + ...(permissionMode ? { permissionMode } : {}) } } diff --git a/src/shared/agent-session/lifecycle/provider-session-live-settings.ts b/src/shared/agent-session/lifecycle/provider-session-live-settings.ts new file mode 100644 index 00000000000..7d183d3ec65 --- /dev/null +++ b/src/shared/agent-session/lifecycle/provider-session-live-settings.ts @@ -0,0 +1,6 @@ +// Why a shape, not Claude's mode list: a mode a newer Claude adds must still round-trip. +const CLAUDE_PERMISSION_MODE = /^[A-Za-z]{1,32}$/ + +export function readClaudePermissionMode(value: unknown): string | undefined { + return typeof value === 'string' && CLAUDE_PERMISSION_MODE.test(value) ? value : undefined +} diff --git a/src/shared/agent-status/contracts/agent-status-ipc-payload.ts b/src/shared/agent-status/contracts/agent-status-ipc-payload.ts index 46a8161aea9..95be9963304 100644 --- a/src/shared/agent-status/contracts/agent-status-ipc-payload.ts +++ b/src/shared/agent-status/contracts/agent-status-ipc-payload.ts @@ -2,7 +2,10 @@ import type { StructuredHostStatus } from '../../agent-hook-listener/listener-event' import type { ProgramStatusFacet } from '../../program-status/facet' -import type { AgentProviderSessionMetadata } from '../../agent-session/lifecycle/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../agent-session/lifecycle/agent-session-resume' import type { WithAgentStatusObservation } from './agent-status-observation' import type { AgentStatusExecutionId, @@ -40,6 +43,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { /** Live host acknowledgement of this renderer’s exact pane retirement. */ authorityRestartId?: string launchToken?: string + /** The host's statement on the Orca launch in this pane: its note, or null for none; absent says nothing. */ + launchConfig?: SleepingAgentLaunchConfig | null terminalHandle?: string tabId?: string worktreeId?: string diff --git a/src/shared/agent-status/contracts/agent-status-types.ts b/src/shared/agent-status/contracts/agent-status-types.ts index 4f987a3d2b3..aa77fa6bf9f 100644 --- a/src/shared/agent-status/contracts/agent-status-types.ts +++ b/src/shared/agent-status/contracts/agent-status-types.ts @@ -125,6 +125,8 @@ export type AgentStatusEntry = { /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata + /** The launch owner's last word: its admitted launch runs (true) or none (false); absent from older hosts. */ + launchAttested?: boolean /** False when the status belongs to a non-terminal owner that restores itself. */ terminalResumeEligible?: false /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ diff --git a/src/shared/orchestration/workers/orchestration-worker-output.ts b/src/shared/orchestration/workers/orchestration-worker-output.ts index 1bef977db7f..7d910b23f9f 100644 --- a/src/shared/orchestration/workers/orchestration-worker-output.ts +++ b/src/shared/orchestration/workers/orchestration-worker-output.ts @@ -1,4 +1,7 @@ -import type { AgentProviderSessionMetadata } from '../../agent-session/lifecycle/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../agent-session/lifecycle/agent-session-resume' import type { AgentType } from '../../agent-status/contracts/agent-status-types' import type { NativeChatMessage } from '../../native-chat/native-chat-types' import type { OrchestrationFleetWorker } from '../fleet/orchestration-fleet-projection' @@ -29,6 +32,8 @@ export type ExactWorkerProviderSession = { wslDistro?: string agent: AgentType providerSession: AgentProviderSessionMetadata + /** The recipe Orca launched this worker's agent with; a reopen replays it. */ + launchConfig?: SleepingAgentLaunchConfig observedAt: number } diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts index 08381e0cc1b..3000c90d601 100644 --- a/src/shared/rpc-contract/agent-session-params.ts +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -132,6 +132,8 @@ export const ExplicitEnsure = z .object({ kind: z.literal('explicit'), resumeOrigin: z.literal('saved').optional(), + /** The pane a saved restore resumes; `placement` names the pane it opens in. */ + restoreSourcePaneKey: z.string().min(1).max(300).optional(), worktree: WorktreeSelector, agent: z.enum(RESUMABLE_TUI_AGENTS), providerSession: ProviderSession, diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index df20d7d795a..aaa54ffd4e6 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -28,6 +28,8 @@ export type RuntimeMobileSessionTerminalTab = { /** Event-only lead-turn end time for paired clients; never persisted in AgentStatusEntry. */ turnCompletedAt?: number launchAgent?: TuiAgent + /** Whether the host's launch owner attests that the Orca launch it admitted still runs here; older hosts omit it. */ + launchAttested?: boolean startupCwd?: string parentLayout?: TerminalLayoutSnapshot color?: string | null diff --git a/src/shared/workspace-session/workspace-session-sleeping-agents.ts b/src/shared/workspace-session/workspace-session-sleeping-agents.ts index 153568c3d73..d2c9d928db6 100644 --- a/src/shared/workspace-session/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session/workspace-session-sleeping-agents.ts @@ -22,7 +22,8 @@ const agentProviderSessionSchema = z.preprocess( // Pi resume requires its persisted authoritative transcript path. transcriptPath: z.string().min(1).optional(), cwd: z.string().min(1).optional(), - effort: z.string().regex(AGENT_EFFORT_LEVEL).optional() + effort: z.string().regex(AGENT_EFFORT_LEVEL).optional(), + permissionMode: z.string().optional() }) ) diff --git a/tests/e2e/terminal-agent-pane-launch.unit.test.ts b/tests/e2e/terminal-agent-pane-launch.unit.test.ts index d1e00b1da0a..4e22938a31a 100644 --- a/tests/e2e/terminal-agent-pane-launch.unit.test.ts +++ b/tests/e2e/terminal-agent-pane-launch.unit.test.ts @@ -316,7 +316,11 @@ describe('a pane that starts its own agent launch', () => { const env = providerSpawn.mock.calls[0]?.[0]?.env ?? {} expect(env.ORCA_AGENT_LAUNCH_TOKEN).toMatch(/.+/) expect(JSON.stringify(runtime.registerPty.mock.calls)).toContain( - JSON.stringify({ launchToken: env.ORCA_AGENT_LAUNCH_TOKEN, launchAgent: 'claude' }) + JSON.stringify({ + launchToken: env.ORCA_AGENT_LAUNCH_TOKEN, + launchAgent: 'claude', + launchConfig: { agentArgs: '', agentEnv: {} } + }) ) }) From 55db56d989ff5e057f864ab74d7fca77a0831e83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:12:36 -0800 Subject: [PATCH 03/29] Make Settings easier to scan and organize General (#40249) * Simplify Settings chrome and organize General into focused views * Preserve search and skill actions across Settings views * Correct Settings validation leftovers * Keep skill conflict details visible in compact Settings rows * Refresh Settings search and protected copy catalogs * Use Settings design tokens and button variants * Keep Settings test setup and translation catalogs current * Keep Settings search metadata independent of display labels * Build repository search groups only for the selected pane * Keep pending skill installs and server menu labels accurate * Use the supported role locator in the skill regression * Keep absent skill installation actions on their supported path * Restore the Settings sidebar to its existing appearance * Restore shared navigation title keys in pane headings * Preserve the Settings shell entry animation * Drop unused Voice labels after the main sync * Preserve main Voice translations and the moved skill module import --- docs/STYLEGUIDE.md | 2 +- src/renderer/src/assets/main.css | 41 + .../AgentDashboardExperimentalSetting.tsx | 2 +- .../settings-persistence-test-fixture.ts | 4 +- .../accounts/accounts-claude-search.ts | 2 +- .../accounts-pane-removal-dialogs.tsx | 4 +- .../settings/accounts/accounts-search.ts | 25 +- .../claude/accounts-pane-claude-section.tsx | 4 +- .../codex/accounts-pane-codex-section.tsx | 4 +- .../accounts-pane-opencode-section.tsx | 6 +- .../scope/accounts-pane-location-section.tsx | 2 +- .../accounts-pane-minimax-credentials.tsx | 8 +- .../accounts-pane-opencode-credentials.tsx | 4 +- .../settings/advanced/AdvancedPane.tsx | 6 +- .../advanced/ExperimentalPane.test.tsx | 25 + .../settings/advanced/ExperimentalPane.tsx | 18 +- .../advanced/HiddenExperimentalGroup.tsx | 17 +- .../settings/advanced/advanced-search.ts | 117 +- .../settings/advanced/experimental-search.ts | 393 +++-- .../AdvancedNetworkSettingsSection.tsx | 10 +- .../settings/agents/agents-search.ts | 7 +- .../agents/behavior/AgentAwakeSetting.tsx | 2 +- .../behavior/AgentCacheTimerSection.tsx | 14 +- .../behavior/agent-cache-timer-search.ts | 2 +- .../agents/launch/AgentDefaultSetting.tsx | 2 +- .../permissions/AgentPermissionsSetting.tsx | 2 +- .../appearance/AppearanceInterfaceSection.tsx | 24 +- .../settings/appearance/AppearancePane.tsx | 6 +- .../AppearanceWindowSidebarSection.tsx | 36 +- .../LeftSidebarAppearanceSetting.tsx | 16 +- .../appearance/RecentTabOrderControl.tsx | 4 +- .../appearance/ReduceVisualEffectsSetting.tsx | 2 +- .../appearance/WindowMaterialSetting.tsx | 2 +- .../appearance/appearance-app-icon-search.ts | 2 +- ...appearance-reduce-visual-effects-search.ts | 2 +- .../settings/appearance/appearance-search.ts | 15 +- .../appearance/appearance-sidebar-search.ts | 18 +- ...ce-status-bar-antigravity-toggle-search.ts | 5 +- ...earance-status-bar-cursor-toggle-search.ts | 5 +- ...ppearance-status-bar-grok-toggle-search.ts | 2 +- ...arance-status-bar-minimax-toggle-search.ts | 2 +- ...ppearance-status-bar-muse-toggle-search.ts | 2 +- .../appearance-status-bar-search.ts | 16 +- ...pearance-status-bar-zcode-toggle-search.ts | 2 +- .../appearance-system-presence-search.ts | 6 +- .../themes/AppearanceThemeControl.tsx | 4 +- .../artifacts/ArtifactsSettingsPane.tsx | 4 +- .../artifacts/artifacts-settings-search.ts | 2 +- .../automations/AutomationsSettingsPane.tsx | 4 +- .../automations-settings-search.ts | 2 +- .../browser/BrowserDefaultZoomSetting.tsx | 4 +- .../browser/BrowserHomePageSetting.tsx | 4 +- .../browser/BrowserSearchEngineSetting.tsx | 4 +- .../settings/browser/browser-search.ts | 12 +- .../BrowserExtensionsExperimentalSetting.tsx | 2 +- .../profiles/BrowserNewProfileDialog.tsx | 2 +- .../browser/profiles/BrowserProfileRow.tsx | 4 +- .../profiles/BrowserSessionCookiesSection.tsx | 13 +- .../routing/BrowserLinkRoutingSetting.tsx | 6 +- .../BrowserLocalhostWorktreeLabelsSetting.tsx | 4 +- .../use/BrowserUseComputerUseNotice.tsx | 4 +- .../use/BrowserUseCookieImportStep.tsx | 6 +- .../browser/use/BrowserUseEnableSwitch.tsx | 4 +- .../settings/browser/use/BrowserUsePane.tsx | 8 +- .../browser/use/browser-use-search.ts | 8 +- .../settings/cli/install/CliSection.tsx | 83 +- .../computer-use/computer-use-search.ts | 2 +- .../DeveloperPermissionsPane.tsx | 36 +- .../LocalNetworkConnectionTest.tsx | 4 +- .../TerminalTccAttributionNotice.tsx | 4 +- .../developer-permissions-search.ts | 12 +- .../CollapseUnchangedRegionsSetting.tsx | 6 +- .../editor/DiffShowWhitespaceSetting.tsx | 14 +- .../editor/EditorFontFamilySetting.tsx | 9 +- .../editor/EditorFormatOnSaveSetting.tsx | 5 +- .../EditorInsertFinalNewlineSetting.tsx | 5 +- .../editor/EditorTypographySettings.tsx | 11 +- .../settings/editor/EditorWordWrapSetting.tsx | 14 +- .../editor/GeneralAutoSaveSetting.tsx | 45 + .../editor/GeneralEditorSettingsSection.tsx | 557 ++++--- .../editor/MarkdownDefaultViewSetting.tsx | 8 +- .../editor/RichMarkdownSpellcheckSetting.tsx | 10 +- .../editor/editor-typography-search.ts | 26 +- .../settings/editor/general-editor-search.ts | 133 +- .../FloatingWorkspacePane.tsx | 18 +- .../floating-workspace-search.ts | 4 +- .../form-controls/SettingsFormControls.tsx | 4 +- .../form-controls/SettingsNavigationRow.tsx | 39 + .../SettingsSearchableSelect.tsx | 8 +- .../general/GeneralConfirmationsSection.tsx | 79 + .../general/GeneralPane.navigation.test.tsx | 176 +++ .../settings/general/GeneralPane.tsx | 441 +++--- .../settings/general/GeneralSearchResults.tsx | 53 + .../general/GeneralSupportSection.tsx | 23 +- .../GeneralWorkspaceSettingsSection.tsx | 322 ++-- ...GlobalWorktreeVisibilitySourcesSetting.tsx | 1 + .../settings/general/OpenInMenuSetting.tsx | 25 +- .../general/WorkspaceDirectorySetting.tsx | 10 +- .../general/general-confirmations-search.ts | 116 ++ .../settings/general/general-search.ts | 158 +- .../general/general-setting-anchors.ts | 70 + .../settings/general/general-sub-pages.ts | 21 + .../general-project-runtime-search.ts | 4 +- .../host-scope/SettingsHostScopePicker.tsx | 11 +- .../components/settings/input/InputPane.tsx | 12 +- .../components/settings/input/input-search.ts | 4 +- .../integrations/integrations-search.ts | 26 +- .../settings/mobile/MobileSettingsPane.tsx | 8 +- .../MobileEmulatorAgentControlRow.tsx | 4 +- .../emulator/MobileEmulatorSettingsPane.tsx | 12 +- .../mobile/emulator/mobile-emulator-search.ts | 16 +- .../settings/mobile/mobile-pane-search.ts | 6 +- .../settings/mobile/mobile-settings-search.ts | 4 +- .../pairing/MobilePairedDevicesSection.tsx | 2 +- .../settings/navigation/SettingsHeader.tsx | 67 + .../use-settings-repo-scroll-effects.ts | 19 +- .../NotificationSettingToggle.tsx | 2 +- .../NotificationSoundSection.tsx | 6 +- .../notifications/NotificationsPane.tsx | 16 +- .../notifications/notifications-search.ts | 30 +- .../orchestration/OrchestrationPane.tsx | 2 +- .../orchestration/orchestration-search.ts | 4 +- .../settings-advanced-section-renderers.tsx | 2 +- .../settings-capability-section-renderers.tsx | 5 +- .../settings-git-task-section-renderers.tsx | 4 +- ...gs-interface-primary-section-renderers.tsx | 6 +- ...-interface-secondary-section-renderers.tsx | 4 +- .../settings/page/settings-page-renderer.tsx | 119 +- ...ings-remote-security-section-renderers.tsx | 10 +- ...tings-setup-workflow-section-renderers.tsx | 17 +- .../settings/page/settings-view-model.ts | 9 + .../use-settings-interaction-controller.ts | 14 +- .../page/use-settings-page-effects.ts | 25 +- .../settings/page/use-settings-store-model.ts | 10 +- .../privacy/PrivacyDiagnosticsSection.tsx | 2 +- .../settings/privacy/PrivacyPane.tsx | 2 +- .../settings/privacy/privacy-search.ts | 6 +- .../quick-commands/QuickCommandsToolbar.tsx | 2 +- .../quick-commands/quick-commands-search.ts | 2 +- .../repository/RepositoryFormatterSection.tsx | 2 +- .../repository/RepositoryPane.search.test.tsx | 70 + .../settings/repository/RepositoryPane.tsx | 55 +- .../RepositoryRemoveProjectButton.tsx | 2 +- .../hooks/RepositoryHookPolicySettings.tsx | 2 +- .../hooks/RepositoryHooksSection.tsx | 14 +- .../hooks/RepositoryIssueCommandSetting.tsx | 8 +- .../repository-git-hooks-search-entries.ts | 10 +- .../hooks/repository-hook-settings-draft.ts | 4 +- .../hosts/RepositoryHostSetupsSection.tsx | 4 +- .../identity/RepositoryIconPicker.tsx | 2 +- .../identity/RepositoryIconTabs.tsx | 2 +- .../repository/mcp/McpConfigSection.tsx | 2 +- .../repository/mcp/mcp-config-search.ts | 2 +- .../repository/repository-search.perf.test.ts | 53 + .../settings/repository/repository-search.ts | 481 +++--- .../RepositoryWindowsRuntimeSection.tsx | 2 +- .../RepositoryForkSyncSection.tsx | 14 +- .../RepositoryGitHubAccountSection.tsx | 10 +- .../RepositorySourceControlAiSection.tsx | 4 +- .../repository-git-author-search-entries.ts | 2 +- .../RepositoryWorktreeDefaultsSection.tsx | 10 +- .../worktrees/SparsePresetSettingsSection.tsx | 6 +- .../worktrees/WorktreeSymlinksSection.tsx | 10 +- .../repository-git-worktree-search-entries.ts | 49 +- .../catalog/runtime-environment-dialogs.tsx | 4 +- .../catalog/runtime-environments-search.ts | 8 +- .../updates/RemoteServerUpdateDialog.tsx | 4 +- .../section-layout/SearchableSetting.tsx | 13 +- .../settings/section-layout/SettingsGroup.tsx | 10 +- .../section-layout/SettingsSection.tsx | 4 +- .../settings-target-highlight.ts | 4 + .../shortcuts/KeybindingsFileActions.tsx | 12 +- .../ShortcutTerminalPolicyControl.tsx | 8 +- .../settings/shortcuts/ShortcutsPane.tsx | 2 +- .../shortcuts/keybinding-title-copy.ts | 111 +- .../settings/shortcuts/shortcuts-search.ts | 4 +- .../skills/linear/LinearAgentSkillGuide.tsx | 2 +- .../sharing/ShareSkillsSettingsPane.tsx | 4 +- .../CompareAgainstUpstreamSetting.tsx | 2 +- .../settings/source-control/GitPane.tsx | 18 +- .../GitProviderApiBudgetPane.tsx | 8 +- .../git-provider-api-budget-search.ts | 4 +- .../settings/source-control/git-search.ts | 20 +- .../keep-local-main-up-to-date-setting.ts | 4 +- .../src/components/settings/ssh/ssh-search.ts | 8 +- .../targets/SshManagedHostRemoveDialog.tsx | 8 +- .../targets/SshTargetDestructiveActions.tsx | 4 +- .../settings/ssh/targets/SshTargetForm.tsx | 6 +- .../tasks/JiraIssueKeyCasingSetting.tsx | 2 +- .../settings/tasks/TaskSourceProviderCard.tsx | 2 +- .../tasks/TaskSourceShowInTasksStep.tsx | 2 +- .../components/settings/tasks/TasksPane.tsx | 2 +- .../components/settings/tasks/tasks-search.ts | 2 +- .../terminal/TerminalAdvancedSection.tsx | 16 +- .../terminal/TerminalInteractionSection.tsx | 33 +- .../TerminalAdvancedTypographyControls.tsx | 26 +- .../appearance/TerminalAppearanceSection.tsx | 12 +- .../appearance/TerminalContrastSetting.tsx | 2 +- .../TerminalCursorAppearanceSection.tsx | 18 +- .../appearance/TerminalFontSizeSetting.tsx | 4 +- .../TerminalPaneAppearanceSection.tsx | 18 +- .../appearance/TerminalRenderingSection.tsx | 10 +- .../appearance/TerminalWindowSection.tsx | 40 +- .../terminal-pane-appearance-search.ts | 21 +- .../appearance/terminal-typography-search.ts | 20 +- .../terminal-window-color-groups.tsx | 50 +- .../appearance/terminal-window-search.ts | 14 +- .../TerminalConfigImportModal.tsx | 4 +- .../TerminalWindowsAdministratorSection.tsx | 2 +- .../platform/TerminalWindowsShellSection.tsx | 8 +- .../terminal-advanced-platform-search.ts | 4 +- .../platform/terminal-windows-search.ts | 8 +- .../ManageSessionsSection.tsx | 2 +- .../manage-sessions-search.ts | 2 +- .../terminal/terminal-clipboard-search.ts | 13 +- .../terminal/themes/TerminalThemeSections.tsx | 18 +- .../terminal/themes/WarpThemeImportModal.tsx | 6 +- .../terminal/themes/terminal-theme-search.ts | 17 +- .../updates/GeneralRemoteServerUpdates.tsx | 19 +- .../updates/GeneralUpdateSettingsSection.tsx | 39 +- .../updates/UpdateDelaySetting.fixture.tsx | 2 +- .../settings/updates/UpdateDelaySetting.tsx | 4 +- .../settings/updates/update-delay-copy.ts | 2 +- .../EphemeralVmsExperimentalSetting.tsx | 6 +- .../runtimes/EphemeralVmRuntimesSection.tsx | 4 +- .../voice/OpenAiTranscriptionKeyDialog.tsx | 10 +- .../voice/OpenAiTranscriptionSettingsRow.tsx | 6 +- .../VoiceDictationLanguageSetting.test.tsx | 4 +- .../voice/VoiceDictationLanguageSetting.tsx | 2 +- .../settings/voice/VoiceMicrophoneSetting.tsx | 2 +- .../voice/VoiceModelUnloadSetting.tsx | 2 +- .../components/settings/voice/VoicePane.tsx | 2 +- .../voice/VoiceSpeechModelSection.tsx | 4 +- .../settings/voice/voice-pane-search.ts | 25 +- .../WorktreeSourceVisibilityControl.tsx | 65 + .../WorktreeVisibilitySourceAddForm.tsx | 35 + .../sources/WorktreeVisibilitySourceList.tsx | 54 +- .../skills/SkillFreshnessStatusPill.tsx | 53 +- .../AgentSkillSetupPanel.plan-status.test.tsx | 51 +- .../skills/setup/AgentSkillSetupPanel.tsx | 38 +- .../skills/setup/SkillSetupCommand.tsx | 41 + .../components/skills/setup/SkillSetupRow.tsx | 34 + .../skills/setup/WslAgentSkillSetupPanel.tsx | 170 +- .../setup/agent-skill-setup-panel-props.ts | 2 +- .../wsl-agent-skill-setup-panel-props.ts | 2 +- .../src/i18n/en-runtime-required.json | 7 + src/renderer/src/i18n/locales/de.json | 1200 +++++++-------- src/renderer/src/i18n/locales/en.json | 1361 +++++++++-------- src/renderer/src/i18n/locales/es.json | 1198 +++++++-------- src/renderer/src/i18n/locales/fr.json | 1198 +++++++-------- src/renderer/src/i18n/locales/ja.json | 1198 +++++++-------- src/renderer/src/i18n/locales/ko.json | 1198 +++++++-------- src/renderer/src/i18n/locales/pt.json | 1202 +++++++-------- src/renderer/src/i18n/locales/ru.json | 1202 +++++++-------- src/renderer/src/i18n/locales/tr.json | 1200 +++++++-------- src/renderer/src/i18n/locales/zh-Hant.json | 1200 +++++++-------- src/renderer/src/i18n/locales/zh.json | 1198 +++++++-------- .../skill-freshness-display-status.ts | 12 +- .../languages/protected-copy-keys.json | 6 +- tests/e2e/sparse-preset-proof.spec.ts | 4 +- 260 files changed, 10269 insertions(+), 9300 deletions(-) create mode 100644 src/renderer/src/components/settings/advanced/ExperimentalPane.test.tsx create mode 100644 src/renderer/src/components/settings/editor/GeneralAutoSaveSetting.tsx create mode 100644 src/renderer/src/components/settings/form-controls/SettingsNavigationRow.tsx create mode 100644 src/renderer/src/components/settings/general/GeneralConfirmationsSection.tsx create mode 100644 src/renderer/src/components/settings/general/GeneralPane.navigation.test.tsx create mode 100644 src/renderer/src/components/settings/general/GeneralSearchResults.tsx create mode 100644 src/renderer/src/components/settings/general/general-confirmations-search.ts create mode 100644 src/renderer/src/components/settings/general/general-setting-anchors.ts create mode 100644 src/renderer/src/components/settings/general/general-sub-pages.ts create mode 100644 src/renderer/src/components/settings/navigation/SettingsHeader.tsx create mode 100644 src/renderer/src/components/settings/repository/RepositoryPane.search.test.tsx create mode 100644 src/renderer/src/components/settings/repository/repository-search.perf.test.ts create mode 100644 src/renderer/src/components/settings/section-layout/settings-target-highlight.ts create mode 100644 src/renderer/src/components/sidebar/workspace-visibility/sources/WorktreeSourceVisibilityControl.tsx create mode 100644 src/renderer/src/components/skills/setup/SkillSetupCommand.tsx create mode 100644 src/renderer/src/components/skills/setup/SkillSetupRow.tsx diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 8befb02e39a..ee8a8c1e177 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -44,7 +44,7 @@ Tokens pair a **surface** with a **foreground** that meets contrast on it; alway - Body letter-spacing is set globally on `body`; don't override per component. - Use Tailwind's scale. Common: 14px body/default buttons, 13px sidebar items and dense rows, 12px sub-text and paths, 11px uppercase meta/captions. - Use `text-2xs` (11px) and `text-3xs` (10px), not `text-[11px]` / `text-[10px]`. -- Category labels: 11px, `font-semibold`, `uppercase`, `tracking-wider` (0.05em). +- Category labels: 11px, `font-semibold`, `uppercase`, `tracking-wider` (0.05em). Settings content uses sentence case without tracking: 14px row labels, `text-settings-detail` (12.5px) help, and `text-settings-heading` (15px) semibold section headings. Settings form rows use about 9px vertical padding (44px minimum), selects are 28px, and the centered content column is at most 720px with at least 32px side gutters. These density rules are scoped to `.settings-view-shell`; shared primitives retain their defaults elsewhere. - Native chat has its own lint-enforced scale and colours: see [native-chat.md](./styleguide/native-chat.md). ## Radius and elevation diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 59c6fd15fb5..181e66ac5aa 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -49,6 +49,10 @@ --text-2xs--line-height: calc(1 / 0.6875); --text-3xs: 0.625rem; --text-3xs--line-height: calc(0.875 / 0.625); + --text-settings-detail: 0.78125rem; + --text-settings-detail--line-height: 1.35; + --text-settings-heading: 0.9375rem; + --text-settings-heading--line-height: 1.4; --text-chat-code: 0.75rem; --text-chat-code--line-height: calc(1 / 0.75); } @@ -2149,6 +2153,43 @@ html.native-shell .app-layout { animation: settings-shell-enter 180ms ease-out; } +/* Shared row primitives also render outside Settings; their density stays local to this page. */ +.settings-view-shell > :not(aside) [data-slot='label'], +.settings-view-shell > :not(aside) [data-source-title] { + font-size: var(--text-sm); + line-height: 1.4; +} + +.settings-view-shell > :not(aside) p.text-xs, +.settings-view-shell > :not(aside) p.text-2xs, +.settings-view-shell > :not(aside) p[class~='text-[10px]'], +.settings-view-shell > :not(aside) p[class~='text-[11px]'], +.settings-view-shell > :not(aside) p[class~='text-[12px]'], +.settings-view-shell > :not(aside) p[class~='text-[13px]'], +.settings-view-shell > :not(aside) [data-source-path] { + font-size: var(--text-settings-detail); + line-height: 1.35; +} + +.settings-view-shell > :not(aside) [data-slot='select-trigger'], +.settings-view-shell > :not(aside) button[role='combobox'] { + min-height: 28px; + height: 28px; +} + +.settings-view-shell > :not(aside) [data-settings-row]:not(:has([data-settings-row])) { + padding-block: 9px; + min-height: 44px; +} + +.settings-view-shell > :not(aside) [data-settings-group-heading] { + font-size: var(--text-settings-heading); +} + +.settings-view-shell > :not(aside) [data-settings-group-description] { + font-size: var(--text-settings-detail); +} + .repo-icon-emoji-picker .EmojiPickerReact { --epr-bg-color: var(--card); --epr-category-label-bg-color: var(--card); diff --git a/src/renderer/src/components/dashboard/AgentDashboardExperimentalSetting.tsx b/src/renderer/src/components/dashboard/AgentDashboardExperimentalSetting.tsx index a2e48e51396..4f5f74314ae 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardExperimentalSetting.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardExperimentalSetting.tsx @@ -27,7 +27,7 @@ export function AgentDashboardExperimentalSetting({ 'auto.components.settings.ExperimentalPane.agentDashboard.description', 'Kanban board for monitoring agents across worktrees, in-window or as a pop-out.' )} - keywords={getExperimentalSearchEntry(t).agentDashboard.keywords} + keywords={getExperimentalSearchEntry().agentDashboard.keywords} className="space-y-3 py-3" id="experimental-agent-dashboard" > diff --git a/src/renderer/src/components/settings/__fixtures__/settings-persistence-test-fixture.ts b/src/renderer/src/components/settings/__fixtures__/settings-persistence-test-fixture.ts index 7e30c7ecc3f..39c0a6d6099 100644 --- a/src/renderer/src/components/settings/__fixtures__/settings-persistence-test-fixture.ts +++ b/src/renderer/src/components/settings/__fixtures__/settings-persistence-test-fixture.ts @@ -91,12 +91,12 @@ export function settingsModel(): SettingsPersistenceModel { hasUnsavedBranchPromptChanges: false, hasUnsavedChatPromptChanges: false, hasUnsavedCommitPromptChanges: false, - highlightedSettingsTargetId: null, + highlightedSettingsTarget: null, setFontSuggestions: vi.fn(), setHasUnsavedBranchPromptChanges: vi.fn(), setHasUnsavedChatPromptChanges: vi.fn(), setHasUnsavedCommitPromptChanges: vi.fn(), - setHighlightedSettingsTargetId: vi.fn(), + setHighlightedSettingsTarget: vi.fn(), setSettingsSearchQuery: vi.fn(), setSourceControlAiPromptDiscardSignal: vi.fn() } satisfies SettingsPersistenceModel diff --git a/src/renderer/src/components/settings/accounts/accounts-claude-search.ts b/src/renderer/src/components/settings/accounts/accounts-claude-search.ts index a5ac0bccc7b..6ea25153719 100644 --- a/src/renderer/src/components/settings/accounts/accounts-claude-search.ts +++ b/src/renderer/src/components/settings/accounts/accounts-claude-search.ts @@ -4,7 +4,7 @@ import { translateSearchKeyword } from '../search/settings-search-keywords' export const getAccountsClaudeSearchEntries = createLocalizedCatalog(() => [ { - title: translate('auto.components.settings.accounts.search.75682e1b62', 'Claude Accounts'), + title: translate('auto.components.settings.accounts.search.be3a748ebb', 'Claude accounts'), description: translate( 'auto.components.settings.accounts.search.dd75a73991', 'Optional account switching for Claude while preserving shared chat context.' diff --git a/src/renderer/src/components/settings/accounts/accounts-pane-removal-dialogs.tsx b/src/renderer/src/components/settings/accounts/accounts-pane-removal-dialogs.tsx index efd18c7f1c1..377ad532bcb 100644 --- a/src/renderer/src/components/settings/accounts/accounts-pane-removal-dialogs.tsx +++ b/src/renderer/src/components/settings/accounts/accounts-pane-removal-dialogs.tsx @@ -65,7 +65,7 @@ export function renderAccountsRemovalDialogs( ) }} > - {t('auto.components.settings.AccountsPane.c2d2751587', 'Remove Account')} + {t('auto.components.settings.AccountsPane.699680a328', 'Remove account')} @@ -105,7 +105,7 @@ export function renderAccountsRemovalDialogs( ) }} > - {t('auto.components.settings.AccountsPane.c2d2751587', 'Remove Account')} + {t('auto.components.settings.AccountsPane.699680a328', 'Remove account')} diff --git a/src/renderer/src/components/settings/accounts/accounts-search.ts b/src/renderer/src/components/settings/accounts/accounts-search.ts index fd78aba0d15..dbf6bdb196f 100644 --- a/src/renderer/src/components/settings/accounts/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts/accounts-search.ts @@ -6,7 +6,7 @@ import { getAccountsClaudeSearchEntries } from './accounts-claude-search' export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [ { - title: translate('auto.components.settings.accounts.search.d09fb5ca92', 'Account Location'), + title: translate('auto.components.settings.accounts.search.5e2101f61f', 'Account location'), description: translate( 'auto.components.settings.accounts.search.b84a5b0c8a', 'Choose whether provider accounts are inspected and added on this device or in WSL.' @@ -25,7 +25,7 @@ export const getAccountsLocationSearchEntries = createLocalizedCatalog(() => [ export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [ { - title: translate('auto.components.settings.accounts.search.17c5d244eb', 'Codex Accounts'), + title: translate('auto.components.settings.accounts.search.54c541fc6e', 'Codex accounts'), description: translate( 'auto.components.settings.accounts.search.b40d5b6570', 'Optional account switching for Codex and live rate limit fetching.' @@ -55,7 +55,7 @@ export const getAccountsCodexSearchEntries = createLocalizedCatalog(() => [ ] }, { - title: translate('auto.components.settings.accounts.search.a4bcfd6f86', 'Active Codex Account'), + title: translate('auto.components.settings.accounts.search.4518496456', 'Active Codex account'), description: translate( 'auto.components.settings.accounts.search.87a4a8584e', 'Choose which optional saved Codex account powers live quota reads.' @@ -109,8 +109,8 @@ export const getAccountsGeminiSearchEntries = createLocalizedCatalog(() => [ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [ { title: translate( - 'auto.components.settings.accounts.search.6ed1401020', - 'OpenCode Go Session Cookie' + 'auto.components.settings.accounts.search.f0e6678f99', + 'OpenCode Go session cookie' ), description: translate( 'auto.components.settings.accounts.search.25591bf95b', @@ -130,8 +130,8 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [ }, { title: translate( - 'auto.components.settings.accounts.search.4ee2029e9c', - 'OpenCode Go Workspace ID' + 'auto.components.settings.accounts.search.8eef999193', + 'OpenCode Go workspace id' ), description: translate( 'auto.components.settings.accounts.search.38d22ff8d6', @@ -153,7 +153,7 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [ export const getAccountsMiniMaxSearchEntries = createLocalizedCatalog(() => [ { - title: translate('auto.components.settings.accounts.search.733f9e2a93', 'MiniMax Usage'), + title: translate('auto.components.settings.accounts.search.9b6ce52c36', 'MiniMax usage'), description: translate( 'auto.components.settings.accounts.search.f8374c3151', 'Configure MiniMax usage tracking. Pick the overseas or China endpoint, then paste a session cookie or save an API key that works on either host.' @@ -198,7 +198,7 @@ export const getAccountsGrokSearchEntries = createLocalizedCatalog(() => [ export const getAccountsAntigravitySearchEntries = createLocalizedCatalog(() => [ { - title: translate('accounts.antigravity.searchTitle', 'Antigravity Accounts'), + title: translate('accounts.antigravity.fd47b5a5dd', 'Antigravity accounts'), description: translate( 'accounts.antigravity.searchDescription', 'Save and select native agy Google accounts on the execution host.' @@ -216,7 +216,7 @@ export const getAccountsAntigravitySearchEntries = createLocalizedCatalog(() => export const getAccountsCursorSearchEntries = createLocalizedCatalog(() => [ { - title: translate('auto.components.settings.accounts.search.cursor.title', 'Cursor Usage'), + title: translate('auto.components.settings.accounts.search.cursor.39bbdebe13', 'Cursor usage'), description: translate( 'auto.components.settings.accounts.search.cursor.description', 'Plan usage read from the Cursor sign-in already on this computer (cursor-agent login).' @@ -248,7 +248,10 @@ export const getAccountsCursorSearchEntries = createLocalizedCatalog(() => [ export const getAccountsZcodePlanSearchEntries = createLocalizedCatalog(() => [ { - title: translate('auto.components.settings.accounts.search.zcodePlan.title', 'GLM Coding Plan'), + title: translate( + 'auto.components.settings.accounts.search.zcodePlan.0db9d0465f', + 'GLM coding plan' + ), description: translate( 'auto.components.settings.accounts.search.zcodePlan.description', 'Track Z.AI or Zhipu (BigModel) GLM Coding Plan usage. Pick the site and save the plan API key.' diff --git a/src/renderer/src/components/settings/accounts/claude/accounts-pane-claude-section.tsx b/src/renderer/src/components/settings/accounts/claude/accounts-pane-claude-section.tsx index 117ef72b818..bc05589e570 100644 --- a/src/renderer/src/components/settings/accounts/claude/accounts-pane-claude-section.tsx +++ b/src/renderer/src/components/settings/accounts/claude/accounts-pane-claude-section.tsx @@ -98,7 +98,7 @@ export function renderClaudeAccountsSection( )} > )} - {t('auto.components.settings.AccountsPane.b0e948a4f9', 'Add Account')} + {t('auto.components.settings.AccountsPane.7e9977b560', 'Add account')} {!isRemoteAccountScope && canOfferClaudeSignInLink(accountRuntime.runtime) ? ( )} - {t('auto.components.settings.AccountsPane.b0e948a4f9', 'Add Account')} + {t('auto.components.settings.AccountsPane.7e9977b560', 'Add account')} {/* Follow the running addition; hide Cancel only when its server prompt already offers it. */} {codexAction === 'adding' && !accountAdditionShownInPrompt('codex', accountOwner) ? ( diff --git a/src/renderer/src/components/settings/accounts/providers/accounts-pane-opencode-section.tsx b/src/renderer/src/components/settings/accounts/providers/accounts-pane-opencode-section.tsx index 3c166ba7143..251606f4ca9 100644 --- a/src/renderer/src/components/settings/accounts/providers/accounts-pane-opencode-section.tsx +++ b/src/renderer/src/components/settings/accounts/providers/accounts-pane-opencode-section.tsx @@ -32,8 +32,8 @@ export function renderOpenCodeAccountsSection(
{cookieNotice ?

{cookieNotice}

: null} diff --git a/src/renderer/src/components/settings/accounts/usage/accounts-pane-opencode-credentials.tsx b/src/renderer/src/components/settings/accounts/usage/accounts-pane-opencode-credentials.tsx index d56984df1b0..c4d7acc10c9 100644 --- a/src/renderer/src/components/settings/accounts/usage/accounts-pane-opencode-credentials.tsx +++ b/src/renderer/src/components/settings/accounts/usage/accounts-pane-opencode-credentials.tsx @@ -58,8 +58,8 @@ export function OpenCodeGoCredentials({ onSaved }: { onSaved: () => void }): Rea return ( diff --git a/src/renderer/src/components/settings/advanced/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/advanced/ExperimentalPane.test.tsx new file mode 100644 index 00000000000..b9c9627d224 --- /dev/null +++ b/src/renderer/src/components/settings/advanced/ExperimentalPane.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../../shared/constants' +import { useAppStore } from '@/store' +import { TooltipProvider } from '../../ui/tooltip' +import { ExperimentalPane } from './ExperimentalPane' + +beforeEach(() => useAppStore.setState(useAppStore.getInitialState(), true)) +afterEach(cleanup) + +it('opens the ordinary Experimental pane and preserves its setting action', () => { + const updateSettings = vi.fn() + render( + + + + ) + + fireEvent.click(screen.getByRole('switch', { name: 'Pet' })) + expect(updateSettings).toHaveBeenCalledWith({ experimentalPet: true }) +}) diff --git a/src/renderer/src/components/settings/advanced/ExperimentalPane.tsx b/src/renderer/src/components/settings/advanced/ExperimentalPane.tsx index d50f0698959..941d1a333ea 100644 --- a/src/renderer/src/components/settings/advanced/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/advanced/ExperimentalPane.tsx @@ -36,18 +36,18 @@ export function ExperimentalPane({ }: ExperimentalPaneProps): React.JSX.Element { const t = useTranslate() const searchQuery = useAppStore((s) => s.settingsSearchQuery) - const showPet = matchesSettingsSearch(searchQuery, [getExperimentalSearchEntry(t).pet]) + const showPet = matchesSettingsSearch(searchQuery, [getExperimentalSearchEntry().pet]) const showAgentDashboard = matchesSettingsSearch(searchQuery, [ - getExperimentalSearchEntry(t).agentDashboard + getExperimentalSearchEntry().agentDashboard ]) const showTerminalAttention = matchesSettingsSearch(searchQuery, [ - getExperimentalSearchEntry(t).terminalAttention + getExperimentalSearchEntry().terminalAttention ]) const showAgentHibernation = matchesSettingsSearch(searchQuery, [ - getExperimentalSearchEntry(t).agentHibernation + getExperimentalSearchEntry().agentHibernation ]) const showNewWorktreeCardStyle = matchesSettingsSearch(searchQuery, [ - getExperimentalSearchEntry(t).newWorktreeCardStyle + getExperimentalSearchEntry().newWorktreeCardStyle ]) const agentHibernationEnabled = settings.experimentalAgentHibernation === true const newWorktreeCardStyleEnabled = settings.experimentalNewWorktreeCardStyle === true @@ -70,7 +70,7 @@ export function ExperimentalPane({ 'auto.components.settings.ExperimentalPane.0e89a574ae', 'Floating animated pet in the bottom-right corner.' )} - keywords={getExperimentalSearchEntry(t).pet.keywords} + keywords={getExperimentalSearchEntry().pet.keywords} className="space-y-3 py-3" id="experimental-pet" > @@ -102,7 +102,7 @@ export function ExperimentalPane({ 'auto.components.settings.ExperimentalPane.88b7613afb', 'Persistent pane highlight for terminal bell and agent-completion events.' )} - keywords={getExperimentalSearchEntry(t).terminalAttention.keywords} + keywords={getExperimentalSearchEntry().terminalAttention.keywords} className="space-y-3 py-3" >
@@ -143,7 +143,7 @@ export function ExperimentalPane({ 'auto.components.settings.ExperimentalPane.agentHibernation.description', 'Stops idle background agent terminals after the configured idle window and resumes supported sessions when you open them again.' )} - keywords={getExperimentalSearchEntry(t).agentHibernation.keywords} + keywords={getExperimentalSearchEntry().agentHibernation.keywords} className="space-y-3 py-3" id="experimental-agent-hibernation" > @@ -216,7 +216,7 @@ export function ExperimentalPane({ 'auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description', 'Preview updated worktree-card layout, metadata placement, card-display menu options, and status presentation.' )} - keywords={getExperimentalSearchEntry(t).newWorktreeCardStyle.keywords} + keywords={getExperimentalSearchEntry().newWorktreeCardStyle.keywords} className="space-y-3 py-3" id="experimental-new-worktree-card-style" > diff --git a/src/renderer/src/components/settings/advanced/HiddenExperimentalGroup.tsx b/src/renderer/src/components/settings/advanced/HiddenExperimentalGroup.tsx index 6de87e81686..447a7d57660 100644 --- a/src/renderer/src/components/settings/advanced/HiddenExperimentalGroup.tsx +++ b/src/renderer/src/components/settings/advanced/HiddenExperimentalGroup.tsx @@ -8,7 +8,7 @@ import { import { useTranslate } from '@/i18n/use-translate' import { getShortcutPlatform } from '@/lib/shortcut-platform' -// Use shared orange styling to identify unfinished/staff-only controls. +// Warning tokens distinguish unfinished controls across themes. export function HiddenExperimentalGroup(): React.JSX.Element { const t = useTranslate() const isMac = getShortcutPlatform() === 'darwin' @@ -20,23 +20,26 @@ export function HiddenExperimentalGroup(): React.JSX.Element { setRenderDiagnosticsArmed(armed) } return ( -
-

+
+

{t( 'auto.components.settings.HiddenExperimentalGroup.f2c81d904a', 'Hidden experimental settings' )}

-
+
-
diff --git a/src/renderer/src/components/settings/advanced/advanced-search.ts b/src/renderer/src/components/settings/advanced/advanced-search.ts index f252ae2f56c..5b8f97f6205 100644 --- a/src/renderer/src/components/settings/advanced/advanced-search.ts +++ b/src/renderer/src/components/settings/advanced/advanced-search.ts @@ -2,61 +2,72 @@ import type { SettingsSearchEntry } from '../search/settings-search' import { getAdvancedNetworkSearchEntries } from './network/advanced-network-search' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' -import type { Translate } from '@/i18n/use-translate' import { translateSearchKeyword } from '../search/settings-search-keywords' +export const getAdvancedSearchEntry = createLocalizedCatalog( + () => + ({ + http1Compatibility: { + title: translate( + 'auto.components.settings.advanced.search.11eea3da72', + 'HTTP/1.1 Compatibility' + ), + description: translate( + 'auto.components.settings.advanced.search.585f56fae0', + 'Use HTTP/1.1 for Electron networking when HTTP/2 fails behind a proxy.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.e04e9db503', + 'advanced' + ), + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.2b4d26d11e', + 'networking' + ), + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.4d44352eea', + 'network' + ), + ...translateSearchKeyword('auto.components.settings.advanced.search.48a1c8f534', 'http'), + ...translateSearchKeyword('auto.components.settings.advanced.search.4b4ae4345a', 'http2'), + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.a0f71bd909', + 'http/2' + ), + ...translateSearchKeyword('auto.components.settings.advanced.search.f8ff125ebe', 'http1'), + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.621233008b', + 'http/1.1' + ), + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.65bf6af262', + 'compatibility' + ), + ...translateSearchKeyword('auto.components.settings.advanced.search.f98a60af11', 'proxy'), + ...translateSearchKeyword('auto.components.settings.advanced.search.4383251647', 'vpn'), + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.79e0947e95', + 'support' + ), + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.6576fce4d2', + 'troubleshooting' + ), + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.e61ed8ab33', + 'updates' + ), + ...translateSearchKeyword( + 'auto.components.settings.advanced.search.a7002e1ac4', + 'updater' + ) + ] + } + }) satisfies Record +) + export const getAdvancedPaneSearchEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [ ...getAdvancedNetworkSearchEntries(), - { - title: translate( - 'auto.components.settings.advanced.search.11eea3da72', - 'HTTP/1.1 Compatibility' - ), - description: translate( - 'auto.components.settings.advanced.search.585f56fae0', - 'Use HTTP/1.1 for Electron networking when HTTP/2 fails behind a proxy.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.advanced.search.e04e9db503', 'advanced'), - ...translateSearchKeyword( - 'auto.components.settings.advanced.search.2b4d26d11e', - 'networking' - ), - ...translateSearchKeyword('auto.components.settings.advanced.search.4d44352eea', 'network'), - ...translateSearchKeyword('auto.components.settings.advanced.search.48a1c8f534', 'http'), - ...translateSearchKeyword('auto.components.settings.advanced.search.4b4ae4345a', 'http2'), - ...translateSearchKeyword('auto.components.settings.advanced.search.a0f71bd909', 'http/2'), - ...translateSearchKeyword('auto.components.settings.advanced.search.f8ff125ebe', 'http1'), - ...translateSearchKeyword('auto.components.settings.advanced.search.621233008b', 'http/1.1'), - ...translateSearchKeyword( - 'auto.components.settings.advanced.search.65bf6af262', - 'compatibility' - ), - ...translateSearchKeyword('auto.components.settings.advanced.search.f98a60af11', 'proxy'), - ...translateSearchKeyword('auto.components.settings.advanced.search.4383251647', 'vpn'), - ...translateSearchKeyword('auto.components.settings.advanced.search.79e0947e95', 'support'), - ...translateSearchKeyword( - 'auto.components.settings.advanced.search.6576fce4d2', - 'troubleshooting' - ), - ...translateSearchKeyword('auto.components.settings.advanced.search.e61ed8ab33', 'updates'), - ...translateSearchKeyword('auto.components.settings.advanced.search.a7002e1ac4', 'updater') - ] - } + getAdvancedSearchEntry().http1Compatibility ]) - -function findEntry(title: string): SettingsSearchEntry { - const entry = getAdvancedPaneSearchEntries().find((e) => e.title === title) - if (!entry) { - throw new Error(`Missing advanced-pane search entry: "${title}"`) - } - return entry -} - -export function getAdvancedSearchEntry(t: Translate = translate) { - return { - http1Compatibility: findEntry( - t('auto.components.settings.advanced.search.11eea3da72', 'HTTP/1.1 Compatibility') - ) - } as const -} diff --git a/src/renderer/src/components/settings/advanced/experimental-search.ts b/src/renderer/src/components/settings/advanced/experimental-search.ts index 97f63766797..d69b5528840 100644 --- a/src/renderer/src/components/settings/advanced/experimental-search.ts +++ b/src/renderer/src/components/settings/advanced/experimental-search.ts @@ -1,227 +1,200 @@ import type { SettingsSearchEntry } from '../search/settings-search' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' -import type { Translate } from '@/i18n/use-translate' import { translateSearchKeyword } from '../search/settings-search-keywords' import { getNewWorktreeCardStyleSearchEntry } from './new-worktree-card-style-search-entry' import { getEphemeralVmsSearchEntry } from '../virtual-machines/recipes/ephemeral-vms-search' import { getBrowserExtensionsExperimentalSearchEntry } from '../browser/extensions/browser-extensions-experimental-search' import { getWslHostsExperimentalSearchEntry } from '../wsl/hosts/wsl-hosts-experimental-search' +export const getExperimentalSearchEntry = createLocalizedCatalog( + () => + ({ + pet: { + title: translate('auto.components.settings.experimental.search.87d99e634b', 'Pet'), + description: translate( + 'auto.components.settings.experimental.search.6b5a56ac35', + 'Floating animated pet in the bottom-right corner.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.0d24759f14', + 'experimental' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.051203d37c', + 'pet' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.b54cea709b', + 'sidekick' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.2a33975d72', + 'mascot' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.9f5609bfb8', + 'overlay' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.65df471ab2', + 'animated' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.791fefc0b0', + 'corner' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.9af7a518db', + 'character' + ) + ] + }, + agentDashboard: { + title: translate( + 'auto.components.settings.experimental.search.agentDashboard.eaf17a7214', + 'Agent dashboard' + ), + description: translate( + 'auto.components.settings.experimental.search.agentDashboard.description', + 'Kanban board for monitoring agents across worktrees, in-window or as a pop-out.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.0d24759f14', + 'experimental' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.agent', + 'agent' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.dashboard', + 'dashboard' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.kanban', + 'kanban' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.popout', + 'pop-out' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.board', + 'board' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.inWindow', + 'in-window' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentDashboard.worktrees', + 'worktrees' + ) + ] + }, + terminalAttention: { + title: translate( + 'auto.components.settings.experimental.search.9e4ddf776d', + 'Terminal attention' + ), + description: translate( + 'auto.components.settings.experimental.search.11877246fc', + 'Persistent pane highlight for terminal bell and agent-completion events.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.0d24759f14', + 'experimental' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.9bb3bd5098', + 'terminal' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.01567f19ca', + 'attention' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.268e99d957', + 'highlight' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.edc49480a1', + 'pane' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.8facf10138', + 'bell' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.7695fd30e9', + 'notification' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.5f067ba0f9', + 'agent' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.f10d307468', + 'completion' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.7b79081695', + 'unread' + ) + ] + }, + agentHibernation: { + title: translate( + 'auto.components.settings.experimental.search.agentHibernation.title', + 'Agent sleep' + ), + description: translate( + 'auto.components.settings.experimental.search.agentHibernation.description', + 'Stops idle background agent terminals after the configured idle window and resumes supported sessions when opened again. Agent sleep preserves launch options for agents started by Orca; manually started agents may resume with current Orca defaults.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.0d24759f14', + 'experimental' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentHibernation.agent', + 'agent' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentHibernation.agents', + 'agents' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentHibernation.sleep', + 'sleep' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentHibernation.minutes', + 'minutes' + ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.agentHibernation.terminal', + 'terminal' + ) + ] + }, + newWorktreeCardStyle: getNewWorktreeCardStyleSearchEntry(), + ephemeralVms: getEphemeralVmsSearchEntry(), + browserExtensions: getBrowserExtensionsExperimentalSearchEntry() + }) satisfies Record +) + export const getExperimentalPaneSearchEntries = createLocalizedCatalog( (): SettingsSearchEntry[] => [ - { - title: translate('auto.components.settings.experimental.search.87d99e634b', 'Pet'), - description: translate( - 'auto.components.settings.experimental.search.6b5a56ac35', - 'Floating animated pet in the bottom-right corner.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.0d24759f14', - 'experimental' - ), - ...translateSearchKeyword('auto.components.settings.experimental.search.051203d37c', 'pet'), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.b54cea709b', - 'sidekick' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.2a33975d72', - 'mascot' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.9f5609bfb8', - 'overlay' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.65df471ab2', - 'animated' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.791fefc0b0', - 'corner' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.9af7a518db', - 'character' - ) - ] - }, - { - title: translate( - 'auto.components.settings.experimental.search.agentDashboard.title', - 'Agent Dashboard' - ), - description: translate( - 'auto.components.settings.experimental.search.agentDashboard.description', - 'Kanban board for monitoring agents across worktrees, in-window or as a pop-out.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.0d24759f14', - 'experimental' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentDashboard.agent', - 'agent' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentDashboard.dashboard', - 'dashboard' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentDashboard.kanban', - 'kanban' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentDashboard.popout', - 'pop-out' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentDashboard.board', - 'board' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentDashboard.inWindow', - 'in-window' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentDashboard.worktrees', - 'worktrees' - ) - ] - }, - { - title: translate( - 'auto.components.settings.experimental.search.9e4ddf776d', - 'Terminal attention' - ), - description: translate( - 'auto.components.settings.experimental.search.11877246fc', - 'Persistent pane highlight for terminal bell and agent-completion events.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.0d24759f14', - 'experimental' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.9bb3bd5098', - 'terminal' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.01567f19ca', - 'attention' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.268e99d957', - 'highlight' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.edc49480a1', - 'pane' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.8facf10138', - 'bell' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.7695fd30e9', - 'notification' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.5f067ba0f9', - 'agent' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.f10d307468', - 'completion' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.7b79081695', - 'unread' - ) - ] - }, - { - title: translate( - 'auto.components.settings.experimental.search.agentHibernation.title', - 'Agent sleep' - ), - description: translate( - 'auto.components.settings.experimental.search.agentHibernation.description', - 'Stops idle background agent terminals after the configured idle window and resumes supported sessions when opened again. Agent sleep preserves launch options for agents started by Orca; manually started agents may resume with current Orca defaults.' - ), - keywords: [ - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.0d24759f14', - 'experimental' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentHibernation.agent', - 'agent' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentHibernation.agents', - 'agents' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentHibernation.sleep', - 'sleep' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentHibernation.minutes', - 'minutes' - ), - ...translateSearchKeyword( - 'auto.components.settings.experimental.search.agentHibernation.terminal', - 'terminal' - ) - ] - }, - getNewWorktreeCardStyleSearchEntry(), - getEphemeralVmsSearchEntry(), - getBrowserExtensionsExperimentalSearchEntry(), + ...Object.values(getExperimentalSearchEntry()), // Index WSL toggles only on Windows desktops that expose them. ...(typeof window !== 'undefined' && window.api?.runtimeEnvironments?.wslHosts ? [getWslHostsExperimentalSearchEntry()] : []) ] ) - -// Resolve by title and fail loudly on renames so changing catalog shape cannot silently retarget numeric indexes. -function findEntry(title: string): SettingsSearchEntry { - const entry = getExperimentalPaneSearchEntries().find((e) => e.title === title) - if (!entry) { - throw new Error(`Missing experimental-pane search entry: "${title}"`) - } - return entry -} - -export function getExperimentalSearchEntry(t: Translate = translate) { - return { - pet: findEntry(t('auto.components.settings.experimental.search.87d99e634b', 'Pet')), - agentDashboard: findEntry( - t('auto.components.settings.experimental.search.agentDashboard.title', 'Agent Dashboard') - ), - terminalAttention: findEntry( - t('auto.components.settings.experimental.search.9e4ddf776d', 'Terminal attention') - ), - agentHibernation: findEntry( - t('auto.components.settings.experimental.search.agentHibernation.title', 'Agent sleep') - ), - newWorktreeCardStyle: findEntry( - t('auto.components.settings.experimental.search.newWorktreeCardStyle.title', 'New card style') - ), - ephemeralVms: findEntry( - t('auto.components.settings.ephemeralVms.search.cloudVmTitle', 'Cloud VM') - ), - browserExtensions: findEntry( - t( - 'auto.components.settings.experimental.search.browserExtensions.title', - 'Browser extensions' - ) - ) - } as const -} diff --git a/src/renderer/src/components/settings/advanced/network/AdvancedNetworkSettingsSection.tsx b/src/renderer/src/components/settings/advanced/network/AdvancedNetworkSettingsSection.tsx index c9ab9f90216..d8943143898 100644 --- a/src/renderer/src/components/settings/advanced/network/AdvancedNetworkSettingsSection.tsx +++ b/src/renderer/src/components/settings/advanced/network/AdvancedNetworkSettingsSection.tsx @@ -245,7 +245,7 @@ export function AdvancedNetworkSettingsSection({

{t( @@ -278,8 +278,8 @@ export function AdvancedNetworkSettingsSection({