feat: show runtime checklists and support remote Continue

Display native OpenCode and Continue checklists alongside scheduled tasks, preserving request ownership, explicit clears and saved progress. Bundle both remote runtimes and support managed Continue installation, model routing, cancellation and restart continuation.

Validation: real local and Linux remote model checks, full Electron remote Continue flows, typecheck and lint passed. Full suite: 4707 passed, 67 skipped, one OpenCode compaction failure passed targeted rerun.

Release note: Display native execution checklists above conversations and support managed remote Continue projects with saved progress and cancellation recovery.
This commit is contained in:
mesalogo
2026-09-19 10:47:27 +08:00
parent 164e89253a
commit c7a93803c9
78 changed files with 2266 additions and 238 deletions
+2
View File
@@ -228,12 +228,14 @@ jobs:
set -euo pipefail
output="$RUNNER_TEMP/goodbuddy-agent-output"
mkdir -p "$output"
continue_archive="$(node build/acquire-continue-runtime.cjs "$RUNNER_TEMP/goodbuddy-continue-runtime" "$AGENT_ARCH" "$AGENT_PLATFORM")"
node build/agent-package.cjs build \
--platform "$AGENT_PLATFORM" \
--arch "$AGENT_ARCH" \
--minimum-desktop-version "$(node -p "require('./package.json').version")" \
--node-archive "$RUNNER_TEMP/goodbuddy-agent-release-inputs/$NODE_ARCHIVE" \
--runtime-archive "$RUNNER_TEMP/goodbuddy-agent-release-inputs/$OPENCODE_ARCHIVE" \
--continue-archive "$continue_archive" \
--output "$output/goodbuddy-agent-$AGENT_VERSION-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" \
> "$output/result.json"
node - "$output/result.json" "$AGENT_ARCH" "$AGENT_VERSION" <<'NODE'
+2
View File
@@ -305,11 +305,13 @@ jobs:
rm -rf "$directory"
mkdir -p "$directory"
agent_version="$(node -p "require('./agent-runtime-lock.json').agentVersion")"
continue_archive="$(node build/acquire-continue-runtime.cjs "$RUNNER_TEMP/goodbuddy-continue-runtime" "$AGENT_ARCH" "$AGENT_PLATFORM")"
node build/agent-ci-bundle.cjs \
--platform "$AGENT_PLATFORM" \
--arch "$AGENT_ARCH" \
--node-archive "$RUNNER_TEMP/goodbuddy-agent-node/$NODE_ARCHIVE" \
--opencode-archive "$RUNNER_TEMP/goodbuddy-opencode-runtime/$OPENCODE_ARCHIVE" \
--continue-archive "$continue_archive" \
--output-directory "$directory/bundle" \
--archive "$directory/goodbuddy-agent-$agent_version-$AGENT_PLATFORM-$AGENT_ARCH.gbagent" |
tee "$directory/result.json"
+9 -1
View File
@@ -313,7 +313,7 @@ OSS 先写 `agent-releases/v<version>/` 不可变对象,GitHub Release 公开
`agent-catalog.json` 与 `agent-catalog.sig`,避免客户端观察到跨版本组合。Agent Release 必须使用
`--latest=false`,不得改变桌面 Release 的 Latest 标记。
### 远程 OpenCode Runtime 基础
### 远程 Runtime 工件
`remote-runtime-lock.json` 独立锁定首个远程 Runtime:OpenCode 1.18.29 的 Linux x64
baseline 与 arm64 官方包 integrity、`bin/opencode` 入口和固定 `acp` 参数。它不同于
@@ -321,6 +321,14 @@ baseline 与 arm64 官方包 integrity、`bin/opencode` 入口和固定 `acp`
的 Koffi 版本。Agent bundle 将 Koffi loader 作为 external module,并只携带目标
Linux 架构的 glibc/musl 原生 binding;构建与导入都会校验其 ELF 架构和 MIT 许可证。
lock 同时固定 `@continuedev/cli@1.5.47` 的 npm integrity。CN 工件使用
`lib/continue/dist/cn.js`,由 Agent 中的 HTTP-to-ACP helper 启动,不能传入原生 `acp`
参数。`remote-runtime:build` 与 `build/agent-package.cjs build` 接受
`--runtime-id continue`;默认 Agent 组包通过 `--continue-archive` 同时携带 OC/CN。
CN 的 JS payload 通过文件摘要与签名校验,执行它的 Node 仍来自 Agent 固定包。
默认 workflow 已接入联合组包;开发包与完整桌面到 Linux 的实测见
[CN 部署记录](./docs/features/remote-host/runtime-checklist-validation.md#cn-部署与生命周期)。
远程 Runtime 工件由以下命令独立管理:
```bash
+8
View File
@@ -805,6 +805,14 @@ records are listed separately and do not introduce another feature status.
### Open interfaces, team collaboration, and remote execution
- [x] **Remote Continue delivery**: Current source supports a
managed CN 1.5.47 package, Agent HTTP-to-ACP adaptation, model routing, project
selection, and native checklists. Linux x64 development packages passed real
model, cancellation and reconnection checks. Default packages include OC/CN;
Windows desktop-to-Linux checks cover installation, checklist updates, long lists,
cancellation and restart recovery. Packages are not yet published; see the
[validation record](./docs/features/remote-host/runtime-checklist-validation.md).
- [x] **Remote task delegation**: Enabled only after the user explicitly
configures an endpoint and token, uses HTTP(S) according to the global
private-network compatibility setting, and writes results to a persistent
+2
View File
@@ -140,6 +140,8 @@
### 开放接口、团队协作与远程执行
- [x] **远程 Continue 交付**:已接入 CN 1.5.47 受管包、Agent HTTP-to-ACP 适配、模型路由、项目选择和原生清单。默认包同时携带 OC/CN;Windows 桌面到 Linux x64 已通过安装、真实模型清单更新、长清单、取消及重启恢复验证。开发包尚未发布,详见[验证记录](./docs/features/remote-host/runtime-checklist-validation.md)。
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
+18
View File
@@ -0,0 +1,18 @@
const { createHash } = require('node:crypto')
const { mkdirSync, readFileSync } = require('node:fs')
const { join, resolve } = require('node:path')
const { execFileSync } = require('node:child_process')
const { readRemoteRuntimeLock, resolveLockedRuntimeInput } = require('./remote-runtime-bundle.cjs')
const [directory, architecture, platform = 'linux'] = process.argv.slice(2)
if (!directory) throw new Error('Continue archive destination is required')
const input = resolveLockedRuntimeInput(readRemoteRuntimeLock(), architecture, platform, 'continue')
const destination = resolve(directory)
mkdirSync(destination, { recursive: true })
execFileSync('npm', ['pack', `${input.packageName}@${input.version}`, '--ignore-scripts',
'--registry=https://registry.npmjs.org', '--pack-destination', destination], { stdio: 'ignore' })
const archive = join(destination, input.archive)
if (`sha512-${createHash('sha512').update(readFileSync(archive)).digest('base64')}` !== input.integrity) {
throw new Error('Continue archive integrity mismatch')
}
process.stdout.write(`${archive}\n`)
+22 -4
View File
@@ -152,8 +152,8 @@ function assertPackageDescriptor(descriptor) {
descriptor.files.length > 50_000 ||
typeof descriptor.remoteRuntime !== 'object' ||
descriptor.remoteRuntime === null ||
descriptor.remoteRuntime.runtimeId !== 'opencode' ||
descriptor.remoteRuntime.provider !== 'opencode' ||
!['opencode', 'continue'].includes(descriptor.remoteRuntime.runtimeId) ||
descriptor.remoteRuntime.provider !== descriptor.remoteRuntime.runtimeId ||
!semanticVersionPattern.test(
descriptor.remoteRuntime.version ?? ''
) ||
@@ -164,6 +164,7 @@ function assertPackageDescriptor(descriptor) {
throw new Error('Agent package descriptor is invalid')
}
assertProtocol(descriptor.agentProtocol, 'Agent protocol')
assertAdditionalRuntimes(descriptor)
assertProtocol(
descriptor.remoteRuntime.protocol,
'Remote Runtime protocol'
@@ -592,8 +593,8 @@ function assertCatalog(catalog) {
entry.formatVersion !== 1 ||
entry.product !== 'GoodBuddy' ||
entry.component !== 'agent' ||
entry.remoteRuntime?.runtimeId !== 'opencode' ||
entry.remoteRuntime?.provider !== 'opencode' ||
!['opencode', 'continue'].includes(entry.remoteRuntime?.runtimeId) ||
entry.remoteRuntime?.provider !== entry.remoteRuntime?.runtimeId ||
!semanticVersionPattern.test(
entry.remoteRuntime?.version ?? ''
) ||
@@ -604,6 +605,7 @@ function assertCatalog(catalog) {
throw new Error('Agent catalog entry is invalid')
}
assertProtocol(entry.agentProtocol, 'Agent protocol')
assertAdditionalRuntimes(entry)
assertProtocol(
entry.remoteRuntime.protocol,
'Remote Runtime protocol'
@@ -634,6 +636,18 @@ function readVerifiedCatalog(catalogPath, signaturePath, registry) {
return catalog
}
function assertAdditionalRuntimes(value) {
if (value.additionalRuntimes === undefined) return
if (!Array.isArray(value.additionalRuntimes) || value.additionalRuntimes.length > 1) throw new Error('Invalid additional Runtimes')
for (const runtime of value.additionalRuntimes) {
if (!runtime || !['opencode', 'continue'].includes(runtime.runtimeId) || runtime.runtimeId === value.remoteRuntime.runtimeId ||
runtime.provider !== runtime.runtimeId || !semanticVersionPattern.test(runtime.version ?? '') || !/^sha256:[a-f0-9]{64}$/u.test(runtime.bundleDigest ?? '')) {
throw new Error('Invalid additional Runtime identity')
}
assertProtocol(runtime.protocol, 'Additional Runtime protocol')
}
}
function parseVersion(value) {
const match =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/u
@@ -710,6 +724,10 @@ function createCatalog(options) {
...(options.darwinArm64Package ? [readPackageMetadata(options.darwinArm64Package, registry)] : [])
]
const [first, second] = packages
const additionalIdentity = descriptor => canonicalJson((descriptor.additionalRuntimes ?? []).map(({ bundleDigest: _digest, ...identity }) => { void _digest; return identity }))
if (packages.some(value => additionalIdentity(value.descriptor) !== additionalIdentity(first.descriptor))) {
throw new Error('Agent package matrix has inconsistent additional Runtimes')
}
const {
bundleDigest: _firstRuntimeDigest,
...firstRuntimeIdentity
+9
View File
@@ -38,6 +38,7 @@ function parseArguments(argv) {
'--platform',
'--node-archive',
'--opencode-archive',
'--continue-archive',
'--output-directory',
'--archive'
].includes(argument)
@@ -63,6 +64,7 @@ function parseArguments(argv) {
for (const key of [
'nodearchive',
'opencodearchive',
'continuearchive',
'outputdirectory',
'archive'
]) {
@@ -77,6 +79,7 @@ function parseArguments(argv) {
platform,
nodeArchive: resolve(options.nodearchive),
opencodeArchive: resolve(options.opencodearchive),
continueArchive: resolve(options.continuearchive),
outputDirectory: resolve(options.outputdirectory),
archive: resolve(options.archive)
}
@@ -160,6 +163,11 @@ async function buildCiAgentBundle(options) {
registry: signing.registry,
testSigningIdentity: identity
})
const continueRuntime = buildRuntimeBundle({
platform, runtimeId: 'continue', lock: runtimeLock, projectRoot, architecture: options.arch,
runtimeArchive: options.continueArchive, outputRoot: runtimeRoot,
registry: signing.registry, testSigningIdentity: identity
})
const assemble = (archive) =>
assembleAgentPackage({
platform,
@@ -170,6 +178,7 @@ async function buildCiAgentBundle(options) {
output: archive,
agentBundle: options.outputDirectory,
runtimeBundle: runtime.bundleDirectory,
additionalRuntimeBundles: [continueRuntime.bundleDirectory],
agentLock: lock,
runtimeLock,
registry: signing.registry,
+43 -6
View File
@@ -142,7 +142,7 @@ function packageFileMode(path) {
if (path.startsWith('agent/')) {
return expectedAgentManifestMode(path.slice('agent/'.length))
}
return /\/bin\/opencode$/u.test(path) ? '0755' : '0644'
return /(?:\/bin\/opencode|\/lib\/continue\/dist\/cn\.js)$/u.test(path) ? '0755' : '0644'
}
function stagePackagePayload(options) {
@@ -153,10 +153,13 @@ function stagePackagePayload(options) {
join(
staging,
'runtime',
'opencode',
options.runtimeId ?? 'opencode',
basename(options.runtimeBundle)
)
)
for (const runtime of options.additionalRuntimes ?? []) {
copyTree(runtime.bundleDirectory, join(staging, 'runtime', runtime.manifest.runtimeId, basename(runtime.bundleDirectory)))
}
for (const [value, destination] of [
[
options.registry,
@@ -420,13 +423,22 @@ function assembleAgentPackage(options) {
join(tmpdir(), `goodbuddy-agent-package-${target}-`)
)
try {
const additionalRuntimes = (options.additionalRuntimeBundles ?? []).map(directory => verifyRuntimeBundle(directory, {
platform, projectRoot, architecture, registry, lock: runtimeLock,
verificationEnvironment: options.testSigningIdentity ? 'test' : 'production'
}))
if (new Set([runtime, ...additionalRuntimes].map(value => value.manifest.runtimeId)).size !== additionalRuntimes.length + 1) {
throw new Error('Duplicate packaged Runtime')
}
stagePackagePayload({
staging,
registry,
agentLock,
runtimeLock,
agentBundle: resolve(options.agentBundle),
runtimeBundle: runtime.bundleDirectory
runtimeBundle: runtime.bundleDirectory,
runtimeId: runtime.manifest.runtimeId,
additionalRuntimes
})
const payload = payloadFiles(staging)
const initial = {
@@ -447,6 +459,10 @@ function assembleAgentPackage(options) {
bundleDigest: runtime.manifest.bundleDigest,
protocol: runtime.manifest.protocol
},
...(additionalRuntimes.length ? { additionalRuntimes: additionalRuntimes.map(({ manifest }) => ({
runtimeId: manifest.runtimeId, provider: manifest.provider, version: manifest.runtimeVersion,
bundleDigest: manifest.bundleDigest, protocol: manifest.protocol
})) } : {}),
contentDigest: `sha256:${'0'.repeat(64)}`,
files: payload.files
}
@@ -510,6 +526,7 @@ function buildAgentPackage(options) {
: { signingIdentity })
})
const runtime = buildRuntimeBundle({
runtimeId: options.runtimeId,
platform: options.platform ?? 'linux',
projectRoot,
architecture: options.architecture,
@@ -521,6 +538,16 @@ function buildAgentPackage(options) {
? { testSigningIdentity: options.testSigningIdentity }
: { signingIdentity })
})
const additionalRuntimeBundles = []
if (!options.runtimeId || options.runtimeId === 'opencode') {
if (!options.continueArchive) throw new Error('Default Agent package requires the locked Continue archive')
additionalRuntimeBundles.push(buildRuntimeBundle({
runtimeId: 'continue', platform: options.platform ?? 'linux', projectRoot,
architecture: options.architecture, runtimeArchive: options.continueArchive, outputRoot: runtimeRoot,
registry, ...(options.runtimeLock ? { lock: options.runtimeLock } : {}),
...(options.testSigningIdentity ? { testSigningIdentity: options.testSigningIdentity } : { signingIdentity })
}).bundleDirectory)
}
return assembleAgentPackage({
...options,
projectRoot,
@@ -529,7 +556,8 @@ function buildAgentPackage(options) {
? {}
: { signingIdentity }),
agentBundle,
runtimeBundle: runtime.bundleDirectory
runtimeBundle: runtime.bundleDirectory,
additionalRuntimeBundles
})
} finally {
rmSync(agentRoot, { recursive: true, force: true })
@@ -555,6 +583,9 @@ function parseArguments(argv) {
'--minimum-desktop-version',
'--node-archive',
'--runtime-archive',
'--runtime-id',
'--continue-archive',
'--continue-bundle',
'--agent-bundle',
'--runtime-bundle',
'--output'
@@ -579,11 +610,15 @@ function parseArguments(argv) {
throw new Error('Agent package arguments are incomplete')
}
const common = {
runtimeId: options.runtimeId ?? 'opencode',
platform: options.platform ?? 'linux',
architecture: options.arch,
minimumDesktopVersion: options.minimumDesktopVersion,
output: resolve(options.output)
}
if (!['opencode', 'continue'].includes(common.runtimeId)) {
throw new Error('Unsupported Agent package Runtime')
}
targetName(common.architecture, common.platform)
if (command === 'build') {
if (!options.nodeArchive || !options.runtimeArchive) {
@@ -593,7 +628,8 @@ function parseArguments(argv) {
command,
...common,
nodeArchive: resolve(options.nodeArchive),
runtimeArchive: resolve(options.runtimeArchive)
runtimeArchive: resolve(options.runtimeArchive),
continueArchive: options.continueArchive ? resolve(options.continueArchive) : undefined
}
}
if (!options.agentBundle || !options.runtimeBundle) {
@@ -603,7 +639,8 @@ function parseArguments(argv) {
command,
...common,
agentBundle: resolve(options.agentBundle),
runtimeBundle: resolve(options.runtimeBundle)
runtimeBundle: resolve(options.runtimeBundle),
additionalRuntimeBundles: options.continueBundle ? [resolve(options.continueBundle)] : []
}
}
+68 -32
View File
@@ -49,6 +49,17 @@ const profileDigests = Object.freeze({
"sha256:ac7696abe504bbea444d8ad80dd8faa96437e6dbb27ed987ce46e1045f3dd365",
acp: "sha256:0e3764ab897258bc0234162357c2eefc3faeb9918080ae81b60e2084ed7996a6",
});
function runtimeProfileDigests(runtimeId) {
if (runtimeId === 'opencode') return profileDigests;
if (runtimeId !== 'continue') throw new Error('Unsupported Runtime');
return {
adapter: `sha256:${sha256('GoodBuddy Continue HTTP ACP facade v1')}`,
acp: `sha256:${sha256(canonicalJson({ protocolVersion: 1, capabilities: {
loadSession: true, mcpCapabilities: { http: true }, promptCapabilities: { image: true },
sessionCapabilities: { resume: {}, close: {} }
} }))}`
};
}
const fixedLimits = Object.freeze({
// Zero leaves prompt duration to its requested deadline and cancellation.
maximumPromptRuntimeMilliseconds: 0,
@@ -365,9 +376,9 @@ function validateManifestShape(manifest) {
if (
manifest?.formatVersion !== 2 ||
manifest.product !== "GoodBuddy" ||
manifest.runtimeId !== "opencode" ||
manifest.runtimeVersion !== "1.18.29" ||
manifest.provider !== "opencode" ||
!['opencode', 'continue'].includes(manifest.runtimeId) ||
manifest.runtimeVersion !== (manifest.runtimeId === 'continue' ? '1.5.47' : '1.18.29') ||
manifest.provider !== manifest.runtimeId ||
!(
manifest.platform === "linux" ||
(manifest.platform === "darwin" && manifest.architecture === "arm64")
@@ -396,10 +407,10 @@ function validateManifestShape(manifest) {
throw new Error("Runtime manifest limits contract is invalid");
}
if (
manifest.entrypoint?.identity !== "opencode-acp" ||
manifest.entrypoint.path !== "bin/opencode" ||
manifest.entrypoint?.identity !== `${manifest.runtimeId}-acp` ||
manifest.entrypoint.path !== (manifest.runtimeId === 'continue' ? 'lib/continue/dist/cn.js' : 'bin/opencode') ||
!/^[a-f0-9]{64}$/u.test(manifest.entrypoint.sha256) ||
JSON.stringify(manifest.entrypoint.argvPrefix) !== JSON.stringify(["acp"])
JSON.stringify(manifest.entrypoint.argvPrefix) !== JSON.stringify(manifest.runtimeId === 'continue' ? [] : ['acp'])
) {
throw new Error("Runtime manifest entrypoint is invalid");
}
@@ -436,13 +447,14 @@ function assertManifestMatchesLock(
architecture,
platform = "linux",
) {
const expected = lock.runtimes.opencode;
const expected = lock.runtimes[manifest.runtimeId];
if (!expected) throw new Error('Runtime is not locked');
const target =
expected.targets[
platform === "linux" ? architecture : targetName(architecture, platform)
];
if (
manifest.runtimeId !== "opencode" ||
manifest.runtimeId !== expected.provider ||
manifest.runtimeVersion !== expected.version ||
manifest.provider !== expected.provider ||
manifest.platform !== platform ||
@@ -457,8 +469,8 @@ function assertManifestMatchesLock(
JSON.stringify(expected.allowedEnvironmentNames) ||
manifest.protocol?.major !== expected.protocol.major ||
manifest.protocol?.minor !== expected.protocol.minor ||
manifest.adapterDigest !== profileDigests.adapter ||
manifest.acpCapabilitiesDigest !== profileDigests.acp
manifest.adapterDigest !== runtimeProfileDigests(manifest.runtimeId).adapter ||
manifest.acpCapabilitiesDigest !== runtimeProfileDigests(manifest.runtimeId).acp
) {
throw new Error(
"Runtime manifest does not match the locked OpenCode profile",
@@ -572,7 +584,7 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
) {
throw new Error("Runtime entrypoint payload is missing");
}
assertElfArchitecture(
if (manifest.runtimeId === 'opencode') assertElfArchitecture(
join(bundleDirectory, ...manifest.entrypoint.path.split("/")),
architecture,
platform,
@@ -584,8 +596,8 @@ function verifyBundleDirectory(bundleDirectoryInput, options = {}) {
};
}
function lockedArchive(lock, architecture, archivePath, platform = "linux") {
const input = resolveLockedRuntimeInput(lock, architecture, platform);
function lockedArchive(lock, architecture, archivePath, platform = "linux", runtimeId = 'opencode') {
const input = resolveLockedRuntimeInput(lock, architecture, platform, runtimeId);
if (!archivePath || !existsSync(archivePath)) {
throw new Error(`Locked OpenCode archive is required: ${input.archive}`);
}
@@ -603,14 +615,15 @@ function lockedArchive(lock, architecture, archivePath, platform = "linux") {
`Runtime archive integrity mismatch for ${targetName(architecture)}`,
);
}
return lock.runtimes.opencode.targets[
return lock.runtimes[runtimeId].targets[
platform === "linux" ? architecture : targetName(architecture, platform)
];
}
function resolveLockedRuntimeInput(lock, architecture, platform = "linux") {
function resolveLockedRuntimeInput(lock, architecture, platform = "linux", runtimeId = 'opencode') {
targetName(architecture, platform);
const runtime = lock.runtimes.opencode;
const runtime = lock.runtimes[runtimeId];
if (!runtime) throw new Error('Runtime is not locked');
const target =
runtime.targets[
platform === "linux" ? architecture : targetName(architecture, platform)
@@ -619,7 +632,7 @@ function resolveLockedRuntimeInput(lock, architecture, platform = "linux") {
packageName: target.package,
version: runtime.version,
integrity: target.integrity,
archive: `${target.package}-${runtime.version}.tgz`,
archive: `${target.package.replace('@', '').replace('/', '-')}-${runtime.version}.tgz`,
};
}
@@ -636,24 +649,24 @@ function createManifest(bundleDirectory, metadata) {
path,
size: statSync(filePath).size,
sha256: sha256FileSync(filePath),
mode: path === "bin/opencode" ? "0755" : "0644",
mode: path === metadata.runtime.entrypoint ? "0755" : "0644",
};
});
const entrypoint = files.find((file) => file.path === "bin/opencode");
const entrypoint = files.find((file) => file.path === metadata.runtime.entrypoint);
if (!entrypoint) {
throw new Error("Runtime entrypoint is missing");
}
const initial = {
formatVersion: 2,
product: "GoodBuddy",
runtimeId: "opencode",
runtimeId: metadata.runtime.provider,
runtimeVersion: metadata.runtime.version,
provider: metadata.runtime.provider,
platform: metadata.platform ?? "linux",
architecture: metadata.architecture,
signingKeyId: metadata.signingKeyId,
bundleDigest: `sha256:${"0".repeat(64)}`,
adapterDigest: profileDigests.adapter,
adapterDigest: runtimeProfileDigests(metadata.runtime.provider).adapter,
sourcePackage: metadata.sourcePackage,
entrypoint: {
identity: metadata.runtime.entrypointIdentity,
@@ -664,15 +677,15 @@ function createManifest(bundleDirectory, metadata) {
files,
licenses: [
{
package: "opencode-ai",
package: metadata.runtime.provider === 'continue' ? '@continuedev/cli' : 'opencode-ai',
version: metadata.runtime.version,
spdx: "MIT",
path: "licenses/opencode-MIT.txt",
spdx: metadata.runtime.provider === 'continue' ? 'Apache-2.0' : 'MIT',
path: metadata.runtime.provider === 'continue' ? 'licenses/continue-Apache-2.0.txt' : 'licenses/opencode-MIT.txt',
},
],
allowedEnvironmentNames: metadata.runtime.allowedEnvironmentNames,
protocol: metadata.runtime.protocol,
acpCapabilitiesDigest: profileDigests.acp,
acpCapabilitiesDigest: runtimeProfileDigests(metadata.runtime.provider).acp,
limits: fixedLimits,
};
return {
@@ -685,11 +698,11 @@ function defaultOutputRoot(projectRoot = root) {
return join(projectRoot, ".remote-runtime-resources");
}
function bundleDirectory(outputRoot, architecture, digest, platform = "linux") {
function bundleDirectory(outputRoot, architecture, digest, platform = "linux", runtimeId = 'opencode') {
return join(
outputRoot,
targetName(architecture, platform),
"opencode",
runtimeId,
digest.slice("sha256:".length),
);
}
@@ -699,12 +712,14 @@ function buildRuntimeBundle(options) {
const architecture = options.architecture;
const platform = options.platform ?? "linux";
const lock = options.lock ?? readRemoteRuntimeLock(projectRoot);
const runtime = lock.runtimes.opencode;
const runtimeId = options.runtimeId ?? 'opencode';
const runtime = lock.runtimes[runtimeId];
const sourcePackage = lockedArchive(
lock,
architecture,
options.runtimeArchive,
platform,
runtimeId,
);
const registry = options.registry ?? readTrustedKeyRegistry(projectRoot);
const signingIdentity =
@@ -730,6 +745,7 @@ function buildRuntimeBundle(options) {
sync: true,
strict: true,
filter(path) {
if (runtimeId === 'continue') return path === 'package/package.json' || path.startsWith('package/dist/') || path === 'package/LICENSE';
return (
path === "package/package.json" || path === "package/bin/opencode"
);
@@ -753,6 +769,15 @@ function buildRuntimeBundle(options) {
"OpenCode source package version does not match the lock",
);
}
if (runtimeId === 'continue') {
mkdirSync(join(staging, 'lib'), { mode: 0o700 });
cpSync(join(extracted, 'package'), join(staging, 'lib', 'continue'), { recursive: true });
for (const file of listFiles(staging)) chmodSync(file, 0o644);
chmodSync(join(staging, runtime.entrypoint), 0o755);
mkdirSync(join(staging, 'licenses'), { mode: 0o700 });
// The pinned CN package declares Apache-2.0 but omits the license text.
copyFileSync(join(projectRoot, 'node_modules', '@agentclientprotocol', 'sdk', 'LICENSE'), join(staging, 'licenses', 'continue-Apache-2.0.txt'));
} else {
mkdirSync(join(staging, "bin"), { mode: 0o700 });
copyFileSync(
join(extracted, "package", "bin", "opencode"),
@@ -780,6 +805,7 @@ function buildRuntimeBundle(options) {
join(staging, "licenses", "opencode-MIT.txt"),
);
chmodSync(join(staging, "licenses", "opencode-MIT.txt"), 0o644);
}
const manifest = createManifest(staging, {
platform,
architecture,
@@ -811,6 +837,7 @@ function buildRuntimeBundle(options) {
architecture,
manifest.bundleDigest,
platform,
runtimeId,
);
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
if (existsSync(destination)) {
@@ -849,6 +876,8 @@ function buildRuntimeBundle(options) {
function createRuntimeArchive(bundleDirectoryInput, archivePath) {
const directory = resolve(bundleDirectoryInput);
const manifest = readJson(join(directory, manifestFileName), 'Runtime manifest');
const modes = new Map(manifest.files.map(file => [file.path, parseInt(file.mode, 8)]));
const entries = listFiles(directory)
.map((filePath) => posixRelative(directory, filePath))
.sort(compareUtf8);
@@ -862,6 +891,9 @@ function createRuntimeArchive(bundleDirectoryInput, archivePath) {
portable: true,
noPax: true,
mtime: new Date(0),
onWriteEntry(entry) {
if (entry.stat) entry.stat.mode = modes.get(entry.path) ?? 0o644;
},
},
entries,
);
@@ -896,6 +928,8 @@ function importRuntimeArchive(archivePath, options) {
outputRoot,
architecture,
verified.manifest.bundleDigest,
verified.manifest.platform,
verified.manifest.runtimeId,
);
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
if (existsSync(destination)) {
@@ -917,8 +951,8 @@ function importRuntimeArchive(archivePath, options) {
}
}
function findOnlyBundle(outputRoot, architecture) {
const runtimeRoot = join(outputRoot, targetName(architecture), "opencode");
function findOnlyBundle(outputRoot, architecture, runtimeId = 'opencode') {
const runtimeRoot = join(outputRoot, targetName(architecture), runtimeId);
const entries = existsSync(runtimeRoot)
? readdirSync(runtimeRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
@@ -954,6 +988,7 @@ function parseArguments(argv) {
const allowed = new Set([
"--arch",
"--runtime-archive",
"--runtime-id",
"--archive",
"--output-root",
"--bundle",
@@ -997,6 +1032,7 @@ function main(argv = process.argv.slice(2)) {
if (options.command === "build") {
const verified = buildRuntimeBundle({
architecture: options.arch,
runtimeId: options.runtimeId,
runtimeArchive: resolve(options.runtimeArchive ?? ""),
outputRoot,
});
@@ -1006,7 +1042,7 @@ function main(argv = process.argv.slice(2)) {
root,
"dist",
"remote-runtime-bundles",
`goodbuddy-opencode-${targetName(options.arch)}.tar`,
`goodbuddy-${options.runtimeId ?? 'opencode'}-${targetName(options.arch)}.tar`,
);
createRuntimeArchive(verified.bundleDirectory, archivePath);
console.log(
@@ -1027,7 +1063,7 @@ function main(argv = process.argv.slice(2)) {
verifyBundleDirectory(
options.bundle
? resolve(options.bundle)
: findOnlyBundle(outputRoot, options.arch),
: findOnlyBundle(outputRoot, options.arch, options.runtimeId),
{ architecture: options.arch },
);
console.log(`Remote Runtime bundle verified: ${targetName(options.arch)}`);
@@ -1,5 +1,93 @@
# 工作栏实现与验证进度
## 2026-09-19 远程 CN 完整桌面验证
默认联合开发包已包含 OC/CN,并通过桌面导入、验签、传输至共享 Linux x64、安装及
CN 项目创建。真实输入框发送产生清单更新,顶部和消息保存一致;退出重启后恢复完成
与待办状态。修复实测发现的 CN 冷会话续发及重复序号问题,复验重启续发、点击停止和
取消后再次发送通过。证据、调用口径及平台边界见
[远程记录](../remote-host/runtime-checklist-validation.md#2026-09-19-联合包与完整桌面验收)。
任务与清单分别展开,容器高度有界,键盘可进入清单滚动区;状态图标不再影响文字
基线。此前真实 Electron 视觉矩阵覆盖同时展开、长列表、窄窗口、浅深主题及 200% 缩放。
本轮实际桌面截图进一步核对真实远程清单和取消状态。开发包未发布,非 x64 平台未实测。
远程 CN 真实长清单为 55 项、5,601 字符,展示与落库一致;清空后退出重启仍保持空数组。
本轮 Agent gateway 账本记录 16 次模型请求,其中 15 次完成、1 次取消结果未确认。
测试 Host 目录、所属进程及本地隔离 profile 已清理。全量 4707 passed、67 skipped、
1 failed;本地 OC 压缩失败项与远程 ACP、项目选择、清单组件专项复验共 136 passed。
最终 typecheck、lint 和差异检查通过。
## 2026-09-18 Remote Continue Checklist
CL-P1、CL-P3 的远程 CN 源码已接入受管包、HTTP 宿主到 ACP 的适配、模型桥、项目
校验和聊天选择。CN 原生成功 Checklist 经既有 semantic transcript 传递统一清单,
不增加数据库或重连协议。修复聊天页把远程 CN 选择恢复为 OC、隐藏 Continue 菜单,
以及复合包验包默认读取 OC payload 的遗漏。
Linux x64 当前源码与开发包通过验包、prepare/commit、Agent bootstrap/health、CN
激活、真实模型清单新增/清空、未 ACK 断线重放、取消和同对话 Ask 续发。本轮新增
17 个外部模型 HTTP 请求;精确分项、临时探针、已清理的 Host 目录及剩余验收见
[远程执行清单验证记录](../remote-host/runtime-checklist-validation.md#cn-部署与生命周期)。
本阶段使用单 Runtime 开发包;后续联合包与完整桌面验证见上方 2026-09-19 记录。
远程相关测试 27 文件、374 项通过;最终项目选择、顶部组件和安装管理 41 项、App
远程选择与继承 3 项、CN 复合包安装专项通过。typecheck 和 lint 通过。
全量测试完成一轮:4665 passed、67 skipped、6 failed。其中 CN 新增包测试的文件名
错误已修正并复验;其余 5 项为 ActivityPanel/activity-store 的引用复用断言,本轮未
修改这些文件。最终 typecheck、lint 和专项结果见上述远程记录。
并行工作区更新后,ActivityPanel/activity-store 的 39 项专项复验全部通过。
## 2026-09-18 Local Runtime Checklist Backend
CL-P1 and the backend portion of CL-P2 are implemented. Shared contracts use
`runtime-checklist.ts`, `AgentEvent.type = checklist`, and message field
`runtimeChecklist`. Local OC validates parent Session, submitted message identity,
assistant parent identity and native tool arguments. CN extracts before truncation,
commits successful calls once, excludes startup history and retains explicit clears.
Message metadata serialization, reconstruction, remote replay deduplication and
terminalization preserve the field without a database migration. Existing IPC
transport already forwards these events. Renderer and `acp-remote-runtime` were
outside this backend change; no shared Linux Host acceptance is claimed here.
The focused backend suite passed 275 tests across shared checklist, OC/CN Runtime,
host adapter, permissions, real-binary lifecycle and assistant database tests.
Coverage includes OC reused Sessions and stale/child updates, native event ordering,
CN long lists and omitted success arguments, failure preservation, history dedupe,
empty lists, database reopen and remote event replay/terminalization.
`npm run typecheck` and `npm run lint` passed on the final validation run.
The full `npm test` run completed with 4604 passed, 67 skipped and 9 failed:
one Agent offline-package inventory test exceeded 60 seconds; eight failures were
in concurrently changing Magic Notes renderer tests (including PDF `DOMMatrix`
availability and canvas UI expectations). These files were not modified here.
Real model evidence on Windows, using production local adapters and isolated
temporary profiles:
| Runtime | Version | Submitted tasks | Actual model HTTP POSTs | Observed result |
| --- | --- | --- | --- | --- |
| CN | 1.5.47 | 1 | 3 | Two successful Checklist calls, two structured snapshots; A pending then completed, B pending |
| OC investigation | 1.18.29 | 2 | 6 | Native tools succeeded; revealed todo.updated precedes running input, initially no projected snapshot |
| OC corrected ordering | 1.18.29 | 1 | 3 | Two native updates projected; done retained |
| OC final sortable message ID | 1.18.29 | 1 | 3 | Two native updates projected; assistant parent IDs matched; done retained |
Total: **15 actual external model HTTP POSTs**, all HTTP 200, **5 submitted tasks**.
The unavailable old OC server probe and two isolated startup attempts dispatched
zero model requests. Startup attempts were blocked by the probe's fetch wrapper
not accepting Request objects; correcting that wrapper required no product change.
The proxy allowed only the exact native checklist tool, with a per-run request cap.
No business-file tools ran. Saved production settings were read through isolated
credential handling; the existing explicit test connection was used in memory
because its endpoint/model tuple differed from the saved connection. Credentials
were not printed, original settings were unchanged, and isolated run data was removed.
The real probes verified adapter extraction and OC Runtime events, not desktop
rendering. CN Runtime event forwarding and persistence were verified by tests.
Long lists, clears, cancellation boundaries and history replay were tested with
fixtures/database tests, not exhaustively exercised through real models. Temporary
probe sources: `cn-todo-probe.ts` / `cn-todo-run.cjs` under the approved OpenCode
temporary directory; each run printed its dispatch count and structured results.
## 2026-09-17 连续请求取消与停止状态
- 复现并修复 OpenCode 迟到的会话取消中断下一条请求:同一次运行复用一个有界 abort,
@@ -1,16 +1,38 @@
# Runtime 执行清单技术设计
状态:设计阶段,尚未实现。需求、场景与顶部布局由 [PRD](./prd.md#131-runtime-执行清单)
状态(2026-09-19):本地与远程 OC/CN 提取、统一事件、消息保存和顶部展示已实现。
默认开发组包同时携带 OC/CN,远程 CN 已通过 Windows 桌面到 Linux x64 的真实模型
更新、取消、重启恢复及续发验证。验证范围与调用数见
[远程记录](../remote-host/runtime-checklist-validation.md#2026-09-19-联合包与完整桌面验收)。
需求、场景与顶部布局由 [PRD](./prd.md#131-runtime-执行清单)
维护,替换、隔离和终态规则由 [Runtime 交互边界](./runtime-interactions.md#runtime-执行清单)
维护。研究证据与交付状态见[进度](./progress.md#2026-09-15-runtime-执行清单设计未实现)。
## 1. 原生数据与提取位置
Implemented local behavior:
- OC sends a native-sortable `messageID` with both prompt and command submissions.
Only assistant messages whose `parentID` matches that request can own checklist
calls. Parent Session identity is checked independently of child-session routing.
OC 1.18.29 emits `pending`, `todo.updated`, then `running` with complete arguments.
The adapter retains native snapshots only while an owned call is active and
submits a snapshot once its validated items match that call's original input.
Calls and pending snapshots are request-local; no `session.todo` polling is used.
- CN extracts the exact `Checklist.checklist` argument in the patched native start
callback and in native history before display truncation. Candidates survive
success frames without input. A completed call commits once per call ID; failed
calls and startup history do not commit. Ordinary tool events retain their limits.
- CN accepts blank lines and `- [ ]`, `- [x]`, `- [X]` entries with nonempty content.
Empty or whitespace-only strings clear the list. Other nonempty lines invalidate
the entire update. Native 1.5.47 accepts a string without Markdown validation;
these are GoodBuddy's explicit projection rules, not additional native guarantees.
| Runtime / 路径 | 已确认格式与设计接入 |
| --- | --- |
| 本地 OC 1.18.29 | `todo.updated` 与 `session.todo` 返回完整条目列表,字段为 `content/status/priority`,无 item ID;以原生更新事件驱动本轮整份替换 |
| 本地 CN 1.5.47 | 精确原生工具 `Checklist` 的参数 `checklist` 是 Markdown 勾选列表,`- [ ]` / `- [x]` 表示未完成/完成;按一次成功调用提交完整替换 |
| 远程 OC/CN | 先核对实际部署版本、原生输出与 Agent 传递结果;只有格式已确认且来源归属明确的原生 plan entries 或精确清单工具参数可以映射,当前尚无本功能远程实测 |
| 远程 OC/CN | OC 1.18.29 通过原生 todo.updated,CN 1.5.47 通过成功 Checklist 参数进入 Agent transcript;Linux x64 清单与重放已实测,CN 同时验证取消后续发,见[远程记录](../remote-host/runtime-checklist-validation.md) |
`opencode-runtime.ts` 当前将普通工具 input 经 `boundedToolDetail(..., 4_000)` 转成
展示字符串。接入应在 SDK 原始事件层消费 `todo.updated`;如需核对 `todowrite` 参数,
@@ -24,23 +46,29 @@ CN 的提取位置在 `continue-host-adapter.ts`:宿主产生工具事件、HT
流转。超出 4,000 字符的清单需要独立验证完整性,但不扩大所有普通工具输入的展示上限。
Markdown 解析只适用于已确认的 CN `Checklist.checklist`,不扫描助手正文或其他工具
输出。空数组在统一事件中明确表示清除;CN 原生如何表达空清单,以及空白、大小写和
非条目行的实际接受规则,实施时核对后固定解析规则。未确认或无效输入保留旧清单,
不能误当清空。
输出。空数组在统一事件中明确表示清除;CN 空字符串或仅空白的字符串表示清空。
解析规则见上文;无效输入保留旧清单,不能误当清空。
## 2. 统一事件与消息归属
拟在现有 Runtime 事件联合中增加完整清单事件,承载 `requestId`、条目数组和原生来源;
条目包含 `content`、`status` 及可选 `priority`。最终命名随实现确定,不设计条目级增删
协议或稳定 item ID。空数组必须经过 schema、IPC、消息投影和保存链路,不能被“无内容”
过滤丢弃。
The shared contract is `src/shared/runtime-checklist.ts`: `runtimeChecklistSchema`
and `RuntimeChecklist { source: 'opencode' | 'continue', items }`. Each item has
`content`, `status: pending | in_progress | completed | cancelled`, and optional
`priority: low | medium | high`. `AgentEvent` carries
`{ type: 'checklist', requestId, checklist }`; conversation messages carry optional
`runtimeChecklist`. Empty arrays are valid replacements. Existing IPC event
transport forwards them without a separate channel or tool-input parsing.
Main 使用现有请求到助手消息的映射投影清单,Renderer 顶部读取该消息的最新有效状态。
本地 Renderer 按请求映射更新助手消息并沿既有保存入口落库;远程 Main 使用既有事务
投影更新消息。顶部读取所属请求消息的最新有效状态。
同一请求若同时观察到原生清单事件与其工具记录,只以前者更新清单,后者保留活动;不能
提交两次替换。原生父 Session、请求开始/结束边界需共同校验,子 Session 或旧请求事件
不得通过当前活动请求映射到新轮。
优先扩展现有消息 metadata、Runtime 事件和数据库投影,历史清单与所属助手消息一起保存。
The existing message metadata serializer and every database message reconstruction
retain `runtimeChecklist`, including remote event projection and terminalization.
No SQLite schema migration or separate checklist store is needed.
历史清单与所属助手消息一起保存。
远端沿用事件提交、消息投影和 ACK 的既有事务及去重规则;相关存储边界见
[执行记录存储](./execution-history-storage.md)。实施前核对本地会话与远程任务实际落库
字段,选择能复用的最小表示。不新建清单数据库、独立快照系统、恢复日志或持久化服务。
@@ -48,16 +76,16 @@ Main 使用现有请求到助手消息的映射投影清单,Renderer 顶部读
## 3. 远程 Agent 与 ACP 复用
本次源码检查确认以下传递位置,属于设计依据,不是本功能远程验证结果:
远程传递复用以下实现:
1. `AgentAcpConnection` 按原生 `sessionId` 分派 `sessionUpdate`;
`AgentOwnedAcpPrompt` 校验 Session 后,以 `bindingId/operationId` 将通知写入
既有 semantic transcript。
2. `ProtocolRemoteRuntimeChannel` 已有 transcript 读取/ACK、身份核对,以及 ACP
channel 恢复与重放;`AcpRemoteRuntime` 消费 `session-update` 并转换桌面事件。
3. `AcpRemoteRuntime` 当前将 `plan.entries` 及 `plan_update` 的 items 转成 `status`
字符串;markdown/URI 也只形成状态消息。工具事件按 `toolCallId` 合并 `rawInput`、
`status` 和结果,再有界保留。清单提取应发生在结构丢失或截断前。
3. OC 插件转发原生 `todo.updated`,CN 宿主将成功清单写入 `_meta.goodbuddyChecklist`;
`AcpRemoteRuntime` 校验归属和结构后产生统一清单事件。标准 OC `plan.entries` 也有
映射,但 OC 1.18.29 实测走插件事件。Markdown、URI 和普通状态文字不生成清单。
优先复用上述通知和 transcript,不另建远端清单通道。对实际 OC/CN 部署确认过的
`plan.entries`,或已确认等价的 `plan_update` items,校验条目字段和状态后转成统一
@@ -69,17 +97,25 @@ Main 使用现有请求到助手消息的映射投影清单,Renderer 顶部读
真实格式证据,不能根据 ACP 类型定义推断 OC/CN 一定会发送。ACP 是协议,Runtime 身份
仍为 OC 或 CN。
当前托管 SSH 产品及受管安装、校验、创建链路只支持 OC,详见
[远程范围](../remote-host/technical-design.md#执行清单接入范围)。远程 CN 交付需补齐
受管工件、安装与启动、项目选择/校验及原生事件接入,确认 Main 模型桥、请求归属、取消
和已有恢复流程能用于该部署。不能只放开选择菜单或使用本地 CN 调远程模型来宣称完成。
受管安装、校验、项目创建与聊天选择已接入 CN;Agent 在 Host 运行固定 CN HTTP 宿主,
复用本地适配器和已有模型桥、请求归属、取消及恢复。开发包与 Linux 验证、联合分发和
完整桌面验收的剩余范围见[远程记录](../remote-host/runtime-checklist-validation.md)。
重连按[既有 ACP 与断线规则](../remote-host/technical-design.md#acp-与断线)执行。
已接受 Prompt 使用原 binding/operation 恢复,清单从已提交消息和其后的既有事件恢复;
重复事件不重复展示。连接丢失不表示条目完成,不为恢复清单重新发送 Prompt、模型请求或
工具调用,也不新增独立重连机制。
## 4. 实施与验收
## 4. 顶部布局
`ConversationTaskStrip` 与 `RuntimeChecklistStrip` 共用顶部容器,任务在上、清单在下,
各自展开和折叠。没有 Task 时清单仍可显示;远程会话也保留清单入口。总高度不超过
45vh,各自展开内容不超过 20vh 并内部滚动,标题不收缩。清单内容区可用键盘聚焦和滚动。
状态文字与条目首行按文字基线对齐,状态图标单独居中。CN 不推断进行中或优先级。
新轮先选择本轮消息,再读取清单,不回溯寻找旧轮非空清单。后台更新保持展开状态和焦点。
## 5. 实施与验收
| 阶段 | 必须完成的工作 | 对应需求 |
| --- | --- | --- |
@@ -88,7 +124,7 @@ Main 使用现有请求到助手消息的映射投影清单,Renderer 顶部读
| CL-P3 | 确认远程 OC 原生格式,补齐远程 CN 真实部署与事件接入,复用 Agent 传递和恢复 | FR-CL1、FR-CL4 |
| CL-P4 | 本地与共享 Linux 真实 Host 逐项验收,记录版本、结果、调用口径与剩余阻塞 | FR-CL1–FR-CL4 |
以下均为待执行验收,本次文档工作不运行模型或代码测试:
以下为产品验收范围;已执行项和未覆盖项分别记录在进度与远程验证记录中:
- 本地 OC/CN 各经桌面正常发送路径产生并连续修改清单,检查新增、修改、重排、删除、
重复文本、有效清空及超过 4,000 字符的完整内容;CN 成功才替换,失败保留前值。
+1
View File
@@ -9,6 +9,7 @@
| --- | --- |
| [远程主机与 Agent 技术设计](./technical-design.md) | 当前产品语义、Agent、Runtime、Workspace、协议、生命周期和验证事实 |
| [远程环境准备技术设计](./environment-provisioning-technical-design.md) | Host 下载、GoodBuddy 传输、安装事务、更新和恢复边界 |
| [执行清单验证记录](./runtime-checklist-validation.md) | 远程 OC/CN 原生清单、CN 开发包安装、取消及断线重放的实测结果与剩余验收 |
现有文档包含产品、功能逻辑和进度事实,后续修改远程功能时再按标准职责拆分。
@@ -16,6 +16,10 @@ bootstrap 能力元数据。公开直连能力仍须完成本文末尾 GitHub/
[SSH 远程主机与 GoodBuddy Agent 实现说明](./technical-design.md)
和源码为准。
2026-09-18 补注:现有单 Runtime 复合包格式也支持 CN,安装器按包内 Runtime 身份
选择并登记 payload。CN 开发包的 Linux 验证及联合分发的剩余工作见
[执行清单验证记录](./runtime-checklist-validation.md#cn-部署与生命周期)。
## 设计结论
将安装和更新从项目切换移到 Host 管理是合理的,原因如下:
@@ -0,0 +1,225 @@
# 远程执行清单验证记录
日期:2026-09-18。对应 CL-P1、CL-P3、CL-P4;需求与统一契约见
[执行清单设计](../assistant-workbar/runtime-checklist-technical-design.md)。
本记录包含远程 Agent 到 Main Runtime 的事件验证,以及后续 Windows 完整桌面到
Linux CN 的安装、清单展示、保存、重启和取消验证;各阶段覆盖范围分别列出。
## OC 实现与原生证据
共享 Linux x64 经 `192.168.0.23` 连接,使用已保存的 SSH 凭据和固定 Host Key。
未使用 VPN 路由,不计作第二台 Host。Agent 由当前源码构建到专用测试目录,复用该机
受管 Node 24.19.0 和签名 OC 包;manifest 与二进制 `--version` 均为 1.18.29。
真实模型按提示两次调用 `todowrite`:先写入一个 `in_progress/high` 条目,再写入
`todos: []`。未适配时两次工具均成功,ACP 只有 `tool_call/tool_call_update`,
没有 `plan`。上游同版本
[acp/event.ts](https://github.com/anomalyco/opencode/blob/v1.18.29/packages/opencode/src/acp/event.ts)
也未处理 `todo.updated`,因此不能把 ACP 类型定义当作部署证据。
现有 `opencode-subagent-plugin.ts` 将精确原生 `todo.updated` 放入既有 ACP metadata,
由 Agent 原 Session 分派、operation 归属和 semantic transcript 传递。
`acp-remote-runtime.ts` 在工具截断之前消费该结构并校验 `RuntimeChecklist`。
原 Session 与当前 Prompt 不符、无效条目均不替换清单。工具活动保留;正文及泛化
工具名不触发更新。`cancelled` 原样保留,避免 ACP plan 只允许三种状态造成信息损失。
标准 OC `plan.entries` 的结构化转换也有测试,但本版本的真实成功路径使用插件原生事件。
## 共享 Host 结果
最终源码复验目录为 `/root/tmp/gb-acp-recovery-4bLjbp`,首次通过目录为
`/root/tmp/gb-acp-recovery-SJbdZQ`。首次清单抵达桌面运行时后,在其
semantic checkpoint 被消费前关闭 attach。随后恢复同一 controller、binding 和
operation,设置 `remoteRecoveryOnly: true`,没有再次提交 Prompt。
| 验证项 | 结果 |
| --- | --- |
| 原生清单新增 | sequence 1,`todo.updated`,一个 `in_progress/high` 条目 |
| 未 ACK 清单重放 | 重连后仍为 sequence 1,内容相同 |
| 原生清空 | sequence 5,`todos: []`,映射为 `{source:'opencode',items:[]}` |
| 重连后完成 | 两份清单、工具活动和终态继续到达 |
| 额外模型调用 | 重连未重发 Prompt;该次总计 3 个真实 provider 请求 |
| 清理 | Agent 停止后,测试目录所属残留进程 0;临时上传及运行目录已删除 |
更长条目、取消状态、非法输入、子 Session、标准空 plan、工具活动不重复生成清单、
相同 provenance 重放,由单元测试覆盖。远程真实模型的长清单、取消及连续多轮隔离
本次未实测,不能将单元测试记为真实 Host 验收。
一次较早的重放探测在 `done` 后才断开,之前已 ACK 的记录被正常裁剪,故不能从
sequence 0 重新读取整段历史。该次断言失败后,改为验证未 ACK 边界;产品未增加
历史快照或另一套重连机制。已 ACK 内容的恢复仍依赖 Main 已提交的消息。
另复现 OC 冷启动超过默认 15 秒控制超时:连续五次请求未进入 provider。
将 `runtime/startPrompt` 控制等待提高到至少 30 秒后,原生初始化、建会话和模型选择
完成。对应测试模拟 20 秒启动,确认仅提交一次 start;其他控制请求时限保持原设置。
## 模型调用口径
统计为测试 Agent 中实际向非 loopback provider 发出的 HTTP 请求,不将 ACP Prompt
次数、工具调用、内部 `/session` 路由请求或重连计作模型调用。读取已有 OC 模型配置,
测试未打印凭据或端点。
| 探测 | 实际模型请求 |
| --- | ---: |
| 五次冷启动定位 | 0 |
| 提高启动等待后的原始 ACP 观察 | 3 |
| 输出工具状态、确认两次 todowrite 成功 | 3 |
| 插件接入后在 done 边界重放,断言失败 | 3 |
| 最终未 ACK 边界断线重放通过 | 3 |
| 最终源码再次复验未 ACK 边界 | 3 |
| CN Linux 包与命令入口探测 | 0 |
| 合计 | 15 |
五次进入 provider 的 OC 探测均返回三个 HTTP 200;无受控假 provider 调用。
## CN 部署与生命周期
已从 npm 获取固定 `@continuedev/cli@1.5.47` 工件,压缩包为 17,945,195 bytes,
并在 Host 校验 SHA-512:
```text
gtpewV3RoIOD9dyTtKIBi1SY0VOHRu3Ehe7C/mmnswm+j34MPyrcQhQaWj/m+jdfGO4fNIKdrgGIlLso1ULDFw==
```
在 `/root/tmp/gb-checklist-upload-44f47e99-94e2-4e35-8efb-ef80b7a830ca` 解包,
以受管 Node 24.19.0 执行 `dist/cn.js --version` 得到 1.5.47;`serve --help`
确认 HTTP `/state`、`/message` 入口。`acp --help` 返回普通顶层帮助,并非 ACP
子命令;工件 `src/index.ts` 有 serve 注册、没有 acp 注册。探测未启动模型会话,
没有把包入口可运行记为远程 CN 功能完成。测试工件已从 Host 清理。
日志留存复验目录为 `/root/tmp/gb-checklist-upload-424f412c-cf2a-4857-810f-48da03285821`,
同样通过并清理,模型请求仍为 0。
以下为同日后续实施结果,取代最初调查时的未实现清单。
| 路径 | 当前实现 |
| --- | --- |
| 受管工件 | lock 固定 `@continuedev/cli@1.5.47`、SHA-512 和 `lib/continue/dist/cn.js`;组包保存 payload、manifest、签名和许可文本 |
| 工件安装 | Runtime 与复合 Agent 包校验接受 CN;桌面按 descriptor 的 Runtime ID 选择 payload,Host 复用 `prepare/commit` 和 Runtime registry |
| Agent 启动 | `continue-acp-helper.ts` 在 Host 上复用 `ContinueHostAdapter`,启动 Node 驱动的 CN HTTP 宿主,对既有 Agent 提供 ACP Session 接口 |
| 模型桥 | Main 选择 CN 模型 profile,Agent gateway 保留逐 operation 的模型配置;CN 请求携带 Session/operation 路由头,配置中使用桥接占位凭据 |
| 清单 | 宿主在原始参数层提取成功的 `Checklist.checklist`,经 `_meta.goodbuddyChecklist` 和原 semantic transcript 输出统一 `checklist` 事件 |
| 项目及聊天 | 创建、保存、安装激活、Main Runtime 工厂和聊天菜单均接受 CN;项目默认值与历史 CN 选择保持;安装身份必须匹配选择 |
| 生命周期 | ACP cancel 取消本轮宿主,已有 attach、transcript/ACK 负责断线恢复;后续 Prompt 可在同一对话继续 |
最终复验目录为 `/root/tmp/gb-cn-runtime-EALG99`。开发复合包为 188,609,399 bytes,
使用临时签名身份和隔离 trust registry,没有更改应用或 Host 的正式信任配置。
桌面验包函数、Host `prepare/commit`、安装后 Agent `bootstrap/health` 与 CN
`runtime activate` 均通过。随后当前源码 Agent 使用该安装得到的 CN payload 执行下列场景:
| 场景 | 实测结果 |
| --- | --- |
| Execute 原生清单 | sequence 3 为一个 pending 条目,sequence 6 为显式空列表 |
| 未 ACK 断线重连 | 同一 binding/operation 重放原 sequence 3,再收到空清单;不重发 Prompt |
| 取消 | 新一轮产生清单后取消,Main 返回 `RemotePromptCancelledError`;清单不改写为完成 |
| 同对话续发 | 切换 Ask 后返回 `RECOVERED`,没有旧清单事件 |
| 回收 | Agent 停止后测试所属进程 0;隔离目录和上传目录已删除 |
本轮实际向外部 provider 发出的 HTTP 请求数为 **17**:首次取消探针 5 次,修正探针对
预期取消异常的处理后 6 次,最终安装包复验 6 次。三轮分别取得 4、5、5 个 HTTP 200,
各有 1 次请求在取得响应前取消。安装探针的四次排错运行均为 0 次;重连没有额外提交。
此前已有 `cn-runtime-live.log` 记录过 3 次 CN 请求,属于接手前证据,不计入本轮 17 次,
也不并入上方原 OC 阶段的 15 次。本轮全部 CN 进程均运行于 Linux Host。
安装排错包括复用旧缓存时缺少 Koffi CJS 文件,以及探针把安装器 CLI 一起内联后污染
relay stdout。当前组包实现已包含 Koffi CJS 文件;探针改为单独导入安装器模块。
这些运行未进入模型。最终日志为临时目录中的 `cn-runtime-live.log`。
本阶段验证从 Main 受管 Runtime 工厂进入。后续完整桌面与联合开发包结果见下节。
CN 的 Linux arm64/macOS arm64 尚未实测;本轮没有提交或发布 Agent 包。
## 2026-09-19 联合包与完整桌面验收
默认 Agent 组包与 CI 构建已同时包含 OC 1.18.29、CN 1.5.47。描述符保留主 Runtime,
通过 `additionalRuntimes` 描述随包安装的 CN;目录、桌面验包、Host 安装和资源加载
均校验该列表。单 Runtime 包仍可读取,已有发布包无需迁移。联合包专项测试为
31 passed、1 skipped,包含生产安装器对单包及联合包的安装回归。
完整桌面使用当前生产 Main、Preload 和 Renderer,隔离 profile 与开发签名 registry,
保留 sandbox/context isolation。连接由既有凭据和固定 Host Key 到 `192.168.0.23`,
再转发至同机专属 SSH 服务;该服务将 HOME 限定在 `/root/tmp/gb-joint-ui-2oQ87z/home`。
本地 loopback 地址仅为 SSH 转发入口,CN 进程实际运行于 Linux。原生文件选择对话框
由测试驱动返回开发包路径;其后的导入、验签、传输和安装均走生产 IPC。
桌面完成联合包导入和“由 GoodBuddy 传输”安装后,通过实际项目表单创建 CN 远程项目,
选择 Execute,在真实输入框发送清单请求。两次 Checklist 更新后,顶部显示一项完成、
一项待办,工具活动仍可见;保存和退出重启后,原项目保留这两项状态。
重启续发首次实测失败:CN 内存原生会话已退出,但旧 ready binding 仍引用其 Session;
随后恢复同一个 start 请求又重复递增序号。修复后冷打开 CN ready binding 会从现有
对话历史新建原生会话,未确认 start 的恢复保持原 operation 和序号。相邻 4 文件
167 passed、1 skipped;新构建的真实桌面已验证重启续发成功、清单出现后点击停止、
取消条目仍为待办,以及随后返回 `REMOTE_CN_RESUMED` 且不显示旧轮清单。
测试启动器曾遗漏托盘资源,以及误清除托管 Node 所需的 `ELECTRON_RUN_AS_NODE`,导致
前一次构建未刷新产物;修复启动器后核对产物包含源码修复,再进行上述成功复验。
这两项是临时驱动问题。最初测试计数钩子未覆盖远端模型出口,改从隔离 Agent 的
`model-calls.sqlite` 读取 operation、dispatch 状态及取消结果,仅导出计数,不读取凭据。
首轮全量测试为 4707 passed、67 skipped、1 failed;失败为本地 OpenCode 压缩返回
`compacted: false`。随后单独复验该文件及远程 ACP、项目选择、顶部清单,共 136 passed。
不将专项复验写成一次全量全绿。类型检查和 lint 已通过。
完整桌面截图位于批准临时目录的 `joint-cn-real-before-restart.png` 和
`joint-cn-cancelled.png`,均已打开审阅。窗口中状态与内容对齐,取消后保留待办,
输入框仍可使用。UI 驱动为 `joint-desktop.cjs`、`joint-scenario.ps1`;共享 Host
及隔离 profile 由 `joint-ui-host.ts` 管理。
补充真实长清单测试收到 55 项、5,601 字符的条目正文,数据库读取数量和字符数一致。
随后成功调用空字符串清单,顶部隐藏;再次退出重启并打开原项目,保存的空数组仍有效,
没有复活旧清单。自动断言摘要保存在批准临时目录的 `joint-final-evidence.json`,覆盖
SSH 项目身份、CN 选择、长清单落库、取消保留待办、取消后续发和清空后的重启状态。
本轮新增模型请求以隔离 Agent gateway 调用账本为准,共 **16 次**,15 次 completed,
1 次 outcome-unknown/cancelled。三轮创建并更新两项清单分别 3 次;取消场景 2 次;
取消后续发 1 次;55 项长清单 2 次;清空 2 次。旧会话失败和启动排错未新增 gateway
dispatch。本轮 16 次与上文 CN 17 次、早期 OC 15 次分别记录,不重复累计。
清理后已用原固定身份重新连接 Host,确认测试目录不存在、所属进程为 0;本地隔离
profile 及其加密测试凭据已删除。正式 Host 安装、应用配置和业务文件未改动。最终
typecheck、lint、差异检查通过。尚未执行 Linux arm64/macOS arm64 真机与多远程
会话同时生成清单的完整桌面矩阵;隔离与去重仍有定向测试覆盖,不据此扩大真机结论。
## 测试与复查路径
源码相邻测试:7 个文件,185 passed、6 skipped。覆盖 ACP Runtime、协议通道、
managed Runtime、原生插件、Agent-owned Prompt、ACP connection 和 model bridge。
```text
npx vitest run src/main/agent/acp-remote-runtime.test.ts src/agent-daemon/opencode-subagent-plugin.test.ts src/main/remote-agent/protocol-remote-runtime-channel.test.ts src/main/remote-agent/managed-remote-acp-runtime.test.ts src/agent-daemon/agent-owned-acp-prompt.test.ts src/agent-daemon/agent-acp-connection.test.ts src/agent-daemon/model-bridge.test.ts
npm run typecheck
npm run lint
```
Typecheck、lint 通过。`npm test` 在 240 秒时超时,未得到全量完成结果;此前
`src/renderer/src/App.test.tsx` 两项 Magic Notes 测试因 `pdfjs` 的 `DOMMatrix is not defined`
失败。本次未修改该 Renderer 路径。
本机临时探测目录为 `C:\Users\jiang\AppData\Local\Temp\opencode`,保留这些脚本供复查:
- `checklist-host-driver.ts`:读取既有凭据、SSH 固定身份、部署源码 Agent、清单及断线断言。
- `checklist-host-build.cjs`、`checklist-host-run.cjs`:构建和 Electron 执行入口;`--verify` 跑 OC,`--cn-only` 跑 CN 无模型探测。
- `checklist-host-daemon.ts`:只记录原生 ACP 初始化阶段名称的诊断包装。
- `checklist-cn-probe.cjs`:CN 工件完整性与 Linux CLI 入口验证。
- 复用 `acp-recovery-host-build.cjs`、`acp-recovery-host-daemon.ts` 和 `image-host-agent.ts`,没有复制凭据文件。
最终 stdout 证据分别为该目录的 `checklist-oc-final.log` 和 `checklist-cn-final.log`。
文档审校扫描无阻断项;复核项为测试范围列举及必要的未验证边界,按事实保留。
构建顺序为先运行 `acp-recovery-host-build.cjs`,再运行 `checklist-host-build.cjs`,
最后运行 `checklist-host-run.cjs --verify`。真实 OC 探测会调用模型;早期 `--cn-only`
探测只检查 CN 入口,不调用模型。
后续 CN 全路径复验使用 `cn-runtime-build.cjs` 构建当前源码与临时签名复合包,再运行
`checklist-host-run.cjs --continue --verify --lifecycle`。该命令会发出真实模型请求,
与前述只运行 version/help 的 `--cn-only` 不同。`cn-runtime-daemon.ts` 在隔离 Home
调用桌面验包及 Host 安装器,验证 CLI 激活,然后使用当前源码 Agent 持有模型轮次;
provider HTTP 请求计数在 Agent gateway 的外部 fetch 层完成。
后续专项验证:远程相关 27 个测试文件、374 项通过;新增 CN 复合包测试经过桌面完整
验包与生产安装器的 prepare/commit。全量运行完成,4665 passed、67 skipped、6 failed;
其中一项是运行期间新增 CN 测试尚未修正的归档名,随后专项复验通过。其余五项为
`ActivityPanel.test.tsx` 与 `activity-store.test.ts` 的引用复用断言,本轮未修改对应实现。
这两个文件在并行工作区继续更新后重新运行,39 项全部通过;全量运行中的失败项均已
经后续专项复验,但没有重新运行全部测试来取得同一最终快照的全绿报告。
最终另复验项目选择、顶部清单和安装管理 3 文件、41 项通过;App 远程 Runtime 选择与
继承 3 项通过。`npm run typecheck`、`npm run lint` 均通过。中文审校扫描阻断项为 0,
复核项为测试列举、请求数分项与必要验收边界,按事实保留。
+33 -11
View File
@@ -225,10 +225,10 @@ Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、
## Runtime
首个远程 Runtime 固定为签名 OpenCode ACP bundle。
托管 SSH 项目的 Composer Runtime 菜单只显示当前配置的 OpenCode 和管理入口,不显示
直连模型、Continue 或 DeepSeek Harness。激活旧远程会话时,若其保存了其他 Runtime
selection,Renderer 会恢复为当前 OpenCode 配置;Main 的远程请求校验仍是最终边界。
托管 SSH 项目支持签名 OpenCode ACP bundle 和 Continue 1.5.47 HTTP 宿主。
Composer Runtime 菜单显示 OpenCode、Continue 和管理入口,不显示直连模型或
DeepSeek Harness。项目与历史会话保留 OC/CN 选择;不支持的旧 selection 恢复为项目
默认值。Main 校验已安装 Runtime 身份与选择一致,按相应 CN/OC 模型 profile 建立模型桥。
本机配置的 SKILL 包和 stdio MCP Server 不上传或分配给远程 Runtime;远程 ACP Session
继续使用空 MCP Server 列表。Node.js、Python 等工具执行环境同样不修改或同步到 Host,
完整边界见[工具执行环境](../local-tool-environment/README.md)。
@@ -273,16 +273,34 @@ Execute 直接启动已签名 Runtime:
## 执行清单接入范围
OC `todowrite` / CN `Checklist` 顶部展示属于待实施功能,权威需求及接入设计见
OC `todowrite` / CN `Checklist` 顶部展示的权威需求及接入设计见
[Runtime 执行清单](../assistant-workbar/runtime-checklist-technical-design.md),
当前证据见[工作栏进度](../assistant-workbar/progress.md#2026-09-15-runtime-执行清单设计未实现)。
远程实现与验证范围见[执行清单验证记录](./runtime-checklist-validation.md)。
目标范围包括远程 GoodBuddy Agent 运行 OC 和 CN;ACP 仅承担传输与远端协议。
当前托管安装与实时 Runtime 路径仍只接受 OC,不能把通用 ACP plan/tool 传递代码视作
远程 CN 已可用。CN 的受管工件、安装/启动、项目选择与校验、模型桥及原生清单传递
需要在该功能实施时补齐,并在共享 Linux 真实 Host 分别确认 OC/CN 的可用程度。
本次只完善文档,未实测任一远程 Runtime 的清单;已有 OC Ask/Execute、问答或重连证据
不作为本功能通过证据。清单恢复复用下节流程,不增加独立持久化或重连系统。
CN 1.5.47 的 `serve` 提供 HTTP `/state`、`/message`,没有原生 ACP 命令。
Agent 的 `continue-acp-helper.ts` 复用 `ContinueHostAdapter` 启动该宿主,转换 ACP
Session、文字、工具、问答、清单与取消。Main 冻结的模型配置仍通过既有 Agent gateway
执行,CN 配置仅持有 loopback 路由及占位凭据。成功的原生清单经
`_meta.goodbuddyChecklist` 进入同一 operation 的 semantic transcript,Main 校验 CN
来源和统一 schema;普通工具参数和正文不生成清单。
2026-09-18 Linux x64 已通过 CN 开发复合包验包、安装、Agent 启动及 CN 激活,随后
通过真实模型清单新增/清空、未 ACK 断线重放、取消和同对话 Ask 续发。默认组包现同时
携带 OC/CN,`additionalRuntimes` 随主 Runtime 一起验签、安装和加载,仍可读取已有单
Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创建、清单更新、重启和
取消后续发;实测范围及尚未覆盖的平台见上述验证记录。开发包尚未发布。
2026-09-18 实测 OC 1.18.29 的 ACP 仅转发 `todowrite` 工具活动,没有原生 `plan`
通知。已有 OC 插件现将精确 `todo.updated` 事件经 `_meta.goodbuddyTodoEvent` 转发,
保留原 Session ID、完整条目及空数组。Main 校验原生事件类型、Session 和清单 schema
后输出 `checklist`,不从工具标题、截断字符串或正文推测清单。标准 `plan.entries`
也可映射 OC 清单;尚未确认的 `plan_update`、`plan_removed` 只形成活动状态。
清单恢复复用下节流程及既有 semantic transcript/ACK;已通过首份清单未 ACK 时断开、
同操作重连、随后收到空清单的真实 Host 验证。顶部显示与数据库历史由对应模块单独验收。
同次验证复现原生 ACP 冷启动超过 15 秒控制超时。`runtime/startPrompt` 的控制等待
现至少为 30 秒,其余控制请求沿用原时限;Runtime 外层仍保留启动超时和取消处理。
## ACP 与断线
@@ -355,6 +373,10 @@ OC `todowrite` / CN `Checklist` 顶部展示属于待实施功能,权威需求
的断线继续执行语义。
- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。
- Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。
- Continue helper 的会话与上下文仅保存在进程内。Desktop 冷打开已完成的 CN 对话时,
关闭旧空闲 binding,以新 binding 和已有对话历史创建会话;同一存活会话内续发仍递增
Prompt 序列。活动请求恢复沿用原 operation 和序列;start 未确认时通过既有 attach
核对,不递增序列或重新发送 Prompt。
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
- 只有远端返回 identity 匹配且 `closed: true` 时,Main 才删除持久化 binding;传输失败或未确认 close 会保留 recovery identity。
- 终态 close 由 Agent 在一个事务中为两个方向记录 sequence high-water tombstone,删除剩余 frame 和 active channel,并归零对应 journal quota。tombstone 用于拒绝迟到的旧 epoch frame,因此当前不会按时间自动裁剪。
+14
View File
@@ -38,6 +38,20 @@
"integrity": "sha512-Lr7XXik5wPJZBV5RW+aR6Uogf1n5GogpB565m+D/jiO/vRXr9LhvCpixgAr1tiwuH1ZfMsqOOlbFQz5jgH3Tqg=="
}
}
},
"continue": {
"version": "1.5.47",
"provider": "continue",
"entrypoint": "lib/continue/dist/cn.js",
"entrypointIdentity": "continue-acp",
"argvPrefix": [],
"allowedEnvironmentNames": ["HOME", "LANG", "LC_ALL", "PATH", "TMPDIR", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"],
"protocol": { "major": 1, "minor": 0 },
"targets": {
"x64": { "package": "@continuedev/cli", "integrity": "sha512-gtpewV3RoIOD9dyTtKIBi1SY0VOHRu3Ehe7C/mmnswm+j34MPyrcQhQaWj/m+jdfGO4fNIKdrgGIlLso1ULDFw==" },
"arm64": { "package": "@continuedev/cli", "integrity": "sha512-gtpewV3RoIOD9dyTtKIBi1SY0VOHRu3Ehe7C/mmnswm+j34MPyrcQhQaWj/m+jdfGO4fNIKdrgGIlLso1ULDFw==" },
"darwin-arm64": { "package": "@continuedev/cli", "integrity": "sha512-gtpewV3RoIOD9dyTtKIBi1SY0VOHRu3Ehe7C/mmnswm+j34MPyrcQhQaWj/m+jdfGO4fNIKdrgGIlLso1ULDFw==" }
}
}
}
}
+20 -8
View File
@@ -45,6 +45,7 @@ import {
type VerifiedRuntimeBundle
} from './runtime-bundle-verifier'
import { createProductionRuntimeProtocol } from './runtime-composition'
import { runContinueAcpHelper } from './continue-acp-helper'
import {
runOpenCodeModelBridgeHelper,
type ModelBridgeProtocol
@@ -86,7 +87,7 @@ export type AgentCliDependencies = {
loadRegisteredInstallation?: typeof loadRegisteredAgentBundle
runtimePaths?: (
installationId: string,
runtimeId: 'opencode',
runtimeId: 'opencode' | 'continue',
bundleDigest: string
) => ManagedRuntimePaths
runtimeReleaseKeyRegistry?: AgentReleaseKeyRegistry
@@ -187,6 +188,8 @@ async function runModelBridgeHelper(
'supports-image-input',
'work-mode',
'shared-sessions',
'runtime-id',
'continue-entrypoint',
'opencode-entrypoint'
])
requireOptions(options, [
@@ -194,8 +197,7 @@ async function runModelBridgeHelper(
'protocol',
'model',
'supports-image-input',
'work-mode',
'opencode-entrypoint'
'work-mode'
])
const protocol = options.protocol
if (
@@ -216,6 +218,16 @@ async function runModelBridgeHelper(
if (options['shared-sessions'] !== undefined && options['shared-sessions'] !== 'true') {
throw new Error('Invalid model bridge shared-sessions option')
}
if (options['runtime-id'] === 'continue') {
requireOptions(options, ['continue-entrypoint'])
return runContinueAcpHelper({
socketPath: options['socket-path']!, protocol, model: options.model!,
supportsImageInput: imageInput === 'true', workMode,
sharedSessions: options['shared-sessions'] === 'true', entrypoint: options['continue-entrypoint']!
})
}
if (options['runtime-id'] !== undefined && options['runtime-id'] !== 'opencode') throw new Error('Invalid Runtime helper identity')
requireOptions(options, ['opencode-entrypoint'])
return await (
dependencies.runModelBridgeHelper ??
runOpenCodeModelBridgeHelper
@@ -943,7 +955,7 @@ function resolveInstallationPaths(
function resolveRuntimePaths(
installationId: string,
runtimeId: 'opencode',
runtimeId: 'opencode' | 'continue',
bundleDigest: string,
dependencies: AgentCliDependencies
): ManagedRuntimePaths {
@@ -997,8 +1009,8 @@ function validateInstallationId(value: string): string {
return value
}
function validateRuntimeId(value: string): 'opencode' {
if (value !== 'opencode') {
function validateRuntimeId(value: string): 'opencode' | 'continue' {
if (value !== 'opencode' && value !== 'continue') {
throw new Error('Invalid Runtime ID')
}
return value
@@ -1022,13 +1034,13 @@ function validateRuntimeArchitecture(
function assertActivatedRuntimeIdentity(
verified: VerifiedRuntimeBundle,
runtimeId: 'opencode',
runtimeId: 'opencode' | 'continue',
bundleDigest: string,
architecture: AgentArchitecture
): void {
if (
verified.manifest.runtimeId !== runtimeId ||
verified.manifest.provider !== 'opencode' ||
verified.manifest.provider !== runtimeId ||
verified.manifest.bundleDigest !== bundleDigest ||
verified.manifest.architecture !== architecture
) {
+190
View File
@@ -0,0 +1,190 @@
import { randomBytes, randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable, Writable } from 'node:stream'
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, type Agent, type McpServer } from '@agentclientprotocol/sdk'
import { ContinueHostAdapter } from '../main/agent/continue-host-adapter'
import { promptWithUntrustedConversationHistory } from '../main/agent/runtime-conversation-history'
import type { AgentExecutionRequest, AgentImage } from '../main/agent/runtime'
import { createUnixModelBridgeExchange } from './model-bridge-broker'
import { ModelBridgeLoopbackProxy, MODEL_BRIDGE_SDK_AUTH_SENTINEL, openCodeModelBridgeModelId, type ModelBridgeProtocol } from './model-bridge-helper'
export async function runContinueAcpHelper(options: {
socketPath: string
protocol: ModelBridgeProtocol
model: string
supportsImageInput: boolean
workMode: 'ask' | 'execute'
sharedSessions?: boolean
entrypoint: string
}): Promise<number> {
const proxy = new ModelBridgeLoopbackProxy({
exchange: createUnixModelBridgeExchange({ socketPath: options.socketPath }),
sharedSessions: options.sharedSessions
})
const origin = await proxy.listen()
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-continue-'))
const questions = new Map<string, { sessionId: string; adapter: ContinueHostAdapter }>()
const token = randomBytes(32).toString('base64url')
const replies = createServer((request, response) => { void (async () => {
if (request.method !== 'POST' || request.url !== `/${token}`) { response.writeHead(404).end(); return }
let body = ''
for await (const chunk of request) { body += chunk; if (body.length > 64 * 1024) throw new Error('Question reply too large') }
const value = JSON.parse(body) as { questionId: string; sessionId: string; answers: string[][] }
const pending = questions.get(value.questionId)
if (!pending || pending.sessionId !== value.sessionId) { response.writeHead(409).end(); return }
await pending.adapter.respondToQuestion(value.questionId, value.answers.length ? value.answers : undefined)
questions.delete(value.questionId)
response.writeHead(200).end('{}')
})().catch(() => response.writeHead(400).end()) })
await new Promise<void>((resolve, reject) => { replies.once('error', reject); replies.listen(0, '127.0.0.1', resolve) })
const questionEndpoint = `http://127.0.0.1:${(replies.address() as import('node:net').AddressInfo).port}/${token}`
const sessions = new Map<string, {
cwd: string
mcpServers: McpServer[]
history: NonNullable<AgentExecutionRequest['history']>
active?: { abort: AbortController; adapter: ContinueHostAdapter; finished: Promise<void> }
}>()
const modelId = openCodeModelBridgeModelId(options.protocol, options.model)
const configOptions = [{ id: 'model', name: 'Model', type: 'select' as const, currentValue: modelId,
options: [{ value: modelId, name: options.model }] }]
const agent: Agent = {
initialize: async () => ({ protocolVersion: PROTOCOL_VERSION, agentCapabilities: {
loadSession: true, mcpCapabilities: { http: true }, promptCapabilities: { image: true },
sessionCapabilities: { resume: {}, close: {} }
}, agentInfo: { name: 'GoodBuddy Continue', version: '1.5.47' } }),
authenticate: async () => ({}),
newSession: async ({ cwd, mcpServers }) => {
const sessionId = randomUUID()
sessions.set(sessionId, { cwd, mcpServers, history: [] })
return { sessionId, configOptions }
},
loadSession: async ({ sessionId, cwd, mcpServers }) => {
const session = sessions.get(sessionId)
if (!session || session.cwd !== cwd) throw new Error('Continue Session is unavailable')
session.mcpServers = mcpServers
return { configOptions }
},
resumeSession: async ({ sessionId, cwd, mcpServers }) => {
const session = sessions.get(sessionId)
if (!session || session.cwd !== cwd) throw new Error('Continue Session is unavailable')
if (mcpServers) session.mcpServers = mcpServers
return { configOptions }
},
setSessionConfigOption: async ({ sessionId, configId, value }) => {
if (!sessions.has(sessionId) || configId !== 'model' || value !== modelId) throw new Error('Continue model selection does not match the prepared model')
return { configOptions }
},
prompt: async ({ sessionId, prompt }) => {
const session = sessions.get(sessionId)
if (!session || session.active) throw new Error('Continue Session is unavailable or busy')
const route = options.sharedSessions ? await fetch(`${origin}/session?sessionId=${encodeURIComponent(sessionId)}`, {
signal: AbortSignal.timeout(10_000)
}).then(async response => {
if (!response.ok) throw new Error('Continue model route is unavailable')
return await response.json() as { operationId: string; workMode: 'ask' | 'execute' }
}) : undefined
const workMode = route?.workMode ?? options.workMode
const configPath = join(root, `${sessionId}.json`)
await writeFile(configPath, JSON.stringify({ name: 'GoodBuddy', version: '1.0.0', schema: 'v1',
mcpServers: workMode === 'execute' ? session.mcpServers.map(server => {
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
return { name: server.name, type: 'streamable-http', url: server.url,
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
}) : [] }), { mode: 0o600 })
const adapter = new ContinueHostAdapter({
binaryPath: options.entrypoint, configPath, workspace: session.cwd, cacheRoot: root, mode: 'chat',
modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model,
protocol: options.protocol, authentication: 'api-key', apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL,
baseUrl: `${origin}/v1`, supportsImageInput: options.supportsImageInput,
...(route ? { requestHeaders: { 'x-goodbuddy-session': sessionId, 'x-goodbuddy-operation': route.operationId } } : {}) },
launchHost: (entry, args, launch) => spawn(process.execPath, [entry, ...args], { ...launch, stdio: ['ignore', 'ignore', 'pipe'] })
})
const abort = new AbortController()
let finish!: () => void
const text = prompt.filter(part => part.type === 'text').map(part => part.text).join('\n')
const images = prompt.filter(part => part.type === 'image').map<AgentImage>((part, index) => {
if (part.mimeType !== 'image/png' && part.mimeType !== 'image/jpeg') throw new Error('Unsupported Continue image type')
return { name: `image-${index}`, mediaType: part.mimeType, data: part.data }
})
session.active = { abort, adapter, finished: new Promise(resolve => { finish = resolve }) }
try {
const result = await adapter.run(promptWithUntrustedConversationHistory({ prompt: text, history: session.history }, true), abort.signal,
async () => workMode === 'execute' ? 'once' : 'deny', {
workMode, images,
onEvent: async event => {
if (event.type === 'text') await connection.sessionUpdate({ sessionId, update: {
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: event.delta }
} })
if (event.type === 'checklist') await connection.sessionUpdate({ sessionId, update: {
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-checklist',
_meta: { goodbuddyChecklist: event.checklist }
} })
if (event.type === 'tool') await connection.sessionUpdate({ sessionId, update: {
sessionUpdate: 'tool_call_update', toolCallId: event.tool.callId, title: event.tool.name,
status: event.tool.state === 'running' ? 'in_progress' : event.tool.state,
rawInput: event.tool.input,
content: event.tool.output || event.tool.error ? [{ type: 'content', content: {
type: 'text', text: event.tool.error ?? event.tool.output!
} }] : []
} })
if (event.type === 'question') {
questions.set(event.questionId, { sessionId, adapter })
await connection.sessionUpdate({ sessionId, update: {
sessionUpdate: 'tool_call_update', toolCallId: event.questionId,
_meta: { goodbuddyQuestion: { endpoint: questionEndpoint,
question: { id: event.questionId, sessionID: sessionId, questions: event.questions } } }
} })
}
}
})
if (!result.streamedText && result.text) await connection.sessionUpdate({ sessionId, update: {
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: result.text }
} })
session.history.push({ role: 'user', content: text }, { role: 'assistant', content: result.text })
return { stopReason: 'end_turn', ...(result.usage ? { usage: {
inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens,
totalTokens: result.usage.inputTokens + result.usage.outputTokens,
cachedReadTokens: result.usage.cacheReadTokens, cachedWriteTokens: result.usage.cacheWriteTokens
} } : {}) }
} catch (error) {
if (abort.signal.aborted) return { stopReason: 'cancelled' }
throw error
} finally {
await adapter.dispose()
for (const [id, pending] of questions) if (pending.adapter === adapter) questions.delete(id)
await rm(configPath, { force: true })
session.active = undefined
finish()
}
},
cancel: async ({ sessionId }) => {
const active = sessions.get(sessionId)?.active
active?.abort.abort(new DOMException('Cancelled', 'AbortError'))
await active?.finished
},
closeSession: async ({ sessionId }) => {
await agent.cancel({ sessionId })
sessions.delete(sessionId)
return {}
}
}
const connection = new AgentSideConnection(() => agent, ndJsonStream(
Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)
))
try {
if (options.sharedSessions) await connection.extNotification('goodbuddy/modelBridgeReady', { origin })
await connection.closed
return 0
} finally {
for (const session of sessions.values()) session.active?.abort.abort()
await Promise.all([...sessions.values()].map(session => session.active?.finished))
await proxy.close()
replies.closeAllConnections()
await new Promise<void>(resolve => replies.close(() => resolve()))
await rm(root, { recursive: true, force: true })
}
}
@@ -24,6 +24,34 @@ afterEach(() => {
})
describe('OpenCode direct launch profile', () => {
it.each(['ask', 'execute'] as const)('launches Continue through the managed HTTP facade in %s', (workMode) => {
const fixture = createFixture()
const manifest = remoteRuntimeBundleManifestSchema.parse({
...fixture.manifest, runtimeId: 'continue', provider: 'continue', runtimeVersion: '1.5.47',
files: fixture.manifest.files.map(file => file.path === 'bin/opencode' ? { ...file, path: 'lib/continue/dist/cn.js' } : file),
entrypoint: { ...fixture.manifest.entrypoint, identity: 'continue-acp', path: 'lib/continue/dist/cn.js', argvPrefix: [] }
})
expect(() => createOpenCodeLaunchProfile({ ...fixture, manifest, workMode })).toThrow(/model bridge/)
const profile = createOpenCodeLaunchProfile({ ...fixture, manifest, workMode,
modelBridge: {
agentExecutablePath: resolve(fixture.bundleDirectory, 'agent', 'goodbuddy-agent'),
bridgeDirectory: resolve(fixture.bundleDirectory, 'bridge'),
socketPath: resolve(fixture.bundleDirectory, 'bridge', 'model.sock'),
sharedSessions: true,
policy: { protocol: 'openai-responses', model: 'selected-model',
modelProfileDigest: `sha256:${'9'.repeat(64)}`, supportsImageInput: true }
}
})
expect(profile.args).toEqual(expect.arrayContaining([
'model-bridge-helper', '--runtime-id', 'continue', '--continue-entrypoint',
resolve(fixture.bundleDirectory, 'lib/continue/dist/cn.js'), '--work-mode', workMode,
'--shared-sessions', 'true'
]))
expect(profile.args).not.toContain('acp')
expect(profile.cwd).toBe(fixture.workspaceDirectory)
expect(profile.env.OPENCODE_CONFIG_CONTENT === process.env.OPENCODE_CONFIG_CONTENT).toBe(true)
})
it.each(['ask', 'execute'] as const)(
'disables unused automatic Git snapshots for %s',
(workMode) => {
+13 -7
View File
@@ -45,15 +45,20 @@ export function createOpenCodeLaunchProfile(input: {
};
}): OpenCodeLaunchProfile {
const manifest = remoteRuntimeBundleManifestSchema.parse(input.manifest);
const isContinue = manifest.runtimeId === "continue" && manifest.provider === "continue";
if (
manifest.runtimeId !== "opencode" ||
!isContinue && (manifest.runtimeId !== "opencode" ||
manifest.provider !== "opencode" ||
manifest.entrypoint.identity !== "opencode-acp" ||
manifest.entrypoint.argvPrefix.length !== 1 ||
manifest.entrypoint.argvPrefix[0] !== "acp"
manifest.entrypoint.argvPrefix[0] !== "acp")
) {
throw new Error("Runtime manifest is not the fixed OpenCode ACP profile");
}
if (isContinue && (manifest.entrypoint.identity !== "continue-acp" ||
manifest.entrypoint.path !== "lib/continue/dist/cn.js" || manifest.entrypoint.argvPrefix.length !== 0 || !input.modelBridge)) {
throw new Error("Continue requires its managed ACP facade and model bridge");
}
if (
[...manifest.allowedEnvironmentNames].sort().join("\0") !==
[...OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES].sort().join("\0")
@@ -135,25 +140,26 @@ export function createOpenCodeLaunchProfile(input: {
"--work-mode",
input.workMode,
...(modelBridge.sharedSessions ? ["--shared-sessions", "true"] : []),
"--opencode-entrypoint",
...(isContinue ? ["--runtime-id", "continue"] : []),
isContinue ? "--continue-entrypoint" : "--opencode-entrypoint",
executablePath,
],
};
const environmentBase = {
...executeEnvironment(process.env, workspaceDirectory),
...OPEN_CODE_RUNTIME_ENVIRONMENT,
OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode"),
...(isContinue ? {} : { ...OPEN_CODE_RUNTIME_ENVIRONMENT,
OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode") }),
};
const permission = input.workMode === "ask" ? "ask" : "allow";
const environment = {
...environmentBase,
OPENCODE_CONFIG_CONTENT: JSON.stringify({
...(isContinue ? {} : { OPENCODE_CONFIG_CONTENT: JSON.stringify({
// Native snapshot summaries are not consumed by GoodBuddy.
snapshot: false,
permission,
agent: { build: { permission } },
}),
}) }),
};
return {
...runtimeCommand,
@@ -5,6 +5,31 @@ import { describe, expect, it, vi } from 'vitest'
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
describe('OpenCode ACP child event plugin', () => {
it('forwards native todos intact under their own Session and stops after disposal', async () => {
const write = vi.fn()
const factory = runInNewContext(
`(${openCodeSubagentPluginSource().replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
{ process: { stdout: { write } }, createServer, randomBytes }
)
const hooks = await factory()
const items = [{ content: 'long'.repeat(2000), status: 'cancelled', priority: 'low' }]
for (const sessionID of ['root', 'child']) {
for (const todos of [items, []]) {
const event = { type: 'todo.updated', properties: { sessionID, todos } }
await hooks.event({ event })
expect(JSON.parse(write.mock.calls.at(-1)![0]).params).toEqual({
sessionId: sessionID, update: {
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-todos',
_meta: { goodbuddyTodoEvent: event }
}
})
}
}
await hooks.dispose()
await hooks.event({ event: { type: 'todo.updated', properties: { sessionID: 'root', todos: [] } } })
expect(write).toHaveBeenCalledTimes(4)
})
it('hides foreign image tools and removes the current image tool in Ask on a reused Session', async () => {
let mode = 'execute'
const imageToolName = `goodbuddy_image_${'a'.repeat(24)}`
+11 -1
View File
@@ -1,5 +1,5 @@
/**
* OpenCode's ACP adapter omits native questions and child sessions. Native
* OpenCode's ACP adapter omits native todos, questions and child sessions. Native
* events use ACP metadata; question replies use a process-local capability.
*/
export function openCodeSubagentPluginSource(modelBridgeOrigin?: string): string {
@@ -155,6 +155,7 @@ async function plugin(input?: {
requestID?: string
status?: { type?: string }
sessionID?: string
todos?: unknown
part?: {
sessionID?: string
type: string
@@ -168,6 +169,15 @@ async function plugin(input?: {
} }) => {
const properties = event.properties
if (closed) return
if (event.type === 'todo.updated' && properties.sessionID) {
// Keep the native Session identity: child todos must not become root todos.
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {
sessionId: properties.sessionID,
update: { sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-todos',
_meta: { goodbuddyTodoEvent: event } }
} }) + '\n')
return
}
if (event.type === 'session.status' && properties.status?.type === 'idle' && properties.sessionID) {
for (const [key, route] of modelMessages) {
if (route.sessionId === properties.sessionID) modelMessages.delete(key)
+1 -1
View File
@@ -2638,7 +2638,7 @@ export class RuntimeAcpBackend {
): void {
const manifest = verified.manifest
if (
manifest.provider !== 'opencode' ||
(manifest.provider !== 'opencode' && manifest.provider !== 'continue') ||
manifest.runtimeId !== preparation.runtimeId ||
manifest.bundleDigest !== preparation.runtimeBundleDigest ||
manifest.adapterDigest !== preparation.runtimeAdapterDigest ||
+170 -4
View File
@@ -1,4 +1,7 @@
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
AgentSideConnection,
PROTOCOL_VERSION,
@@ -35,7 +38,7 @@ import type {
RemotePromptOperationReconciliationResult,
RemoteRuntimeChannel
} from './remote-runtime-channel'
import { MemoryRuntimeSessionBindingStore } from './runtime-session-binding-store'
import { MemoryRuntimeSessionBindingStore, SqliteRuntimeSessionBindingStore, type RuntimeSessionBindingStore } from './runtime-session-binding-store'
import { AssistantDatabase } from '../assistant/assistant-database'
type FakeServer = {
@@ -277,7 +280,7 @@ function runtime(
function factoryRuntime(
factory: (bindingId: string) => Promise<RemoteRuntimeChannel>,
store = new MemoryRuntimeSessionBindingStore(),
store: RuntimeSessionBindingStore = new MemoryRuntimeSessionBindingStore(),
overrides: Partial<AcpRemoteRuntimeOptions> = {}
): AcpRemoteRuntime {
return new AcpRemoteRuntime({
@@ -1459,7 +1462,7 @@ describe('AcpRemoteRuntime', () => {
'text',
'reasoning',
'tool',
'status',
'checklist',
'context-metrics',
'model-usage',
'done'
@@ -2591,6 +2594,8 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
}
function ownedChannel(options?: {
runtimeId?: 'opencode' | 'continue'
bindingStore?: RuntimeSessionBindingStore
updates?: SessionUpdate[]
state?: 'completed' | 'failed' | 'cancelled' | 'outcome-unknown'
terminalPayload?: unknown
@@ -2803,11 +2808,12 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
})),
close: vi.fn(async () => {})
}
const store = new MemoryRuntimeSessionBindingStore()
const store = options?.bindingStore ?? new MemoryRuntimeSessionBindingStore()
const instance = factoryRuntime(
async () => channel,
store,
{
runtimeId: options?.runtimeId ?? 'opencode',
modelBridgePolicy,
modelProfile: ownedModelProfile
}
@@ -2825,6 +2831,166 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
}
}
it.each(['continue', 'opencode'] as const)('continues %s after reopening the desktop binding database', async (runtimeId) => {
const directory = mkdtempSync(join(tmpdir(), 'remote-runtime-restart-'))
const databasePath = join(directory, 'bindings.sqlite')
let store = new SqliteRuntimeSessionBindingStore(databasePath)
const first = ownedChannel({ runtimeId, bindingStore: store })
let replacement: AcpRemoteRuntime | undefined
try {
await collect(first.instance.run(request, new AbortController().signal))
const original = (await store.getByConversation(request.conversationId))!
await first.instance.dispose()
store.close()
store = new SqliteRuntimeSessionBindingStore(databasePath)
const next = ownedChannel({ runtimeId, bindingStore: store })
replacement = next.instance
if (runtimeId === 'continue') {
next.startOwnedPrompt.mockImplementationOnce(async input => {
if ('acpSessionId' in input) throw new Error('Continue Session is unavailable')
return { bindingId: input.bindingId, operationId: input.operationId, requestId: input.requestId,
sessionId: 'owned-session', state: 'running', latestSemanticSequence: '0' }
})
}
const history = [{ role: 'user' as const, content: 'previous question' },
{ role: 'assistant' as const, content: 'previous answer' }]
const events = await collect(replacement.run({ ...request, requestId: 'second', history }, new AbortController().signal))
expect(events.some(event => event.type === 'done')).toBe(true)
const resumed = (await store.getByConversation(request.conversationId))!
const start = next.startOwnedPrompt.mock.calls[0]![0]
if (runtimeId === 'continue') {
expect(resumed.bindingId).not.toBe(original.bindingId)
expect(await store.getById(original.bindingId)).toMatchObject({ state: 'closed' })
expect(start).not.toHaveProperty('acpSessionId')
expect(JSON.stringify(start)).toContain('previous answer')
expect(resumed.promptSequence).toBe(0)
} else {
expect(resumed.bindingId).toBe(original.bindingId)
expect(start).toHaveProperty('acpSessionId', original.acpSessionId)
expect(resumed.promptSequence).toBe(1)
}
await collect(replacement.run({ ...request, requestId: 'third' }, new AbortController().signal))
expect(await store.getByConversation(request.conversationId)).toMatchObject({
bindingId: resumed.bindingId, state: 'ready', promptSequence: resumed.promptSequence + 1
})
expect(next.startOwnedPrompt).toHaveBeenCalledTimes(2)
expect(next.startOwnedPrompt.mock.calls[1]![0]).toHaveProperty('acpSessionId', resumed.acpSessionId)
expect(next.attachOwnedPrompt).not.toHaveBeenCalled()
} finally {
await replacement?.dispose()
await first.instance.dispose()
store.close()
rmSync(directory, { recursive: true, force: true })
}
})
it.each([true, false])('attaches a cached request after start rejection without advancing its prompt sequence (available: %s)', async available => {
const fixture = ownedChannel({ runtimeId: 'continue' })
fixture.startOwnedPrompt.mockRejectedValueOnce(new Error('Remote Runtime runtime/startPrompt rejected RPC -32602'))
try {
await expect(collect(fixture.instance.run(request, new AbortController().signal))).rejects.toThrow('runtime/startPrompt')
const pending = (await fixture.store.getByConversation(request.conversationId))!
fixture.attachOwnedPrompt.mockImplementationOnce(async input => {
expect(fixture.channel.close).not.toHaveBeenCalled()
if (!available) throw Object.assign(new Error('Remote Runtime runtime/attachPrompt rejected RPC -32602'), {
remoteRequestOutcome: 'rejected', remoteMethod: 'runtime/attachPrompt'
})
return { ...input, sessionId: 'owned-session', state: 'running', latestSemanticSequence: '0' }
})
const recovery = collect(fixture.instance.run({ ...request, remoteRecoveryOnly: true }, new AbortController().signal))
if (available) {
expect((await recovery).some(event => event.type === 'done')).toBe(true)
} else {
await expect(recovery).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError)
}
expect(await fixture.store.getByConversation(request.conversationId)).toMatchObject({
bindingId: pending.bindingId, state: available ? 'ready' : 'prompt-running', promptSequence: pending.promptSequence
})
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
expect(fixture.attachOwnedPrompt).toHaveBeenCalledOnce()
} finally {
await fixture.instance.dispose()
}
})
it('replays complete native checklists and clears with stable provenance without starting another prompt', async () => {
const items = [{ content: 'full content '.repeat(500).trim(), status: 'cancelled', priority: 'low' }]
const native = (todos: unknown, sessionID = 'owned-session'): SessionUpdate => ({
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-todos',
_meta: { goodbuddyTodoEvent: { type: 'todo.updated', properties: { sessionID, todos } } }
})
const fixture = ownedChannel({ updates: [
native(items), native([], 'child-session'), native([{ content: 'bad', status: 'failed' }]),
{ sessionUpdate: 'tool_call', toolCallId: 'todo-tool', title: 'todowrite', status: 'completed', rawInput: { todos: [] } },
native([])
] })
const first: RuntimeEvent[] = []
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
first.push(event)
if (event.type === 'done') break
}
const replacement = factoryRuntime(async () => fixture.channel, fixture.store, { modelBridgePolicy, modelProfile: ownedModelProfile })
try {
const replay = await collect(replacement.run({ ...request, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '0' }, new AbortController().signal))
const checklists = first.filter(event => event.type === 'checklist')
expect(checklists).toHaveLength(2)
expect(checklists[0]).toMatchObject({ requestId: request.requestId, checklist: { source: 'opencode', items } })
expect(checklists[1]).toMatchObject({ checklist: { source: 'opencode', items: [] } })
expect(replay.filter(event => event.type === 'checklist')).toEqual(checklists)
expect(first.filter(event => event.type === 'tool')).toHaveLength(1)
expect(fixture.startOwnedPrompt).toHaveBeenCalledTimes(1)
expect(fixture.attachOwnedPrompt).toHaveBeenCalledTimes(1)
} finally { await replacement.dispose(); await fixture.instance.dispose() }
})
it('replays complete Continue checklists and ignores wrong-source and malformed updates', async () => {
const items = [{ content: 'complete checklist content '.repeat(300).trim(), status: 'pending' }]
const native = (checklist: unknown): SessionUpdate => ({
sessionUpdate: 'tool_call_update', toolCallId: 'goodbuddy-native-checklist',
_meta: { goodbuddyChecklist: checklist as never }
})
const fixture = ownedChannel({ runtimeId: 'continue', updates: [
native({ source: 'continue', items }), native({ source: 'opencode', items: [] }),
native({ source: 'continue', items: [{ content: 'bad', status: 'failed' }] }),
{ sessionUpdate: 'tool_call', toolCallId: 'native-tool', title: 'Checklist', status: 'completed', rawInput: { checklist: '' } },
native({ source: 'continue', items: [] })
] })
const first: RuntimeEvent[] = []
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
first.push(event)
if (event.type === 'done') break
}
const replacement = factoryRuntime(async () => fixture.channel, fixture.store, {
runtimeId: 'continue', modelBridgePolicy, modelProfile: ownedModelProfile
})
try {
const replay = await collect(replacement.run({ ...request, remoteRecoveryOnly: true, remoteSemanticAfterSequence: '0' }, new AbortController().signal))
const checklists = first.filter(event => event.type === 'checklist')
expect(checklists).toHaveLength(2)
expect(checklists[0]).toMatchObject({ checklist: { source: 'continue', items } })
expect(checklists[1]).toMatchObject({ checklist: { source: 'continue', items: [] } })
expect(replay.filter(event => event.type === 'checklist')).toEqual(checklists)
expect(first.filter(event => event.type === 'tool')).toHaveLength(1)
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
} finally { await replacement.dispose(); await fixture.instance.dispose() }
})
it('keeps unconfirmed plan variants as activity, not checklist replacement', async () => {
const fixture = ownedChannel({ updates: [
{ sessionUpdate: 'plan', entries: [] },
{ sessionUpdate: 'plan', entries: [null] } as unknown as SessionUpdate,
{ sessionUpdate: 'plan', entries: [{ content: 'bad', status: 'failed', priority: 'high' }] } as unknown as SessionUpdate,
{ sessionUpdate: 'plan_removed', id: 'plan' },
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '- [x] not a native checklist' } }
] })
const events = await collect(fixture.instance.run(request, new AbortController().signal))
expect(events.filter(event => event.type === 'checklist')).toEqual([
expect.objectContaining({ checklist: { source: 'opencode', items: [] } })
])
await fixture.instance.dispose()
})
it('starts once, maps multiple rounds, and ACKs only after consumption', async () => {
const fixture = ownedChannel({
includePermissionCheckpoint: true
+39 -2
View File
@@ -1,6 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'
import { remoteImageToolMcpName } from '../../shared/remote-image-tool-node'
import { remoteQuestionSchema } from '../../shared/remote-question-contracts'
import { runtimeChecklistSchema } from '../../shared/runtime-checklist'
import type { AgentQuestionAnswer } from '../../shared/contracts'
import { isDeepStrictEqual } from 'node:util'
import {
@@ -1404,7 +1405,9 @@ export class AcpRemoteRuntime implements AgentRuntime {
let bindingId = binding?.bindingId ?? randomUUID()
if (
binding?.state === 'ready' &&
!this.bindingIdentityMatches(binding)
// Continue helper sessions live only in the remote process. A cold open
// must seed a new session from conversation history after that process exits.
(this.options.runtimeId === 'continue' || !this.bindingIdentityMatches(binding))
) {
await this.closeIdleBinding(binding, signal)
binding = undefined
@@ -1743,6 +1746,16 @@ export class AcpRemoteRuntime implements AgentRuntime {
signal: AbortSignal
): Promise<SessionRecord> {
let current = this.sessions.get(conversationId)
if (
current?.binding.activePromptOperationId &&
this.options.modelProfile !== undefined &&
!current.ownedPromptAttached
) {
// A failed start has no confirmed attachment. Reopen through attach using
// the persisted operation, without advancing its sequence or closing its channel.
this.sessions.delete(conversationId)
current = undefined
}
if (
current?.binding.activePromptOperationId &&
this.options.modelProfile === undefined
@@ -2153,8 +2166,23 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt: ActivePrompt,
update: SessionUpdate
): RuntimePublicEvent | undefined {
if (update._meta?.goodbuddyChecklist !== undefined) {
const checklist = runtimeChecklistSchema.safeParse(update._meta.goodbuddyChecklist)
if (this.options.runtimeId !== 'continue' || !prompt.open || !checklist.success || checklist.data.source !== 'continue') return undefined
return { requestId: prompt.requestId, type: 'checklist', checklist: checklist.data }
}
const todoEvent = update._meta?.goodbuddyTodoEvent
if (todoEvent !== undefined) {
if (this.options.runtimeId !== 'opencode' || !prompt.open ||
!todoEvent || typeof todoEvent !== 'object') return undefined
const native = todoEvent as { type?: unknown; properties?: { sessionID?: unknown; todos?: unknown } }
if (native.type !== 'todo.updated' || native.properties?.sessionID !== prompt.sessionId) return undefined
const checklist = runtimeChecklistSchema.safeParse({ source: 'opencode', items: native.properties.todos })
if (!checklist.success) return undefined
return { requestId: prompt.requestId, type: 'checklist', checklist: checklist.data }
}
const extension = update._meta?.goodbuddyQuestion
if (this.options.runtimeId === 'opencode' && extension && typeof extension === 'object' &&
if ((this.options.runtimeId === 'opencode' || this.options.runtimeId === 'continue') && extension && typeof extension === 'object' &&
prompt.open && prompt.context.channel.respondToQuestion) {
const value = extension as { question?: unknown; childCallId?: unknown }
const question = remoteQuestionSchema.parse(value.question)
@@ -2348,6 +2376,15 @@ export class AcpRemoteRuntime implements AgentRuntime {
}
}
if (update.sessionUpdate === 'plan') {
if (this.options.runtimeId === 'opencode') {
if (!Array.isArray(update.entries) || update.entries.some(entry => !entry || typeof entry !== 'object')) return undefined
const checklist = runtimeChecklistSchema.safeParse({
source: 'opencode',
items: update.entries.map(({ content, status, priority }) => ({ content, status, priority }))
})
if (!checklist.success) return undefined
return { requestId: prompt.requestId, type: 'checklist', checklist: checklist.data }
}
return {
requestId: prompt.requestId,
type: 'status',
@@ -117,6 +117,44 @@ afterEach(async () => {
})
describe('ContinueHostAdapter', () => {
it('commits complete raw checklists once on success, retaining candidates and excluding old history', async () => {
const distribution = await createDistribution()
const longContent = 'task'.repeat(1500)
const checklist = { source: 'continue' as const, items: [{ content: longContent, status: 'pending' as const }] }
const historyTool = (id: string, text: string, status = 'done') => ({
toolCallId: id, toolCall: { function: { name: 'Checklist', arguments: JSON.stringify({ checklist: text }) } }, status
})
const old = { toolCallStates: [historyTool('old', '- [ ] old')] }
let polls = 0
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
if (!String(input).endsWith('/state')) return Response.json({})
polls++
return Response.json({
session: { history: polls === 1 ? [old] : [old, {
message: { role: 'assistant', content: 'OK' },
toolCallStates: polls < 3 ? [] : [historyTool('long', `- [ ] ${longContent}`), historyTool('failed', '- [x] bad', 'failed'), ...(polls >= 4 ? [historyTool('empty', '')] : [])]
}] },
isProcessing: polls < 5, messageQueueLength: 0, pendingPermission: null,
goodbuddyEvents: polls === 2 ? [
{ type: 'tool', callId: 'long', name: 'Checklist', state: 'running', input: 'truncated', runtimeChecklist: checklist },
{ type: 'tool', callId: 'failed', name: 'Checklist', state: 'running', runtimeChecklist: checklist }
] : polls === 3 ? [
{ type: 'tool', callId: 'long', name: 'Checklist', state: 'completed' },
{ type: 'tool', callId: 'failed', name: 'Checklist', state: 'failed' }
] : []
})
}))
const events: unknown[] = []
const adapter = new ContinueHostAdapter({
binaryPath: distribution.entryPath, configPath: 'test.yaml', workspace: process.cwd(), cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({ exitCode: null, killed: false, stderr: null, once: () => undefined, kill: () => true })
})
try {
await adapter.run('test', new AbortController().signal, async () => 'once', { onEvent: event => { if (event.type === 'checklist') events.push(event) } })
expect(events).toEqual([{ type: 'checklist', checklist }, { type: 'checklist', checklist: { source: 'continue', items: [] } }])
} finally { await adapter.dispose() }
})
it('creates a versioned authenticated loopback host copy', async () => {
const distribution = await createDistribution()
const adapter = new ContinueHostAdapter({
+15 -1
View File
@@ -1,4 +1,5 @@
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 {
@@ -113,6 +114,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
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()
@@ -179,6 +181,7 @@ export type ContinueHostUsage = {
}
export type ContinueHostTool = {
runtimeChecklist?: RuntimeChecklist
callId: string
name: string
state: 'pending' | 'running' | 'completed' | 'failed'
@@ -197,6 +200,7 @@ export type ContinueHostRunResult = {
}
export type ContinueHostStreamEvent =
| { type: 'checklist'; checklist: RuntimeChecklist }
| { type: 'text'; delta: string }
| { type: 'tool'; tool: ContinueHostTool }
| {
@@ -729,6 +733,7 @@ function extractContinueTools(
? 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,
@@ -756,6 +761,7 @@ function mergeContinueTools(
...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
@@ -987,7 +993,7 @@ export class ContinueHostAdapter {
patched = replaceExactly(
patched,
'state:"running",input:(()=>',
`state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>`
`state:"running",runtimeChecklist:(${parseContinueChecklist.toString()})(u,l),summary:(${toolOperationSummary.toString()})(l),input:(()=>`
)
patched = replaceExactly(
patched,
@@ -1600,6 +1606,7 @@ export class ContinueHostAdapter {
let streamTruncated = false
let streamEventBytes = 0
const observedToolCallIds = new Set<string>()
const submittedChecklists = new Set<string>()
try {
const initialState = await this.waitForStartup(
child,
@@ -1692,6 +1699,7 @@ export class ContinueHostAdapter {
continue
}
const tool: ContinueHostTool = {
runtimeChecklist: event.runtimeChecklist,
callId: event.callId,
name: event.name,
state: event.state,
@@ -1712,6 +1720,12 @@ export class ContinueHostAdapter {
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 &&
+16
View File
@@ -492,6 +492,22 @@ describe('ContinueAgentRuntime', () => {
} finally { await runtime.dispose() }
})
it('forwards committed checklist replacements with the request identity including clears', async () => {
mocks.runHost.mockImplementation(async (_prompt, _signal, _authorize, options) => {
options.onEvent({ type: 'checklist', checklist: { source: 'continue', items: [{ content: 'task', status: 'pending' }] } })
options.onEvent({ type: 'checklist', checklist: { source: 'continue', items: [] } })
return { text: 'OK' }
})
const runtime = createRuntime()
try {
const events = await collectEvents(runtime, 'execute')
expect(events.filter(event => event.type === 'checklist')).toEqual([
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'continue', items: [{ content: 'task', status: 'pending' }] } },
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'continue', items: [] } }
])
} finally { await runtime.dispose() }
})
it('adds assigned Skill instructions to the Continue prompt', async () => {
let hostOptions: ContinueHostAdapterOptions | undefined
const runtime = new ContinueAgentRuntime({
+2
View File
@@ -571,6 +571,8 @@ export class ContinueAgentRuntime implements AgentRuntime {
type: 'text',
delta: event.delta
}
} else if (event.type === 'checklist') {
yield { type: 'checklist', requestId: request.requestId, checklist: event.checklist }
} else if (event.type === 'tool') {
yield toContinueToolEvent(
request.requestId,
+40
View File
@@ -288,6 +288,45 @@ async function collectRun(
}
describe("OpenCodeRuntime embedded launcher", () => {
it('attributes todos to the current parent message and running call in reused sessions', async () => {
const setup = runClient([]);
let previousMessage = 'old-message';
const items = [{ content: 'task', status: 'pending', priority: 'low' }];
vi.mocked(setup.event.subscribe).mockImplementation(async () => ({
stream: (async function* () {
const current = vi.mocked(setup.session.promptAsync).mock.calls.at(-1)![0]!.messageID!;
const update = (parentID: string) => ({ type: 'message.updated', properties: { sessionID: 'session-1', info: { id: parentID + '-assistant', sessionID: 'session-1', role: 'assistant', parentID, time: { created: Date.now() } } } });
const tool = (parentID: string, todos: unknown[], status = 'running', callID = 'call') => ({ type: 'message.part.updated', properties: { sessionID: 'session-1', part: { id: 'part', callID, sessionID: 'session-1', messageID: parentID + '-assistant', type: 'tool', tool: 'todowrite', state: { status, input: { todos } } } } });
const todo = (sessionID: string, todos: unknown[]) => ({ type: 'todo.updated', properties: { sessionID, todos } });
yield update(previousMessage);
yield tool(previousMessage, items);
yield todo('session-1', items);
yield update(current);
yield tool(current, [], 'pending');
yield todo('session-1', items);
yield tool(current, items);
yield todo('child-session', items);
yield todo('session-1', [{ content: 'late old task', status: 'pending' }]);
yield todo('session-1', items);
yield completedToolEvent('call', 'todowrite');
yield tool(current, [], 'running', 'clear');
yield todo('session-1', []);
yield completedToolEvent('clear', 'todowrite');
previousMessage = current;
yield { type: 'session.idle', properties: { sessionID: 'session-1' } };
})()
}) as never);
const runtime = embeddedRuntime(setup.client);
try {
for (let round = 0; round < 2; round++) {
const events = await collectRun(runtime);
expect(events.filter(event => event.type === 'checklist')).toEqual([
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items } },
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items: [] } }
]);
}
} finally { await runtime.dispose(); }
});
it.each(["native", "profile"] as const)(
"disables unused automatic Git snapshots with %s model configuration",
async (configuration) => {
@@ -4831,6 +4870,7 @@ describe("OpenCodeRuntime native customization", () => {
command: "review",
arguments: "--staged",
agent: "build",
messageID: expect.stringMatching(/^msg_/u),
},
expect.objectContaining({
signal: expect.any(AbortSignal),
+47
View File
@@ -1,3 +1,4 @@
import { runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist';
import {
createOpencodeClient,
type AssistantMessage,
@@ -2445,6 +2446,13 @@ export class OpenCodeRuntime implements AgentRuntime {
};
};
let hasResponseTextAfterFailure = false;
// Match OC's sortable 48-bit timestamp prefix so reused-session history stays ordered.
const messageTime = ((BigInt(Date.now()) << 12n) & 0xffffffffffffn).toString(16).padStart(12, '0');
const promptMessageId = `msg_${messageTime}${crypto.randomUUID().replaceAll('-', '').slice(0, 14)}`;
const checklistMessageIds = new Set<string>();
const checklistCalls = new Map<string, unknown>();
const submittedChecklistCalls = new Set<string>();
const pendingChecklistUpdates: RuntimeChecklist[] = [];
try {
const promptText = promptWithUntrustedConversationHistory(
request,
@@ -2462,6 +2470,7 @@ export class OpenCodeRuntime implements AgentRuntime {
sessionID: sessionId,
directory,
command: selectedCommand.name,
messageID: promptMessageId,
arguments: selectedCommand.arguments,
...(selectedAgent ? { agent: selectedAgent } : {}),
...(this.options.modelProfile
@@ -2482,6 +2491,7 @@ export class OpenCodeRuntime implements AgentRuntime {
{
sessionID: sessionId,
directory,
messageID: promptMessageId,
model: this.options.modelProfile
? {
providerID: resolveOpenCodeProvider(
@@ -2538,6 +2548,43 @@ export class OpenCodeRuntime implements AgentRuntime {
subscription.stream,
subscriptionController,
)) {
if (signal.aborted) break;
if (event.type === 'message.updated' &&
event.properties.info.sessionID === sessionId &&
event.properties.info.role === 'assistant' &&
event.properties.info.parentID === promptMessageId) {
checklistMessageIds.add(event.properties.info.id);
}
if (event.type === 'message.part.updated') {
const part = event.properties.part;
if (part.sessionID === sessionId && part.type === 'tool' &&
part.tool === 'todowrite' && checklistMessageIds.has(part.messageID)) {
if (part.state.status === 'pending' && !submittedChecklistCalls.has(part.callID)) {
checklistCalls.set(part.callID, undefined);
} else if (part.state.status === 'running' && !submittedChecklistCalls.has(part.callID)) {
checklistCalls.set(part.callID, part.state.input.todos);
} else {
checklistCalls.delete(part.callID);
}
}
}
if (event.type === 'todo.updated' && event.properties.sessionID === sessionId) {
const parsed = runtimeChecklistSchema.safeParse({ source: 'opencode', items: event.properties.todos });
if (parsed.success && checklistCalls.size > 0) pendingChecklistUpdates.push(parsed.data);
}
// OC 1.18.29 publishes todo.updated before the tool's running input frame.
for (const [callId, items] of checklistCalls) {
const candidate = runtimeChecklistSchema.safeParse({ source: 'opencode', items });
if (!candidate.success) continue;
const index = pendingChecklistUpdates.findIndex(update => JSON.stringify(update) === JSON.stringify(candidate.data));
if (index >= 0) {
const checklist = pendingChecklistUpdates.splice(index, 1)[0]!;
submittedChecklistCalls.add(callId);
checklistCalls.delete(callId);
yield { type: 'checklist', requestId: request.requestId, checklist };
}
}
if (checklistCalls.size === 0) pendingChecklistUpdates.length = 0;
const childProgress = subagentProgress.update(event);
if (
childProgress &&
@@ -263,6 +263,25 @@ function claimManualScheduleQueueItem(
}
describe('AssistantDatabase', () => {
it('round trips long checklist metadata and explicit clears across reopen', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-checklist-'))
temporaryDirectories.push(directory)
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
await database.initialize(process.cwd())
const id = randomUUID()
const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const, content: '', createdAt: 1,
runtimeChecklist: { source: 'continue' as const, items: [{ content: 'task'.repeat(1500), status: 'pending' as const }] } }
database.replaceConversations([{ id, title: 'Checklist', updatedAt: 1, messages: [message] }])
database.close()
const reopened = new AssistantDatabase(path)
await reopened.initialize(process.cwd())
try {
expect(reopened.getConversation(id).messages[0]?.runtimeChecklist).toEqual(message.runtimeChecklist)
reopened.saveLocalConversations([{ header: { id, title: 'Checklist', updatedAt: 2 }, messages: [{ ...message, runtimeChecklist: { source: 'continue', items: [] } }] }])
expect(reopened.getConversation(id).messages[0]?.runtimeChecklist).toEqual({ source: 'continue', items: [] })
} finally { reopened.close() }
})
it('lists lightweight history without dropping searchable text or full process metadata', async () => {
const database = await createDatabase()
try {
@@ -3828,6 +3847,13 @@ describe('AssistantDatabase', () => {
}))
]
})
const checklistEvent = { requestId: taskId, type: 'checklist' as const,
checklist: { source: 'opencode' as const, items: [{ content: 'task', status: 'in_progress' as const }] } }
expect(() => append('7', 1, { ...checklistEvent, checklist: { source: 'invalid' as never, items: [] } })).toThrow()
expect(append('7', 1, checklistEvent)).toBe(true)
expect(append('7', 1, checklistEvent)).toBe(false)
expect(database.getConversation(conversationId).messages[1]?.runtimeChecklist).toEqual(checklistEvent.checklist)
expect(append('7', 2, { ...checklistEvent, checklist: { source: 'opencode', items: [] } })).toBe(true)
append('8', 0, {
requestId: taskId,
type: 'done'
@@ -3845,6 +3871,7 @@ describe('AssistantDatabase', () => {
expect(recoveredMessage).toMatchObject({
content: recoveredText,
reasoning: '先验证状态',
runtimeChecklist: { source: 'opencode', items: [] },
state: 'complete',
artifactIds: [
'00000000-0000-4000-8000-000000000707'
@@ -3961,6 +3988,7 @@ describe('AssistantDatabase', () => {
id: assistantMessageId,
content: recoveredText,
state: 'error',
runtimeChecklist: { source: 'opencode', items: [] },
status: '远端请求已不存在'
})
expect(reopened.listRecoverableRemoteTasks()).toEqual([])
+7
View File
@@ -275,6 +275,7 @@ type MessageMetadata = {
queueItemId?: string
status?: string
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
runtimeChecklist?: ConversationMessage['runtimeChecklist']
blocks?: ConversationSnapshot['messages'][number]['blocks']
displayCaptureTruncated?: boolean
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
@@ -1171,6 +1172,7 @@ function toConversationSnapshot(
role: message.role,
content: message.content,
reasoning: metadata.reasoning,
runtimeChecklist: metadata.runtimeChecklist,
blocks: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
@@ -1205,6 +1207,7 @@ function serializeConversationMessageMetadata(
queueItemId: message.queueItemId,
status: message.status,
reasoning: message.reasoning,
runtimeChecklist: message.runtimeChecklist,
blocks: message.blocks,
displayCaptureTruncated: message.displayCaptureTruncated,
contextCompression: message.contextCompression,
@@ -1373,6 +1376,8 @@ function reduceRecoveredAgentEvent(
blocks,
status: undefined
}
} else if (event.type === 'checklist') {
next = { ...message, runtimeChecklist: event.checklist }
} else if (event.type === 'status') {
next = { ...message, status: event.message }
} else if (event.type === 'context-compression') {
@@ -5015,6 +5020,7 @@ export class AssistantDatabase {
createdAt: metadata.createdAt ?? Date.parse(row.created_at),
status: metadata.status,
reasoning: metadata.reasoning,
runtimeChecklist: metadata.runtimeChecklist,
blocks: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
@@ -5429,6 +5435,7 @@ export class AssistantDatabase {
metadata.createdAt ?? Date.parse(row.created_at),
status: metadata.status,
reasoning: metadata.reasoning,
runtimeChecklist: metadata.runtimeChecklist,
blocks: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
+7 -5
View File
@@ -674,11 +674,13 @@ if (hasSingleInstanceLock) {
await settingsStore.getResolvedSettings(),
selection
),
resolveModelProfile: async (selection) =>
applyRuntimeSelection(
resolveModelProfile: async (selection) => {
const resolved = applyRuntimeSelection(
await settingsStore.getResolvedSettings(),
selection
).settings.opencodeModelProfile
).settings
return selection.provider === 'continue' ? resolved.continueModelProfile : resolved.opencodeModelProfile
}
})
await startupManagedRemoteExecutionServices.initialize()
managedRemoteExecutionServices =
@@ -1081,10 +1083,10 @@ if (hasSingleInstanceLock) {
selection
)
if (executionSpace?.kind === 'ssh') {
const profile = resolved.settings.opencodeModelProfile
const profile = selection.provider === 'continue' ? resolved.settings.continueModelProfile : resolved.settings.opencodeModelProfile
if (profile === undefined) {
throw new Error(
'托管远程 OpenCode 需要选择一个可用的文本模型配置'
'托管远程 Runtime 需要选择一个可用的文本模型配置'
)
}
return await startupManagedRemoteExecutionServices.createRuntime({
+24 -10
View File
@@ -16,6 +16,7 @@ import {
writeFile
} from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { isDeepStrictEqual } from 'node:util'
import { agentPlatformSchema, type AgentPlatform } from '../../shared/agent-target'
import {
agentPackageArchiveName,
@@ -24,6 +25,7 @@ import {
agentPackageInventorySchema,
type AgentPackageCatalog,
type AgentPackageCatalogEntry,
type AgentPackageDescriptor,
type AgentPackageDownloadProgress,
type AgentPackageInventory
} from '../../shared/agent-package-contracts'
@@ -105,6 +107,7 @@ export type AgentPackageManagerOptions = {
}
export type VerifiedRemoteAgentInstallCandidate = {
additionalRuntimes?: AgentPackageDescriptor['additionalRuntimes']
source: UpdateSource
platform: AgentPlatform
architecture: AgentArchitecture
@@ -115,8 +118,8 @@ export type VerifiedRemoteAgentInstallCandidate = {
minor: number
}
remoteRuntime: {
runtimeId: 'opencode'
provider: 'opencode'
runtimeId: 'opencode' | 'continue'
provider: 'opencode' | 'continue'
version: string
bundleDigest: string
protocol: {
@@ -138,8 +141,8 @@ export type VerifiedRemoteAgentEnvironmentCatalog = {
expected: {
agent: { version: string }
runtimes: Array<{
runtimeId: 'opencode'
provider: 'opencode'
runtimeId: 'opencode' | 'continue'
provider: 'opencode' | 'continue'
version: string
}>
}
@@ -170,15 +173,17 @@ export type AcquireInstallArchiveOptions = {
function expectedCatalog(
agentVersion: string,
runtimeVersion: string
runtimeVersion: string,
runtimeId: 'opencode' | 'continue' = 'opencode',
additionalRuntimes: NonNullable<AgentPackageDescriptor['additionalRuntimes']> = []
): VerifiedRemoteAgentEnvironmentCatalog['expected'] {
return {
agent: { version: agentVersion },
runtimes: [{
runtimeId: 'opencode',
provider: 'opencode',
runtimeId,
provider: runtimeId,
version: runtimeVersion
}]
}, ...additionalRuntimes.map(({ runtimeId, provider, version }) => ({ runtimeId, provider, version }))]
}
}
@@ -193,6 +198,7 @@ function candidateFromCatalogEntry(
version: entry.version,
minimumDesktopVersion: entry.minimumDesktopVersion,
agentProtocol: { ...entry.agentProtocol },
...(entry.additionalRuntimes ? { additionalRuntimes: structuredClone(entry.additionalRuntimes) } : {}),
remoteRuntime: {
...entry.remoteRuntime,
protocol: { ...entry.remoteRuntime.protocol }
@@ -217,6 +223,7 @@ function candidateFromVerifiedRecord(
version: descriptor.version,
minimumDesktopVersion: descriptor.minimumDesktopVersion,
agentProtocol: { ...descriptor.agentProtocol },
...(descriptor.additionalRuntimes ? { additionalRuntimes: structuredClone(descriptor.additionalRuntimes) } : {}),
remoteRuntime: {
...descriptor.remoteRuntime,
protocol: { ...descriptor.remoteRuntime.protocol }
@@ -399,7 +406,9 @@ export class AgentPackageManager {
return {
expected: expectedCatalog(
installed.verified.descriptor.version,
installed.verified.descriptor.remoteRuntime.version
installed.verified.descriptor.remoteRuntime.version,
installed.verified.descriptor.remoteRuntime.runtimeId,
installed.verified.descriptor.additionalRuntimes
),
candidate: null,
candidateFailure: {
@@ -414,7 +423,9 @@ export class AgentPackageManager {
}
const expected = expectedCatalog(
entry.version,
entry.remoteRuntime.version
entry.remoteRuntime.version,
entry.remoteRuntime.runtimeId,
entry.additionalRuntimes
)
try {
return {
@@ -710,6 +721,7 @@ export class AgentPackageManager {
candidate.agentProtocol.major &&
descriptor.agentProtocol.minor ===
candidate.agentProtocol.minor &&
isDeepStrictEqual(descriptor.additionalRuntimes ?? [], candidate.additionalRuntimes ?? []) &&
descriptor.remoteRuntime.runtimeId ===
candidate.remoteRuntime.runtimeId &&
descriptor.remoteRuntime.provider ===
@@ -835,6 +847,7 @@ export class AgentPackageManager {
entry.agentProtocol.major ||
descriptor.agentProtocol.minor !==
entry.agentProtocol.minor ||
!isDeepStrictEqual(descriptor.additionalRuntimes ?? [], entry.additionalRuntimes ?? []) ||
descriptor.remoteRuntime.runtimeId !==
entry.remoteRuntime.runtimeId ||
descriptor.remoteRuntime.provider !==
@@ -1981,6 +1994,7 @@ function assertCandidateMatchesCatalogEntry(
entry.agentProtocol.major ||
candidate.agentProtocol.minor !==
entry.agentProtocol.minor ||
!isDeepStrictEqual(candidate.additionalRuntimes ?? [], entry.additionalRuntimes ?? []) ||
candidate.remoteRuntime.runtimeId !==
entry.remoteRuntime.runtimeId ||
candidate.remoteRuntime.provider !==
@@ -223,30 +223,35 @@ export async function verifyExtractedAgentPackage(options: {
)
}
}
for (const runtime of [descriptor.remoteRuntime, ...(descriptor.additionalRuntimes ?? [])]) {
const runtimeBundle =
await loadVerifiedRemoteRuntimeResourceBundle(
runtimePaths,
descriptor.architecture,
{
runtimeId: runtime.runtimeId,
verificationEnvironment:
options.verificationEnvironment ?? 'production'
}
)
if (
runtimeBundle.manifest.runtimeId !== runtime.runtimeId ||
runtimeBundle.manifest.provider !== runtime.provider ||
runtimeBundle.manifest.platform !== descriptor.platform ||
runtimeBundle.manifest.runtimeVersion !==
descriptor.remoteRuntime.version ||
runtime.version ||
runtimeBundle.manifest.bundleDigest !==
descriptor.remoteRuntime.bundleDigest ||
runtime.bundleDigest ||
runtimeBundle.manifest.protocol.major !==
descriptor.remoteRuntime.protocol.major ||
runtime.protocol.major ||
runtimeBundle.manifest.protocol.minor !==
descriptor.remoteRuntime.protocol.minor
runtime.protocol.minor
) {
throw new Error(
'Agent package descriptor does not match its Runtime bundle'
)
}
}
return {
rootDirectory,
descriptor,
@@ -1,4 +1,4 @@
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
export type BundledRemoteRuntimeResourcePaths = {
@@ -72,7 +72,8 @@ export function resolveBundledRemoteRuntimeResourcePaths(
export function getBundledRemoteRuntimeRoot(
paths: BundledRemoteRuntimeResourcePaths,
architecture: AgentArchitecture
architecture: AgentArchitecture,
runtimeId: 'opencode' | 'continue' = 'opencode'
): string {
return paths.runtimeRoots[architecture]
return runtimeId === 'opencode' ? paths.runtimeRoots[architecture] : join(dirname(paths.runtimeRoots[architecture]), runtimeId)
}
@@ -103,7 +103,7 @@ export type PackageInstallerAgentIdentity = {
}
export type PackageInstallerRuntimeIdentity = {
runtimeId: 'opencode'
runtimeId: 'opencode' | 'continue'
runtimeVersion: string
bundleDigest: string
manifestDigest: string
@@ -121,6 +121,7 @@ export type PackageInstallerResult = {
archiveSha256: string
agent: PackageInstallerAgentIdentity
runtime: PackageInstallerRuntimeIdentity
additionalRuntimes?: PackageInstallerRuntimeIdentity[]
}
export type PackageInstallerEvent =
@@ -166,6 +167,7 @@ type VerifiedPackage = {
runtimeManifestDigest: string
agent: PackageInstallerAgentIdentity
runtime: PackageInstallerRuntimeIdentity
additionalRuntimes?: PackageInstallerRuntimeIdentity[]
}
type PreparedState = {
@@ -178,6 +180,7 @@ type PreparedState = {
packageSignatureSha256: string
agent: PackageInstallerAgentIdentity
runtime: PackageInstallerRuntimeIdentity
additionalRuntimes?: PackageInstallerRuntimeIdentity[]
}
function canonicalJson(value: unknown): string {
@@ -811,6 +814,7 @@ function assertOuterDescriptor(
'signingKeyId',
'agentProtocol',
'remoteRuntime',
...(descriptor.additionalRuntimes === undefined ? [] : ['additionalRuntimes']),
'contentDigest',
'files'
],
@@ -839,8 +843,8 @@ function assertOuterDescriptor(
'Agent package Runtime identity'
)
if (
remoteRuntime.runtimeId !== 'opencode' ||
remoteRuntime.provider !== 'opencode' ||
!['opencode', 'continue'].includes(String(remoteRuntime.runtimeId)) ||
remoteRuntime.provider !== remoteRuntime.runtimeId ||
!versionPattern.test(String(remoteRuntime.version)) ||
!digestPattern.test(String(remoteRuntime.bundleDigest))
) {
@@ -848,6 +852,18 @@ function assertOuterDescriptor(
}
protocol(descriptor.agentProtocol, 'Agent package protocol')
protocol(remoteRuntime.protocol, 'Agent package Runtime protocol')
if (descriptor.additionalRuntimes !== undefined) {
if (!Array.isArray(descriptor.additionalRuntimes) || descriptor.additionalRuntimes.length > 1) throw new Error('Invalid additional Runtimes')
for (const extra of descriptor.additionalRuntimes) {
if (!isRecord(extra)) throw new Error('Invalid additional Runtime')
exactKeys(extra, ['runtimeId', 'provider', 'version', 'bundleDigest', 'protocol'], 'Additional Runtime')
if (!['opencode', 'continue'].includes(String(extra.runtimeId)) || extra.runtimeId === remoteRuntime.runtimeId ||
extra.provider !== extra.runtimeId || !versionPattern.test(String(extra.version)) || !digestPattern.test(String(extra.bundleDigest))) {
throw new Error('Invalid additional Runtime identity')
}
protocol(extra.protocol, 'Additional Runtime protocol')
}
}
const { contentDigest: _contentDigest, ...content } = descriptor
void _contentDigest
if (
@@ -864,6 +880,11 @@ function assertOuterDescriptor(
'Agent package'
)
const files = parseFiles(descriptor.files, 'Agent package')
const runtimePrefixes = [remoteRuntime, ...((descriptor.additionalRuntimes as JsonRecord[] | undefined) ?? [])]
.map(runtime => `runtime/${String(runtime.runtimeId)}/${String(runtime.bundleDigest).slice(7)}/`)
if (files.some(file => file.path.startsWith('runtime/') && !runtimePrefixes.some(prefix => file.path.startsWith(prefix)))) {
throw new Error('Agent package contains an undeclared Runtime')
}
const expected = new Set([
...files.map((file) => file.path),
PACKAGE_DESCRIPTOR,
@@ -1093,8 +1114,9 @@ function assertRuntimeManifest(
],
'Runtime manifest'
)
const runtime = isRecord(lock.runtimes) && isRecord(lock.runtimes.opencode)
? lock.runtimes.opencode
const remote = descriptor.remoteRuntime as JsonRecord
const runtime = isRecord(lock.runtimes) && isRecord(lock.runtimes[String(remote.runtimeId)])
? lock.runtimes[String(remote.runtimeId)] as JsonRecord
: undefined
const targetKey = descriptor.platform === 'darwin' ? 'darwin-arm64' : String(descriptor.architecture)
const target =
@@ -1103,12 +1125,11 @@ function assertRuntimeManifest(
isRecord(runtime.targets[targetKey])
? runtime.targets[targetKey]
: undefined
const remote = descriptor.remoteRuntime as JsonRecord
if (
manifest.formatVersion !== 2 ||
manifest.product !== 'GoodBuddy' ||
manifest.runtimeId !== 'opencode' ||
manifest.provider !== 'opencode' ||
manifest.runtimeId !== remote.runtimeId ||
manifest.provider !== remote.provider ||
manifest.platform !== descriptor.platform ||
manifest.architecture !== descriptor.architecture ||
!versionPattern.test(String(manifest.runtimeVersion)) ||
@@ -1195,14 +1216,14 @@ function assertRuntimeManifest(
)
const files = parseFiles(manifest.files, 'Runtime manifest')
const digestDirectory = String(manifest.bundleDigest).slice('sha256:'.length)
const prefix = `runtime/opencode/${digestDirectory}/`
const prefix = `runtime/${String(remote.runtimeId)}/${digestDirectory}/`
const expectedRuntimePaths = new Set([
...files.map((file) => `${prefix}${file.path}`),
`${prefix}manifest.json`,
`${prefix}manifest.sig`
])
const actualRuntimePaths = [...packageFiles.keys()].filter((path) =>
path.startsWith('runtime/')
path.startsWith(`runtime/${String(remote.runtimeId)}/`)
)
if (
actualRuntimePaths.length !== expectedRuntimePaths.size ||
@@ -1214,7 +1235,7 @@ function assertRuntimeManifest(
if (!safeManifestPath(file.path)) {
throw new Error(`Runtime manifest path is invalid: ${file.path}`)
}
const expectedMode = file.path === 'bin/opencode' ? '0755' : '0644'
const expectedMode = file.path === entrypoint.path ? '0755' : '0644'
const outer = packageFiles.get(`${prefix}${file.path}`)
if (
file.mode !== expectedMode ||
@@ -1232,20 +1253,20 @@ function assertRuntimeManifest(
'Runtime manifest'
)
const openCodeLicense = runtimeLicenses.find(
(license) => license.package === 'opencode-ai'
(license) => license.package === (remote.runtimeId === 'continue' ? '@continuedev/cli' : 'opencode-ai')
)
if (
openCodeLicense?.version !== manifest.runtimeVersion ||
openCodeLicense?.spdx !== 'MIT' ||
openCodeLicense?.path !== 'licenses/opencode-MIT.txt'
openCodeLicense?.spdx !== (remote.runtimeId === 'continue' ? 'Apache-2.0' : 'MIT') ||
openCodeLicense?.path !== (remote.runtimeId === 'continue' ? 'licenses/continue-Apache-2.0.txt' : 'licenses/opencode-MIT.txt')
) {
throw new Error('Runtime license declaration is invalid')
}
if (
entrypoint.path !== 'bin/opencode' ||
entrypoint.path !== (remote.runtimeId === 'continue' ? 'lib/continue/dist/cn.js' : 'bin/opencode') ||
!files.some(
(file) =>
file.path === 'bin/opencode' &&
file.path === entrypoint.path &&
file.sha256 === entrypoint.sha256
)
) {
@@ -1299,12 +1320,13 @@ function elfArchitecture(bytes: Buffer, platform: Platform): Architecture | unde
function assertExtractedArchitectures(
preparedRoot: string,
architecture: Architecture,
platform: Platform
platform: Platform,
runtimeId: 'opencode' | 'continue'
): void {
for (const path of [
join(preparedRoot, 'agent', 'node'),
...nativeKoffiPaths(platform, architecture).map(path => join(preparedRoot, 'agent', path)),
join(preparedRoot, 'runtime', 'bin', 'opencode')
...(runtimeId === 'opencode' ? [join(preparedRoot, 'runtime', 'bin', 'opencode')] : [])
]) {
if (
elfArchitecture(readFileHeader(path, 64), platform) !== architecture
@@ -1460,13 +1482,14 @@ function verifyArchive(
const runtimeDigest = String(
(descriptor.remoteRuntime as JsonRecord).bundleDigest
).slice('sha256:'.length)
const runtimeId = (descriptor.remoteRuntime as JsonRecord).runtimeId as 'opencode' | 'continue'
const runtimeManifestBytes = readEntry(
handle,
entries,
`runtime/opencode/${runtimeDigest}/manifest.json`
`runtime/${runtimeId}/${runtimeDigest}/manifest.json`
)
assertDeclaredMetadata(
`runtime/opencode/${runtimeDigest}/manifest.json`,
`runtime/${runtimeId}/${runtimeDigest}/manifest.json`,
runtimeManifestBytes,
declaredMap
)
@@ -1481,7 +1504,7 @@ function verifyArchive(
declaredMap
)
const runtimeSignaturePath =
`runtime/opencode/${runtimeDigest}/manifest.sig`
`runtime/${runtimeId}/${runtimeDigest}/manifest.sig`
const runtimeSignatureBytes = readEntry(
handle,
entries,
@@ -1503,6 +1526,16 @@ function verifyArchive(
descriptor,
declaredMap
)
const additionalRuntimes = (descriptor.additionalRuntimes as JsonRecord[] | undefined)?.map(remote => {
const prefix = `runtime/${String(remote.runtimeId)}/${String(remote.bundleDigest).slice(7)}/`
const bytes = readEntry(handle, entries, `${prefix}manifest.json`)
const signature = readEntry(handle, entries, `${prefix}manifest.sig`)
assertDeclaredMetadata(`${prefix}manifest.json`, bytes, declaredMap)
assertDeclaredMetadata(`${prefix}manifest.sig`, signature, declaredMap)
const verified = assertRuntimeManifest(bytes, signature, registry,
parseCanonicalJson(remoteRuntimeLockBytes, 'Remote Runtime lock'), { ...descriptor, remoteRuntime: remote }, declaredMap)
return runtimeIdentity(verified, descriptor)
})
if (!sameSnapshot(snapshot, fstatSync(handle))) {
throw new Error('Agent package archive changed during verification')
}
@@ -1522,6 +1555,7 @@ function verifyArchive(
runtimeManifest: runtime.manifest,
agentManifestSha256,
runtimeManifestDigest: runtime.manifestDigest,
...(additionalRuntimes ? { additionalRuntimes } : {}),
agent: {
installationId: `agent-${agentManifestSha256}`,
agentVersion: String(agentManifest.agentVersion),
@@ -1533,7 +1567,7 @@ function verifyArchive(
supervisor: 'detached-on-demand'
},
runtime: {
runtimeId: 'opencode',
runtimeId,
runtimeVersion: String(runtime.manifest.runtimeVersion),
bundleDigest: String(runtime.manifest.bundleDigest),
manifestDigest: runtime.manifestDigest,
@@ -1646,7 +1680,8 @@ function persistPreparedState(
packageSignatureSha256:
sha256(verified.packageSignatureBytes),
agent: verified.agent,
runtime: verified.runtime
runtime: verified.runtime,
...(verified.additionalRuntimes ? { additionalRuntimes: verified.additionalRuntimes } : {})
}
const bytes = prettyBytes(state)
if (bytes.length > MAXIMUM_METADATA_BYTES) {
@@ -1728,7 +1763,10 @@ function extractPayload(
mkdirSync(join(temporary, 'agent'), { recursive: true, mode: 0o700 })
mkdirSync(join(temporary, 'runtime'), { recursive: true, mode: 0o700 })
const runtimePrefix =
`runtime/opencode/${verified.runtime.bundleDigest.slice('sha256:'.length)}/`
`runtime/${verified.runtime.runtimeId}/${verified.runtime.bundleDigest.slice('sha256:'.length)}/`
const extraPrefixes = (verified.additionalRuntimes ?? []).map(runtime => ({
prefix: `runtime/${runtime.runtimeId}/${runtime.bundleDigest.slice(7)}/`, directory: `runtime-${runtime.runtimeId}`
}))
const declared = new Map(
parseFiles(verified.descriptor.files, 'Agent package')
.map((file) => [file.path, file])
@@ -1757,6 +1795,9 @@ function extractPayload(
...name.slice(runtimePrefix.length).split('/')
)
}
for (const extra of extraPrefixes) {
if (name.startsWith(extra.prefix)) target = join(temporary, extra.directory, ...name.slice(extra.prefix.length).split('/'))
}
if (target !== undefined) {
const actual = streamEntry(handle, entry, target)
const expected = declared.get(name)
@@ -1777,7 +1818,11 @@ function extractPayload(
} finally {
closeSync(handle)
}
assertExtractedArchitectures(temporary, verified.agent.architecture, verified.agent.platform)
assertExtractedArchitectures(temporary, verified.agent.architecture, verified.agent.platform, verified.runtime.runtimeId)
if (verified.additionalRuntimes?.some(runtime => runtime.runtimeId === 'opencode') &&
elfArchitecture(readFileHeader(join(temporary, 'runtime-opencode/bin/opencode'), 64), verified.agent.platform) !== verified.agent.architecture) {
throw new Error('Additional OpenCode executable architecture mismatch')
}
chmodSync(temporary, 0o700)
renameSync(temporary, destinationRoot)
} catch (error) {
@@ -1812,7 +1857,8 @@ export function preparePackage(options: InstallerOptions): PackageInstallerResul
status: 'prepared',
archiveSha256: state.archiveSha256,
agent: state.agent,
runtime: state.runtime
runtime: state.runtime,
...(state.additionalRuntimes ? { additionalRuntimes: state.additionalRuntimes } : {})
}
options.emit?.(result)
return result
@@ -1843,7 +1889,8 @@ export function preparePackage(options: InstallerOptions): PackageInstallerResul
status: 'prepared',
archiveSha256: verified.archiveSha256,
agent: verified.agent,
runtime: verified.runtime
runtime: verified.runtime,
...(verified.additionalRuntimes ? { additionalRuntimes: verified.additionalRuntimes } : {})
}
options.emit?.(result)
return result
@@ -2070,6 +2117,7 @@ function readPreparedState(path: string): PreparedState {
'packageSignatureSha256',
'agent',
'runtime'
,...(value.additionalRuntimes === undefined ? [] : ['additionalRuntimes'])
],
'Prepared package state'
)
@@ -2097,6 +2145,11 @@ function readPreparedState(path: string): PreparedState {
throw new Error('Prepared package state is invalid')
}
const agent = value.agent
if (value.additionalRuntimes !== undefined && (!Array.isArray(value.additionalRuntimes) || value.additionalRuntimes.length > 1 ||
value.additionalRuntimes.some(extra => !isRecord(extra) || !['opencode', 'continue'].includes(String(extra.runtimeId)) ||
extra.runtimeId === (value.runtime as JsonRecord).runtimeId || !digestPattern.test(String(extra.bundleDigest))))) {
throw new Error('Invalid prepared additional Runtimes')
}
const runtime = value.runtime
exactKeys(
agent,
@@ -2141,7 +2194,7 @@ function readPreparedState(path: string): PreparedState {
!isSupportedTarget(agent.platform, agent.architecture) ||
!['x64', 'arm64'].includes(String(agent.architecture)) ||
agent.supervisor !== 'detached-on-demand' ||
runtime.runtimeId !== 'opencode' ||
!['opencode', 'continue'].includes(String(runtime.runtimeId)) ||
!versionPattern.test(String(runtime.runtimeVersion)) ||
!digestPattern.test(String(runtime.bundleDigest)) ||
!digestPattern.test(String(runtime.manifestDigest)) ||
@@ -2155,6 +2208,7 @@ function readPreparedState(path: string): PreparedState {
return {
formatVersion: 1,
archiveSha256: String(value.archiveSha256),
...(value.additionalRuntimes ? { additionalRuntimes: value.additionalRuntimes as PackageInstallerRuntimeIdentity[] } : {}),
releaseKeyRegistrySha256:
String(value.releaseKeyRegistrySha256),
agentRuntimeLockSha256:
@@ -2176,7 +2230,7 @@ function readPreparedState(path: string): PreparedState {
supervisor: 'detached-on-demand'
},
runtime: {
runtimeId: 'opencode',
runtimeId: runtime.runtimeId as 'opencode' | 'continue',
runtimeVersion: String(runtime.runtimeVersion),
bundleDigest: String(runtime.bundleDigest),
manifestDigest: String(runtime.manifestDigest),
@@ -2230,6 +2284,16 @@ function readBoundedRegularFile(
}
}
function runtimeIdentity(runtime: { manifest: JsonRecord; manifestDigest: string }, descriptor: JsonRecord): PackageInstallerRuntimeIdentity {
return {
runtimeId: runtime.manifest.runtimeId as 'opencode' | 'continue',
runtimeVersion: String(runtime.manifest.runtimeVersion), bundleDigest: String(runtime.manifest.bundleDigest),
manifestDigest: runtime.manifestDigest, runtimeAdapterDigest: String(runtime.manifest.adapterDigest),
acpCapabilitiesDigest: String(runtime.manifest.acpCapabilitiesDigest), platform: descriptor.platform as Platform,
architecture: descriptor.architecture as Architecture, protocol: protocol(runtime.manifest.protocol, 'Runtime protocol')
}
}
function assertPreparedIdentity(
agentRoot: string,
runtimeRoot: string,
@@ -2238,12 +2302,17 @@ function assertPreparedIdentity(
agentRuntimeLockBytes: Buffer,
remoteRuntimeLockBytes: Buffer,
descriptorBytes: Buffer,
packageSignatureBytes: Buffer
packageSignatureBytes: Buffer,
selectedRuntime = state.runtime
): void {
const descriptor = parseCanonicalJson(
descriptorBytes,
'Prepared Agent package descriptor'
)
const packagedRuntimes = [descriptor.remoteRuntime, ...((descriptor.additionalRuntimes as JsonRecord[] | undefined) ?? [])] as JsonRecord[]
if (packagedRuntimes.length !== 1 + (state.additionalRuntimes?.length ?? 0)) throw new Error('Prepared Runtime inventory differs from package')
const remoteRuntime = packagedRuntimes.find(runtime => runtime.runtimeId === selectedRuntime.runtimeId)
if (!remoteRuntime) throw new Error('Prepared Runtime is not declared in package')
const declared = parseFiles(
descriptor.files,
'Prepared Agent package'
@@ -2323,7 +2392,7 @@ function assertPreparedIdentity(
remoteRuntimeLockBytes,
'Prepared Remote Runtime lock'
),
descriptor,
{ ...descriptor, remoteRuntime },
packageFiles
)
const agentManifestSha256 = sha256(agentManifestBytes)
@@ -2339,7 +2408,7 @@ function assertPreparedIdentity(
supervisor: 'detached-on-demand'
}
const expectedRuntime: PackageInstallerRuntimeIdentity = {
runtimeId: 'opencode',
runtimeId: runtime.manifest.runtimeId as 'opencode' | 'continue',
runtimeVersion: String(runtime.manifest.runtimeVersion),
bundleDigest: String(runtime.manifest.bundleDigest),
manifestDigest: runtime.manifestDigest,
@@ -2356,7 +2425,7 @@ function assertPreparedIdentity(
}
if (
canonicalJson(state.agent) !== canonicalJson(expectedAgent) ||
canonicalJson(state.runtime) !== canonicalJson(expectedRuntime)
canonicalJson(selectedRuntime) !== canonicalJson(expectedRuntime)
) {
throw new Error(
'Prepared package identity does not match the authenticated archive'
@@ -2473,6 +2542,15 @@ export function commitPackage(options: InstallerOptions): PackageInstallerResult
join(managedRoot, 'runtimes', 'release-keys.json'),
join(managedRoot, 'runtimes', 'remote-runtime-lock.json')
] as const
const extraPublications = (state.additionalRuntimes ?? []).map(runtime => {
ensureOwnedManagedHierarchy(home, runtime.runtimeId)
const source = join(payloadRoot, `runtime-${runtime.runtimeId}`)
const destination = join(managedRoot, 'runtimes', runtime.runtimeId, runtime.bundleDigest.slice(7))
assertPreparedIdentity(agentSourceExists ? agentSource : agentDestination,
existsSync(source) ? source : destination, state, releaseKeyRegistryBytes, agentRuntimeLockBytes,
remoteRuntimeLockBytes, descriptorBytes, packageSignatureBytes, runtime)
return { source, destination }
})
if (!agentSourceExists || !runtimeSourceExists) {
assertPreparedIdentity(
agentSourceExists ? agentSource : agentDestination,
@@ -2507,6 +2585,9 @@ export function commitPackage(options: InstallerOptions): PackageInstallerResult
)
)
}
for (const extra of extraPublications) {
if (existsSync(extra.source)) published.push(publishDirectory(extra.source, extra.destination, inputs.operationRoot))
}
progress('publishing-metadata')
writePrivateFileAtomic(
metadataPaths[0],
@@ -2529,7 +2610,8 @@ export function commitPackage(options: InstallerOptions): PackageInstallerResult
status: 'committed',
archiveSha256: state.archiveSha256,
agent: state.agent,
runtime: state.runtime
runtime: state.runtime,
...(state.additionalRuntimes ? { additionalRuntimes: state.additionalRuntimes } : {})
}
options.emit?.(result)
return result
@@ -277,19 +277,25 @@ describe('createManagedRemoteAcpRuntime', () => {
await runtime.dispose()
})
it('rejects non-OpenCode providers before acquiring remote resources', async () => {
it('rejects unsupported providers before acquiring remote resources', async () => {
const fixture = harness({
selection: { provider: 'continue' }
selection: { provider: 'model', profileId: '00000000-0000-4000-8000-000000000001' }
})
await expect(
createManagedRemoteAcpRuntime(fixture.options)
).rejects.toThrow(/OpenCode Runtime/iu)
).rejects.toThrow(/OpenCode or Continue/iu)
expect(
fixture.options.agentServices.connectionManager.acquire
).not.toHaveBeenCalled()
})
it('rejects an installed Runtime that differs from the selected Continue provider', async () => {
const fixture = harness({ selection: { provider: 'continue' } })
await expect(createManagedRemoteAcpRuntime(fixture.options)).rejects.toThrow(/selected provider/iu)
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
it('binds the current Agent and Runtime and owns its connection lease', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
@@ -307,6 +313,7 @@ describe('createManagedRemoteAcpRuntime', () => {
expect(
fixture.options.runtimeInstallationManager.activateInstalled
).toHaveBeenCalledWith('host-1', {
runtimeId: 'opencode',
agentInstallationId: 'agent-1'
})
expect(
@@ -43,7 +43,7 @@ type ManagedRemoteTransportIdentity = {
}
type ManagedRemoteRuntimeIdentity = {
runtimeId: 'opencode'
runtimeId: 'opencode' | 'continue'
runtimeVersion: string
bundleDigest: string
runtimeAdapterDigest: string
@@ -80,9 +80,9 @@ export async function createManagedRemoteAcpRuntime(
options: ManagedRemoteAcpRuntimeOptions
): Promise<AgentRuntime> {
const descriptor = options.executionSpace
if (options.selection.provider !== 'opencode') {
if (options.selection.provider !== 'opencode' && options.selection.provider !== 'continue') {
throw new Error(
'Managed remote execution requires the OpenCode Runtime'
'Managed remote execution requires OpenCode or Continue'
)
}
const workspaceBinding = workspaceProjectBinding(descriptor)
@@ -114,8 +114,11 @@ export async function createManagedRemoteAcpRuntime(
const runtimeInstallation =
await options.runtimeInstallationManager.activateInstalled(
descriptor.hostId,
{ agentInstallationId: agent.installationId }
{ agentInstallationId: agent.installationId, runtimeId: options.selection.provider }
)
if (runtimeInstallation.runtimeId !== options.selection.provider) {
throw new Error('Installed Runtime does not match the selected provider')
}
const capabilities = await activeConnection.refreshCapabilities()
assertConnectionMatches(workspaceBinding, agent, activeConnection)
const runtime = runtimeIdentityFromCapabilities(
@@ -137,8 +140,8 @@ export async function createManagedRemoteAcpRuntime(
transport: ManagedRemoteTransportIdentity
): AcpRemoteRuntime => {
const remoteOptions: AcpRemoteRuntimeOptions = {
runtimeId: 'opencode',
label: 'OpenCode(远程托管)',
runtimeId: runtime.runtimeId,
label: `${runtime.runtimeId === 'continue' ? 'Continue' : 'OpenCode'}(远程托管)`,
workspacePath: descriptor.remoteRootPath,
identity: {
controllerId,
@@ -180,7 +183,7 @@ export async function createManagedRemoteAcpRuntime(
...(options.usage
? {
usage: {
runtime: 'opencode',
runtime: runtime.runtimeId,
provider: options.usage.provider,
model: options.usage.model
}
@@ -296,7 +299,7 @@ class ManagedRemoteRuntimeResources {
connection: this.connection,
openIdentity: {
bindingId,
runtimeId: 'opencode',
runtimeId: this.runtime.runtimeId,
runtimeBundleDigest: this.runtime.bundleDigest,
workspaceIdentity: this.workspaceIdentity
}
@@ -429,7 +432,7 @@ class ManagedRemoteAcpRuntime implements AgentRuntime {
readonly requiresToolApproval = false
readonly supportsScopedDataTools = false
readonly capability = 'chat' as const
readonly runtimeId = 'opencode' as const
get runtimeId(): 'opencode' | 'continue' { return this.remote.runtimeId as 'opencode' | 'continue' }
#disposePromise?: Promise<void>
#transitionTail: Promise<void> = Promise.resolve()
#activeRuns = 0
@@ -660,7 +663,7 @@ function runtimeIdentityFromCapabilities(
>['capabilities']
): ManagedRemoteRuntimeIdentity {
if (
installation.runtimeId !== 'opencode' ||
(installation.runtimeId !== 'opencode' && installation.runtimeId !== 'continue') ||
(installation.platform !== 'linux' && !(installation.platform === 'darwin' && installation.architecture === 'arm64'))
) {
throw new Error(
@@ -668,7 +671,7 @@ function runtimeIdentityFromCapabilities(
)
}
const runtime: ManagedRemoteRuntimeIdentity = {
runtimeId: 'opencode',
runtimeId: installation.runtimeId,
runtimeVersion: installation.runtimeVersion,
bundleDigest: installation.bundleDigest,
runtimeAdapterDigest: installation.runtimeAdapterDigest,
@@ -718,7 +721,7 @@ function assertRuntimeAdvertised(
(entry) => entry.name === RUNTIME_ACP_CAPABILITY.name
)
const advertised = capabilities.runtimes.filter(
(entry) => entry.runtimeId === 'opencode'
(entry) => entry.runtimeId === runtime.runtimeId
)
if (
capability === undefined ||
@@ -93,7 +93,7 @@ export class ManagedRemoteExecutionServices {
)
).installationId
signal.throwIfAborted()
if (runtimeId !== 'opencode') {
if (runtimeId !== 'opencode' && runtimeId !== 'continue') {
throw new Error(
'Remote Runtime identity is unsupported'
)
@@ -186,6 +186,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
expect(test.calls).toEqual(['model', 'activate', 'refresh'])
expect(test.activateInstalled).toHaveBeenCalledWith('host-1', {
runtimeId: 'opencode',
agentInstallationId,
signal: test.input.signal
})
@@ -209,7 +210,7 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
expect(() => lease.assertCurrent()).toThrow(/not ready/iu)
})
it('rejects non-OpenCode selections before installation', async () => {
it('rejects unsupported selections before installation', async () => {
const test = harness({
selection: {
provider: 'model',
@@ -218,12 +219,28 @@ describe('ManagedRemoteProjectRuntimeValidator', () => {
})
await expect(test.validator.validate(test.input)).rejects.toThrow(
/OpenCode Runtime/iu
/OpenCode or Continue/iu
)
expect(test.activateInstalled).not.toHaveBeenCalled()
expect(test.refreshCapabilities).not.toHaveBeenCalled()
})
it('validates Continue against its own installed bundle and capability', async () => {
const refreshed = advertisedCapabilities()
refreshed.runtimes[0]!.runtimeId = 'continue'
const test = harness({
selection: { provider: 'continue' },
installed: { ...installation, runtimeId: 'continue' },
refreshed
})
const lease = await test.validator.validate(test.input)
expect(test.activateInstalled).toHaveBeenCalledWith('host-1', {
runtimeId: 'continue', agentInstallationId, signal: test.input.signal
})
expect(() => lease.assertCurrent()).not.toThrow()
lease.release()
})
it('requires the installed Runtime architecture to exactly match the Agent', async () => {
const test = harness({ architecture: 'arm64' })
@@ -46,10 +46,10 @@ export class ManagedRemoteProjectRuntimeValidator
): Promise<RemoteProjectRuntimeValidationLease> {
input.signal.throwIfAborted()
if (
input.selection.provider !== 'opencode'
input.selection.provider !== 'opencode' && input.selection.provider !== 'continue'
) {
throw new Error(
'Managed remote projects require the OpenCode Runtime'
'Managed remote projects require OpenCode or Continue'
)
}
assertInputIdentity(input)
@@ -72,7 +72,8 @@ export class ManagedRemoteProjectRuntimeValidator
input.host.hostId,
{
signal: input.signal,
agentInstallationId: input.agent.installationId
agentInstallationId: input.agent.installationId,
runtimeId: input.selection.provider
}
)
input.signal.throwIfAborted()
@@ -164,7 +165,7 @@ function assertInstallationIdentity(
installation: RemoteRuntimeInstallationIdentity
): void {
if (
installation.runtimeId !== 'opencode' ||
installation.runtimeId !== input.selection.provider ||
installation.platform !== input.agent.platform ||
installation.architecture !== input.agent.architecture
) {
@@ -186,7 +187,7 @@ function assertRuntimeCapability(
capability.name === RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME
)
const runtimes = capabilities.runtimes.filter(
(runtime) => runtime.runtimeId === 'opencode'
(runtime) => runtime.runtimeId === installation.runtimeId
)
if (
acp === undefined ||
@@ -594,6 +594,24 @@ describe('ProtocolRemoteRuntimeChannel', () => {
await fixture.channel.close()
})
it('allows cold native initialization beyond the ordinary control timeout without resending', async () => {
const fixture = await openChannel()
vi.useFakeTimers()
try {
fixture.client.responder = async (method, params) => {
if (method === 'runtime/startPrompt') await new Promise(resolve => setTimeout(resolve, 20_000))
return defaultResponse(method, params)
}
const started = fixture.channel.startOwnedPrompt({
bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1',
prompt: [{ type: 'text', text: 'hello' }]
})
await vi.advanceTimersByTimeAsync(20_000)
await expect(started).resolves.toMatchObject({ sessionId: 'session-owned-1' })
expect(fixture.client.requests.filter(request => request.method === 'runtime/startPrompt')).toHaveLength(1)
} finally { vi.useRealTimers(); await fixture.channel.close() }
})
it('attaches the exact operation when a prompt start response is lost', async () => {
const fixture = await openChannel()
const deadlineAt = new Date(Date.now() + 10_000).toISOString()
@@ -1116,7 +1116,10 @@ export class ProtocolRemoteRuntimeChannel
this.#state.client,
method,
params,
this.#state.controlTimeoutMs,
// Cold OpenCode ACP initialization on the shared Host exceeds 15 seconds.
method === 'runtime/startPrompt'
? Math.max(this.#state.controlTimeoutMs, 30_000)
: this.#state.controlTimeoutMs,
this.#lifetime.signal
)
this.#assertCurrent()
@@ -389,21 +389,23 @@ export class RemoteEnvironmentPreparer implements Preparer {
signal.throwIfAborted()
emit(method, 'installing-runtime')
for (const runtime of [identity.runtime, ...(identity.additionalRuntimes ?? [])]) {
assertAgentCommandSucceeded(
await lease.runAgentRuntimeAction(
installationId,
{
kind: 'runtime-activate',
runtimeId: identity.runtime.runtimeId,
bundleDigest: identity.runtime.bundleDigest,
architecture: identity.runtime.architecture,
runtimeId: runtime.runtimeId,
bundleDigest: runtime.bundleDigest,
architecture: runtime.architecture,
forceVerification: true
},
signal
),
'OpenCode Runtime 激活'
`${runtime.runtimeId} Runtime 激活`
)
signal.throwIfAborted()
}
}
async #prepareUploadedArchive(options: {
@@ -693,6 +695,14 @@ function assertPackageIdentity(
identity: SshRemotePackageIdentity,
candidate: VerifiedRemoteAgentInstallCandidate
): void {
const extras = identity.additionalRuntimes ?? []
const expected = candidate.additionalRuntimes ?? []
if (extras.length !== expected.length || extras.some((runtime, index) => {
const item = expected[index]!
return runtime.runtimeId !== item.runtimeId || runtime.runtimeVersion !== item.version ||
runtime.bundleDigest !== item.bundleDigest || runtime.platform !== candidate.platform ||
runtime.architecture !== candidate.architecture || !isDeepStrictEqual(runtime.protocol, item.protocol)
})) throw new Error('Additional Runtime identity does not match the signed package')
if (
identity.archiveSha256 !== candidate.sha256 ||
identity.agent.agentVersion !== candidate.version ||
@@ -21,7 +21,6 @@ import { remoteHostTargetIdentityKey } from './remote-host-target-identity'
const MAXIMUM_METADATA_BYTES = 1024 * 1024
const RUNTIME_REGISTRY_PATH =
'.goodbuddy/runtimes/registry.json'
const OPENCODE_ROOT = '.goodbuddy/runtimes/opencode'
export type RemoteRuntimeInstallationPhase =
| 'inspecting-host'
@@ -45,6 +44,7 @@ export interface RemoteRuntimeInstallationTargetResolver {
}
export type RemoteRuntimeActivationRequestOptions = {
runtimeId?: 'opencode' | 'continue'
signal?: AbortSignal
onProgress?: (phase: RemoteRuntimeInstallationPhase) => void
agentInstallationId?: VerifiedAgentInstallationId
@@ -117,7 +117,8 @@ export class RemoteRuntimeInstallationManager {
this.#coordinator.assertAvailable()
options.signal?.throwIfAborted()
emitOne(options.onProgress, 'inspecting-host')
const agentKey = options.agentInstallationId ?? ''
const runtimeId = options.runtimeId ?? 'opencode'
const agentKey = `${options.agentInstallationId ?? ''}\0${runtimeId}`
const earlyCached =
this.#coordinator.cachedForHost(hostId, agentKey)
if (earlyCached !== undefined) {
@@ -154,6 +155,7 @@ export class RemoteRuntimeInstallationManager {
this.#activateRegistered(
target,
options.agentInstallationId,
runtimeId,
signal,
progress
)
@@ -171,6 +173,7 @@ export class RemoteRuntimeInstallationManager {
async #activateRegistered(
target: SshConnectionPoolTarget,
agentInstallationId: VerifiedAgentInstallationId | undefined,
runtimeId: 'opencode' | 'continue',
signal: AbortSignal,
progress: (phase: RemoteRuntimeInstallationPhase) => void
): Promise<RemoteRuntimeInstallationIdentity> {
@@ -182,7 +185,7 @@ export class RemoteRuntimeInstallationManager {
const probe = await lease.runAgentBootstrapProbe(signal)
if (!probe.ready) {
throw new RemoteRuntimeInstallationError(
`Remote host cannot activate OpenCode Runtime: ${probe.reason}`,
`Remote host cannot activate ${runtimeId} Runtime: ${probe.reason}`,
'incompatible',
probe.reason
)
@@ -222,12 +225,12 @@ export class RemoteRuntimeInstallationManager {
)
const current = registry.current.find(
(entry) =>
entry.runtimeId === 'opencode' &&
entry.runtimeId === runtimeId &&
entry.architecture === probe.architecture
)
if (current === undefined) {
throw new RemoteRuntimeInstallationError(
'The Host has no current OpenCode Runtime for this architecture',
`The Host has no current ${runtimeId} Runtime for this architecture`,
'incompatible'
)
}
@@ -236,7 +239,7 @@ export class RemoteRuntimeInstallationManager {
current.runtimeAdapterDigest
if (runtimeAdapterDigest === undefined) {
const manifestPath =
`${OPENCODE_ROOT}/${digestDirectoryName(
`.goodbuddy/runtimes/${runtimeId}/${digestDirectoryName(
current.bundleDigest
)}/manifest.json`
const manifest =
@@ -288,7 +291,7 @@ export class RemoteRuntimeInstallationManager {
}
if (isMissingPathError(error)) {
throw new RemoteRuntimeInstallationError(
'The Host has no current OpenCode Runtime',
`The Host has no current ${runtimeId} Runtime`,
'incompatible',
error
)
@@ -39,6 +39,7 @@ type RuntimeBundleVerifier = (
) => Promise<VerifiedRuntimeBundle>
export type RemoteRuntimeResourceLoaderOptions = {
runtimeId?: 'opencode' | 'continue'
verificationEnvironment?: 'production' | 'test'
verifyRuntimeBundle?: RuntimeBundleVerifier
}
@@ -74,7 +75,7 @@ export async function loadVerifiedRemoteRuntimeResourceBundle(
const metadata =
await loadRemoteRuntimeVerificationMetadata(paths)
const bundleDirectory = await resolveSingleDigestDirectory(
getBundledRemoteRuntimeRoot(paths, architecture)
getBundledRemoteRuntimeRoot(paths, architecture, options.runtimeId)
)
const verifier = options.verifyRuntimeBundle ?? verifyRuntimeBundle
const verified = await verifier(bundleDirectory, {
@@ -89,7 +90,8 @@ export async function loadVerifiedRemoteRuntimeResourceBundle(
bundleDirectory,
architecture,
metadata.canonicalReleaseKeyRegistryBytes,
metadata.canonicalRemoteRuntimeLockBytes
metadata.canonicalRemoteRuntimeLockBytes,
options.runtimeId ?? 'opencode'
)
}
@@ -258,15 +260,16 @@ async function validateVerifiedBundle(
bundleDirectory: string,
architecture: AgentArchitecture,
keyRegistryBytes: Buffer,
runtimeLockBytes: Buffer
runtimeLockBytes: Buffer,
runtimeId: 'opencode' | 'continue'
): Promise<VerifiedRemoteRuntimeResourceBundle> {
const manifest = remoteRuntimeBundleManifestSchema.parse(
verified.manifest
)
if (
verified.bundleDirectory !== bundleDirectory ||
manifest.runtimeId !== 'opencode' ||
manifest.provider !== 'opencode' ||
manifest.runtimeId !== runtimeId ||
manifest.provider !== runtimeId ||
(manifest.platform !== 'linux' && !(manifest.platform === 'darwin' && manifest.architecture === 'arm64')) ||
manifest.architecture !== architecture
) {
+3 -3
View File
@@ -32,7 +32,7 @@ const lifecycleActions = [
'stop'
] as const
const runtimeArchitectures = ['x64', 'arm64'] as const
const RUNTIME_ID = 'opencode' as const
const runtimeIds = ['opencode', 'continue'] as const
declare const verifiedInstallationIdBrand: unique symbol
@@ -46,7 +46,7 @@ export type AgentRuntimeArchitecture =
export type AgentRuntimeActivationAction = {
kind: 'runtime-activate'
runtimeId: typeof RUNTIME_ID
runtimeId: (typeof runtimeIds)[number]
bundleDigest: string
architecture: AgentRuntimeArchitecture
forceVerification?: true
@@ -216,7 +216,7 @@ export function buildFixedAgentCliArgv(
}
return [action.action, ...installationOption]
case 'runtime-activate': {
if (action.runtimeId !== RUNTIME_ID) {
if (!runtimeIds.includes(action.runtimeId)) {
throw new Error('GoodBuddy Agent Runtime ID 无效')
}
if (!sha256DigestSchema.safeParse(action.bundleDigest).success) {
+3 -3
View File
@@ -47,8 +47,8 @@ type ExpectedCatalog = {
version: string
}
runtimes: Array<{
runtimeId: 'opencode'
provider: 'opencode'
runtimeId: 'opencode' | 'continue'
provider: 'opencode' | 'continue'
version: string
}>
}
@@ -480,7 +480,7 @@ async function loadExpectedCatalog(
},
runtimes: Object.entries(runtimeLock.runtimes)
.map(([runtimeId, runtime]) => ({
runtimeId: runtimeId as 'opencode',
runtimeId: runtimeId as 'opencode' | 'continue',
provider: runtime.provider,
version: runtime.version
}))
+21 -3
View File
@@ -134,7 +134,7 @@ export type SshRemotePackageAgentIdentity = {
};
export type SshRemotePackageRuntimeIdentity = {
runtimeId: "opencode";
runtimeId: "opencode" | "continue";
runtimeVersion: string;
bundleDigest: string;
manifestDigest: string;
@@ -149,6 +149,7 @@ export type SshRemotePackageIdentity = {
archiveSha256: string;
agent: SshRemotePackageAgentIdentity;
runtime: SshRemotePackageRuntimeIdentity;
additionalRuntimes?: SshRemotePackageRuntimeIdentity[];
};
export type SshRemotePackageBootstrapPrepareResult =
@@ -275,6 +276,7 @@ type TerminalMessage =
archiveSha256: string;
agent: SshRemotePackageAgentIdentity;
runtime: SshRemotePackageRuntimeIdentity;
additionalRuntimes?: SshRemotePackageRuntimeIdentity[];
}
| {
type: "result";
@@ -935,6 +937,7 @@ function parsePackageIdentity(
"archiveSha256",
"agent",
"runtime",
...(message.additionalRuntimes === undefined ? [] : ["additionalRuntimes"]),
]) ||
message.type !== "result" ||
message.command !== action ||
@@ -985,7 +988,7 @@ function parsePackageIdentity(
(architecture !== "x64" && architecture !== "arm64") ||
!agentProtocol ||
agent.supervisor !== "detached-on-demand" ||
runtime.runtimeId !== "opencode" ||
(runtime.runtimeId !== "opencode" && runtime.runtimeId !== "continue") ||
!isVersion(runtime.runtimeVersion) ||
typeof runtime.bundleDigest !== "string" ||
!DIGEST_PATTERN.test(runtime.bundleDigest) ||
@@ -1001,10 +1004,22 @@ function parsePackageIdentity(
) {
return undefined;
}
const additionalRuntimes: SshRemotePackageRuntimeIdentity[] = [];
if (message.additionalRuntimes !== undefined) {
if (!Array.isArray(message.additionalRuntimes) || message.additionalRuntimes.length > 1) return undefined;
for (const extra of message.additionalRuntimes) {
const { additionalRuntimes: _extras, ...base } = message;
void _extras;
const parsed = parsePackageIdentity({ ...base, runtime: extra }, action, candidate);
if (!parsed || !("runtime" in parsed) || parsed.runtime.runtimeId === runtime.runtimeId) return undefined;
additionalRuntimes.push(parsed.runtime);
}
}
return {
type: "result",
command: action,
status: expectedStatus,
...(additionalRuntimes.length ? { additionalRuntimes } : {}),
archiveSha256: candidate.sha256,
agent: {
installationId: agent.installationId,
@@ -1017,7 +1032,7 @@ function parsePackageIdentity(
supervisor: "detached-on-demand",
},
runtime: {
runtimeId: "opencode",
runtimeId: runtime.runtimeId,
runtimeVersion: runtime.runtimeVersion,
bundleDigest: runtime.bundleDigest,
manifestDigest: runtime.manifestDigest,
@@ -1444,6 +1459,7 @@ export function createSshRemotePackageBootstrapExecutor(
archiveSha256: result.archiveSha256,
agent: result.agent,
runtime: result.runtime,
...(result.additionalRuntimes ? { additionalRuntimes: result.additionalRuntimes } : {}),
},
};
}
@@ -1487,6 +1503,7 @@ export function createSshRemotePackageBootstrapExecutor(
archiveSha256: result.archiveSha256,
agent: result.agent,
runtime: result.runtime,
...(result.additionalRuntimes ? { additionalRuntimes: result.additionalRuntimes } : {}),
},
};
}
@@ -1602,6 +1619,7 @@ export function createSshRemotePackageBootstrapExecutor(
archiveSha256: result.archiveSha256,
agent: result.agent,
runtime: result.runtime,
...(result.additionalRuntimes ? { additionalRuntimes: result.additionalRuntimes } : {}),
},
};
}
+5 -1
View File
@@ -1245,7 +1245,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
expect(input).toHaveValue('/typed/path')
})
it('saves Execute in one request without extra confirmation checklists', async () => {
it.each(['opencode', 'continue'])('saves %s Execute in one request without extra confirmation checklists', async (provider) => {
const api = installRemoteApi()
const { onRemoteCommitted } = renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
@@ -1264,6 +1264,9 @@ describe('ProjectSwitcher managed SSH projects', () => {
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv/project' }
})
fireEvent.change(within(dialog).getByRole('combobox', { name: '新对话默认 Runtime' }), {
target: { value: provider }
})
const modeGroup = within(dialog)
.getAllByRole('group', { name: '默认模式' })
.find((candidate) =>
@@ -1285,6 +1288,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
intent: 'create',
draft: expect.objectContaining({
defaultWorkMode: 'execute',
runtimeSelection: { provider },
hostId,
remoteRootPath: '/srv/project'
})
+13 -6
View File
@@ -927,7 +927,7 @@ export function ProjectSwitcher({
name: draft.name,
description: draft.description,
runtimeSelection:
draft.runtimeSelection?.provider === 'opencode'
draft.runtimeSelection?.provider === 'opencode' || draft.runtimeSelection?.provider === 'continue'
? draft.runtimeSelection
: ({ provider: 'opencode' } as const),
hostId: remoteHostId,
@@ -1478,8 +1478,7 @@ export function ProjectSwitcher({
setDraft((current) => ({
...current,
runtimeSelection:
current.runtimeSelection?.provider ===
'opencode'
current.runtimeSelection?.provider === 'opencode' || current.runtimeSelection?.provider === 'continue'
? current.runtimeSelection
: { provider: 'opencode' }
}))
@@ -1707,19 +1706,27 @@ export function ProjectSwitcher({
{t('projectSwitcher.remote.rootHelp')}
</small>
</label>
<div className="remote-project-runtime">
<label className="remote-project-runtime">
<span>
{t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
</span>
<strong>OpenCode</strong>
<select aria-label={t('projectSwitcher.dialog.fields.defaultRuntime')}
disabled={remoteFieldsDisabled} value={draft.runtimeSelection?.provider ?? 'opencode'}
onChange={event => {
const provider = event.target.value === 'continue' ? 'continue' : 'opencode'
setDraft(current => ({ ...current, runtimeSelection: { provider } }))
}}>
<option value="opencode">OpenCode</option>
<option value="continue">Continue</option>
</select>
<small>
{t(
'projectSwitcher.remote.runtimeHelp'
)}
</small>
</div>
</label>
<ProjectWorkModeFields
ariaLabel={t(
'projectSwitcher.dialog.fields.defaultMode'
@@ -0,0 +1,95 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import type { Message } from './ChatTimeline'
import { RuntimeChecklistStrip } from './RuntimeChecklistStrip'
import { ConversationTaskStrip } from './ConversationTaskStrip'
afterEach(cleanup)
const message: Message = {
id: 'request-message', role: 'assistant', content: '', createdAt: 1, state: 'streaming',
runtimeChecklist: { source: 'opencode', items: [
{ content: 'Inspect', status: 'completed' },
{ content: 'Implement', status: 'in_progress', priority: 'high' },
{ content: 'Verify', status: 'pending' },
{ content: 'Omitted', status: 'cancelled' }
] }
}
describe('RuntimeChecklistStrip', () => {
it('is read-only, preserves collapse and focus during full replacements, and retains terminal item states', () => {
const { rerender } = render(<RuntimeChecklistStrip messages={[message]} />)
const toggle = screen.getByRole('button', { name: /执行清单/ })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('list')).not.toBeInTheDocument()
toggle.focus()
fireEvent.click(toggle)
expect(document.getElementById(toggle.getAttribute('aria-controls')!)).toBeVisible()
expect(screen.getByRole('group', { name: '执行清单' })).toHaveAttribute('tabindex', '0')
expect(screen.getByText('高优先级')).toBeVisible()
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
const updated: Message = { ...message, state: 'error', status: '已取消', runtimeChecklist: {
source: 'continue', items: [
{ content: 'Duplicate', status: 'pending' }, { content: 'Duplicate', status: 'completed' }
]
} }
rerender(<RuntimeChecklistStrip messages={[updated]} />)
expect(toggle).toHaveFocus()
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(toggle).toHaveTextContent('已完成 1/2')
expect(toggle).toHaveTextContent('已取消')
expect(screen.getAllByText('Duplicate')).toHaveLength(2)
expect(screen.getByText('待处理')).toBeVisible()
fireEvent.click(toggle)
rerender(<RuntimeChecklistStrip messages={[{ ...updated, status: '失败' }]} />)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(toggle).toHaveFocus()
})
it('selects the current request before looking at data and hides explicit empty snapshots', () => {
const next: Message = { id: 'next', role: 'assistant', content: '', createdAt: 2, state: 'streaming' }
const { rerender } = render(<RuntimeChecklistStrip messages={[message, next]} />)
expect(screen.queryByRole('region')).not.toBeInTheDocument()
rerender(<RuntimeChecklistStrip messages={[message, next]} activeMessageId="missing" />)
expect(screen.queryByRole('region')).not.toBeInTheDocument()
rerender(<RuntimeChecklistStrip messages={[message, next]} activeMessageId={message.id} />)
expect(screen.getByRole('region', { name: '执行清单' })).toBeVisible()
rerender(<RuntimeChecklistStrip messages={[message, { ...next, runtimeChecklist: { source: 'opencode', items: [] } }]} />)
expect(screen.queryByRole('region')).not.toBeInTheDocument()
rerender(<RuntimeChecklistStrip messages={[{ ...message, state: 'complete' }, next]} activeMessageId={message.id} />)
expect(screen.queryByRole('region')).not.toBeInTheDocument()
})
it('coexists with independent Task expansion and keeps the existing Task action', () => {
const select = vi.fn()
render(<div className="conversation-context-strips">
<ConversationTaskStrip locale="zh-CN" schedules={[]} tasks={[{
id: 'task', title: 'Existing Task', status: 'running', createdAt: new Date().toISOString(),
instructions: 'Work', origin: 'schedule'
}]} onSelectTask={select} onRemoveSchedule={vi.fn()} onRunSchedule={vi.fn()} onSetScheduleEnabled={vi.fn()} />
<RuntimeChecklistStrip messages={[message]} />
</div>)
const checklistToggle = screen.getByRole('button', { name: /执行清单/ })
const taskToggle = document.querySelector<HTMLButtonElement>('.conversation-task-strip__toggle')!
fireEvent.click(taskToggle)
expect(checklistToggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(checklistToggle)
expect(taskToggle).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Existing Task' }))
expect(select).toHaveBeenCalledWith('task')
fireEvent.click(taskToggle)
expect(within(screen.getByRole('region', { name: '执行清单' })).getByRole('list')).toBeVisible()
})
it('bounds combined height and long content while retaining wrapping and keyboard focus', () => {
const css = readFileSync('src/renderer/src/runtime-checklist.css', 'utf8')
expect(css).toMatch(/\.conversation-context-strips\s*\{[^}]*max-height: 45vh;[^}]*overflow: auto;/s)
expect(css).toMatch(/\.runtime-checklist__toggle\s*\{\s*flex: 0 0 auto;/s)
expect(css).toMatch(/\.runtime-checklist__content\s*\{[^}]*min-height: 0;/s)
expect(css).toMatch(/\.runtime-checklist__content\s*\{[^}]*max-height: 20vh;[^}]*overflow: auto;/s)
expect(css).toContain('overflow-wrap: anywhere')
expect(css).toContain('flex-wrap: wrap')
expect(css).toContain(':focus-visible')
})
})
@@ -0,0 +1,69 @@
import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } from 'lucide-react'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Message } from './ChatTimeline'
import './runtime-checklist.css'
const statusIcons = {
pending: Circle,
in_progress: CircleDashed,
completed: CheckCircle2,
cancelled: XCircle
}
export function RuntimeChecklistStrip({ messages, activeMessageId }: {
messages: readonly Message[]
activeMessageId?: string
}): React.JSX.Element | null {
// Select the request first, never search backwards for a populated checklist.
const activeMessage = activeMessageId
? messages.find(candidate => candidate.id === activeMessageId)
: undefined
const message = activeMessageId && (!activeMessage || activeMessage.state === 'streaming')
? activeMessage
: messages.at(-1)
if (message?.role !== 'assistant' || !message.runtimeChecklist?.items.length) return null
return <Checklist key={message.id} message={message} />
}
function Checklist({ message }: { message: Message }): React.JSX.Element {
const { t } = useTranslation('app')
const [expanded, setExpanded] = useState(false)
const contentId = useId()
const items = message.runtimeChecklist!.items
const completed = items.reduce((count, item) => count + Number(item.status === 'completed'), 0)
const current = items.find(item => item.status === 'in_progress')
const result = message.state === 'streaming' ? undefined : message.status || t(
message.state === 'error' ? 'runtimeChecklist.failed' : 'runtimeChecklist.finished'
)
const summary = t('runtimeChecklist.count', { completed, total: items.length })
return (
<section className="runtime-checklist" aria-label={t('runtimeChecklist.title')}>
<button className="runtime-checklist__toggle" type="button"
aria-expanded={expanded} aria-controls={contentId}
onClick={() => setExpanded(value => !value)}>
<strong>{t('runtimeChecklist.title')}</strong>
<span>{summary}</span>
{result && <span className="runtime-checklist__result">{result}</span>}
{current && <span className="runtime-checklist__current" title={current.content}>{current.content}</span>}
{expanded ? <ChevronUp aria-hidden="true" size={14} /> : <ChevronDown aria-hidden="true" size={14} />}
</button>
<span className="sr-only" role="status" aria-live="polite">{summary}{result ? ` · ${result}` : ''}</span>
<div id={contentId} hidden={!expanded} className="runtime-checklist__content"
tabIndex={0} role="group" aria-label={t('runtimeChecklist.title')}>
<ol>
{items.map((item, index) => {
const Icon = statusIcons[item.status]
return <li key={index}>
<span className={`runtime-checklist__status runtime-checklist__status--${item.status}`}>
<Icon aria-hidden="true" size={14} /><span>{t(`runtimeChecklist.status.${item.status}`)}</span>
</span>
<span className="runtime-checklist__text">{item.content}</span>
{item.priority && <small>{t(`runtimeChecklist.priority.${item.priority}`)}</small>}
</li>
})}
</ol>
</div>
</section>
)
}
@@ -2,6 +2,12 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app'
export const app = {
runtimeChecklist: {
title: 'Execution checklist', count: '{{completed}}/{{total}} completed',
finished: 'Request completed', failed: 'Request failed',
status: { pending: 'Pending', in_progress: 'In progress', completed: 'Completed', cancelled: 'Cancelled' },
priority: { low: 'Low priority', medium: 'Medium priority', high: 'High priority' }
},
applications: {
alwaysShown: 'Always shown', optional: 'Optional',
manage: 'Manage Apps',
@@ -573,7 +573,7 @@ export const settingsSections = {
agentInventory: {
title: 'GoodBuddy Agent packages',
description:
'Agent packages are released independently and include the Agent, pinned Node, and the remote OpenCode Runtime adapted by GoodBuddy. Opening this page reads only the small signed catalog to show available updates and never downloads an Agent package automatically. Online actions use the source selected under About & Updates.',
'Agent packages are released independently and include the Agent, pinned Node, and the remote OpenCode and Continue Runtimes adapted by GoodBuddy. Opening this page reads only the small signed catalog to show available updates and never downloads an Agent package automatically. Online actions use the source selected under About & Updates.',
loading: 'Verifying local Agent packages and checking online versions…',
refresh: 'Refresh package inventory',
import: 'Import offline package',
@@ -68,7 +68,7 @@ export const workspace = {
stages: {
network: 'Restoring network connection…',
agent: 'Restoring remote Agent…',
runtime: 'Restoring OpenCode Runtime…',
runtime: 'Restoring remote Runtime…',
cursor: 'Restoring conversation at event {{current}}',
completed: 'Recovery completed'
},
@@ -172,7 +172,7 @@ export const workspace = {
select: 'Select this directory'
},
runtimeHelp:
'Managed SSH projects use OpenCode Runtime only. Ask is read-only; Execute can use every permission available to the selected SSH account. Saving checks the Host, Agent, workspace, and Runtime.',
'Managed SSH projects can use an installed OpenCode or Continue Runtime. Ask is read-only; Execute can use every permission available to the selected SSH account. Saving checks the Host, Agent, workspace, and selected Runtime.',
actions: {
save: 'Save remote project',
saving: 'Saving remote project…'
@@ -186,7 +186,7 @@ export const workspace = {
host: 'SSH host',
agent: 'Remote Agent',
workspace: 'Remote workspace',
runtime: 'OpenCode Runtime',
runtime: 'Remote Runtime',
saving: 'Project settings'
},
validation: {
@@ -1,4 +1,10 @@
export const app = {
runtimeChecklist: {
title: '执行清单', count: '已完成 {{completed}}/{{total}}',
finished: '请求已完成', failed: '请求失败',
status: { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' },
priority: { low: '低优先级', medium: '中优先级', high: '高优先级' }
},
applications: {
alwaysShown: '始终显示', optional: '可选',
manage: '管理应用',
@@ -543,7 +543,7 @@ export const settingsSections = {
agentInventory: {
title: 'GoodBuddy Agent 包',
description:
'Agent 包独立发布,包含 Agent、固定 Node 和桌面版本适配的远端 OpenCode Runtime。打开此页只读取小型签名目录并显示可用更新,不会自动下载 Agent 包;在线操作使用“关于与更新”中选择的更新源。',
'Agent 包独立发布,包含 Agent、固定 Node 和桌面版本适配的远端 OpenCode、Continue Runtime。打开此页只读取小型签名目录并显示可用更新,不会自动下载 Agent 包;在线操作使用“关于与更新”中选择的更新源。',
loading: '正在校验本地 Agent 包并检查在线版本…',
refresh: '刷新包清单',
import: '导入离线包',
@@ -65,7 +65,7 @@ export const workspace = {
stages: {
network: '正在恢复网络连接…',
agent: '正在恢复远端 Agent…',
runtime: '正在恢复 OpenCode Runtime…',
runtime: '正在恢复远端 Runtime…',
cursor: '正在恢复对话,已同步到事件 {{current}}',
completed: '恢复完成'
},
@@ -167,7 +167,7 @@ export const workspace = {
select: '选择此目录'
},
runtimeHelp:
'托管 SSH 项目仅使用 OpenCode Runtime。Ask 为只读;Execute 可使用所选 SSH 账户拥有的全部权限。保存时会检查主机、远端 Agent、工作区和 Runtime。',
'托管 SSH 项目可使用已安装的 OpenCode 或 Continue Runtime。Ask 为只读;Execute 可使用所选 SSH 账户拥有的全部权限。保存时会检查主机、远端 Agent、工作区和所选 Runtime。',
actions: {
save: '保存远程项目',
saving: '正在保存远程项目…'
@@ -181,7 +181,7 @@ export const workspace = {
host: 'SSH 主机',
agent: '远端 Agent',
workspace: '远端工作区',
runtime: 'OpenCode Runtime',
runtime: '远端 Runtime',
saving: '项目设置'
},
validation: {
+84
View File
@@ -0,0 +1,84 @@
.conversation-context-strips {
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-width: 0;
max-height: 45vh;
overflow: auto;
scrollbar-width: thin;
}
.conversation-context-strips > .conversation-task-strip,
.conversation-context-strips > .runtime-checklist {
display: flex;
flex-direction: column;
flex: 0 1 auto;
min-height: 46px;
}
.conversation-context-strips .conversation-task-strip__header,
.runtime-checklist__toggle {
flex: 0 0 auto;
}
.conversation-context-strips .conversation-task-strip__content {
flex: 0 1 auto;
min-height: 0;
max-height: 20vh;
overflow: auto;
}
.runtime-checklist {
min-width: 0;
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-raised);
color: var(--text-secondary);
font-size: var(--font-caption);
padding: 0 max(var(--page-gutter), calc((100% - var(--content-reading)) / 2));
}
.runtime-checklist__toggle {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
width: 100%;
min-height: 46px;
padding: var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.runtime-checklist__toggle:hover { background: var(--surface-subtle); }
.runtime-checklist__toggle:focus-visible,
.runtime-checklist__content:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.runtime-checklist__toggle strong { color: var(--text-primary); }
.runtime-checklist__toggle svg { flex: 0 0 auto; }
.runtime-checklist__current {
flex: 1 1 6em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-checklist__result { overflow-wrap: anywhere; }
.runtime-checklist__content { flex: 0 1 auto; min-height: 0; max-height: 20vh; overflow: auto; }
.runtime-checklist__content ol { margin: 0; padding: var(--space-2); list-style: none; }
.runtime-checklist__content li {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-2) 0;
}
.runtime-checklist__text { flex: 1 1 12em; min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.runtime-checklist__status { display: inline-flex; align-items: baseline; gap: var(--space-1); }
.runtime-checklist__status svg { align-self: center; flex: 0 0 auto; }
.runtime-checklist__status--completed { color: var(--success); }
.runtime-checklist__status--in_progress { color: var(--accent); }
.runtime-checklist__content small { font-size: inherit; }
+1
View File
@@ -0,0 +1 @@
{"name":"runtime-checklist-visual-validation","version":"1.0.0","main":"runtime-checklist-electron.mjs","private":true}
@@ -0,0 +1,151 @@
import { app, BrowserWindow } from 'electron'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import process from 'node:process'
import console from 'node:console'
import { setTimeout as wait } from 'node:timers/promises'
const evidence = process.env.CHECKLIST_VISUAL_EVIDENCE
const results = []
let window
const evaluate = script => window.webContents.executeJavaScript(script, true)
const click = selector => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`)
const check = async (name, script) => {
const passed = await evaluate(script)
results.push({ name, passed })
console.log(name, passed ? 'PASS' : 'FAIL')
}
async function key(keyCode, modifiers = []) {
if (keyCode === 'Enter') keyCode = 'Return'
window.focus()
window.webContents.focus()
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
if (keyCode === 'Return') window.webContents.sendInputEvent({ type: 'char', keyCode: '\r', modifiers })
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
await wait(100)
}
async function capture(name) {
await wait(150)
const measurements = await evaluate(`(() => {
const rect = selector => { const e = document.querySelector(selector); if (!e) return null; const r = e.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,clientWidth:e.clientWidth,scrollWidth:e.scrollWidth } };
return { viewport: {width:innerWidth,height:innerHeight,dpr:devicePixelRatio}, body:rect('body'), top:rect('.conversation-context-strips'), task:rect('.conversation-task-strip__toggle'), checklist:rect('.runtime-checklist__toggle'), list:rect('.runtime-checklist__content'), chat:rect('.chat'), input:rect('input'), focus:document.activeElement?.className };
})()`)
const image = await window.webContents.capturePage()
writeFileSync(join(evidence, `${name}.png`), image.toPNG())
results.push({ name, ...measurements })
results.push({ name: `${name}-layout`, passed: measurements.body.scrollWidth <= measurements.body.clientWidth + 1 && measurements.input.bottom <= measurements.viewport.height + 1 && measurements.chat.height > 40 && (!measurements.checklist || measurements.checklist.bottom <= measurements.top.bottom + 1) && (!measurements.task || measurements.task.y >= measurements.top.y - 1) })
console.log(name, JSON.stringify(measurements))
}
app.whenReady().then(async () => {
mkdirSync(evidence, { recursive: true })
window = new BrowserWindow({ width: 1440, height: 1000, useContentSize: true, show: true, autoHideMenuBar: true, webPreferences: { contextIsolation: true, sandbox: true } })
window.webContents.on('console-message', event => console.log('renderer:', event.message))
await window.loadURL(process.env.CHECKLIST_VISUAL_URL)
for (let attempt = 0; attempt < 100; attempt++) {
if (await evaluate('Boolean(window.checklistHarness)')) break
await wait(100)
}
if (process.env.CHECKLIST_VISUAL_BASELINE === '1') {
await evaluate('document.fonts.ready.then(() => true)')
await click('.runtime-checklist__toggle')
for (const [name, width, height, zoom] of [
['wide-100', 1440, 1000, 1], ['narrow-100', 760, 800, 1],
['wide-200', 1440, 1000, 2], ['narrow-200', 760, 800, 2]
]) {
window.setContentSize(width, height)
window.focus()
await wait(300)
window.webContents.setZoomFactor(zoom)
await window.webContents.capturePage()
let viewportReady = false
for (let attempt = 0; attempt < 40; attempt++) {
await wait(100)
viewportReady = await evaluate(`Math.abs(innerWidth - ${width / zoom}) < 3 && Math.abs(innerHeight - ${height / zoom}) < 3`)
if (viewportReady) break
}
if (!viewportReady) throw new Error(`Viewport did not settle for ${name}: ${JSON.stringify(await evaluate('({width:innerWidth,height:innerHeight,dpr:devicePixelRatio})'))}; native=${window.getContentSize()}; zoom=${window.webContents.getZoomFactor()}`)
for (const index of [0, 1, 3, 4]) {
await evaluate(`document.querySelectorAll('.runtime-checklist__content li')[${index}].scrollIntoView({block:'start'})`)
await wait(100)
const measured = await evaluate(`(() => {
const item = document.querySelectorAll('.runtime-checklist__content li')[${index}];
const label = item.querySelector('.runtime-checklist__status > span');
const text = item.querySelector('.runtime-checklist__text');
const range = document.createRange();
const rect = r => ({top:r.top,bottom:r.bottom,height:r.height,left:r.left,right:r.right});
range.selectNodeContents(label);
const labelText = rect(range.getClientRects()[0]);
range.selectNodeContents(text);
const lines = Array.from(range.getClientRects()).filter(r => r.width > 0);
const firstLine = rect(lines[0]);
const labelSpan = rect(label.getBoundingClientRect());
return {status:label.textContent, content:text.textContent, viewport:{width:innerWidth,height:innerHeight,dpr:devicePixelRatio},
lineCount:new Set(lines.map(r => r.top)).size, labelSpan, labelText, firstLine,
spanTopDelta:labelSpan.top-firstLine.top,spanBottomDelta:labelSpan.bottom-firstLine.bottom,
textTopDelta:labelText.top-firstLine.top,textBottomDelta:labelText.bottom-firstLine.bottom};
})()`)
const passed = [measured.textTopDelta, measured.textBottomDelta, measured.spanTopDelta, measured.spanBottomDelta]
.every(delta => Math.abs(delta) < 0.1)
results.push({ name, index, zoom, passed, ...measured })
console.log('baseline', JSON.stringify({ name, index, passed, ...measured, content: undefined }))
if (index === 0 || index === 3) {
writeFileSync(join(evidence, `${name}-item-${index + 1}.png`), (await window.webContents.capturePage()).toPNG())
}
}
}
writeFileSync(join(evidence, 'baseline-results.json'), JSON.stringify({ environment: {
platform:process.platform,electron:process.versions.electron,chrome:process.versions.chrome,
path:'production renderer components in Electron harness; not full desktop IPC',units:'CSS pixels'
}, results }, null, 2))
app.exit(results.some(result => !result.passed) ? 1 : 0)
return
}
await capture('01-wide-collapsed')
await click('.conversation-task-strip__toggle')
await click('.runtime-checklist__toggle')
await capture('02-wide-both-expanded')
await evaluate("document.querySelector('.runtime-checklist__toggle').focus()")
await key('Space')
await check('space-collapses-only-checklist', "document.querySelector('.runtime-checklist__toggle').getAttribute('aria-expanded') === 'false' && document.querySelector('.conversation-task-strip__toggle').getAttribute('aria-expanded') === 'true'")
await key('Enter')
await check('enter-expands-checklist-keeps-focus', "document.querySelector('.runtime-checklist__toggle').getAttribute('aria-expanded') === 'true' && document.activeElement.matches('.runtime-checklist__toggle')")
await evaluate("document.querySelector('input').focus(); window.checklistHarness.update()")
results.push({ name: 'update-keeps-input-focus', passed: await evaluate("document.activeElement === document.querySelector('input')") })
await evaluate("document.querySelector('.runtime-checklist__content').scrollTop = 100000")
await capture('03-long-list-end')
window.webContents.setZoomFactor(2)
await capture('03b-wide-200-percent')
window.webContents.setZoomFactor(1)
window.setContentSize(760, 800)
await capture('04-narrow-both-expanded')
window.webContents.setZoomFactor(2)
await capture('05-narrow-200-percent')
await evaluate("document.querySelector('.runtime-checklist__toggle').focus()")
await key('Tab')
await check('tab-reaches-checklist-scroll-region', "document.activeElement.matches('.runtime-checklist__content')")
await key('End', ['control'])
await wait(300)
await check('keyboard-reaches-last-item-at-200-percent', "(() => {const list=document.querySelector('.runtime-checklist__content'); const last=list.querySelector('li:last-child').getBoundingClientRect(); const r=list.getBoundingClientRect(); return document.activeElement === list && last.bottom <= r.bottom + 1 && last.bottom > r.top;})()")
await evaluate("document.querySelector('.conversation-task-details__actions button').focus()")
await key('Enter')
await check('task-run-action-at-200-percent', 'window.checklistHarness.runs === 1')
await capture('05b-narrow-keyboard-task-action')
await evaluate("window.checklistHarness.theme('dark')")
await check('dark-theme-applied-to-root', "document.documentElement.dataset.theme === 'dark' && getComputedStyle(document.documentElement).getPropertyValue('--surface-raised').trim() !== '#ffffff'")
await capture('06-dark-narrow-200-percent')
window.webContents.setZoomFactor(1)
window.setContentSize(1440, 1000)
await evaluate("window.checklistHarness.scenario('next')")
results.push({ name: 'new-request-hides-old-checklist', passed: await evaluate("!document.querySelector('.runtime-checklist')") })
await capture('07-new-request')
await evaluate("window.checklistHarness.scenario('stale')")
await check('stale-terminal-request-does-not-revive-old-checklist', "!document.querySelector('.runtime-checklist')")
await evaluate("window.checklistHarness.scenario('cleared')")
results.push({ name: 'empty-hides-old-checklist', passed: await evaluate("!document.querySelector('.runtime-checklist')") })
await evaluate("window.checklistHarness.scenario('history')")
await click('.runtime-checklist__toggle')
await capture('08-cancelled-history')
await evaluate("window.checklistHarness.scenario('remote')")
await capture('09-remote-without-task')
writeFileSync(join(evidence, 'results.json'), JSON.stringify({ environment: { platform:process.platform, electron:process.versions.electron, chrome:process.versions.chrome, node:process.versions.node, path:'component harness, not full desktop IPC path', sandbox:true, contextIsolation:true, gpu:'default', checklistItems:80 }, results }, null, 2))
app.exit(results.some(result => result.passed === false) ? 1 : 0)
}).catch(error => { console.error(error); app.exit(1) })
@@ -0,0 +1,20 @@
import { createServer } from 'vite'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
import process from 'node:process'
import console from 'node:console'
const require = createRequire(import.meta.url)
const server = await createServer({ configFile: false, root: process.cwd(), server: { host: '127.0.0.1', port: 0 }, esbuild: { jsx: 'automatic' } })
await server.listen()
const port = server.httpServer.address().port
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
env.CHECKLIST_VISUAL_URL = `http://127.0.0.1:${port}/src/renderer/validation/runtime-checklist.html`
env.CHECKLIST_VISUAL_EVIDENCE = resolve(process.argv[2])
env.CHECKLIST_VISUAL_BASELINE = process.argv.includes('--baseline') ? '1' : ''
const child = spawn(require('electron'), [resolve('src/renderer/validation'), `--user-data-dir=${resolve(process.argv[2], 'profile')}`], { env, stdio: ['ignore', 'pipe', 'pipe'] })
child.stdout.pipe(process.stdout)
child.stderr.pipe(process.stderr)
child.on('exit', async code => { console.log('Electron exit:', code); await server.close(); process.exit(code ?? 1) })
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"><title>Runtime checklist visual validation</title></head>
<body><div id="root"></div><script type="module" src="./runtime-checklist.tsx"></script></body></html>
@@ -0,0 +1,63 @@
import React, { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'
import '../src/i18n'
import '../src/styles.css'
import { RuntimeChecklistStrip } from '../src/RuntimeChecklistStrip'
import { ConversationTaskStrip } from '../src/ConversationTaskStrip'
import type { Message } from '../src/ChatTimeline'
const original: Message = {
id: 'original', role: 'assistant', content: '', createdAt: 1, state: 'streaming',
runtimeChecklist: { source: 'opencode', items: Array.from({ length: 80 }, (_, index) => ({
content: `${index + 1}. ${index === 1 ? '正在核对当前项目中的修改与既有任务,同时保留用户未提交的工作' : index === 79 ? 'LAST ITEM' : '检查生产组件布局、当前请求归属及历史状态'} ${index === 3 ? 'long_unbroken_path_'.repeat(35) : ''}`,
status: index === 0 ? 'completed' : index === 1 ? 'in_progress' : index === 4 ? 'cancelled' : 'pending',
priority: index % 3 === 0 ? 'high' : index % 3 === 1 ? 'medium' : 'low'
})) }
}
const next: Message = { id: 'next', role: 'assistant', content: '', createdAt: 2, state: 'streaming' }
const task = {
id: 'task', title: '核对项目与构建验证', instructions: '保留用户修改', origin: 'schedule' as const,
status: 'idle' as const, createdAt: '2026-09-18T00:00:00Z'
}
function Harness() {
const [scenario, setScenario] = useState('live')
const [runs, setRuns] = useState(0)
const [update, setUpdate] = useState(0)
const [theme, setTheme] = useState('light')
const messages = scenario === 'stale' ? [{ ...original, state: 'complete' as const }, next]
: scenario === 'next' ? [original, next]
: scenario === 'cleared' ? [original, { ...next, runtimeChecklist: { source: 'opencode' as const, items: [] } }]
: scenario === 'history' || scenario === 'remote' ? [{ ...original, state: 'error' as const, status: '已取消' }]
: [{ ...original, runtimeChecklist: { ...original.runtimeChecklist!, items: original.runtimeChecklist!.items.map((item, index) => index === 2 && update ? { ...item, content: `更新 ${update}`, status: 'completed' as const } : item) } }]
Object.assign(window, { checklistHarness: {
scenario: (value: string) => flushSync(() => setScenario(value)),
update: () => flushSync(() => setUpdate(value => value + 1)),
theme: (value: string) => {
document.documentElement.dataset.theme = value
document.documentElement.style.colorScheme = value
flushSync(() => setTheme(value))
},
runs
} })
return <div data-theme={theme} style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: 'var(--surface-raised)', color: 'var(--text-primary)' }}>
<header style={{ flex: '0 0 auto', padding: '8px 16px', background: 'var(--surface-subtle)' }}>Renderer component harness · {scenario} · {theme}</header>
<main style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div className="chat-history-pane" style={{ flex: '1 1 auto', height: 'auto' }}>
<div className="conversation-context-strips">
{scenario !== 'remote' && <ConversationTaskStrip locale="zh-CN" tasks={[task]} schedules={[{
id: 'schedule', taskId: 'task', title: task.title, instructions: task.instructions, enabled: true,
recurrence: 'daily', nextRunAt: '2026-09-19T08:00:00Z', createdAt: task.createdAt, updatedAt: task.createdAt
}]} onSelectTask={() => undefined} onRunSchedule={async () => { setRuns(value => value + 1) }} onRemoveSchedule={async () => undefined} onSetScheduleEnabled={async () => undefined} />}
<RuntimeChecklistStrip messages={messages} activeMessageId={scenario === 'next' ? 'next' : scenario === 'stale' ? 'original' : undefined} />
</div>
<section className="chat"><div style={{ padding: '16px' }}>对话正文仍应可见。此页仅复用 renderer 组件,未连接 Main、IPC 或模型。</div></section>
</div>
<footer style={{ flex: '0 0 auto', padding: '16px', borderTop: '1px solid var(--border-default)' }}>
<label>消息输入 <input aria-label="消息输入" placeholder="输入区保持可见" style={{ width: '100%', minWidth: 0 }} /></label>
</footer>
</main>
</div>
}
createRoot(document.getElementById('root')!).render(<Harness />)
+18 -9
View File
@@ -80,6 +80,16 @@ export const agentPackageFileSchema = z
})
.strict()
const packagedRuntimeSchema = z.object({
runtimeId: z.enum(['opencode', 'continue']),
provider: z.enum(['opencode', 'continue']),
version: semanticVersionSchema,
bundleDigest: sha256DigestSchema,
protocol: agentProtocolVersionSchema
}).strict().refine(value => value.runtimeId === value.provider, {
message: 'Runtime provider must match its identity'
})
const agentPackageDescriptorObjectSchema = z
.object({
format: z.literal(AGENT_PACKAGE_FORMAT),
@@ -92,15 +102,8 @@ const agentPackageDescriptorObjectSchema = z
architecture: agentArchitectureSchema,
signingKeyId: signingKeyIdSchema,
agentProtocol: agentProtocolVersionSchema,
remoteRuntime: z
.object({
runtimeId: z.literal('opencode'),
provider: z.literal('opencode'),
version: semanticVersionSchema,
bundleDigest: sha256DigestSchema,
protocol: agentProtocolVersionSchema
})
.strict(),
remoteRuntime: packagedRuntimeSchema,
additionalRuntimes: z.array(packagedRuntimeSchema).max(1).optional(),
contentDigest: sha256DigestSchema,
files: z
.array(agentPackageFileSchema)
@@ -112,6 +115,9 @@ const agentPackageDescriptorObjectSchema = z
export const agentPackageDescriptorSchema =
agentPackageDescriptorObjectSchema
.superRefine((descriptor, context) => {
if (descriptor.additionalRuntimes?.some(runtime => runtime.runtimeId === descriptor.remoteRuntime.runtimeId)) {
context.addIssue({ code: 'custom', path: ['additionalRuntimes'], message: 'Duplicate packaged Runtime' })
}
const paths = descriptor.files.map((file) => file.path)
if (new Set(paths).size !== paths.length) {
context.addIssue({
@@ -152,6 +158,9 @@ export const agentPackageCatalogEntrySchema =
})
.strict()
.superRefine((entry, context) => {
if (entry.additionalRuntimes?.some(runtime => runtime.runtimeId === entry.remoteRuntime.runtimeId)) {
context.addIssue({ code: 'custom', path: ['additionalRuntimes'], message: 'Duplicate packaged Runtime' })
}
if (
entry.archive !==
agentPackageArchiveName(entry.version, entry.architecture, entry.platform)
+2
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { runtimeChecklistSchema } from './runtime-checklist'
import { externalKnowledgeLocatorSchema } from './external-knowledge-contracts'
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
import { sshHostIdSchema } from './ssh-host-contracts'
@@ -367,6 +368,7 @@ export const conversationMessageSchema = z
role: z.enum(['user', 'assistant']),
content: z.string(),
reasoning: z.string().optional(),
runtimeChecklist: runtimeChecklistSchema.optional(),
blocks: conversationMessageBlocksSchema.optional(),
displayCaptureTruncated: z.boolean().optional(),
createdAt: z.number().int().nonnegative(),
+1
View File
@@ -1251,6 +1251,7 @@ export const subagentEventSchema = z.union([
export type SubagentEvent = z.infer<typeof subagentEventSchema>
export type AgentEvent =
| { type: 'checklist'; requestId: string; checklist: import('./runtime-checklist').RuntimeChecklist }
| {
requestId: string
type: 'status'
+5 -3
View File
@@ -218,7 +218,7 @@ export async function verifyPublishedRuntimeBundle(
'Published Runtime entrypoint metadata changed'
)
}
await assertElfArchitecture(
if (manifest.provider === 'opencode') await assertElfArchitecture(
executablePath,
options.architecture,
'Runtime executable',
@@ -414,13 +414,15 @@ export function assertRuntimeManifestMatchesLock(
lock: RemoteRuntimeLock,
architecture: AgentArchitecture
): void {
const expected = lock.runtimes.opencode
const expected = manifest.runtimeId === 'opencode' ? lock.runtimes.opencode
: manifest.runtimeId === 'continue' ? lock.runtimes.continue : undefined
if (!expected) throw new Error('Runtime manifest does not match a locked profile')
const target = manifest.platform === 'darwin'
? expected.targets['darwin-arm64']
: expected.targets[architecture]
if (
target === undefined ||
manifest.runtimeId !== 'opencode' ||
manifest.runtimeId !== expected.provider ||
manifest.provider !== expected.provider ||
manifest.runtimeVersion !== expected.version ||
manifest.architecture !== architecture ||
+19 -2
View File
@@ -42,6 +42,9 @@ export const remoteRuntimeProviderKindSchema = z.enum([
'continue'
])
export const managedRemoteRuntimeIdSchema = z.enum(['opencode', 'continue'])
export type ManagedRemoteRuntimeId = z.infer<typeof managedRemoteRuntimeIdSchema>
export const remoteRuntimeArchitectureSchema = agentArchitectureSchema
export const remoteRuntimeAllowedEnvironmentNameSchema = z.enum([
@@ -80,7 +83,7 @@ const npmPackageIntegritySchema = z
export const remoteRuntimeSourcePackageSchema = z
.object({
name: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,127}$/u),
name: z.string().regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]{0,127}$/u),
integrity: npmPackageIntegritySchema
})
.strict()
@@ -262,7 +265,21 @@ export const remoteRuntimeLockSchema = z
})
.strict()
})
.strict()
.strict(),
continue: z.object({
version: componentVersionSchema,
provider: z.literal('continue'),
entrypoint: z.literal('lib/continue/dist/cn.js'),
entrypointIdentity: z.literal('continue-acp'),
argvPrefix: z.tuple([]),
allowedEnvironmentNames: z.array(remoteRuntimeAllowedEnvironmentNameSchema),
protocol: agentProtocolVersionSchema,
targets: z.object({
x64: remoteRuntimeLockedTargetSchema,
arm64: remoteRuntimeLockedTargetSchema,
'darwin-arm64': remoteRuntimeLockedTargetSchema.optional()
}).strict()
}).strict().optional()
})
.strict()
})
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { parseContinueChecklist, runtimeChecklistSchema } from './runtime-checklist'
describe('native runtime checklist', () => {
it('preserves long lists, duplicate items and explicit clears', () => {
const content = 'task'.repeat(1500)
expect(parseContinueChecklist('Checklist', { checklist: `- [ ] ${content}\n- [X] ${content}` })).toEqual({
source: 'continue', items: [{ content, status: 'pending' }, { content, status: 'completed' }]
})
expect(runtimeChecklistSchema.parse(parseContinueChecklist('Checklist', '{"checklist":""}'))).toEqual({ source: 'continue', items: [] })
})
it('rejects unrelated tools and malformed lists without clearing', () => {
for (const input of [{}, { checklist: [] }, { checklist: '- [ ] valid\ninvalid' }, { checklist: '- [ ] ' }, '{']) {
expect(parseContinueChecklist('Checklist', input)).toBeUndefined()
}
expect(parseContinueChecklist('checklist', { checklist: '' })).toBeUndefined()
expect(runtimeChecklistSchema.safeParse({ source: 'opencode', items: [{ content: 'x', status: 'done' }] }).success).toBe(false)
})
})
+31
View File
@@ -0,0 +1,31 @@
import { z } from 'zod'
export const runtimeChecklistSchema = z.object({
source: z.enum(['opencode', 'continue']),
items: z.array(z.object({
content: z.string().trim().min(1),
status: z.enum(['pending', 'in_progress', 'completed', 'cancelled']),
priority: z.enum(['low', 'medium', 'high']).optional()
}).strict())
}).strict()
export type RuntimeChecklist = z.infer<typeof runtimeChecklistSchema>
// Only the native Checklist argument uses this syntax. Invalid input is not a clear.
export function parseContinueChecklist(name: string, input: unknown): RuntimeChecklist | undefined {
if (name !== 'Checklist') return undefined
try {
const args = typeof input === 'string' ? JSON.parse(input) : input
if (!args || typeof args.checklist !== 'string') return undefined
const items: RuntimeChecklist['items'] = []
for (const line of args.checklist.split(/\r?\n/u)) {
if (!line.trim()) continue
const match = /^\s*- \[([ xX])\]\s+(.+?)\s*$/u.exec(line)
if (!match || !match[2]?.trim()) return undefined
items.push({ content: match[2]!, status: match[1] === ' ' ? 'pending' : 'completed' })
}
return { source: 'continue', items }
} catch {
return undefined
}
}
+2 -2
View File
@@ -312,8 +312,8 @@ export const sshHostRemoteEnvironmentSchema = z
.array(
z
.object({
runtimeId: z.literal('opencode'),
provider: z.literal('opencode'),
runtimeId: z.enum(['opencode', 'continue']),
provider: z.enum(['opencode', 'continue']),
state: sshHostRemoteEnvironmentStateSchema,
expected: sshHostRemoteEnvironmentVersionSchema,
installed:
+52
View File
@@ -67,6 +67,7 @@ type AgentBundleModule = {
type RuntimeBundleModule = {
buildRuntimeBundle(options: {
runtimeId?: 'opencode' | 'continue'
projectRoot: string
architecture: 'x64' | 'arm64'
runtimeArchive: string
@@ -91,6 +92,7 @@ type AgentPackageModule = {
output: string
agentBundle: string
runtimeBundle: string
additionalRuntimeBundles?: string[]
agentLock: AgentRuntimeLock
runtimeLock: RemoteRuntimeLock
registry: AgentReleaseKeyRegistry
@@ -369,6 +371,56 @@ afterAll(() => {
})
describe('compound Agent packages', () => {
it.each([false, true])('verifies and installs a Continue compound package through the production installer (joint=%s)', async (joint) => {
const root = join(temporaryRoot, `continue-package-${joint}`)
const source = join(root, 'source')
mkdirSync(join(source, 'package', 'dist'), { recursive: true })
writeFileSync(join(source, 'package', 'package.json'), JSON.stringify({ name: '@continuedev/cli', version: '1.5.47' }))
writeFileSync(join(source, 'package', 'dist', 'cn.js'), 'console.log("1.5.47")\n')
const archive = join(root, 'continuedev-cli-1.5.47.tgz')
createTar({ cwd: source, file: archive, sync: true, gzip: true }, ['package'])
const lock = JSON.parse(readFileSync(join(process.cwd(), 'remote-runtime-lock.json'), 'utf8')) as RemoteRuntimeLock
const integrity = `sha512-${createHash('sha512').update(readFileSync(archive)).digest('base64')}`
lock.runtimes.continue!.targets.x64.integrity = integrity
lock.runtimes.opencode = remoteRuntimeLock.runtimes.opencode
const built = runtimeBundle.buildRuntimeBundle({
projectRoot: process.cwd(), runtimeId: 'continue', architecture: 'x64', runtimeArchive: archive,
outputRoot: join(root, 'runtime'), lock, registry,
testSigningIdentity: { keyId: 'agent-package-fixture', privateKey }, enforceFilesystemMode: false
})
const result = agentPackage.assembleAgentPackage({
projectRoot: process.cwd(), architecture: 'x64', minimumDesktopVersion: '0.13.6',
output: join(root, agentPackageArchiveName(agentLock.agentVersion, 'x64')),
agentBundle: join(temporaryRoot, 'agent'), runtimeBundle: joint ? runtimeBundle.buildRuntimeBundle({
projectRoot: temporaryRoot, architecture: 'x64', runtimeArchive: createRuntimeInput('x64'),
outputRoot: join(root, 'opencode'), lock, registry,
testSigningIdentity: { keyId: 'agent-package-fixture', privateKey }, enforceFilesystemMode: false
}).bundleDirectory : built.bundleDirectory,
...(joint ? { additionalRuntimeBundles: [built.bundleDirectory] } : {}),
agentLock, runtimeLock: lock, registry, testSigningIdentity: { keyId: 'agent-package-fixture', privateKey }
})
await expect(extractAndVerifyAgentPackage({
archivePath: join(root, result.archive), destinationDirectory: join(root, 'verified'), architecture: 'x64',
desktopVersion: '0.13.6', trustedRegistry: registry
})).resolves.toMatchObject({ descriptor: joint
? { remoteRuntime: { runtimeId: 'opencode' }, additionalRuntimes: [{ runtimeId: 'continue', version: '1.5.47' }] }
: { remoteRuntime: { runtimeId: 'continue', version: '1.5.47' } } })
const options = { operationRoot: join(root, 'operation'), archive: join(root, result.archive),
expectedSha256: result.sha256, homeDirectory: join(root, 'home') }
mkdirSync(options.homeDirectory, { mode: 0o700 })
const expected = joint ? { runtime: { runtimeId: 'opencode' }, additionalRuntimes: [{ runtimeId: 'continue' }] } : { runtime: { runtimeId: 'continue' } }
expect(packageInstaller.preparePackage(options)).toMatchObject({ status: 'prepared', ...expected })
expect(packageInstaller.commitPackage(options)).toMatchObject({ status: 'committed', ...expected })
const contents = unzipSync(new Uint8Array(readFileSync(join(root, result.archive))))
const cnEntry = Object.keys(contents).find(path => path.endsWith('/lib/continue/dist/cn.js'))!
contents[cnEntry] = new TextEncoder().encode('tampered')
const corrupted = join(root, 'corrupted.gbagent')
writeFileSync(corrupted, zipSync(contents, { level: 0 }))
await expect(extractAndVerifyAgentPackage({ archivePath: corrupted,
destinationDirectory: join(root, 'corrupted'), architecture: 'x64', desktopVersion: '0.13.6', trustedRegistry: registry
})).rejects.toThrow()
})
it('verifies and installs offline dependency inventories larger than one MiB', async () => {
const projectRoot = join(temporaryRoot, 'large-offline-config')
const config = createOpenCodeConfigFixture(projectRoot)
+3
View File
@@ -202,6 +202,8 @@ describe('GoodBuddy Agents workflow', () => {
'node.tar.gz',
'--opencode-archive',
'opencode.tgz',
'--continue-archive',
'continue.tgz',
'--output-directory',
'bundle',
'--archive',
@@ -243,6 +245,7 @@ describe('GoodBuddy Agents workflow', () => {
'--platform', 'darwin', '--arch', 'arm64',
'--node-archive', 'node.tar.gz',
'--opencode-archive', 'opencode.tgz',
'--continue-archive', 'continue.tgz',
'--output-directory', 'bundle',
'--archive', 'goodbuddy-agent-1.0.0-darwin-arm64.gbagent'
])).toMatchObject({ platform: 'darwin', arch: 'arm64' })