import { randomUUID } from 'node:crypto' import { execFile } from 'node:child_process' import { cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { promisify } from 'node:util' import { AgentModelGateway, type ModelCallLedger, type ModelCallLedgerFactory } from '../../agent-daemon/agent-model-gateway' import { ModelBridgeLoopbackProxy, MODEL_BRIDGE_SDK_AUTH_SENTINEL, createOpenCodeModelBridgeProviderConfig } from '../../agent-daemon/model-bridge-helper' import type { TerminalSize, TerminalSnapshot } from '../../shared/terminal-contracts' import type { ResolvedRuntimeSettings } from '../runtime-settings-store' import type { RuntimeSkillPackage } from '../capabilities/capability-service' import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider' import { createManagedModelBridge, createResolvedModelProfileDigest } from '../remote-agent/managed-model-bridge' import type { TerminalSessionManager, NativeTerminalLaunch } from '../terminal/terminal-session-manager' import type { BundledRuntimePaths } from './bundled-runtimes' import { ContinueHostAdapter, loadContinueConfig } from './continue-host-adapter' import { detectRuntimeBinary } from './runtime-discovery' import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment, runtimePrivacyEnvironment } from './process-environment' import { stageRuntimeSkillPackages } from './runtime-skill-packages' import { createRuntimeTemporaryDirectory, recordRuntimeTemporaryChild, removeRuntimeTemporaryDirectory, runtimeTemporaryProcessIsActive } from '../runtime-temporary-directory' export type NativeTerminalClientInput = { projectId: string projectName: string directory: string runtime: 'continue' | 'opencode' settings: ResolvedRuntimeSettings size?: TerminalSize skillPackages?: RuntimeSkillPackage[] /** Session-lived, Main-owned MCP endpoints, such as the existing knowledge gateway. */ mcpServers?: Array<{ name: string; url: string; headers: Record }> } export type NativeTerminalClientOptions = { openModelCallLedger: ModelCallLedgerFactory terminalManager: Pick /** GoodBuddy user-data directory; native history is retained here. */ rootDirectory: string /** Application version/run launch root; retained histories stay in rootDirectory. */ temporaryRoot?: string bundledRuntimePaths: BundledRuntimePaths /** Standard Node resolved by the local tool environment, never Electron. */ nodeExecutable?: string launchEnvironmentProvider?: LaunchEnvironmentProvider fetcher?: typeof fetch } const sharedOpenCodeConfigs = new Map>() /** Copies the bundled OpenCode plugin tree once instead of on every terminal launch. */ function prepareSharedOpenCodeConfig(source: string, target: string): Promise { const existing = sharedOpenCodeConfigs.get(target) if (existing) return existing const operation = (async () => { const marker = '.goodbuddy-ready.json' const expected = await readFile(join(source, marker), 'utf8').catch(() => undefined) if (expected !== undefined && await readFile(join(target, marker), 'utf8').catch(() => undefined) === expected) return target const staging = `${target}.staging-${randomUUID()}` try { await cp(source, staging, { recursive: true }) await rm(target, { recursive: true, force: true }) await rename(staging, target) } finally { await rm(staging, { recursive: true, force: true }) } return target })().catch(error => { sharedOpenCodeConfigs.delete(target); throw error }) sharedOpenCodeConfigs.set(target, operation) return operation } export class NativeTerminalClient { private readonly pending = new Map>() constructor(private readonly options: NativeTerminalClientOptions) {} open(ownerId: number, input: NativeTerminalClientInput): Promise { const profile = input.runtime === 'continue' ? input.settings.continueModelProfile : input.settings.opencodeModelProfile if (!profile) return Promise.reject(new Error('Native client requires the selected text model connection')) const key = JSON.stringify([ownerId, input.projectId, input.runtime, createResolvedModelProfileDigest(profile)]) const existing = this.pending.get(key) if (existing) return existing const operation = this.launch(ownerId, structuredClone(input)).finally(() => this.pending.delete(key)) this.pending.set(key, operation) return operation } private async launch(ownerId: number, input: NativeTerminalClientInput): Promise { const launch = await this.prepare(input) try { const snapshot = await this.options.terminalManager.create(ownerId, { target: { type: 'project', projectId: input.projectId }, ...(input.size ?? { cols: 100, rows: 30 }) }, launch) if (snapshot.state === 'failed') { await launch.dispose() throw new Error(snapshot.error?.message ?? 'Native terminal failed to start') } return snapshot } catch (error) { await launch.dispose() throw error } } private async prepare(input: NativeTerminalClientInput): Promise { if (!(await stat(input.directory)).isDirectory()) throw new Error('Native client project directory is unavailable') const selected = input.runtime === 'continue' ? input.settings.continueModelProfile : input.settings.opencodeModelProfile if (!selected) throw new Error('Native client requires a model connection') const managed = createManagedModelBridge({ profile: selected }) const detection = await detectRuntimeBinary({ binaryPath: input.runtime === 'continue' ? input.settings.continueBinaryPath : input.settings.opencodeBinaryPath, bundledPath: this.options.bundledRuntimePaths[input.runtime], binaryNames: input.runtime === 'continue' ? ['cn'] : ['opencode'], label: input.runtime, validation: 'canonical-file' }) if (!detection.path) throw new Error(detection.detail) const env = applyLaunchEnvironmentPath({ ...buildCredentialFilteredUserEnvironment(), ...runtimePrivacyEnvironment }, this.options.launchEnvironmentProvider) // Do not inherit another native client's provider/config selection. for (const name of Object.keys(env)) { if (/^(OPENCODE_|CONTINUE_)/u.test(name)) delete env[name] } let executable = detection.path const args: string[] = [] if (input.runtime === 'continue') { const node = this.options.nodeExecutable if (!node) throw new Error('Continue interactive terminal requires a standard Node executable from the local tool environment') const { stdout } = await promisify(execFile)(node, ['-p', 'JSON.stringify({node:process.versions.node,electron:process.versions.electron})'], { env, timeout: 5_000, windowsHide: true }) const version = JSON.parse(stdout.trim()) as { node?: string; electron?: string } if (!version.node || version.electron) throw new Error('Continue interactive terminal requires standard Node, not Electron') executable = node args.push(detection.path) } const root = this.options.rootDirectory await mkdir(root, { recursive: true }) const temporary = await createRuntimeTemporaryDirectory( this.options.temporaryRoot ?? join(root, 'goodbuddy-runtime-launch'), `goodbuddy-${input.runtime}-`) let childPid: number | undefined const history = join(root, input.runtime, randomUUID()) let ledger: ModelCallLedger | undefined let ledgerOpening = false let gateway: AgentModelGateway const bindingId = randomUUID() const proxy = new ModelBridgeLoopbackProxy({ exchange: (request, context) => gateway.dispatch({ bindingId, operationId: context.requestId, promptSequence: 0, roundIndex: 0, profileDigest: managed.profile.modelProfileDigest, profile: managed.profile }, request, context.signal) }) let disposal: Promise | undefined const dispose = (): Promise => disposal ??= (async () => { if (childPid && runtimeTemporaryProcessIsActive(childPid)) throw new Error('Native terminal child exit is unconfirmed') try { await proxy.close() } finally { await ledger?.close() // A lost open reply can leave a live connection in the host. if (!ledgerOpening || ledger) await removeRuntimeTemporaryDirectory(temporary) } })() try { ledgerOpening = true ledger = await this.options.openModelCallLedger(join(temporary, 'model-calls.sqlite')) gateway = new AgentModelGateway({ ledger, fetcher: this.options.fetcher }) await mkdir(history, { recursive: true }) const origin = await proxy.listen() const skills = input.skillPackages ?? [] const mcp = input.mcpServers ?? [] if (input.runtime === 'continue') { // Reuse the pinned adapter's protocol, permission-order and Windows fixes. const adapter = new ContinueHostAdapter({ binaryPath: detection.path, configPath: '', workspace: input.directory, cacheRoot: temporary, mode: 'chat' }) const prepared = await adapter.getPreparedHost() args[0] = prepared.entryPath const configured = input.settings.continueConfigPath.trim() ? await loadContinueConfig(input.settings.continueConfigPath.trim()) : {} const configPath = join(temporary, 'continue.json') await writeFile(configPath, JSON.stringify({ name: 'GoodBuddy Native Client', version: '1.0.0', schema: 'v1', models: [{ name: selected.name, model: selected.modelName, provider: selected.protocol === 'anthropic-messages' ? 'anthropic' : 'openai', apiBase: `${origin}/v1`, apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL, roles: ['chat'], capabilities: selected.supportsImageInput ? ['image_input'] : [], ...(selected.protocol !== 'anthropic-messages' ? { useResponsesApi: selected.protocol === 'openai-responses' } : {}) }], ...(configured.rules ? { rules: configured.rules } : {}), ...(configured.prompts ? { prompts: configured.prompts } : {}), mcpServers: mcp.map(server => ({ name: server.name, type: 'streamable-http', url: server.url, requestOptions: { headers: server.headers } })) }), { mode: 0o600 }) await stageRuntimeSkillPackages(history, skills, 'Continue') Object.assign(env, { CONTINUE_GLOBAL_DIR: history, GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1', CONTINUE_CLI_AUTO_UPDATED: '1', CONTINUE_CLI_ENABLE_TELEMETRY: '0', CONTINUE_METRICS_ENABLED: '0', CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1' }) args.push('--config', configPath) args.push('--auto') } else { const bundledConfig = this.options.bundledRuntimePaths.opencodeConfig const configDirectory = bundledConfig ? await prepareSharedOpenCodeConfig(bundledConfig, join(root, 'opencode-config')) : join(temporary, 'config') await mkdir(configDirectory, { recursive: true }) const skillsRoot = await stageRuntimeSkillPackages(join(temporary, 'opencode'), skills, 'OpenCode') const config = createOpenCodeModelBridgeProviderConfig({ protocol: managed.profile.protocol, model: selected.modelName, name: selected.name, loopbackOrigin: origin, supportsImageInput: selected.supportsImageInput }) const permission = 'allow' Object.assign(env, { OPENCODE_CONFIG_DIR: configDirectory, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission, agent: { ...config.agent, build: { permission }, plan: { permission } }, skills: { paths: [skillsRoot] }, mcp: Object.fromEntries(mcp.map(server => [server.name, { type: 'remote', url: server.url, headers: server.headers, enabled: true }])) }), OPENCODE_DISABLE_AUTOUPDATE: '1', OPENCODE_DISABLE_PROJECT_CONFIG: '1', OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1', OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', OPENCODE_DISABLE_MODELS_FETCH: '1', OPENCODE_DISABLE_LSP_DOWNLOAD: '1', OPENCODE_DISABLE_SHARE: '1', XDG_CONFIG_HOME: join(temporary, 'xdg-config'), XDG_CACHE_HOME: join(root, 'cache'), XDG_DATA_HOME: join(history, 'data'), XDG_STATE_HOME: join(history, 'state') }) args.push('--model', config.model) } return { spawnSpec: { executable, args, cwd: input.directory, env, label: input.runtime, onSpawn: async pid => { childPid = pid; await recordRuntimeTemporaryChild(temporary, pid) } }, title: `${input.runtime} ยท ${input.projectName}`, dispose } } catch (error) { await dispose() throw error } } }