REC-099: `postBridgeJson` (tools/bifrost-bridge.mjs) and `postDispatchRunJson` (tools/dispatch-agent-requests.mjs) each occur exactly once in their file: their own definition, with no caller. Each was the sole caller of that file's private `ensureTrailingSlash` copy, which becomes dead in the same cut. The `ensureTrailingSlash` copies in agent-transport.mjs and governance-threads.mjs are separate, still-called definitions and were left untouched. Kill attempts that failed: none. - `rg -n "postBridgeJson" tools/*.mjs` -> only its own (now removed) definition. - `rg -n "postDispatchRunJson" tools/*.mjs` -> only its own (now removed) definition. - `rg -n "ensureTrailingSlash" tools/bifrost-bridge.mjs` and the same against tools/dispatch-agent-requests.mjs -> confirmed each file's copy had exactly one caller (the function just removed) before cutting it. Checks run: `node --check tools/bifrost-bridge.mjs` and `node --check tools/dispatch-agent-requests.mjs` -> both syntax OK. No package.json/test suite exists for these standalone tool scripts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1023 lines
31 KiB
JavaScript
1023 lines
31 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const bifrostRoot = resolve(scriptDir, "..");
|
|
const transportCli = resolve(bifrostRoot, "tools", "agent-transport.mjs");
|
|
const bridgeCli = resolve(bifrostRoot, "tools", "bifrost-bridge.mjs");
|
|
const defaultStatusDir = resolve(bifrostRoot, ".bifrost", "agent-dispatch");
|
|
const defaultProjectsRoot = resolve(bifrostRoot, "..");
|
|
const defaultAquariumChannelId = "1501196543150264332";
|
|
const defaultPersonaName = "Bifrost";
|
|
const defaultPersonaAvatarUrl =
|
|
"https://raw.githubusercontent.com/GameCult/Bifrost/main/src/Bifrost.Web/wwwroot/img/bifrost-profile.png";
|
|
|
|
async function main() {
|
|
loadBifrostLocalEnv();
|
|
const [command, ...rawArgs] = process.argv.slice(2);
|
|
const options = parseArgs(rawArgs);
|
|
|
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
if (command === "dispatch" || command === "run-claimed") {
|
|
ensureDispatchReceiptGate(options);
|
|
}
|
|
|
|
switch (command) {
|
|
case "dispatch":
|
|
await dispatchQueuedRequests(options);
|
|
return;
|
|
case "run-claimed":
|
|
await runClaimedRequest(options);
|
|
return;
|
|
default:
|
|
throw new Error(`Unknown command "${command}". Run "node tools/dispatch-agent-requests.mjs help".`);
|
|
}
|
|
}
|
|
|
|
function loadBifrostLocalEnv() {
|
|
if (process.env.BIFROST_SKIP_LOCAL_ENV === "true") {
|
|
return;
|
|
}
|
|
|
|
loadLocalEnv(resolve(bifrostRoot, ".env"));
|
|
loadLocalEnv(resolve(defaultProjectsRoot, "VoidBot", ".env"));
|
|
}
|
|
|
|
async function dispatchQueuedRequests(options) {
|
|
const max = parseInteger(options.max ?? "1", "max");
|
|
const statusDir = resolveOptionPath(options["status-dir"] ?? defaultStatusDir);
|
|
await mkdir(statusDir, { recursive: true });
|
|
|
|
const dispatched = [];
|
|
for (let index = 0; index < max; index += 1) {
|
|
const request = claimNextRequest(options);
|
|
if (!request) {
|
|
break;
|
|
}
|
|
|
|
const repoRoot = resolveRepoRoot(request, options);
|
|
const runDir = resolve(statusDir, request.id);
|
|
mkdirSync(runDir, { recursive: true });
|
|
const requestPath = resolve(runDir, "request.json");
|
|
const promptPath = resolve(runDir, "prompt.md");
|
|
const logPath = resolve(runDir, "codex.log");
|
|
writeFileSync(requestPath, `${JSON.stringify(request, null, 2)}\n`, "utf8");
|
|
writeFileSync(promptPath, buildCodexPrompt(request, repoRoot), "utf8");
|
|
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
fileURLToPath(import.meta.url),
|
|
"run-claimed",
|
|
"--request-file", requestPath,
|
|
"--repo-root", repoRoot,
|
|
"--prompt-file", promptPath,
|
|
"--log", logPath,
|
|
...optionalArg("--codex-executable", options["codex-executable"]),
|
|
...optionalArg("--codex-exec-args", options["codex-exec-args"]),
|
|
...optionalArg("--model", options.model),
|
|
...optionalArg("--reasoning-effort", options["reasoning-effort"]),
|
|
...optionalArg("--sandbox", options.sandbox),
|
|
...optionalArg("--launch-mode", options["launch-mode"]),
|
|
...optionalArg("--channel-id", options["channel-id"]),
|
|
...optionalArg("--persona-name", options["persona-name"]),
|
|
...optionalArg("--persona-avatar-url", options["persona-avatar-url"]),
|
|
...(options["no-discord"] === "true" ? ["--no-discord", "true"] : []),
|
|
],
|
|
{
|
|
cwd: bifrostRoot,
|
|
detached: true,
|
|
stdio: "ignore",
|
|
windowsHide: true,
|
|
},
|
|
);
|
|
child.unref();
|
|
|
|
const dispatchRecord = {
|
|
requestId: request.id,
|
|
title: request.title,
|
|
repoRoot,
|
|
promptPath,
|
|
logPath,
|
|
pid: child.pid,
|
|
dispatchedAt: new Date().toISOString(),
|
|
};
|
|
writeFileSync(resolve(runDir, "dispatch.json"), `${JSON.stringify(dispatchRecord, null, 2)}\n`, "utf8");
|
|
|
|
dispatched.push(dispatchRecord);
|
|
}
|
|
|
|
printJson({
|
|
ok: true,
|
|
dispatchedCount: dispatched.length,
|
|
dispatched,
|
|
});
|
|
}
|
|
|
|
function ensureDispatchReceiptGate(options) {
|
|
if (wantsUnreceiptedActivity(options)) {
|
|
throw new Error(
|
|
"Bifrost dispatch cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
|
|
"Use the typed Bifrost CultCache/CultMesh dispatch store instead of launching off-book work.",
|
|
);
|
|
}
|
|
|
|
if (hasRemovedHttpBridgeConfig()) {
|
|
throw new Error(
|
|
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " +
|
|
"Bifrost dispatch receipts are written to the typed CultCache/CultMesh request/run artifacts.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function hasRemovedHttpBridgeConfig() {
|
|
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN));
|
|
}
|
|
|
|
function wantsUnreceiptedActivity(options) {
|
|
return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
|
|
}
|
|
|
|
async function runClaimedRequest(options) {
|
|
const requestPath = resolveOptionPath(requireOption(options, "request-file"));
|
|
const repoRoot = resolveOptionPath(requireOption(options, "repo-root"));
|
|
const promptPath = resolveOptionPath(requireOption(options, "prompt-file"));
|
|
const logPath = resolveOptionPath(requireOption(options, "log"));
|
|
const request = JSON.parse(await readFile(requestPath, "utf8"));
|
|
const launchMode = options["launch-mode"] ?? process.env.BIFROST_CODEX_LAUNCH_MODE ?? "app-server";
|
|
|
|
if (launchMode === "codex-exec") {
|
|
await runClaimedViaCodexExec(request, repoRoot, promptPath, logPath, options);
|
|
return;
|
|
}
|
|
|
|
if (launchMode !== "app-server") {
|
|
throw new Error(`Unsupported --launch-mode "${launchMode}". Use app-server or codex-exec.`);
|
|
}
|
|
|
|
await runClaimedViaAppServer(request, repoRoot, promptPath, logPath, options);
|
|
}
|
|
|
|
async function runClaimedViaCodexExec(request, repoRoot, promptPath, logPath, options) {
|
|
const codexExecutable = options["codex-executable"] ?? process.env.CODEX_EXECUTABLE ?? "codex";
|
|
const codexExecArgs = splitCommandArgs(options["codex-exec-args"] ?? process.env.CODEX_EXEC_ARGS ?? "");
|
|
const model = options.model ?? process.env.CODEX_MODEL ?? "gpt-5.4";
|
|
const reasoningEffort = options["reasoning-effort"] ?? process.env.CODEX_MODEL_REASONING_EFFORT ?? "medium";
|
|
const sandbox = options.sandbox ?? "workspace-write";
|
|
const startedAt = new Date().toISOString();
|
|
const resultPath = resolve(dirname(logPath), "result.json");
|
|
await mkdir(dirname(logPath), { recursive: true });
|
|
const bridgeContextEnv = buildBridgeContextEnv(request, dirname(logPath));
|
|
appendLog(logPath, `started ${startedAt}`);
|
|
appendLog(logPath, `request ${request.id}: ${request.title}`);
|
|
appendLog(logPath, `repoRoot ${repoRoot}`);
|
|
let dispatchRun = null;
|
|
|
|
try {
|
|
dispatchRun = await beginDispatchRun(request, {
|
|
launchMode: "codex-exec",
|
|
workerProcessId: process.pid,
|
|
threadId: "",
|
|
turnId: "",
|
|
logPath,
|
|
resultPath,
|
|
note: "Codex dispatch process started.",
|
|
});
|
|
|
|
const prompt = await readFile(promptPath, "utf8");
|
|
const result = spawnSync(
|
|
codexExecutable,
|
|
[
|
|
...codexExecArgs,
|
|
"exec",
|
|
"-m", model,
|
|
"-c", 'approval_policy="never"',
|
|
"-c", `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`,
|
|
"--skip-git-repo-check",
|
|
"-s", sandbox,
|
|
"-",
|
|
],
|
|
{
|
|
cwd: repoRoot,
|
|
input: prompt,
|
|
encoding: "utf8",
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env: {
|
|
...process.env,
|
|
...bridgeContextEnv,
|
|
},
|
|
windowsHide: true,
|
|
},
|
|
);
|
|
|
|
appendLog(logPath, result.stdout ?? "");
|
|
appendLog(logPath, result.stderr ?? "");
|
|
const launchError = result.error ? summarizeError(result.error) : "";
|
|
const ok = result.status === 0 && !launchError;
|
|
const closeStatus = ok ? "completed" : "cancelled";
|
|
const note = ok
|
|
? `Codex dispatch completed from Bifrost. Log: ${logPath}`
|
|
: launchError
|
|
? `Codex dispatch failed before completion: ${launchError}. Log: ${logPath}`
|
|
: `Codex dispatch failed with exit ${result.status ?? "unknown"}. Log: ${logPath}`;
|
|
const close = runNodeJson([
|
|
transportCli,
|
|
"close",
|
|
"--id", request.id,
|
|
"--status", closeStatus,
|
|
"--note", note,
|
|
], bifrostRoot);
|
|
|
|
if (launchError) {
|
|
await dispatchRun?.fail({
|
|
threadId: "",
|
|
turnId: "",
|
|
resultPath,
|
|
note,
|
|
error: launchError,
|
|
});
|
|
} else {
|
|
await dispatchRun?.complete({
|
|
status: ok ? "Completed" : "Cancelled",
|
|
threadId: "",
|
|
turnId: "",
|
|
resultPath,
|
|
note,
|
|
});
|
|
}
|
|
|
|
await writeDispatchResult(logPath, {
|
|
requestId: request.id,
|
|
ok,
|
|
exitCode: result.status,
|
|
error: launchError,
|
|
launchMode: "codex-exec",
|
|
startedAt,
|
|
finishedAt: new Date().toISOString(),
|
|
close,
|
|
});
|
|
} catch (error) {
|
|
const message = summarizeError(error);
|
|
const note = `Codex dispatch failed before completion: ${message}. Log: ${logPath}`;
|
|
const close = runNodeJson([
|
|
transportCli,
|
|
"close",
|
|
"--id", request.id,
|
|
"--status", "cancelled",
|
|
"--note", note,
|
|
], bifrostRoot);
|
|
await dispatchRun?.fail({
|
|
threadId: "",
|
|
turnId: "",
|
|
resultPath,
|
|
note,
|
|
error: message,
|
|
});
|
|
await writeDispatchResult(logPath, {
|
|
requestId: request.id,
|
|
ok: false,
|
|
error: message,
|
|
launchMode: "codex-exec",
|
|
startedAt,
|
|
finishedAt: new Date().toISOString(),
|
|
close,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, options) {
|
|
const model = options.model ?? process.env.CODEX_MODEL ?? "gpt-5.4";
|
|
const reasoningEffort = options["reasoning-effort"] ?? process.env.CODEX_MODEL_REASONING_EFFORT ?? "medium";
|
|
const sandbox = options.sandbox ?? "workspace-write";
|
|
const startedAt = new Date().toISOString();
|
|
const prompt = await readFile(promptPath, "utf8");
|
|
const resultPath = resolve(dirname(logPath), "result.json");
|
|
await mkdir(dirname(logPath), { recursive: true });
|
|
const client = new CodexAppServerClient({
|
|
logPath,
|
|
command: resolveCodexCommand(options),
|
|
env: buildBridgeContextEnv(request, dirname(logPath)),
|
|
});
|
|
|
|
appendLog(logPath, `started ${startedAt}`);
|
|
appendLog(logPath, `request ${request.id}: ${request.title}`);
|
|
appendLog(logPath, `repoRoot ${repoRoot}`);
|
|
appendLog(logPath, "launchMode app-server");
|
|
|
|
let threadId;
|
|
let turnId;
|
|
const dispatchRun = await beginDispatchRun(request, {
|
|
launchMode: "app-server",
|
|
workerProcessId: process.pid,
|
|
threadId: "",
|
|
turnId: "",
|
|
logPath,
|
|
resultPath,
|
|
note: "Codex app-server launch started.",
|
|
});
|
|
try {
|
|
await client.start();
|
|
await client.request("initialize", {
|
|
clientInfo: {
|
|
name: "bifrost-dispatcher",
|
|
title: "Bifrost Dispatcher",
|
|
version: "0.1.0",
|
|
},
|
|
capabilities: {
|
|
experimentalApi: true,
|
|
},
|
|
});
|
|
|
|
const threadStart = await client.request("thread/start", {
|
|
cwd: repoRoot,
|
|
approvalPolicy: "never",
|
|
approvalsReviewer: "user",
|
|
sandbox,
|
|
model,
|
|
personality: "pragmatic",
|
|
ephemeral: false,
|
|
sessionStartSource: "startup",
|
|
experimentalRawEvents: false,
|
|
persistExtendedHistory: true,
|
|
});
|
|
threadId = threadStart.thread?.id;
|
|
if (!threadId) {
|
|
throw new Error("Codex app-server thread/start returned no thread id.");
|
|
}
|
|
|
|
const turnStart = await client.request("turn/start", {
|
|
threadId,
|
|
cwd: repoRoot,
|
|
approvalPolicy: "never",
|
|
approvalsReviewer: "user",
|
|
sandboxPolicy: sandboxPolicyFromMode(sandbox),
|
|
model,
|
|
effort: reasoningEffort,
|
|
personality: "pragmatic",
|
|
input: [
|
|
{
|
|
type: "text",
|
|
text: prompt,
|
|
text_elements: [],
|
|
},
|
|
],
|
|
});
|
|
turnId = turnStart.turn?.id;
|
|
if (!turnId) {
|
|
throw new Error("Codex app-server turn/start returned no turn id.");
|
|
}
|
|
|
|
await writeFile(
|
|
resolve(dirname(logPath), "app-server-dispatch.json"),
|
|
`${JSON.stringify({
|
|
requestId: request.id,
|
|
launchMode: "app-server",
|
|
threadId,
|
|
turnId,
|
|
startedAt,
|
|
}, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
|
|
if (options["no-discord"] !== "true") {
|
|
postDispatchReceipt(request, { threadId, turnId, startedAt }, options);
|
|
}
|
|
|
|
const completed = await client.waitForTurnCompleted(threadId, turnId);
|
|
const ok = completed.turn?.status === "completed";
|
|
const closeStatus = ok ? "completed" : "cancelled";
|
|
const note = ok
|
|
? `Codex app turn completed for ${threadId}/${turnId}.`
|
|
: `Codex app turn ended as ${completed.turn?.status ?? "unknown"} for ${threadId}/${turnId}.`;
|
|
const close = runNodeJson([
|
|
transportCli,
|
|
"close",
|
|
"--id", request.id,
|
|
"--status", closeStatus,
|
|
"--note", note,
|
|
], bifrostRoot);
|
|
await dispatchRun?.complete({
|
|
status: ok ? "Completed" : "Cancelled",
|
|
threadId,
|
|
turnId,
|
|
resultPath,
|
|
note,
|
|
});
|
|
await writeDispatchResult(logPath, {
|
|
requestId: request.id,
|
|
ok,
|
|
launchMode: "app-server",
|
|
threadId,
|
|
turnId,
|
|
startedAt,
|
|
finishedAt: new Date().toISOString(),
|
|
close,
|
|
});
|
|
} catch (error) {
|
|
const message = summarizeError(error);
|
|
appendLog(logPath, message);
|
|
if (!threadId || !turnId) {
|
|
releaseClaimedRequest(request.id, `Visible Codex app turn did not start: ${message}`);
|
|
} else {
|
|
runNodeJson([
|
|
transportCli,
|
|
"close",
|
|
"--id", request.id,
|
|
"--status", "cancelled",
|
|
"--note", `Codex app dispatch failed after turn start for ${threadId}/${turnId}.`,
|
|
], bifrostRoot);
|
|
}
|
|
await dispatchRun?.fail({
|
|
threadId: threadId ?? "",
|
|
turnId: turnId ?? "",
|
|
resultPath,
|
|
note: "Codex dispatch failed.",
|
|
error: message,
|
|
});
|
|
await writeDispatchResult(logPath, {
|
|
requestId: request.id,
|
|
ok: false,
|
|
launchMode: "app-server",
|
|
threadId,
|
|
turnId,
|
|
startedAt,
|
|
finishedAt: new Date().toISOString(),
|
|
error: message,
|
|
});
|
|
process.exitCode = 1;
|
|
} finally {
|
|
client.stop();
|
|
}
|
|
}
|
|
|
|
function claimNextRequest(options) {
|
|
const repo = optionalString(options.repo);
|
|
const agent = optionalString(options.agent);
|
|
if (!repo || repo === "*") {
|
|
const queued = runNodeJson([
|
|
transportCli,
|
|
"list",
|
|
"--status", "queued",
|
|
...optionalArg("--store", options.store),
|
|
], bifrostRoot);
|
|
const next = queued[0];
|
|
if (!next) {
|
|
return null;
|
|
}
|
|
return runNodeJson([
|
|
transportCli,
|
|
"claim",
|
|
"--repo", next.targetRepoName,
|
|
...optionalArg("--agent", agent ?? next.targetAgentIdentity),
|
|
"--claimed-by", options["claimed-by"] ?? "bifrost-dispatcher",
|
|
...optionalArg("--store", options.store),
|
|
], bifrostRoot);
|
|
}
|
|
|
|
const args = [
|
|
transportCli,
|
|
"claim",
|
|
"--repo", repo,
|
|
...optionalArg("--agent", agent),
|
|
"--claimed-by", options["claimed-by"] ?? "bifrost-dispatcher",
|
|
...optionalArg("--store", options.store),
|
|
];
|
|
return runNodeJson(args, bifrostRoot);
|
|
}
|
|
|
|
function resolveRepoRoot(request, options) {
|
|
if (options["repo-root"]) {
|
|
return resolveOptionPath(options["repo-root"]);
|
|
}
|
|
const projectsRoot = resolveOptionPath(options["projects-root"] ?? defaultProjectsRoot);
|
|
return resolve(projectsRoot, request.targetRepoName);
|
|
}
|
|
|
|
function buildCodexPrompt(request, repoRoot) {
|
|
return `You are ${request.targetAgentIdentity ?? "Codex"}, receiving a Bifrost-dispatched update request.
|
|
|
|
Target repo: ${request.targetRepoName}
|
|
Target workspace: ${repoRoot}
|
|
Bifrost request id: ${request.id}
|
|
Priority: ${request.priority}
|
|
|
|
Work the request in this workspace. Prefer a small coherent change, create or update tests/docs when appropriate, and leave the repo in a reviewable state. If this is only an analysis/proposal request, produce the artifact requested. Bifrost will close the transport request after this Codex turn exits.
|
|
|
|
Transport policy: do not push to GitHub, open a pull request, or publish external changes unless this specific request explicitly asks for that. A Bifrost-dispatched turn may prepare local changes and report the commit/PR command it would run, but the bridge owns publication policy.
|
|
If this request explicitly authorizes a governed crossing and you use \`tools/bifrost-bridge.mjs\`, the dispatch runtime already provides bridge provenance for request \`${request.id}\`. Preserve it.
|
|
|
|
${request.requestMarkdown}
|
|
`;
|
|
}
|
|
|
|
function postDispatchReceipt(request, dispatchRecord, options) {
|
|
const channelId = resolveDispatchReceiptChannelId(options);
|
|
const personaName = options["persona-name"] ?? process.env.BIFROST_DISCORD_PERSONA_NAME ?? defaultPersonaName;
|
|
const personaAvatarUrl =
|
|
optionalString(options["persona-avatar-url"]) ??
|
|
optionalString(process.env.BIFROST_DISCORD_PERSONA_AVATAR_URL) ??
|
|
optionalString(process.env.DISCORD_PERSONA_AVATAR_URL_BIFROST) ??
|
|
defaultPersonaAvatarUrl;
|
|
const content = renderDispatchReceiptContent(request);
|
|
|
|
runNodeJson([
|
|
bridgeCli,
|
|
"discord-post",
|
|
"--channel-id", channelId,
|
|
"--persona-name", personaName,
|
|
"--content", content,
|
|
"--source-kind", "bifrost_agent_transport_request",
|
|
"--source-id", request.id,
|
|
"--authority-ref", "bifrost_dispatch_started_receipt",
|
|
...optionalArg("--target-repository-full-name", request.targetRepositoryFullName),
|
|
...optionalArg("--persona-avatar-url", personaAvatarUrl),
|
|
...bridgeRecoveryArgs(options),
|
|
], bifrostRoot);
|
|
}
|
|
|
|
function resolveDispatchReceiptChannelId(options) {
|
|
return (
|
|
optionalString(options["channel-id"]) ??
|
|
optionalString(process.env.BIFROST_DISCORD_CHANNEL_ID) ??
|
|
optionalString(process.env.DISCORD_BIFROST_CHANNEL_ID) ??
|
|
defaultAquariumChannelId
|
|
);
|
|
}
|
|
|
|
function bridgeRecoveryArgs(options) {
|
|
return [];
|
|
}
|
|
|
|
function renderDispatchReceiptContent(request) {
|
|
const topic = summarizeRequestTopic(request);
|
|
const actor = request.targetAgentIdentity
|
|
? `${request.targetAgentIdentity} / ${request.targetRepoName}`
|
|
: request.targetRepoName;
|
|
return [
|
|
`Bifrost Codex dispatch started`,
|
|
``,
|
|
`**${request.targetRepoName}: ${topic}**`,
|
|
`Request: \`${request.id}\``,
|
|
`Target: ${actor}`,
|
|
`Status: claimed by dispatcher`,
|
|
`Codex job: started`,
|
|
``,
|
|
`Codex has started a turn. The claim is now specific enough to inspect, and the work is no longer sitting unclaimed in chat.`,
|
|
`Keep discussion here if it sharpens this request; Bifrost will carry the result back across the bridge.`,
|
|
].join("\n");
|
|
}
|
|
|
|
function buildBridgeContextEnv(request, runtimeDir) {
|
|
const hooksPath = resolve(bifrostRoot, "tools", "git-hooks");
|
|
const gitGatePath = resolve(bifrostRoot, "tools", "git-gate");
|
|
const githubConfigDir = resolve(runtimeDir, "github-gh-config");
|
|
const gitGlobalConfigPath = resolve(runtimeDir, "github-gitconfig");
|
|
mkdirSync(githubConfigDir, { recursive: true });
|
|
if (!existsSync(gitGlobalConfigPath)) {
|
|
writeFileSync(gitGlobalConfigPath, "", "utf8");
|
|
}
|
|
return {
|
|
BIFROST_BRIDGE_SOURCE_KIND: "bifrost_agent_transport_request",
|
|
BIFROST_BRIDGE_SOURCE_ID: request.id,
|
|
BIFROST_BRIDGE_AUTHORITY_REF: "bifrost_dispatch_execution",
|
|
BIFROST_ENFORCE_GITHUB_GATE: "true",
|
|
BIFROST_LOCK_RECOVERY_HATCHES: "true",
|
|
BIFROST_GIT_EXECUTABLE: resolve(gitGatePath, "git.cmd"),
|
|
BIFROST_GH_EXECUTABLE: resolve(gitGatePath, "gh.cmd"),
|
|
BIFROST_NODE_EXECUTABLE: process.execPath,
|
|
...buildGitHooksConfigEnv(hooksPath),
|
|
...buildPathPrependEnv(gitGatePath),
|
|
GH_CONFIG_DIR: githubConfigDir,
|
|
GIT_CONFIG_GLOBAL: gitGlobalConfigPath,
|
|
GH_TOKEN: "",
|
|
GITHUB_TOKEN: "",
|
|
GIT_ASKPASS: "",
|
|
SSH_ASKPASS: "",
|
|
SSH_AUTH_SOCK: "",
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
GCM_INTERACTIVE: "never",
|
|
};
|
|
}
|
|
|
|
function buildGitHooksConfigEnv(hooksPath) {
|
|
const entries = [
|
|
["core.hooksPath", hooksPath],
|
|
["credential.helper", ""],
|
|
["credential.interactive", "never"],
|
|
];
|
|
const existingCount = Number.parseInt(process.env.GIT_CONFIG_COUNT ?? "", 10);
|
|
const startIndex = Number.isInteger(existingCount) && existingCount >= 0 ? existingCount : 0;
|
|
const env = {
|
|
GIT_CONFIG_COUNT: String(startIndex + entries.length),
|
|
};
|
|
|
|
entries.forEach(([key, value], index) => {
|
|
const slot = startIndex + index;
|
|
env[`GIT_CONFIG_KEY_${slot}`] = key;
|
|
env[`GIT_CONFIG_VALUE_${slot}`] = value;
|
|
});
|
|
|
|
return env;
|
|
}
|
|
|
|
function buildPathPrependEnv(directory) {
|
|
const currentPath = process.env.Path ?? process.env.PATH ?? "";
|
|
const nextPath = currentPath ? `${directory};${currentPath}` : directory;
|
|
return {
|
|
PATH: nextPath,
|
|
Path: nextPath,
|
|
};
|
|
}
|
|
|
|
class CodexAppServerClient {
|
|
constructor({ command, env, logPath }) {
|
|
this.command = command;
|
|
this.env = env;
|
|
this.logPath = logPath;
|
|
this.child = undefined;
|
|
this.nextId = 1;
|
|
this.buffer = "";
|
|
this.pending = new Map();
|
|
this.turnWaiters = [];
|
|
this.completedTurns = new Map();
|
|
}
|
|
|
|
async start() {
|
|
this.child = spawn(this.command.exe, [...this.command.args, "app-server", "--listen", "stdio://"], {
|
|
cwd: bifrostRoot,
|
|
env: {
|
|
...process.env,
|
|
...this.env,
|
|
},
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
|
|
this.child.stdout.on("data", (chunk) => this.readStdout(chunk.toString()));
|
|
this.child.stderr.on("data", (chunk) => appendLog(this.logPath, chunk.toString()));
|
|
this.child.on("exit", (code, signal) => {
|
|
const error = new Error(`codex app-server exited with ${code ?? signal ?? "unknown"}`);
|
|
for (const waiter of this.pending.values()) {
|
|
waiter.reject(error);
|
|
}
|
|
this.pending.clear();
|
|
for (const waiter of this.turnWaiters) {
|
|
waiter.reject(error);
|
|
}
|
|
this.turnWaiters = [];
|
|
this.completedTurns.clear();
|
|
});
|
|
this.child.on("error", (error) => {
|
|
for (const waiter of this.pending.values()) {
|
|
waiter.reject(error);
|
|
}
|
|
this.pending.clear();
|
|
for (const waiter of this.turnWaiters) {
|
|
waiter.reject(error);
|
|
}
|
|
this.turnWaiters = [];
|
|
this.completedTurns.clear();
|
|
});
|
|
}
|
|
|
|
request(method, params) {
|
|
if (!this.child?.stdin.writable) {
|
|
return Promise.reject(new Error("codex app-server stdin is not writable."));
|
|
}
|
|
|
|
const id = this.nextId;
|
|
this.nextId += 1;
|
|
appendLog(this.logPath, `client-request ${method}#${id}`);
|
|
return new Promise((resolvePromise, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`Timed out waiting for ${method}#${id}`));
|
|
}, 60000);
|
|
this.pending.set(id, {
|
|
resolve: (value) => {
|
|
clearTimeout(timer);
|
|
resolvePromise(value);
|
|
},
|
|
reject: (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
},
|
|
});
|
|
this.child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
|
|
});
|
|
}
|
|
|
|
waitForTurnCompleted(threadId, turnId) {
|
|
const key = `${threadId}:${turnId}`;
|
|
const completed = this.completedTurns.get(key);
|
|
if (completed) {
|
|
this.completedTurns.delete(key);
|
|
return Promise.resolve(completed);
|
|
}
|
|
return new Promise((resolvePromise, reject) => {
|
|
this.turnWaiters.push({ threadId, turnId, resolve: resolvePromise, reject });
|
|
});
|
|
}
|
|
|
|
readStdout(text) {
|
|
this.buffer += text;
|
|
while (true) {
|
|
const newline = this.buffer.indexOf("\n");
|
|
if (newline === -1) {
|
|
return;
|
|
}
|
|
const line = this.buffer.slice(0, newline).trim();
|
|
this.buffer = this.buffer.slice(newline + 1);
|
|
if (line) {
|
|
this.handleMessage(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
handleMessage(line) {
|
|
let message;
|
|
try {
|
|
message = JSON.parse(line);
|
|
} catch {
|
|
appendLog(this.logPath, `app-server non-json stdout: ${line}`);
|
|
return;
|
|
}
|
|
|
|
if (Object.hasOwn(message, "id") && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error"))) {
|
|
const waiter = this.pending.get(message.id);
|
|
if (!waiter) {
|
|
appendLog(this.logPath, `app-server response for unknown id ${message.id}`);
|
|
return;
|
|
}
|
|
this.pending.delete(message.id);
|
|
if (message.error) {
|
|
waiter.reject(new Error(JSON.stringify(message.error)));
|
|
} else {
|
|
waiter.resolve(message.result);
|
|
}
|
|
return;
|
|
}
|
|
|
|
appendLog(this.logPath, `app-server ${message.method ?? "message"}`);
|
|
if (message.method === "turn/completed") {
|
|
const params = message.params ?? {};
|
|
const completedTurnId = params.turn?.id;
|
|
const key = `${params.threadId ?? ""}:${completedTurnId ?? ""}`;
|
|
const remaining = [];
|
|
let resolved = false;
|
|
for (const waiter of this.turnWaiters) {
|
|
if (waiter.threadId === params.threadId && waiter.turnId === completedTurnId) {
|
|
waiter.resolve(params);
|
|
resolved = true;
|
|
} else {
|
|
remaining.push(waiter);
|
|
}
|
|
}
|
|
this.turnWaiters = remaining;
|
|
if (!resolved && params.threadId && completedTurnId) {
|
|
this.completedTurns.set(key, params);
|
|
}
|
|
}
|
|
}
|
|
|
|
stop() {
|
|
this.child?.kill();
|
|
}
|
|
}
|
|
|
|
function resolveCodexCommand(options) {
|
|
const executable = options["codex-executable"] ?? process.env.CODEX_EXECUTABLE;
|
|
const extraArgs = splitCommandArgs(options["codex-exec-args"] ?? process.env.CODEX_EXEC_ARGS ?? "");
|
|
if (executable) {
|
|
return { exe: executable, args: extraArgs };
|
|
}
|
|
|
|
const codexJs = resolve(process.env.APPDATA ?? "", "npm", "node_modules", "@openai", "codex", "bin", "codex.js");
|
|
if (existsSync(codexJs)) {
|
|
return { exe: process.execPath, args: [codexJs] };
|
|
}
|
|
|
|
return { exe: "codex", args: [] };
|
|
}
|
|
|
|
function sandboxPolicyFromMode(mode) {
|
|
if (mode === "danger-full-access") {
|
|
return { type: "dangerFullAccess" };
|
|
}
|
|
if (mode === "read-only") {
|
|
return { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false };
|
|
}
|
|
return {
|
|
type: "workspaceWrite",
|
|
writableRoots: [],
|
|
readOnlyAccess: { type: "fullAccess" },
|
|
networkAccess: false,
|
|
excludeTmpdirEnvVar: false,
|
|
excludeSlashTmp: false,
|
|
};
|
|
}
|
|
|
|
function releaseClaimedRequest(id, note) {
|
|
runNodeJson([
|
|
transportCli,
|
|
"release",
|
|
"--id", id,
|
|
"--note", note,
|
|
], bifrostRoot);
|
|
}
|
|
|
|
async function writeDispatchResult(logPath, result) {
|
|
await writeFile(
|
|
resolve(dirname(logPath), "result.json"),
|
|
`${JSON.stringify(result, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
async function beginDispatchRun(request, input) {
|
|
void request;
|
|
void input;
|
|
return null;
|
|
}
|
|
|
|
function summarizeError(error) {
|
|
if (error instanceof Error) {
|
|
return error.stack ?? error.message;
|
|
}
|
|
|
|
return String(error);
|
|
}
|
|
|
|
function summarizeRequestTopic(request) {
|
|
const title = String(request.title ?? "").trim();
|
|
if (title && !/^Recent\b/i.test(title)) {
|
|
return title;
|
|
}
|
|
|
|
const text = String(request.requestMarkdown ?? "");
|
|
const section = text.match(/## What Needs To Be Done\s+([\s\S]*?)(?:\n## |\n```|$)/i)?.[1]?.trim();
|
|
const firstParagraph = section
|
|
?.split(/\n\s*\n/)
|
|
.map((entry) => entry.replace(/\s+/g, " ").trim())
|
|
.find(Boolean);
|
|
if (firstParagraph) {
|
|
return summarizeAction(firstParagraph);
|
|
}
|
|
|
|
const heading = text.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
return truncate(heading || "routed update request", 96);
|
|
}
|
|
|
|
function truncate(value, maxLength) {
|
|
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
|
}
|
|
|
|
function summarizeAction(value) {
|
|
const normalized = value.replace(/\s+/g, " ").trim();
|
|
const match =
|
|
normalized.match(/^Implement the ([^:]+?) as /i) ??
|
|
normalized.match(/^Update ([^:]+?) from /i) ??
|
|
normalized.match(/^Add (.+?)(?:\.|$)/i);
|
|
return truncate(stripTrailingRepoName(match?.[1] ?? normalized), 96);
|
|
}
|
|
|
|
function stripTrailingRepoName(value) {
|
|
return value.replace(/\s+for\s+[A-Z][A-Za-z0-9._-]+$/g, "");
|
|
}
|
|
|
|
function runNodeJson(args, cwd) {
|
|
const result = spawnSync(process.execPath, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
timeout: 30000,
|
|
});
|
|
if (result.status !== 0) {
|
|
const reason = result.error?.message ?? result.stderr ?? result.stdout;
|
|
throw new Error(`node ${args.join(" ")} failed with ${result.status ?? "unknown"}:\n${reason}`);
|
|
}
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
function appendLog(path, text) {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, `${text ?? ""}\n`, { encoding: "utf8", flag: "a" });
|
|
}
|
|
|
|
function parseArgs(args) {
|
|
const options = {};
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const token = args[index];
|
|
if (!token.startsWith("--")) {
|
|
throw new Error(`Unexpected argument "${token}".`);
|
|
}
|
|
const key = token.slice(2);
|
|
const next = args[index + 1];
|
|
if (!next || next.startsWith("--")) {
|
|
options[key] = "true";
|
|
continue;
|
|
}
|
|
options[key] = next;
|
|
index += 1;
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function requireOption(options, name) {
|
|
const value = optionalString(options[name]);
|
|
if (!value) {
|
|
throw new Error(`Missing required option --${name}.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optionalArg(flag, value) {
|
|
const normalized = optionalString(value);
|
|
return normalized ? [flag, normalized] : [];
|
|
}
|
|
|
|
function optionalString(value) {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
}
|
|
|
|
function parseInteger(value, name) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
throw new Error(`--${name} must be a positive integer.`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function splitCommandArgs(value) {
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
return [];
|
|
}
|
|
return value
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function resolveOptionPath(path) {
|
|
return resolve(process.cwd(), path);
|
|
}
|
|
|
|
function printJson(value) {
|
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function loadLocalEnv(path) {
|
|
if (!existsSync(path)) {
|
|
return;
|
|
}
|
|
|
|
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) {
|
|
continue;
|
|
}
|
|
const separator = trimmed.indexOf("=");
|
|
if (separator === -1) {
|
|
continue;
|
|
}
|
|
const key = trimmed.slice(0, separator).trim();
|
|
if (process.env[key]) {
|
|
continue;
|
|
}
|
|
let value = trimmed.slice(separator + 1).trim();
|
|
if (
|
|
value.length >= 2 &&
|
|
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
function printHelp() {
|
|
process.stdout.write(`Bifrost agent request dispatcher
|
|
|
|
Commands:
|
|
dispatch Claim queued Bifrost requests and launch detached Codex turns
|
|
run-claimed Internal worker for one claimed request
|
|
|
|
Example:
|
|
node tools/dispatch-agent-requests.mjs dispatch --repo AquaSynth --agent aqua --max 1
|
|
`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|