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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ The `#/` alias is context-sensitive: a custom Vite plugin (`createPathAliasPlugi
- Secrets: use `.env` (see `.env.example`); never commit keys.
- Toolchains: Bun 1.4.0. Windows: enable Developer Mode for symlinks.
- Build: Vite 8 with Rolldown; `vite-plugin-electron` multi-env for main/preload/renderer.
- Runtimes: bundled Bun, ripgrep, uv, rtk in `runtime/` — installed via `bun run installRuntime`.
- Runtimes: uv and ripgrep seeds in `runtime/` — installed via `bun run installRuntime`. Node, uv, and ripgrep resolve at runtime through the daemon's managed toolchain service (see `docs/features/managed-toolchains`).

## Specification-Driven Development

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Argos is a Turborepo monorepo. The desktop app is an Electron **shell** that loa
- `packages/shared/` (`@argos/shared`): Shared types and utilities (web-safe).
- `packages/backend-core/`, `packages/{acp,mcp,skills,memory,remote-control}-runtime/`, `packages/agent-runtime/`, `packages/pi-orchestrator-extension/`: Shared backend logic and host-port-injected runtimes.
- `apps/landing/`: Marketing site + GitHub OAuth relay (Cloudflare Worker).
- `runtime/`: Bundled runtimes used by MCP and agent tooling (Bun/uv/ripgrep/rtk) — installed via `bun run installRuntime`.
- `runtime/`: Bundled runtime seeds used by MCP and agent tooling (uv/ripgrep) - installed via `bun run installRuntime`. Node/uv/ripgrep used by the daemon resolve through the managed toolchain service (`apps/daemon/src/host/toolchains/`).
- `scripts/`, `resources/`, `build/`: Build, packaging, and asset pipelines.
- `dist/`, `out/`: Build outputs (do not edit manually).
- `docs/`: Design docs, guides, and the SDD spec/plan/task records.
Expand Down
47 changes: 47 additions & 0 deletions apps/daemon/src/dispatch/daemonDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { resolveDaemonVersion } from "../version";
import type { DaemonTerminalRuntime } from "../terminal/daemonTerminalRuntime";
import { diagnoseDaemonSchema, repairDaemonSchema } from "../host/daemonSchemaDiagnostics";
import { settleSessionForOwnershipChange, type SettleSessionHost } from "../host/sessionSettlement";
import type { ToolchainService } from "../host/toolchains/service";
import { getPiToolDefinitions } from "../host/piToolCatalog";
import { aggregateUsageStats, resolveBuiltinModelPrice } from "../host/usageStatsAggregator";
import { resolveModelCost } from "../host/modelCost";
Expand All @@ -48,6 +49,11 @@ import {
onboardingSetStepStatusRoute,
onboardingCompleteRoute,
onboardingResetRoute,
toolchainsListRoute,
toolchainsSetSourceRoute,
toolchainsRemoveSourceRoute,
toolchainsInstallRoute,
toolchainsCancelInstallRoute,
settingsGetSnapshotRoute,
settingsUpdateRoute,
settingsActivityListRoute,
Expand Down Expand Up @@ -935,6 +941,7 @@ export function createDaemonDispatcher(
},
knowledgeRuntime?: DaemonKnowledgeRuntimePort,
terminalRuntime?: DaemonTerminalRuntime,
toolchains?: ToolchainService,
): RouteDispatcher {
const settingsHandler = new SettingsRouteHandler(createSettingsRouteAdapter(configPresenter));
const runtime: {
Expand Down Expand Up @@ -2061,6 +2068,46 @@ export function createDaemonDispatcher(
return settingsListSystemFontsRoute.output.parse({ fonts: [] });
}

if (route === toolchainsListRoute.name) {
if (!toolchains) throw new Error("Toolchain service is not available in this runtime.");
toolchainsListRoute.input.parse(rawInput);
return toolchainsListRoute.output.parse({ tools: await toolchains.list() });
}

if (route === toolchainsSetSourceRoute.name) {
if (!toolchains) throw new Error("Toolchain service is not available in this runtime.");
const input = toolchainsSetSourceRoute.input.parse(rawInput);
return toolchainsSetSourceRoute.output.parse({
status: await toolchains.setSource(input.tool, input.source, input.path),
});
}

if (route === toolchainsRemoveSourceRoute.name) {
if (!toolchains) throw new Error("Toolchain service is not available in this runtime.");
const input = toolchainsRemoveSourceRoute.input.parse(rawInput);
return toolchainsRemoveSourceRoute.output.parse({ status: await toolchains.removeSource(input.tool) });
}

if (route === toolchainsInstallRoute.name) {
if (!toolchains) throw new Error("Toolchain service is not available in this runtime.");
const input = toolchainsInstallRoute.input.parse(rawInput);
const started = toolchains.install(input.tool);
return toolchainsInstallRoute.output.parse({
started: started.started,
status: await toolchains.status(input.tool),
});
}

if (route === toolchainsCancelInstallRoute.name) {
if (!toolchains) throw new Error("Toolchain service is not available in this runtime.");
const input = toolchainsCancelInstallRoute.input.parse(rawInput);
toolchains.cancelInstall(input.tool);
return toolchainsCancelInstallRoute.output.parse({
cancelled: true,
status: await toolchains.status(input.tool),
});
}

if (isDesktopOnlyRoute(route)) {
// Routes that are truly desktop-only (open windows, file dialogs) throw.
throw new Error(`Route not available in headless mode: ${route}`);
Expand Down
7 changes: 5 additions & 2 deletions apps/daemon/src/host/acp-provider-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { BunSessionRepository } from "./bun-session-repository";
import { usageDateKey } from "./bun-session-repository";
import { createDaemonAcpPorts } from "./acpPorts";
import { createDaemonAcpSqlitePresenter } from "./daemonAcpSqlite";
import type { ToolchainService } from "./toolchains/service";
import { sessionsStatusChangedEvent } from "@argos/shared-contracts";
import { methods as acpMethods, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import type { AcpConfigState, AcpAgentDiagnostics, AcpDebugRequest, AcpDebugRunResult } from "@argos/shared/presenter";
Expand All @@ -51,8 +52,8 @@ type PendingAcpPermission = {
* clients through the daemon `BunEventPublisher`.
*
* Sessions persist to the daemon's SQLite `acp_sessions` table (resume across
* daemon restarts). The daemon resolves agent runtimes from `$PATH` (no bundled
* runtime).
* daemon restarts). Agent runtimes (`npx`/`uvx`/`node`) resolve through the
* managed toolchain service, falling through to `$PATH` when unconfigured.
*/
export class AcpProviderExecutionPort implements ProviderExecutionPort {
private runtimePromise: Promise<AcpRuntime> | null = null;
Expand Down Expand Up @@ -84,6 +85,7 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {
private readonly deps: {
dataDir: string;
appVersion: string;
toolchains: ToolchainService;
db: {
prepare(sql: string): {
get(...p: unknown[]): unknown;
Expand All @@ -101,6 +103,7 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {
dataDir: this.deps.dataDir,
appVersion: this.deps.appVersion,
eventPublisher: this.eventPublisher,
toolchains: this.deps.toolchains,
});
const sessionPersistence = new AcpSessionPersistence(createDaemonAcpSqlitePresenter(this.deps.db), () =>
ports.paths.homeDir(),
Expand Down
31 changes: 25 additions & 6 deletions apps/daemon/src/host/acpPorts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ import path from "node:path";
import type { AcpHostPorts } from "@argos/acp-runtime";
import type { IEventPublisher } from "@argos/backend-core";
import { shouldRejectAcpTextRead, buildBinaryReadGuidance } from "./acpBinaryGuard";

import type { ToolchainService } from "./toolchains/service";
/**
* Daemon implementation of the ACP host ports. Resolves paths from the OS and
* daemon data dir, uses a no-op runtime (agents resolve `npx`/`uvx`/`node` from
* `$PATH`), bridges events to the daemon `IEventPublisher`, and wires lifecycle
* to process signals.
* daemon data dir, resolves `npx`/`uvx`/`node`/`uv` through the managed
* toolchain service (falling through to `$PATH` when unconfigured), bridges
* events to the daemon `IEventPublisher`, and wires lifecycle to process
* signals.
*/
export function createDaemonAcpPorts(deps: {
dataDir: string;
appVersion: string;
eventPublisher: IEventPublisher;
toolchains: ToolchainService;
}): AcpHostPorts {
return {
paths: {
Expand All @@ -23,10 +25,27 @@ export function createDaemonAcpPorts(deps: {
appVersion: () => deps.appVersion,
},
runtime: {
// v1 daemon ships no bundled runtime; agents use $PATH-resolved tools.
expandPath: (target) => target,
resolveCommand: (command) => command,
buildSpawnEnv: (base) => base,
resolveCommandWithArgs: async ({ command, args }) => {
const resolved = await deps.toolchains.resolveCommand(command, args);
if (resolved.command === command) {
return null;
}
return resolved;
},
buildSpawnEnv: (base) => {
const dirs = deps.toolchains.binDirsSync();
if (dirs.length === 0) {
return base;
}
const existingKey = Object.keys(base).find((key) => key.toLowerCase() === "path");
const key = existingKey ?? (process.platform === "win32" ? "Path" : "PATH");
return {
...base,
[key]: [...dirs, base[key] ?? ""].filter(Boolean).join(path.delimiter),
};
},
},
events: {
broadcast: (name, payload) => deps.eventPublisher.publish(name, payload),
Expand Down
16 changes: 12 additions & 4 deletions apps/daemon/src/host/daemonMcpPorts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { homedir } from "node:os";
import { delimiter } from "node:path";
import { createJsonStoreFactory } from "./jsonStoreFactory";
import {
ArtifactsServer,
Expand All @@ -15,6 +16,7 @@ import {
import { BuiltinKnowledgeServer } from "@argos/backend-core";
import type { IEventPublisher } from "@argos/backend-core";
import type { DaemonConfigPresenter } from "./daemonConfigPresenter";
import type { ToolchainService } from "./toolchains/service";
import type { PluginToolPolicyDecision } from "@argos/shared/types/plugin";

/** Knowledge capabilities exposed by the daemon knowledge runtime. */
Expand Down Expand Up @@ -44,6 +46,7 @@ export function createDaemonMcpPorts(deps: {
eventPublisher: IEventPublisher;
configPresenter: DaemonConfigPresenter;
configDir: string;
toolchains?: ToolchainService;
knowledge?: DaemonKnowledgePort;
db: {
prepare(sql: string): {
Expand Down Expand Up @@ -93,11 +96,16 @@ export function createDaemonMcpPorts(deps: {
runtime: {
initializeRuntimes: () => {},
expandPath: (target) => target,
processCommandWithArgs: (command, args) => ({ command, args }),
normalizePathEnv: (paths) => ({ key: "PATH", value: paths.join(":") }),
getDefaultPaths: () => [],
processCommandWithArgs: (command, args) =>
deps.toolchains ? deps.toolchains.resolveCommandSync(command, args) : { command, args },
/** Coalesce concurrent identical Ollama lookups into one upstream request. */
normalizePathEnv: (paths: string[]) => ({
key: process.platform === "win32" ? "Path" : "PATH",
value: paths.join(delimiter),
}),
getDefaultPaths: () => deps.toolchains?.binDirsSync() ?? [],
getBunRuntimePath: () => null,
getUvRuntimePath: () => null,
getUvRuntimePath: () => deps.toolchains?.binDirForToolSync("uv") ?? null,
setBunRuntimePath: () => {},
setUvRuntimePath: () => {},
},
Expand Down
106 changes: 106 additions & 0 deletions apps/daemon/src/host/toolchains/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { ToolchainArchive } from "./types";

/**
* Managed-install catalog. Pins carry real SHA-256 digests captured from the
* official release artifacts:
* - Node from https://nodejs.org/dist/<pin>/SHASUMS256.txt
* - uv from the GitHub release artifacts for the pin.
*
* uv archive filenames do not embed the version, so a pin bump without fresh
* hashes would pass compile-time checks and fail at first install — the
* catalog tests therefore assert every pin has complete non-empty hashes.
Comment on lines +9 to +11
* ripgrep has no managed pin: the bundled seed plus system installs cover it.
*/

export const NODE_PIN = "v24.18.0";
export const UV_PIN = "0.9.18";

export const NODE_DIST_BASE = "https://nodejs.org/dist";
export const UV_RELEASE_BASE = "https://github.com/astral-sh/uv/releases/download";

type PlatformKey = string;

const NODE_ARCHIVES: Record<string, Record<PlatformKey, ToolchainArchive>> = {
[NODE_PIN]: {
"win32-x64": {
filename: `node-${NODE_PIN}-win-x64.zip`,
url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-win-x64.zip`,
sha256: "0ae68406b42d7725661da979b1403ec9926da205c6770827f33aac9d8f26e821",
},
"win32-arm64": {
filename: `node-${NODE_PIN}-win-arm64.zip`,
url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-win-arm64.zip`,
sha256: "f274669adb93b1fd0fbf8f21fd078609e9dcc84333d4f2718d2dde3f9a161a01",
},
"darwin-arm64": {
filename: `node-${NODE_PIN}-darwin-arm64.tar.gz`,
url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-darwin-arm64.tar.gz`,
sha256: "e1a97e14c99c803e96c7339403282ea05a499c32f8d83defe9ef5ec66f979ed1",
},
"darwin-x64": {
filename: `node-${NODE_PIN}-darwin-x64.tar.gz`,
url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-darwin-x64.tar.gz`,
sha256: "dfd0dbd3e721503434df7b7205e719f61b3a3a31b2bcf9729b8b91fea240f080",
},
"linux-x64": {
filename: `node-${NODE_PIN}-linux-x64.tar.gz`,
url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-linux-x64.tar.gz`,
sha256: "783130984963db7ba9cbd01089eaf2c2efb055c7c1693c943174b967b3050cb8",
},
"linux-arm64": {
filename: `node-${NODE_PIN}-linux-arm64.tar.gz`,
url: `${NODE_DIST_BASE}/${NODE_PIN}/node-${NODE_PIN}-linux-arm64.tar.gz`,
sha256: "6b4484c2190274175df9aa8f28e2d758a819cb1c1fe6ab481e2f95b463ab8508",
},
},
};

const UV_ARCHIVES: Record<string, Record<PlatformKey, ToolchainArchive>> = {
[UV_PIN]: {
"win32-x64": {
filename: "uv-x86_64-pc-windows-msvc.zip",
url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-x86_64-pc-windows-msvc.zip`,
sha256: "28cbe5d30907a774bfe27a517a39b494ec6f7d3816bda8bbf6f9645490449182",
},
"win32-arm64": {
filename: "uv-aarch64-pc-windows-msvc.zip",
url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-aarch64-pc-windows-msvc.zip`,
sha256: "fadb43ba13091f44e1786fc3967e65c7786d86192aa205d718307c649927cfc2",
},
"darwin-arm64": {
filename: "uv-aarch64-apple-darwin.tar.gz",
url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-aarch64-apple-darwin.tar.gz`,
sha256: "dc3bee4abbb3bac267a3985a23ea7617d19d41ff381dbaf560ba415ad65af68f",
},
Comment thread
Copilot marked this conversation as resolved.
"darwin-x64": {
filename: "uv-x86_64-apple-darwin.tar.gz",
url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-x86_64-apple-darwin.tar.gz`,
sha256: "f86836c637333c65bbc7902acc9c49888eef9fbd15dccbc1946b10e30b041073",
},
"linux-x64": {
filename: "uv-x86_64-unknown-linux-gnu.tar.gz",
url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-x86_64-unknown-linux-gnu.tar.gz`,
sha256: "c2def3db178ade63933fa15ffc96e882c196ce53e06173dcee05b36c5f6f68f5",
},
"linux-arm64": {
filename: "uv-aarch64-unknown-linux-gnu.tar.gz",
url: `${UV_RELEASE_BASE}/${UV_PIN}/uv-aarch64-unknown-linux-gnu.tar.gz`,
sha256: "f8e23ec786b18660ade6b033b6191b7e9c283c872eeb8c4531d56a873decf160",
},
},
};

function platformArchKey(): PlatformKey {
return `${process.platform}-${process.arch}`;
}

/** Managed archive for the current platform, or null when the tool has no pin. */
export function archiveFor(tool: "node" | "uv"): ToolchainArchive | null {
const key = platformArchKey();
const table = tool === "node" ? NODE_ARCHIVES[NODE_PIN] : UV_ARCHIVES[UV_PIN];
return table?.[key] ?? null;
}

export function pinFor(tool: "node" | "uv"): string {
return tool === "node" ? NODE_PIN : UV_PIN;
}
Loading