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
105 changes: 103 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,108 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}

- name: Verify macOS artifacts
if: ${{ inputs.dry_run == false }}
run: |
set -euo pipefail

while IFS= read -r app_path; do
echo "Verifying signature for $app_path"
codesign --verify --deep --strict --verbose=2 "$app_path"
done < <(find dist-electron -maxdepth 2 -path '*/Deus.app' -type d | sort)

while IFS= read -r dmg_path; do
echo "Validating notarization for $dmg_path"
xcrun stapler validate "$dmg_path"
spctl --assess --type open --verbose=4 "$dmg_path"
done < <(find dist-electron -maxdepth 1 -name '*.dmg' -type f | sort)

- name: Smoke test packaged runtime from DMG copy
if: ${{ inputs.dry_run == false }}
run: |
set -euo pipefail

dmg_path="$(find dist-electron -maxdepth 1 -name '*arm64.dmg' -type f | head -n 1)"
if [[ -z "$dmg_path" ]]; then
dmg_path="$(find dist-electron -maxdepth 1 -name '*.dmg' -type f | head -n 1)"
fi

mount_dir="$(mktemp -d "${RUNNER_TEMP}/deus-dmg.XXXXXX")"
copied_root="$(mktemp -d "${RUNNER_TEMP}/deus-app.XXXXXX")"
copied_app="$copied_root/Deus.app"
smoke_log="$(mktemp "${RUNNER_TEMP}/deus-smoke.XXXXXX.log")"
agent_log="$(mktemp "${RUNNER_TEMP}/deus-agent.XXXXXX.log")"
smoke_db="$(mktemp "${RUNNER_TEMP}/deus-smoke.XXXXXX.db")"
attached=0
backend_pid=""
agent_pid=""

cleanup() {
if [[ -n "$backend_pid" ]]; then
kill "$backend_pid" 2>/dev/null || true
wait "$backend_pid" 2>/dev/null || true
fi
if [[ -n "$agent_pid" ]]; then
kill "$agent_pid" 2>/dev/null || true
wait "$agent_pid" 2>/dev/null || true
fi
if [[ "$attached" -eq 1 ]]; then
hdiutil detach "$mount_dir" -quiet || true
fi
}
trap cleanup EXIT

hdiutil attach "$dmg_path" -mountpoint "$mount_dir" -nobrowse
attached=1
ditto "$mount_dir/Deus.app" "$copied_app"
hdiutil detach "$mount_dir" -quiet
attached=0

app_bin="$copied_app/Contents/MacOS/Deus"
resources_dir="$copied_app/Contents/Resources"

ELECTRON_RUN_AS_NODE=1 \
DATABASE_PATH="$smoke_db" \
NODE_PATH="$resources_dir/app.asar/node_modules" \
"$app_bin" "$resources_dir/bin/index.bundled.cjs" >"$agent_log" 2>&1 &
agent_pid=$!

agent_url=""
for _ in {1..30}; do
if grep -q '^LISTEN_URL=' "$agent_log"; then
agent_url="$(grep '^LISTEN_URL=' "$agent_log" | head -n 1 | sed 's/^LISTEN_URL=//')"
break
fi
if ! kill -0 "$agent_pid" 2>/dev/null; then
break
fi
sleep 1
done

[[ -n "$agent_url" ]]

ELECTRON_RUN_AS_NODE=1 \
DATABASE_PATH="$smoke_db" \
AGENT_SERVER_URL="$agent_url" \
AUTH_TOKEN=smoke \
PORT=0 \
CDP_PORT=19222 \
NODE_PATH="$resources_dir/app.asar/node_modules" \
"$app_bin" "$resources_dir/backend/server.bundled.cjs" >"$smoke_log" 2>&1 &
backend_pid=$!

for _ in {1..30}; do
if grep -q '^\[BACKEND_PORT\]' "$smoke_log"; then
break
fi
if ! kill -0 "$backend_pid" 2>/dev/null; then
break
fi
sleep 1
done

grep -q '^\[BACKEND_PORT\]' "$smoke_log"

- uses: actions/upload-artifact@v4
with:
name: macos
Expand Down Expand Up @@ -288,8 +390,7 @@ jobs:

- name: Build CLI
run: |
bun run build:agent-server
bun run build:backend
bun run build:runtime
cd apps/cli && bun run build

- name: Publish to npm
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@

Deus runs in three modes depending on your setup:

### Desktop app
### Desktop app (macOS)

Download the macOS app from [GitHub Releases](https://github.com/zvadaadam/deus-machine/releases). Open it, point it at a repo, and start spinning up workspaces.
Download the macOS app from [GitHub Releases](https://github.com/zvadaadam/deus-machine/releases). Open the DMG, drag `Deus.app` into `Applications`, then launch it from `Applications`.

If Deus detects that it is running from a disk image, Downloads, or another transient location, it will ask to move itself into `Applications` before continuing.

Linux and Windows users: download the desktop package for your platform from the same Releases page.

### Headless server (CLI)

Expand Down
18 changes: 18 additions & 0 deletions apps/agent-server/agents/claude/claude-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Orchestrates the generator lifecycle, delegates to focused modules.

import { query as claudeSDK, type PermissionMode } from "@anthropic-ai/claude-agent-sdk";
import * as fs from "fs";
import { getErrorMessage } from "@shared/lib/errors";
import { createCheckpoint } from "./checkpoint";
import { AsyncQueue } from "../async-queue";
Expand Down Expand Up @@ -54,6 +55,18 @@ interface WorkspaceInitOptions {
* expected by the Claude Agent SDK. Handles both plain text and JSON-encoded
* content blocks (when user attaches images).
*/
function getInvalidWorkspacePathError(cwd: string | undefined): string | null {
if (!cwd) {
return "Workspace path is missing for this Claude session.";
}

if (!fs.existsSync(cwd)) {
return `Workspace path does not exist: ${cwd}. This workspace likely points to a deleted or transient folder. Remove and recreate it.`;
}

return null;
}

function buildPromptIterable(queue: AsyncQueue<string>, sessionId: string) {
return (async function* () {
for await (const message of queue) {
Expand Down Expand Up @@ -421,6 +434,11 @@ export class ClaudeAgentHandler implements AgentHandler {
const ctx = createStreamContext();

try {
const invalidWorkspacePathError = getInvalidWorkspacePathError(options.cwd);
if (invalidWorkspacePathError) {
throw new Error(invalidWorkspacePathError);
}

// Build environment using shared env-builder
const tEnvStart = Date.now();
const envForClaude = buildAgentEnvironment({
Expand Down
18 changes: 18 additions & 0 deletions apps/agent-server/test/claude-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "fs";

// ============================================================================
// Mock setup — must come before importing the module under test
Expand Down Expand Up @@ -169,6 +170,23 @@ describe("claude-handler", () => {
);
});

it("sends a clear error when the workspace path is missing", async () => {
vi.mocked(fs.existsSync).mockImplementation((value: fs.PathLike) => value !== "/missing");

await handler.query("sess-missing-cwd", "hello", { cwd: "/missing", turnId: "turn-1" });

await new Promise((r) => setTimeout(r, 50));

expect(mockClaudeSDK).not.toHaveBeenCalled();
expect(mockFrontendAPI.sendError).toHaveBeenCalledWith(
expect.objectContaining({
id: "sess-missing-cwd",
type: "error",
error: expect.stringContaining("Workspace path does not exist: /missing"),
})
);
});

it("creates a new generator for a new session", async () => {
// Mock SDK to return an async iterable that yields one message then completes
const mockMessages = [{ type: "assistant", message: { role: "assistant", content: "Hi" } }];
Expand Down
6 changes: 2 additions & 4 deletions apps/backend/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,14 @@ build({
format: "cjs",
outfile: path.join(backendDir, "dist/server.bundled.cjs"),
external: [
// Native modules — must be resolved at runtime
// Native modules — must be resolved at runtime (compiled against Electron's ABI)
"better-sqlite3",
"node-pty",
// WebSocket library with optional native extensions
"ws",
// Sentry — optional, loaded at runtime if DSN is configured
// Sentry — uses native crash-reporter hooks, must match runtime
"@sentry/node",
],
// Mark all Node.js built-ins as external
packages: "external",
minify: false,
sourcemap: false,
logLevel: "info",
Expand Down
11 changes: 7 additions & 4 deletions apps/backend/src/lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import os from "os";
import { resolveDefaultDatabasePath } from "../../../../shared/runtime";
import { SCHEMA_SQL, MIGRATIONS } from "@shared/schema";

const DEFAULT_DB_PATH = path.join(
process.env.HOME || os.homedir(),
"Library/Application Support/com.deus.app/deus.db"
);
const DEFAULT_DB_PATH = resolveDefaultDatabasePath({
platform: process.platform,
homeDir: process.env.HOME || os.homedir(),
appData: process.env.APPDATA,
xdgDataHome: process.env.XDG_DATA_HOME,
});

const DB_PATH = process.env.DATABASE_PATH || DEFAULT_DB_PATH;

Expand Down
95 changes: 2 additions & 93 deletions apps/backend/src/routes/onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,101 +1,10 @@
import { Hono } from "hono";
import Database from "better-sqlite3";
import { existsSync, readdirSync } from "fs";
import { homedir } from "os";
import { join, basename } from "path";
import type { RecentProject } from "@shared/types/onboarding";
import { listRecentProjects } from "../services/recent-projects.service";

const app = new Hono();

app.get("/onboarding/recent-projects", (c) => {
const home = homedir();
const projects: RecentProject[] = [];
const seenPaths = new Set<string>();

// Read from Cursor state.vscdb
const cursorDbPath = join(
home,
"Library/Application Support/Cursor/User/globalStorage/state.vscdb"
);
readVscdbProjects(cursorDbPath, "cursor", projects, seenPaths);

// Read from VSCode state.vscdb
const vscodeDbPath = join(
home,
"Library/Application Support/Code/User/globalStorage/state.vscdb"
);
readVscdbProjects(vscodeDbPath, "vscode", projects, seenPaths);

// Read from Claude projects directory
readClaudeProjects(join(home, ".claude/projects"), projects, seenPaths);

return c.json({ projects: projects.slice(0, 30) });
return c.json({ projects: listRecentProjects() });
});

// Worktree directories created by AI coding tools — filter these from recent projects
// so only root repos are shown, not individual worktree checkouts.
const WORKTREE_SEGMENTS = [
"/.deus/", // Deus worktrees
"/.conductor/", // OpenDevs worktrees
"/.claude/worktrees/", // Claude Code worktrees
"/.cursor/worktrees/", // Cursor parallel agent worktrees
"/copilot-worktree/", // GitHub Copilot CLI worktrees
];

function isWorktreePath(fsPath: string): boolean {
return WORKTREE_SEGMENTS.some((seg) => fsPath.includes(seg));
}

function readVscdbProjects(
dbPath: string,
source: "cursor" | "vscode",
projects: RecentProject[],
seen: Set<string>
) {
if (!existsSync(dbPath)) return;
let db: InstanceType<typeof Database> | undefined;
try {
db = new Database(dbPath, { readonly: true });
const row = db
.prepare("SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'")
.get() as { value: string } | undefined;

if (!row?.value) return;
const data = JSON.parse(row.value);
const entries = data.entries || [];

for (const entry of entries) {
const uri = entry.folderUri;
if (!uri || !uri.startsWith("file://")) continue;
const fsPath = decodeURIComponent(uri.replace("file://", ""));
if (seen.has(fsPath) || isWorktreePath(fsPath) || !existsSync(fsPath)) continue;
seen.add(fsPath);
projects.push({ path: fsPath, name: basename(fsPath), source });
}
} catch {
// Silently skip if DB is locked or malformed
} finally {
db?.close();
}
}

function readClaudeProjects(dir: string, projects: RecentProject[], seen: Set<string>) {
if (!existsSync(dir)) return;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Claude encodes paths: leading dash + dashes as path separators
// e.g., "-Users-zvada-Developer-myproject" -> "/Users/zvada/Developer/myproject"
const decoded = entry.name.replace(/-/g, "/");
if (!decoded.startsWith("/")) continue;
if (seen.has(decoded) || isWorktreePath(decoded) || !existsSync(decoded)) continue;
seen.add(decoded);
projects.push({ path: decoded, name: basename(decoded), source: "claude" });
}
} catch {
// Silently skip
}
}

export default app;
Loading
Loading