codex-flows/scripts/run-code-mode-in-new-thread.ts
matamune 0d3c2e2ab6
All checks were successful
ci / check (push) Successful in 30s
Merge Code Mode flow support into main
2026-05-13 03:17:06 +00:00

634 lines
16 KiB
TypeScript

#!/usr/bin/env bun
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { CodexAppServerClient } from "../packages/codex-client/src/index.ts";
type Args = {
candidate: string;
cwd?: string;
codexCommand?: string;
codexHome?: string;
cliPath: string;
timeoutMs: number;
ephemeral: boolean;
stream: boolean;
injectContext: boolean;
injectResult: boolean;
notes: string[];
threadName?: string | null;
mode: ReplayMode;
};
type ReplayMode = "native" | "shim";
type CandidateMetadata = Record<string, unknown>;
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultCliPath = path.join(repoRoot, "apps/cli/src/index.ts");
async function main() {
const args = await parseArgs(process.argv.slice(2));
const candidate = path.resolve(args.candidate);
const metadata = await readCandidateMetadata(candidate);
const cwd = path.resolve(args.cwd ?? metadataCwd(metadata) ?? process.cwd());
const source = await readFile(candidate, "utf8");
const cliPath = path.resolve(args.cliPath);
const threadName =
args.threadName === undefined ? defaultThreadName(candidate) : args.threadName;
const command =
args.mode === "shim"
? replayCommand({
candidate,
cliPath,
codexCommand: args.codexCommand,
cwd,
timeoutMs: args.timeoutMs,
})
: undefined;
const client = new CodexAppServerClient({
transportOptions: {
codexCommand: args.codexCommand,
args: appServerArgs(),
env: args.codexHome ? { CODEX_HOME: path.resolve(args.codexHome) } : undefined,
requestTimeoutMs: args.timeoutMs,
},
clientName: "code-mode-replay-thread",
clientTitle: "Code Mode Replay Thread",
clientVersion: "0.1.0",
});
const output: string[] = [];
let completedItem: unknown;
let commandExitCode: number | null = null;
let resolveTurnCompleted: (value: unknown) => void = () => undefined;
const turnCompleted = new Promise((resolve) => {
resolveTurnCompleted = resolve;
});
client.on("request", (message) => {
client.respondError(message.id, -32603, "replay script does not handle server requests");
});
client.on("notification", (message) => {
if (message.method === "item/commandExecution/outputDelta") {
const delta = stringField(message.params, "delta");
if (delta) {
output.push(delta);
if (args.stream) {
process.stdout.write(delta);
}
}
}
if (message.method === "item/agentMessage/delta") {
const delta = stringField(message.params, "delta");
if (delta) {
output.push(delta);
if (args.stream) {
process.stdout.write(delta);
}
}
}
if (message.method === "item/completed") {
const item = recordField(message.params, "item");
if (item && stringField(item, "type") === "commandExecution") {
completedItem = item;
commandExitCode = numberField(item, "exitCode") ?? numberField(item, "exit_code");
}
if (item && stringField(item, "type") === "agentMessage") {
completedItem = item;
}
}
if (message.method === "turn/completed") {
resolveTurnCompleted(message.params);
}
});
try {
await client.connect();
const started = await client.startThread({
cwd,
approvalPolicy: "never",
sandbox: "danger-full-access",
ephemeral: args.ephemeral,
experimentalRawEvents: false,
persistExtendedHistory: false,
});
const threadId = started.thread.id;
if (threadName) {
await client.request("thread/name/set", {
threadId,
name: threadName,
});
}
let injectedContext = false;
if (args.injectContext || args.notes.length > 0) {
await injectAssistantText(
client,
threadId,
replayContextText({
candidate,
codexHome: args.codexHome ? path.resolve(args.codexHome) : undefined,
cwd,
metadata,
mode: args.mode,
notes: args.notes,
source,
}),
);
injectedContext = true;
}
if (args.mode === "shim") {
await client.request("thread/shellCommand", {
threadId,
command,
});
} else {
await client.request("thread/codeMode/execute", {
threadId,
source,
});
}
const completed = await withTimeout(
turnCompleted,
args.timeoutMs,
"timed out waiting for Code Mode replay completion",
);
let replayOutput = output.join("");
if (args.mode === "native") {
const read = await client.request("thread/read", {
threadId,
includeTurns: true,
});
replayOutput = latestAgentMessageText(read) ?? replayOutput;
if (args.stream && replayOutput && output.length === 0) {
process.stdout.write(replayOutput.endsWith("\n") ? replayOutput : replayOutput + "\n");
}
}
let injectedResult = false;
if (args.injectResult) {
await injectAssistantText(
client,
threadId,
replayResultText({
candidate,
command,
commandExitCode,
cwd,
mode: args.mode,
output: replayOutput,
}),
);
injectedResult = true;
}
const result = {
threadId,
cwd,
candidate,
mode: args.mode,
command,
commandExitCode: args.mode === "shim" ? commandExitCode : null,
output: replayOutput,
injectedContext,
injectedResult,
threadName,
codexHome: args.codexHome ? path.resolve(args.codexHome) : undefined,
notes: args.notes,
completed,
completedItem,
};
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
process.stdout.write("threadId=" + threadId + "\n");
process.exitCode = args.mode === "shim" ? commandExitCode ?? 0 : 0;
} finally {
client.close();
}
}
function replayCommand(options: {
candidate: string;
cliPath: string;
codexCommand?: string;
cwd: string;
timeoutMs: number;
}) {
const command = [
"bun",
shellQuote(options.cliPath),
"--url",
"stdio://",
"--timeout-ms",
String(options.timeoutMs),
"run-code-mode",
shellQuote(options.candidate),
"--cwd",
shellQuote(options.cwd),
];
if (options.codexCommand) {
command.splice(4, 0, "--codex-command", shellQuote(options.codexCommand));
}
return command.join(" ");
}
function appServerArgs() {
return [
"app-server",
"--listen",
"stdio://",
"--enable",
"apps",
"--enable",
"hooks",
"--enable",
"code_mode",
"--enable",
"code_mode_only",
];
}
function defaultThreadName(candidate: string) {
return "Code Mode replay: " + path.basename(candidate);
}
async function readCandidateMetadata(candidate: string): Promise<CandidateMetadata | undefined> {
const metadataPath = candidate.replace(/\.[^.]+$/, ".json");
try {
const parsed = JSON.parse(await readFile(metadataPath, "utf8")) as unknown;
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
function metadataCwd(metadata: CandidateMetadata | undefined) {
const cwd = metadata?.cwd;
return typeof cwd === "string" && cwd ? cwd : undefined;
}
function latestAgentMessageText(value: unknown) {
const thread = recordField(value, "thread");
const turns = Array.isArray(thread?.turns) ? thread.turns : [];
for (const turn of turns.slice().reverse()) {
const turnRecord = isRecord(turn) ? turn : undefined;
const items = Array.isArray(turnRecord?.items) ? turnRecord.items : [];
for (const item of items.slice().reverse()) {
if (!isRecord(item) || stringField(item, "type") !== "agentMessage") {
continue;
}
const text = stringField(item, "text");
if (text !== undefined) {
return text;
}
}
}
return undefined;
}
async function injectAssistantText(
client: CodexAppServerClient,
threadId: string,
text: string,
) {
await client.request("thread/inject_items", {
threadId,
items: [
{
type: "message",
role: "assistant",
content: [
{
type: "output_text",
text,
},
],
},
],
});
}
function replayContextText(options: {
candidate: string;
codexHome?: string;
cwd: string;
metadata: CandidateMetadata | undefined;
mode: ReplayMode;
notes: string[];
source: string;
}) {
const parts = [
"Code Mode replay context",
"",
"Candidate: " + options.candidate,
"Working directory: " + options.cwd,
"Replay mode: " + options.mode,
];
if (options.codexHome) {
parts.push("Codex home: " + options.codexHome);
}
if (options.notes.length > 0) {
parts.push("", "Notes:");
for (const note of options.notes) {
parts.push("- " + note);
}
}
parts.push(
"",
"Candidate metadata:",
options.metadata ? formatJson(options.metadata) : "unavailable",
"",
"Saved Code Mode script:",
truncateText(options.source, 50_000),
);
return parts.join("\n");
}
function replayResultText(options: {
candidate: string;
command: string | undefined;
commandExitCode: number | null;
cwd: string;
mode: ReplayMode;
output: string;
}) {
const lines = [
"Code Mode replay result",
"",
"Candidate: " + options.candidate,
"Working directory: " + options.cwd,
"Replay mode: " + options.mode,
];
if (options.command !== undefined) {
lines.push(
"Command exit code: " + String(options.commandExitCode),
"",
"Thread shell command:",
options.command,
);
}
lines.push(
"",
"Replay output:",
truncateText(options.output, 50_000),
);
return lines.join("\n");
}
function truncateText(value: string, limit: number) {
if (value.length <= limit) {
return value;
}
return (
value.slice(0, limit) +
"\n...[truncated " +
String(value.length - limit) +
" chars]"
);
}
function formatJson(value: unknown) {
return JSON.stringify(value, null, 2);
}
async function parseArgs(argv: string[]): Promise<Args> {
let candidate: string | undefined;
let cwd: string | undefined;
let codexCommand = process.env.CODEX_APP_SERVER_CODEX_COMMAND;
let codexHome: string | undefined;
let cliPath = defaultCliPath;
let timeoutMs = 180_000;
let ephemeral = false;
let stream = true;
let injectContext = true;
let injectResult = true;
const notes: string[] = [];
let threadName: string | null | undefined;
let mode: ReplayMode = "native";
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg) {
continue;
}
if (arg === "-h" || arg === "--help") {
printHelp();
process.exit(0);
}
if (arg === "--cwd") {
cwd = requiredValue(argv, ++index, "--cwd");
continue;
}
if (arg.startsWith("--cwd=")) {
cwd = arg.slice("--cwd=".length);
continue;
}
if (arg === "--codex-command") {
codexCommand = requiredValue(argv, ++index, "--codex-command");
continue;
}
if (arg.startsWith("--codex-command=")) {
codexCommand = arg.slice("--codex-command=".length);
continue;
}
if (arg === "--codex-home") {
codexHome = requiredValue(argv, ++index, "--codex-home");
continue;
}
if (arg.startsWith("--codex-home=")) {
codexHome = arg.slice("--codex-home=".length);
continue;
}
if (arg === "--native") {
mode = "native";
continue;
}
if (arg === "--shim") {
mode = "shim";
continue;
}
if (arg === "--cli") {
cliPath = requiredValue(argv, ++index, "--cli");
continue;
}
if (arg.startsWith("--cli=")) {
cliPath = arg.slice("--cli=".length);
continue;
}
if (arg === "--timeout-ms") {
timeoutMs = parseTimeout(requiredValue(argv, ++index, "--timeout-ms"));
continue;
}
if (arg.startsWith("--timeout-ms=")) {
timeoutMs = parseTimeout(arg.slice("--timeout-ms=".length));
continue;
}
if (arg === "--ephemeral") {
ephemeral = true;
continue;
}
if (arg === "--no-stream") {
stream = false;
continue;
}
if (arg === "--no-inject-context") {
injectContext = false;
continue;
}
if (arg === "--no-inject-result") {
injectResult = false;
continue;
}
if (arg === "--name") {
threadName = requiredValue(argv, ++index, "--name");
continue;
}
if (arg.startsWith("--name=")) {
threadName = arg.slice("--name=".length);
continue;
}
if (arg === "--no-name") {
threadName = null;
continue;
}
if (arg === "--note") {
notes.push(requiredValue(argv, ++index, "--note"));
continue;
}
if (arg.startsWith("--note=")) {
notes.push(arg.slice("--note=".length));
continue;
}
if (arg.startsWith("-")) {
throw new Error("unknown option: " + arg);
}
if (candidate) {
throw new Error("unexpected positional argument: " + arg);
}
candidate = arg;
}
if (!candidate) {
printHelp();
throw new Error("candidate file is required");
}
return {
candidate,
cwd,
codexCommand,
codexHome,
cliPath,
timeoutMs,
ephemeral,
stream,
injectContext,
injectResult,
notes,
threadName,
mode,
};
}
function requiredValue(argv: string[], index: number, flag: string) {
const value = argv[index];
if (!value) {
throw new Error(flag + " requires a value");
}
return value;
}
function parseTimeout(value: string) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error("invalid --timeout-ms value: " + value);
}
return parsed;
}
function shellQuote(value: string) {
return "'" + value.replaceAll("'", "'\\''") + "'";
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function recordField(value: unknown, field: string) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const nested = record[field];
return typeof nested === "object" && nested !== null && !Array.isArray(nested)
? (nested as Record<string, unknown>)
: undefined;
}
function stringField(value: unknown, field: string) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
const fieldValue = (value as Record<string, unknown>)[field];
return typeof fieldValue === "string" ? fieldValue : undefined;
}
function numberField(value: unknown, field: string) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
const fieldValue = (value as Record<string, unknown>)[field];
return typeof fieldValue === "number" ? fieldValue : undefined;
}
function printHelp() {
process.stdout.write(
[
"Run a saved Code Mode candidate in a new Codex thread without starting a model turn.",
"",
"Usage:",
" bun scripts/run-code-mode-in-new-thread.ts <candidate.mjs> [options]",
"",
"Options:",
" --cwd <dir> Thread cwd. Defaults to candidate sidecar cwd, then process cwd.",
" --codex-command <path> Codex binary for both app-server and replay.",
" Defaults to CODEX_APP_SERVER_CODEX_COMMAND.",
" With CODEX_FLOWS_MODE=code-mode, falls back to",
" bunx @peezy.tech/codex.",
" --codex-home <dir> CODEX_HOME for the spawned app-server, useful for prepared MCP config.",
" --native Use native thread/codeMode/execute replay. This is the default.",
" --shim Use the older TypeScript shell-command shim fallback.",
" --cli <path> codex-app CLI path. Defaults to " + defaultCliPath,
" --timeout-ms <ms> Timeout for app-server requests and completion wait.",
" --ephemeral Create an ephemeral thread.",
" --no-stream Do not stream command output while waiting.",
" --note <text> Add a note to the injected replay context. Repeatable.",
" --name <text> Set the thread title. Defaults to the candidate filename.",
" --no-name Leave the thread title unset.",
" --no-inject-context Skip injecting candidate metadata/source before execution.",
" --no-inject-result Skip injecting the replay summary after execution.",
" -h, --help Show this help.",
"",
].join("\n"),
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
process.exit(1);
});