diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index abd489e8633..7083bef1115 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -15,6 +15,7 @@ import { isStableCliVersionFrom, isStableCliVersionOnLine } from '../agent-cli-v import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' import type { InitializeResponse } from './generated/acp-protocol.generated' +import type { ManagedRuntime } from '../node-runtime/managed-runtime-release' import { readGrokModelCatalog } from './acp-dialects/grok-model-catalog' import { OPENCODE_MODEL_LISTING_ARGS, @@ -88,6 +89,9 @@ export type AcpLaunchSpec = { supportsVersion?(version: string): boolean /** Where the agent installs its own binary, searched after PATH. */ installDirectories(input: { env: Readonly>; homePath: string }): string[] + /** A pinned copy Orca installs under its data folder when the person asks, searched last; a + * start never installs it. A spec with one names no `supportsVersion`. */ + managedRuntime?: ManagedRuntime /** Images go to the agent when it also advertises them; off sends text prompts only. */ imagePrompts?: true /** The agent compacts its conversation when sent `/compact` as a prompt; off hides `/compact`. */ diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts index a166a143a33..7afa57e763a 100644 --- a/src/main/acp/acp-model-catalog-probe.test.ts +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -69,6 +69,7 @@ function scriptedGrok(initializeMeta: Record): { closed: () => closes, deps: { resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + dataDirectory: '/orca-data', resolveLaunchEnv: () => ({ XAI_API_KEY: 'from-settings' }), resolveCommand: () => '/opt/grok/bin/grok', homePath: '/home/user', @@ -155,6 +156,7 @@ describe('ACP model catalog probes', () => { const connect = vi.fn() const probe = createAcpModelCatalogProbe(OPENCODE, { resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + dataDirectory: '/orca-data', resolveCommand: () => '/opt/opencode/bin/opencode', probeVersion: async () => true, homePath: '/home/user', @@ -180,6 +182,7 @@ describe('ACP model catalog probes', () => { const runListing = vi.fn() const probe = createAcpModelCatalogProbe(OPENCODE, { resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + dataDirectory: '/orca-data', resolveCommand: () => '/opt/opencode/bin/opencode', probeVersion: async () => false, runListing diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 98323a272a1..29f483c5201 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -27,7 +27,7 @@ import type { AcpStructuredConnection } from './acp-structured-connection' import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions' import { AcpStructuredLane, acpLaneChildWorkDelivery } from './acp-structured-lane' import { NO_ACP_CHILD_STOPS, probeAcpChildStops } from './acp-structured-child-stop' -import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { prepareAcpStructuredLaunch } from './acp-structured-launch-preparation' import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' import { AcpStructuredPrompts } from './acp-structured-prompts' import { @@ -77,12 +77,7 @@ export async function acquireAcpStructuredSession(input: { } } closedBeforeSpawn() - const launch: AcpStructuredLaunch = await deps - .resolveLaunch({ identity: acquire.identity, spawnToken: acquire.spawnToken }) - .catch((error: unknown) => { - throw new AgentSessionPreSpawnError(error) - }) - closedBeforeSpawn() + const launch = await prepareAcpStructuredLaunch(deps, acquire, closedBeforeSpawn) let session: AcpStructuredSession | null = null // A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write. const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = { diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index f37dbafac6d..22d6fdc73d2 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -62,6 +62,7 @@ afterEach(async () => { function grokProbe() { return createAcpModelCatalogProbe(GROK, { resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + dataDirectory: '/orca-data', resolveCommand: () => '/opt/grok/bin/grok', homePath: '/home/user', connect: () => { diff --git a/src/main/acp/acp-structured-launch-preparation.ts b/src/main/acp/acp-structured-launch-preparation.ts new file mode 100644 index 00000000000..b118c01b562 --- /dev/null +++ b/src/main/acp/acp-structured-launch-preparation.ts @@ -0,0 +1,32 @@ +// What a start checks on this host before it spawns the agent. A runtime Orca manages is installed +// only when the person asks, so a start that finds no copy refuses rather than downloading one. + +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' + +/** The launch a start spawns; `closedBeforeSpawn` throws once the start was abandoned. */ +export async function prepareAcpStructuredLaunch( + deps: Pick, + acquire: Pick, + closedBeforeSpawn: () => void +): Promise { + const { spec } = deps + const launch = await deps + .resolveLaunch({ identity: acquire.identity, spawnToken: acquire.spawnToken }) + .catch((error: unknown) => { + throw new AgentSessionPreSpawnError(error) + }) + closedBeforeSpawn() + // A missed lookup returns the bare name; a selected Command setting resolves or refuses. + if (spec.managedRuntime && launch.command === spec.command) { + throw new AgentSessionPreSpawnError( + new Error(`${spec.managedRuntime.label} is not installed on this host`), + { reason: 'runtimeNotInstalled' } + ) + } + return launch +} diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 0052950e9e4..4330c67c81e 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -21,6 +21,11 @@ import { type StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import { probeAgentCliVersion } from '../agent-cli-version-probe' +import { + copyBesidePinnedMembers, + resolveInstalledRuntimeRelease +} from '../node-runtime/managed-runtime-release' +import { hasExplicitTuiLaunchCommand } from '../../shared/tui-agent-launch-command-override' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { JournalLoad } from '../native-chat/agent-session-journal/journal-open' import { @@ -73,6 +78,8 @@ export type AcpStructuredLaunchPartDeps = { /** The environment the child process inherits at spawn; this process's own by default. */ inheritedEnv?: NodeJS.ProcessEnv probeVersion?: typeof probeAgentCliVersion + /** Orca's data folder on this host, which holds the spec's managed runtime. */ + dataDirectory: string } & Pick /** What a launch's binary and environment are read from; a catalog probe reads the same. */ @@ -87,11 +94,16 @@ export type AcpLaunchInvocationDeps = { refreshEnvironmentAfterMiss?: (askedAt: number) => Promise /** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */ resolveWorkingDirectory?: () => Promise | string -} & Pick +} & Pick< + AcpStructuredLaunchPartDeps, + 'resolveCommand' | 'homePath' | 'inheritedEnv' | 'dataDirectory' +> /** The binary a launch with `env` spawns: the user's Command setting when set, else the stock - * binary from PATH, then the agent's own install directories. A create's version check resolves - * through here too, so it asks about the same file. */ + * binary from PATH, then the agent's own install directories. A spec with a managed runtime runs + * its installed release before those, and a copy from them only beside every pinned member; the + * bare name when none qualifies. A create's version check resolves through here too, so it asks + * about the same file. A null `dataDirectory` skips the installed release. */ export function resolveAcpLaunchCommand( spec: AcpLaunchSpec, env: Readonly>, @@ -99,21 +111,36 @@ export function resolveAcpLaunchCommand( commandSettings?: StructuredAgentCommandSettings resolveCommand?: typeof resolveCliCommand homePath?: string - } = {} + dataDirectory: string | null + } ): string { const homePath = env.HOME ?? env.USERPROFILE ?? options.homePath ?? homedir() const pathEnv = [env.PATH ?? env.Path, ...spec.installDirectories({ env, homePath })] .filter((entry): entry is string => Boolean(entry)) .join(delimiter) - return resolveStructuredAgentCommand( - spec.agent, - options.commandSettings ?? {}, - { pathEnv, homePath }, - { - command: spec.command, - ...(options.resolveCommand ? { resolve: options.resolveCommand } : {}) - } - ) + const settings = options.commandSettings ?? {} + const resolve = (searchPath: string) => + resolveStructuredAgentCommand( + spec.agent, + settings, + { pathEnv: searchPath, homePath }, + { + command: spec.command, + ...(options.resolveCommand ? { resolve: options.resolveCommand } : {}) + } + ) + const { managedRuntime } = spec + if (!managedRuntime || hasExplicitTuiLaunchCommand(settings, spec.agent)) { + return resolve(pathEnv) + } + const pin = managedRuntime.pin() + const root = options.dataDirectory && managedRuntime.root(options.dataDirectory) + const release = root ? resolveInstalledRuntimeRelease(root, pin).release : null + if (release) { + return resolve(release.folder) + } + const found = resolve(pathEnv) + return (found !== spec.command && copyBesidePinnedMembers(found, pin)) || spec.command } /** Whether `command` is a release the spec runs a structured chat on (true when it names none), @@ -202,7 +229,8 @@ export function acpStructuredLaunchPart( resolveWorkingDirectory: basis.launchDirectory, ...(deps.resolveCommand ? { resolveCommand: deps.resolveCommand } : {}), ...(deps.homePath ? { homePath: deps.homePath } : {}), - ...(deps.inheritedEnv ? { inheritedEnv: deps.inheritedEnv } : {}) + ...(deps.inheritedEnv ? { inheritedEnv: deps.inheritedEnv } : {}), + dataDirectory: deps.dataDirectory } ) const cwd = await basis.launchDirectory() diff --git a/src/main/acp/acp-structured-managed-runtime.test.ts b/src/main/acp/acp-structured-managed-runtime.test.ts new file mode 100644 index 00000000000..1c20b61c380 --- /dev/null +++ b/src/main/acp/acp-structured-managed-runtime.test.ts @@ -0,0 +1,80 @@ +import { mkdir, mkdtemp, realpath, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { RUNTIME_RELEASE_RECEIPT } from '../node-runtime/managed-runtime-release' +import { createAcpStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support' +import { GROK, openAcpAdapterRig } from './acp-structured-adapter.test-support' + +afterEach(() => closeProviderTimelineRigs()) + +it('starts the installed release first, else a copy beside every pinned member', async () => { + const dataDirectory = await mkdtemp(join(tmpdir(), 'orca-acp-managed-')) + const homePath = join(dataDirectory, 'home') + const command = `orca-managed-${basename(dataDirectory)}` + const root = join(dataDirectory, 'agent-runtimes', 'grok') + const pin = { + version: '1.0', + url: 'https://example.test/grok-1.0.zip', + archiveSha256: 'a'.repeat(64), + archiveBytes: 10, + members: [ + { path: command, sha256: 'b'.repeat(64), bytes: 6 }, + { path: 'harness', sha256: 'c'.repeat(64), bytes: 7 } + ] + } + const spec = { + ...GROK, + command, + managedRuntime: { label: 'Grok', root: () => root, pin: () => pin } + } + const spawn = async () => { + const resolveLaunch = createAcpStructuredLaunchResolver(spec, { + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + provider: 'grok', + providerHandleChain: [], + accountHome: { variable: 'GROK_HOME', path: join(dataDirectory, '.grok') } + }), + pinLaunchDirectory: vi.fn() + }, + readJournal: () => null, + resolveEnvironment: async () => ({ PATH: '', HOME: homePath }), + dataDirectory + }) + const rig = await openAcpAdapterRig({ spec, deps: { resolveLaunch } }) + await rig.acquire() + return rig.child().launch.command + } + await expect(spawn()).rejects.toMatchObject({ reason: 'runtimeNotInstalled' }) + + // A server without its companion beside it is no copy. + const localBin = join(homePath, '.local', 'bin') + await mkdir(localBin, { recursive: true }) + await writeFile(join(localBin, command), '', { mode: 0o755 }) + await expect(spawn()).rejects.toMatchObject({ reason: 'runtimeNotInstalled' }) + + const release = join(root, '1.0') + await mkdir(release, { recursive: true }) + await writeFile(join(release, command), 'server', { mode: 0o755 }) + await writeFile(join(release, 'harness'), 'harness', { mode: 0o755 }) + await writeFile( + join(release, RUNTIME_RELEASE_RECEIPT), + JSON.stringify({ + releaseId: pin.archiveSha256, + version: '1.0', + platform: process.platform, + arch: process.arch, + members: pin.members.map(({ path, bytes }) => ({ path, bytes })) + }) + ) + await writeFile(join(localBin, 'harness'), '', { mode: 0o755 }) + expect(await spawn()).toBe(join(release, command)) + + await truncate(join(release, command), 2) + expect(await spawn()).toBe(await realpath(join(localBin, command))) + await rm(dataDirectory, { recursive: true, force: true }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 8a3b493b766..cc563e75258 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -135,6 +135,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'claudeAccountFolderMissing' | 'claudeAccountSetupFailed' | 'agentCommandNotRunnable' + | 'runtimeNotInstalled' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 02d248288e1..ac7c598fecb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -36,7 +36,8 @@ const TYPED_START_REFUSALS = [ 'historyInOtherAccount', 'claudeAccountFolderMissing', 'claudeAccountSetupFailed', - 'agentCommandNotRunnable' + 'agentCommandNotRunnable', + 'runtimeNotInstalled' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts index 42009e6d884..f39c77875d2 100644 --- a/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts +++ b/src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts @@ -410,7 +410,8 @@ describe('adopting an OpenCode CLI conversation', () => { const launch = await resolveAcpLaunchInvocation(spec, unmanaged, { resolveEnvironment: async () => baseEnvironment, resolveLaunchEnv: () => launchEnv, - resolveCommand: () => '/test/opencode' + resolveCommand: () => '/test/opencode', + dataDirectory: root }) expect(adoption.transcriptPath).toBe(configuredDb) diff --git a/src/main/node-runtime/managed-runtime-release.ts b/src/main/node-runtime/managed-runtime-release.ts new file mode 100644 index 00000000000..f16ade04e42 --- /dev/null +++ b/src/main/node-runtime/managed-runtime-release.ts @@ -0,0 +1,149 @@ +// The installed releases of a runtime Orca downloads for an agent: `//` holding the +// pinned members and a receipt written before the folder was renamed into place. Read-only, and +// cheap enough for every launch: a full hash is only ever taken by an install. + +import { lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { z } from 'zod' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' + +export type ManagedRuntimePin = { + version: string + url: string + archiveSha256: string + /** The decoded archive's size. */ + archiveBytes: number + /** Archive-relative path, sha256 and size of every executable the release must hold. */ + members: readonly { path: string; sha256: string; bytes: number }[] +} + +/** A runtime Orca installs only when the person asks; a launch reads what is installed. */ +export type ManagedRuntime = { + /** Shown to the person, e.g. "Antigravity". */ + label: string + /** The folder that holds only this runtime's releases. */ + root(dataDirectory: string): string + /** This host's pinned release; null where none is published. */ + pin(): ManagedRuntimePin | null +} + +export const RUNTIME_RELEASE_RECEIPT = '.install-complete.json' +const RECEIPT_MAX_BYTES = 8 * 1024 +const VERSION_ORDER = new Intl.Collator(undefined, { numeric: true }) + +const runtimeReleaseReceiptSchema = z.object({ + releaseId: z.string().regex(/^[a-f0-9]{64}$/), + version: z.string().min(1), + platform: z.string(), + arch: z.string(), + members: z.array(z.object({ path: z.string().min(1), bytes: z.number().int().positive() })).min(1) +}) + +export type InstalledRuntimeRelease = { + folder: string + version: string + /** The pinned release itself, rather than an older one kept runnable. */ + current: boolean +} + +/** What a release folder read proved: whole, definitively not (absent, mismatched or partial), or + * unknown because the read itself failed. */ +type ReleaseRead = { releaseId: string } | 'broken' | 'unreadable' + +export type InstalledRuntimeReleases = { + /** The release a launch runs: the pinned one when installed whole, else the newest other whole + * one; null when none is. Staging, links and anything incomplete never count. */ + release: InstalledRuntimeRelease | null + /** Release folders that are definitively not whole, e.g. a member deleted or truncated. */ + broken: string[] + /** Some folder could not be read, so whether it is whole is unknown. */ + unreadable: boolean +} + +export function resolveInstalledRuntimeRelease( + root: string, + pin: ManagedRuntimePin | null +): InstalledRuntimeReleases { + let names: string[] + try { + names = readdirSync(root) + } catch (error) { + return { release: null, broken: [], unreadable: !isDefinitiveAbsence(error) } + } + const reads = names + .filter((name) => !name.startsWith('.')) + .map((name) => ({ name, read: readRelease(join(root, name), name) })) + const releases = reads.flatMap(({ name, read }) => + typeof read === 'object' ? [{ name, releaseId: read.releaseId }] : [] + ) + const pinned = releases.find( + (release) => release.name === pin?.version && release.releaseId === pin.archiveSha256 + ) + const chosen = pinned ?? releases.sort((a, b) => VERSION_ORDER.compare(b.name, a.name))[0] + return { + release: chosen + ? { folder: join(root, chosen.name), version: chosen.name, current: chosen === pinned } + : null, + broken: reads.filter(({ read }) => read === 'broken').map(({ name }) => name), + unreadable: reads.some(({ read }) => read === 'unreadable') + } +} + +/** `command`'s real path when every pinned member sits beside it, as the server expects its + * companions; null otherwise, so a lone server file found elsewhere is never started. */ +export function copyBesidePinnedMembers( + command: string, + pin: ManagedRuntimePin | null +): string | null { + try { + const real = realpathSync(command) + const whole = (pin?.members ?? []).every((member) => { + const file = statSync(join(dirname(real), basename(member.path))) + return file.isFile() && (process.platform === 'win32' || (file.mode & 0o111) !== 0) + }) + return whole ? real : null + } catch { + return null + } +} + +function readRelease(folder: string, version: string): ReleaseRead { + try { + const receiptPath = join(folder, RUNTIME_RELEASE_RECEIPT) + const stat = lstatSync(receiptPath) + if (!lstatSync(folder).isDirectory() || !stat.isFile() || stat.size > RECEIPT_MAX_BYTES) { + return 'broken' + } + const receipt = runtimeReleaseReceiptSchema.safeParse( + parseJson(readFileSync(receiptPath, 'utf8')) + ) + const whole = + receipt.success && + receipt.data.version === version && + receipt.data.platform === process.platform && + receipt.data.arch === process.arch && + receipt.data.members.every((member) => { + const segments = member.path.split('/') + if (segments.some((segment) => !segment || segment === '.' || segment === '..')) { + return false + } + const file = lstatSync(join(folder, ...segments)) + return ( + file.isFile() && + file.size === member.bytes && + (process.platform === 'win32' || (file.mode & 0o111) !== 0) + ) + }) + return whole ? { releaseId: receipt.data.releaseId } : 'broken' + } catch (error) { + return isDefinitiveAbsence(error) ? 'broken' : 'unreadable' + } +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return null + } +} diff --git a/src/main/runtime/structured-agent-launch-composition.test-support.ts b/src/main/runtime/structured-agent-launch-composition.test-support.ts index 2c3d55753d5..2dfee770cbc 100644 --- a/src/main/runtime/structured-agent-launch-composition.test-support.ts +++ b/src/main/runtime/structured-agent-launch-composition.test-support.ts @@ -110,12 +110,14 @@ export function createCodexStructuredLaunchResolver( /** Unsealed unless a token is passed: the env an agent part produced, before the seal. */ export function createAcpStructuredLaunchResolver( spec: AcpLaunchSpec, - deps: LaunchTestSources & AcpStructuredLaunchPartDeps + deps: LaunchTestSources & + Omit & + Partial> ) { return withTestSpawnToken( acpStructuredAgentDefinition(spec), launchSources(deps), - acpStructuredLaunchPart(spec, deps), + acpStructuredLaunchPart(spec, { dataDirectory: '/orca-data', ...deps }), null ) } diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.ts b/src/main/runtime/structured-agent-model-catalog-discovery.ts index 2ebc814cfe3..632339b57f8 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.ts @@ -70,6 +70,7 @@ export function acpModelCatalogDiscovery( probe: createAcpModelCatalogProbe(spec, { resolveEnvironment: environment.resolveBaseEnvironment, refreshEnvironmentAfterMiss: environment.refreshAfterMissingProgram, + dataDirectory: deps.stateDirectory, ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), ...(deps.resolveAgentCommandSettings ? { resolveCommandSettings: deps.resolveAgentCommandSettings } diff --git a/src/main/runtime/structured-agent-runtime-metadata.ts b/src/main/runtime/structured-agent-runtime-metadata.ts index c3ef273c8f9..e622c3d571d 100644 --- a/src/main/runtime/structured-agent-runtime-metadata.ts +++ b/src/main/runtime/structured-agent-runtime-metadata.ts @@ -42,7 +42,11 @@ function acpMetadata(spec: AcpLaunchSpec): StructuredAgentRuntimeMetadata { launchCheck: { resolveLaunch: (shellEnv, commandSettings) => { const env = { ...shellEnv, ...spec.env } - return { command: resolveAcpLaunchCommand(spec, env, { commandSettings }), env } + const command = resolveAcpLaunchCommand(spec, env, { + commandSettings, + dataDirectory: null + }) + return { command, env } }, supportsVersion: (launch) => acpLaunchVersionSupported(spec, launch) } diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index c7c6cb2da69..3532428938c 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -117,6 +117,7 @@ function createAcpAdapter( const part = acpStructuredLaunchPart(spec, { readJournal, ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}), + dataDirectory: deps.stateDirectory, logger: deps.logger }) return new AcpStructuredSessionAdapter({ diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 0cb3ffb6db1..155a683fb04 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -135,6 +135,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.runtimeNotInstalled', + COPY.runtimeNotInstalled, + values + ), chooseClaudeAccount: () => translate( 'components.native-chat.failureWords.chooseClaudeAccount', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index f991f446662..8a9ed342cdd 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2849,6 +2849,7 @@ "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress. Try again after it finishes.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", + "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", "agentCommandNotSignedIn": "Sign in to {{agent}} with `{{loginCommand}}` on the computer running this chat.", "agentNotSignedIn": "Sign in to {{agent}}.", "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 44483b889ca..ba25a37a7d7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18194,6 +18194,7 @@ "claudeAccountFolderMissing": "The selected Claude account's folder is missing. Sign in to it again or choose another account.", "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", + "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0b3523186cb..ba4de069f0d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17974,6 +17974,7 @@ "claudeAccountFolderMissing": "Falta la carpeta de la cuenta de Claude seleccionada. Vuelva a iniciar sesión en ella o elija otra cuenta.", "claudeAccountSetupFailed": "No se pudo configurar la cuenta de Claude seleccionada. Vuelva a intentarlo o elija otra cuenta.", "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", + "runtimeNotInstalled": "{{agent}} no está instalado en el equipo que ejecuta este chat. Instálelo desde el chat en una versión actual de Orca.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f663d5a95fc..625e9e880e2 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18078,6 +18078,7 @@ "claudeAccountFolderMissing": "Le dossier du compte Claude sélectionné est introuvable. Reconnectez-vous à ce compte ou choisissez-en un autre.", "claudeAccountSetupFailed": "Le compte Claude sélectionné n'a pas pu être configuré. Réessayez ou choisissez un autre compte.", "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", + "runtimeNotInstalled": "{{agent}} n'est pas installé sur l'ordinateur qui exécute ce chat. Installez-le depuis le chat dans une version récente d'Orca.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b14f79ffc6d..9c5e18a06fc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18009,6 +18009,7 @@ "claudeAccountFolderMissing": "選択した Claude アカウントのフォルダーが見つかりません。もう一度サインインするか、別のアカウントを選択してください。", "claudeAccountSetupFailed": "選択した Claude アカウントを設定できませんでした。もう一度試すか、別のアカウントを選択してください。", "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", + "runtimeNotInstalled": "{{agent}} はこのチャットを実行しているコンピューターにインストールされていません。最新バージョンの Orca でチャットからインストールしてください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a36344a4dd5..1575022d30b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18009,6 +18009,7 @@ "claudeAccountFolderMissing": "선택한 Claude 계정의 폴더가 없습니다. 다시 로그인하거나 다른 계정을 선택하세요.", "claudeAccountSetupFailed": "선택한 Claude 계정을 설정할 수 없습니다. 다시 시도하거나 다른 계정을 선택하세요.", "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", + "runtimeNotInstalled": "{{agent}}이(가) 이 채팅을 실행하는 컴퓨터에 설치되어 있지 않습니다. 최신 버전의 Orca에서 채팅을 통해 설치하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2e928e8e7e9..d84bb132534 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17974,6 +17974,7 @@ "claudeAccountFolderMissing": "所选 Claude 账户的文件夹不存在。请重新登录该账户或选择其他账户。", "claudeAccountSetupFailed": "无法设置所选 Claude 账户。请重试或选择其他账户。", "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", + "runtimeNotInstalled": "运行此聊天的计算机上未安装 {{agent}}。请在最新版本的 Orca 中从聊天里安装。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index c6f430a00c9..007bbb0b056 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -71,6 +71,8 @@ export const AGENT_SESSION_FAILURE_COPY = { claudeAccountSetupFailed: CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, agentCommandNotRunnable: "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", + runtimeNotInstalled: + "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', chooseClaudeAccountThenRunCommand: 'Choose or add one in Claude Accounts settings, then run /{{command}} again.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 9a2572a9cd2..73e2762d1cf 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -208,6 +208,8 @@ const FAILURE_SENTENCES = { claudeAccountSetupFailed: (_context, _fact, _surface, say) => say('claudeAccountSetupFailed'), agentCommandNotRunnable: (context, _fact, _surface, say) => say('agentCommandNotRunnable', agent(say, context)), + runtimeNotInstalled: (context, _fact, _surface, say) => + say('runtimeNotInstalled', agent(say, context)), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 5ae24ee4588..c31e47894b9 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -37,6 +37,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'claudeAccountFolderMissing', 'claudeAccountSetupFailed', 'agentCommandNotRunnable', + 'runtimeNotInstalled', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 8aea50584bf..6214543aa73 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -84,6 +84,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'claudeAccountSetupFailed', /** Settings → Agents → Command names no program this host can run. */ 'agentCommandNotRunnable', + /** The runtime Orca installs for the agent is not installed on this host. */ + 'runtimeNotInstalled', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 4c9da698eb5..781f36bd82f 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -118,6 +118,7 @@ const REASON_WORDS = { claudeAccountFolderMissing: { fact: 'claudeAccountFolderMissing', action: 'actFirst' }, claudeAccountSetupFailed: { fact: 'claudeAccountSetupFailed', action: 'retry' }, agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, + runtimeNotInstalled: { fact: 'runtimeNotInstalled', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 800c0e8b439..0007e00f30a 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -96,6 +96,7 @@ const KIND_CATEGORY = { claudeAccountFolderMissing: 'startFailed', claudeAccountSetupFailed: 'startFailed', agentCommandNotRunnable: 'startFailed', + runtimeNotInstalled: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -127,6 +128,7 @@ const KIND_VERDICT = { claudeAccountFolderMissing: 'failure', claudeAccountSetupFailed: 'failure', agentCommandNotRunnable: 'failure', + runtimeNotInstalled: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure',