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
8 changes: 7 additions & 1 deletion .github/workflows/code-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ concurrency:
jobs:
publish-macos:
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
arch: [arm64, x64]
permissions:
id-token: write
contents: write
env:
NODE_OPTIONS: "--max-old-space-size=8192"
NODE_ENV: production
npm_config_arch: ${{ matrix.arch }}
npm_config_platform: darwin
VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }}
VITE_POSTHOG_API_HOST: ${{ secrets.VITE_POSTHOG_API_HOST }}
POSTHOG_SOURCEMAP_API_KEY: ${{ secrets.POSTHOG_SOURCEMAP_API_KEY }}
Expand Down Expand Up @@ -128,7 +134,7 @@ jobs:
env:
APP_VERSION: ${{ steps.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
run: pnpm --filter code run publish
run: pnpm --filter code run publish -- --arch=${{ matrix.arch }}

publish-windows:
runs-on: windows-latest
Expand Down
30 changes: 25 additions & 5 deletions apps/code/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,16 @@ const config: ForgeConfig = {
prePackage: async () => {
if (process.platform !== "darwin") return;

// Build native modules for DMG maker on Node.js 22
// Build native modules for DMG maker on Node.js 22. These run on the
// build host (DMG creation is host-side), so we force npm to target the
// host arch even when the rest of the build is cross-targeting (e.g.
// building darwin-x64 on an arm64 runner).
const modules = ["macos-alias", "fs-xattr"];
const hostBuildEnv = {
...process.env,
npm_config_arch: process.arch,
npm_config_platform: process.platform,
};

for (const mod of modules) {
const candidates = [
Expand All @@ -237,32 +245,44 @@ const config: ForgeConfig = {

if (modulePath) {
console.log(`Building native module: ${mod} (${modulePath})`);
execSync("npm install", { cwd: modulePath, stdio: "inherit" });
execSync("npm install", {
cwd: modulePath,
stdio: "inherit",
env: hostBuildEnv,
});
}
}
},
postStart: async (_forgeConfig, child) => {
electronChild = child;
},
packageAfterCopy: async (_forgeConfig, buildPath) => {
// Resolve the target arch (cross-builds set npm_config_arch); fall back
// to the host so non-cross builds keep their existing behavior.
const targetArch = process.env.npm_config_arch ?? process.arch;

copyNativeDependency("node-pty", buildPath);
copyNativeDependency("node-addon-api", buildPath);
copyNativeDependency("@parcel/watcher", buildPath);

// Platform-specific native dependencies
if (process.platform === "darwin") {
copyNativeDependency("@parcel/watcher-darwin-arm64", buildPath);
const watcherPkg =
targetArch === "x64"
? "@parcel/watcher-darwin-x64"
: "@parcel/watcher-darwin-arm64";
copyNativeDependency(watcherPkg, buildPath);
copyNativeDependency("file-icon", buildPath);
copyNativeDependency("p-map", buildPath);
} else if (process.platform === "win32") {
const watcherPkg =
process.arch === "arm64"
targetArch === "arm64"
? "@parcel/watcher-win32-arm64"
: "@parcel/watcher-win32-x64";
copyNativeDependency(watcherPkg, buildPath);
} else if (process.platform === "linux") {
const watcherPkg =
process.arch === "arm64"
targetArch === "arm64"
? "@parcel/watcher-linux-arm64-glibc"
: "@parcel/watcher-linux-x64-glibc";
copyNativeDependency(watcherPkg, buildPath);
Expand Down
4 changes: 1 addition & 3 deletions apps/code/src/main/services/agent/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ const mockClientSideConnection = vi.hoisted(() =>
this.initialize = vi.fn().mockResolvedValue({});
this.newSession = mockNewSession;
this.loadSession = vi.fn().mockResolvedValue({ configOptions: [] });
this.unstable_resumeSession = vi
.fn()
.mockResolvedValue({ configOptions: [] });
this.resumeSession = vi.fn().mockResolvedValue({ configOptions: [] });
}),
);

Expand Down
11 changes: 7 additions & 4 deletions apps/code/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,10 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
}

private getClaudeCliPath(): string {
return this.bundledResources.resolve(".vite/build/claude-cli/cli.js");
// Keep in sync with the destDir in apps/code/vite.main.config.mts
// (copyClaudeExecutable plugin).
const binary = process.platform === "win32" ? "claude.exe" : "claude";
return this.bundledResources.resolve(`.vite/build/claude-cli/${binary}`);
}

private getCodexBinaryPath(): string {
Expand Down Expand Up @@ -747,7 +750,7 @@ When creating pull requests, add the following footer at the end of the PR descr
// Claude-specific: hydrate session JSONL from PostHog before resuming.
// If hydration finds no conversation to restore, skip the resume and
// fall through to creating a new session. This avoids a doomed
// unstable_resumeSession that would fail with "Resource not found"
// resumeSession that would fail with "Resource not found"
if (isReconnect && config.sessionId) {
const existingSessionId = config.sessionId;

Expand Down Expand Up @@ -777,10 +780,10 @@ When creating pull requests, add the following footer at the end of the PR descr
if (isReconnect && config.sessionId) {
const existingSessionId = config.sessionId;

// Both adapters implement unstable_resumeSession:
// Both adapters implement resumeSession:
// - Claude: delegates to SDK's resumeSession with JSONL hydration
// - Codex: delegates to codex-acp's loadSession internally
const resumeResponse = await connection.unstable_resumeSession({
const resumeResponse = await connection.resumeSession({
sessionId: existingSessionId,
cwd: repoPath,
mcpServers,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,7 @@ export class SessionService {
* The main process already cleaned up the agent, so we only need to
* unsubscribe from the channel and mark the session as errored.
* Preserves events, logUrl, configOptions and adapter so that Retry
* can reconnect with full context via unstable_resumeSession.
* can reconnect with full context via resumeSession.
*/
private handleIdleKill(taskRunId: string): void {
this.unsubscribeFromChannel(taskRunId);
Expand Down
194 changes: 133 additions & 61 deletions apps/code/vite.main.config.mts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { execFile, execSync } from "node:child_process";
import {
closeSync,
copyFileSync,
cpSync,
existsSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
readSync,
statSync,
} from "node:fs";
import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
Expand All @@ -16,6 +19,15 @@ import { promisify } from "node:util";
import { unzipSync } from "fflate";
import { defineConfig, loadEnv, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
// @ts-expect-error - plain ESM helper shared with packages/agent/tsup.config.ts
import {
CLAUDE_CLI_SUPPORT_DIRS,
CLAUDE_CLI_SUPPORT_FILES,
claudeBinName,
claudeExecutableCandidates as sdkClaudeExecutableCandidates,
targetArch,
targetPlatform,
} from "../../packages/agent/build/native-binary.mjs";
import {
createForceDevModeDefine,
createPosthogPlugin,
Expand Down Expand Up @@ -58,87 +70,147 @@ function fixFilenameCircularRef(): Plugin {

let claudeCliCopied = false;

function verifyBinaryArch(destPath: string): void {
// Best-effort: parse the binary's magic bytes and confirm the embedded arch
// matches what we believe we're packaging for. `file(1)` is more portable
// but adds a subprocess; reading 20 bytes is enough for Mach-O / ELF / PE.
let header: Buffer;
try {
const fd = openSync(destPath, "r");
try {
header = Buffer.alloc(20);
readSync(fd, header, 0, 20, 0);
} finally {
closeSync(fd);
}
} catch (err) {
console.warn("[copy-claude-executable] Could not inspect binary:", err);
return;
}

const arch = targetArch();
const platform = targetPlatform();
const actual = detectBinaryArch(header, platform);
if (actual && actual !== arch) {
throw new Error(
`[copy-claude-executable] Architecture mismatch: copied binary is ${actual} but target is ${arch} (platform=${platform}). ` +
`Reinstall @anthropic-ai/claude-agent-sdk optional deps for the target arch, or set npm_config_arch=${arch} before building.`,
);
}
}

function detectBinaryArch(
header: Buffer,
platform: string,
): "arm64" | "x64" | "ia32" | null {
// Mach-O 64-bit LE: magic 0xFEEDFACF, then cputype at offset 4 (LE).
if (platform === "darwin" && header.readUInt32LE(0) === 0xfeedfacf) {
const cpuType = header.readUInt32LE(4);
if (cpuType === 0x0100000c) return "arm64";
if (cpuType === 0x01000007) return "x64";
}
// ELF: \x7FELF, then e_machine at offset 18 (LE).
if (
platform === "linux" &&
header[0] === 0x7f &&
header[1] === 0x45 &&
header[2] === 0x4c &&
header[3] === 0x46
) {
const eMachine = header.readUInt16LE(18);
if (eMachine === 0x3e) return "x64";
if (eMachine === 0xb7) return "arm64";
if (eMachine === 0x03) return "ia32";
}
// PE: MZ at 0, PE header offset at 0x3C — too long to inline; skip.
return null;
}

function signClaudeBinary(destPath: string): void {
if (targetPlatform() !== "darwin") return;
if (process.platform !== "darwin") {
// Can't ad-hoc sign a darwin binary from a non-darwin build host; the
// resulting app won't launch on Apple Silicon. Fail loud rather than
// shipping an unrunnable bundle.
throw new Error(
"[copy-claude-executable] Cannot ad-hoc sign darwin binary from non-darwin host. Build on macOS.",
);
}
try {
execSync(`xattr -cr "${destPath}"`, { stdio: "inherit" });
execSync(`codesign --force --sign - "${destPath}"`, { stdio: "inherit" });
} catch (err) {
console.warn(
"[copy-claude-executable] FAILED to ad-hoc sign binary; macOS will reject the bundled app:",
err,
);
}
}

function copyClaudeSupportAssets(sourcePath: string, destDir: string): void {
const sourceDir = dirname(sourcePath);

for (const file of CLAUDE_CLI_SUPPORT_FILES) {
const source = join(sourceDir, file);
if (existsSync(source)) {
copyFileSync(source, join(destDir, file));
}
}

for (const dir of CLAUDE_CLI_SUPPORT_DIRS) {
const source = join(sourceDir, dir);
if (existsSync(source)) {
cpSync(source, join(destDir, dir), { recursive: true });
}
}
}

function copyClaudeExecutable(): Plugin {
return {
name: "copy-claude-executable",
writeBundle() {
const binName = claudeBinName();
const destDir = join(__dirname, ".vite/build/claude-cli");
const destBinary = join(destDir, binName);

// Skip re-copying on subsequent HMR rebuilds
if (claudeCliCopied && existsSync(join(destDir, "cli.js"))) {
if (claudeCliCopied && existsSync(destBinary)) {
return;
}

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

const candidates = [
{
path: join(__dirname, "node_modules/@posthog/agent/dist/claude-cli"),
type: "package",
},
{
path: join(
__dirname,
"../../node_modules/@posthog/agent/dist/claude-cli",
),
type: "package",
},
{
path: join(__dirname, "../../packages/agent/dist/claude-cli"),
type: "package",
},
const packageCandidates = [
join(__dirname, "node_modules/@posthog/agent/dist/claude-cli", binName),
join(
__dirname,
"../../node_modules/@posthog/agent/dist/claude-cli",
binName,
),
join(__dirname, "../../packages/agent/dist/claude-cli", binName),
...sdkClaudeExecutableCandidates(join(__dirname, "node_modules")),
...sdkClaudeExecutableCandidates(join(__dirname, "../../node_modules")),
];

for (const candidate of candidates) {
if (
existsSync(join(candidate.path, "cli.js")) &&
existsSync(join(candidate.path, "yoga.wasm"))
) {
const files = ["cli.js", "package.json", "yoga.wasm"];
for (const file of files) {
copyFileSync(join(candidate.path, file), join(destDir, file));
}
const vendorDir = join(candidate.path, "vendor");
if (existsSync(vendorDir)) {
cpSync(vendorDir, join(destDir, "vendor"), { recursive: true });
}
claudeCliCopied = true;
return;
}
}

const rootNodeModules = join(__dirname, "../../node_modules");
const sdkDir = join(rootNodeModules, "@anthropic-ai/claude-agent-sdk");
const yogaDir = join(rootNodeModules, "yoga-wasm-web/dist");

if (
existsSync(join(sdkDir, "cli.js")) &&
existsSync(join(yogaDir, "yoga.wasm"))
) {
copyFileSync(join(sdkDir, "cli.js"), join(destDir, "cli.js"));
copyFileSync(
join(sdkDir, "package.json"),
join(destDir, "package.json"),
);
copyFileSync(join(yogaDir, "yoga.wasm"), join(destDir, "yoga.wasm"));
const vendorDir = join(sdkDir, "vendor");
if (existsSync(vendorDir)) {
cpSync(vendorDir, join(destDir, "vendor"), { recursive: true });
}
console.log(
"Assembled Claude CLI from workspace sources in claude-cli/ subdirectory",
const source = packageCandidates.find((p: string) => existsSync(p));
if (!source) {
console.warn(
`[copy-claude-executable] FAILED to find native Claude binary for ${targetPlatform()}-${targetArch()}. Agent execution may fail.`,
);
claudeCliCopied = true;
console.warn(`Checked paths:\n ${packageCandidates.join("\n ")}`);
return;
}

console.warn(
"[copy-claude-executable] FAILED to find Claude CLI artifacts. Agent execution may fail.",
);
console.warn("Checked paths:", candidates.map((c) => c.path).join(", "));
console.warn("Checked workspace sources:", sdkDir);
copyFileSync(source, destBinary);
if (targetPlatform() !== "win32") {
execSync(`chmod +x "${destBinary}"`);
}
copyClaudeSupportAssets(source, destDir);
verifyBinaryArch(destBinary);
signClaudeBinary(destBinary);
claudeCliCopied = true;
},
};
}
Expand Down
Loading
Loading