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/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r backend/requirements.txt -r mcp_server/requirements.txt pytest httpx
python -m pip install -r backend/requirements-build.txt -r mcp_server/requirements.txt pytest httpx

- name: Run backend tests
run: python -m pytest
Expand All @@ -54,5 +54,11 @@ jobs:
working-directory: client
run: npm run build

- name: Build and smoke-test bundled backend
working-directory: client
run: |
npm run build:backend-runtime
npm run test:backend-runtime

- name: Run permanent regression suite
run: python scripts/run_regression_checks.py
9 changes: 9 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ jobs:
cache: npm
cache-dependency-path: client/package-lock.json

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip

- name: Install backend bundling dependencies
run: python -m pip install -r backend/requirements-build.txt

- name: Install client dependencies
working-directory: client
run: npm ci
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules/
client/dist/
client/dist-electron/
client/release/
client/backend-runtime/

# Local workspace state and generated recall caches can contain private paths,
# session titles, backend ports, and resume commands.
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ If your preferred Python is not `python3`, set:
export CONTEXT_WORKSPACE_PYTHON=/absolute/path/to/python
```

The Electron app uses this value when spawning the FastAPI backend.
Development builds use this value when spawning the FastAPI backend. Packaged
desktop releases include a self-contained backend runtime and do not require
FastAPI, Uvicorn, or Python to be installed on the host. Setting
`CONTEXT_WORKSPACE_PYTHON` explicitly overrides that bundled runtime.

## Running The Desktop App

Expand All @@ -211,10 +214,15 @@ npm run build
To build an AppImage on Linux:

```bash
python3 -m pip install -r backend/requirements-build.txt
cd client
npm run dist
```

`npm run dist` builds and smoke-tests the bundled backend before packaging the
desktop artifact. The same process runs natively for the Windows and macOS
release jobs.

To launch a previously built Electron app:

```bash
Expand Down Expand Up @@ -422,6 +430,10 @@ lets Hermes *drive* Athena (spawn terminals, read sessions, write recall).
## Hermes MCP Bridge

Athena includes an MCP server under `mcp_server/` so Hermes can call into the running desktop workspace.
Packaged desktop builds automatically launch this bridge from Athena's bundled
runtime for Codex, Claude, OpenCode, and Athena Code, so those agents do not
need Python or MCP dependencies installed on the host. The manual setup below
is only for a separately installed Hermes process that needs to drive Athena.

Install the MCP server dependencies into the Python environment Hermes will use:

Expand Down Expand Up @@ -557,7 +569,10 @@ bundle and hand the agent a bootstrap prompt that points at the bundle file.

### Backend does not start

Check that backend dependencies are installed and that Electron is using the expected Python:
Packaged releases use Athena's bundled backend. Check
`~/.context-workspace/backend.json` for the captured startup error. For local
development, verify that backend dependencies are installed and Electron is
using the expected Python:

```bash
export CONTEXT_WORKSPACE_PYTHON=/path/to/python
Expand Down
58 changes: 58 additions & 0 deletions backend/launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Entry point for Athena's self-contained desktop backend runtime."""

from __future__ import annotations

import argparse
import os
import runpy
from collections.abc import Sequence

import uvicorn

from backend.app import app


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the Athena desktop backend.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("CONTEXT_WORKSPACE_BACKEND_PORT", "8000")),
)
parser.add_argument("--no-access-log", action="store_true")
parser.add_argument(
"--mcp-server",
action="store_true",
help="Run Athena's bundled stdio MCP server instead of the HTTP server.",
)
parser.add_argument(
"--refresh-recall-script",
metavar="PATH",
help="Run Athena's bundled recall refresh script instead of the HTTP server.",
)
return parser


def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.mcp_server:
from mcp_server.server import main as run_mcp_server

run_mcp_server()
return 0
if args.refresh_recall_script:
runpy.run_path(args.refresh_recall_script, run_name="__main__")
return 0

uvicorn.run(
app,
host=args.host,
port=args.port,
access_log=not args.no_access_log,
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
3 changes: 3 additions & 0 deletions backend/requirements-build.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
-r ../mcp_server/requirements.txt
pyinstaller==6.21.0
4 changes: 4 additions & 0 deletions client/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ files:
- build/icon.png
- package.json
extraResources:
- from: backend-runtime/athena-backend
to: backend-runtime/athena-backend
filter:
- "**/*"
- from: ../backend
to: backend
filter:
Expand Down
40 changes: 30 additions & 10 deletions client/electron/agent-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
// - opencode / athena-code -> the `OPENCODE_CONFIG_CONTENT` env var, which is
// deep-merged over the user's config so their providers/auth stay intact.
//
// All three point at the same stdio server (`python3 <server.py>`) and pass the
// backend/control URLs through the environment the server reads on startup.
// All three point at the same stdio server command and pass the backend/control
// URLs through the environment the server reads on startup.

import path from "node:path";

export const MCP_SERVER_NAME = "context_workspace";

Expand All @@ -23,6 +25,24 @@ export type AgentMcpLaunch = {
codexConfigArgs?: readonly string[] | null;
};

export type McpServerCommand = {
command: string;
args: string[];
};

export function bundledMcpServerCommand(
appRoot: string,
platform: NodeJS.Platform = process.platform,
): McpServerCommand | null {
if (!appRoot.includes(".asar")) return null;
const pathApi = platform === "win32" ? path.win32 : path.posix;
const executable = platform === "win32" ? "athena-backend.exe" : "athena-backend";
return {
command: pathApi.join(pathApi.dirname(appRoot), "backend-runtime", "athena-backend", executable),
args: ["--mcp-server"],
};
}

export type ClaudeMcpConfig = {
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
};
Expand All @@ -34,19 +54,19 @@ function mcpServerEnv(backendUrl: string, controlUrl: string): Record<string, st
};
}

export function buildClaudeMcpConfig(serverPath: string, backendUrl: string, controlUrl: string): ClaudeMcpConfig {
export function buildClaudeMcpConfig(server: McpServerCommand, backendUrl: string, controlUrl: string): ClaudeMcpConfig {
return {
mcpServers: {
[MCP_SERVER_NAME]: {
command: "python3",
args: [serverPath],
command: server.command,
args: server.args,
env: mcpServerEnv(backendUrl, controlUrl),
},
},
};
}

export function buildCodexMcpConfigArgs(serverPath: string, backendUrl: string, controlUrl: string): string[] {
export function buildCodexMcpConfigArgs(server: McpServerCommand, backendUrl: string, controlUrl: string): string[] {
// Codex parses the value after each `=` as TOML, falling back to a literal
// string. Basic (double-quoted) TOML strings share JSON's escaping rules for
// the paths and URLs used here, so JSON.stringify produces a valid TOML value.
Expand All @@ -57,21 +77,21 @@ export function buildCodexMcpConfigArgs(serverPath: string, backendUrl: string,
.map(([name, value]) => `${name}=${toml(value)}`)
.join(",");
return [
`${key}.command=${toml("python3")}`,
`${key}.args=[${toml(serverPath)}]`,
`${key}.command=${toml(server.command)}`,
`${key}.args=[${server.args.map(toml).join(",")}]`,
`${key}.env={${envTable}}`,
];
}

export function buildOpenCodeMcpConfigContent(serverPath: string, backendUrl: string, controlUrl: string): string {
export function buildOpenCodeMcpConfigContent(server: McpServerCommand, backendUrl: string, controlUrl: string): string {
// opencode (and the athena-code fork) deep-merge OPENCODE_CONFIG_CONTENT over
// the resolved config, so injecting only the `mcp` block leaves the user's
// providers, agents, and auth untouched.
return JSON.stringify({
mcp: {
[MCP_SERVER_NAME]: {
type: "local",
command: ["python3", serverPath],
command: [server.command, ...server.args],
enabled: true,
environment: mcpServerEnv(backendUrl, controlUrl),
},
Expand Down
66 changes: 56 additions & 10 deletions client/electron/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export type BackendState = {
lastError: string | null;
};

export type BackendLaunch = {
command: string;
args: string[];
bundled: boolean;
};

const BACKEND_STDERR_LIMIT = 16_384;

let backendProcess: ChildProcessWithoutNullStreams | null = null;
let startedAt: string | null = null;
let state: BackendState = {
Expand All @@ -35,14 +43,14 @@ export async function startBackend(appRoot: string): Promise<BackendState> {

const port = await findFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const python = defaultPythonExecutable();
const launch = resolveBackendLaunch(appRoot, port);
const backendParent = resolveBackendParent(appRoot);
const hermesRefreshCommand = process.env.CONTEXT_WORKSPACE_HERMES_REFRESH_CMD?.trim()
|| defaultHermesRefreshCommand(appRoot, python);
|| defaultHermesRefreshCommand(appRoot, launch);

backendProcess = spawn(
python,
["-m", "uvicorn", "backend.app:app", "--host", "127.0.0.1", "--port", String(port), "--no-access-log"],
launch.command,
launch.args,
{
cwd: backendParent,
// POSIX group ownership lets shutdown signal uvicorn and every child it
Expand All @@ -52,7 +60,9 @@ export async function startBackend(appRoot: string): Promise<BackendState> {
...process.env,
CONTEXT_WORKSPACE_BACKEND_PORT: String(port),
CONTEXT_WORKSPACE_HERMES_REFRESH_CMD: hermesRefreshCommand,
PYTHONPATH: mergePythonPath(backendParent, process.env.PYTHONPATH),
// Frozen runtimes already contain the backend package and dependencies.
// Keep host modules from shadowing that tested bundle at runtime.
PYTHONPATH: launch.bundled ? "" : mergePythonPath(backendParent, process.env.PYTHONPATH),
},
windowsHide: true,
},
Expand All @@ -72,8 +82,11 @@ export async function startBackend(appRoot: string): Promise<BackendState> {
// Drain stdout so a verbose backend cannot block on pipe backpressure.
});

let stderrTail = "";
backendProcess.stderr.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8").trim();
const rawText = chunk.toString("utf8");
stderrTail = `${stderrTail}${rawText}`.slice(-BACKEND_STDERR_LIMIT);
const text = rawText.trim();
for (const line of text.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean)) {
if (isBackendErrorLine(line)) {
state = { ...state, lastError: line };
Expand All @@ -87,18 +100,19 @@ export async function startBackend(appRoot: string): Promise<BackendState> {
...state,
healthy: false,
running: false,
lastError: `Backend failed to start with ${python}: ${error.message}`,
lastError: `Backend failed to start with ${launch.command}: ${error.message}`,
};
writeBackendDiscovery();
if (backendProcess === launchedProcess) backendProcess = null;
});

backendProcess.on("exit", (code, signal) => {
const exitSummary = `Backend exited: ${code ?? signal ?? "unknown"}`;
state = {
...state,
healthy: false,
running: false,
lastError: `Backend exited: ${code ?? signal ?? "unknown"}`,
lastError: formatBackendExitError(exitSummary, stderrTail),
};
writeBackendDiscovery();
if (backendProcess === launchedProcess) backendProcess = null;
Expand Down Expand Up @@ -258,13 +272,40 @@ function resolveBackendParent(appRoot: string): string {
return path.resolve(appRoot, "..");
}

export function resolveBackendLaunch(appRoot: string, port: number): BackendLaunch {
const configuredPython = process.env.CONTEXT_WORKSPACE_PYTHON?.trim();
if (configuredPython) {
return pythonBackendLaunch(configuredPython, port);
}
if (appRoot.includes(".asar")) {
const executable = process.platform === "win32" ? "athena-backend.exe" : "athena-backend";
return {
command: path.join(path.dirname(appRoot), "backend-runtime", "athena-backend", executable),
args: ["--host", "127.0.0.1", "--port", String(port), "--no-access-log"],
bundled: true,
};
}
return pythonBackendLaunch(defaultPythonExecutable(), port);
}

function pythonBackendLaunch(python: string, port: number): BackendLaunch {
return {
command: python,
args: ["-m", "uvicorn", "backend.app:app", "--host", "127.0.0.1", "--port", String(port), "--no-access-log"],
bundled: false,
};
}

function mergePythonPath(backendParent: string, existing: string | undefined): string {
return existing ? `${backendParent}${path.delimiter}${existing}` : backendParent;
}

function defaultHermesRefreshCommand(appRoot: string, python: string): string {
export function defaultHermesRefreshCommand(appRoot: string, launch: BackendLaunch): string {
const scriptPath = resolveRefreshScriptPath(appRoot);
return `${quoteCommandArg(python)} ${quoteCommandArg(scriptPath)}`;
const command = quoteCommandArg(launch.command);
return launch.bundled
? `${command} --refresh-recall-script ${quoteCommandArg(scriptPath)}`
: `${command} ${quoteCommandArg(scriptPath)}`;
}

function resolveRefreshScriptPath(appRoot: string): string {
Expand Down Expand Up @@ -297,6 +338,11 @@ function isBackendErrorLine(line: string): boolean {
return /^(ERROR|CRITICAL):/.test(line) || line.startsWith("Traceback ");
}

export function formatBackendExitError(exitSummary: string, stderr: string): string {
const detail = stderr.trim();
return detail ? `${exitSummary}\n${detail}` : exitSummary;
}

function writeBackendDiscovery(): void {
try {
const directory = path.join(os.homedir(), ".context-workspace");
Expand Down
Loading
Loading