Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
370 changes: 239 additions & 131 deletions packages/opencode/src/altimate/datamate-transport.ts

Large diffs are not rendered by default.

77 changes: 62 additions & 15 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"

const log = Log.create({ service: "datamate" })

Expand Down Expand Up @@ -206,18 +207,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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When datamate_manager add finds a connected global entry without provenance, this new stamp is never persisted because the connected branch returns first. Boot healing then rejects the entry, so legacy/global entries cannot be repaired; persist the stamp before returning without replacing the live client.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/tools/datamate.ts, line 236:

<comment>When `datamate_manager add` finds a connected global entry without provenance, this new stamp is never persisted because the connected branch returns first. Boot healing then rejects the entry, so legacy/global entries cannot be repaired; persist the stamp before returning without replacing the live client.</comment>

<file context>
@@ -229,6 +229,11 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
+      // 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 }
       const existingNames = await listMcpInConfig(configPath)
       const staleEntries = existingNames.filter(
</file context>

const existingNames = await listMcpInConfig(configPath)
const staleEntries = existingNames.filter(
(n) => n !== DATAMATE_KEY && n.startsWith("datamate-"),
Expand Down Expand Up @@ -249,21 +265,52 @@ 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)
// enabled joins the shared transport-identity set here because this path
// re-derives it too (always written as true below).
const replacedFields = new Set([...TRANSPORT_IDENTITY_FIELDS, "enabled"])
const preserved: Record<string, unknown> = {}
for (const [k, v] of Object.entries(existing ?? {})) {
if (!replacedFields.has(k)) preserved[k] = v
}
const refreshed = {
...preserved,
...mcpConfig,
enabled: true,
...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 @@ -942,6 +942,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,20 +27,43 @@ 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 — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling
// is enforced by the compiler rather than by a comment.
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 All @@ -65,6 +88,10 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined

export const rpc = {
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 @@ -90,6 +117,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
15 changes: 11 additions & 4 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { MCP } from "../mcp"
// Using datamate-transport.ts instead of serve.ts avoids a dep on a cmd handler.
import { syncDatamateUrlFromVscodeMcp } from "../altimate/datamate-transport"
import { readMcpEntryFromDisk } from "../mcp/config"
import { resolveConfigPath } from "../mcp/config"
import { findAllConfigPaths } from "../mcp/config"
import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt"
// altimate_change end
import { FileRoutes } from "./routes/file"
Expand Down Expand Up @@ -688,12 +688,19 @@ export namespace Server {
log.info("reload-datamate: config updated, reconnecting MCP servers", { updatedNames })
// Reconnect each updated server using the freshly-written disk entry.
// Bypass Config.get() (stale singleton) by reading the file directly.
const configPath = await resolveConfigPath(directory)
// The healed entry may live in any config file the sync covers —
// project, project subdirs, or the global config (scope: "global"
// adds) — so scan them all instead of only the project path.
const configPaths = await findAllConfigPaths(directory, Global.Path.config)
const currentStatus = await MCP.status()
for (const name of updatedNames) {
const freshEntry = await readMcpEntryFromDisk(name, configPath)
let freshEntry: Awaited<ReturnType<typeof readMcpEntryFromDisk>>
for (const configPath of configPaths) {
freshEntry = await readMcpEntryFromDisk(name, configPath)
if (freshEntry) break
Comment thread
ralphstodomingo marked this conversation as resolved.
}
if (!freshEntry) {
log.warn("reload-datamate: fresh config entry not found on disk", { name, configPath })
log.warn("reload-datamate: fresh config entry not found on disk", { name, configPaths })
continue
}
log.info("reload-datamate: reconnecting with fresh config", {
Expand Down
Loading
Loading