feat: unify execution and add workspace file imports

Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content.

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

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

Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
This commit is contained in:
mesalogo
2026-10-04 20:02:59 +08:00
parent 0cd5da33f4
commit 56ebd40bd8
374 changed files with 7075 additions and 6224 deletions
+113
View File
@@ -0,0 +1,113 @@
// Uses the same pinned SSH/stdio probe transport as story-graph-host-probe.ts.
// Desktop owns reopened SQLite; current-source Agent MCP writes the Linux file.
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { AgentImageToolMcp } from '../src/agent-daemon/image-tool-mcp'
import type { RuntimeProtocolBinaryChannel, RuntimeProtocolBinaryFrame } from '../src/main/remote-agent/protocol-remote-runtime-channel'
import { remoteImageToolSchema } from '../src/shared/remote-image-tool-contracts'
const emit = (value: object): void => { process.stdout.write(`${JSON.stringify(value)}\n`) }
const hash = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
async function hostProbe(): Promise<void> {
assert.equal(process.platform, 'linux')
assert.equal(process.arch, 'x64')
const root = await mkdtemp('/root/tmp/goodbuddy-image-save-')
const lines = createInterface({ input: process.stdin })
let adapter: AgentImageToolMcp | undefined
let ready!: (value: { descriptor: unknown; sha256: string }) => void
const initialized = new Promise<{ descriptor: unknown; sha256: string }>(resolve => { ready = resolve })
lines.on('line', line => {
const value = JSON.parse(line)
if (value.descriptor) ready(value)
else if (value.frame) adapter!.onReply(Buffer.from(value.frame, 'base64'))
})
const client = new Client({ name: 'image-save-host-probe', version: '1' })
try {
const initial = await initialized
const descriptor = remoteImageToolSchema.parse(initial.descriptor)
assert.equal(descriptor.description, undefined)
adapter = new AgentImageToolMcp(descriptor, async payload => { emit({ frame: Buffer.from(payload).toString('base64') }) })
await adapter.start()
await client.connect(new StreamableHTTPClientTransport(new URL(adapter.url!)))
const tools = (await client.listTools()).tools
assert.deepEqual(tools.map(tool => tool.name), ['save_image'])
assert.match(JSON.stringify(tools[0]!.inputSchema.properties), /conversation image\/upload references or generate_image results/)
const images = JSON.parse(tools[0]!.description!.split('Conversation images (oldest first): ')[1]!)
assert.equal(images.length, 1)
const path = join(root, 'nested', 'saved.png')
const result = await client.callTool({ name: 'save_image', arguments: { artifactId: images[0].artifactId, path } })
assert.notEqual(result.isError, true)
const bytes = await readFile(path)
assert.equal(hash(bytes), initial.sha256)
emit({ passed: true, platform: process.platform, arch: process.arch, bytes: bytes.length, sha256: hash(bytes), saveOnly: true, modelCalls: 0 })
} finally {
await client.close()
adapter?.close()
lines.close()
process.stdin.pause()
await rm(root, { recursive: true, force: true })
}
}
async function desktopProbe(): Promise<void> {
const { AssistantDatabase } = await import('../src/main/assistant/assistant-database')
const { ImageGenerationService } = await import('../src/main/agent/image-generation-service')
const { MainImageToolSession } = await import('../src/main/remote-agent/main-image-tool-session')
const host = process.argv[2], remoteBundle = process.argv[3]
assert(host && remoteBundle && /^\/root\/tmp\/[\w./-]+$/.test(remoteBundle), 'Expected SSH host and bundle in /root/tmp')
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-image-save-desktop-'))
let database = new AssistantDatabase(join(root, 'assistant.sqlite'))
database.initialize(root)
const bytes = Buffer.concat([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64'), Buffer.alloc(300 * 1024, 17)])
const image = database.createImageArtifact({ title: 'Direct conversation image', mimeType: 'image/png', base64: bytes.toString('base64') })
const context = { conversationId: randomUUID(), messageId: randomUUID(), requestId: randomUUID() }
database.saveLocalConversations([{ header: { id: context.conversationId, title: 'Save probe', updatedAt: 1 }, messages: [
{ id: context.messageId, role: 'assistant', content: 'Direct image reply', createdAt: 1, state: 'complete', artifactIds: [image.id] }
] }])
database.close()
database = new AssistantDatabase(join(root, 'assistant.sqlite'))
database.initialize(root)
const service = new ImageGenerationService({ database, getSettings: async () => ({ modelProfiles: [] }) })
const binding = service.bind(context)
assert.equal(await binding.describe(), undefined)
const descriptor = remoteImageToolSchema.parse({ channelId: 'image-save-probe', channelEpoch: '1', saveDescription: await binding.describeSave!() })
const child = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5', host,
`TMPDIR=/root/tmp ELECTRON_RUN_AS_NODE=1 /opt/GoodBuddy/goodbuddy ${remoteBundle} --host`],
{ stdio: ['pipe', 'pipe', 'inherit'], signal: AbortSignal.timeout(60_000) })
const exited = new Promise<number | null>((resolve, reject) => { child.once('close', resolve); child.once('error', reject) })
const queue: RuntimeProtocolBinaryFrame[] = []
let receive: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined
let reject: ((error: Error) => void) | undefined
const channel: RuntimeProtocolBinaryChannel = { channelId: descriptor.channelId, channelEpoch: descriptor.channelEpoch,
send: async payload => { child.stdin.write(`${JSON.stringify({ frame: Buffer.from(payload).toString('base64') })}\n`) },
receive: async () => queue.shift() ?? await new Promise((resolve, fail) => { receive = resolve; reject = fail }),
close: () => reject?.(new Error('closed')), onClose: () => () => {} }
const main = new MainImageToolSession(channel, binding)
const lines = createInterface({ input: child.stdout })
let passed = false, chunks = 0
lines.on('line', line => {
const value = JSON.parse(line)
if (value.frame) {
chunks++
const frame = { payload: Buffer.from(value.frame, 'base64'), sequence: String(chunks), consume: async () => {} }
if (receive) { const resolve = receive; receive = undefined; resolve(frame) } else queue.push(frame)
} else if (value.passed) { passed = true; emit({ ...value, chunks, reopenedDesktopDatabase: true }) }
})
child.stdin.write(`${JSON.stringify({ descriptor, sha256: hash(bytes) })}\n`)
try { assert.equal(await exited, 0); assert(passed); assert(chunks > 1) }
finally {
lines.close(); main.close(); child.kill()
await service.dispose(); database.close()
await rm(root, { recursive: true, force: true })
}
}
void (process.argv[2] === '--host' ? hostProbe() : desktopProbe()).catch(error => { console.error(error); process.exitCode = 1 })
+1 -1
View File
@@ -85,7 +85,7 @@ void app.whenReady().then(async () => {
const observation = { stage, inputUtf16: prompt.length, inputBytes: Buffer.byteLength(prompt), done: false }
metrics.calls.push(observation); persist()
try {
for await (const event of runtime.run({ requestId: id, conversationId: id, workMode: 'ask', prompt }, AbortSignal.any([signal, AbortSignal.timeout(600000)]), async () => 'deny')) {
for await (const event of runtime.run({ requestId: id, conversationId: id, prompt }, AbortSignal.any([signal, AbortSignal.timeout(600000)]), async () => 'deny')) {
if (event.type === 'text') { firstTextMs ??= performance.now() - start; text += event.delta }
if (event.type === 'done') done = true
if (event.type === 'model-usage') usage.push({ inputTokens: event.inputTokens, outputTokens: event.outputTokens, cacheReadTokens: event.cacheReadTokens })
+14 -14
View File
@@ -21,7 +21,7 @@ import { KnowledgeMcpGateway } from '../src/main/agent/knowledge-mcp-gateway'
import type { KnowledgeService } from '../src/main/knowledge/knowledge-service'
import { MainImageToolSession } from '../src/main/remote-agent/main-image-tool-session'
import type { RuntimeProtocolBinaryChannel, RuntimeProtocolBinaryFrame } from '../src/main/remote-agent/protocol-remote-runtime-channel'
import { isStoryGraphTool, storyGraphToolNames } from '../src/shared/story-graph-tools'
import { storyGraphToolNames } from '../src/shared/story-graph-tools'
const emit = (value: object) => process.stdout.write(`${JSON.stringify(value)}\n`)
@@ -50,7 +50,7 @@ async function hostProbe() {
if (++inferenceRequests > 20) throw new Error('Unexpected inference loop')
const tools = body.tools as Array<{ function: { name: string } }> | undefined
const tool = tools?.find(tool => tool.function.name.endsWith('story_graph_search'))
if (tools?.length) assert.ok(tool, 'Runtime must expose Story Graph in Ask and Execute')
if (tools?.length) assert.ok(tool, 'Runtime must expose Story Graph')
else backgroundRequests++
if (live) {
const response = new Promise<{ status: number; body: string }>(resolve => pendingInference.set(inferenceRequests, resolve))
@@ -89,13 +89,12 @@ async function hostProbe() {
assert.equal(unsupported.isError, true)
await new Promise<void>(resolve => inference.listen(0, '127.0.0.1', resolve))
const origin = `http://127.0.0.1:${(inference.address() as { port: number }).port}/${randomBytes(32).toString('base64url')}`
for (const workMode of ['ask', 'execute'] as const) {
const config = createOpenCodeModelBridgeProviderConfig({ protocol: 'openai-chat-completions', model: 'graph-probe', loopbackOrigin: origin, workMode })
const permission = workMode === 'execute' ? 'allow' : { '*': 'deny', 'graph_story_graph_*': 'allow' }
{
const config = createOpenCodeModelBridgeProviderConfig({ protocol: 'openai-chat-completions', model: 'graph-probe', loopbackOrigin: origin })
const child = spawn(opencode!, ['acp'], { cwd: root, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env,
HOME: root, XDG_CONFIG_HOME: join(root, 'config'), XDG_DATA_HOME: join(root, 'data'), XDG_CACHE_HOME: join(root, 'cache'), XDG_STATE_HOME: join(root, 'state'),
OPENCODE_DISABLE_AUTOUPDATE: '1', OPENCODE_DISABLE_MODELS_FETCH: '1', OPENCODE_DISABLE_PROJECT_CONFIG: '1', OPENCODE_DISABLE_EXTERNAL_SKILLS: '1', OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1',
OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission, agent: { build: { permission } } })
OPENCODE_CONFIG_CONTENT: JSON.stringify(config)
} })
child.stderr.resume()
const exited = new Promise<void>(resolve => child.once('close', () => resolve()))
@@ -104,17 +103,17 @@ async function hostProbe() {
subscribeOutput: (listener: Parameters<RuntimeAcpProcessOwner['subscribeOutput']>[0]) => { const data = (data: Buffer) => { void listener({ stream: 'stdout', data }) }; child.stdout.on('data', data); return () => { child.stdout.off('data', data) } },
subscribeExit: (listener: () => void) => { child.on('close', listener); return () => { child.off('close', listener) } }
} as RuntimeAcpProcessOwner
const transcript = new SemanticPromptStore(join(root, `${workMode}.sqlite`))
const transcript = new SemanticPromptStore(join(root, 'graph.sqlite'))
let done!: () => void
const completed = new Promise<void>(resolve => { done = resolve })
const owner = new AgentOwnedAcpPrompt({ bindingId: 'graph', controllerId: 'probe', workspaceDirectory: root, process: processOwner, transcript,
mcpServers: () => [{ type: 'http', name: 'graph', url: adapter.url!, headers: [] }],
completePrompt: async (_operation, _status, _response, commit) => { commit(); done() } })
try {
transcript.prepare({ bindingId: 'graph', controllerId: 'probe', operationId: workMode, requestId: workMode, preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
await owner.start({ bindingId: 'graph', operationId: workMode, requestId: workMode, prompt: [{ type: 'text', text: 'Search the story graph for Decision B using story_graph_search, then reply GRAPH_RUNTIME_OK. Do not use other tools.' }] }, workMode)
transcript.prepare({ bindingId: 'graph', controllerId: 'probe', operationId: 'graph-probe', requestId: 'graph-probe', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
await owner.start({ bindingId: 'graph', operationId: 'graph-probe', requestId: 'graph-probe', prompt: [{ type: 'text', text: 'Search the story graph for Decision B using story_graph_search, then reply GRAPH_RUNTIME_OK. Do not use other tools.' }] })
await completed
const page = transcript.page({ bindingId: 'graph', operationId: workMode, controllerId: 'probe', afterSequence: '0', limit: 128 })
const page = transcript.page({ bindingId: 'graph', operationId: 'graph-probe', controllerId: 'probe', afterSequence: '0', limit: 128 })
assert.equal(page.state, 'completed')
const answer = page.events.map(event => {
const update = (event.payload as { update?: { sessionUpdate: string; content?: { text?: string } } }).update
@@ -122,7 +121,8 @@ async function hostProbe() {
}).join('')
assert.ok(answer.includes('GRAPH_RUNTIME_OK'), 'Runtime must return the completion marker')
} finally { owner.close(); child.kill(); await exited; transcript.close() }
if (live) continue
}
if (!live) {
const continueAdapter = new ContinueHostAdapter({ binaryPath: continueEntry!, configPath: '', workspace: root, cacheRoot: join(root, 'continue'), mode: 'chat',
modelProfile: { id: 'probe', name: 'Probe', modelName: 'graph-probe', baseUrl: `${origin}/v1`, protocol: 'openai-chat-completions', authentication: 'none' },
launchHost: (entry, args, options) => {
@@ -131,8 +131,8 @@ async function hostProbe() {
return process
} })
try {
const result = await continueAdapter.run('Search the graph.', AbortSignal.timeout(40_000), async approval => workMode === 'execute' || isStoryGraphTool(approval.toolName ?? '') ? 'once' : 'deny', {
workMode, sessionMcpServers: [{ name: 'graph', type: 'streamable-http', url: adapter.url!, requestOptions: { headers: {} } }]
const result = await continueAdapter.run('Search the graph.', AbortSignal.timeout(40_000), async () => 'once', {
sessionMcpServers: [{ name: 'graph', type: 'streamable-http', url: adapter.url!, requestOptions: { headers: {} } }]
})
assert.match(result.text, /GRAPH_RUNTIME_OK/)
} finally { await continueAdapter.dispose() }
@@ -142,7 +142,7 @@ async function hostProbe() {
assert.equal((await client.callTool({ name: 'story_graph_search', arguments: { query: 'Decision' } })).isError, true)
assert.deepEqual(failures, [])
emit({ passed: true, inferenceRequests, backgroundRequests, paidCalls: live ? inferenceRequests : 0,
runtimes: live ? ['OpenCode Ask', 'OpenCode Execute'] : ['OpenCode Ask', 'OpenCode Execute', 'Continue Ask', 'Continue Execute'] })
runtimes: live ? ['OpenCode'] : ['OpenCode', 'Continue'] })
} finally { lines.close(); await client.close(); adapter.close(); inference.closeAllConnections(); inference.close(); await rm(root, { recursive: true, force: true }) }
}
+1 -1
View File
@@ -65,7 +65,7 @@ void app.whenReady().then(async () => {
let output = '', firstTextMs: number | null = null, done = false
const usage: object[] = []
try {
for await (const event of runtime.run({ requestId: randomUUID(), conversationId, workMode: 'ask', prompt }, AbortSignal.timeout(60000), async () => 'deny')) {
for await (const event of runtime.run({ requestId: randomUUID(), conversationId, prompt }, AbortSignal.timeout(60000), async () => 'deny')) {
if (event.type === 'text') { firstTextMs ??= performance.now() - started; output += event.delta }
if (event.type === 'done') done = true
if (event.type === 'model-usage') usage.push({ inputTokens: event.inputTokens, outputTokens: event.outputTokens, cacheReadTokens: event.cacheReadTokens, cacheWriteTokens: event.cacheWriteTokens })
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
import { ControllerRegistry } from '../src/agent-daemon/controller-registry'
import { WorkspaceRegistry } from '../src/agent-daemon/workspace-registry'
import { WorkspaceGitService } from '../src/agent-daemon/workspace-git-service'
import { createWorkspaceProtocolMethods } from '../src/agent-daemon/workspace-protocol-methods'
import { RemoteWorkspaceAccess, type RemoteWorkspaceTransportLease } from '../src/main/workspace/remote-workspace-access'
import { importWorkspaceFiles } from '../src/main/workspace/workspace-import'
// Source-level Host probe: real workspace handlers over an SSH stdio test bridge.
// The bridge does not replace the separate production binary-framing tests.
if (process.argv[2] === '--host') {
const root = await mkdtemp('/root/tmp/goodbuddy-workspace-import-')
const controllers = new ControllerRegistry()
const context = { controller: controllers.attach('workspace-import-probe'), channelId: 'workspace-import-probe' }
const methods = createWorkspaceProtocolMethods({ workspaces: new WorkspaceRegistry({ controllers }), git: new WorkspaceGitService() })
await mkdir(join(root, 'selected'))
process.stdout.write(`${JSON.stringify({ root })}\n`)
try {
for await (const line of createInterface({ input: process.stdin })) {
const { id, method, params } = JSON.parse(line)
try {
let result: unknown
if (method === 'probe/verify') {
const data = await readFile(join(root, 'selected/image.bin'))
assert.equal(data.length, 700001)
assert(data.every((byte, index) => byte === index % 256))
const metadata = await stat(join(root, 'selected/image.bin'))
result = { bytes: data.length, modifiedAt: metadata.mtime.toISOString(), createdAt: metadata.birthtimeMs > 0 ? metadata.birthtime.toISOString() : undefined }
} else {
assert(method in methods)
result = await methods[method as keyof typeof methods](params, context)
}
process.stdout.write(`${JSON.stringify({ id, result })}\n`)
} catch (error) { process.stdout.write(`${JSON.stringify({ id, error: String(error) })}\n`) }
}
} finally { await rm(root, { recursive: true, force: true }) }
} else {
const host = process.argv[2], remoteBundle = process.argv[3]
assert(host && remoteBundle && /^\/root\/tmp\/[\w./-]+$/.test(remoteBundle), 'Expected SSH host and bundle in /root/tmp')
const child = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5', host,
`TMPDIR=/root/tmp ELECTRON_RUN_AS_NODE=1 /opt/GoodBuddy/goodbuddy ${remoteBundle} --host`], { stdio: ['pipe', 'pipe', 'inherit'] })
const exited = new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
let ready!: (root: string) => void
const rootPromise = new Promise<string>(resolve => { ready = resolve })
const pending = new Map<number, { resolve: (value: never) => void; reject: (error: Error) => void }>()
createInterface({ input: child.stdout }).on('line', line => {
const message = JSON.parse(line)
if (message.root) ready(message.root)
else { const request = pending.get(message.id); pending.delete(message.id); if (message.error) request?.reject(new Error(message.error)); else request?.resolve(message.result as never) }
})
let nextId = 0
const request = <T>(method: string, params: unknown): Promise<T> => new Promise((resolve, reject) => {
const id = ++nextId
pending.set(id, { resolve, reject })
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`)
})
const timeout = setTimeout(() => { child.kill(); process.exitCode = 1 }, 60000)
const source = await mkdtemp(join(tmpdir(), 'goodbuddy-workspace-import-source-'))
try {
const root = await rootPromise
const lease: RemoteWorkspaceTransportLease = {
binding: { hostId: 'probe-host', hostRevision: 1, hostKeyGeneration: 1, remoteUsername: 'root', agentInstallationId: 'probe', agentBinaryDigest: `sha256:${'a'.repeat(64)}`, agentVersion: 'source', agentArchitecture: 'x64', agentProtocolMajor: 1, capabilityGeneration: 1 },
validateWorkspace: params => request('workspace/validate', params), closeWorkspace: params => request('workspace/close', params),
manageWorkspace: params => request('workspace/manage', params), listWorkspace: params => request('workspace/list', params),
statWorkspace: params => request('workspace/stat', params), readWorkspaceText: params => request('workspace/readText', params),
searchWorkspace: params => request('workspace/search', params), getGitStatus: params => request('git/status', params), getGitDiff: params => request('git/diff', params), release: () => {}
}
const access = new RemoteWorkspaceAccess({ hostId: 'probe-host', remoteRootPath: root }, { acquireLease: async () => lease })
await writeFile(join(source, 'image.bin'), Buffer.alloc(700001).map((_, index) => index % 256))
await writeFile(join(source, 'empty.txt'), '')
const imported = await importWorkspaceFiles(access, 'selected', [join(source, 'image.bin'), join(source, 'empty.txt')])
assert.deepEqual(imported, { imported: ['selected/image.bin', 'selected/empty.txt'], failed: [] })
const duplicate = await importWorkspaceFiles(access, 'selected', [join(source, 'image.bin')])
assert.equal(duplicate.failed.length, 1); assert.equal(duplicate.imported.length, 0)
const verified = await request<{ bytes: number; modifiedAt: string; createdAt?: string }>('probe/verify', {})
const listing = await access.listDirectory({ path: 'selected' })
const image = listing.entries.find(entry => entry.name === 'image.bin')!
assert.equal(image.modifiedAt, verified.modifiedAt); assert.equal(image.createdAt, verified.createdAt)
assert.equal((await access.listDirectory({ path: '' })).entries.length, 1)
await access.dispose()
process.stdout.write(`${JSON.stringify({ passed: true, ...verified, imported: imported.imported, collisionPreserved: true, modelCalls: 0 })}\n`)
} finally {
child.stdin.end()
await exited
clearTimeout(timeout)
await rm(source, { recursive: true, force: true })
}
}