Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cbf4f65
fix: carry the IDE entry's env when wiring the datamate stdio MCP server
Aug 6, 2026
80d4ad4
fix: address review — heal ordering, existing-entry refresh, project-…
Aug 7, 2026
1cb8fad
refactor: share TRANSPORT_IDENTITY_FIELDS between sync and datamate a…
Aug 7, 2026
7bcc9b6
fix: connect refreshed datamate entry with its preserved settings; ca…
Aug 7, 2026
37c3d2c
refactor: hoist shared updatedAt spread in handleAdd
Aug 7, 2026
33b60d8
fix: heal the datamate entry in the global config too
Aug 7, 2026
6625177
fix: harden the datamate heal — full config-filename coverage, intern…
Aug 8, 2026
42311f2
fix: scope legacy config.json to global config candidates only
Aug 8, 2026
7f68428
test: update reload-endpoint source guard for the multi-path disk read
Aug 8, 2026
9b16734
fix: skip blanked {} datamate entries when selecting the mcp.json source
Aug 10, 2026
692417a
refactor: share the blank-tombstone predicate between both mcp.json s…
Aug 26, 2026
e96f26b
fix: bind the datamate heal to extension-written IDE files and never …
Aug 26, 2026
7ad1bca
Merge origin/main: compose DiscoveryFiles canonical scan with the IDE…
Sep 7, 2026
01124f1
fix: stamp provenance on connected entries, canonicalize the home-roo…
Sep 7, 2026
bdce412
fix: refresh connected entries on transport change, share the restamp…
Sep 7, 2026
04daa04
fix: reload read-back walks the same paths the heal writes
Sep 7, 2026
b83a8cb
fix: compare enabled in the connected refresh; reuse the sync's resol…
Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
415 changes: 287 additions & 128 deletions packages/opencode/src/altimate/datamate-transport.ts

Large diffs are not rendered by default.

123 changes: 104 additions & 19 deletions packages/opencode/src/altimate/tools/datamate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import {
listMcpInConfig,
resolveConfigPath,
findAllConfigPaths,
readMcpEntryFromDisk,
} from "../../mcp/config"
import { Instance } from "../../project/instance"
import { Global } from "../../global"
import { Log } from "@/altimate/util/log"
import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport"
import { DATAMATE_KEY, DATAMATE_PROVENANCE, readDatamateTransportFromIde, TRANSPORT_IDENTITY_FIELDS } from "../datamate-transport"
// altimate_change - workspace mode owns the datamate key
import { managedWorkspaceLoaded } from "../workspace/engine-overlay"

Expand All @@ -31,10 +32,11 @@ export function slugify(name: string): string {
.replace(/^-|-$/g, "")
}

// Scans .vscode/mcp.json, .cursor/mcp.json, .github/copilot/mcp.json in projectRootDir
// so this works in Cursor, Copilot, and other IDEs that write their own MCP config file.
// Returns the exact command from the IDE config so altimate-code reuses the same process
// the extension already manages rather than spawning a second one.
// Scans the extension-written IDE configs (.vscode/mcp.json, .cursor/mcp.json —
// the only locations the extension writes; .github/copilot/mcp.json is generic
// discovery territory, see mcp/discover.ts) and returns the exact command so
// altimate-code reuses the process the extension already manages rather than
// spawning a second one.

export const DatamateManagerTool = Tool.define("datamate_manager", {
description:
Expand Down Expand Up @@ -182,6 +184,27 @@ async function handleListIntegrations() {

// DATAMATE_KEY is imported from altimate/datamate-transport.ts (shared constant).

/**
* Merge a fresh IDE-derived transport into an existing persisted entry:
* user-managed fields (timeout, oauth, headers, …) are carried forward;
* transport identity, enabled, updatedAt, and provenance are re-derived.
* Shared by the connected-entry stamp and the disconnected refresh so the
* exclusion rule and merge order cannot drift apart.
*/
function mergeRefreshedEntry(
existing: Record<string, unknown>,
mcpConfig: Record<string, unknown>,
updatedAtField: Record<string, unknown>,
provenanceFields: Record<string, unknown>,
): Record<string, unknown> {
const replacedFields = new Set([...TRANSPORT_IDENTITY_FIELDS, "enabled"])
const merged: Record<string, unknown> = {}
for (const [k, v] of Object.entries(existing)) {
if (!replacedFields.has(k)) merged[k] = v
}
return Object.assign(merged, mcpConfig, { enabled: true }, updatedAtField, provenanceFields)
}

async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) {
if (!args.datamate_id) {
return {
Expand Down Expand Up @@ -242,18 +265,33 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
transport?.type === "remote"
? { type: "remote" as const, url: transport.url }
: transport?.type === "local"
// Use the exact command from the IDE config so we reuse the process the
// extension manages rather than spawning a second one. The extension and
// altimate-code would otherwise maintain two separate stdio child processes
// connected to the same datamate binary, wasting resources.
? { type: "local" as const, command: transport.command }
// Use the exact command + env from the IDE config so we reuse the process
// the extension manages rather than spawning a second one. The env block
// must be carried: on desktop editors the command is the editor's Electron
// binary, which only runs as Node when ELECTRON_RUN_AS_NODE=1 is set —
// spawned without it, the editor GUI boots and opens datamate-cli.js as a
// document instead.
? {
type: "local" as const,
command: transport.command,
...(transport.environment ? { environment: transport.environment } : {}),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
: AltimateApi.buildMcpConfig(creds!, args.datamate_id)

const isGlobal = args.scope === "global"
const configPath = await resolveConfigPath(isGlobal ? Global.Path.config : projectRoot(), isGlobal)

if (transport !== null) {
// IDE/extension mode: check if DATAMATE_KEY is already wired up
// IDE/extension mode: check if DATAMATE_KEY is already wired up.
// updatedAt is disk-only (the runtime config schema has no such field); the
// mcp.json sync uses it to recognize the entry as current instead of
// rewriting it on the next boot.
const updatedAtField = transport.updatedAt ? { updatedAt: transport.updatedAt } : {}
// Provenance (disk-only): marks the entry as derived from this exact IDE
// file. The boot-time heal rewrites a GLOBAL entry only when this stamp
// matches, so an explicit `add` is what authorizes future auto-repair of
// a global-scope entry.
const provenanceFields = { managedBy: DATAMATE_PROVENANCE, sourceMcpJson: transport.source }
Comment thread
ralphstodomingo marked this conversation as resolved.
const existingNames = await listMcpInConfig(configPath)
const staleEntries = existingNames.filter(
(n) => n !== DATAMATE_KEY && n.startsWith("datamate-"),
Expand All @@ -271,6 +309,30 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
log.info("handleAdd: already connected, skipping add", {
serverName: DATAMATE_KEY,
})
// The live client stays untouched, but the persisted entry must still
// track the current IDE transport: without the provenance stamp a
// legacy entry can never be repaired by the boot heal (the explicit-add
// remedy would be a no-op exactly when the entry happens to be
// connected), and without the transport comparison a changed command/
// env under matching provenance would keep spawning stale settings —
// entries without updatedAt are skipped by the boot sync, so this path
// is their only repair. Disk-only update; the fresh transport applies
// from the next session.
const existingOnDisk = await readMcpEntryFromDisk(DATAMATE_KEY, configPath)
const onDisk = (existingOnDisk ?? {}) as Record<string, unknown>
const restamped = mergeRefreshedEntry(onDisk, mcpConfig, updatedAtField, provenanceFields)
// enabled is compared too: a connected entry disabled on disk must be
// re-enabled by an explicit add or the disable resurrects on restart.
const identityChanged = [...TRANSPORT_IDENTITY_FIELDS, "enabled", "managedBy", "sourceMcpJson"].some(
Comment thread
ralphstodomingo marked this conversation as resolved.
(k) => JSON.stringify(onDisk[k]) !== JSON.stringify(restamped[k]),
)
if (identityChanged) {
await addMcpToConfig(DATAMATE_KEY, restamped as Parameters<typeof addMcpToConfig>[1], configPath)
log.info("handleAdd: refreshed connected entry on disk (live client untouched)", {
serverName: DATAMATE_KEY,
configPath,
})
}
const mcpTools = await MCP.tools()
const toolCount = Object.keys(mcpTools).filter((k) =>
k.startsWith(DATAMATE_KEY + "_"),
Expand All @@ -285,21 +347,44 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
output: `Datamate tools are already available via the '${DATAMATE_KEY}' MCP server (${toolCount} tools active).${staleNote}`,
}
}
// In config but not connected — reconnect via MCP.connect() so persistMcpEnabled
// is called and the enabled:true state survives the next session restart.
// Bug-fix: was previously MCP.add() which skips persistMcpEnabled, so a session
// that had the server disabled would not re-enable it on the next restart.
log.info("handleAdd: reconnecting existing datamate entry", {
// In config but not connected — refresh the persisted entry from the current
// IDE transport before connecting. MCP.connect() reads the in-memory Config
// singleton, so a stale entry (e.g. one persisted without its environment
// block) would be respawned broken no matter what the IDE entry says now.
// Same pattern as the reload-datamate endpoint: write the fresh entry to
// disk, then MCP.add() with the config directly. Writing enabled: true
// preserves the re-enable-on-restart behavior MCP.connect()'s
// persistMcpEnabled used to provide; other user-managed fields (timeout,
// oauth, …) are carried over from the existing entry.
log.info("handleAdd: refreshing and reconnecting existing datamate entry", {
serverName: DATAMATE_KEY,
type: mcpConfig.type,
})
await MCP.connect(DATAMATE_KEY)
const existing = await readMcpEntryFromDisk(DATAMATE_KEY, configPath)
const refreshed = mergeRefreshedEntry(
(existing ?? {}) as Record<string, unknown>,
mcpConfig,
updatedAtField,
provenanceFields,
)
await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters<typeof addMcpToConfig>[1], configPath)
// The live client must get the same merged entry as the disk write — the
// bare transport config would drop preserved auth/connection settings
// (headers, oauth, timeout) for the session being connected right now.
await MCP.add(DATAMATE_KEY, refreshed as Parameters<typeof MCP.add>[1])
} else {
// Not in config yet — write to disk then connect
// Not in config yet — write to disk then connect.
log.info("handleAdd: adding new datamate entry", {
serverName: DATAMATE_KEY,
type: mcpConfig.type,
})
await addMcpToConfig(DATAMATE_KEY, { ...mcpConfig, enabled: true }, configPath)
const diskEntry = {
...mcpConfig,
enabled: true,
...updatedAtField,
...provenanceFields,
}
await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters<typeof addMcpToConfig>[1], configPath)
await MCP.add(DATAMATE_KEY, mcpConfig)
}
} else {
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,17 @@ You are speaking to a non-technical business executive. Follow these rules stric
return await execute(sdk)
}

// altimate_change start — heal the datamate MCP entry before the session starts,
// mirroring cli/cmd/serve.ts: an entry persisted without its env block (e.g.
// missing ELECTRON_RUN_AS_NODE for an Electron command) would otherwise be
// re-spawned broken on every run invocation with no path to self-repair. The
// sync resolves the project root itself, so a run from a subdirectory still
// finds the root IDE config and the persisted entry it needs to repair.
{
const { syncDatamateUrlFromVscodeMcp } = await import("../../altimate/datamate-transport")
await syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {})
}
// altimate_change end
await bootstrap(process.cwd(), async () => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/src/cli/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ import { Instance } from "@/project/instance"
// altimate_change — onboarding telemetry: flush this thread's buffer in rpc.shutdown()
import { Telemetry } from "@/altimate/telemetry"
import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding"
// altimate_change start — heal the datamate MCP entry at boot. `altimate serve` runs
// this sync before listening (cli/cmd/serve.ts), but the TUI worker never did, so an
// entry persisted without its env block (e.g. missing ELECTRON_RUN_AS_NODE for an
// Electron command) was re-spawned broken on every TUI session start with no path to
// self-repair.
import { syncDatamateUrlFromVscodeMcp } from "@/altimate/datamate-transport"
// altimate_change end
// altimate_change start — register Altimate Base only across the private parent/worker RPC boundary
import { FreeTier } from "@/altimate/free/client"
import { FreeTierConsent } from "@/altimate/free/consent"
Expand All @@ -39,13 +46,29 @@ const SHUTDOWN_BUDGET_MS = Telemetry.TUI_SHUTDOWN_BUDGET_MS

Heap.start()

// altimate_change start — datamate entry heal (the sync resolves the project root
// itself, so a session launched from a subdirectory still finds the root IDE config
// + persisted entry). Everything that reads the config is sequenced AFTER this
// promise — trace init below, the first in-process request, and Server.listen —
// because the heal writes altimate-code.json with a non-atomic write, and
// InstanceRuntime.load/Config.get() would otherwise race it (transiently truncated
// read) or cache the pre-heal entry, making the first session spawn the broken
// config anyway. Errors are swallowed: a failed sync must never block the TUI.
const datamateSyncReady: Promise<unknown> = syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {})
// altimate_change end

const traceConsumer = new TraceConsumer()
// loadConfig() must complete before the first event: getOrCreateTrace caches, per session, a Trace
// whose snapshot dir comes from loadConfig's FileExporter — an event handled before it finishes caches
// a trace that never persists. So the event chain starts with this promise. loadConfig reads
// Config.get() (a facade needing an Instance on the canonical ALS the bare worker lacks at init), so
// load the project instance for the worker's cwd first; best-effort fallback otherwise.
const traceReady: Promise<void> = (async () => {
// altimate_change start — the datamate heal writes altimate-code.json; let it finish
// before InstanceRuntime.load/Config.get() read (and cache) the config, so the first
// session connects with the healed entry instead of a stale or half-written one.
await datamateSyncReady
// altimate_change end
try {
const ctx = await InstanceRuntime.load({ directory: process.cwd() })
await Instance.restore(ctx, () => traceConsumer.loadConfig())
Expand Down Expand Up @@ -87,6 +110,10 @@ export const rpc = {
},
// altimate_change end
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
// altimate_change start — no request is served until the datamate entry heal
// completes (already-resolved after the first request; effectively free thereafter).
await datamateSyncReady
Comment thread
ralphstodomingo marked this conversation as resolved.
// altimate_change end
const headers = { ...input.headers }
const auth = ServerAuth.header()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
Expand All @@ -112,6 +139,10 @@ export const rpc = {
return result
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
// altimate_change start — external-server mode bypasses rpc.fetch, so gate listen
// on the datamate entry heal the same way (mirrors cli/cmd/serve.ts ordering).
await datamateSyncReady
// altimate_change end
if (server) await server.stop(true)
server = await Server.listen(input)
return { url: server.url.toString() }
Expand Down
54 changes: 36 additions & 18 deletions packages/opencode/src/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@ import { modify, applyEdits, parse, parseTree, findNodeAtLocation, getNodeValue,
import { Filesystem } from "../util/filesystem"
import type { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"

// altimate_change start — primary config filename is altimate-code.json; opencode.json
// is fallback for users with pre-existing upstream installs. New writes land in
// altimate-code.json (first entry of the list).
const CONFIG_FILENAMES = ["altimate-code.json", "opencode.json", "opencode.jsonc"]
// altimate_change start — primary config filename is altimate-code.json; the rest are
// fallbacks for users with pre-existing installs. The list mirrors every filename the
// config loader merges (config/config.ts loadFile calls: altimate-code.json/.jsonc,
// opencode.json/.jsonc, legacy config.json) — an entry in any of them is live config,
// so lookups/removals/heals must see them all. New writes land in altimate-code.json
// (first entry of the list).
const CONFIG_FILENAMES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc"]
// The GLOBAL config dir additionally merges the legacy config.json
// (config/config.ts global load path). The project loader never reads
// config.json, so it must stay out of project-side candidates — otherwise an
// unrelated project file named config.json becomes a discovery hit and, worse,
// a write target for entries the loader would never load.
const GLOBAL_CONFIG_FILENAMES = [...CONFIG_FILENAMES, "config.json"]
// altimate_change end

export async function resolveConfigPath(baseDir: string, global = false) {
Expand All @@ -20,8 +29,8 @@ export async function resolveConfigPath(baseDir: string, global = false) {
)
}

// Then check root-level configs
candidates.push(...CONFIG_FILENAMES.map((f) => path.join(baseDir, f)))
// Then check root-level configs (the global dir also accepts legacy config.json)
candidates.push(...(global ? GLOBAL_CONFIG_FILENAMES : CONFIG_FILENAMES).map((f) => path.join(baseDir, f)))

for (const candidate of candidates) {
if (await Filesystem.exists(candidate)) {
Expand Down Expand Up @@ -95,26 +104,35 @@ export async function listMcpInConfig(configPath: string): Promise<string[]> {
}

/** Find all config files that exist (project + global) */
export async function findAllConfigPaths(projectDir: string, globalDir: string): Promise<string[]> {
export async function findProjectConfigPaths(projectDir: string): Promise<string[]> {
const paths: string[] = []
for (const dir of [projectDir, globalDir]) {
for (const name of CONFIG_FILENAMES) {
const p = path.join(projectDir, name)
if (await Filesystem.exists(p)) paths.push(p)
}
// Also check .altimate-code and .opencode subdirectories
for (const subdir of [".altimate-code", ".opencode"]) {
for (const name of CONFIG_FILENAMES) {
const p = path.join(dir, name)
const p = path.join(projectDir, subdir, name)
if (await Filesystem.exists(p)) paths.push(p)
}
// Also check .altimate-code and .opencode subdirectories for project
if (dir === projectDir) {
for (const subdir of [".altimate-code", ".opencode"]) {
for (const name of CONFIG_FILENAMES) {
const p = path.join(dir, subdir, name)
if (await Filesystem.exists(p)) paths.push(p)
}
}
}
}
return paths
}

export async function findGlobalConfigPaths(globalDir: string): Promise<string[]> {
const paths: string[] = []
for (const name of GLOBAL_CONFIG_FILENAMES) {
const p = path.join(globalDir, name)
if (await Filesystem.exists(p)) paths.push(p)
}
return paths
}

export async function findAllConfigPaths(projectDir: string, globalDir: string): Promise<string[]> {
return [...(await findProjectConfigPaths(projectDir)), ...(await findGlobalConfigPaths(globalDir))]
}

/**
* Read a single MCP entry directly from a config file, bypassing the Config
* singleton so callers can get the freshly-written config without busting the
Expand Down
Loading
Loading