Skip to content
Open
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
12 changes: 9 additions & 3 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,15 @@ if [ -f scripts/build-binaries.sh ] && [ -f packages/cli/src/cli.ts ]; then
packages/cli/src/cli.ts

# Smoke test: the binary must start and exit 0 on a safe, non-interactive command.
# `version` or `--help` are both acceptable — pick one that does NOT touch the
# network, database, or require env vars.
if ! "$TMP_BINARY" version > /tmp/archon-preflight.log 2>&1; then
# Use `--help` (NOT `version`). The `version` command's compiled-binary code
# path depends on BUNDLED_IS_BINARY=true, which is set by scripts/build-binaries.sh
# — but we're doing a bare `bun build --compile` here to keep the smoke fast,
# so BUNDLED_IS_BINARY is still `false`. That sends `version` down the dev
# branch of version.ts which tries to read package.json from a path that only
# exists in node_modules, producing a false-positive ENOENT. `--help` has no
# such dev/binary branch and exercises the same module-init graph we're
# actually testing. Must NOT touch network, database, or require env vars.
if ! "$TMP_BINARY" --help > /tmp/archon-preflight.log 2>&1; then
echo "ERROR: compiled binary crashed at startup"
cat /tmp/archon-preflight.log
echo ""
Expand Down
33 changes: 33 additions & 0 deletions .claude/skills/test-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ About to test:
Path: brew (Homebrew tap on macOS)
Version: 0.3.1 (expected)
Cleanup: will uninstall after tests (brew uninstall + untap)
If `archon-stable` symlink is detected in Phase 2, it will be
restored at the end of Phase 5 by reinstalling the tap formula.

Proceed? (y/N)
```
Expand Down Expand Up @@ -112,6 +114,18 @@ gh release view v<version> --repo coleam00/Archon --json tagName,assets --jq '{t

If the release does not exist or has no assets, abort with a clear message. Do not proceed to install a non-existent release.

4. **Detect persistent `archon-stable` install (brew path only).** If the user has renamed a prior brew install to `archon-stable` (the dual-homebrew pattern — see `~/.config/fish/functions/brew-upgrade-archon.fish`), Phase 5's `brew uninstall` will wipe it. Capture the state so Phase 5b can restore it:

```bash
ARCHON_STABLE_WAS_INSTALLED=""
if [ -L /opt/homebrew/bin/archon-stable ] || [ -L /usr/local/bin/archon-stable ]; then
ARCHON_STABLE_WAS_INSTALLED="yes"
echo "Detected persistent archon-stable — will restore after Phase 5 uninstall."
fi
```

Export `ARCHON_STABLE_WAS_INSTALLED` into the environment used by Phase 5b. Only applies to the `brew` path — `curl-mac` and `curl-vps` don't go through brew and don't disturb `archon-stable`.

## Phase 3 — Install

### Path: brew
Expand Down Expand Up @@ -352,6 +366,25 @@ archon version | head -1
# should match the dev version captured in Phase 2
```

**Restore `archon-stable` if it existed before the test** (dual-homebrew pattern — see Phase 2 item 4):

```bash
if [ -n "$ARCHON_STABLE_WAS_INSTALLED" ]; then
echo "Restoring archon-stable (detected before test)..."
brew tap coleam00/archon
brew install coleam00/archon/archon
BREW_BIN="$(brew --prefix)/bin"
if [ -e "$BREW_BIN/archon" ]; then
mv "$BREW_BIN/archon" "$BREW_BIN/archon-stable"
echo "archon-stable restored: $(archon-stable version 2>/dev/null | head -1)"
else
echo "WARNING: brew install succeeded but $BREW_BIN/archon missing — check formula"
fi
fi
```

> **Note on the restored version**: this reinstalls from whatever the tap currently ships, which is typically the release you just tested (so `archon-stable` ends up at the newly-tested version). That's usually what the operator wants — you just verified the new release works, and you want `archon-stable` pointed at it. If you were testing an older version for back-version QA, the restored `archon-stable` will be the *current* tap formula, not the pre-test version. For that rare case, the operator should re-run `brew-upgrade-archon` manually after the test.

### Path: curl-mac

```bash
Expand Down
10 changes: 5 additions & 5 deletions homebrew/archon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,28 @@
class Archon < Formula
desc "Remote agentic coding platform - control AI assistants from anywhere"
homepage "https://github.com/coleam00/Archon"
version "0.3.6"
version "0.3.9"
license "MIT"

on_macos do
on_arm do
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-arm64"
sha256 "96b6dac50b046eece9eddbb988a0c39b4f9a0e2faac66e49b977ba6360069e86"
sha256 "b617f85a2181938b793b25ad816a9f6b3149d184f64b2e9e2ea2430f27778d64"
end
on_intel do
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-x64"
sha256 "09f1dbe12417b4300b7b07b531eb7391a286305f8d4eafc11e7f61f5d26eb8eb"
sha256 "5a928af5e0e67ffe084159161a9ea3994a9304cc39bd06132719cd89cc715e86"
end
end

on_linux do
on_arm do
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-arm64"
sha256 "80b06a6ff699ec57cd4a3e49cfe7b899a3e8212688d70285f5a887bf10086731"
sha256 "567bfca9175e10d9b4fd748e3862bbd34141a234766a7ecf0a714d9c27b8c92e"
end
on_intel do
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-x64"
sha256 "09f5dac6db8037ed6f3e5b7e9c5eb8e37f19822a4ed2bf4cd7e654780f9d00de"
sha256 "c918218df2f0f853d107e6b1727dcd9accc034b183ffbccea93a331d8d376ed8"
end
end

Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/orchestrator/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,10 @@ export async function dispatchBackgroundWorkflow(

// 3. Resolve isolation for this worker (each background workflow gets its own worktree).
// Isolation failure is fatal — never run a workflow in a shared/parent worktree.
// However, if the workflow explicitly disables worktrees, skip isolation entirely
// and run in the parent's working directory (worktree.enabled: false in YAML).
let workerCwd: string;
if (ctx.codebaseId) {
if (ctx.codebaseId && workflow.worktree?.enabled !== false) {
const codebase = await getCodebase(ctx.codebaseId);
if (!codebase) {
throw new Error(
Expand All @@ -299,7 +301,8 @@ export async function dispatchBackgroundWorkflow(
);
});
} else {
// No codebase — run in parent's cwd (no isolation needed for non-repo workflows)
// Either no codebase, or workflow opts out of worktree isolation.
// Run in the parent's working directory.
workerCwd = ctx.cwd;
}

Expand Down
47 changes: 46 additions & 1 deletion packages/providers/src/claude/binary-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,52 @@ describe('resolveClaudeBinaryPath (binary mode)', () => {
expect(result).toBe('/env/cli.js');
});

test('throws with install instructions when nothing configured', async () => {
test('autodetects native installer path when env and config are unset', async () => {
const home = process.env.HOME ?? '/Users/test';
const expected =
process.platform === 'win32'
? `${home}\\.local\\bin\\claude.exe`
: `${home}/.local/bin/claude`;
// File exists only at the native-installer path.
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation(
(path: string) => path === expected
);

const result = await resolver.resolveClaudeBinaryPath();
expect(result).toBe(expected);
// Log must mark this as autodetect, not 'env' or 'config' — the source
// string is load-bearing for debug triage.
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: expected, source: 'autodetect' },
'claude.binary_resolved'
);
});

test('env var takes precedence over autodetect when both would match', async () => {
process.env.CLAUDE_BIN_PATH = '/custom/env/claude';
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true);

const result = await resolver.resolveClaudeBinaryPath();
expect(result).toBe('/custom/env/claude');
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: '/custom/env/claude', source: 'env' },
'claude.binary_resolved'
);
});

test('config takes precedence over autodetect when both would match', async () => {
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true);

const result = await resolver.resolveClaudeBinaryPath('/custom/config/claude');
expect(result).toBe('/custom/config/claude');
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: '/custom/config/claude', source: 'config' },
'claude.binary_resolved'
);
});

test('throws with install instructions when nothing is configured and autodetect misses', async () => {
// Every probe returns false — env unset, config unset, native path absent.
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false);

const promise = resolver.resolveClaudeBinaryPath();
Expand Down
26 changes: 24 additions & 2 deletions packages/providers/src/claude/binary-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
* Resolution order (binary mode only):
* 1. `CLAUDE_BIN_PATH` environment variable
* 2. `assistants.claude.claudeBinaryPath` in config
* 3. Throw with install instructions
* 3. Autodetect canonical install path (native installer default)
* 4. Throw with install instructions
*
* In dev mode (BUNDLED_IS_BINARY=false), returns undefined so the caller
* omits `pathToClaudeCodeExecutable` entirely and the SDK resolves via its
* normal node_modules lookup.
*/
import { existsSync as _existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { BUNDLED_IS_BINARY, createLogger } from '@archon/paths';

/** Wrapper for existsSync — enables spyOn in tests (direct imports can't be spied on). */
Expand Down Expand Up @@ -89,6 +92,25 @@ export async function resolveClaudeBinaryPath(
return configClaudeBinaryPath;
}

// 3. Not found — throw with install instructions
// 3. Autodetect — the Anthropic native installer
// (`curl -fsSL https://claude.ai/install.sh | bash` on macOS/Linux,
// `irm https://claude.ai/install.ps1 | iex` on Windows) writes the
// executable to a fixed location relative to $HOME. Users who follow
// the recommended install path don't need any env var or config entry;
// users who deviate (npm global, custom path, etc.) still set one of
// the higher-priority sources above.
const nativeInstallerPath =
process.platform === 'win32'
? join(homedir(), '.local', 'bin', 'claude.exe')
: join(homedir(), '.local', 'bin', 'claude');
if (fileExists(nativeInstallerPath)) {
getLog().info(
{ binaryPath: nativeInstallerPath, source: 'autodetect' },
'claude.binary_resolved'
);
return nativeInstallerPath;
}

// 4. Not found — throw with install instructions
throw new Error(INSTALL_INSTRUCTIONS);
}
63 changes: 63 additions & 0 deletions packages/providers/src/codex/binary-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,70 @@ describe('resolveCodexBinaryPath (binary mode)', () => {
expect(normalized).toContain('/tmp/test-archon-home/vendor/codex/');
});

test('autodetects npm global install at ~/.npm-global/bin/codex (POSIX)', async () => {
if (process.platform === 'win32') return; // POSIX-only probe
const home = process.env.HOME ?? '/Users/test';
const expected = `${home}/.npm-global/bin/codex`;
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation(
(path: string) => path === expected
);

const result = await resolver.resolveCodexBinaryPath();
expect(result).toBe(expected);
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: expected, source: 'autodetect' },
'codex.binary_resolved'
);
});

test('autodetects homebrew install on Apple Silicon', async () => {
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
// `/opt/homebrew/bin/codex` is only probed on darwin-arm64; on other
// hosts this test has nothing to assert (the probe list excludes it).
return;
}
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation(
(path: string) => path === '/opt/homebrew/bin/codex'
);

const result = await resolver.resolveCodexBinaryPath();
expect(result).toBe('/opt/homebrew/bin/codex');
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: '/opt/homebrew/bin/codex', source: 'autodetect' },
'codex.binary_resolved'
);
});

test('autodetects system install at /usr/local/bin/codex', async () => {
if (process.platform === 'win32') {
// /usr/local/bin is not probed on Windows.
return;
}
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation(
(path: string) => path === '/usr/local/bin/codex'
);

const result = await resolver.resolveCodexBinaryPath();
expect(result).toBe('/usr/local/bin/codex');
});

test('vendor directory takes precedence over autodetect', async () => {
// Both vendor and npm-global would match; vendor must win (lower tier #).
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation((path: string) => {
const normalized = path.replace(/\\/g, '/');
return normalized.includes('vendor/codex') || normalized.includes('.npm-global');
});

const result = await resolver.resolveCodexBinaryPath();
expect(result!.replace(/\\/g, '/')).toContain('/vendor/codex/');
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({ source: 'vendor' }),
'codex.binary_resolved'
);
});

test('throws with install instructions when binary not found anywhere', async () => {
// Env unset, config unset, vendor dir empty, every autodetect path missing.
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false);

await expect(resolver.resolveCodexBinaryPath()).rejects.toThrow('Codex CLI binary not found');
Expand Down
62 changes: 60 additions & 2 deletions packages/providers/src/codex/binary-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
* 1. `CODEX_BIN_PATH` environment variable
* 2. `assistants.codex.codexBinaryPath` in config
* 3. `~/.archon/vendor/codex/<platform-binary>` (user-placed)
* 4. Throw with install instructions
* 4. Autodetect canonical install paths (npm prefix defaults per platform)
* 5. Throw with install instructions
*
* In dev mode (BUNDLED_IS_BINARY=false), returns undefined so the SDK
* uses its normal node_modules-based resolution.
*/
import { existsSync as _existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { BUNDLED_IS_BINARY, getArchonHome, createLogger } from '@archon/paths';

Expand Down Expand Up @@ -89,7 +91,19 @@ export async function resolveCodexBinaryPath(
}
}

// 4. Not found — throw with install instructions
// 4. Autodetect — probe the handful of paths Codex typically lands at
// when installed via the documented package managers. Users who install
// somewhere else (custom npm prefix, etc.) still set one of the higher-
// priority sources above. Order: most specific → least specific.
const autodetectPaths = getAutodetectPaths();
for (const probePath of autodetectPaths) {
if (fileExists(probePath)) {
getLog().info({ binaryPath: probePath, source: 'autodetect' }, 'codex.binary_resolved');
return probePath;
}
}

// 5. Not found — throw with install instructions
const vendorPath = `~/.archon/${CODEX_VENDOR_DIR}/`;
throw new Error(
'Codex CLI binary not found. The Codex provider requires a native binary\n' +
Expand All @@ -105,3 +119,47 @@ export async function resolveCodexBinaryPath(
' codexBinaryPath: /path/to/codex\n'
);
}

/**
* Canonical install locations probed by tier 4 autodetect. Grounded in
* the official @openai/codex README and the npm global-install contract
* (npm writes the binary to `{npm_prefix}/bin/<name>` on POSIX and
* `{npm_prefix}\<name>.cmd` on Windows). The probes cover the npm prefix
* a default install lands at on each platform:
*
* - `$HOME/.npm-global/bin/codex` — common when the user ran
* `npm config set prefix ~/.npm-global` to avoid root writes
* - `/opt/homebrew/bin/codex` — mac Apple Silicon with homebrew-node
* (homebrew sets npm prefix to /opt/homebrew)
* - `/usr/local/bin/codex` — mac Intel with homebrew-node, or linux
* with system-installed node (npm prefix defaults to /usr/local)
* - `%AppData%\npm\codex.cmd` — Windows npm global default
*
* Not covered (explicit override required via CODEX_BIN_PATH or config):
* - users with other custom npm prefixes — `npm root -g` would spawn
* a subprocess per resolve, too heavy for a probe helper
* - Homebrew cask install (`brew install --cask codex`) — cask layout
* isn't a PATH binary; users should symlink or set the path
* - manual GitHub Releases extract — placement is user-determined
*/
function getAutodetectPaths(): string[] {
const paths: string[] = [];

if (process.platform === 'win32') {
const appData = process.env.APPDATA;
if (appData) paths.push(join(appData, 'npm', 'codex.cmd'));
paths.push(join(homedir(), '.npm-global', 'codex.cmd'));
return paths;
}

// POSIX (macOS + Linux)
paths.push(join(homedir(), '.npm-global', 'bin', 'codex'));

if (process.platform === 'darwin' && process.arch === 'arm64') {
paths.push('/opt/homebrew/bin/codex');
}

paths.push('/usr/local/bin/codex');

return paths;
}
Loading