Files
goodbuddy/src/main/agent/continue-host-adapter.ts
T
mesalogo 66f77b83c2 feat: let agents read supervisor story graphs
Expose saved decisions, timelines and source evidence through read-only Story Graph tools across Model, Harness and local/remote OpenCode and Continue. Supervisor enablement and runtime assignments govern discovery and calls; reads do not invoke models or modify stored evidence. Remote use requires matching Desktop and Agent changes.

Make Continue discussion show the complete editable message with a separated destination and shared action layout. Sending uses the visible draft without reattaching removed context.

Validation: existing focused MCP, runtime, controlled Linux Host, discussion component and Electron layout checks plus typechecks; staged diff check passed. Cross-scope fact reuse, historical reconstruction and full package acceptance remain deferred.

Release note: Agents can consult saved supervisor decisions and their sources when continuing work. Continue discussion now lets you review and edit the complete message before sending.
2026-09-28 12:38:00 +08:00

1940 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import spawn from 'cross-spawn'
import { parseContinueChecklist, runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist'
import { toolOperationSummary } from './tool-operation-summary'
import { createHash, randomBytes } from 'node:crypto'
import {
copyFile,
mkdir,
readFile,
realpath,
rename,
rm,
stat,
writeFile
} from 'node:fs/promises'
import {
basename,
dirname,
extname,
isAbsolute,
join,
resolve
} from 'node:path'
import json5 from 'json5'
import { parse as parseYaml } from 'yaml'
import { z } from 'zod'
import type {
AgentQuestionAnswer,
RuntimeNativeSnapshot,
RuntimeSettings
} from '../../shared/contracts'
import {
continueConfigurationPresetSchema,
runtimeNativeInventoryLimits,
type ContinueConfigurationPreset
} from '../../shared/runtime-customization-contracts'
import type { AgentImage, RuntimeAuthorizer } from './runtime'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import type { RuntimeSkillPackage } from '../capabilities/capability-service'
import { getAvailableLoopbackPort } from './loopback-port'
import {
applyLaunchEnvironmentPath,
buildExplicitProfileRuntimeEnvironment,
buildRuntimeEnvironment,
runtimePrivacyEnvironment
} from './process-environment'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
import { createAnthropicApiBaseUrl } from './anthropic-endpoint'
import { createOpenAIApiBaseUrl } from './openai-endpoint'
import {
boundedToolDetail,
safeToolErrorDetail
} from './approval-summary'
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import { readBoundedResponseText } from './bounded-response'
import { scopedReadToolNames } from '../../shared/scoped-data-tools'
import { readBoundedFile } from '../workspace-file-access'
import { terminateProcessTreeAndWait } from './child-process-termination'
const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([
'500cf1ae9637ba397fcb5ae0856fdd31b9ad49ba45a32e277477452be196e5d6'
])
const maximumBundleBytes = 32 * 1024 * 1024
const maximumStateBytes = 8 * 1024 * 1024
const maximumMessageBytes = 20 * 1024 * 1024
const maximumConfigBytes = 1024 * 1024
const maximumConfiguredMcpServers =
runtimeNativeInventoryLimits.mcpServers
const maximumConfiguredRules = runtimeNativeInventoryLimits.rules
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
const maximumStreamEventBytes = 2 * 1024 * 1024
const defaultControlRequestTimeoutMs = 30_000
const knowledgeMcpName = 'goodbuddy-knowledge'
const customMcpName = 'goodbuddy-custom-mcp'
export const continueConfigurationRequiredMessage =
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
const utilityBootstrap = [
"import { pathToFileURL } from 'node:url'",
'const entryPath = process.argv[2]',
"if (!entryPath) throw new Error('Missing Continue host entry')",
'process.argv = process.argv.slice(2)',
'await import(pathToFileURL(entryPath).href)',
''
].join('\n')
const tokenCountSchema = z
.number()
.int()
.min(0)
.max(Number.MAX_SAFE_INTEGER)
const sessionUsageSchema = z.object({
promptTokens: tokenCountSchema,
completionTokens: tokenCountSchema,
promptTokensDetails: z
.object({
cachedTokens: tokenCountSchema.optional(),
cacheWriteTokens: tokenCountSchema.optional()
})
.optional()
})
const continueHostStreamEventSchema = z.discriminatedUnion('type', [
z
.object({
type: z.literal('text'),
delta: z.string().min(1).max(100_000)
})
.strict(),
z
.object({
type: z.literal('tool'),
callId: z.string().min(1).max(256),
name: z.string().min(1).max(200),
state: z.enum(['running', 'completed', 'failed']),
input: z.string().max(4_000).optional(),
runtimeChecklist: runtimeChecklistSchema.optional(),
summary: z.string().max(240).optional(),
output: z.string().max(16_000).optional(),
error: z.string().max(1_000).optional()
})
.strict()
])
const continueHostQuestionSchema = z
.object({
requestId: z.string().min(1),
timestamp: z.number().finite().optional(),
question: z.string().trim().min(1),
options: z
.array(z.string().trim().min(1))
.optional(),
defaultAnswer: z.string().trim().optional()
})
.strict()
const stateSchema = z.object({
session: z.object({
history: z.array(z.unknown()),
usage: sessionUsageSchema.optional()
}),
isProcessing: z.boolean(),
messageQueueLength: z.number().int().min(0),
pendingPermission: z
.object({
toolName: z.string().min(1).max(128),
toolArgs: z.record(z.string(), z.unknown()),
requestId: z.string().min(1).max(256),
toolCallPreview: z.array(z.unknown()).optional()
})
.nullable(),
goodbuddyEvents: z
.array(continueHostStreamEventSchema)
.optional(),
goodbuddyEventsOverflow: z.boolean().optional(),
goodbuddyQuestion: continueHostQuestionSchema.nullable().optional()
})
type ContinueHostState = z.infer<typeof stateSchema>
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
)
}
type PreparedHost = {
entryPath: string
version: string
}
export type ContinueHostUsage = {
provider: string
model: string
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}
export type ContinueHostTool = {
runtimeChecklist?: RuntimeChecklist
callId: string
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
input?: string
summary?: string
output?: string
error?: string
}
export type ContinueHostRunResult = {
text: string
streamedText?: true
streamTruncated?: true
usage?: ContinueHostUsage
tools?: ContinueHostTool[]
}
export type ContinueHostStreamEvent =
| { type: 'checklist'; checklist: RuntimeChecklist }
| { type: 'text'; delta: string }
| { type: 'tool'; tool: ContinueHostTool }
| {
type: 'question'
questionId: string
questions: Array<{
header: string
question: string
options: Array<{
label: string
description: string
}>
multiple: boolean
custom: boolean
}>
}
export class ContinueHostRunError extends Error {
constructor(
message: string,
options: { cause: unknown; tools: ContinueHostTool[] }
) {
super(message, { cause: options.cause })
this.name = 'ContinueHostRunError'
this.tools = options.tools
}
readonly tools: ContinueHostTool[]
}
export type ContinueHostAdapterOptions = {
binaryPath: string
configPath: string
workspace: string
cacheRoot: string
mode?: RuntimeSettings['continueMode']
trustedBundleHashes?: string[]
launchHost?: ContinueHostLauncher
modelProfile?: ResolvedModelProfile
skillPackages?: RuntimeSkillPackage[]
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
export type ContinueHostAdapterDependencies = {
terminateProcessTree: typeof terminateProcessTreeAndWait
maximumStreamEventBytes: number
controlRequestTimeoutMs: number
}
export type ContinueHostRunOptions = {
workMode?: 'ask' | 'execute'
images?: AgentImage[]
/** Explicit session capabilities, not MCP servers from a local model config. */
sessionMcpServers?: Array<{
name: string
type: 'streamable-http'
url: string
requestOptions: { headers: Record<string, string> }
}>
knowledgeCapability?: {
endpoint: string
token: string
}
customMcpCapability?: {
endpoint: string
token: string
}
preset?: ContinueConfigurationPreset
onEvent?: (event: ContinueHostStreamEvent) => void | Promise<void>
}
type KnowledgeCapability = NonNullable<
ContinueHostRunOptions['knowledgeCapability']
>
function createLoopbackMcpServer(
name: string,
capability: KnowledgeCapability
): Record<string, unknown> {
return {
name,
type: 'streamable-http',
url: capability.endpoint,
requestOptions: {
headers: {
Authorization: `Bearer ${capability.token}`
}
}
}
}
export async function loadContinueConfig(
configPath: string
): Promise<Record<string, unknown>> {
const tooLargeMessage =
'Continue 配置文件超过 1 MB 安全大小限制'
const invalidFileMessage = 'Continue 配置路径不是文件'
const data = await readBoundedFile(
configPath,
maximumConfigBytes,
tooLargeMessage,
invalidFileMessage
).catch((error: unknown) => {
if (
error instanceof Error &&
(error.message === tooLargeMessage ||
error.message === invalidFileMessage)
) {
throw error
}
throw new Error('Continue 配置文件无法读取', { cause: error })
})
const source = data.toString('utf8')
let parsed: unknown
try {
const extension = extname(configPath).toLowerCase()
parsed =
extension === '.json' || extension === '.jsonc'
? json5.parse(source)
: parseYaml(source, { maxAliasCount: 100 })
} catch (error) {
throw new Error(
'Continue 配置文件无法解析,无法安全注入知识库工具',
{ cause: error }
)
}
if (!isRecord(parsed)) {
throw new Error('Continue 配置文件必须包含配置对象')
}
return parsed
}
function boundedText(
value: unknown,
maximum: number
): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = value.trim()
if (
!normalized ||
normalized.length > maximum ||
[...normalized].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 && code !== 9 && code !== 10 && code !== 13
})
) {
return undefined
}
return normalized
}
function configuredRules(config: Record<string, unknown>): unknown[] {
if (config.rules === undefined) {
return []
}
if (
!Array.isArray(config.rules) ||
config.rules.length > maximumConfiguredRules
) {
throw new Error(
`Continue 配置文件中的 Rules 不能超过 ${maximumConfiguredRules} 个`
)
}
return config.rules
}
function configuredPrompts(config: Record<string, unknown>): unknown[] {
if (config.prompts === undefined) {
return []
}
if (
!Array.isArray(config.prompts) ||
config.prompts.length > maximumConfiguredPrompts
) {
throw new Error(
`Continue 配置文件中的 Prompts 不能超过 ${maximumConfiguredPrompts} 个`
)
}
return config.prompts
}
function presetConfig(
preset: ContinueConfigurationPreset | undefined
): {
rules: Array<Record<string, unknown>>
prompts: Array<Record<string, unknown>>
} {
if (!preset) {
return { rules: [], prompts: [] }
}
const validPreset = continueConfigurationPresetSchema.parse(preset)
return {
rules: validPreset.rules
.filter((rule) => rule.enabled)
.map((rule) => ({
name: rule.name,
rule: rule.content
})),
prompts: validPreset.prompts.map((prompt) => ({
name: prompt.name,
...(prompt.description
? { description: prompt.description }
: {}),
prompt: prompt.prompt
}))
}
}
export async function inspectContinueNativeConfiguration(options: {
configPath: string
workspace: string
}): Promise<
Pick<
RuntimeNativeSnapshot,
'mcpServers' | 'rules' | 'prompts'
> & { detail: string }
> {
const config = options.configPath.trim()
? await loadContinueConfig(options.configPath.trim())
: {}
const prompts: RuntimeNativeSnapshot['prompts'] = []
const rules: RuntimeNativeSnapshot['rules'] = []
for (const [index, value] of configuredRules(config).entries()) {
if (!isRecord(value)) {
continue
}
const prompt = boundedText(value.rule ?? value.content, 20_000)
const name =
boundedText(value.name, 200) ?? `Rule ${index + 1}`
if (prompt) {
rules.push({
id: `configuration-rule-${index + 1}`,
name,
content: prompt,
source: 'configuration'
})
}
}
for (const [index, value] of configuredPrompts(config).entries()) {
if (!isRecord(value)) {
continue
}
const prompt = boundedText(value.prompt, 20_000)
const name =
boundedText(value.name, 200) ?? `Prompt ${index + 1}`
const description = boundedText(value.description, 2_000)
if (prompt) {
prompts.push({
id: `configuration-prompt-${index + 1}`,
name,
...(description ? { description } : {}),
prompt,
source: 'configuration'
})
}
}
const mcpServers: RuntimeNativeSnapshot['mcpServers'] = []
if (
config.mcpServers !== undefined &&
!Array.isArray(config.mcpServers)
) {
throw new Error('Continue 配置文件中的 mcpServers 必须是数组')
}
const servers = config.mcpServers ?? []
if (servers.length > maximumConfiguredMcpServers) {
throw new Error(
`Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个`
)
}
for (const [index, value] of servers.entries()) {
if (!isRecord(value)) {
continue
}
const name = boundedText(value.name, 200)
if (
!name ||
name === knowledgeMcpName ||
name === customMcpName
) {
continue
}
mcpServers.push({
id: `configuration-mcp-${index + 1}`,
name,
status: value.disabled === true ? 'disabled' : 'unknown',
detail:
value.disabled === true
? '已在 Continue 配置中停用'
: '已配置;静态快照不会启动 MCP Server 或验证连接'
})
}
return {
mcpServers,
rules,
prompts: prompts.slice(0, 200),
detail:
'Rules 与 Prompts 来自原始静态配置;MCP Prompt 仅在 MCPService 运行并连接后可发现,非运行快照不会启动服务器。Continue MCPService 不提供 Resources。'
}
}
export function hasContinueModelConfiguration(
configPath: string,
modelProfile?: ResolvedModelProfile
): boolean {
return Boolean(modelProfile || configPath.trim())
}
export type ContinueHostChild = {
exitCode: number | null
killed: boolean
pid?: number
stderr?: {
on: (
event: 'data',
listener: (chunk: Buffer | string) => void
) => unknown
} | null
once: (
event: 'error' | 'close',
listener: (error: Error | number | null) => void
) => unknown
removeListener?: (
event: 'error' | 'close',
listener: (error: Error | number | null) => void
) => unknown
kill: (signal?: NodeJS.Signals) => unknown
}
export type ContinueHostLauncher = (
entryPath: string,
args: string[],
options: {
cwd: string
env: NodeJS.ProcessEnv
}
) => ContinueHostChild
function hashContents(value: string | Buffer): string {
return createHash('sha256').update(value).digest('hex')
}
function replaceExactly(
source: string,
marker: string,
replacement: string
): string {
const first = source.indexOf(marker)
if (first < 0 || source.indexOf(marker, first + marker.length) >= 0) {
throw new Error('Continue CLI 版本与宿主适配层不兼容')
}
return `${source.slice(0, first)}${replacement}${source.slice(
first + marker.length
)}`
}
async function isFile(filePath: string): Promise<boolean> {
try {
return (await stat(filePath)).isFile()
} catch {
return false
}
}
async function resolveDistribution(binaryPath: string): Promise<string> {
const canonical = await realpath(binaryPath).catch(() => binaryPath)
const candidates = [
basename(canonical).toLowerCase() === 'cn.js'
? dirname(canonical)
: '',
join(dirname(canonical), 'node_modules', '@continuedev', 'cli', 'dist'),
join(dirname(binaryPath), 'node_modules', '@continuedev', 'cli', 'dist')
].filter(Boolean)
for (const candidate of candidates) {
if (
(await isFile(join(candidate, 'cn.js'))) &&
(await isFile(join(candidate, 'index.js')))
) {
return candidate
}
}
throw new Error(
'当前 Continue 二进制不包含可适配的宿主模块,请使用 npm 安装的 Continue CLI 1.5.47'
)
}
function delay(milliseconds: number, signal: AbortSignal): Promise<void> {
return new Promise((resolveDelay, reject) => {
const finish = (): void => {
signal.removeEventListener('abort', abort)
resolveDelay()
}
const timeout = setTimeout(finish, milliseconds)
const abort = (): void => {
clearTimeout(timeout)
signal.removeEventListener('abort', abort)
reject(signal.reason)
}
signal.addEventListener('abort', abort, { once: true })
})
}
function extractAssistantText(
history: unknown[],
startIndex: number
): string {
for (const item of history.slice(startIndex).reverse()) {
if (!item || typeof item !== 'object') {
continue
}
const message = (item as Record<string, unknown>).message
if (!message || typeof message !== 'object') {
continue
}
const record = message as Record<string, unknown>
if (
record.role === 'assistant' &&
typeof record.content === 'string' &&
record.content.trim()
) {
return record.content.trim()
}
}
return ''
}
function parseContinueFailure(text: string): string | undefined {
const match = /^Error:\s*(\{[\s\S]{1,16384}\})$/u.exec(text.trim())
if (!match?.[1]) {
return undefined
}
try {
const payload = JSON.parse(match[1]) as unknown
if (!payload || typeof payload !== 'object') {
return undefined
}
const record = payload as Record<string, unknown>
const error = record.error
const message =
typeof error === 'string'
? error
: error && typeof error === 'object'
? (error as Record<string, unknown>).message
: record.message
const detail =
typeof message === 'string' && message.trim()
? `:${message.trim().slice(0, 500)}`
: ''
return `Continue 模型请求失败${detail}`
} catch {
return undefined
}
}
function extractContinueFailure(
history: unknown[],
startIndex: number
): string | undefined {
for (const item of history.slice(startIndex).reverse()) {
if (!item || typeof item !== 'object') {
continue
}
const message = (item as Record<string, unknown>).message
if (!message || typeof message !== 'object') {
continue
}
const content = (message as Record<string, unknown>).content
if (typeof content !== 'string') {
continue
}
const failure = parseContinueFailure(content)
if (failure) {
return failure
}
}
return undefined
}
function extractContinueTools(
history: unknown[],
startIndex: number
): ContinueHostTool[] {
const tools = new Map<string, ContinueHostTool>()
for (const item of history.slice(startIndex)) {
if (!item || typeof item !== 'object') {
continue
}
const states = (item as Record<string, unknown>).toolCallStates
if (!Array.isArray(states)) {
continue
}
for (const value of states) {
if (!value || typeof value !== 'object') {
continue
}
const state = value as Record<string, unknown>
const toolCall = state.toolCall
const toolFunction =
toolCall && typeof toolCall === 'object'
? (toolCall as Record<string, unknown>).function
: undefined
const callId =
typeof state.toolCallId === 'string'
? state.toolCallId.slice(0, 256)
: ''
const name =
toolFunction && typeof toolFunction === 'object'
? (toolFunction as Record<string, unknown>).name
: undefined
if (!callId || typeof name !== 'string' || !name.trim()) {
continue
}
const status = state.status
const normalizedState =
status === 'done' || status === 'completed'
? 'completed'
: status === 'calling' || status === 'running'
? 'running'
: status === 'generated' || status === 'pending'
? 'pending'
: 'failed'
const error =
normalizedState === 'failed'
? normalizeContinueToolError(state.output)
: undefined
const input =
toolFunction && typeof toolFunction === 'object'
? boundedToolDetail(
(toolFunction as Record<string, unknown>).arguments,
4_000
)
: undefined
const output =
normalizedState === 'completed'
? boundedToolDetail(state.output, 16_000)
: undefined
tools.set(callId, {
runtimeChecklist: parseContinueChecklist(name, (toolFunction as Record<string, unknown>).arguments),
callId,
name: name.trim().slice(0, 200),
state: normalizedState,
summary: toolOperationSummary(
(toolFunction as Record<string, unknown>).arguments,
state.title
),
...(input ? { input } : {}),
...(output ? { output } : {}),
...(error ? { error } : {})
})
}
}
return [...tools.values()]
}
function mergeContinueTools(
current: ContinueHostTool[],
updates: ContinueHostTool[]
): ContinueHostTool[] {
const tools = new Map(current.map((tool) => [tool.callId, tool]))
for (const tool of updates) {
const previous = tools.get(tool.callId)
tools.set(tool.callId, {
...previous,
...tool,
input: tool.input ?? previous?.input,
runtimeChecklist: tool.runtimeChecklist ?? previous?.runtimeChecklist,
summary: tool.summary ?? previous?.summary,
output: tool.output ?? previous?.output,
error: tool.error ?? previous?.error
})
}
return [...tools.values()]
}
function normalizeContinueToolError(value: unknown): string | undefined {
return safeToolErrorDetail(value)
}
function subtractTokenCount(completed: number, initial: number): number {
return Math.max(0, completed - initial)
}
function extractUsageDelta(
initial: ContinueHostState['session']['usage'],
completed: ContinueHostState['session']['usage'],
fallbackProvider: string,
fallbackModel?: string
): ContinueHostUsage | undefined {
if (!initial || !completed) {
return undefined
}
return {
provider: fallbackProvider,
model: fallbackModel ?? 'unknown',
inputTokens: subtractTokenCount(
completed.promptTokens,
initial.promptTokens
),
outputTokens: subtractTokenCount(
completed.completionTokens,
initial.completionTokens
),
cacheReadTokens: subtractTokenCount(
completed.promptTokensDetails?.cachedTokens ?? 0,
initial.promptTokensDetails?.cachedTokens ?? 0
),
cacheWriteTokens: subtractTokenCount(
completed.promptTokensDetails?.cacheWriteTokens ?? 0,
initial.promptTokensDetails?.cacheWriteTokens ?? 0
)
}
}
export class ContinueHostAdapter {
private readonly children = new Set<ContinueHostChild>()
private readonly childTerminations = new WeakMap<
ContinueHostChild,
Promise<void>
>()
private readonly pendingQuestions = new Map<
string,
{
origin: string
token: string
signal: AbortSignal
options: readonly string[]
}
>()
private preparation?: Promise<PreparedHost>
private readonly dependencies: ContinueHostAdapterDependencies
constructor(
private readonly options: ContinueHostAdapterOptions,
dependencies: Partial<ContinueHostAdapterDependencies> = {}
) {
this.dependencies = {
terminateProcessTree: terminateProcessTreeAndWait,
maximumStreamEventBytes,
controlRequestTimeoutMs: defaultControlRequestTimeoutMs,
...dependencies
}
}
private async prepare(): Promise<PreparedHost> {
if (!isAbsolute(this.options.cacheRoot)) {
throw new Error('Continue 宿主缓存目录必须是绝对路径')
}
const distribution = await resolveDistribution(this.options.binaryPath)
const packagePath = resolve(distribution, '..', 'package.json')
const packageValue = JSON.parse(await readFile(packagePath, 'utf8')) as {
version?: unknown
}
if (packageValue.version !== supportedVersion) {
throw new Error(
`Continue 宿主适配层仅支持 ${supportedVersion},当前版本为 ${
typeof packageValue.version === 'string'
? packageValue.version
: 'unknown'
}`
)
}
const sourceBundlePath = join(distribution, 'index.js')
const sourceBundle = await readFile(sourceBundlePath, 'utf8')
if (Buffer.byteLength(sourceBundle) > maximumBundleBytes) {
throw new Error('Continue CLI bundle 超过安全大小限制')
}
const sourceHash = hashContents(sourceBundle)
const trustedHashes = new Set(
this.options.trustedBundleHashes ?? supportedBundleHashes
)
if (!trustedHashes.has(sourceHash)) {
throw new Error('Continue CLI bundle 未通过宿主兼容性校验')
}
const serveInitializationMarker =
'toolPermissionOverrides:s,headless:!0});let[a,u,l,c]'
const permissionOptionsMarker =
'i={allow:o.allow,ask:o.ask,exclude:o.exclude,isHeadless:e.headless}'
const permissionInitializeMarker =
'E6t.initialize({isHeadless:e.headless},r,n)'
const permissionFlagOrderMarker =
'function ZZo(e){let t=[];if(e.exclude)for(let n of e.exclude){let r=n;t.push({tool:r,permission:"exclude"})}if(e.ask)for(let n of e.ask){let r=n;t.push({tool:r,permission:"ask"})}if(e.allow)for(let n of e.allow){let r=n;t.push({tool:r,permission:"allow"})}return t}'
const serverMarker =
'let j=(0,atn.default)();j.use(atn.default.json()),j.get("/state"'
const listenMarker =
'listen(i,async()=>{console.log(Ht.green(`Server started on http://localhost:${i}`))'
const versionCheckMarker =
'async function SCt(e){return n5e||'
const updateServiceMarker = 'async checkAndAutoUpdate(){'
const responseRoutingMarker =
'shouldUseResponsesEndpoint(t){return this.config.useResponsesApi===!1?!1:this.apiBase==="https://api.openai.com/v1/"&&A0e(t)}'
const modelConfigurationMarker =
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env};return CGn(n)??null}'
const messageOrderingMarker =
'function Sin(e,t){let n=[];n.push({role:"system",content:t});let r=oot(e);return n.push(...r),n}'
const windowsShellMarker =
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-ExecutionPolicy","Bypass","-Command",e]}'
const terminalOutputMarker =
'let{shell:d,args:p}=Csa(e),f=Esa(d,p),g="",y="",A,S=!1,x=18e4;'
const skillDirectoriesMarker =
'let r=[eS.join(n,".continue",AKt),eS.join(n,".claude",AKt),eS.join(hu.continueHome,AKt)],o='
const streamCallbacksMarker =
'a={onContent:u=>{},onContentComplete:u=>{},onToolStart:(u,l)=>{},onToolResult:(u,l,c)=>{},onToolError:(u,l)=>{},onToolPermissionRequest:'
const serverStateMarker = 'pendingPermission:null},B='
const serverStateEndpointMarker =
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission);Te.json(ue)})'
const preprocessToolStartMarker =
'n?.onToolStart?.(i.name,i.arguments);'
const preprocessToolErrorMarker =
'n?.onToolError?.(l,i.name)'
const executeToolStartMarker =
't?.onToolStart?.(c.name,c.arguments);'
const cancelledToolResultMarker =
't?.onToolResult?.(String(y.content),c.name,"canceled")'
const completedToolResultMarker =
't?.onToolResult?.(f,c.name,"done")'
const failedToolResultMarker = 't?.onToolError?.(g,c.name)'
const permissionToolErrorMarker = 't?.onToolError?.(p,c.name)'
let patched = replaceExactly(
sourceBundle,
serveInitializationMarker,
'toolPermissionOverrides:s,headless:!0,interactivePermissions:!0});let[a,u,l,c]'
)
patched = replaceExactly(
patched,
permissionOptionsMarker,
'i={allow:o.allow,ask:o.ask,exclude:o.exclude,isHeadless:e.interactivePermissions?!1:e.headless}'
)
patched = replaceExactly(
patched,
permissionInitializeMarker,
'E6t.initialize({isHeadless:e.interactivePermissions?!1:e.headless},r,n)'
)
patched = replaceExactly(
patched,
permissionFlagOrderMarker,
'function ZZo(e){let t=[];if(e.allow)for(let n of e.allow){let r=n;t.push({tool:r,permission:"allow"})}if(e.exclude)for(let n of e.exclude){let r=n;t.push({tool:r,permission:"exclude"})}if(e.ask)for(let n of e.ask){let r=n;t.push({tool:r,permission:"ask"})}return t}'
)
patched = replaceExactly(
patched,
serverMarker,
'let j=(0,atn.default)();if(!process.env.GOODBUDDY_CONTINUE_HOST_TOKEN)throw new Error("Missing GoodBuddy host token");j.use((we,Te,ue)=>{we.headers.authorization===`Bearer ${process.env.GOODBUDDY_CONTINUE_HOST_TOKEN}`?ue():Te.status(401).json({error:"Unauthorized"})}),j.use(atn.default.json({limit:"100mb"})),j.post("/goodbuddy/question-answer",(we,Te)=>{let{requestId:ue,answer:ce,isCustomAnswer:de}=we.body??{};typeof ue==="string"&&ue.length>0&&typeof ce==="string"&&ce.length>0?Lbe.answerQuestion(ue,ce,de===!0)?Te.json({success:!0}):Te.status(404).json({error:"Question not pending"}):Te.status(400).json({error:"Invalid question answer"})}),j.get("/state"'
)
patched = replaceExactly(
patched,
listenMarker,
'listen(i,"127.0.0.1",async()=>{console.log(Ht.green(`Server started on http://localhost:${i}`))'
)
patched = replaceExactly(
patched,
versionCheckMarker,
'async function SCt(e){if(process.env.GOODBUDDY_DISABLE_CONTINUE_UPDATES==="1")return null;return n5e||'
)
patched = replaceExactly(
patched,
updateServiceMarker,
`${updateServiceMarker}if(process.env.GOODBUDDY_DISABLE_CONTINUE_UPDATES==="1"){this.setState({autoUpdate:!1,status:"idle",message:"Continue CLI",isUpdateAvailable:!1});return;}`
)
patched = replaceExactly(
patched,
responseRoutingMarker,
'shouldUseResponsesEndpoint(t){return this.config.useResponsesApi===!0?!0:this.config.useResponsesApi===!1?!1:this.apiBase==="https://api.openai.com/v1/"&&A0e(t)}'
)
patched = replaceExactly(
patched,
modelConfigurationMarker,
'function uAe(e,t){let n={provider:e.provider,model:e.model,apiKey:e.apiKey,apiBase:e.apiBase,requestOptions:e.requestOptions,env:e.env,useResponsesApi:e.useResponsesApi};return CGn(n)??null}'
)
patched = replaceExactly(
patched,
messageOrderingMarker,
'function Sin(e,t){let n=[];n.push({role:"system",content:t});let r=oot(e).filter(o=>o.role!=="system");return n.push(...r),n}'
)
patched = replaceExactly(
patched,
windowsShellMarker,
'function Csa(e){return process.platform==="win32"?{shell:"powershell.exe",args:["-NoLogo","-NoProfile","-ExecutionPolicy","Bypass","-Command",\'[Console]::InputEncoding=[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);$OutputEncoding=[Console]::OutputEncoding;\'+e]}'
)
patched = replaceExactly(
patched,
terminalOutputMarker,
`${terminalOutputMarker}f.stdout.setEncoding("utf8"),f.stderr.setEncoding("utf8");`
)
patched = replaceExactly(
patched,
skillDirectoriesMarker,
'let r=[eS.join(hu.continueHome,AKt)],o='
)
patched = replaceExactly(
patched,
streamCallbacksMarker,
'a={onContent:u=>{if(!u)return;let l=String(u);e.goodbuddyEventsBytes+=Buffer.byteLength(l);l.length<=1e5&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push({type:"text",delta:l}):e.goodbuddyEventsOverflow=!0},onContentComplete:u=>{},onToolStart:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolResult:(u,l,c,d)=>{if(!d)return;let p={type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(p));e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(p):e.goodbuddyEventsOverflow=!0},onToolError:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolPermissionRequest:'
)
patched = replaceExactly(
patched,
serverStateMarker,
'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B='
)
patched = replaceExactly(
patched,
'state:"running",input:(()=>',
`state:"running",runtimeChecklist:(${parseContinueChecklist.toString()})(u,l),summary:(${toolOperationSummary.toString()})(l),input:(()=>`
)
patched = replaceExactly(
patched,
serverStateEndpointMarker,
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission),ce=M.goodbuddyEvents.splice(0),de=M.goodbuddyEventsOverflow;M.goodbuddyEventsBytes=0,M.goodbuddyEventsOverflow=!1;Te.json({...ue,goodbuddyEvents:ce,goodbuddyEventsOverflow:de,goodbuddyQuestion:Lbe.currentState.pendingQuestion})})'
)
patched = replaceExactly(
patched,
preprocessToolStartMarker,
'n?.onToolStart?.(i.name,i.arguments,i.id);'
)
patched = replaceExactly(
patched,
preprocessToolErrorMarker,
'n?.onToolError?.(l,i.name,i.id)'
)
patched = replaceExactly(
patched,
executeToolStartMarker,
't?.onToolStart?.(c.name,c.arguments,c.id);'
)
patched = replaceExactly(
patched,
cancelledToolResultMarker,
't?.onToolResult?.(String(y.content),c.name,"canceled",c.id)'
)
patched = replaceExactly(
patched,
completedToolResultMarker,
't?.onToolResult?.(f,c.name,"done",c.id)'
)
patched = replaceExactly(
patched,
failedToolResultMarker,
't?.onToolError?.(g,c.name,c.id)'
)
patched = replaceExactly(
patched,
permissionToolErrorMarker,
't?.onToolError?.(p,c.name,c.id)'
)
const patchedHash = hashContents(patched)
const digest = sourceHash.slice(0, 16)
const targetRoot = join(
this.options.cacheRoot,
`host-v7-${supportedVersion}-${digest}`
)
const targetDist = join(targetRoot, 'dist')
const targetBundle = join(targetDist, 'index.js')
const readyMarker = join(targetRoot, '.ready')
if (
(await isFile(readyMarker)) &&
(await isFile(join(targetDist, 'cn.js'))) &&
(await isFile(join(targetDist, 'utility-bootstrap.mjs'))) &&
(await isFile(targetBundle)) &&
hashContents(await readFile(targetBundle)) === patchedHash
) {
return {
entryPath: join(targetDist, 'cn.js'),
version: supportedVersion
}
}
await rm(targetRoot, { recursive: true, force: true })
const stagingRoot = `${targetRoot}.staging-${crypto.randomUUID()}`
const stagingDist = join(stagingRoot, 'dist')
try {
await mkdir(stagingDist, { recursive: true })
await Promise.all([
writeFile(join(stagingDist, 'index.js'), patched, 'utf8'),
copyFile(join(distribution, 'cn.js'), join(stagingDist, 'cn.js')),
copyFile(
join(distribution, 'xhr-sync-worker.js'),
join(stagingDist, 'xhr-sync-worker.js')
),
writeFile(
join(stagingDist, 'utility-bootstrap.mjs'),
utilityBootstrap,
'utf8'
),
copyFile(packagePath, join(stagingRoot, 'package.json'))
])
await writeFile(
join(stagingRoot, '.ready'),
JSON.stringify({ sourceHash, patchedHash }),
'utf8'
)
await mkdir(this.options.cacheRoot, { recursive: true })
await rename(stagingRoot, targetRoot).catch(async (error) => {
if (
!(await isFile(targetBundle)) ||
hashContents(await readFile(targetBundle)) !== patchedHash
) {
throw error
}
})
} finally {
await rm(stagingRoot, { recursive: true, force: true })
}
return {
entryPath: join(targetDist, 'cn.js'),
version: supportedVersion
}
}
getPreparedHost(): Promise<PreparedHost> {
this.preparation ??= this.prepare().catch((error) => {
this.preparation = undefined
throw error
})
return this.preparation
}
async respondToQuestion(
questionId: string,
answers?: AgentQuestionAnswer[]
): Promise<void> {
const pending = this.pendingQuestions.get(questionId)
if (!pending) {
throw new Error('Continue 提问已失效或不存在')
}
let answer = 'User declined to answer this question.'
let isCustomAnswer = true
if (answers) {
if (
answers.length !== 1 ||
answers[0]?.length !== 1 ||
!answers[0][0]?.trim()
) {
throw new Error('Continue 提问回答数量不匹配')
}
answer = answers[0][0].trim()
isCustomAnswer = !pending.options.includes(answer)
}
await this.request(
pending.origin,
pending.token,
'/goodbuddy/question-answer',
{
method: 'POST',
body: JSON.stringify({
requestId: questionId,
answer,
isCustomAnswer
}),
signal: pending.signal
}
)
this.pendingQuestions.delete(questionId)
}
private async request(
origin: string,
token: string,
path: string,
init: RequestInit = {}
): Promise<unknown> {
const timeoutSignal = AbortSignal.timeout(
Math.max(1, this.dependencies.controlRequestTimeoutMs)
)
const requestSignal = init.signal
? AbortSignal.any([init.signal, timeoutSignal])
: timeoutSignal
let response: Response
try {
response = await fetch(`${origin}${path}`, {
...init,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
...init.headers
},
redirect: 'error',
signal: requestSignal
})
} catch (error) {
if (timeoutSignal.aborted && !init.signal?.aborted) {
throw new Error(`Continue 宿主请求超时(${path})`, {
cause: error
})
}
throw error
}
const body = await readBoundedResponseText(response, {
maxBytes: maximumStateBytes,
tooLargeMessage: 'Continue 宿主响应超过安全大小限制'
})
if (!response.ok) {
throw new Error(`Continue 宿主请求失败(HTTP ${response.status})`)
}
return body ? JSON.parse(body) : undefined
}
private async waitForStartup(
child: ContinueHostChild,
getChildFailure: () => Error | undefined,
origin: string,
token: string,
signal: AbortSignal
): Promise<ContinueHostState> {
const expiresAt = Date.now() + 30_000
const timeoutSignal = AbortSignal.timeout(30_000)
const startupSignal = AbortSignal.any([signal, timeoutSignal])
try {
while (Date.now() < expiresAt) {
startupSignal.throwIfAborted()
const childFailure = getChildFailure()
if (childFailure) {
throw childFailure
}
if (child.exitCode !== null) {
throw new Error('Continue 宿主在启动期间退出')
}
try {
return stateSchema.parse(
await this.request(origin, token, '/state', {
signal: startupSignal
})
)
} catch {
await delay(150, startupSignal)
}
}
} catch (error) {
if (timeoutSignal.aborted && !signal.aborted) {
throw new Error('Continue 宿主启动超时', { cause: error })
}
throw error
}
throw new Error('Continue 宿主启动超时')
}
private async writeTemporaryConfig(
prefix: string,
config: Record<string, unknown>
): Promise<string> {
await mkdir(this.options.cacheRoot, { recursive: true })
const configPath = join(
this.options.cacheRoot,
`${prefix}-${crypto.randomUUID()}.yaml`
)
await writeFile(configPath, JSON.stringify(config), {
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
})
return configPath
}
private async createRunConfig(
runOptions: ContinueHostRunOptions
): Promise<string | undefined> {
const knowledgeCapability = runOptions.knowledgeCapability
const customMcpCapability = runOptions.customMcpCapability
if (
customMcpCapability &&
runOptions.workMode !== 'execute'
) {
throw new Error(
'Continue 自定义 MCP 仅允许在 Agent Execute 模式使用'
)
}
const capabilityServers = [
...(runOptions.sessionMcpServers ?? []),
...(knowledgeCapability
? [
createLoopbackMcpServer(
knowledgeMcpName,
knowledgeCapability
)
]
: []),
...(customMcpCapability
? [
createLoopbackMcpServer(
customMcpName,
customMcpCapability
)
]
: [])
]
if (capabilityServers.length > maximumConfiguredMcpServers) {
throw new Error(
`Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个`
)
}
const selectedPreset = presetConfig(runOptions.preset)
const hasPresetContent =
selectedPreset.rules.length > 0 ||
selectedPreset.prompts.length > 0
if (!this.options.modelProfile) {
if (capabilityServers.length === 0 && !hasPresetContent) {
return undefined
}
const configured = await loadContinueConfig(
this.options.configPath.trim()
)
const nativeRules = configuredRules(configured)
const nativePrompts = configuredPrompts(configured)
if (
nativeRules.length + selectedPreset.rules.length >
maximumConfiguredRules
) {
throw new Error(
`Continue 合并后的 Rules 不能超过 ${maximumConfiguredRules} 个`
)
}
if (
nativePrompts.length + selectedPreset.prompts.length >
maximumConfiguredPrompts
) {
throw new Error(
`Continue 合并后的 Prompts 不能超过 ${maximumConfiguredPrompts} 个`
)
}
const existingServers = configured.mcpServers
if (
existingServers !== undefined &&
!Array.isArray(existingServers)
) {
throw new Error(
'Continue 配置文件中的 mcpServers 必须是数组'
)
}
const servers = existingServers ?? []
if (servers.length > maximumConfiguredMcpServers) {
throw new Error(
`Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个`
)
}
const retainedServers =
runOptions.workMode === 'ask' && Boolean(knowledgeCapability || runOptions.sessionMcpServers?.length)
? []
: servers.filter(
(server) =>
!isRecord(server) ||
(
server.name !== knowledgeMcpName &&
server.name !== customMcpName
)
)
if (
retainedServers.length + capabilityServers.length >
maximumConfiguredMcpServers
) {
throw new Error(
`Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个`
)
}
return this.writeTemporaryConfig(
capabilityServers.length > 0
? 'knowledge-config'
: 'customization-config',
{
...configured,
...(nativeRules.length + selectedPreset.rules.length > 0
? {
rules: [...nativeRules, ...selectedPreset.rules]
}
: {}),
...(nativePrompts.length + selectedPreset.prompts.length > 0
? {
prompts: [...nativePrompts, ...selectedPreset.prompts]
}
: {}),
mcpServers: [
...retainedServers,
...capabilityServers
]
}
)
}
if (
this.options.modelProfile.authentication === 'api-key' &&
!this.options.modelProfile.apiKey
) {
throw new Error('Continue 独立模型连接尚未配置 API Key')
}
const anthropic =
this.options.modelProfile.protocol === 'anthropic-messages'
const modelConfig: Record<string, unknown> = {
name: this.options.modelProfile.name,
provider: anthropic ? 'anthropic' : 'openai',
model: this.options.modelProfile.modelName,
apiBase: anthropic
? createAnthropicApiBaseUrl(this.options.modelProfile.baseUrl)
: createOpenAIApiBaseUrl(this.options.modelProfile.baseUrl),
roles: ['chat'],
capabilities: this.options.modelProfile.supportsImageInput === true
? ['image_input']
: []
}
if (!anthropic) {
modelConfig.useResponsesApi =
this.options.modelProfile.protocol === 'openai-responses'
}
if (this.options.modelProfile.authentication === 'api-key') {
modelConfig.apiKey = anthropic
? '${{ secrets.ANTHROPIC_API_KEY }}'
: '${{ secrets.OPENAI_API_KEY }}'
}
const requestHeaders =
this.options.modelProfile.requestHeaders ?? {}
const requestBody = this.options.modelProfile.requestBody ?? {}
if (
Object.keys(requestHeaders).length > 0 ||
Object.keys(requestBody).length > 0
) {
modelConfig.requestOptions = {
...(Object.keys(requestHeaders).length > 0
? { headers: requestHeaders }
: {}),
...(Object.keys(requestBody).length > 0
? { extraBodyProperties: requestBody }
: {})
}
}
const configured = this.options.configPath.trim()
? await loadContinueConfig(this.options.configPath.trim())
: {}
const nativeRules = configuredRules(configured)
const nativePrompts = configuredPrompts(configured)
if (
nativeRules.length + selectedPreset.rules.length >
maximumConfiguredRules
) {
throw new Error(
`Continue 合并后的 Rules 不能超过 ${maximumConfiguredRules} 个`
)
}
if (
nativePrompts.length + selectedPreset.prompts.length >
maximumConfiguredPrompts
) {
throw new Error(
`Continue 合并后的 Prompts 不能超过 ${maximumConfiguredPrompts} 个`
)
}
return this.writeTemporaryConfig('model-config', {
name: 'GoodBuddy Runtime',
version: '1.0.0',
schema: 'v1',
models: [modelConfig],
...(nativeRules.length + selectedPreset.rules.length > 0
? {
rules: [...nativeRules, ...selectedPreset.rules]
}
: {}),
...(nativePrompts.length + selectedPreset.prompts.length > 0
? {
prompts: [...nativePrompts, ...selectedPreset.prompts]
}
: {}),
...(capabilityServers.length > 0
? {
mcpServers: capabilityServers
}
: {})
})
}
private async createRunGlobalDirectory(): Promise<string> {
const root = join(
this.options.cacheRoot,
`isolated-global-${crypto.randomUUID()}`
)
await mkdir(root, { recursive: false, mode: 0o700 })
const skillPackages = this.options.skillPackages ?? []
if (skillPackages.length === 0) {
return root
}
await stageRuntimeSkillPackages(root, skillPackages, 'Continue')
return root
}
async run(
prompt: string,
signal: AbortSignal,
authorize: RuntimeAuthorizer,
runOptions: ContinueHostRunOptions = {}
): Promise<ContinueHostRunResult> {
signal.throwIfAborted()
if (
!hasContinueModelConfiguration(
this.options.configPath,
this.options.modelProfile
)
) {
throw new Error(continueConfigurationRequiredMessage)
}
let generatedConfigPath: string | undefined
let isolatedGlobalDirectory: string | undefined
try {
generatedConfigPath = await this.createRunConfig(runOptions)
const [{ entryPath }, port] = await Promise.all([
this.getPreparedHost(),
getAvailableLoopbackPort()
]).catch(async (error) => {
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
}
throw error
})
const token = randomBytes(32).toString('base64url')
const origin = `http://127.0.0.1:${port}`
isolatedGlobalDirectory = await this.createRunGlobalDirectory()
const args: string[] = []
const configPath =
generatedConfigPath ?? this.options.configPath.trim()
if (configPath) {
args.push('--config', configPath)
}
if (
runOptions.workMode === 'ask' &&
(runOptions.knowledgeCapability || runOptions.sessionMcpServers?.length)
) {
for (const toolName of scopedReadToolNames) {
args.push('--allow', toolName)
}
args.push('--exclude', '*')
} else if (runOptions.workMode === 'execute') {
args.push('--auto')
} else if (this.options.mode === 'chat') {
args.push('--readonly')
}
args.push('serve', '--port', String(port), '--timeout', '300')
const environmentOverrides = {
...runtimePrivacyEnvironment,
CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1',
CONTINUE_CLI_AUTO_UPDATED: '1',
CONTINUE_CLI_ENABLE_TELEMETRY: '0',
CONTINUE_METRICS_ENABLED: '0',
CONTINUE_GLOBAL_DIR: isolatedGlobalDirectory,
...(process.platform === 'win32'
? {
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
}
: {}),
FORCE_NO_TTY: '1',
GOODBUDDY_CONTINUE_HOST_TOKEN: token,
GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1'
}
const profile = this.options.modelProfile
let environment = profile
? buildExplicitProfileRuntimeEnvironment(
environmentOverrides,
profile.authentication === 'api-key' && profile.apiKey
? {
name:
profile.protocol === 'anthropic-messages'
? 'ANTHROPIC_API_KEY'
: 'OPENAI_API_KEY',
value: profile.apiKey
}
: undefined
)
: buildRuntimeEnvironment(environmentOverrides)
environment = applyLaunchEnvironmentPath(
environment,
this.options.launchEnvironmentProvider
)
signal.throwIfAborted()
let child: ContinueHostChild
try {
child = (
this.options.launchHost ??
((hostEntryPath, hostArgs, hostOptions) =>
spawn(
process.platform === 'win32' ? 'node.exe' : 'node',
[hostEntryPath, ...hostArgs],
{
...hostOptions,
shell: false,
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
}
))
)(entryPath, args, {
cwd: this.options.workspace,
env: environment
})
} catch (error) {
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
}
await rm(isolatedGlobalDirectory, {
recursive: true,
force: true
})
throw error
}
this.children.add(child)
let childFailure: Error | undefined
child.once('error', (error) => {
childFailure = new Error('Continue 宿主进程启动失败', {
cause: error
})
})
let stderrBytes = 0
child.stderr?.on('data', (chunk: Buffer | string) => {
stderrBytes += Buffer.byteLength(chunk)
if (stderrBytes > 64 * 1024) {
void this.terminate(child)
}
})
const abort = (): void => {
void this.terminate(child)
}
signal.addEventListener('abort', abort, { once: true })
let observedTools: ContinueHostTool[] = []
const reportedQuestionIds = new Set<string>()
let streamedText = false
let streamTruncated = false
let streamEventBytes = 0
const observedToolCallIds = new Set<string>()
const submittedChecklists = new Set<string>()
try {
const initialState = await this.waitForStartup(
child,
() => childFailure,
origin,
token,
signal
)
const startIndex = initialState.session.history.length
const executionSignal = signal
const message =
runOptions.images && runOptions.images.length > 0
? [
{ type: 'text', text: prompt },
...runOptions.images.map((image) => ({
type: 'imageUrl',
imageUrl: {
url: `data:${image.mediaType};base64,${image.data}`
}
}))
]
: prompt
const messageBody = JSON.stringify({ message })
if (Buffer.byteLength(messageBody) > maximumMessageBytes) {
throw new Error('Continue 图片上下文超过 20 MB 安全大小限制')
}
await this.request(origin, token, '/message', {
method: 'POST',
body: messageBody,
signal: executionSignal
})
const handledPermissionIds = new Set<string>()
while (true) {
executionSignal.throwIfAborted()
if (childFailure) {
throw childFailure
}
if (child.exitCode !== null) {
throw new Error(
`Continue 宿主意外退出(code ${child.exitCode})`
)
}
const state = stateSchema.parse(
await this.request(origin, token, '/state', {
signal: executionSignal
})
)
streamTruncated ||= state.goodbuddyEventsOverflow === true
let streamEvents = state.goodbuddyEvents ?? []
const batchStreamEventBytes = Buffer.byteLength(
JSON.stringify(streamEvents)
)
const nextStreamEventBytes =
streamEventBytes + batchStreamEventBytes
if (
streamTruncated ||
nextStreamEventBytes >
this.dependencies.maximumStreamEventBytes
) {
streamTruncated = true
streamEvents = []
} else {
streamEventBytes = nextStreamEventBytes
}
const nextToolCallIds = new Set(observedToolCallIds)
for (const event of streamEvents) {
if (event.type === 'tool') {
nextToolCallIds.add(event.callId)
}
}
for (const callId of nextToolCallIds) {
observedToolCallIds.add(callId)
}
const historyTools = extractContinueTools(
state.session.history,
startIndex
)
for (const tool of historyTools) {
observedToolCallIds.add(tool.callId)
}
observedTools = mergeContinueTools(
observedTools,
historyTools
)
for (const event of streamEvents) {
if (event.type === 'text') {
streamedText = true
await runOptions.onEvent?.(event)
continue
}
const tool: ContinueHostTool = {
runtimeChecklist: event.runtimeChecklist,
callId: event.callId,
name: event.name,
state: event.state,
summary: toolOperationSummary(event.input, event.summary),
...(event.input
? { input: boundedToolDetail(event.input, 4_000) }
: {}),
...(event.output
? { output: boundedToolDetail(event.output, 16_000) }
: {}),
...(event.error
? { error: normalizeContinueToolError(event.error) }
: {})
}
observedTools = mergeContinueTools(observedTools, [tool])
await runOptions.onEvent?.({
type: 'tool',
tool: observedTools.find((item) => item.callId === tool.callId)!
})
}
for (const tool of observedTools) {
if (tool.name === 'Checklist' && tool.state === 'completed' && tool.runtimeChecklist && !submittedChecklists.has(tool.callId)) {
submittedChecklists.add(tool.callId)
await runOptions.onEvent?.({ type: 'checklist', checklist: tool.runtimeChecklist })
}
}
const pendingQuestion = state.goodbuddyQuestion
if (
pendingQuestion &&
!reportedQuestionIds.has(pendingQuestion.requestId)
) {
if (this.pendingQuestions.has(pendingQuestion.requestId)) {
throw new Error('Continue 提问 ID 与另一活动请求冲突')
}
reportedQuestionIds.add(pendingQuestion.requestId)
this.pendingQuestions.set(pendingQuestion.requestId, {
origin,
token,
signal: executionSignal,
options: pendingQuestion.options ?? []
})
await runOptions.onEvent?.({
type: 'question',
questionId: pendingQuestion.requestId,
questions: [
{
header: 'Continue',
question: pendingQuestion.question,
options: (
pendingQuestion.options ?? []
).map((option) => ({
label: option,
description: ''
})),
multiple: false,
custom: true
}
]
})
}
const pending = state.pendingPermission
if (pending && !handledPermissionIds.has(pending.requestId)) {
handledPermissionIds.add(pending.requestId)
const pendingCallId =
observedTools.find(
(tool) =>
tool.name === pending.toolName &&
tool.state !== 'completed' &&
tool.state !== 'failed'
)?.callId ?? pending.requestId.slice(0, 256)
if (
!observedTools.some((tool) => tool.callId === pendingCallId)
) {
observedToolCallIds.add(pendingCallId)
observedTools = [
...observedTools,
{
callId: pendingCallId,
name: pending.toolName,
state: 'pending',
input: boundedToolDetail(pending.toolArgs, 4_000)
}
]
}
const decision = await authorize({
scopeKey: `continue:${pending.toolName}`,
title: `Continue 请求调用 ${pending.toolName}`,
description: 'Continue Runtime 工具调用由 GoodBuddy 自动放行。',
toolName: pending.toolName,
allowPermanent: false
})
await this.request(origin, token, '/permission', {
method: 'POST',
body: JSON.stringify({
requestId: pending.requestId,
approved: decision !== 'deny'
}),
signal: executionSignal
})
}
if (
!state.isProcessing &&
state.messageQueueLength === 0 &&
!state.pendingPermission &&
state.session.history.length > startIndex
) {
const failure = extractContinueFailure(
state.session.history,
startIndex
)
if (failure) {
throw new Error(failure)
}
const text = extractAssistantText(
state.session.history,
startIndex
)
if (!text) {
throw new Error('Continue 宿主未返回最终回复')
}
const usage = extractUsageDelta(
initialState.session.usage,
state.session.usage,
this.options.modelProfile
? this.options.modelProfile.protocol ===
'anthropic-messages'
? 'anthropic'
: 'openai'
: 'continue',
this.options.modelProfile?.modelName
)
return {
text,
...(streamedText ? { streamedText: true as const } : {}),
...(streamTruncated
? { streamTruncated: true as const }
: {}),
...(usage ? { usage } : {}),
...(observedTools.length > 0
? { tools: observedTools }
: {})
}
}
await delay(150, executionSignal)
}
} catch (error) {
if (error instanceof ContinueHostRunError) {
throw error
}
throw new ContinueHostRunError(
error instanceof Error
? error.message
: 'Continue 宿主执行失败',
{ cause: error, tools: observedTools }
)
} finally {
signal.removeEventListener('abort', abort)
for (const questionId of reportedQuestionIds) {
this.pendingQuestions.delete(questionId)
}
try {
const cleanupSignal = AbortSignal.timeout(1_000)
if (signal.aborted) {
await this.request(origin, token, '/pause', {
method: 'POST',
signal: cleanupSignal
}).catch(() => undefined)
}
await this.request(origin, token, '/exit', {
method: 'POST',
signal: cleanupSignal
}).catch(() => undefined)
} finally {
await this.terminate(child)
this.children.delete(child)
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
}
await rm(isolatedGlobalDirectory, {
recursive: true,
force: true
})
}
}
} finally {
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
}
if (isolatedGlobalDirectory) {
await rm(isolatedGlobalDirectory, {
recursive: true,
force: true
})
}
}
}
private async terminate(child: ContinueHostChild): Promise<void> {
const existing = this.childTerminations.get(child)
if (existing) {
await existing
return
}
const termination = this.dependencies
.terminateProcessTree(child)
.catch(() => undefined)
this.childTerminations.set(child, termination)
await termination
}
async dispose(): Promise<void> {
this.pendingQuestions.clear()
await Promise.all(
[...this.children].map((child) => this.terminate(child))
)
this.children.clear()
}
}