feat: let agents read supervisor story graphs

Expose saved decisions, timelines and source evidence through read-only Story Graph tools across Model, Harness and local/remote OpenCode and Continue. Supervisor enablement and runtime assignments govern discovery and calls; reads do not invoke models or modify stored evidence. Remote use requires matching Desktop and Agent changes.

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

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

Release note: Agents can consult saved supervisor decisions and their sources when continuing work. Continue discussion now lets you review and edit the complete message before sending.
This commit is contained in:
mesalogo
2026-09-28 12:38:00 +08:00
parent 83a3ea261e
commit 66f77b83c2
61 changed files with 1527 additions and 150 deletions
+2 -2
View File
@@ -1188,8 +1188,8 @@ export class AcpRemoteRuntime implements AgentRuntime {
raw = await this.awaitOperation(
context,
'启动远端请求',
request.imageToolBinding
? context.channel.preparePrompt(preparation, request.imageToolBinding, signal)
request.imageToolBinding || request.storyGraphBinding
? context.channel.preparePrompt(preparation, request.imageToolBinding, signal, request.storyGraphBinding)
: context.channel.preparePrompt(preparation),
signal
)
+3 -5
View File
@@ -1270,9 +1270,7 @@ export class ContinueHostAdapter {
)
}
const capabilityServers = [
...(runOptions.workMode === 'execute'
? runOptions.sessionMcpServers ?? []
: []),
...(runOptions.sessionMcpServers ?? []),
...(knowledgeCapability
? [
createLoopbackMcpServer(
@@ -1340,7 +1338,7 @@ export class ContinueHostAdapter {
)
}
const retainedServers =
runOptions.workMode === 'ask' && Boolean(knowledgeCapability)
runOptions.workMode === 'ask' && Boolean(knowledgeCapability || runOptions.sessionMcpServers?.length)
? []
: servers.filter(
(server) =>
@@ -1524,7 +1522,7 @@ export class ContinueHostAdapter {
}
if (
runOptions.workMode === 'ask' &&
runOptions.knowledgeCapability
(runOptions.knowledgeCapability || runOptions.sessionMcpServers?.length)
) {
for (const toolName of scopedReadToolNames) {
args.push('--allow', toolName)
@@ -43,6 +43,8 @@ import { defaultRuntimeSettings } from '../../shared/contracts'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import { AssistantDatabase } from '../assistant/assistant-database'
import { ImageGenerationService } from './image-generation-service'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import type { KnowledgeService } from '../knowledge/knowledge-service'
vi.mock('electron', () => ({ nativeImage: {} }))
@@ -101,6 +103,41 @@ it('generates and edits durable images through the controlled Harness Main proxy
}, 60_000)
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
it('reads Story Graph through the controlled Harness ACP Main proxy in Ask and Execute, then removes it when disabled', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-graph-')))
const db = new AssistantDatabase(':memory:'); db.initialize(root)
let enabled = true
const projectId = db.listProjects()[0]!.id
const gateway = new KnowledgeMcpGateway({} as KnowledgeService, { storyGraphService: {
available: async () => enabled, read: db.readStoryGraph.bind(db)
} })
const signal = new AbortController().signal
const token = gateway.grant('harness-graph', [], signal, 'none', undefined, undefined, undefined, undefined, undefined,
{ projectId, runtimeTarget: 'deepseek-harness' })!
const inProcess = createInProcessLaunch(root, { stream(options) {
const prompt = latestUserText(options)
const tool = options.tools?.find(tool => tool.name === 'story_graph_search')
if (!enabled) { expect(tool).toBeUndefined(); return textResponse('DISABLED') }
expect(tool).toBeDefined()
if (!toolResultText(options, prompt)) return toolCall(prompt, 'story_graph_search', { query: 'Decision' })
expect(toolResultText(options, prompt)).toContain(projectId)
return textResponse('GRAPH_READ')
} })
const provider = new ModelToolProvider(root, [], undefined, gateway)
const runtime = new DeepSeekHarnessRuntime({ defaultWorkspace: root, baseUrl: 'https://chat.test/v1', model: 'fixture',
launch: inProcess.launch, credentialRefs: { [CREDENTIAL_REF]: 'fixture' }, toolProvider: provider,
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000 })
try {
for (const mode of ['ask', 'execute', 'disabled'] as const) {
enabled = mode !== 'disabled'
const events = await collect(runtime.run({ requestId: crypto.randomUUID(), conversationId: 'graph', prompt: mode,
workMode: mode === 'execute' ? 'execute' : 'ask', knowledgeCapabilityToken: token }, signal))
expect(events.at(-1)?.type).toBe('done')
expect(JSON.stringify(events)).toContain(enabled ? 'GRAPH_READ' : 'DISABLED')
}
} finally { await runtime.dispose(); await provider.dispose(); await gateway.dispose(); db.close(); await rm(root, { recursive: true, force: true }) }
}, 60_000)
const SKILL_CALL_ID = 'e2e-skill-call'
const MCP_CALL_ID = 'e2e-mcp-call'
const ASK_MCP_CALL_ID = 'e2e-ask-mcp-call'
+14 -5
View File
@@ -350,6 +350,14 @@ function proxyToolInputSchema(
ReturnType<ModelToolProviderLike['listTools']>
>[number]
): Record<string, unknown> {
if (isStoryGraphTool(tool.name)) {
const schema = structuredClone(tool.inputSchema)
const scope = (schema.properties as Record<string, Record<string, unknown>>).scope!
// Scope is a discriminated union: Global and projects are mutually exclusive.
scope.oneOf = scope.anyOf
delete scope.anyOf
return dshCompatibleMainInputSchema(schema)
}
return isMainWebTool(tool) || tool.name === 'generate_image'
? dshCompatibleMainInputSchema(tool.inputSchema)
: tool.inputSchema
@@ -367,7 +375,7 @@ function boundedProxyToolCatalog(
}> {
const catalog = tools.filter(
(tool) =>
isMainWebTool(tool) ||
isMainWebTool(tool) || isStoryGraphTool(tool.name) ||
(workMode === 'execute' && (tool.source === 'mcp' || tool.name === 'generate_image'))
)
if (catalog.length > MAX_MCP_PROXY_TOOLS) {
@@ -856,7 +864,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
)
const catalog = boundedProxyToolCatalog(tools, context.workMode)
this.proxyToolCatalogs.set(params.sessionId, tools.filter(
(tool) => tool.source === 'mcp' || isMainWebTool(tool) || tool.name === 'generate_image'
(tool) => tool.source === 'mcp' || isMainWebTool(tool) || isStoryGraphTool(tool.name) || tool.name === 'generate_image'
))
return { tools: catalog }
}
@@ -897,7 +905,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
const tool = tools.find(
(candidate) =>
candidate.name === name &&
(candidate.source === 'mcp' || candidate.name === 'generate_image' ||
(candidate.source === 'mcp' || candidate.name === 'generate_image' || isStoryGraphTool(candidate.name) ||
isMainWebTool(candidate))
)
if (!tool) {
@@ -907,7 +915,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
const isWebTool = isMainWebTool(tool)
if (
!isWebTool &&
!isWebTool && !isStoryGraphTool(name) &&
(context.workMode !== 'execute' || !run.authorize)
) {
throw new Error(
@@ -937,7 +945,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
.slice(0, 1_000)}`
)
}
if (!isWebTool) {
if (!isWebTool && !isStoryGraphTool(name)) {
const approval =
run.toolProvider.getApproval(
tool,
@@ -1758,3 +1766,4 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
}
}
import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts'
import { isStoryGraphTool } from '../../shared/story-graph-tools'
@@ -922,7 +922,7 @@ export class GoodBuddyHarnessControlPlane {
definition,
dispose
})
if (MAIN_WEB_TOOL_NAMES.has(tool.name)) {
if (MAIN_WEB_TOOL_NAMES.has(tool.name) || isStoryGraphTool(tool.name)) {
record.askToolDefinitions.set(tool.name, definition)
}
}
@@ -1454,3 +1454,4 @@ export class GoodBuddyHarnessControlPlane {
}
}
import { imageToolDescriptionLimit } from '../../shared/image-generation-contracts'
import { isStoryGraphTool } from '../../shared/story-graph-tools'
+55 -3
View File
@@ -1,4 +1,16 @@
import { randomBytes } from 'node:crypto'
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
import type { RuntimeTarget } from '../../shared/capability-contracts'
export type StoryGraphBinding = { projectId?: string; runtimeTarget: RuntimeTarget }
export type StoryGraphRemoteBinding = {
available(): Promise<boolean>
call(name: StoryGraphToolName, input: unknown, signal: AbortSignal): Promise<Record<string, unknown>>
}
export type StoryGraphService = {
available(binding: StoryGraphBinding): Promise<boolean>
read(name: StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Record<string, unknown>
}
import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference'
import {
createServer,
@@ -191,6 +203,7 @@ export type KnowledgeLibraryListItem = {
}
type Capability = {
storyGraph?: StoryGraphBinding
imageToolBinding?: ImageToolBinding
requestId: string
libraryIds: readonly string[]
@@ -242,6 +255,7 @@ type DownstreamMcpSession = {
}
export type KnowledgeMcpGatewayOptions = {
storyGraphService?: StoryGraphService
capabilityTtlMs?: number
maximumBodyBytes?: number
now?: () => number
@@ -364,6 +378,7 @@ async function readBoundedJson(
}
export class KnowledgeMcpGateway {
private readonly storyGraphService?: StoryGraphService
private readonly capabilities = new Map<string, Capability>()
private readonly downstreamMcpSessions = new Map<
string,
@@ -407,6 +422,7 @@ export class KnowledgeMcpGateway {
this.maximumBodyBytes =
options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES
this.now = options.now ?? Date.now
this.storyGraphService = options.storyGraphService
this.magicNotesDatabase = options.magicNotesDatabase
this.configService = options.configService
this.obsidianService = options.obsidianService
@@ -466,7 +482,8 @@ export class KnowledgeMcpGateway {
browserConversationId?: string,
browserTabId?: BrowserTabId,
browserUsageLease?: BrowserTabUsageLease,
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' },
storyGraph?: StoryGraphBinding
): string | undefined {
const effectiveMagicNotesAccess = this.magicNotesDatabase
? magicNotesAccess
@@ -516,7 +533,8 @@ export class KnowledgeMcpGateway {
effectiveMagicNotesAccess === 'none' &&
effectiveConfigAccess === 'none' &&
!effectiveBrowserConversationId &&
!effectiveObsidian
!effectiveObsidian &&
!(storyGraph && this.storyGraphService)
) {
return undefined
}
@@ -529,6 +547,7 @@ export class KnowledgeMcpGateway {
magicNotesAccess: effectiveMagicNotesAccess,
configAccess: effectiveConfigAccess,
obsidian: effectiveObsidian,
storyGraph: this.storyGraphService ? storyGraph : undefined,
browserConversationId: effectiveBrowserConversationId,
browserTabId: effectiveBrowserTabId,
browserUsageLease: effectiveBrowserUsageLease,
@@ -799,6 +818,7 @@ export class KnowledgeMcpGateway {
getAvailableToolNames(token: string): GoodBuddyBuiltinToolName[] {
const capability = this.getCapability(token)
return [
...(capability.storyGraph ? storyGraphToolNames : []),
...(capability.imageToolBinding ? ['generate_image' as const] : []),
...(capability.libraryIds.length > 0
? knowledgeToolNames
@@ -1544,6 +1564,29 @@ export class KnowledgeMcpGateway {
return { deleted: true, noteId: parsed.noteId }
}
async isStoryGraphAvailable(token: string): Promise<boolean> {
const binding = this.getCapability(token).storyGraph
return Boolean(binding && this.storyGraphService && await this.storyGraphService.available(binding))
}
bindRemoteStoryGraph(token: string): StoryGraphRemoteBinding | undefined {
if (!this.getCapability(token).storyGraph) return undefined
return { available: () => this.isStoryGraphAvailable(token), call: (name, input, signal) => this.callStoryGraphTool(token, name, input, signal) }
}
async callStoryGraphTool(token: string, name: StoryGraphToolName, input: unknown, signal?: AbortSignal): Promise<Record<string, unknown>> {
const capability = this.getCapability(token)
const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])])
effectiveSignal.throwIfAborted()
if (!await this.isStoryGraphAvailable(token)) throw new Error('story_graph_unavailable: Supervisor or runtime capability is disabled')
effectiveSignal.throwIfAborted()
const result = this.storyGraphService!.read(name, input, capability.storyGraph!.projectId, effectiveSignal)
effectiveSignal.throwIfAborted()
if (!await this.isStoryGraphAvailable(token)) throw new Error('story_graph_unavailable: Supervisor or runtime capability is disabled')
effectiveSignal.throwIfAborted()
return result
}
private async callScopedTool(
token: string,
name: ScopedDataToolName,
@@ -1551,6 +1594,10 @@ export class KnowledgeMcpGateway {
signal?: AbortSignal
): Promise<Record<string, unknown>> {
switch (name) {
case 'story_graph_search':
case 'story_graph_get_context':
case 'story_graph_read_source':
return this.callStoryGraphTool(token, name, input, signal)
case 'knowledge_list':
return { libraries: this.listLibraries(token, input) }
case 'knowledge_search':
@@ -1651,7 +1698,8 @@ export class KnowledgeMcpGateway {
token,
extra.signal
)
const scopedTools = [...availableTools].flatMap(
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
@@ -1724,6 +1772,10 @@ export class KnowledgeMcpGateway {
async (call, extra) => {
const name = call.params.name
const input = call.params.arguments ?? {}
if (isStoryGraphTool(name)) {
try { return { content: [{ type: 'text' as const, text: JSON.stringify(await this.callStoryGraphTool(token, name, input, extra.signal)) }] } }
catch (error) { return { isError: true, content: [{ type: 'text' as const, text: error instanceof Error ? error.message : 'Story Graph read failed' }] } }
}
if (name === 'generate_image') {
const capability = this.getCapability(token)
if (!capability.imageToolBinding) throw new Error('Image capability is unavailable')
@@ -1015,6 +1015,7 @@ describe('ModelToolProvider', () => {
listLibraries: vi.fn(() => []),
search: vi.fn(async () => []),
searchMagicNotes: vi.fn(() => []),
isStoryGraphAvailable: vi.fn(async () => true),
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
} as unknown as KnowledgeMcpGateway
const context = {
@@ -1072,6 +1073,7 @@ describe('ModelToolProvider', () => {
it('rechecks cached and refreshed MCP catalogs against each request scope without shrinking the cache', async () => {
const gateway = {
isStoryGraphAvailable: vi.fn(async () => true),
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
} as unknown as KnowledgeMcpGateway
const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer(true)],
@@ -1117,6 +1119,7 @@ describe('ModelToolProvider', () => {
it('enforces the aggregate MCP limit when a cached catalog gains scoped tools', async () => {
const gateway = {
isStoryGraphAvailable: vi.fn(async () => true),
getAvailableToolNames: vi.fn(() => [...scopedDataToolByName.keys()])
} as unknown as KnowledgeMcpGateway
mocks.client.listTools.mockResolvedValue({ tools: Array.from({ length: 40 }, (_, index) => ({
+10 -1
View File
@@ -1252,7 +1252,10 @@ export class ModelToolProvider implements ModelToolProviderLike {
signal: AbortSignal
): Promise<ModelToolDefinition[]> {
signal.throwIfAborted()
const scopedTools = this.getScopedTools(context)
const grantedTools = this.getScopedTools(context)
const storyGraphAvailable = grantedTools.some(tool => tool.name.startsWith('story_graph_')) && context.knowledgeCapabilityToken && this.knowledgeGateway
? await this.knowledgeGateway.isStoryGraphAvailable(context.knowledgeCapabilityToken) : false
const scopedTools = grantedTools.filter(tool => !tool.name.startsWith('story_graph_') || storyGraphAvailable)
const imageTool = context.workMode === 'execute' ? await imageToolDefinition(context.imageToolBinding) : undefined
const webTools = this.webSearchEnabled
? this.getWebSearchDefinitions()
@@ -1406,6 +1409,12 @@ export class ModelToolProvider implements ModelToolProviderLike {
): Promise<ModelToolResult> {
signal.throwIfAborted()
assertToolAuthorizedForWorkMode(name, context)
if (name.startsWith('story_graph_') && scopedDataToolByName.has(name as ScopedDataToolName)) {
if (!this.knowledgeGateway || !context.knowledgeCapabilityToken) throw new Error('Story Graph capability is unavailable')
return createTextToolResult(JSON.stringify(await this.knowledgeGateway.callStoryGraphTool(
context.knowledgeCapabilityToken, name as import('../../shared/story-graph-tools').StoryGraphToolName, argumentsValue, signal
)))
}
if (name.startsWith('obsidian_') && scopedDataToolByName.has(name as ScopedDataToolName)) {
if (!this.knowledgeGateway || !context.knowledgeCapabilityToken) {
throw new Error('Obsidian 工具授权不可用')
+5 -3
View File
@@ -101,7 +101,7 @@ export class NativeClientCoordinator {
target: selected.target, profile: selected.target === 'deepseek-harness' ? selected.settings.deepseekHarnessModelProfile
: selected.target === 'continue' ? selected.settings.continueModelProfile : selected.settings.opencodeModelProfile,
workMode, skills, mcpServers, builtin,
libraries: conversation.knowledgeLibraryIds, magicNotes: application.magicNotesEnabled, obsidian,
libraries: conversation.knowledgeLibraryIds, magicNotes: application.magicNotesEnabled, supervisor: application.heartbeatEnabled, obsidian,
node: application.localToolEnvironment.node,
binary: selected.target === 'continue' ? selected.settings.continueBinaryPath
: selected.target === 'opencode' ? selected.settings.opencodeBinaryPath : undefined,
@@ -162,9 +162,11 @@ export class NativeClientCoordinator {
const token = gateway.grant(requestId, builtin.includes('knowledge-base') ? conversation.knowledgeLibraryIds ?? [] : [], signal,
builtin.includes('magic-notes') && application.magicNotesEnabled ? access : 'none',
builtin.includes('goodbuddy-config') ? { access, workspacePath: space.rootPath } : undefined,
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined)
undefined, undefined, undefined, builtin.includes('obsidian') ? { settings: obsidian, access } : undefined,
builtin.includes('story-graph') && application.heartbeatEnabled ? { projectId: project.id, runtimeTarget: selected.target } : undefined)
const customToken = workMode === 'execute' ? gateway.grantCustomMcp(requestId, mcpServers, signal) : undefined
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` } }] : [])
const endpoints = [token, customToken].flatMap((value, index) => value ? [{ name: `goodbuddy-${index}`, url: gateway.getEndpoint()!, headers: { Authorization: `Bearer ${value}` },
readOnlyTools: index === 0 && workMode === 'ask' ? gateway.getAvailableToolNames(value) : [] }] : [])
if (selected.target === 'deepseek-harness') {
const profile = selected.settings.deepseekHarnessModelProfile
if (!profile) throw new Error('DS Web requires a text model connection')
@@ -91,4 +91,18 @@ describe('native terminal client', () => {
expect(config.mcp.tools.url).toBe('http://127.0.0.1:12345/mcp')
expect(await readFile(new URL(config.plugin[0]), 'utf8')).toContain('execute')
})
it('allows only the bound read tools through native OpenCode Ask permissions and execution hook', async () => {
const { client, input } = await fixture()
await client.open(1, { ...input, mcpServers: [{ name: 'goodbuddy-0', url: 'http://127.0.0.1:12345/mcp', headers: {},
readOnlyTools: ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source'] }] })
const config = JSON.parse(launches[0]!.spawnSpec.env.OPENCODE_CONFIG_CONTENT!)
expect(config.mcp['goodbuddy-0']).toBeDefined()
const hooks = await (await import(/* @vite-ignore */ new URL(config.plugin[0]).href)).default()
for (const name of ['story_graph_search', 'story_graph_get_context', 'story_graph_read_source']) {
expect(config.permission[`goodbuddy-0_${name}`]).toBe('allow')
await expect(hooks['tool.execute.before']({ tool: `goodbuddy-0_${name}` })).resolves.toBeUndefined()
}
await expect(hooks['tool.execute.before']({ tool: 'goodbuddy-0_generate_image' })).rejects.toThrow('read-only')
})
})
+8 -6
View File
@@ -32,7 +32,7 @@ export type NativeTerminalClientInput = {
size?: TerminalSize
skillPackages?: RuntimeSkillPackage[]
/** Session-lived, Main-owned MCP endpoints, such as the existing knowledge gateway. */
mcpServers?: Array<{ name: string; url: string; headers: Record<string, string> }>
mcpServers?: Array<{ name: string; url: string; headers: Record<string, string>; readOnlyTools?: readonly string[] }>
}
export type NativeTerminalClientOptions = {
@@ -141,7 +141,9 @@ export class NativeTerminalClient {
await mkdir(history, { recursive: true })
const origin = await proxy.listen()
const skills = input.skillPackages ?? []
const mcp = input.workMode === 'execute' ? input.mcpServers ?? [] : []
const mcp = input.mcpServers ?? []
const allowedContinueTools = [...continueReadTools, ...mcp.flatMap(server => server.readOnlyTools ?? [])]
const allowedOpenCodeTools = [...openCodeReadTools, 'skill', ...mcp.flatMap(server => (server.readOnlyTools ?? []).map(name => `${server.name}_${name}`))]
if (input.runtime === 'continue') {
// Reuse the pinned adapter's protocol, permission-order and Windows fixes.
const adapter = new ContinueHostAdapter({ binaryPath: detection.path,
@@ -151,7 +153,7 @@ export class NativeTerminalClient {
const bundle = await readFile(bundlePath, 'utf8')
const executionMarker = 'async function hti(e,t={parallelToolCallCount:1}){'
if (bundle.split(executionMarker).length !== 2) throw new Error('Continue native tool execution entry point is incompatible')
await writeFile(bundlePath, bundle.replace(executionMarker, `${executionMarker}if(${JSON.stringify(input.workMode)}==="ask"&&!${JSON.stringify(continueReadTools)}.includes(e.name))throw new Error("GoodBuddy Ask mode is read-only");`))
await writeFile(bundlePath, bundle.replace(executionMarker, `${executionMarker}if(${JSON.stringify(input.workMode)}==="ask"&&!${JSON.stringify(allowedContinueTools)}.includes(e.name))throw new Error("GoodBuddy Ask mode is read-only");`))
args[0] = prepared.entryPath
const configured = input.settings.continueConfigPath.trim()
? await loadContinueConfig(input.settings.continueConfigPath.trim()) : {}
@@ -172,7 +174,7 @@ export class NativeTerminalClient {
Object.assign(env, { CONTINUE_GLOBAL_DIR: history, GOODBUDDY_DISABLE_CONTINUE_UPDATES: '1', CONTINUE_CLI_AUTO_UPDATED: '1', CONTINUE_CLI_ENABLE_TELEMETRY: '0', CONTINUE_METRICS_ENABLED: '0', CONTINUE_CLI_DISABLE_COMMIT_SIGNATURE: '1' })
args.push('--config', configPath)
if (input.workMode === 'execute') args.push('--auto')
else args.push('--readonly', ...continueReadTools.flatMap(name => ['--allow', name]), '--exclude', '*')
else args.push('--readonly', ...allowedContinueTools.flatMap(name => ['--allow', name]), '--exclude', '*')
} else {
const configDirectory = join(temporary, 'config')
if (this.options.bundledRuntimePaths.opencodeConfig) {
@@ -184,11 +186,11 @@ export class NativeTerminalClient {
loopbackOrigin: origin, supportsImageInput: selected.supportsImageInput, workMode: input.workMode
})
const permission = input.workMode === 'execute' ? 'allow' : Object.fromEntries([
['*', 'deny'], ...openCodeReadTools.map(name => [name, 'allow']), ['skill', 'allow']
['*', 'deny'], ...allowedOpenCodeTools.map(name => [name, 'allow'])
])
// A tool hook keeps the launch mode fixed even when the TUI changes agents.
const pluginPath = join(temporary, 'work-mode.mjs')
await writeFile(pluginPath, `export default async () => ({'tool.execute.before': async ({tool}) => { if (${JSON.stringify(input.workMode)} === 'ask' && !${JSON.stringify([...openCodeReadTools, 'skill'])}.includes(tool)) throw new Error('GoodBuddy Ask mode is read-only'); }});`, { mode: 0o600 })
await writeFile(pluginPath, `export default async () => ({'tool.execute.before': async ({tool}) => { if (${JSON.stringify(input.workMode)} === 'ask' && !${JSON.stringify(allowedOpenCodeTools)}.includes(tool)) throw new Error('GoodBuddy Ask mode is read-only'); }});`, { mode: 0o600 })
Object.assign(env, {
OPENCODE_CONFIG_DIR: configDirectory,
OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission,
+2 -1
View File
@@ -114,7 +114,8 @@ export interface RemoteRuntimeChannel {
preparePrompt(
preparation: RemotePromptOperationPreparation,
imageToolBinding?: ImageToolBinding,
waitSignal?: AbortSignal
waitSignal?: AbortSignal,
storyGraphBinding?: import('./knowledge-mcp-gateway').StoryGraphRemoteBinding
): Promise<RemotePromptOperationAcceptance>
/** Starts an Agent-owned ACP prompt exactly once for its stable operation. */
startOwnedPrompt?(
+1
View File
@@ -146,6 +146,7 @@ export type AgentExecutionRequest = Omit<AgentRequest, 'workMode'> & {
trustedInstructions?: string
/** Main-process-only request-scoped authorization for built-in data tools. */
knowledgeCapabilityToken?: string
storyGraphBinding?: import('./knowledge-mcp-gateway').StoryGraphRemoteBinding
imageToolBinding?: ImageToolBinding
/** Main-process-only browser tab fixed for this request. */
browserTabId?: BrowserTabId