Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ test-results/

*storybook.log
storybook-static

# Downloaded binaries
apps/twig/resources/codex-acp/
2 changes: 1 addition & 1 deletion apps/twig/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const config: ForgeConfig = {
packagerConfig: {
asar: {
unpack:
"{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**}",
"{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**}",
},
prune: false,
name: "Twig",
Expand Down
3 changes: 2 additions & 1 deletion apps/twig/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"test": "vitest run",
"test:e2e": "playwright test --config=tests/e2e/playwright.config.ts",
"test:e2e:headed": "playwright test --config=tests/e2e/playwright.config.ts --headed",
"postinstall": "cd ../.. && npx @electron/rebuild -f -m node_modules/node-pty || true && bash apps/twig/scripts/patch-electron-name.sh",
"postinstall": "cd ../.. && npx @electron/rebuild -f -m node_modules/node-pty || true && bash apps/twig/scripts/patch-electron-name.sh && node apps/twig/scripts/download-codex-acp.mjs",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
Expand Down Expand Up @@ -160,6 +160,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"reflect-metadata": "^0.2.2",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tippy.js": "^6.3.7",
Expand Down
148 changes: 148 additions & 0 deletions apps/twig/scripts/download-codex-acp.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env node

import { execSync } from "node:child_process";
import { chmodSync, createWriteStream, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";
import { extract } from "tar";

const __dirname = dirname(fileURLToPath(import.meta.url));

const CODEX_ACP_VERSION = "0.9.1";
const GITHUB_RELEASE_BASE = `https://github.com/zed-industries/codex-acp/releases/download/v${CODEX_ACP_VERSION}`;

function getPlatformTarget() {
const platform = process.platform;
const arch = process.arch;

if (platform === "darwin") {
return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
}

if (platform === "linux") {
return arch === "arm64"
? "aarch64-unknown-linux-gnu"
: "x86_64-unknown-linux-gnu";
}

if (platform === "win32") {
return arch === "arm64"
? "aarch64-pc-windows-msvc"
: "x86_64-pc-windows-msvc";
}

throw new Error(`Unsupported platform: ${platform}-${arch}`);
}

function getDownloadUrl(target) {
const ext = target.includes("windows") ? "zip" : "tar.gz";
return `${GITHUB_RELEASE_BASE}/codex-acp-${CODEX_ACP_VERSION}-${target}.${ext}`;
}

function getBinaryName() {
return process.platform === "win32" ? "codex-acp.exe" : "codex-acp";
}

async function downloadFile(url, destPath) {
console.log(`Downloading ${url}...`);

const response = await fetch(url, { redirect: "follow" });

if (!response.ok) {
throw new Error(
`Failed to download: ${response.status} ${response.statusText}`,
);
}

const fileStream = createWriteStream(destPath);
await pipeline(response.body, fileStream);

console.log(`Downloaded to ${destPath}`);
}

async function extractTarGz(archivePath, destDir) {
console.log(`Extracting ${archivePath} to ${destDir}...`);

await extract({
file: archivePath,
cwd: destDir,
});

console.log("Extraction complete");
}

async function extractZip(archivePath, destDir) {
const { default: AdmZip } = await import("adm-zip");
console.log(`Extracting ${archivePath} to ${destDir}...`);

const zip = new AdmZip(archivePath);
zip.extractAllTo(destDir, true);

console.log("Extraction complete");
}

async function main() {
const destDir = join(__dirname, "..", "resources", "codex-acp");
const binaryName = getBinaryName();
const binaryPath = join(destDir, binaryName);

if (existsSync(binaryPath)) {
console.log(`codex-acp binary already exists at ${binaryPath}`);
return;
}

if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true });
}

const target = getPlatformTarget();
const url = getDownloadUrl(target);
const isZip = url.endsWith(".zip");
const archivePath = join(
destDir,
isZip ? "codex-acp.zip" : "codex-acp.tar.gz",
);

await downloadFile(url, archivePath);

if (isZip) {
await extractZip(archivePath, destDir);
} else {
await extractTarGz(archivePath, destDir);
}

if (existsSync(binaryPath)) {
if (process.platform !== "win32") {
chmodSync(binaryPath, 0o755);
}

if (process.platform === "darwin") {
try {
execSync(`xattr -cr "${binaryPath}"`, { stdio: "inherit" });
console.log("Cleared extended attributes");
} catch {
console.log("No extended attributes to clear");
}

try {
execSync(`codesign --force --sign - "${binaryPath}"`, {
stdio: "inherit",
});
console.log("Ad-hoc signed binary for macOS");
} catch (err) {
console.warn("Failed to ad-hoc sign binary:", err.message);
}
}

console.log(`codex-acp binary ready at ${binaryPath}`);
} else {
console.error(`Binary not found after extraction. Expected: ${binaryPath}`);
process.exit(1);
}
}

main().catch((err) => {
console.error("Failed to download codex-acp:", err);
process.exit(1);
});
82 changes: 56 additions & 26 deletions apps/twig/src/main/services/agent/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type {
RequestPermissionRequest,
PermissionOption as SdkPermissionOption,
} from "@agentclientprotocol/sdk";
import { executionModeSchema } from "@shared/types";
import { z } from "zod";

// Session credentials schema
Expand All @@ -21,9 +20,9 @@ export const sessionConfigSchema = z.object({
repoPath: z.string(),
credentials: credentialsSchema,
logUrl: z.string().optional(),
sdkSessionId: z.string().optional(),
model: z.string().optional(),
executionMode: executionModeSchema.optional(),
/** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */
sessionId: z.string().optional(),
adapter: z.enum(["claude", "codex"]).optional(),
/** Additional directories Claude can access beyond cwd (for worktree support) */
additionalDirectories: z.array(z.string()).optional(),
});
Expand All @@ -41,12 +40,8 @@ export const startSessionInput = z.object({
projectId: z.number(),
permissionMode: z.string().optional(),
autoProgress: z.boolean().optional(),
model: z.string().optional(),
executionMode: z
.enum(["default", "acceptEdits", "plan", "bypassPermissions"])
.optional(),
runMode: z.enum(["local", "cloud"]).optional(),
/** Additional directories Claude can access beyond cwd (for worktree support) */
adapter: z.enum(["claude", "codex"]).optional(),
additionalDirectories: z.array(z.string()).optional(),
});

Expand All @@ -61,11 +56,45 @@ export const modelOptionSchema = z.object({

export type ModelOption = z.infer<typeof modelOptionSchema>;

const sessionConfigSelectOptionSchema = z
.object({
value: z.string(),
name: z.string(),
description: z.string().nullish(),
_meta: z.record(z.string(), z.unknown()).nullish(),
})
.passthrough();

const sessionConfigSelectGroupSchema = z
.object({
group: z.string(),
name: z.string(),
options: z.array(sessionConfigSelectOptionSchema),
_meta: z.record(z.string(), z.unknown()).nullish(),
})
.passthrough();

export const sessionConfigOptionSchema = z
.object({
id: z.string(),
name: z.string(),
type: z.literal("select"),
currentValue: z.string(),
options: z
.array(sessionConfigSelectOptionSchema)
.or(z.array(sessionConfigSelectGroupSchema)),
category: z.string().nullish(),
description: z.string().nullish(),
_meta: z.record(z.string(), z.unknown()).nullish(),
})
.passthrough();

export type SessionConfigOption = z.infer<typeof sessionConfigOptionSchema>;

export const sessionResponseSchema = z.object({
sessionId: z.string(),
channel: z.string(),
availableModels: z.array(modelOptionSchema).optional(),
currentModelId: z.string().optional(),
configOptions: z.array(sessionConfigOptionSchema).optional(),
});

export type SessionResponse = z.infer<typeof sessionResponseSchema>;
Expand Down Expand Up @@ -124,7 +153,8 @@ export const reconnectSessionInput = z.object({
apiHost: z.string(),
projectId: z.number(),
logUrl: z.string().optional(),
sdkSessionId: z.string().optional(),
sessionId: z.string().optional(),
adapter: z.enum(["claude", "codex"]).optional(),
/** Additional directories Claude can access beyond cwd (for worktree support) */
additionalDirectories: z.array(z.string()).optional(),
});
Expand All @@ -136,21 +166,16 @@ export const tokenUpdateInput = z.object({
token: z.string(),
});

// Set model input
export const setModelInput = z.object({
sessionId: z.string(),
modelId: z.string(),
});

// Set mode input
export const setModeInput = z.object({
// Set config option input (for Codex reasoning level, etc.)
export const setConfigOptionInput = z.object({
sessionId: z.string(),
modeId: executionModeSchema,
configId: z.string(),
value: z.string(),
});

// Subscribe to session events input
export const subscribeSessionInput = z.object({
sessionId: z.string(),
taskRunId: z.string(),
});

// Agent events
Expand All @@ -160,12 +185,17 @@ export const AgentServiceEvent = {
} as const;

export interface AgentSessionEventPayload {
sessionId: string;
taskRunId: string;
payload: unknown;
}

export type PermissionOption = SdkPermissionOption;
export type PermissionRequestPayload = RequestPermissionRequest;
export type PermissionRequestPayload = Omit<
RequestPermissionRequest,
"sessionId"
> & {
taskRunId: string;
};

export interface AgentServiceEvents {
[AgentServiceEvent.SessionEvent]: AgentSessionEventPayload;
Expand All @@ -174,7 +204,7 @@ export interface AgentServiceEvents {

// Permission response input for tRPC
export const respondToPermissionInput = z.object({
sessionId: z.string(),
taskRunId: z.string(),
toolCallId: z.string(),
optionId: z.string(),
// For "Other" option: custom text input from user (ACP extension via _meta)
Expand All @@ -187,7 +217,7 @@ export type RespondToPermissionInput = z.infer<typeof respondToPermissionInput>;

// Permission cancellation input for tRPC
export const cancelPermissionInput = z.object({
sessionId: z.string(),
taskRunId: z.string(),
toolCallId: z.string(),
});

Expand Down
Loading
Loading