feat: open local runtime clients from the composer

Add composer shortcuts for local Continue and OpenCode terminals and the official DeepSeek Harness Web client, using the current project and model bridge. Manage browser service reuse, stop actions and terminal cleanup. Upgrade DeepSeek Harness to 0.1.7-rc.2 and Cordis to 4.0.4.

Include the coordinated IPC handler extraction and related tests and documentation. Remote native-client coordinator wiring, managed Node consolidation, Ask MCP mappings and full desktop/package acceptance remain unfinished; this commit records the implemented local workflow and supporting SSH groundwork.

Validation: typecheck and lint passed; 267 focused tests passed and 5 skipped. No new real-provider calls were made for this commit. Prior real-model evidence and remaining acceptance work are documented in the feature progress files.

Release note: 本地项目选择 Continue 或 OpenCode 后,可从输入框下方打开原生终端;选择 DS 后,可打开使用当前项目和模型的官方 Web 客户端。客户端使用独立会话,DS 网页关闭后服务继续运行,可在 GoodBuddy 中停止;远程入口尚未完成。
This commit is contained in:
mesalogo
2026-09-27 08:38:00 +08:00
parent 1094433a7d
commit f81f342cde
71 changed files with 11943 additions and 2478 deletions
+213
View File
@@ -0,0 +1,213 @@
import { randomUUID } from 'node:crypto'
import { execFile } from 'node:child_process'
import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
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'
workMode: 'ask' | 'execute'
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
}
// Same native read permissions as the existing remote OpenCode adapter.
const openCodeReadTools = ['read', 'glob', 'grep', 'list', 'lsp', 'webfetch', 'websearch', 'codesearch', 'question', 'external_directory']
const continueReadTools = ['Read', 'List', 'Search', 'Fetch', 'Diff', 'AskQuestion', 'CheckBackgroundJob', 'Skills']
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, input.workMode, 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.workMode === 'execute' ? 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()
const bundlePath = join(dirname(prepared.entryPath), 'index.js')
const bundle = await readFile(bundlePath, 'utf8')
const executionMarker = 'async function hti(e,t={parallelToolCallCount:1}){'
if (bundle.split(executionMarker).length !== 2) throw new Error('Continue native tool execution entry point is incompatible')
await writeFile(bundlePath, bundle.replace(executionMarker, `${executionMarker}if(${JSON.stringify(input.workMode)}==="ask"&&!${JSON.stringify(continueReadTools)}.includes(e.name))throw new Error("GoodBuddy Ask mode is read-only");`))
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)
if (input.workMode === 'execute') args.push('--auto')
else args.push('--readonly', ...continueReadTools.flatMap(name => ['--allow', name]), '--exclude', '*')
} else {
const configDirectory = join(temporary, 'config')
if (this.options.bundledRuntimePaths.opencodeConfig) {
await cp(this.options.bundledRuntimePaths.opencodeConfig, configDirectory, { recursive: true })
}
const skillsRoot = await stageRuntimeSkillPackages(configDirectory, skills, 'OpenCode')
const config = createOpenCodeModelBridgeProviderConfig({
protocol: managed.profile.protocol, model: selected.modelName, name: selected.name,
loopbackOrigin: origin, supportsImageInput: selected.supportsImageInput, workMode: input.workMode
})
const permission = input.workMode === 'execute' ? 'allow' : Object.fromEntries([
['*', 'deny'], ...openCodeReadTools.map(name => [name, 'allow']), ['skill', 'allow']
])
// A tool hook keeps the launch mode fixed even when the TUI changes agents.
const pluginPath = join(temporary, 'work-mode.mjs')
await writeFile(pluginPath, `export default async () => ({'tool.execute.before': async ({tool}) => { if (${JSON.stringify(input.workMode)} === 'ask' && !${JSON.stringify([...openCodeReadTools, 'skill'])}.includes(tool)) throw new Error('GoodBuddy Ask mode is read-only'); }});`, { mode: 0o600 })
Object.assign(env, {
OPENCODE_CONFIG_DIR: configDirectory,
OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission,
agent: { ...config.agent, build: { permission }, plan: { permission } },
plugin: [pathToFileURL(pluginPath).href], 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
}
}
}