Files
goodbuddy/src/main/agent/native-terminal-client.ts
T
mesalogo 56ebd40bd8 feat: unify execution and add workspace file imports
Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content.

Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation.

Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit.

Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
2026-10-04 20:02:59 +08:00

222 lines
12 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { execFile } from 'node:child_process'
import { cp, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { AgentModelCallLedger, AgentModelGateway } 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'
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<string, string> }>
}
export type NativeTerminalClientOptions = {
terminalManager: Pick<TerminalSessionManager, 'create'>
/** GoodBuddy user-data directory; native history is retained here. */
rootDirectory: string
bundledRuntimePaths: BundledRuntimePaths
/** Standard Node resolved by the local tool environment, never Electron. */
nodeExecutable?: string
launchEnvironmentProvider?: LaunchEnvironmentProvider
fetcher?: typeof fetch
}
const sharedOpenCodeConfigs = new Map<string, Promise<string>>()
/** Copies the bundled OpenCode plugin tree once instead of on every terminal launch. */
function prepareSharedOpenCodeConfig(source: string, target: string): Promise<string> {
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<string, Promise<TerminalSnapshot>>()
constructor(private readonly options: NativeTerminalClientOptions) {}
open(ownerId: number, input: NativeTerminalClientInput): Promise<TerminalSnapshot> {
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<TerminalSnapshot> {
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<NativeTerminalLaunch> {
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 mkdtemp(join(root, 'launch-'))
const history = join(root, input.runtime, randomUUID())
let ledger: AgentModelCallLedger | undefined
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<void> | undefined
const dispose = (): Promise<void> => disposal ??= (async () => {
try { await proxy.close() } finally {
ledger?.close()
await rm(temporary, { recursive: true, force: true })
}
})()
try {
ledger = new AgentModelCallLedger(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 }, title: `${input.runtime} · ${input.projectName}`, dispose }
} catch (error) {
await dispose()
throw error
}
}
}