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
84 changes: 84 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 @@ -303,6 +309,9 @@ import {
providersPullOllamaModelRoute,
providersImportScanRoute,
providersImportApplyRoute,
providersStartAcpAuthRoute,
providersWriteAcpAuthInputRoute,
providersCancelAcpAuthRoute,
modelsListRuntimeRoute,
modelsTranscribeAudioRoute,
sessionsResumePendingQueueRoute,
Expand Down Expand Up @@ -335,6 +344,12 @@ type DaemonAcpSessionExecutionPort = {
getAcpSessionModes?(conversationId: string): Promise<unknown>;
setAcpSessionMode?(conversationId: string, modeId: string): Promise<void>;
resolveAgentPermission?(requestId: string, granted: boolean): Promise<void>;
startAcpAuth?(input: { agentId: string; workdir?: string; methodId: string }): Promise<{
mode: "agent" | "terminal";
runId: string | null;
}>;
writeAcpAuthInput?(agentId: string, runId: string, data: string): Promise<void>;
cancelAcpAuth?(agentId: string): Promise<void>;
};

type DaemonTranslatePort = {
Expand Down Expand Up @@ -935,6 +950,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 +2077,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 Expand Up @@ -3133,6 +3189,7 @@ export function createDaemonDispatcher(
deletedSessionIds.push(session.id);
continue;
}
const targetContext = await resolveMoveTargetContext(input.toAgentId);
await repo.moveSessionToAgent(session.id, {
...targetContext,
projectDir: session.projectDir ?? null,
Expand Down Expand Up @@ -3337,6 +3394,33 @@ export function createDaemonDispatcher(
return sessionsClearAcpSessionRoute.output.parse({ cleared: true });
}

if (route === providersStartAcpAuthRoute.name) {
const input = providersStartAcpAuthRoute.input.parse(rawInput);
if (!acpSessionExecutionPort?.startAcpAuth) {
throw new Error("ACP authentication is not available in this runtime.");
}
const result = await acpSessionExecutionPort.startAcpAuth(input);
return providersStartAcpAuthRoute.output.parse(result);
}

if (route === providersWriteAcpAuthInputRoute.name) {
const input = providersWriteAcpAuthInputRoute.input.parse(rawInput);
if (!acpSessionExecutionPort?.writeAcpAuthInput) {
throw new Error("ACP authentication is not available in this runtime.");
}
await acpSessionExecutionPort.writeAcpAuthInput(input.agentId, input.runId, input.data);
return providersWriteAcpAuthInputRoute.output.parse({ ok: true });
}

if (route === providersCancelAcpAuthRoute.name) {
const input = providersCancelAcpAuthRoute.input.parse(rawInput);
if (!acpSessionExecutionPort?.cancelAcpAuth) {
throw new Error("ACP authentication is not available in this runtime.");
}
await acpSessionExecutionPort.cancelAcpAuth(input.agentId);
return providersCancelAcpAuthRoute.output.parse({ cancelled: true });
}

if (route === sessionsGetAcpSessionModesRoute.name) {
const input = sessionsGetAcpSessionModesRoute.input.parse(rawInput);
const result = await acpSessionExecutionPort?.getAcpSessionModes?.(input.sessionId);
Expand Down
144 changes: 133 additions & 11 deletions apps/daemon/src/host/acp-provider-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from "@argos/shared/types/agent-interface";
import type * as schema from "@agentclientprotocol/sdk";
import { randomUUID } from "node:crypto";
import path from "node:path";
import {
getAcpConfigOption,
getLegacyModeState,
Expand All @@ -25,6 +26,10 @@ 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 { DaemonAcpAuthRuntime } from "./acpAuthRuntime";
import { resolvePtyTerminalCtor } from "../terminal/daemonTerminalRuntime";
import { isAuthRequiredError } from "@argos/acp-runtime/protocol/acpCapabilities";
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,11 +56,12 @@ 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;
private authRuntimePromise: Promise<DaemonAcpAuthRuntime> | null = null;
private activeTurns = new Map<
string,
{
Expand Down Expand Up @@ -84,6 +90,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 +108,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 All @@ -119,6 +127,74 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {
return this.runtimePromise;
}

/** Auth flows (agent-method authenticate + terminal login TUI). */
private async getAuthRuntime(): Promise<DaemonAcpAuthRuntime> {
if (!this.authRuntimePromise) {
this.authRuntimePromise = (async () => {
const runtime = await this.getRuntime();
return new DaemonAcpAuthRuntime({
eventPublisher: this.eventPublisher,
getProcessManager: async () => runtime.processManager,
resolveLaunchSpec: async (agentId, workdir) => {
const spec = await this.configPresenter.resolveAcpLaunchSpec(agentId, workdir);
// Route the launch command through the managed toolchains
// (npx -> node npx-cli.js etc.) and prepend resolved bin dirs —
// mirroring the process manager's launch pipeline for normal
// sessions, so terminal auth works for managed runtimes too.
const rewritten = await this.deps.toolchains.resolveCommand(spec.command, spec.args ?? []);
const binDirs = this.deps.toolchains.binDirsSync();
const env: Record<string, string> = { ...spec.env };
if (binDirs.length > 0) {
const existingKey = Object.keys(env).find((key) => key.toLowerCase() === "path");
const key = existingKey ?? (process.platform === "win32" ? "Path" : "PATH");
env[key] = [...binDirs, env[key] ?? ""].filter(Boolean).join(path.delimiter);
}
return { command: rewritten.command, args: rewritten.args, env };
},
ptyFactory: (options) => {
const ctor = resolvePtyTerminalCtor();
return new ctor({
cols: options.cols,
rows: options.rows,
data: (_terminal, data) =>
options.onData(typeof data === "string" ? new TextEncoder().encode(data) : data),
}) as unknown as { write: (data: string | Uint8Array) => void; kill: (signal?: string) => void };
},
spawnPty: (argv, options) =>
Bun.spawn(argv, {
cwd: options.cwd,
env: options.env,
terminal: options.terminal,
} as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as {
write: (data: string | Uint8Array) => void;
kill: (signal?: string) => void;
exited: Promise<number>;
},
});
})();
}
return this.authRuntimePromise;
}

/** Entry point for the ACP auth dialog (agent + terminal methods). */
async startAcpAuth(input: { agentId: string; workdir?: string; methodId: string }): Promise<{
mode: "agent" | "terminal";
runId: string | null;
}> {
const auth = await this.getAuthRuntime();
return await auth.start(input);
}

async writeAcpAuthInput(agentId: string, runId: string, data: string): Promise<void> {
const auth = await this.getAuthRuntime();
auth.write(agentId, runId, data);
}

async cancelAcpAuth(agentId: string): Promise<void> {
const auth = await this.getAuthRuntime();
auth.cancel({ agentId });
}

private async getSessionRecord(conversationId: string): Promise<AcpSessionRecord | null> {
const runtime = await this.getRuntime();
return runtime.sessionManager.getSession(conversationId);
Expand Down Expand Up @@ -288,15 +364,29 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {

await runtime.sessionPersistence.updateWorkdir(conversationId, agent.id, persistedWorkdir);

await runtime.sessionManager.getOrCreateSession(
conversationId,
agent as never,
{
onSessionUpdate: () => {},
onPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
normalizedWorkdir,
);
try {
await runtime.sessionManager.getOrCreateSession(
conversationId,
agent as never,
{
onSessionUpdate: () => {},
onPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
normalizedWorkdir,
);
} catch (error) {
if (isAuthRequiredError(error)) {
// The dispatcher swallows draft-prep failures; the event is what makes
// them actionable in the UI.
this.eventPublisher.publish("acp.auth.required", {
sessionId: conversationId,
agentId,
workdir: normalizedWorkdir,
message: error instanceof Error ? error.message : String(error),
});
}
throw error;
}

try {
const configState = await this.getAcpSessionConfigOptions(conversationId);
Expand Down Expand Up @@ -597,6 +687,38 @@ export class AcpProviderExecutionPort implements ProviderExecutionPort {
await this.turnSettledHandler?.(sessionId);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (isAuthRequiredError(error)) {
// Surface an actionable auth state instead of a raw JSON-RPC string.
const agentId = agent?.id ?? "";
const handle = runtime.processManager.listProcesses().find((candidate) => candidate.agentId === agentId);
this.eventPublisher.publish("acp.auth.required", {
sessionId,
agentId,
workdir: handle?.workdir ?? null,
message: errorMsg,
});
const friendly = `This agent requires sign-in. Open "Sign in" to authenticate (${agent?.name ?? agentId}).`;
await this.sessionRepository.setMessageError(
assistantMessageId,
[{ type: "error", content: friendly, status: "error", timestamp: Date.now() }],
JSON.stringify({ model: agent?.id ?? "", provider: "acp", authRequired: true }),
);
this.eventPublisher.publish("chat.stream.failed", {
requestId,
sessionId,
messageId: assistantMessageId,
failedAt: Date.now(),
error: friendly,
});
await this.sessionRepository.setSessionStatus?.(sessionId, "error");
this.eventPublisher.publish(sessionsStatusChangedEvent.name, {
sessionId,
status: "error",
reason: "auth-required",
version: 1,
});
return;
}
await this.sessionRepository.setMessageError(
assistantMessageId,
blocks.length > 0 ? blocks : [{ type: "error", content: errorMsg, status: "error", timestamp: Date.now() }],
Expand Down
Loading
Loading