mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
Each native Claude and Codex chat now has its own permission mode (Ask for approval, Accept edits for Claude, Approve for me, Full access), shown and changed from a button right after "+" in the composer. A change takes effect before the next message: the agent restarts quietly and resumes the same conversation, never interrupting a reply or background work. New chats start from Settings → Chat → Permissions, which is seeded once from Agent Permissions. The mode is stored as an ordinary chat option, and the restart reuses the existing "replace the agent before the next message" step.
375 lines
17 KiB
TypeScript
375 lines
17 KiB
TypeScript
// Where the structured agent-session wire becomes a live host on this runtime.
|
|
//
|
|
// Built on the first `agentSession.*` call rather than at startup: the record
|
|
// store and the journal live under the profile's user-data path, which is not
|
|
// final until Electron is ready, and a runtime that never serves a structured
|
|
// session should not pay for a store it will never read. The slot the RPC layer
|
|
// reads is module-level for the same reason the registry is — the runtime
|
|
// service is already far past its size budget.
|
|
//
|
|
// A process whose journal will not open installs none and answers every
|
|
// structured request with the refusal that says why.
|
|
|
|
import type { AgentSessionAccountKind } from '../../shared/agent-session-availability'
|
|
import type { PiRpcSessionDeps } from '../pi/rpc-session'
|
|
import { existsSync } from 'node:fs'
|
|
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
|
import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker'
|
|
import type { StructuredAttentionMobileDelivery } from './structured-agent-session-mobile-attention'
|
|
import {
|
|
structuredAgentSessionTeardownTrigger,
|
|
tearDownRuntime,
|
|
type InstalledRuntime
|
|
} from './structured-agent-session-runtime-teardown'
|
|
import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule'
|
|
import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution'
|
|
import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter'
|
|
import type { readCodexCliInstallation } from '../preflight/codex-cli-installation'
|
|
import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter'
|
|
import {
|
|
StructuredAgentSessionHost,
|
|
type StructuredAgentSessionHostDeps
|
|
} from '../native-chat/agent-session-wire/structured-agent-session-host'
|
|
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
|
|
import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry'
|
|
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
|
import {
|
|
installAgentSessionAttachments,
|
|
stopAgentSessionAttachments
|
|
} from './structured-agent-session-attachment-wiring'
|
|
import {
|
|
openAgentSessionRecordStoreOnce,
|
|
releaseAgentSessionRecordStore,
|
|
type OpenedAgentSessionRecordStore
|
|
} from './agent-session-record-store-slot'
|
|
import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database'
|
|
import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database'
|
|
import {
|
|
createStructuredAgentSessionOwnerProbe,
|
|
createStructuredAgentSessionOwnerProbes
|
|
} from './structured-agent-session-owner-probe'
|
|
import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment'
|
|
import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment'
|
|
import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy'
|
|
import {
|
|
STRUCTURED_AGENT_RUNTIME_REGISTRATIONS,
|
|
type StructuredAgentAdapterContext
|
|
} from './structured-agent-runtime-registrations'
|
|
import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery'
|
|
import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups'
|
|
import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store'
|
|
import {
|
|
neverThrowingStructuredAgentSessionLogger,
|
|
type StructuredAgentSessionLogger
|
|
} from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
|
import {
|
|
modelCatalogHostDeps,
|
|
type RuntimeAgentAccountHomeResolver
|
|
} from './structured-agent-model-catalog-wiring'
|
|
import type { ClaudeCliFlagSupport } from '../claude/claude-cli-flag-support'
|
|
import {
|
|
scheduleNativeChatVisualsSweep,
|
|
type NativeChatVisualsSweepDeps
|
|
} from '../native-chat/native-chat-visuals-sweep'
|
|
|
|
/** Whether this profile holds a structured chat: a record or tab in the journal database. */
|
|
export function hasPersistedStructuredAgentSessionStore(
|
|
stateDirectory: string,
|
|
fileExists: (path: string) => boolean = existsSync
|
|
): boolean {
|
|
const databasePath = journalDatabasePath(stateDirectory)
|
|
if (!fileExists(databasePath)) {
|
|
return false
|
|
}
|
|
try {
|
|
return journalDatabaseHoldsAgentSessions(databasePath)
|
|
} catch {
|
|
// A database that cannot be read cannot say it is empty.
|
|
return true
|
|
}
|
|
}
|
|
|
|
export type StructuredAgentSessionRuntimeDeps = {
|
|
/** Host state root. The record store and the journal database both hang off it. */
|
|
stateDirectory: string
|
|
/** Execution host this runtime *is*. A record pinned elsewhere is not ours to
|
|
* probe and not ours to spawn for. */
|
|
hostId: string
|
|
/** Key id this host's claims are minted under. */
|
|
claimKeyId: string
|
|
resolveWorkspacePath: (workspaceId: string) => Promise<string>
|
|
resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
|
|
resolveClaudeCommand?: () => string
|
|
openPiConnection?: PiRpcSessionDeps['openConnection']
|
|
/** Which version-gated flags a Claude CLI takes; absent never passes one. */
|
|
claudeCliFlags?: ClaudeCliFlagSupport
|
|
/** Gives each chat a visuals folder and the skill that teaches it, and sweeps folders whose chat
|
|
* is gone. Wired by the real hosts only, so a test runtime never loads the bundled skill. */
|
|
nativeChatVisuals?: {
|
|
/** Read this host's preference when a chat starts a provider process. */
|
|
isEnabled: () => boolean
|
|
workspaceVerdicts: NonNullable<NativeChatVisualsSweepDeps['workspaceVerdicts']>
|
|
}
|
|
/** Provider transports are overridden only to drive the runtime against scripted children. */
|
|
openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection']
|
|
/** Scripted transports must also replace the real host's version probe. */
|
|
readCodexInstallation?: typeof readCodexCliInstallation
|
|
openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection']
|
|
/** Scripted app-servers carry fake pids the real start-time read cannot answer for. */
|
|
readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime']
|
|
/** Required, and asserted at install time — saved Arguments must never be silently omitted. */
|
|
resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise<string[]> | string[]
|
|
/** Required, and asserted at install time — an absent policy must not degrade to a guess. */
|
|
resolveClaudeAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
|
|
/** Agent Permissions for an ACP agent; Claude and Codex chats run their own permission mode. */
|
|
resolveAgentFullAccess?: (agent: string) => boolean
|
|
/** The user's per-agent environment overlay from settings, laid over every agent's base env. */
|
|
resolveAgentLaunchEnv?: (agent: string) => Record<string, string>
|
|
/** The settings a per-agent Command override is read from, for the same agents. */
|
|
resolveAgentCommandSettings?: () => StructuredAgentCommandSettings
|
|
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
|
|
/** Which login-shell variables Codex and Claude children inherit; absent inherits all. */
|
|
resolveShellEnvironmentPolicy?: () => NativeChatShellEnvironmentPolicy
|
|
/** Where every failure the runtime and its host carry on past is reported. Required, so no path
|
|
* can drop one: the desktop and headless hosts both pass the trace-file logger. */
|
|
logger: StructuredAgentSessionLogger
|
|
/** Every structured-session status projection, for host-side reactions such as the first-work
|
|
* workspace rename that CLI agents get from their hooks. */
|
|
onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged']
|
|
/** The agent-status store; see `StructuredAgentSessionHostDeps.statusSink`. */
|
|
statusSink?: StructuredAgentSessionHostDeps['statusSink']
|
|
/** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */
|
|
hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch']
|
|
resolveCodexAccountKind?: (home: string) => AgentSessionAccountKind | undefined
|
|
/** Launch prep's sync for a probed home; see `CodexModelCatalogProbeDeps.prepareHome`. */
|
|
prepareCodexCatalogProbeHome?: (homePath: string) => void
|
|
/** See `StructuredAgentSessionHostDeps.onSessionTabHidden`. */
|
|
onSessionTabHidden?: StructuredAgentSessionHostDeps['onSessionTabHidden']
|
|
/** Host-owned phone delivery and reconciliation from the current journal projection. */
|
|
attentionDelivery?: StructuredAttentionMobileDelivery
|
|
/** The account home a structured launch would pin right now, for catalog
|
|
* reads with no session record. Absent disables the catalog surface. */
|
|
resolveAgentAccountHome?: RuntimeAgentAccountHomeResolver
|
|
}
|
|
|
|
let installing: Promise<InstalledRuntime> | null = null
|
|
|
|
/** Thrown when the host is installed without a Claude auth policy resolver. */
|
|
export const CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED =
|
|
'structured agent-session host requires a Claude auth policy resolver'
|
|
|
|
export const STRUCTURED_AGENT_LAUNCH_ARGS_REQUIRED =
|
|
'structured agent-session host requires a launch arguments resolver'
|
|
|
|
/** Thrown when the host is installed without a logger: every failure it carries on past would
|
|
* otherwise reach nobody. */
|
|
export const STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED =
|
|
'structured agent-session host requires a logger'
|
|
|
|
/**
|
|
* Runtimes whose teardown did not finish. `installing` is cleared regardless so
|
|
* nothing new attaches, but dropping the runtime as well would strand every
|
|
* conversation the host kept indexed for a retry — and the journal connection,
|
|
* which closes only once they are settled — so a later stop retries them here.
|
|
*/
|
|
const pendingTeardown = new Set<InstalledRuntime>()
|
|
|
|
export function ensureStructuredAgentSessionHost(
|
|
deps: StructuredAgentSessionRuntimeDeps
|
|
): Promise<StructuredAgentSessionHost> {
|
|
// A failed open must not poison the slot forever — the next call retries.
|
|
installing ??= install(deps).catch((error) => {
|
|
installing = null
|
|
throw error
|
|
})
|
|
return installing.then((installed) => installed.host)
|
|
}
|
|
|
|
/** Resolves once every provider exit observed so far has been published by its
|
|
* adapter and reconciled by the host. Nothing is installed, nothing to wait on.
|
|
*
|
|
* This is the only handle onto that barrier: reconciliation is driven by exit
|
|
* callbacks, so a caller that needs the settled lease — rather than the one the
|
|
* exit is still being reconciled out of — has no other way to know it landed. */
|
|
export async function waitForStructuredAgentSessionRecovery(): Promise<void> {
|
|
const installed = await installing?.catch(() => null)
|
|
await installed?.waitForRecovery()
|
|
}
|
|
|
|
/** Drops the host and reaps every Codex child under it. Runtime teardown and
|
|
* test isolation take the same path, so neither can leave a live app-server.
|
|
*
|
|
* A teardown that fails is RETRIED by the next stop rather than forgotten: the
|
|
* host keeps every conversation it could not settle, and this is the only handle
|
|
* onto that host once the module slot is cleared. */
|
|
export async function stopStructuredAgentSessionRuntime(options?: {
|
|
trigger?: AgentSessionResumeTrigger
|
|
}): Promise<void> {
|
|
const trigger = options?.trigger ?? structuredAgentSessionTeardownTrigger()
|
|
const pending = installing
|
|
installing = null
|
|
setStructuredAgentSessionHost(null)
|
|
stopAgentSessionAttachments()
|
|
const outstanding = [...pendingTeardown]
|
|
pendingTeardown.clear()
|
|
const installed = pending ? await pending.catch(() => null) : null
|
|
if (installed) {
|
|
outstanding.push(installed)
|
|
}
|
|
// A store admission opened with no host built on it has no teardown to close its database.
|
|
const recordStore = await releaseAgentSessionRecordStore()
|
|
if (
|
|
recordStore &&
|
|
!outstanding.some((runtime) => runtime.journalDatabase === recordStore.journalDatabase)
|
|
) {
|
|
recordStore.journalDatabase.close()
|
|
}
|
|
const failures: unknown[] = []
|
|
for (const runtime of outstanding) {
|
|
try {
|
|
await tearDownRuntime(runtime, trigger)
|
|
} catch (error) {
|
|
pendingTeardown.add(runtime)
|
|
failures.push(error)
|
|
}
|
|
}
|
|
await agentModelCatalogStore.flushPersistence()
|
|
if (failures.length === 1) {
|
|
throw failures[0]
|
|
}
|
|
if (failures.length > 1) {
|
|
throw new AggregateError(failures, 'structured agent-session runtime teardown failed')
|
|
}
|
|
}
|
|
|
|
async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<InstalledRuntime> {
|
|
// Why thrown rather than defaulted: the caller is `@ts-nocheck`, so a dropped
|
|
// field arrives here as `undefined`. Refusing to install is loud; guessing a
|
|
// policy is the silent under-strip this assertion exists to prevent.
|
|
if (typeof deps.resolveClaudeAuthPolicy !== 'function') {
|
|
throw new Error(CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED)
|
|
}
|
|
if (typeof deps.resolveLaunchArgs !== 'function') {
|
|
throw new Error(STRUCTURED_AGENT_LAUNCH_ARGS_REQUIRED)
|
|
}
|
|
const declared: Partial<StructuredAgentSessionLogger> | undefined = deps.logger
|
|
if (typeof declared?.warn !== 'function' || typeof declared.error !== 'function') {
|
|
throw new Error(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED)
|
|
}
|
|
const logger = neverThrowingStructuredAgentSessionLogger(deps.logger)
|
|
// The store launch admission may already have opened; a failed install leaves it to that slot.
|
|
const recordStore = await openAgentSessionRecordStoreOnce({
|
|
stateDirectory: deps.stateDirectory,
|
|
hostId: deps.hostId,
|
|
logger
|
|
})
|
|
return await installOnJournal({ ...deps, logger }, recordStore)
|
|
}
|
|
|
|
async function installOnJournal(
|
|
deps: StructuredAgentSessionRuntimeDeps,
|
|
{ journalDatabase, store }: OpenedAgentSessionRecordStore
|
|
): Promise<InstalledRuntime> {
|
|
const envResolvers = createStructuredAgentEnvironmentResolvers(deps)
|
|
let host: StructuredAgentSessionHost | null = null
|
|
const lifecycle = createStructuredAgentSessionLifecycleDelivery({
|
|
handle: (event) => host?.handleAdapterEvent(event),
|
|
logger: deps.logger,
|
|
// An agent that publishes an observed exit only after its own close work drains it here.
|
|
drainObservedExits: async () => {
|
|
await Promise.all(
|
|
registrations.map(({ adapter }) => adapter.drainObservedExits?.() ?? Promise.resolve())
|
|
)
|
|
}
|
|
})
|
|
const context: StructuredAgentAdapterContext = {
|
|
deps,
|
|
store,
|
|
journalDatabase,
|
|
environment: envResolvers,
|
|
deliverLifecycle: lifecycle.deliver,
|
|
followUps: createStructuredAgentSessionDispatchFollowUps({
|
|
host: () => host,
|
|
logger: deps.logger
|
|
}),
|
|
host: () => host
|
|
}
|
|
const registrations = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(
|
|
({ definition, createAdapter }) => ({ definition, adapter: createAdapter(context) })
|
|
)
|
|
const agents = new StructuredAgentRegistry(registrations)
|
|
const adapter = new StructuredAgentSessionAdapterRouter(agents, async () => {
|
|
await Promise.all(registrations.map((registration) => registration.adapter.closeAll()))
|
|
})
|
|
host = new StructuredAgentSessionHost({
|
|
store,
|
|
adapter,
|
|
agents,
|
|
recoveryCapsule: new AgentSessionRecoveryCapsule(deps.stateDirectory),
|
|
journalDatabase,
|
|
claimKeyId: deps.claimKeyId,
|
|
probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId),
|
|
probeOwners: createStructuredAgentSessionOwnerProbes(deps.hostId),
|
|
resolveWorkspacePath: deps.resolveWorkspacePath,
|
|
logger: deps.logger,
|
|
...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}),
|
|
...(deps.statusSink ? { statusSink: deps.statusSink } : {}),
|
|
...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}),
|
|
...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}),
|
|
...(await modelCatalogHostDeps({
|
|
store,
|
|
agents,
|
|
registrations: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS,
|
|
deps,
|
|
environment: envResolvers
|
|
}))
|
|
})
|
|
if (deps.attentionDelivery) {
|
|
const installed = host
|
|
const delivery = deps.attentionDelivery
|
|
// Lives exactly as long as the host: teardown drops the host and its subscribers together.
|
|
installed.subscribeTurnCompletions({
|
|
id: 'host-attention-delivery',
|
|
includePrompts: true,
|
|
emit: (event) => {
|
|
if (event.type === 'end') {
|
|
return
|
|
}
|
|
const sessionId =
|
|
event.type === 'prompt' ? event.prompt.sessionId : event.completion.sessionId
|
|
delivery.deliver(event, installed.readStatusSummary(sessionId))
|
|
},
|
|
onState: delivery.reconcile
|
|
})
|
|
}
|
|
setStructuredAgentSessionHost(host)
|
|
// The host starts with its runtime, local or remote, so this is the runtime-start listing.
|
|
const modelCatalog = host.deps.modelCatalog
|
|
void modelCatalog?.prewarm()
|
|
installAgentSessionAttachments({
|
|
stateDirectory: deps.stateDirectory,
|
|
store,
|
|
journalDatabase,
|
|
logger: deps.logger
|
|
})
|
|
const stopVisualsSweep = deps.nativeChatVisuals
|
|
? scheduleNativeChatVisualsSweep({
|
|
stateDirectory: deps.stateDirectory,
|
|
listHeldSessionIds: () => (store.readOnly ? null : store.listHeldSessionIds()),
|
|
locationOf: (sessionId) => store.getRecord(sessionId)?.location ?? null,
|
|
workspaceVerdicts: deps.nativeChatVisuals.workspaceVerdicts,
|
|
logger: deps.logger
|
|
})
|
|
: undefined
|
|
return {
|
|
host,
|
|
adapter,
|
|
journalDatabase,
|
|
waitForRecovery: lifecycle.drain,
|
|
stopBackgroundWork: () => {
|
|
stopVisualsSweep?.()
|
|
modelCatalog?.stop()
|
|
}
|
|
}
|
|
}
|