Skip to content
Closed
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
40 changes: 34 additions & 6 deletions packages/opencode/src/altimate/datamate-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,26 @@ const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const

export type DatamateTransport =
| { type: "remote"; url: string }
| { type: "local"; command: string[] }
| { type: "local"; command: string[]; environment?: Record<string, string>; updatedAt?: string }

/**
* Env block to carry over when spawning the datamate CLI from an IDE mcp.json
* entry, minus ALTIMATE_EXTENSION_RPC (the extension-private RPC socket path,
* which goes stale whenever the extension restarts and is re-resolved by the
* CLI itself). ELECTRON_RUN_AS_NODE must survive: on desktop editors the
* entry's command is the editor's Electron binary, and without the flag the
* spawn boots the editor GUI — which opens datamate-cli.js as a document in
* the IDE — instead of running it as a Node script.
*/
function extractSpawnEnvironment(raw: unknown): Record<string, string> | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (key === "ALTIMATE_EXTENSION_RPC") continue
if (typeof value === "string") env[key] = value
}
return Object.keys(env).length > 0 ? env : undefined
}

/**
* Parse a single mcp.json file and return the servers map, trying each of the
Expand Down Expand Up @@ -108,11 +127,21 @@ export async function readDatamateTransportFromIde(
return { type: "remote", url: entry["url"] }
}

// stdio entry — reuse the exact command + args the extension registered
// stdio entry — reuse the exact command + args + env the extension
// registered. Dropping env here regresses desktop editors: the entry's
// command is the editor's Electron binary and only runs as Node when
// ELECTRON_RUN_AS_NODE=1 is passed through.
const cmd = typeof entry["command"] === "string" ? entry["command"] : undefined
const args = Array.isArray(entry["args"]) ? (entry["args"] as string[]) : []
if (cmd) {
return { type: "local", command: [cmd, ...args] }
const environment = extractSpawnEnvironment(entry["env"])
const updatedAt = typeof entry["updatedAt"] === "string" ? entry["updatedAt"] : undefined
return {
type: "local",
command: [cmd, ...args],
...(environment ? { environment } : {}),
...(updatedAt ? { updatedAt } : {}),
}
}

// Entry exists but has no usable command — treat as local marker
Expand Down Expand Up @@ -221,8 +250,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[

let newEntry: Record<string, unknown>
if ("command" in datamateVscode) {
const env = datamateVscode["env"] as Record<string, string> | undefined
const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {}
const environment = extractSpawnEnvironment(datamateVscode["env"])
const cmd =
typeof datamateVscode["command"] === "string"
? (datamateVscode["command"] as string)
Expand All @@ -231,7 +259,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
...preserved,
type: "local",
command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])],
...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}),
...(environment ? { environment } : {}),
updatedAt: vscodeUpdatedAt,
}
} else {
Expand Down
28 changes: 21 additions & 7 deletions packages/opencode/src/altimate/tools/datamate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,17 @@ 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 } : {}),
}
: AltimateApi.buildMcpConfig(creds!, args.datamate_id)

const isGlobal = args.scope === "global"
Expand Down Expand Up @@ -258,12 +264,20 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
})
await MCP.connect(DATAMATE_KEY)
} else {
// Not in config yet — write to disk then connect
// Not in config yet — write to disk then connect. The persisted entry
// additionally carries the IDE entry's updatedAt (disk-only; the runtime
// config schema has no such field) so the mcp.json sync recognizes the
// entry as current instead of rewriting it on the next serve boot.
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,
...(transport?.type === "local" && transport.updatedAt ? { updatedAt: transport.updatedAt } : {}),
}
await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters<typeof addMcpToConfig>[1], configPath)
await MCP.add(DATAMATE_KEY, mcpConfig)
}
} else {
Expand Down
9 changes: 9 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,15 @@ 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.
{
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
21 changes: 21 additions & 0 deletions packages/opencode/src/cli/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,26 @@ 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, awaited before the first in-process
// request (session start connects MCP servers from the config this sync repairs).
// 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
Expand Down Expand Up @@ -65,6 +78,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
// altimate_change end
const headers = { ...input.headers }
const auth = ServerAuth.header()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
Expand All @@ -90,6 +107,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, test, expect } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { mkdir, writeFile, readFile } from "fs/promises"
import path from "path"
import {
readDatamateTransportFromIde,
syncDatamateUrlFromVscodeMcp,
DATAMATE_KEY,
} from "../../src/altimate/datamate-transport"

// Regression tests for the stdio env carry-through. The IDE extension writes the
// datamate stdio entry with an env block — on desktop editors the entry's command
// is the editor's Electron binary and env carries ELECTRON_RUN_AS_NODE=1, without
// which the spawn boots the editor GUI and opens datamate-cli.js as a document
// instead of running it. readDatamateTransportFromIde used to drop env entirely,
// so `datamate_manager add` persisted a broken entry that re-popped the file on
// every session launch.

async function seedIdeStdio(dir: string, entry: Record<string, unknown>) {
await mkdir(path.join(dir, ".vscode"), { recursive: true })
await writeFile(
path.join(dir, ".vscode", "mcp.json"),
JSON.stringify({ servers: { [DATAMATE_KEY]: entry } }, null, 2),
)
}

describe("readDatamateTransportFromIde stdio env carry-through", () => {
test("carries env minus ALTIMATE_EXTENSION_RPC, plus updatedAt", async () => {
await using tmp = await tmpdir()
await seedIdeStdio(tmp.path, {
type: "stdio",
command: "/path/to/electron",
args: ["/ext/dist/datamate-cli.js", "start-stdio"],
env: {
ALTIMATE_EXTENSION_RPC: "/tmp/altimate-mcp-1.sock",
ELECTRON_RUN_AS_NODE: "1",
},
updatedAt: "2026-08-06T00:00:00.000Z",
})

const t = await readDatamateTransportFromIde(tmp.path)
expect(t).toEqual({
type: "local",
command: ["/path/to/electron", "/ext/dist/datamate-cli.js", "start-stdio"],
environment: { ELECTRON_RUN_AS_NODE: "1" },
updatedAt: "2026-08-06T00:00:00.000Z",
})
})

test("env with only ALTIMATE_EXTENSION_RPC → environment omitted entirely", async () => {
await using tmp = await tmpdir()
await seedIdeStdio(tmp.path, {
type: "stdio",
command: "/usr/lib/code-server/lib/node",
args: ["/ext/dist/datamate-cli.js", "start-stdio"],
env: { ALTIMATE_EXTENSION_RPC: "/tmp/altimate-mcp-1.sock" },
})

const t = await readDatamateTransportFromIde(tmp.path)
expect(t).toEqual({
type: "local",
command: ["/usr/lib/code-server/lib/node", "/ext/dist/datamate-cli.js", "start-stdio"],
})
})

test("entry without env keeps the bare local shape (back-compat)", async () => {
await using tmp = await tmpdir()
await seedIdeStdio(tmp.path, {
type: "stdio",
command: "datamate",
args: ["start-stdio"],
})

const t = await readDatamateTransportFromIde(tmp.path)
expect(t).toEqual({ type: "local", command: ["datamate", "start-stdio"] })
})

test("non-string env values are ignored, string values kept", async () => {
await using tmp = await tmpdir()
await seedIdeStdio(tmp.path, {
type: "stdio",
command: "/path/to/electron",
args: ["start-stdio"],
env: {
ELECTRON_RUN_AS_NODE: "1",
BOGUS_NUMBER: 42,
BOGUS_OBJECT: { nested: true },
},
})

const t = await readDatamateTransportFromIde(tmp.path)
expect(t?.type).toBe("local")
if (t?.type === "local") {
expect(t.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" })
}
})
})

describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => {
test("synced local entry strips ALTIMATE_EXTENSION_RPC but keeps ELECTRON_RUN_AS_NODE", async () => {
await using tmp = await tmpdir()
const configPath = path.join(tmp.path, "altimate-code.json")
await writeFile(
configPath,
JSON.stringify(
{ mcp: { [DATAMATE_KEY]: { type: "local", command: ["stale"], enabled: true, updatedAt: "T1" } } },
null,
2,
),
)
await seedIdeStdio(tmp.path, {
type: "stdio",
command: "/path/to/electron",
args: ["/ext/dist/datamate-cli.js", "start-stdio"],
env: {
ALTIMATE_EXTENSION_RPC: "/tmp/altimate-mcp-1.sock",
ELECTRON_RUN_AS_NODE: "1",
},
updatedAt: "T2",
})

const updated = await syncDatamateUrlFromVscodeMcp(tmp.path)
expect(updated).toContain(DATAMATE_KEY)

const after = JSON.parse(await readFile(configPath, "utf-8"))
const entry = after.mcp[DATAMATE_KEY]
expect(entry.type).toBe("local")
expect(entry.command).toEqual(["/path/to/electron", "/ext/dist/datamate-cli.js", "start-stdio"])
expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" })
expect(entry.updatedAt).toBe("T2")
expect(entry.enabled).toBe(true) // non-transport field preserved
})
})
Loading