diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index e15e5af45..955d4751e 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -1,4 +1,6 @@ import z from "zod" +import { readFile } from "fs/promises" +import path from "path" import { Tool } from "../../tool/tool" import { AltimateApi } from "../api/client" import { MCP } from "../../mcp" @@ -11,6 +13,9 @@ import { } from "../../mcp/config" import { Instance } from "../../project/instance" import { Global } from "../../global" +import { Log } from "../../util/log" + +const log = Log.create({ service: "datamate" }) /** Project root for config resolution — falls back to cwd when no git repo is detected. */ function projectRoot() { @@ -25,6 +30,46 @@ export function slugify(name: string): string { .replace(/^-|-$/g, "") } +// altimate_change start — read transport type from .vscode/mcp.json +// Returns { type: "remote", url } if the datamate entry is an HTTP server, +// { type: "local" } if it is a stdio server, or null if the file is missing +// or no datamate entry is found. The caller uses this to pick the right +// mcpConfig shape and falls back to the cloud config when null is returned. +async function readVscodeMcpTransport( + projectRootDir: string, +): Promise<{ type: "remote"; url: string } | { type: "local" } | null> { + try { + const mcpJsonPath = path.join(projectRootDir, ".vscode", "mcp.json") + const text = await readFile(mcpJsonPath, "utf-8") + const parsed = JSON.parse(text) as Record + + // .vscode/mcp.json uses either "servers" (VS Code 1.99+) or "mcpServers" key + const serversMap = + (parsed["servers"] as Record> | undefined) ?? + (parsed["mcpServers"] as Record> | undefined) ?? + {} + + for (const [key, entry] of Object.entries(serversMap)) { + const args = Array.isArray(entry["args"]) ? (entry["args"] as string[]) : [] + const isDatamate = + key === "datamate" || + args.some((a) => a.includes("start-stdio") || a.includes("datamate-cli")) + + if (!isDatamate) continue + + if (typeof entry["url"] === "string") { + return { type: "remote", url: entry["url"] } + } + return { type: "local" } + } + return null + } catch { + // File missing or unparseable — caller falls back to cloud config + return null + } +} +// altimate_change end + export const DatamateManagerTool = Tool.define("datamate_manager", { description: "Manage Altimate Datamates — AI teammates with integrations (Snowflake, Jira, dbt, etc). " + @@ -39,7 +84,9 @@ export const DatamateManagerTool = Tool.define("datamate_manager", { "'list-config' shows all datamate entries saved in config files (project and global). " + "Config files: project config is at /altimate-code.json, " + "global config is at ~/.config/altimate-code/altimate-code.json. " + - "Datamate server names are prefixed with 'datamate-'. " + + "When a VS Code extension datamate entry exists (.vscode/mcp.json has 'datamate' key), " + + "'add' always uses the server name 'datamate' — tools are then prefixed 'datamate_'. " + + "In standalone mode, server names follow 'datamate-' pattern. " + "Do NOT use glob/grep/read to find config files — use 'list-config' instead.", parameters: z.object({ operation: z.enum(["list", "list-integrations", "add", "create", "edit", "delete", "status", "remove", "list-config"]), @@ -154,6 +201,10 @@ async function handleListIntegrations() { } } +// altimate_change start — server name used by the VS Code extension in .vscode/mcp.json +const EXTENSION_DATAMATE_SERVER = "datamate" +// altimate_change end + async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) { if (!args.datamate_id) { return { @@ -163,17 +214,76 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p } } try { - const creds = await AltimateApi.getCredentials() const datamate = await AltimateApi.getDatamate(args.datamate_id) - const serverName = args.name ?? `datamate-${slugify(datamate.name)}` - const mcpConfig = AltimateApi.buildMcpConfig(creds, args.datamate_id) + const transport = await readVscodeMcpTransport(projectRoot()) + + // altimate_change start — single-gateway mode when extension is present + // If .vscode/mcp.json has a "datamate" entry (written by the VS Code extension), + // always use "datamate" as the server name regardless of which specific datamate + // the user selected. This prevents duplicate tool sets — the extension's gateway + // already serves all datamate tools through a single MCP connection. + // In standalone/CLI mode (no .vscode/mcp.json datamate entry), fall back to the + // original per-datamate naming with cloud URL. + const serverName = transport !== null + ? EXTENSION_DATAMATE_SERVER + : (args.name ?? `datamate-${slugify(datamate.name)}`) + + const creds = transport ? undefined : await AltimateApi.getCredentials() + const mcpConfig = + transport?.type === "remote" + ? { type: "remote" as const, url: transport.url } + : transport?.type === "local" + // Extension stdio: no --datamate id needed — active teammate is resolved + // by the extension over the ALTIMATE_EXTENSION_RPC socket at runtime. + ? { type: "local" as const, command: ["datamate", "start-stdio"] } + : AltimateApi.buildMcpConfig(creds!, args.datamate_id) - // Always save to config first so it persists for future sessions const isGlobal = args.scope === "global" const configPath = await resolveConfigPath(isGlobal ? Global.Path.config : projectRoot(), isGlobal) - await addMcpToConfig(serverName, mcpConfig, configPath) - await MCP.add(serverName, mcpConfig) + if (transport !== null) { + // Extension mode: check if "datamate" is already wired up + const existingNames = await listMcpInConfig(configPath) + const staleEntries = existingNames.filter( + (n) => n !== EXTENSION_DATAMATE_SERVER && n.startsWith("datamate-"), + ) + if (staleEntries.length > 0) { + log.info("handleAdd: stale per-datamate entries detected alongside extension gateway", { + staleEntries, + }) + } + + if (existingNames.includes(EXTENSION_DATAMATE_SERVER)) { + // Already in config — just ensure it is connected in this session + const allStatus = await MCP.status() + if (allStatus[EXTENSION_DATAMATE_SERVER]?.status === "connected") { + const mcpTools = await MCP.tools() + const toolCount = Object.keys(mcpTools).filter((k) => + k.startsWith(EXTENSION_DATAMATE_SERVER + "_"), + ).length + const staleNote = + staleEntries.length > 0 + ? `\n\nNote: stale per-datamate entries found in config: ${staleEntries.join(", ")} — use operation 'remove' to clean them up.` + : "" + return { + title: `Datamate '${datamate.name}': already connected via '${EXTENSION_DATAMATE_SERVER}'`, + metadata: { serverName: EXTENSION_DATAMATE_SERVER, datamateId: args.datamate_id, toolCount }, + output: `Datamate tools are already available via the '${EXTENSION_DATAMATE_SERVER}' MCP server (${toolCount} tools active).${staleNote}`, + } + } + // In config but not connected — reconnect + await MCP.add(EXTENSION_DATAMATE_SERVER, mcpConfig) + } else { + // Not in config yet — write then connect + await addMcpToConfig(EXTENSION_DATAMATE_SERVER, { ...mcpConfig, enabled: true }, configPath) + await MCP.add(EXTENSION_DATAMATE_SERVER, mcpConfig) + } + } else { + // Standalone/CLI mode — original behaviour: per-datamate name + cloud URL + await addMcpToConfig(serverName, { ...mcpConfig, enabled: true }, configPath) + await MCP.add(serverName, mcpConfig) + } + // altimate_change end // Check connection status const allStatus = await MCP.status() @@ -197,7 +307,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p return { title: `Datamate '${datamate.name}': connected as '${serverName}'`, metadata: { serverName, datamateId: args.datamate_id, toolCount, configPath }, - output: `Connected datamate '${datamate.name}' (ID: ${args.datamate_id}) as MCP server '${serverName}'.\n\n${toolCount} tools are now available from this datamate. They will be usable in the next message.\n\nConfiguration saved to ${configPath} for future sessions.`, + output: `Connected datamate '${datamate.name}' (ID: ${args.datamate_id}) as MCP server '${serverName}'.\n\n${toolCount} tools are now available. They will be usable in the next message.\n\nConfiguration saved to ${configPath} for future sessions.`, } } catch (e) { return { diff --git a/packages/opencode/src/altimate/tools/mcp-discover.ts b/packages/opencode/src/altimate/tools/mcp-discover.ts index 30af405ac..1a2592bf9 100644 --- a/packages/opencode/src/altimate/tools/mcp-discover.ts +++ b/packages/opencode/src/altimate/tools/mcp-discover.ts @@ -35,6 +35,22 @@ function safeDetail(server: { type: string } & Record): string { return `(${server.type})` } +// altimate_change start — strip session-specific env vars before persisting +// discovered servers. ALTIMATE_EXTENSION_RPC is a Unix socket path that is +// unique to the current VS Code extension host process. Writing it to disk +// causes altimate-code on a future session (or a different VS Code window) to +// spawn datamate processes that connect to the wrong bridge or a dead socket. +// Stripping it forces runtime discovery via ~/.altimate/extension-rpc/ sidecars, +// which always resolves the correct live bridge by matching process.cwd() against +// each bridge's recorded workspaceFolders. +function stripSessionEnv(cfg: import("../../config/config").Config.Mcp): import("../../config/config").Config.Mcp { + if (cfg.type !== "local" || !cfg.environment) return cfg + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ALTIMATE_EXTENSION_RPC: _rpc, ...rest } = cfg.environment + return { ...cfg, environment: Object.keys(rest).length > 0 ? rest : undefined } +} +// altimate_change end + export const McpDiscoverTool = Tool.define("mcp_discover", { description: "Discover MCP servers from external AI tool configs (VS Code, Cursor, Claude Code, Copilot, Gemini) and optionally add them to altimate-code config permanently.", @@ -110,7 +126,9 @@ export const McpDiscoverTool = Tool.define("mcp_discover", { ) for (const name of toAdd) { - await addMcpToConfig(name, discovered[name], configPath) + // altimate_change start — strip session-specific ALTIMATE_EXTENSION_RPC + await addMcpToConfig(name, stripSessionEnv(discovered[name]), configPath) + // altimate_change end } lines.push(`\nAdded ${toAdd.length} server(s) to ${configPath}: ${toAdd.join(", ")}`) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 528498801..b4c96cb04 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -5,10 +5,190 @@ import { Flag } from "../../flag/flag" import { Workspace } from "../../control-plane/workspace" import { Project } from "../../project/project" import { Installation } from "../../installation" +// altimate_change start — URL sync helpers +import { readFile } from "fs/promises" +import path from "path" +import { existsSync } from "fs" +import { resolveConfigPath, addMcpToConfig } from "../../mcp/config" +import { Filesystem } from "../../util/filesystem" +import { parseTree, findNodeAtLocation } from "jsonc-parser" +import { Log } from "../../util/log" +// altimate_change end // altimate_change start — trace: session tracing in headless serve import { subscribeTraceConsumer } from "../../altimate/observability/trace-consumer" // altimate_change end +// altimate_change start +const log = Log.create({ service: "serve" }) +// altimate_change end + +// altimate_change start — sync datamate from .vscode/mcp.json +// Keeps altimate-code.json in sync with what the VS Code extension writes to +// .vscode/mcp.json. For the extension-managed "datamate" entry, uses the +// updatedAt field as the change signal — works for both stdio and HTTP transport. +// All other remote MCP entries fall back to URL comparison (original behaviour). +// Fire-and-forget: errors are logged but never thrown. +// Returns the list of MCP server names whose config was updated. +const DATAMATE_KEY = "datamate" + +export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise { + const updated: string[] = [] + try { + const mcpJsonPath = path.join(cwd, ".vscode", "mcp.json") + if (!existsSync(mcpJsonPath)) return updated + + const text = await readFile(mcpJsonPath, "utf-8") + let parsed: Record + try { + parsed = JSON.parse(text) as Record + } catch { + return updated + } + + const serversMap = + (parsed["servers"] as Record> | undefined) ?? + (parsed["mcpServers"] as Record> | undefined) ?? + {} + + // ── "datamate" entry: sync by updatedAt (works for stdio + HTTP) ──────── + const datamateVscode = serversMap[DATAMATE_KEY] + const vscodeUpdatedAt = + datamateVscode && typeof datamateVscode["updatedAt"] === "string" + ? (datamateVscode["updatedAt"] as string) + : undefined + + if (datamateVscode && vscodeUpdatedAt) { + const configPath = await resolveConfigPath(cwd) + if (await Filesystem.exists(configPath)) { + const configText = await Filesystem.readText(configPath) + const existingTree = parseTree(configText) + const existingNode = existingTree + ? findNodeAtLocation(existingTree, ["mcp", DATAMATE_KEY]) + : undefined + + if (existingNode) { + // Extract current updatedAt + enabled from altimate-code.json + let existingUpdatedAt: string | undefined + let existingEnabled: boolean | undefined + if (existingNode.type === "object" && existingNode.children) { + for (const prop of existingNode.children) { + if (prop.type !== "property" || !prop.children) continue + const k = prop.children[0]!.value as string + if (k === "updatedAt") existingUpdatedAt = prop.children[1]!.value as string + if (k === "enabled") existingEnabled = prop.children[1]!.value as boolean + } + } + + if (vscodeUpdatedAt !== existingUpdatedAt) { + // Build the new config entry in altimate-code.json format. + // .vscode/mcp.json uses "stdio"/"http"/"streamable-http"/"sse"; + // altimate-code.json uses "local"/"remote". + let newEntry: Record + if (datamateVscode["type"] === "stdio") { + const env = datamateVscode["env"] as Record | undefined + const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {} + newEntry = { + type: "local", + command: [ + datamateVscode["command"] as string, + ...((datamateVscode["args"] as string[]) ?? []), + ], + ...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}), + updatedAt: vscodeUpdatedAt, + } + } else { + // http / streamable-http / sse → remote + newEntry = { + type: "remote", + url: datamateVscode["url"] as string, + updatedAt: vscodeUpdatedAt, + } + } + if (typeof existingEnabled === "boolean") newEntry["enabled"] = existingEnabled + + await addMcpToConfig( + DATAMATE_KEY, + newEntry as Parameters[1], + configPath, + ) + log.info("syncDatamateUrl: datamate entry synced", { + type: datamateVscode["type"], + updatedAt: vscodeUpdatedAt, + }) + updated.push(DATAMATE_KEY) + } + } + } + } + + // ── All other remote MCP entries: existing URL-comparison logic ────────── + const httpEntries: Array<{ key: string; url: string }> = [] + for (const [key, entry] of Object.entries(serversMap)) { + if (key === DATAMATE_KEY) continue // already handled above + if (typeof entry["url"] === "string") { + httpEntries.push({ key, url: entry["url"] }) + } + } + + if (httpEntries.length > 0) { + const configPath = await resolveConfigPath(cwd) + if (await Filesystem.exists(configPath)) { + const configText = await Filesystem.readText(configPath) + const tree = parseTree(configText) + const mcpNode = tree ? findNodeAtLocation(tree, ["mcp"]) : undefined + + if (tree && mcpNode && mcpNode.type === "object" && mcpNode.children) { + const remoteMcpEntries: Array<{ name: string; url: string }> = [] + for (const child of mcpNode.children) { + if (child.type !== "property" || !child.children) continue + const nameNode = child.children[0] + const valueNode = child.children[1] + if (!nameNode || !valueNode || valueNode.type !== "object" || !valueNode.children) continue + const typeNode = findNodeAtLocation(valueNode, ["type"]) + const urlNode = findNodeAtLocation(valueNode, ["url"]) + if (typeNode?.value === "remote" && typeof urlNode?.value === "string") { + remoteMcpEntries.push({ name: nameNode.value as string, url: urlNode.value }) + } + } + + for (const remote of remoteMcpEntries) { + const match = httpEntries.find((e) => e.key === remote.name) + if (match && match.url !== remote.url) { + const entryNode = findNodeAtLocation(tree, ["mcp", remote.name]) + if (!entryNode || entryNode.type !== "object" || !entryNode.children) continue + const entry: Record = {} + for (const prop of entryNode.children) { + if (prop.type === "property" && prop.children) { + entry[prop.children[0]!.value as string] = prop.children[1]!.value + } + } + entry["url"] = match.url + entry["updatedAt"] = new Date().toISOString() + await addMcpToConfig( + remote.name, + entry as Parameters[1], + configPath, + ) + log.info("syncDatamateUrl: updating", { + name: remote.name, + oldUrl: remote.url, + newUrl: match.url, + }) + updated.push(remote.name) + } + } + } + } + } + + if (updated.length === 0) log.info("syncDatamateUrl: no changes") + } catch (err) { + console.warn(`[altimate-code] syncDatamateUrlFromVscodeMcp failed (non-fatal):`, err) + } + return updated +} +// altimate_change end + export const ServeCommand = cmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), @@ -19,6 +199,12 @@ export const ServeCommand = cmd({ console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await resolveNetworkOptions(args) + // altimate_change start — sync datamate URL from .vscode/mcp.json on serve startup + // When a VS Code window restarts, the extension picks a new local port and rewrites + // .vscode/mcp.json. Re-reading it here keeps altimate-code.json in sync without + // requiring any user action. + await syncDatamateUrlFromVscodeMcp(process.cwd()) + // altimate_change end const server = await Server.listen(opts) console.log(`altimate-code server listening on http://${server.hostname}:${server.port}`) // altimate_change end diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 99295821c..4e813883f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -622,6 +622,9 @@ export namespace Config { .positive() .optional() .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + // altimate_change start — sync timestamp written by syncDatamateUrlFromVscodeMcp + updatedAt: z.string().optional().describe("ISO timestamp of last URL sync; used to detect reconnect need."), + // altimate_change end }) .strict() .meta({ @@ -661,6 +664,9 @@ export namespace Config { .positive() .optional() .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + // altimate_change start — sync timestamp written by syncDatamateUrlFromVscodeMcp + updatedAt: z.string().optional().describe("ISO timestamp of last URL sync; used to detect reconnect need."), + // altimate_change end }) .strict() .meta({ diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index b110467ce..df9647847 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -15,6 +15,10 @@ import { NamedError } from "@opencode-ai/util/error" import z from "zod/v4" import { Instance } from "../project/instance" import { Installation } from "../installation" +// altimate_change start — persist enabled flag +import { findAllConfigPaths, listMcpInConfig, addMcpToConfig } from "./config" +import { Global } from "../global" +// altimate_change end import { withTimeout } from "@/util/timeout" import { McpOAuthProvider } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" @@ -694,6 +698,31 @@ export namespace MCP { return state().then((state) => state.clients) } + // altimate_change start — persist enabled/disabled to disk so it survives session restarts + async function persistMcpEnabled(name: string, enabled: boolean): Promise { + try { + const paths = await findAllConfigPaths(Instance.directory, Global.Path.config) + for (const p of paths) { + const names = await listMcpInConfig(p) + if (names.includes(name)) { + const cfg = await Config.get() + const entry = cfg.mcp?.[name] + if (entry) + await addMcpToConfig( + name, + { ...entry, enabled } as Parameters[1], + p, + ) + log.info("persistMcpEnabled", { name, enabled, path: p }) + break + } + } + } catch (err) { + log.error("Failed to persist MCP enabled flag", { name, enabled, error: err }) + } + } + // altimate_change end + export async function connect(name: string) { const cfg = await Config.get() const config = cfg.mcp ?? {} @@ -732,6 +761,9 @@ export namespace MCP { s.clients[name] = result.mcpClient if (result.transport) s.transports[name] = result.transport } + // altimate_change start — persist enabled:true so it survives session restarts + await persistMcpEnabled(name, true) + // altimate_change end } export async function disconnect(name: string) { @@ -754,6 +786,9 @@ export namespace MCP { }) delete s.transports[name] s.status[name] = { status: "disabled" } + // altimate_change start — persist enabled:false so disable survives session restarts + await persistMcpEnabled(name, false) + // altimate_change end } /** Fully remove a dynamically-added MCP server — disconnects, and purges from runtime state. */ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 35f330447..7e83d3a8a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -29,6 +29,10 @@ import { ProjectRoutes } from "./routes/project" import { SessionRoutes } from "./routes/session" import { PtyRoutes } from "./routes/pty" import { McpRoutes } from "./routes/mcp" +// altimate_change start — reload-datamate endpoint +import { MCP } from "../mcp" +import { syncDatamateUrlFromVscodeMcp } from "../cli/cmd/serve" +// altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" @@ -561,6 +565,50 @@ export namespace Server { }) }, ) + // altimate_change start — POST /altimate/mcp/reload-datamate + // Updates the datamate MCP server URL from .vscode/mcp.json and reconnects the + // live MCP client so the new URL takes effect immediately without a server restart. + .post("/altimate/mcp/reload-datamate", async (c) => { + try { + const directory = Instance.directory + // altimate_change start + log.info("reload-datamate: syncing URL from .vscode/mcp.json", { directory }) + // altimate_change end + // Sync URL from .vscode/mcp.json → project config; returns updated server names. + const updatedNames = await syncDatamateUrlFromVscodeMcp(directory) + const updated = updatedNames.length > 0 + + if (updated) { + // altimate_change start + log.info("reload-datamate: URL updated, reconnecting MCP servers", { updatedNames }) + // altimate_change end + // Reconnect each updated server that is currently live so the new URL takes effect. + const currentStatus = await MCP.status() + for (const name of updatedNames) { + if (currentStatus[name]?.status === "connected") { + // altimate_change start + log.info("reload-datamate: reconnecting", { name }) + // altimate_change end + await MCP.disconnect(name) + await MCP.connect(name) + } + } + } else { + // altimate_change start + log.info("reload-datamate: no URL changes detected") + // altimate_change end + } + + return c.json({ ok: true, updated }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + // altimate_change start + log.error("reload-datamate: failed", { error }) + // altimate_change end + return c.json({ ok: false, error }) + } + }) + // altimate_change end .all("/*", async (c) => { const path = c.req.path diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 99aefe46b..c0c3132af 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -416,7 +416,30 @@ export namespace SessionPrompt { process.once("beforeExit", emergencySessionEnd) process.once("exit", emergencySessionEnd) // altimate_change end + // altimate_change start — refresh MCP tools on ToolsChanged event + // When a datamate MCP server reconnects (transport change, window restart), + // MCP.ToolsChanged is published. MCP.tools() already uses a per-client cache + // that is invalidated by the notification handler that publishes this event, + // so the next resolveTools() call (once per LLM turn) naturally picks up fresh + // tools without any extra work here. This subscription makes the session layer + // explicitly aware of the reconnect and logs it so it is traceable in prod. + let toolsNeedRefresh = false + const unsubscribeToolsChanged = Bus.subscribe(MCP.ToolsChanged, (event) => { + log.info("MCP.ToolsChanged received — tools will refresh on next turn", { + server: event.properties.server, + sessionID, + }) + toolsNeedRefresh = true + }) + using _unsubToolsChanged = defer(unsubscribeToolsChanged) + // altimate_change end while (true) { + // altimate_change start — log when a ToolsChanged event was received since last turn + if (toolsNeedRefresh) { + log.info("refreshing MCP tools after ToolsChanged event", { sessionID }) + toolsNeedRefresh = false + } + // altimate_change end // altimate_change start — SessionStatus.set became async in v1.4.0; await so busy state flushes before LLM call await SessionStatus.set(sessionID, { type: "busy" }) // altimate_change end @@ -2062,16 +2085,6 @@ export namespace SessionPrompt { // check would miss the gateway-emitted specific names (#888 J1). The api.id // checks are lowercased and tightened to a `claude-` / `anthropic-` / // `anthropic/...` shape so a model named `foo-claude-bench` doesn't false-match. - // - // NOTE: `family` is a free-form, config-settable string on the model schema — - // a connection that declares `family: "claude-*"` on a non-Anthropic gateway - // will classify as Anthropic-like and SKIP the hoist, which reintroduces the - // #887 refusal on that backend. This is a routing-trust input, not an - // escalation vector (whoever sets the model config already controls the - // prompt), but operators adding gateway models should set `family` correctly. - // - // Exported for testing — the hoist/classification contract is exercised - // behaviorally in test/session/plan-layer-e2e.test.ts. export function isAnthropicLikeModel(model: Provider.Model): boolean { if (model.providerID === "anthropic") return true if (model.providerID === "google-vertex-anthropic") return true @@ -2093,9 +2106,6 @@ export namespace SessionPrompt { // file content as synthetic text), so it is not safe to infer trust from `synthetic` // alone. See #888 review feedback. type InsertRemindersResult = { messages: MessageV2.WithParts[]; trustedReminderParts: MessageV2.TextPart[] } - // Exported for testing — the trust boundary (only self-injected reminders land - // in `trustedReminderParts`, never user/file/resource content) is verified - // behaviorally in test/session/plan-layer-e2e.test.ts. export async function insertReminders(input: { messages: MessageV2.WithParts[] agent: Agent.Info