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
+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({
+25 -11
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 !==
@@ -2218,4 +2232,4 @@ async function writeAll(
}
offset += bytesWritten
}
}
}
@@ -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,
@@ -765,4 +770,4 @@ export function compareSemanticVersions(
return leftIdentifier > rightIdentifier ? 1 : -1
}
return 0
}
}
@@ -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 } : {}),
},
};
}