mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
* fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * Bind OpenCode startup readiness to the current location in intent startup * Retry interrupted OpenCode startup prompt claims --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy <ahmednagy25t@gmail.com> Co-authored-by: Orca startup hydration review <agents@stably.ai> Co-authored-by: Orca <dev@stably.ai>
366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
import { mirrorOpenCodeConfig } from './opencode-overlay-mirror'
|
|
import {
|
|
writeOpenCodeStartupPromptPlugin,
|
|
OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY
|
|
} from '../shared/opencode-startup-prompt-install'
|
|
import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory'
|
|
import { materializeOmpFreshConfig } from '../shared/omp-fresh-config'
|
|
// Why: relay-side equivalent of Orca's local agent integration installers.
|
|
// OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed
|
|
// extension files installed into the remote agent homes. Host paths from the
|
|
// renderer are meaningless on SSH targets, so the relay performs the remote
|
|
// filesystem work itself.
|
|
//
|
|
// Plugin source strings ship over the JSON-RPC channel at session-ready —
|
|
// they are NOT bundled with the relay binary because the relay is versioned
|
|
// independently from Orca and the plugin source changes frequently as new
|
|
// agent events get added; bundling would make every such change a relay
|
|
// redeploy, and an old relay would silently serve stale plugin code.
|
|
//
|
|
// We deliberately do not reuse OpenCodeHookService / PiTitlebarExtensionService
|
|
// directly: those modules import `electron` and ride on Orca's userData
|
|
// path. The relay's electron-free constraint forces a thin parallel
|
|
// implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote
|
|
// Pi/OMP homes for those agents.
|
|
import { createHash } from 'node:crypto'
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write'
|
|
import { homedir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { safeRemoveOverlay } from '../main/pty/overlay-mirror'
|
|
import type { PiAgentKind } from '../shared/pi-agent-kind'
|
|
import {
|
|
installOpenCodePluginInCanonicalConfig,
|
|
isRelayOpenCodeOverlayPath,
|
|
type OpenCodeAgent
|
|
} from './opencode-canonical-config'
|
|
import { writeRelayOmpStatusExtension } from './omp-status-extension'
|
|
import {
|
|
openCodeTuiPluginDirName,
|
|
writeOpenCodeTuiPlugin
|
|
} from '../shared/opencode-tui-plugin-install'
|
|
type LegacyOverlayAgentKind = Exclude<PiAgentKind, 'prime-agent'>
|
|
const RELAY_HOOKS_DIR = '.orca-relay'
|
|
const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays'
|
|
const OPENCODE2_OVERLAY_SUBDIR = 'opencode2-overlays'
|
|
const PI_OVERLAY_SUBDIR_BY_KIND: Record<LegacyOverlayAgentKind, string> = {
|
|
pi: 'pi-overlays',
|
|
omp: 'omp-overlays'
|
|
}
|
|
const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
|
|
const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js'
|
|
// Orca's own entries (either major, file and TUI copy) are never mirrored from user config.
|
|
const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([
|
|
OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY,
|
|
...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)])
|
|
])
|
|
const PI_EXTENSION_FILE = 'orca-agent-status.ts'
|
|
const PI_AGENT_SUBDIR = 'agent'
|
|
const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension'
|
|
// Why: bare-shell OMP still needs ORCA_OMP_STATUS_EXTENSION without mkdir ~/.omp.
|
|
// Mirror local userData/omp-managed-status-extension under the relay home root.
|
|
const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension'
|
|
function withOrcaManagedPiExtensionMarker(source: string): string {
|
|
return source.includes(ORCA_MANAGED_EXTENSION_MARKER)
|
|
? source
|
|
: `// ${ORCA_MANAGED_EXTENSION_MARKER}\n${source}`
|
|
}
|
|
// Why: source-dir resolution is keyed off the launching agent (Pi or OMP).
|
|
// Both consume `PI_CODING_AGENT_DIR` but default to different `~/.<kind>/agent`
|
|
// paths on the remote disk. The renderer-chosen launch command flows in via
|
|
// the relay PtyEnvAugmenter ctx; never derived from disk presence (a
|
|
// cross-agent fallback shadows the other agent's user extensions when both
|
|
// are installed).
|
|
const PI_AGENT_HOME_DIR_NAME: Record<PiAgentKind, string> = {
|
|
pi: '.pi',
|
|
omp: '.omp',
|
|
'prime-agent': '.prime'
|
|
}
|
|
function safeDirName(input: string): string {
|
|
// Why: paneKey embeds tabId:paneId where tabId may itself contain
|
|
// filesystem-unsafe characters in some Orca builds. Hash to a fixed-width
|
|
// hex name so any input produces a portable directory name.
|
|
return createHash('sha256').update(input).digest('hex').slice(0, 32)
|
|
}
|
|
function isUsableId(id: string): boolean {
|
|
return typeof id === 'string' && id.length > 0 && id.length <= 1024
|
|
}
|
|
export type PluginSources = {
|
|
opencodeStartupPromptSource?: string
|
|
/** Empty string revokes future installs; omission preserves the cached source. */
|
|
opencodePluginSource?: string
|
|
/** Source body of OpenCode 2's status plugin. */
|
|
opencode2PluginSource?: string
|
|
/** Source body of Pi's `orca-agent-status.ts` to drop into <overlay>/extensions/. */
|
|
piExtensionSource?: string
|
|
/** Source body of OMP's `orca-agent-status.ts` to drop into <overlay>/extensions/. */
|
|
ompExtensionSource?: string
|
|
/** Source body of Prime Agent's `orca-agent-status.ts` to install in its real agent dir. */
|
|
primeAgentExtensionSource?: string
|
|
}
|
|
/** Result of installing Pi-compatible status into a real agent home or OMP fallback path. */
|
|
export type MaterializePiResult = {
|
|
/** Real agent dir when extensions were installed there. Absent for OMP status-only fallback. */
|
|
sourceAgentDir?: string
|
|
/** Absolute path to orca-agent-status.ts (real home or relay-managed fallback). */
|
|
statusExtensionPath?: string
|
|
}
|
|
/** Presence of this file is what makes an overlay usable — a rebuild that failed
|
|
* after the wipe leaves the dir itself present but the plugin missing. */
|
|
export function getRelayOpenCodePluginPath(
|
|
overlayDir: string,
|
|
agent: 'opencode' | 'opencode2' = 'opencode'
|
|
): string {
|
|
return join(
|
|
overlayDir,
|
|
'plugins',
|
|
agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE
|
|
)
|
|
}
|
|
export class PluginOverlayManager {
|
|
private opencodeStartupPromptSource: string | null = null
|
|
private opencodePluginSource: string | null = null
|
|
private opencode2PluginSource: string | null = null
|
|
private piExtensionSources: Record<PiAgentKind, string | null> = {
|
|
pi: null,
|
|
omp: null,
|
|
'prime-agent': null
|
|
}
|
|
private homeDir: string
|
|
private opencodeRoot: string
|
|
private opencode2Root: string
|
|
private piRoots: Record<LegacyOverlayAgentKind, string>
|
|
constructor(opts?: { homeDir?: string }) {
|
|
const home = opts?.homeDir ?? homedir()
|
|
this.homeDir = home
|
|
this.opencodeRoot = join(home, RELAY_HOOKS_DIR, OPENCODE_OVERLAY_SUBDIR)
|
|
this.opencode2Root = join(home, RELAY_HOOKS_DIR, OPENCODE2_OVERLAY_SUBDIR)
|
|
this.piRoots = {
|
|
pi: join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR_BY_KIND.pi),
|
|
omp: join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR_BY_KIND.omp)
|
|
}
|
|
}
|
|
/** Replace the cached source bodies. Called from relay.ts when Orca sends
|
|
* `agent_hook.installPlugins`. The first install enables the augmenter
|
|
* output; subsequent installs (e.g. Orca version upgrade in flight) refresh
|
|
* the cached source so future spawns see the new strings.
|
|
* Note: existing running agents keep whatever source they loaded at
|
|
* process start. Future PTYs pick up the refreshed source when the relay
|
|
* writes plugin/extension files before spawn. */
|
|
setSources(sources: PluginSources): void {
|
|
if (typeof sources.opencodeStartupPromptSource === 'string') {
|
|
this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource
|
|
}
|
|
if (typeof sources.opencodePluginSource === 'string') {
|
|
this.opencodePluginSource = sources.opencodePluginSource
|
|
}
|
|
if (typeof sources.opencode2PluginSource === 'string') {
|
|
this.opencode2PluginSource = sources.opencode2PluginSource
|
|
}
|
|
if (typeof sources.piExtensionSource === 'string') {
|
|
this.piExtensionSources.pi = withOrcaManagedPiExtensionMarker(sources.piExtensionSource)
|
|
}
|
|
if (typeof sources.ompExtensionSource === 'string') {
|
|
this.piExtensionSources.omp = withOrcaManagedPiExtensionMarker(sources.ompExtensionSource)
|
|
}
|
|
if (typeof sources.primeAgentExtensionSource === 'string') {
|
|
this.piExtensionSources['prime-agent'] = withOrcaManagedPiExtensionMarker(
|
|
sources.primeAgentExtensionSource
|
|
)
|
|
}
|
|
}
|
|
hasOpenCodeSource(agent: 'opencode' | 'opencode2' = 'opencode'): boolean {
|
|
return Boolean(agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource)
|
|
}
|
|
hasPiSource(kind?: PiAgentKind): boolean {
|
|
if (kind) {
|
|
return this.getPiExtensionSource(kind) !== null
|
|
}
|
|
return Object.values(this.piExtensionSources).some((source) => source !== null)
|
|
}
|
|
private getPiExtensionSource(kind: PiAgentKind): string | null {
|
|
const source = this.piExtensionSources[kind]
|
|
return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null)
|
|
}
|
|
private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void {
|
|
const pluginsDir = join(overlayDir, 'plugins')
|
|
mkdirSync(pluginsDir, { recursive: true })
|
|
const pluginPath = join(pluginsDir, pluginFileName)
|
|
writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source, 'overlay')
|
|
writeOverlayOpenCodePluginAtomically(pluginPath, source)
|
|
}
|
|
|
|
/** Materialize the OpenCode plugin overlay for `id` (typically the
|
|
* renderer-supplied paneKey or, fallback, the relay-internal pty-id) and
|
|
* return the directory path. Returns null when no source is cached or
|
|
* the overlay write fails — caller falls back to no plugin (the agent
|
|
* CLI runs without status reporting), which is the existing fail-open
|
|
* behavior on the local side. */
|
|
materializeOpenCode(
|
|
id: string,
|
|
existingConfigDir?: string,
|
|
agent: 'opencode' | 'opencode2' = 'opencode'
|
|
): string | null {
|
|
const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource
|
|
if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) {
|
|
return null
|
|
}
|
|
const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE
|
|
const root = agent === 'opencode2' ? this.opencode2Root : this.opencodeRoot
|
|
const dir = join(root, safeDirName(id))
|
|
try {
|
|
safeRemoveOverlay(dir, root)
|
|
mkdirSync(dir, { recursive: true })
|
|
if (existingConfigDir) {
|
|
if (!existsSync(existingConfigDir)) {
|
|
return null
|
|
}
|
|
// Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's
|
|
// remote root into the overlay before adding Orca's plugin so status
|
|
// reporting does not hide their auth, models, keybinds, or plugins.
|
|
mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES)
|
|
}
|
|
if (source) {
|
|
this.writeOpenCodePlugin(dir, pluginFileName, source)
|
|
}
|
|
if (this.opencodeStartupPromptSource) {
|
|
writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay')
|
|
}
|
|
return dir
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[plugin-overlay] failed to materialize OpenCode overlay: ${err instanceof Error ? err.message : String(err)}\n`
|
|
)
|
|
return null
|
|
}
|
|
}
|
|
|
|
installOpenCodeStartupPromptPlugin(
|
|
environment: NodeJS.ProcessEnv | Record<string, string>,
|
|
configDir?: string
|
|
): boolean {
|
|
if (!this.opencodeStartupPromptSource) {
|
|
return false
|
|
}
|
|
try {
|
|
writeOpenCodeStartupPromptPlugin(
|
|
configDir ??
|
|
environment.OPENCODE_CONFIG_DIR ??
|
|
resolveOpenCodeConfigDirectory(environment, this.homeDir),
|
|
this.opencodeStartupPromptSource
|
|
)
|
|
return true
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n`
|
|
)
|
|
return false
|
|
}
|
|
}
|
|
|
|
hasOpenCode2Source(): boolean {
|
|
return this.hasOpenCodeSource('opencode2')
|
|
}
|
|
|
|
/** Install into OpenCode's normal global config root without changing its environment. */
|
|
installOpenCodePlugin(
|
|
agent: OpenCodeAgent,
|
|
environment: NodeJS.ProcessEnv | Record<string, string>
|
|
): boolean {
|
|
const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource
|
|
return source
|
|
? installOpenCodePluginInCanonicalConfig(source, agent, environment, this.homeDir)
|
|
: false
|
|
}
|
|
|
|
isRelayOverlayPath(path: string): boolean {
|
|
return isRelayOpenCodeOverlayPath(path, this.homeDir)
|
|
}
|
|
|
|
materializeOpenCode2(id: string, existingConfigDir?: string): string | null {
|
|
return this.materializeOpenCode(id, existingConfigDir, 'opencode2')
|
|
}
|
|
|
|
private getDefaultPiAgentDir(kind: PiAgentKind, configDirName?: string): string {
|
|
const root =
|
|
kind === 'omp' ? configDirName || PI_AGENT_HOME_DIR_NAME.omp : PI_AGENT_HOME_DIR_NAME[kind]
|
|
return join(this.homeDir, root, PI_AGENT_SUBDIR)
|
|
}
|
|
|
|
private canOverwritePiExtension(path: string): boolean {
|
|
try {
|
|
return readFileSync(path, 'utf8').includes(ORCA_MANAGED_EXTENSION_MARKER)
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
|
|
materializeOmpFreshConfig(): string {
|
|
return materializeOmpFreshConfig(
|
|
join(this.homeDir, RELAY_HOOKS_DIR, OMP_MANAGED_STATUS_EXTENSION_DIR)
|
|
)
|
|
}
|
|
|
|
/** Install status into the selected Pi-compatible agent home. */
|
|
materializePi(
|
|
id: string,
|
|
existingAgentDir?: string,
|
|
kind: PiAgentKind = 'pi',
|
|
options?: { materializeDefaultHome?: boolean; configDirName?: string }
|
|
): MaterializePiResult | null {
|
|
const extensionSource = this.getPiExtensionSource(kind)
|
|
if (!extensionSource || !isUsableId(id)) {
|
|
return null
|
|
}
|
|
try {
|
|
const sourceAgentDir =
|
|
existingAgentDir ?? this.getDefaultPiAgentDir(kind, options?.configDirName)
|
|
if (existingAgentDir && !existsSync(existingAgentDir)) {
|
|
return null
|
|
}
|
|
const materializeDefaultHome = options?.materializeDefaultHome !== false
|
|
if (!existingAgentDir && !existsSync(sourceAgentDir) && !materializeDefaultHome) {
|
|
// Why: match local titlebar-extension-service bare-shell OMP policy —
|
|
// status wrapper only, never mkdir ~/.omp for unused agents.
|
|
if (kind === 'omp') {
|
|
const statusExtensionPath = writeRelayOmpStatusExtension(this.homeDir, extensionSource)
|
|
return statusExtensionPath ? { statusExtensionPath } : null
|
|
}
|
|
return null
|
|
}
|
|
const extensionsDir = join(sourceAgentDir, 'extensions')
|
|
mkdirSync(extensionsDir, { recursive: true })
|
|
const extensionPath = join(extensionsDir, PI_EXTENSION_FILE)
|
|
if (!this.canOverwritePiExtension(extensionPath)) {
|
|
return null
|
|
}
|
|
writeFileSync(extensionPath, extensionSource)
|
|
return {
|
|
sourceAgentDir,
|
|
statusExtensionPath: extensionPath
|
|
}
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[plugin-overlay] failed to install ${kind} extension: ${err instanceof Error ? err.message : String(err)}\n`
|
|
)
|
|
return null
|
|
}
|
|
}
|
|
|
|
clearOverlay(id: string): void {
|
|
if (!isUsableId(id)) {
|
|
return
|
|
}
|
|
const safe = safeDirName(id)
|
|
for (const root of [this.opencodeRoot, this.opencode2Root, ...Object.values(this.piRoots)]) {
|
|
try {
|
|
safeRemoveOverlay(join(root, safe), root)
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[plugin-overlay] failed to remove overlay dir ${join(root, safe)}: ${err instanceof Error ? err.message : String(err)}\n`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|