Move project and host queries into native MCP with host-owned OAuth/context, while retaining memory, skills, actions and publication plugins. Add nearby-model queries and explicit chat/task/learning model defaults. Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped session ownership, pending input edits and investigation communication. Disable local-provider discovery: Server /health allowed native vLLM to probe /v1/models on port 8000. A native before/after regression prevents recurrence. Validation: 170 focused Bun tests; service/plugin typechecks; native discovery, provider-discovery and input-inbox smoke checks; no paid model inference.
155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdir, readFile } from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import dotenv from "dotenv";
|
|
import { serviceOpencodeConfig } from "../src/runtime/opencode2-config.js";
|
|
import { startOpencode2 } from "../src/runtime/opencode2-server.js";
|
|
|
|
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
dotenv.config({ path: join(projectRoot, ".local.env"), override: false, quiet: true });
|
|
|
|
const agentPort = readPort("INTEGRATION_AGENT_PORT", 18_787);
|
|
const opencodePort = readPort("INTEGRATION_OPENCODE_PORT", 14_096);
|
|
const dataRoot = resolve(
|
|
process.env.INTEGRATION_DATA_DIR ?? join(projectRoot, "data", `integration-${agentPort}`),
|
|
);
|
|
process.env.OPENCODE_SKILLS_ROOT_DIR = join(dataRoot, "skills");
|
|
const bridgeToken = process.env.AGENT_INTERNAL_TOKEN?.trim() || randomBytes(32).toString("hex");
|
|
const agentBaseUrl = `http://127.0.0.1:${agentPort}`;
|
|
const opencodeBaseUrl = `http://127.0.0.1:${opencodePort}`;
|
|
|
|
await assertPortAvailable(agentPort);
|
|
await assertPortAvailable(opencodePort);
|
|
await mkdir(dataRoot, { recursive: true });
|
|
|
|
const sharedEnvironment = {
|
|
...process.env,
|
|
AGENT_INTERNAL_TOKEN: bridgeToken,
|
|
CONVERSATION_WORKSPACE_ROOT: join(dataRoot, "session-workspaces"),
|
|
TJWATER_AGENT_WORKSPACE_ROOT: join(dataRoot, "session-workspaces"),
|
|
TJWATER_AGENT_INTERNAL_BASE_URL: agentBaseUrl,
|
|
TJWATER_AGENT_MCP_CONNECTION_PATH: join(dataRoot, "mcp-query", "connection.json"),
|
|
// Retained for tools loaded by an older compatible OpenCode process.
|
|
TJWATER_AGENT_INTERNAL_TOKEN: bridgeToken,
|
|
};
|
|
|
|
const opencode = await startOpencode2({
|
|
command: process.env.OPENCODE_COMMAND ?? "opencode2",
|
|
hostname: "127.0.0.1",
|
|
port: opencodePort,
|
|
timeout: 30_000,
|
|
config: serviceOpencodeConfig(JSON.parse(await readFile(join(projectRoot, "opencode.json"), "utf8")), undefined, sharedEnvironment.CONVERSATION_WORKSPACE_ROOT),
|
|
env: {
|
|
...sharedEnvironment,
|
|
XDG_CACHE_HOME: join(dataRoot, "xdg-cache"),
|
|
XDG_DATA_HOME: join(dataRoot, "xdg-data"),
|
|
OPENCODE_DB: join(dataRoot, "xdg-data", "opencode", "opencode.db"),
|
|
XDG_STATE_HOME: join(dataRoot, "xdg-state"),
|
|
},
|
|
});
|
|
|
|
let agent: ReturnType<typeof Bun.spawn> | undefined;
|
|
let shuttingDown = false;
|
|
const shutdown = async () => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
agent?.kill("SIGTERM");
|
|
opencode.close();
|
|
await Promise.allSettled([
|
|
...(agent ? [agent.exited] : []),
|
|
opencode.exited,
|
|
]);
|
|
};
|
|
|
|
process.once("SIGINT", () => void shutdown());
|
|
process.once("SIGTERM", () => void shutdown());
|
|
|
|
try {
|
|
await opencode.client.server.info();
|
|
agent = Bun.spawn([process.execPath, "src/server.ts"], {
|
|
cwd: projectRoot,
|
|
env: {
|
|
...sharedEnvironment,
|
|
HOST: "127.0.0.1",
|
|
PORT: String(agentPort),
|
|
OPENCODE_MODE: "client",
|
|
OPENCODE_CLIENT_BASE_URL: opencodeBaseUrl,
|
|
OPENCODE_CLIENT_PASSWORD: opencode.password,
|
|
LLM_REQUEST_AUDIT_LOG_PATH: join(dataRoot, "logs", "llm-request-audit.log"),
|
|
LEARNING_AUDIT_LOG_PATH: join(dataRoot, "logs", "learning-audit.log"),
|
|
MEMORY_STORAGE_DIR: join(dataRoot, "memory"),
|
|
OPENCODE_SKILLS_ROOT_DIR: join(dataRoot, "skills"),
|
|
PERSISTENCE_BACKUP_DIR: join(dataRoot, "backup"),
|
|
SESSION_LEARNING_STATE_STORAGE_DIR: join(dataRoot, "session-learning-state"),
|
|
SESSION_METADATA_STORAGE_DIR: join(dataRoot, "session-metadata"),
|
|
SESSION_TRANSCRIPT_STORAGE_DIR: join(dataRoot, "session-transcripts"),
|
|
SESSION_UI_STATE_STORAGE_DIR: join(dataRoot, "session-ui-states"),
|
|
},
|
|
stderr: "inherit",
|
|
stdout: "inherit",
|
|
});
|
|
await waitUntilHealthy(`${agentBaseUrl}/health`, "Agent", agent);
|
|
console.log(`isolated integration pair ready: Agent ${agentBaseUrl}, OpenCode ${opencodeBaseUrl}`);
|
|
console.log(`isolated runtime data: ${dataRoot}`);
|
|
|
|
const result = await Promise.race([
|
|
agent.exited.then((exitCode) => ({ exitCode, process: "Agent" })),
|
|
opencode.exited.then((exitCode) => ({ exitCode, process: "OpenCode" })),
|
|
]);
|
|
if (!shuttingDown) {
|
|
console.error(`${result.process} exited with code ${result.exitCode}; stopping its paired process`);
|
|
process.exitCode = result.exitCode || 1;
|
|
}
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await shutdown();
|
|
}
|
|
|
|
function readPort(name: string, fallback: number) {
|
|
const raw = process.env[name]?.trim();
|
|
const value = raw ? Number(raw) : fallback;
|
|
if (!Number.isInteger(value) || value < 1 || value > 65_535) {
|
|
throw new Error(`${name} must be a valid TCP port`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertPortAvailable(port: number) {
|
|
return new Promise<void>((resolvePromise, reject) => {
|
|
const server = createServer();
|
|
server.unref();
|
|
server.once("error", (error: NodeJS.ErrnoException) => {
|
|
reject(new Error(`port ${port} is unavailable: ${error.code ?? error.message}`));
|
|
});
|
|
server.listen({ host: "127.0.0.1", port, exclusive: true }, () => {
|
|
server.close((error) => (error ? reject(error) : resolvePromise()));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitUntilHealthy(
|
|
url: string,
|
|
label: string,
|
|
child: ReturnType<typeof Bun.spawn>,
|
|
) {
|
|
const deadline = Date.now() + 30_000;
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) {
|
|
throw new Error(`${label} exited before becoming ready (code ${child.exitCode})`);
|
|
}
|
|
try {
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
|
|
if (response.ok) return;
|
|
} catch {
|
|
// The child is still starting; readiness is decided only by its health endpoint.
|
|
}
|
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 100));
|
|
}
|
|
throw new Error(`${label} did not become healthy within 30 seconds`);
|
|
}
|