From a5317f6e0c4da5749eb11a0d366c0070d68be69c Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:36:25 +0200 Subject: [PATCH] refactor: give the agent host's driver SDK a layout and a module registry The host's sources lay flat in src/, and main.ts knew every agent through two switch statements (loading and the warm-up mode), with agent-specific environment handling in them. Adding an agent meant editing both, and install on demand would have needed a third. The sources now sit by role: host/ (the process, routing and the list of driver modules), sdk/ (what a driver builds on), install/ (pinned runtimes) and drivers//. generated/ stays where it is, since the Rust type generator and CI's drift check write and check it there. Each driver folder exports one DriverModule from module.ts: the agent's id and label, the environment variables that are its alone (claimed before any driver is built, so no other agent's process inherits them), how to build its driver, and its runtime (what the machine already has, and how to install the pinned one). host/modules.ts keeps the list; loading and warm-up iterate it, with behaviour unchanged. A layout test fails when a driver imports anything outside sdk/, install/, generated/ or its own folder, or when anything but the module list names a driver. Co-Authored-By: Claude Code --- docs/AGENT-CANDIDATES.md | 41 +-- docs/PROTOCOL.md | 23 +- packages/agent-host/build.mjs | 2 +- packages/agent-host/package.json | 2 +- .../claude/__tests__/claudeDriver.test.ts | 4 +- .../claude/__tests__/claudeEnv.test.ts | 2 +- .../claude/__tests__/sdk-adapter.test.ts | 4 +- .../agent-host/src/drivers/claude/adapter.ts | 6 +- .../agent-host/src/drivers/claude/driver.ts | 14 +- packages/agent-host/src/drivers/claude/env.ts | 4 +- .../agent-host/src/drivers/claude/facade.ts | 2 +- .../agent-host/src/drivers/claude/install.ts | 4 +- packages/agent-host/src/drivers/claude/mcp.ts | 6 +- .../agent-host/src/drivers/claude/module.ts | 48 ++++ .../agent-host/src/drivers/claude/plugins.ts | 6 +- .../agent-host/src/drivers/claude/usage.ts | 2 +- .../__tests__/deepseek-adapter.test.ts | 2 +- .../deepseek/__tests__/deepseekDriver.test.ts | 6 +- .../deepseek/__tests__/deepseekEnv.test.ts | 4 +- .../deepseek/__tests__/deepseekMcp.test.ts | 2 +- .../src/drivers/deepseek/adapter.ts | 6 +- .../agent-host/src/drivers/deepseek/bridge.ts | 4 +- .../agent-host/src/drivers/deepseek/driver.ts | 12 +- .../agent-host/src/drivers/deepseek/env.ts | 4 +- .../src/drivers/deepseek/gateway.ts | 2 +- .../src/drivers/deepseek/install.ts | 2 +- .../agent-host/src/drivers/deepseek/mcp.ts | 6 +- .../agent-host/src/drivers/deepseek/module.ts | 60 +++++ .../src/drivers/deepseek/plugins.ts | 4 +- .../src/drivers/deepseek/questions.ts | 2 +- .../src/drivers/deepseek/runtime.ts | 4 +- .../src/drivers/{fake.ts => fake/driver.ts} | 8 +- .../agent-host/src/drivers/fake/module.ts | 13 + .../__tests__/opencode-adapter.test.ts | 4 +- .../opencode/__tests__/opencodeDriver.test.ts | 4 +- .../opencode/__tests__/opencodeMcp.test.ts | 2 +- .../src/drivers/opencode/adapter.ts | 6 +- .../agent-host/src/drivers/opencode/driver.ts | 10 +- .../src/drivers/opencode/install.ts | 4 +- .../agent-host/src/drivers/opencode/mcp.ts | 6 +- .../agent-host/src/drivers/opencode/module.ts | 38 +++ .../src/drivers/opencode/plugins.ts | 4 +- .../agent-host/src/drivers/opencode/server.ts | 2 +- .../agent-host/src/generated/dshPackages.ts | 4 +- .../src/generated/platformPackages.ts | 4 +- .../src/{ => host}/__tests__/host.test.ts | 6 +- .../src/host/__tests__/layout.test.ts | 71 ++++++ .../src/host/__tests__/modules.test.ts | 93 +++++++ packages/agent-host/src/{ => host}/host.ts | 4 +- packages/agent-host/src/host/main.ts | 85 +++++++ packages/agent-host/src/host/modules.ts | 64 +++++ .../__tests__/agentInstall.test.ts | 0 .../__tests__/lockfilePins.test.ts | 8 +- .../__tests__/platformPackages.test.ts | 6 +- .../src/{ => install}/agentInstall.ts | 4 +- .../src/{ => install}/lockfilePins.ts | 8 +- packages/agent-host/src/main.ts | 240 ------------------ .../src/{ => sdk}/__tests__/commands.test.ts | 0 .../src/{ => sdk}/__tests__/context.ts | 0 .../{ => sdk}/__tests__/executable.test.ts | 0 packages/agent-host/src/{ => sdk}/commands.ts | 0 packages/agent-host/src/{ => sdk}/driver.ts | 5 +- .../agent-host/src/{ => sdk}/executable.ts | 0 packages/agent-host/src/{ => sdk}/mcp.ts | 0 packages/agent-host/src/sdk/module.ts | 57 +++++ packages/agent-host/src/{ => sdk}/net.ts | 0 packages/agent-host/src/{ => sdk}/provider.ts | 0 packages/agent-host/src/{ => sdk}/tools.ts | 0 .../agent-host/src/{ => sdk}/transcript.ts | 0 packages/agent-host/src/{ => sdk}/types.ts | 2 +- 70 files changed, 674 insertions(+), 378 deletions(-) create mode 100644 packages/agent-host/src/drivers/claude/module.ts create mode 100644 packages/agent-host/src/drivers/deepseek/module.ts rename packages/agent-host/src/drivers/{fake.ts => fake/driver.ts} (98%) create mode 100644 packages/agent-host/src/drivers/fake/module.ts create mode 100644 packages/agent-host/src/drivers/opencode/module.ts rename packages/agent-host/src/{ => host}/__tests__/host.test.ts (98%) create mode 100644 packages/agent-host/src/host/__tests__/layout.test.ts create mode 100644 packages/agent-host/src/host/__tests__/modules.test.ts rename packages/agent-host/src/{ => host}/host.ts (99%) create mode 100644 packages/agent-host/src/host/main.ts create mode 100644 packages/agent-host/src/host/modules.ts rename packages/agent-host/src/{ => install}/__tests__/agentInstall.test.ts (100%) rename packages/agent-host/src/{ => install}/__tests__/lockfilePins.test.ts (96%) rename packages/agent-host/src/{ => install}/__tests__/platformPackages.test.ts (88%) rename packages/agent-host/src/{ => install}/agentInstall.ts (99%) rename packages/agent-host/src/{ => install}/lockfilePins.ts (97%) delete mode 100644 packages/agent-host/src/main.ts rename packages/agent-host/src/{ => sdk}/__tests__/commands.test.ts (100%) rename packages/agent-host/src/{ => sdk}/__tests__/context.ts (100%) rename packages/agent-host/src/{ => sdk}/__tests__/executable.test.ts (100%) rename packages/agent-host/src/{ => sdk}/commands.ts (100%) rename packages/agent-host/src/{ => sdk}/driver.ts (97%) rename packages/agent-host/src/{ => sdk}/executable.ts (100%) rename packages/agent-host/src/{ => sdk}/mcp.ts (100%) create mode 100644 packages/agent-host/src/sdk/module.ts rename packages/agent-host/src/{ => sdk}/net.ts (100%) rename packages/agent-host/src/{ => sdk}/provider.ts (100%) rename packages/agent-host/src/{ => sdk}/tools.ts (100%) rename packages/agent-host/src/{ => sdk}/transcript.ts (100%) rename packages/agent-host/src/{ => sdk}/types.ts (97%) diff --git a/docs/AGENT-CANDIDATES.md b/docs/AGENT-CANDIDATES.md index c95dad4b..802dbb5c 100644 --- a/docs/AGENT-CANDIDATES.md +++ b/docs/AGENT-CANDIDATES.md @@ -144,31 +144,32 @@ image builds `pnpm deploy` one package, and every driver package would need its own build, typecheck and test wiring. Revisit if third-party drivers become a goal. -**Do give the driver SDK a shape inside the package.** The boundary already -holds — drivers import only shared host modules, never each other, and only -`main.ts` names them — but the SDK is ~1,100 lines spread flat over `src/`, -and `main.ts` knows every agent through two `switch` statements (loading and -warm-up), with agent-specific environment handling in them. Target layout: +**Do give the driver SDK a shape inside the package** (done). The layout: ``` src/ - host/ main.ts, host.ts — the process, framing, routing - sdk/ driver.ts, types.ts, transcript, tools, commands, mcp, - provider, net, executable — the only thing a driver imports - install/ agentInstall, lockfilePins, generated/ — pinned runtimes - acp/ the ACP client and a generic ACP session/driver base - (extracted from drivers/deepseek) - drivers// each exports one DriverModule + host/ main.ts, host.ts — the process, framing, routing; + modules.ts — the list of driver modules + sdk/ driver.ts, module.ts, types.ts, transcript, tools, commands, + mcp, provider, net, executable — the driver SDK + install/ agentInstall, lockfilePins — pinned runtimes + generated/ driver-protocol types and lockfile pins (generated; stays + put, since the Rust generator and CI's drift check name it) + acp/ (next) the ACP client and a generic ACP session/driver base, + extracted from drivers/deepseek + drivers// each exports one DriverModule from module.ts ``` -A `DriverModule` is the agent's whole registration: its id and label, how -to build the driver from the environment, and its runtime as an install -descriptor (a pinned binary, a pinned package tree, or "found on PATH"). -`main.ts` keeps a list of modules instead of a `switch`; loading, the -warm-up mode and install-on-demand all iterate that list, so adding an -agent really is one folder plus one line. A test fails the build when a -driver imports anything outside `sdk/`, `install/`, `acp/` or its own -folder. +A `DriverModule` (`sdk/module.ts`) is the agent's whole registration: its +id and label, the environment variables that are its alone (taken out of +the environment every agent process inherits, before any driver is built), +how to build the driver, and its runtime — what the machine already has, +and how to install the pinned one. `host/modules.ts` keeps the list; +loading and the warm-up mode iterate it, and install on demand will too, so +adding an agent is one folder plus one line. `host/__tests__/layout.test.ts` +fails the build when a driver imports anything outside `sdk/`, `install/`, +`generated/` (and later `acp/`) or its own folder, or when anything but the +module list names a driver. ## Order diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 46738d2f..c1b998d6 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -527,20 +527,25 @@ restart. ### Adding an agent 1. Write a driver in `packages/agent-host/src/drivers//` implementing - `Driver` (`src/driver.ts`): `info()` advertises the agent (modes, efforts, - `supports`, credentials); `startSession()` returns a `DriverSession` and - reports through the `SessionContext` it is handed — `emit()` session - events, `requestPermission()` / `askQuestion()` / `requestPlanApproval()` - when the user must decide. + `Driver` (`src/sdk/driver.ts`): `info()` advertises the agent (modes, + efforts, `supports`, credentials); `startSession()` returns a + `DriverSession` and reports through the `SessionContext` it is handed — + `emit()` session events, `requestPermission()` / `askQuestion()` / + `requestPlanApproval()` when the user must decide. 2. Translate the agent's own events into typed `OutputEntry` values in the driver — nothing agent-specific may reach the bridge. -3. Register it in `src/main.ts` (it is enabled through - `CODEDECK_AGENT_HOST_DRIVERS`). +3. Export a `DriverModule` (`src/sdk/module.ts`) from the folder's + `module.ts`: the agent's id, how its driver is built from the + environment, and its runtime (what the machine already has, and how to + install the pinned one). Add it to the list in `src/host/modules.ts`; it + is enabled through `CODEDECK_AGENT_HOST_DRIVERS`. 4. Test it beside the driver, in `src/drivers//__tests__/`, like `drivers/claude/__tests__/claudeDriver.test.ts` and `drivers/opencode/__tests__/opencodeDriver.test.ts` do, with the - recording `SessionContext` in `src/__tests__/context.ts`. Everything the - driver owns — its permission policy included — lives in that folder too. + recording `SessionContext` in `src/sdk/__tests__/context.ts`. Everything + the driver owns — its permission policy included — lives in that folder + too. A driver imports only `src/sdk/`, `src/install/`, `src/generated/` + and its own folder; `src/host/__tests__/layout.test.ts` enforces it. The bridge and the phone need no change: the new agent appears in the catalog, and its entries render through the typed vocabulary above. Only a diff --git a/packages/agent-host/build.mjs b/packages/agent-host/build.mjs index 67dcfd2a..508f420a 100644 --- a/packages/agent-host/build.mjs +++ b/packages/agent-host/build.mjs @@ -10,7 +10,7 @@ import { build } from 'esbuild'; await build({ - entryPoints: ['src/main.ts'], + entryPoints: ['src/host/main.ts'], bundle: true, platform: 'node', target: 'node22', diff --git a/packages/agent-host/package.json b/packages/agent-host/package.json index b9d45bfc..5e5a22f4 100644 --- a/packages/agent-host/package.json +++ b/packages/agent-host/package.json @@ -8,7 +8,7 @@ "build": "node build.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", - "gen:platform-packages": "vitest run -u src/__tests__/lockfilePins.test.ts" + "gen:platform-packages": "vitest run -u src/install/__tests__/lockfilePins.test.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.283", diff --git a/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts b/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts index 736132e8..6dc1c8fc 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts @@ -23,8 +23,8 @@ import type { SdkSessionHandle, SdkSessionOptions, } from '../facade'; -import type { StartSession } from '../../../types'; -import { recordingContext, type Handlers } from '../../../__tests__/context'; +import type { StartSession } from '../../../sdk/types'; +import { recordingContext, type Handlers } from '../../../sdk/__tests__/context'; class ScriptedHandle implements SdkSessionHandle { readonly pushed: string[] = []; diff --git a/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts b/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts index 694f09f0..52ed62ca 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/claudeEnv.test.ts @@ -9,7 +9,7 @@ import { PROVIDER_BASE_URL_ERROR, sanitizeProviderBaseEnv, } from '../env'; -import type { ProviderBinding, StartSession } from '../../../types'; +import type { ProviderBinding, StartSession } from '../../../sdk/types'; const KIMI_TOKEN = 'sk-kimi-TESTSECRET-000'; diff --git a/packages/agent-host/src/drivers/claude/__tests__/sdk-adapter.test.ts b/packages/agent-host/src/drivers/claude/__tests__/sdk-adapter.test.ts index 95307325..3af34cf8 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/sdk-adapter.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/sdk-adapter.test.ts @@ -4,8 +4,8 @@ */ import { describe, it, expect } from 'vitest'; import { extractDiff, sdkMessageToEntries } from '../adapter'; -import { newTranslateContext, type TranslateContext } from '../../../transcript'; -import type { OutputEntry } from '../../../types'; +import { newTranslateContext, type TranslateContext } from '../../../sdk/transcript'; +import type { OutputEntry } from '../../../sdk/types'; import type { SdkAssistantMessage, SdkMessage, diff --git a/packages/agent-host/src/drivers/claude/adapter.ts b/packages/agent-host/src/drivers/claude/adapter.ts index 853a228d..5ea42223 100644 --- a/packages/agent-host/src/drivers/claude/adapter.ts +++ b/packages/agent-host/src/drivers/claude/adapter.ts @@ -9,7 +9,7 @@ * hides the tool calls behind those cards so they do not also render as * ordinary tool actions. */ -import { todosOf, toolInput, toolKindOf, toolLocations, toolTitle } from '../../tools'; +import { todosOf, toolInput, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; import { MAX_DIFF_LINES, toDiffLines, @@ -17,8 +17,8 @@ import { type DiffPayload, type KnownTask, type TranslateContext, -} from '../../transcript'; -import type { DiffLine, OutputEntry, Subagent, TaskKind, TaskStatus } from '../../types'; +} from '../../sdk/transcript'; +import type { DiffLine, OutputEntry, Subagent, TaskKind, TaskStatus } from '../../sdk/types'; import type { SdkMessage, SdkAssistantMessage, diff --git a/packages/agent-host/src/drivers/claude/driver.ts b/packages/agent-host/src/drivers/claude/driver.ts index 956c26a3..0c4d057e 100644 --- a/packages/agent-host/src/drivers/claude/driver.ts +++ b/packages/agent-host/src/drivers/claude/driver.ts @@ -12,13 +12,13 @@ */ import * as os from 'node:os'; import * as path from 'node:path'; -import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../driver'; -import { mcpStatus } from '../../mcp'; -import type { HttpPost } from '../../net'; +import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../sdk/driver'; +import { mcpStatus } from '../../sdk/mcp'; +import type { HttpPost } from '../../sdk/net'; import { isBenignPlanDirWrite } from './policy'; -import { PERMISSION_ALLOW, PERMISSION_ALLOW_ALWAYS, PERMISSION_DENY, toolKindOf, toolLocations, toolTitle } from '../../tools'; -import { newTranslateContext } from '../../transcript'; -import { slashCommand } from '../../commands'; +import { PERMISSION_ALLOW, PERMISSION_ALLOW_ALWAYS, PERMISSION_DENY, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; +import { newTranslateContext } from '../../sdk/transcript'; +import { slashCommand } from '../../sdk/commands'; import type { AgentInfo, ModelEntry, @@ -29,7 +29,7 @@ import type { SlashCommand, StartSession, UsageData, -} from '../../types'; +} from '../../sdk/types'; import { sdkMessageToEntries } from './adapter'; import { ANTHROPIC_API_KEY_CREDENTIAL, buildClaudeEnv } from './env'; import { diff --git a/packages/agent-host/src/drivers/claude/env.ts b/packages/agent-host/src/drivers/claude/env.ts index 910b5049..c31b410e 100644 --- a/packages/agent-host/src/drivers/claude/env.ts +++ b/packages/agent-host/src/drivers/claude/env.ts @@ -3,8 +3,8 @@ * credentials and the session's provider binding, turned into the variables * the CLI reads. The returned objects carry SECRETS — never log them. */ -import { isValidProviderBaseUrl, PROVIDER_BASE_URL_ERROR } from '../../provider'; -import type { ProviderBinding, StartSession } from '../../types'; +import { isValidProviderBaseUrl, PROVIDER_BASE_URL_ERROR } from '../../sdk/provider'; +import type { ProviderBinding, StartSession } from '../../sdk/types'; export const ANTHROPIC_API_KEY_CREDENTIAL = 'anthropic_api_key'; diff --git a/packages/agent-host/src/drivers/claude/facade.ts b/packages/agent-host/src/drivers/claude/facade.ts index ce6c155e..f31b6edd 100644 --- a/packages/agent-host/src/drivers/claude/facade.ts +++ b/packages/agent-host/src/drivers/claude/facade.ts @@ -18,7 +18,7 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { findInDirs, findOnPath, isFile } from '../../executable'; +import { findInDirs, findOnPath, isFile } from '../../sdk/executable'; import type { CanUseTool, Options, diff --git a/packages/agent-host/src/drivers/claude/install.ts b/packages/agent-host/src/drivers/claude/install.ts index 6401a5ac..ccac70a7 100644 --- a/packages/agent-host/src/drivers/claude/install.ts +++ b/packages/agent-host/src/drivers/claude/install.ts @@ -8,8 +8,8 @@ */ import { createRequire } from 'node:module'; import * as path from 'node:path'; -import type { PackagedBinary } from '../../agentInstall'; -import { exeName, isFile, isMusl } from '../../executable'; +import type { PackagedBinary } from '../../install/agentInstall'; +import { exeName, isFile, isMusl } from '../../sdk/executable'; /** The SDK's platform package for a machine. */ export function claudePlatformPackage( diff --git a/packages/agent-host/src/drivers/claude/mcp.ts b/packages/agent-host/src/drivers/claude/mcp.ts index d00c111e..8a0cbc05 100644 --- a/packages/agent-host/src/drivers/claude/mcp.ts +++ b/packages/agent-host/src/drivers/claude/mcp.ts @@ -14,9 +14,9 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import * as path from 'node:path'; -import type { McpManager, McpState } from '../../driver'; -import { serverInfo } from '../../mcp'; -import type { McpAction, McpServerAdd, McpServerInfo } from '../../types'; +import type { McpManager, McpState } from '../../sdk/driver'; +import { serverInfo } from '../../sdk/mcp'; +import type { McpAction, McpServerAdd, McpServerInfo } from '../../sdk/types'; import { failureMessage, type CliRunner } from './plugins'; const CHANGE_TIMEOUT_MS = 60_000; diff --git a/packages/agent-host/src/drivers/claude/module.ts b/packages/agent-host/src/drivers/claude/module.ts new file mode 100644 index 00000000..319f109b --- /dev/null +++ b/packages/agent-host/src/drivers/claude/module.ts @@ -0,0 +1,48 @@ +/** + * Claude Code: the Agent SDK around the `claude` binary — the one on the + * machine (CODEDECK_CLAUDE_PATH, PATH, or the SDK's own platform package), + * else the pinned platform package installed on demand. + * CODEDECK_TEST_MODE=1 swaps the SDK for canned `/test-*` sessions. + */ +import { installBinary } from '../../install/agentInstall'; +import type { DriverEnv, DriverModule } from '../../sdk/module'; +import { httpPost } from '../../sdk/net'; +import { ClaudeDriver } from './driver'; +import { RealSdkFacade, resolveClaudeExecutable } from './facade'; +import { bundledClaudeExecutable, claudeBinary } from './install'; +import { TestModeSdkFacade } from './testModeFacade'; + +const testMode = (ctx: DriverEnv): boolean => ctx.env.CODEDECK_TEST_MODE === '1'; + +/** The `claude` this machine has, if any. */ +function existing(ctx: DriverEnv): string | null { + return resolveClaudeExecutable(undefined, ctx.lookupEnv) ?? bundledClaudeExecutable(); +} + +function install(ctx: DriverEnv): Promise { + return installBinary(claudeBinary(), { cacheDir: ctx.cacheDir, registry: ctx.registry, log: ctx.log }); +} + +export const claudeModule: DriverModule = { + id: 'claude-code', + label: 'Claude Code', + create(ctx) { + const test = testMode(ctx); + const claudePath = test ? null : existing(ctx); + return new ClaudeDriver({ + facade: test ? new TestModeSdkFacade() : new RealSdkFacade(), + ...(claudePath ? { claudePath } : {}), + // Nothing on the machine: the driver installs it in the background, + // so the agent is listed right away and the first session waits for it. + ...(!test && !claudePath ? { installClaude: () => install(ctx) } : {}), + httpPost, + discoverModels: !test, + managePlugins: !test, + manageMcp: !test, + }); + }, + runtime: { + find: (ctx) => (testMode(ctx) ? 'test mode' : existing(ctx)), + install, + }, +}; diff --git a/packages/agent-host/src/drivers/claude/plugins.ts b/packages/agent-host/src/drivers/claude/plugins.ts index c46c5605..0fbac79d 100644 --- a/packages/agent-host/src/drivers/claude/plugins.ts +++ b/packages/agent-host/src/drivers/claude/plugins.ts @@ -14,9 +14,9 @@ import { execFile } from 'node:child_process'; import { readFileSync } from 'node:fs'; import * as path from 'node:path'; -import { shortDescription } from '../../commands'; -import type { PluginManager, PluginState } from '../../driver'; -import type { AvailablePlugin, InstalledPlugin, PluginAction, PluginMarketplace } from '../../types'; +import { shortDescription } from '../../sdk/commands'; +import type { PluginManager, PluginState } from '../../sdk/driver'; +import type { AvailablePlugin, InstalledPlugin, PluginAction, PluginMarketplace } from '../../sdk/types'; export interface CliResult { code: number; diff --git a/packages/agent-host/src/drivers/claude/usage.ts b/packages/agent-host/src/drivers/claude/usage.ts index 7704d1e6..8c7453e3 100644 --- a/packages/agent-host/src/drivers/claude/usage.ts +++ b/packages/agent-host/src/drivers/claude/usage.ts @@ -11,7 +11,7 @@ * a window is only included when the SDK reports it; null inner values are * preserved so the phone can decide whether to render the row. */ -import type { UsageData, UsageWindow } from '../../types'; +import type { UsageData, UsageWindow } from '../../sdk/types'; /** The slice of SDKControlGetUsageResponse we consume (experimental shape). */ interface RawUsageWindow { diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseek-adapter.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseek-adapter.test.ts index e255db89..ecc04128 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseek-adapter.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseek-adapter.test.ts @@ -9,7 +9,7 @@ */ import { describe, expect, it } from 'vitest'; import type { SessionUpdate } from '@agentclientprotocol/sdk'; -import type { DiffLine, OutputEntry } from '../../../types'; +import type { DiffLine, OutputEntry } from '../../../sdk/types'; import { deepseekUpdateToEntries, toolCallDiffs, type ToolCallMemory } from '../adapter'; type EntryOf = Extract; diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts index bfc5646c..867ea428 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts @@ -4,9 +4,9 @@ import { createServer, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { recordingContext, type RecordingContext } from '../../../__tests__/context'; -import type { StartSession } from '../../../types'; -import type { DriverSession } from '../../../driver'; +import { recordingContext, type RecordingContext } from '../../../sdk/__tests__/context'; +import type { StartSession } from '../../../sdk/types'; +import type { DriverSession } from '../../../sdk/driver'; import { DeepSeekDriver } from '../driver'; import { BRIDGE_SOCKET_ENV, QUESTION_MARKER } from '../plugin'; import { DeepSeekMcp } from '../mcp'; diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts index bb044d5a..e496d3b2 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts @@ -3,8 +3,8 @@ * custom provider (a gateway), in the style of claudeEnv.test.ts. */ import { describe, expect, it } from 'vitest'; -import { PROVIDER_BASE_URL_ERROR } from '../../../provider'; -import type { ProviderBinding } from '../../../types'; +import { PROVIDER_BASE_URL_ERROR } from '../../../sdk/provider'; +import type { ProviderBinding } from '../../../sdk/types'; import { DEEPSEEK_API_KEY_CREDENTIAL, buildDeepSeekEnv, diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekMcp.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekMcp.test.ts index 918aae41..85f999b9 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekMcp.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekMcp.test.ts @@ -8,7 +8,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; -import type { McpServerAdd } from '../../../types'; +import type { McpServerAdd } from '../../../sdk/types'; import { DeepSeekMcp } from '../mcp'; const INITIAL_LAYER = `# Your patch layer for this dsh profile, applied after every bundle layer. diff --git a/packages/agent-host/src/drivers/deepseek/adapter.ts b/packages/agent-host/src/drivers/deepseek/adapter.ts index e262a0a9..a6cc72a4 100644 --- a/packages/agent-host/src/drivers/deepseek/adapter.ts +++ b/packages/agent-host/src/drivers/deepseek/adapter.ts @@ -17,9 +17,9 @@ * from the name (src/tools.ts) exactly as they do for the other agents. */ import type { SessionUpdate } from '@agentclientprotocol/sdk'; -import { todosOf, toolInput, toolKindOf, toolLocations, toolTitle } from '../../tools'; -import { MAX_DIFF_LINES, truncateToolResult, toDiffLines, type DiffPayload } from '../../transcript'; -import type { OutputEntry } from '../../types'; +import { todosOf, toolInput, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; +import { MAX_DIFF_LINES, truncateToolResult, toDiffLines, type DiffPayload } from '../../sdk/transcript'; +import type { OutputEntry } from '../../sdk/types'; /** * The tools whose card *is* the exchange. The question tool asks through the diff --git a/packages/agent-host/src/drivers/deepseek/bridge.ts b/packages/agent-host/src/drivers/deepseek/bridge.ts index 1ca6cbb0..106afbad 100644 --- a/packages/agent-host/src/drivers/deepseek/bridge.ts +++ b/packages/agent-host/src/drivers/deepseek/bridge.ts @@ -12,8 +12,8 @@ import { createHash } from 'node:crypto'; import * as path from 'node:path'; import { connect } from 'node:net'; -import { slashCommand } from '../../commands'; -import type { SlashCommand } from '../../types'; +import { slashCommand } from '../../sdk/commands'; +import type { SlashCommand } from '../../sdk/types'; /** How long one question may take. The plugin answers from memory; a command * that does real work (a compaction asks a model) takes as long as it takes, diff --git a/packages/agent-host/src/drivers/deepseek/driver.ts b/packages/agent-host/src/drivers/deepseek/driver.ts index 53d76219..2f99ebba 100644 --- a/packages/agent-host/src/drivers/deepseek/driver.ts +++ b/packages/agent-host/src/drivers/deepseek/driver.ts @@ -23,11 +23,11 @@ * harness's result update carries no diff. */ import type { RequestPermissionRequest, RequestPermissionResponse, SessionConfigOption, SessionNotification } from '@agentclientprotocol/sdk'; -import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../driver'; -import type { HttpGet } from '../../net'; -import { mcpStatus as mcpStatusOf } from '../../mcp'; -import { parseSlashCommand } from '../../commands'; -import { PERMISSION_ALLOW, PERMISSION_DENY, now, toolKindOf, toolLocations, toolTitle } from '../../tools'; +import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../sdk/driver'; +import type { HttpGet } from '../../sdk/net'; +import { mcpStatus as mcpStatusOf } from '../../sdk/mcp'; +import { parseSlashCommand } from '../../sdk/commands'; +import { PERMISSION_ALLOW, PERMISSION_DENY, now, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; import type { AgentInfo, McpStatus, @@ -38,7 +38,7 @@ import type { SlashCommand, StartSession, UsageData, -} from '../../types'; +} from '../../sdk/types'; import { deepseekUpdateToEntries, type ToolCallMemory } from './adapter'; import { DEEPSEEK_API_KEY_CREDENTIAL, DEEPSEEK_API_KEY_ENV, DEEPSEEK_BASE_URL_ENV, buildDeepSeekEnv } from './env'; import { askPlugin, listSessionCommands, runSessionCommand, steerSession } from './bridge'; diff --git a/packages/agent-host/src/drivers/deepseek/env.ts b/packages/agent-host/src/drivers/deepseek/env.ts index b66a9a7d..f1c64794 100644 --- a/packages/agent-host/src/drivers/deepseek/env.ts +++ b/packages/agent-host/src/drivers/deepseek/env.ts @@ -12,8 +12,8 @@ * the DeepSeek messages API, which is what a DeepSeek-compatible relay * serves. */ -import { isValidProviderBaseUrl, PROVIDER_BASE_URL_ERROR } from '../../provider'; -import type { ProviderBinding, StartSession } from '../../types'; +import { isValidProviderBaseUrl, PROVIDER_BASE_URL_ERROR } from '../../sdk/provider'; +import type { ProviderBinding, StartSession } from '../../sdk/types'; export const DEEPSEEK_API_KEY_CREDENTIAL = 'deepseek_api_key'; /** The key the harness reads. */ diff --git a/packages/agent-host/src/drivers/deepseek/gateway.ts b/packages/agent-host/src/drivers/deepseek/gateway.ts index 9e4f2e18..1b6d346a 100644 --- a/packages/agent-host/src/drivers/deepseek/gateway.ts +++ b/packages/agent-host/src/drivers/deepseek/gateway.ts @@ -21,7 +21,7 @@ */ import * as path from 'node:path'; import { dump } from 'js-yaml'; -import type { HttpGet } from '../../net'; +import type { HttpGet } from '../../sdk/net'; import { ProfileLayer, type LayerBlock } from './profileLayer'; /** Our block in the profile's patch layer. */ diff --git a/packages/agent-host/src/drivers/deepseek/install.ts b/packages/agent-host/src/drivers/deepseek/install.ts index 4c073d9c..65102f24 100644 --- a/packages/agent-host/src/drivers/deepseek/install.ts +++ b/packages/agent-host/src/drivers/deepseek/install.ts @@ -12,7 +12,7 @@ * `node`. */ import * as path from 'node:path'; -import { installPackageTree, type InstallOptions } from '../../agentInstall'; +import { installPackageTree, type InstallOptions } from '../../install/agentInstall'; import { DSH_PACKAGES } from '../../generated/dshPackages'; /** The npm package the harness ships as. */ diff --git a/packages/agent-host/src/drivers/deepseek/mcp.ts b/packages/agent-host/src/drivers/deepseek/mcp.ts index 93498170..86e8bfdc 100644 --- a/packages/agent-host/src/drivers/deepseek/mcp.ts +++ b/packages/agent-host/src/drivers/deepseek/mcp.ts @@ -28,9 +28,9 @@ */ import * as path from 'node:path'; import { load, dump } from 'js-yaml'; -import type { McpManager, McpState } from '../../driver'; -import { serverInfo } from '../../mcp'; -import type { McpAction, McpServerAdd, McpServerInfo } from '../../types'; +import type { McpManager, McpState } from '../../sdk/driver'; +import { serverInfo } from '../../sdk/mcp'; +import type { McpAction, McpServerAdd, McpServerInfo } from '../../sdk/types'; import { ProfileLayer, type LayerBlock } from './profileLayer'; /** The plugin every managed row mounts. */ diff --git a/packages/agent-host/src/drivers/deepseek/module.ts b/packages/agent-host/src/drivers/deepseek/module.ts new file mode 100644 index 00000000..ad8458db --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/module.ts @@ -0,0 +1,60 @@ +/** + * The DeepSeek Harness: its CLI over ACP — the operator's own + * (CODEDECK_DEEPSEEK_PATH), else the pinned package tree installed on + * demand — with its state under CODEDECK_DEEPSEEK_HOME. + */ +import type { DriverEnv, DriverModule } from '../../sdk/module'; +import { isFile } from '../../sdk/executable'; +import { httpGet } from '../../sdk/net'; +import { DeepSeekDriver } from './driver'; +import { takeDeepSeekEnv } from './env'; +import { installDshTree } from './install'; +import { DeepSeekMcp } from './mcp'; +import { DeepSeekRuntime, dshHomeDir, dshProfileDir } from './runtime'; + +const explicitPath = (ctx: DriverEnv): string | undefined => ctx.env.CODEDECK_DEEPSEEK_PATH?.trim() || undefined; + +export const deepSeekModule: DriverModule = { + id: 'deepseek-harness', + label: 'DeepSeek Harness', + // The harness's endpoint and key are its driver's alone. + claimEnv: takeDeepSeekEnv, + create(ctx) { + const home = dshHomeDir(ctx.lookupEnv); + const dshPath = explicitPath(ctx); + // The MCP servers live in this profile layer, and a harness process is + // told the layer's version as it starts: the same manager serves both, + // so a change after a process started is configuration it has not loaded. + const mcp = new DeepSeekMcp({ profileDir: dshProfileDir(home), log: ctx.log }); + const driver = DeepSeekDriver.create({ + runtime: new DeepSeekRuntime({ + // An explicit path is the operator's own harness; without one the + // pinned tree is installed on demand, at the version and sha512 + // pnpm-lock.yaml holds. + ...(dshPath ? { dshPath } : {}), + home, + cacheDir: ctx.cacheDir, + registry: ctx.registry, + installDsh: installDshTree, + configVersion: () => mcp.version, + log: ctx.log, + }), + home, + mcp, + baseEnv: ctx.ownEnv, + httpGet, + log: ctx.log, + }); + if (dshPath && !isFile(dshPath)) { + driver.setUnavailable(`CODEDECK_DEEPSEEK_PATH points at ${dshPath}, which is not a file.`); + } + return driver; + }, + runtime: { + find: (ctx) => { + const dshPath = explicitPath(ctx); + return dshPath ? `the CLI at ${dshPath}` : null; + }, + install: (ctx) => installDshTree({ cacheDir: ctx.cacheDir, registry: ctx.registry, log: ctx.log }), + }, +}; diff --git a/packages/agent-host/src/drivers/deepseek/plugins.ts b/packages/agent-host/src/drivers/deepseek/plugins.ts index 982b5ab4..4a8ecd16 100644 --- a/packages/agent-host/src/drivers/deepseek/plugins.ts +++ b/packages/agent-host/src/drivers/deepseek/plugins.ts @@ -23,8 +23,8 @@ import { spawn } from 'node:child_process'; import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import * as path from 'node:path'; -import type { PluginManager, PluginState } from '../../driver'; -import type { InstalledPlugin, PluginAction } from '../../types'; +import type { PluginManager, PluginState } from '../../sdk/driver'; +import type { InstalledPlugin, PluginAction } from '../../sdk/types'; import { dshCommand, type SpawnFn } from './runtime'; /** The layers a profile composes without any plugin: the shared core and the diff --git a/packages/agent-host/src/drivers/deepseek/questions.ts b/packages/agent-host/src/drivers/deepseek/questions.ts index 244ca5ed..e92dedec 100644 --- a/packages/agent-host/src/drivers/deepseek/questions.ts +++ b/packages/agent-host/src/drivers/deepseek/questions.ts @@ -16,7 +16,7 @@ * what makes the round trip exact: option labels are what a selected answer * carries, and free text is what a typed one does. */ -import type { QuestionSpec } from '../../types'; +import type { QuestionSpec } from '../../sdk/types'; /** One question as the plugin pushes it. */ export interface PushedQuestion { diff --git a/packages/agent-host/src/drivers/deepseek/runtime.ts b/packages/agent-host/src/drivers/deepseek/runtime.ts index f0b1c628..d06db37e 100644 --- a/packages/agent-host/src/drivers/deepseek/runtime.ts +++ b/packages/agent-host/src/drivers/deepseek/runtime.ts @@ -33,8 +33,8 @@ import type { RequestPermissionResponse, SessionNotification, } from '@agentclientprotocol/sdk'; -import { agentCacheDir } from '../../agentInstall'; -import { isFile } from '../../executable'; +import { agentCacheDir } from '../../install/agentInstall'; +import { isFile } from '../../sdk/executable'; import { AcpClient, INITIALIZE_TIMEOUT_MS } from './acp'; import { bridgeSocketPath } from './bridge'; import { DSH_LABEL } from './install'; diff --git a/packages/agent-host/src/drivers/fake.ts b/packages/agent-host/src/drivers/fake/driver.ts similarity index 98% rename from packages/agent-host/src/drivers/fake.ts rename to packages/agent-host/src/drivers/fake/driver.ts index 1241ace7..097c73b3 100644 --- a/packages/agent-host/src/drivers/fake.ts +++ b/packages/agent-host/src/drivers/fake/driver.ts @@ -13,9 +13,9 @@ * Starting with `resume: "lost"` ends at once with `resumeLost`, like an * agent whose conversation is gone. */ -import type { Driver, DriverSession, McpManager, McpState, PluginManager, PluginState, SessionContext, SessionMcpState } from '../driver'; -import { serverInfo } from '../mcp'; -import { now, PERMISSION_ALLOW, PERMISSION_DENY } from '../tools'; +import type { Driver, DriverSession, McpManager, McpState, PluginManager, PluginState, SessionContext, SessionMcpState } from '../../sdk/driver'; +import { serverInfo } from '../../sdk/mcp'; +import { now, PERMISSION_ALLOW, PERMISSION_DENY } from '../../sdk/tools'; import type { AgentInfo, InstalledPlugin, @@ -28,7 +28,7 @@ import type { SlashCommand, StartSession, UsageData, -} from '../types'; +} from '../../sdk/types'; export const FAKE_AGENT_ID = 'fake'; diff --git a/packages/agent-host/src/drivers/fake/module.ts b/packages/agent-host/src/drivers/fake/module.ts new file mode 100644 index 00000000..2e6d509f --- /dev/null +++ b/packages/agent-host/src/drivers/fake/module.ts @@ -0,0 +1,13 @@ +/** + * The fake agent: scripted sessions for the bridge's and the host's tests. + * Nothing to install, and loaded only when asked for by name. + */ +import type { DriverModule } from '../../sdk/module'; +import { FAKE_AGENT_ID, FakeDriver } from './driver'; + +export const fakeModule: DriverModule = { + id: FAKE_AGENT_ID, + label: 'Fake agent', + explicitOnly: true, + create: () => new FakeDriver(), +}; diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencode-adapter.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencode-adapter.test.ts index ac44bf92..88a7c258 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencode-adapter.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencode-adapter.test.ts @@ -15,8 +15,8 @@ import type { OpenCodeQuestion, LegacyFileDiff, } from '../adapter'; -import { newTranslateContext, type TranslateContext } from '../../../transcript'; -import type { OutputEntry } from '../../../types'; +import { newTranslateContext, type TranslateContext } from '../../../sdk/transcript'; +import type { OutputEntry } from '../../../sdk/types'; import type { Part } from '@opencode-ai/sdk/v2/client'; type EntryOf = Extract; diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts index 07a88e2a..e00aab6c 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeDriver.test.ts @@ -6,8 +6,8 @@ import { describe, it, expect, vi } from 'vitest'; import type { Event, OpencodeClient, Provider, Session } from '@opencode-ai/sdk/v2/client'; import { OpenCodeDriver, pickDefaultModel, toQuestionAnswers } from '../driver'; -import type { StartSession } from '../../../types'; -import { recordingContext, type Handlers } from '../../../__tests__/context'; +import type { StartSession } from '../../../sdk/types'; +import { recordingContext, type Handlers } from '../../../sdk/__tests__/context'; type FakeClient = OpencodeClient & { permission: { reply: ReturnType; respond: ReturnType }; diff --git a/packages/agent-host/src/drivers/opencode/__tests__/opencodeMcp.test.ts b/packages/agent-host/src/drivers/opencode/__tests__/opencodeMcp.test.ts index 5f8a3a73..f45a2aad 100644 --- a/packages/agent-host/src/drivers/opencode/__tests__/opencodeMcp.test.ts +++ b/packages/agent-host/src/drivers/opencode/__tests__/opencodeMcp.test.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; import { OpenCodeMcp, openCodeSessionMcp, openCodeServerConfig, toggleOpenCodeMcp } from '../mcp'; -import { redactUrl } from '../../../mcp'; +import { redactUrl } from '../../../sdk/mcp'; /** A server whose global config is `config`; an update first re-reads the * global config file when `reload` is given, then merges into it the way diff --git a/packages/agent-host/src/drivers/opencode/adapter.ts b/packages/agent-host/src/drivers/opencode/adapter.ts index 3e0d7e83..b7b086d8 100644 --- a/packages/agent-host/src/drivers/opencode/adapter.ts +++ b/packages/agent-host/src/drivers/opencode/adapter.ts @@ -7,7 +7,7 @@ * is pure: one event in, zero or more entries out. */ import type { Part, SnapshotFileDiff } from '@opencode-ai/sdk/v2/client'; -import { todosOf, toolInput, toolKindOf, toolLocations, toolTitle } from '../../tools'; +import { todosOf, toolInput, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; import { MAX_DIFF_LINE_CHARS, MAX_DIFF_LINES, @@ -15,8 +15,8 @@ import { truncateToolResult, type DiffPayload, type TranslateContext, -} from '../../transcript'; -import type { DiffLine, OutputEntry, Subagent, TaskStatus } from '../../types'; +} from '../../sdk/transcript'; +import type { DiffLine, OutputEntry, Subagent, TaskStatus } from '../../sdk/types'; /** The OpenCode session exists server-side and accepts prompts. */ export interface OpenCodeStarted { diff --git a/packages/agent-host/src/drivers/opencode/driver.ts b/packages/agent-host/src/drivers/opencode/driver.ts index 90e0cd17..acfb5e26 100644 --- a/packages/agent-host/src/drivers/opencode/driver.ts +++ b/packages/agent-host/src/drivers/opencode/driver.ts @@ -30,10 +30,10 @@ import type { Session, SnapshotFileDiff, } from '@opencode-ai/sdk/v2/client'; -import { parseSlashCommand, slashCommand } from '../../commands'; -import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../driver'; -import { PERMISSION_ALLOW, PERMISSION_DENY, toolKindOf, toolLocations, toolTitle } from '../../tools'; -import { newTranslateContext } from '../../transcript'; +import { parseSlashCommand, slashCommand } from '../../sdk/commands'; +import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../sdk/driver'; +import { PERMISSION_ALLOW, PERMISSION_DENY, toolKindOf, toolLocations, toolTitle } from '../../sdk/tools'; +import { newTranslateContext } from '../../sdk/transcript'; import type { AgentInfo, ModelEntry, @@ -43,7 +43,7 @@ import type { StartSession, Subagent, UsageData, -} from '../../types'; +} from '../../sdk/types'; import { opencodeEventToEntries, toolCallDiffs, type OpenCodeEvent } from './adapter'; import { OpenCodeMcp, openCodeSessionMcp, toggleOpenCodeMcp } from './mcp'; import { OpenCodePlugins } from './plugins'; diff --git a/packages/agent-host/src/drivers/opencode/install.ts b/packages/agent-host/src/drivers/opencode/install.ts index e371a363..876d05d3 100644 --- a/packages/agent-host/src/drivers/opencode/install.ts +++ b/packages/agent-host/src/drivers/opencode/install.ts @@ -6,8 +6,8 @@ * dependency of the agent host only so that pnpm-lock.yaml pins those * packages; keep its version in step with the OpenCode SDK's. */ -import type { PackagedBinary } from '../../agentInstall'; -import { exeName, isMusl } from '../../executable'; +import type { PackagedBinary } from '../../install/agentInstall'; +import { exeName, isMusl } from '../../sdk/executable'; /** The platform package for a machine. On x64 it is the `baseline` build, * which runs on any x64 CPU (the default one needs AVX2). */ diff --git a/packages/agent-host/src/drivers/opencode/mcp.ts b/packages/agent-host/src/drivers/opencode/mcp.ts index 061dd314..5a9c3fab 100644 --- a/packages/agent-host/src/drivers/opencode/mcp.ts +++ b/packages/agent-host/src/drivers/opencode/mcp.ts @@ -23,9 +23,9 @@ import { readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import * as path from 'node:path'; import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; -import type { McpManager, McpState, SessionMcpState } from '../../driver'; -import { mcpStatus, serverInfo } from '../../mcp'; -import type { McpAction, McpServerAdd, McpServerInfo } from '../../types'; +import type { McpManager, McpState, SessionMcpState } from '../../sdk/driver'; +import { mcpStatus, serverInfo } from '../../sdk/mcp'; +import type { McpAction, McpServerAdd, McpServerInfo } from '../../sdk/types'; type RawConfig = Record; diff --git a/packages/agent-host/src/drivers/opencode/module.ts b/packages/agent-host/src/drivers/opencode/module.ts new file mode 100644 index 00000000..a0b332ae --- /dev/null +++ b/packages/agent-host/src/drivers/opencode/module.ts @@ -0,0 +1,38 @@ +/** + * OpenCode: its server SDK, against a running server + * (CODEDECK_OPENCODE_SERVER_URL) or one the driver starts and manages + * (CODEDECK_OPENCODE_AUTO_START=1, with CODEDECK_OPENCODE_PATH and + * CODEDECK_OPENCODE_PORT) — from the `opencode` on the machine, else the + * pinned platform package installed on demand. + */ +import { installBinary } from '../../install/agentInstall'; +import type { DriverEnv, DriverModule } from '../../sdk/module'; +import { OpenCodeDriver } from './driver'; +import { openCodeBinary } from './install'; +import { resolveOpenCodePath } from './server'; + +function install(ctx: DriverEnv): Promise { + return installBinary(openCodeBinary(), { cacheDir: ctx.cacheDir, registry: ctx.registry, log: ctx.log }); +} + +export const openCodeModule: DriverModule = { + id: 'opencode', + label: 'OpenCode', + create(ctx) { + const { env } = ctx; + const port = env.CODEDECK_OPENCODE_PORT ? Number(env.CODEDECK_OPENCODE_PORT) : undefined; + return OpenCodeDriver.create({ + ...(env.CODEDECK_OPENCODE_SERVER_URL ? { serverUrl: env.CODEDECK_OPENCODE_SERVER_URL } : {}), + autoStart: env.CODEDECK_OPENCODE_AUTO_START === '1' || env.CODEDECK_OPENCODE_AUTO_START === 'true', + ...(env.CODEDECK_OPENCODE_PATH ? { binaryPath: env.CODEDECK_OPENCODE_PATH } : {}), + installOpenCode: () => install(ctx), + lookupEnv: ctx.lookupEnv, + ...(port !== undefined && Number.isInteger(port) ? { port } : {}), + log: ctx.log, + }); + }, + runtime: { + find: (ctx) => resolveOpenCodePath(undefined, ctx.lookupEnv), + install, + }, +}; diff --git a/packages/agent-host/src/drivers/opencode/plugins.ts b/packages/agent-host/src/drivers/opencode/plugins.ts index 6fa2ac4a..ddb96d67 100644 --- a/packages/agent-host/src/drivers/opencode/plugins.ts +++ b/packages/agent-host/src/drivers/opencode/plugins.ts @@ -9,8 +9,8 @@ * removing it, so only `install` and `uninstall` apply. */ import type { OpencodeClient } from '@opencode-ai/sdk/v2/client'; -import type { PluginManager, PluginState } from '../../driver'; -import type { InstalledPlugin, PluginAction } from '../../types'; +import type { PluginManager, PluginState } from '../../sdk/driver'; +import type { InstalledPlugin, PluginAction } from '../../sdk/types'; type PluginEntry = string | [string, Record]; diff --git a/packages/agent-host/src/drivers/opencode/server.ts b/packages/agent-host/src/drivers/opencode/server.ts index 7029a906..22d1c93d 100644 --- a/packages/agent-host/src/drivers/opencode/server.ts +++ b/packages/agent-host/src/drivers/opencode/server.ts @@ -17,7 +17,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import * as os from 'node:os'; import * as path from 'node:path'; -import { findInDirs, findOnPath, isFile } from '../../executable'; +import { findInDirs, findOnPath, isFile } from '../../sdk/executable'; /** Resolve the `opencode` binary: explicit path → CODEDECK_OPENCODE_PATH → * PATH → well-known global-install locations → null. Same order as diff --git a/packages/agent-host/src/generated/dshPackages.ts b/packages/agent-host/src/generated/dshPackages.ts index c255d4ab..8dc286ef 100644 --- a/packages/agent-host/src/generated/dshPackages.ts +++ b/packages/agent-host/src/generated/dshPackages.ts @@ -1,6 +1,6 @@ -// Generated from pnpm-lock.yaml by src/lockfilePins.ts — do not edit. +// Generated from pnpm-lock.yaml by src/install/lockfilePins.ts — do not edit. // Regenerate with this package's `gen:platform-packages` script. -import type { TreePackageEntry } from '../lockfilePins'; +import type { TreePackageEntry } from '../install/lockfilePins'; export const DSH_PACKAGES: readonly TreePackageEntry[] = [ { name: "@agentclientprotocol/sdk", version: "1.4.0", integrity: "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==", dest: "node_modules/@agentclientprotocol/sdk" }, diff --git a/packages/agent-host/src/generated/platformPackages.ts b/packages/agent-host/src/generated/platformPackages.ts index c18debf6..e75d69b8 100644 --- a/packages/agent-host/src/generated/platformPackages.ts +++ b/packages/agent-host/src/generated/platformPackages.ts @@ -1,6 +1,6 @@ -// Generated from pnpm-lock.yaml by src/lockfilePins.ts — do not edit. +// Generated from pnpm-lock.yaml by src/install/lockfilePins.ts — do not edit. // Regenerate with this package's `gen:platform-packages` script. -import type { PackagePin } from '../lockfilePins'; +import type { PackagePin } from '../install/lockfilePins'; export const PLATFORM_PACKAGES: Readonly> = { "@anthropic-ai/claude-agent-sdk-darwin-arm64": { version: "0.3.283", integrity: "sha512-UQkROekjufppyB/qrsrU81sM0fcYNWJBEITrGp7NLbOocJZBUR+uVMIx/UHVpe6j81trXPIeRWyfJWgZI17Kxg==" }, diff --git a/packages/agent-host/src/__tests__/host.test.ts b/packages/agent-host/src/host/__tests__/host.test.ts similarity index 98% rename from packages/agent-host/src/__tests__/host.test.ts rename to packages/agent-host/src/host/__tests__/host.test.ts index 514e7f42..d0765465 100644 --- a/packages/agent-host/src/__tests__/host.test.ts +++ b/packages/agent-host/src/host/__tests__/host.test.ts @@ -4,10 +4,10 @@ * bridge, and session lifetimes. */ import { describe, it, expect } from 'vitest'; -import type { Driver } from '../driver'; -import { FakeDriver } from '../drivers/fake'; +import type { Driver } from '../../sdk/driver'; +import { FakeDriver } from '../../drivers/fake/driver'; import { AgentHost, parseBridgeFrame } from '../host'; -import type { AgentInfo } from '../types'; +import type { AgentInfo } from '../../sdk/types'; interface Frame { v: number; diff --git a/packages/agent-host/src/host/__tests__/layout.test.ts b/packages/agent-host/src/host/__tests__/layout.test.ts new file mode 100644 index 00000000..13ca0412 --- /dev/null +++ b/packages/agent-host/src/host/__tests__/layout.test.ts @@ -0,0 +1,71 @@ +/** + * The source layout's import rules, so a driver stays a folder that can be + * added or removed on its own: + * - a driver imports only the driver SDK (`sdk/`), the installer + * (`install/`), the generated pins and types (`generated/`) and its own + * folder — never the host, never another driver; + * - the SDK and the installer import no driver and nothing of the host; + * - outside the drivers, only the module list (`host/modules.ts`) names one. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const SRC = path.resolve(__dirname, '../..'); + +function sources(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sources(full); + return entry.name.endsWith('.ts') ? [full] : []; + }); +} + +/** Every relative import of a file, as a path relative to src/. */ +function imports(file: string): string[] { + const text = fs.readFileSync(file, 'utf8'); + const specs = [...text.matchAll(/(?:from|import)\s+['"](\.[^'"]*)['"]/g)].map((m) => m[1]!); + return specs.map((spec) => path.relative(SRC, path.resolve(path.dirname(file), spec)).split(path.sep).join('/')); +} + +const rel = (file: string) => path.relative(SRC, file).split(path.sep).join('/'); +const isTest = (file: string) => rel(file).split('/').includes('__tests__'); +const under = (target: string, dir: string) => target === dir || target.startsWith(`${dir}/`); + +function violations(files: string[], allowed: (file: string, target: string) => boolean): string[] { + return files.flatMap((file) => imports(file).filter((t) => !allowed(file, t)).map((t) => `${rel(file)} -> ${t}`)); +} + +describe('source layout', () => { + const all = sources(SRC); + + it('a driver imports only the SDK, the installer, generated code and its own folder', () => { + const drivers = all.filter((f) => rel(f).startsWith('drivers/')); + const bad = violations(drivers, (file, target) => { + const own = rel(file).split('/').slice(0, 2).join('/'); + return ['sdk', 'install', 'generated', own].some((dir) => under(target, dir)); + }); + expect(bad).toEqual([]); + }); + + it('the SDK and the installer import no driver and nothing of the host', () => { + const shared = all.filter((f) => /^(sdk|install)\//.test(rel(f)) && !isTest(f)); + const bad = violations(shared, (_file, target) => !under(target, 'drivers') && !under(target, 'host')); + expect(bad).toEqual([]); + }); + + it('outside the drivers, only the module list names a driver', () => { + const rest = all.filter((f) => !rel(f).startsWith('drivers/') && !isTest(f) && rel(f) !== 'host/modules.ts'); + const bad = violations(rest, (_file, target) => !under(target, 'drivers')); + expect(bad).toEqual([]); + }); + + it('every driver folder registers one module', () => { + const folders = fs.readdirSync(path.join(SRC, 'drivers'), { withFileTypes: true }).filter((e) => e.isDirectory()); + const list = fs.readFileSync(path.join(SRC, 'host/modules.ts'), 'utf8'); + for (const folder of folders) { + expect(fs.existsSync(path.join(SRC, 'drivers', folder.name, 'module.ts')), folder.name).toBe(true); + expect(list, folder.name).toContain(`'../drivers/${folder.name}/module'`); + } + }); +}); diff --git a/packages/agent-host/src/host/__tests__/modules.test.ts b/packages/agent-host/src/host/__tests__/modules.test.ts new file mode 100644 index 00000000..eaa01c27 --- /dev/null +++ b/packages/agent-host/src/host/__tests__/modules.test.ts @@ -0,0 +1,93 @@ +/** + * The module list: which modules a host loads, the order it builds them in + * (every claim before any driver), and the warm-up mode. + */ +import { describe, expect, it } from 'vitest'; +import { FakeDriver } from '../../drivers/fake/driver'; +import { DRIVER_MODULES, loadDrivers, selectModules, warmModules } from '../modules'; +import type { DriverModule } from '../../sdk/module'; + +const base = (env: NodeJS.ProcessEnv, log: string[] = []) => ({ + env, + lookupEnv: env, + cacheDir: '/cache', + registry: 'https://registry.example', + log: (m: string) => log.push(m), +}); + +describe('selectModules', () => { + it('loads every agent but the test ones by default, in catalog order', () => { + expect(selectModules({}, () => {}).map((m) => m.id)).toEqual(['claude-code', 'opencode', 'deepseek-harness']); + }); + + it('loads what CODEDECK_AGENT_HOST_DRIVERS names, in its order, and skips an unknown id', () => { + const log: string[] = []; + const picked = selectModules({ CODEDECK_AGENT_HOST_DRIVERS: ' fake, pi ,opencode' }, (m) => log.push(m)); + expect(picked.map((m) => m.id)).toEqual(['fake', 'opencode']); + expect(log).toEqual(["[agent-host] unknown driver 'pi' — skipped"]); + }); + + it('module ids are unique', () => { + const ids = DRIVER_MODULES.map((m) => m.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe('loadDrivers', () => { + it('lets every module claim its variables before any driver is built', async () => { + const seen: Array<[string, string | undefined, string | undefined]> = []; + const module = (id: string, claims?: string): DriverModule => ({ + id, + label: id, + ...(claims + ? { + claimEnv: (env: NodeJS.ProcessEnv) => { + const own = { ...env }; + delete env[claims]; + return own; + }, + } + : {}), + create: (ctx) => { + seen.push([id, ctx.env.SECRET, ctx.ownEnv.SECRET]); + return new FakeDriver(); + }, + }); + const env: NodeJS.ProcessEnv = { SECRET: 'k' }; + const drivers = await loadDrivers([module('a'), module('b', 'SECRET')], base(env)); + expect(drivers).toHaveLength(2); + // `a` comes first but already sees the variable gone; `b` keeps it. + expect(seen).toEqual([ + ['a', undefined, undefined], + ['b', undefined, 'k'], + ]); + }); +}); + +describe('warmModules', () => { + it('installs what is missing and leaves alone what the machine has', async () => { + const installed: string[] = []; + const runtime = (found: string | null) => ({ + find: () => found, + install: async () => { + installed.push(found ?? 'x'); + return '/cache/x'; + }, + }); + const log: string[] = []; + await warmModules( + [ + { id: 'has', label: 'Has', runtime: runtime('/usr/bin/has'), create: () => new FakeDriver() }, + { id: 'needs', label: 'Needs', runtime: runtime(null), create: () => new FakeDriver() }, + { id: 'none', label: 'None', create: () => new FakeDriver() }, + ], + base({}, log), + ); + expect(installed).toEqual(['x']); + expect(log).toEqual([ + '[warm] Has is already available (/usr/bin/has)', + '[warm] Needs: /cache/x', + '[warm] every agent this host was asked for is installed', + ]); + }); +}); diff --git a/packages/agent-host/src/host.ts b/packages/agent-host/src/host/host.ts similarity index 99% rename from packages/agent-host/src/host.ts rename to packages/agent-host/src/host/host.ts index 8f890c08..c469afc0 100644 --- a/packages/agent-host/src/host.ts +++ b/packages/agent-host/src/host/host.ts @@ -10,7 +10,7 @@ * - a request the host sent is settled exactly once — by the bridge's reply, * or with `cancelled` when the host shuts down. */ -import type { Driver, DriverSession, McpManager, PluginManager, SessionContext } from './driver'; +import type { Driver, DriverSession, McpManager, PluginManager, SessionContext } from '../sdk/driver'; import { DRIVER_PROTOCOL_VERSION, type BridgeFrame, @@ -18,7 +18,7 @@ import { type HostMessage, type SelectOutcome, type SessionEvent, -} from './types'; +} from '../sdk/types'; export interface HostIo { /** Write one frame (a single line, no trailing newline). */ diff --git a/packages/agent-host/src/host/main.ts b/packages/agent-host/src/host/main.ts new file mode 100644 index 00000000..f81bb270 --- /dev/null +++ b/packages/agent-host/src/host/main.ts @@ -0,0 +1,85 @@ +/** + * The agent host process: the bridge spawns `node main.js`, writes driver + * protocol frames to its stdin and reads them from its stdout, one per line. + * Everything else — logs from the host, the drivers or the SDKs — goes to + * stderr, which the bridge copies into its own log. + * + * Configuration is the environment the bridge passes down: + * CODEDECK_AGENT_HOST_DRIVERS comma-separated drivers to load + * (default `claude-code,opencode,deepseek-harness`; + * `fake` for tests) + * CODEDECK_CLAUDE_PATH the `claude` executable + * CODEDECK_TEST_MODE=1 Claude Code sessions answer canned /test-* commands + * CODEDECK_OPENCODE_SERVER_URL an OpenCode server to use + * CODEDECK_OPENCODE_AUTO_START=1, CODEDECK_OPENCODE_PATH, CODEDECK_OPENCODE_PORT + * spawn and manage an OpenCode server instead + * CODEDECK_DEEPSEEK_PATH the DeepSeek Harness CLI to run (its + * `lib/bin.js`, or an executable of your own); + * unset = the runtime this build pins + * CODEDECK_DEEPSEEK_HOME `$DSH_HOME`, the harness's state root + * (the bridge passes `/dsh`) + * CODEDECK_AGENT_HOST_WARM=1 install the enabled agents' runtimes and + * exit, without serving anything (an image + * build, or a first-run warm-up) + * CODEDECK_AGENT_CACHE where agent binaries installed on demand live + * (the bridge passes `/agents`; `bin/` + * in it links each one under a stable name) + * CODEDECK_NPM_REGISTRY an npm mirror to install them from + */ +import * as readline from 'node:readline'; +import pkg from '../../package.json'; +import { agentCacheDir, registryUrl, withoutAgentBin } from '../install/agentInstall'; +import { AgentHost, type HostIo } from './host'; +import { loadDrivers, selectModules, warmModules } from './modules'; + +// stdout carries protocol frames only; a stray console.log from any library +// would corrupt the stream, so every console method writes to stderr. +for (const method of ['log', 'info', 'debug', 'warn'] as const) { + console[method] = (...args: unknown[]) => console.error(...args); +} + +const log = (message: string): void => { + process.stderr.write(`${message} +`); +}; + +process.on('uncaughtException', (err) => log(`[agent-host] uncaught exception: ${err instanceof Error ? err.stack : String(err)}`)); +process.on('unhandledRejection', (err) => log(`[agent-host] unhandled rejection: ${err instanceof Error ? err.stack : String(err)}`)); + +/** What every module is handed, from the host's environment. */ +function driverEnv(env: NodeJS.ProcessEnv) { + const cacheDir = agentCacheDir(env); + return { env, lookupEnv: withoutAgentBin(env, cacheDir), cacheDir, registry: registryUrl(env), log }; +} + +async function main(): Promise { + const warm = process.env.CODEDECK_AGENT_HOST_WARM?.trim(); + if (warm === '1' || warm === 'true') { + // A build step or a first-run warm-up: install what the asked-for + // agents run from (an image built with BUNDLE_AGENTS=1, so a host with + // no internet has every runtime; or so the first session does not wait + // for a download), then exit without ever reading stdin. + await warmModules(selectModules(process.env, log), driverEnv(process.env)); + return; + } + const io: HostIo = { + write: (line) => { + process.stdout.write(`${line}\n`); + }, + log, + }; + const host = new AgentHost(await loadDrivers(selectModules(process.env, log), driverEnv(process.env)), io, pkg.version); + const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + // Lines are handled concurrently: a slow request (a model list, a mode + // switch) must not hold up the replies that unblock a waiting agent. + lines.on('line', (line) => void host.handleLine(line)); + lines.on('close', () => { + // stdin closed: the bridge is gone or restarting us. Stop every agent. + void host.shutdown().finally(() => process.exit(0)); + }); +} + +main().catch((error: unknown) => { + log(`[agent-host] could not start: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`); + process.exitCode = 1; +}); diff --git a/packages/agent-host/src/host/modules.ts b/packages/agent-host/src/host/modules.ts new file mode 100644 index 00000000..b987f7c5 --- /dev/null +++ b/packages/agent-host/src/host/modules.ts @@ -0,0 +1,64 @@ +/** + * Every agent this host can run — the one place that names them. The order + * is the catalog's. + */ +import { claudeModule } from '../drivers/claude/module'; +import { deepSeekModule } from '../drivers/deepseek/module'; +import { fakeModule } from '../drivers/fake/module'; +import { openCodeModule } from '../drivers/opencode/module'; +import type { Driver } from '../sdk/driver'; +import type { DriverEnv, DriverModule } from '../sdk/module'; + +export const DRIVER_MODULES: readonly DriverModule[] = [claudeModule, openCodeModule, deepSeekModule, fakeModule]; + +/** + * The modules `CODEDECK_AGENT_HOST_DRIVERS` (comma-separated ids) asks for, + * in its order; unset = every module not marked `explicitOnly`. An unknown + * id is logged and skipped. + */ +export function selectModules( + env: NodeJS.ProcessEnv, + log: (message: string) => void, + modules: readonly DriverModule[] = DRIVER_MODULES, +): DriverModule[] { + const asked = env.CODEDECK_AGENT_HOST_DRIVERS; + if (asked === undefined) return modules.filter((m) => !m.explicitOnly); + const selected: DriverModule[] = []; + for (const id of asked.split(',').map((n) => n.trim()).filter(Boolean)) { + const module = modules.find((m) => m.id === id); + if (module) selected.push(module); + else log(`[agent-host] unknown driver '${id}' — skipped`); + } + return selected; +} + +type Base = Omit; + +/** Build the modules' drivers. Every module claims its own variables first, + * since every agent process inherits the environment left over. */ +export async function loadDrivers(modules: readonly DriverModule[], base: Base): Promise { + const own = modules.map((m) => m.claimEnv?.(base.env) ?? base.env); + const drivers: Driver[] = []; + for (const [i, module] of modules.entries()) { + drivers.push(await module.create({ ...base, ownEnv: own[i]! })); + } + return drivers; +} + +/** + * Install what the modules' agents run from — the same installs a first + * session would trigger — leaving alone any agent the machine already has. + */ +export async function warmModules(modules: readonly DriverModule[], base: Base): Promise { + for (const module of modules) { + if (!module.runtime) continue; + const ctx = { ...base, ownEnv: base.env }; + const found = module.runtime.find(ctx); + if (found) { + base.log(`[warm] ${module.label} is already available (${found})`); + continue; + } + base.log(`[warm] ${module.label}: ${await module.runtime.install(ctx)}`); + } + base.log('[warm] every agent this host was asked for is installed'); +} diff --git a/packages/agent-host/src/__tests__/agentInstall.test.ts b/packages/agent-host/src/install/__tests__/agentInstall.test.ts similarity index 100% rename from packages/agent-host/src/__tests__/agentInstall.test.ts rename to packages/agent-host/src/install/__tests__/agentInstall.test.ts diff --git a/packages/agent-host/src/__tests__/lockfilePins.test.ts b/packages/agent-host/src/install/__tests__/lockfilePins.test.ts similarity index 96% rename from packages/agent-host/src/__tests__/lockfilePins.test.ts rename to packages/agent-host/src/install/__tests__/lockfilePins.test.ts index e8d6e5cf..782712c5 100644 --- a/packages/agent-host/src/__tests__/lockfilePins.test.ts +++ b/packages/agent-host/src/install/__tests__/lockfilePins.test.ts @@ -61,8 +61,8 @@ describe('src/generated/platformPackages.ts', () => { // `vitest run -u` (the gen:platform-packages script) rewrites it; a plain // run fails when it no longer matches the lockfile. it('matches pnpm-lock.yaml', async () => { - const lockfile = fs.readFileSync(path.resolve(__dirname, '../../../../pnpm-lock.yaml'), 'utf8'); - await expect(renderPlatformPackages(platformPackages(lockfile))).toMatchFileSnapshot('../generated/platformPackages.ts'); + const lockfile = fs.readFileSync(path.resolve(__dirname, '../../../../../pnpm-lock.yaml'), 'utf8'); + await expect(renderPlatformPackages(platformPackages(lockfile))).toMatchFileSnapshot('../../generated/platformPackages.ts'); }); }); @@ -167,9 +167,9 @@ describe('packageTree', () => { describe('src/generated/dshPackages.ts', () => { it('matches pnpm-lock.yaml', async () => { - const lockfile = fs.readFileSync(path.resolve(__dirname, '../../../../pnpm-lock.yaml'), 'utf8'); + const lockfile = fs.readFileSync(path.resolve(__dirname, '../../../../../pnpm-lock.yaml'), 'utf8'); await expect(renderDshPackages(packageTree(lockfile, 'packages/agent-host', '@deepseek-ai/dsh'))).toMatchFileSnapshot( - '../generated/dshPackages.ts', + '../../generated/dshPackages.ts', ); }); }); diff --git a/packages/agent-host/src/__tests__/platformPackages.test.ts b/packages/agent-host/src/install/__tests__/platformPackages.test.ts similarity index 88% rename from packages/agent-host/src/__tests__/platformPackages.test.ts rename to packages/agent-host/src/install/__tests__/platformPackages.test.ts index 54f1e4ee..8da777a2 100644 --- a/packages/agent-host/src/__tests__/platformPackages.test.ts +++ b/packages/agent-host/src/install/__tests__/platformPackages.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { claudePlatformPackage } from '../drivers/claude/install'; -import { openCodePlatformPackage } from '../drivers/opencode/install'; -import { PLATFORM_PACKAGES } from '../generated/platformPackages'; +import { claudePlatformPackage } from '../../drivers/claude/install'; +import { openCodePlatformPackage } from '../../drivers/opencode/install'; +import { PLATFORM_PACKAGES } from '../../generated/platformPackages'; /** Every machine a bridge may run on, as Node names it. */ const MACHINES: Array<[NodeJS.Platform, string, boolean]> = [ diff --git a/packages/agent-host/src/agentInstall.ts b/packages/agent-host/src/install/agentInstall.ts similarity index 99% rename from packages/agent-host/src/agentInstall.ts rename to packages/agent-host/src/install/agentInstall.ts index 061f869f..f6847aa4 100644 --- a/packages/agent-host/src/agentInstall.ts +++ b/packages/agent-host/src/install/agentInstall.ts @@ -20,8 +20,8 @@ import * as path from 'node:path'; import { Readable, Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { createGunzip, gunzipSync } from 'node:zlib'; -import { isFile, isMusl } from './executable'; -import { PLATFORM_PACKAGES } from './generated/platformPackages'; +import { isFile, isMusl } from '../sdk/executable'; +import { PLATFORM_PACKAGES } from '../generated/platformPackages'; import type { PackagePin, TreePackageEntry } from './lockfilePins'; const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; diff --git a/packages/agent-host/src/lockfilePins.ts b/packages/agent-host/src/install/lockfilePins.ts similarity index 97% rename from packages/agent-host/src/lockfilePins.ts rename to packages/agent-host/src/install/lockfilePins.ts index a2863e4b..ccc84113 100644 --- a/packages/agent-host/src/lockfilePins.ts +++ b/packages/agent-host/src/install/lockfilePins.ts @@ -254,9 +254,9 @@ export function renderPlatformPackages(pins: Record): string return ` ${JSON.stringify(name)}: { version: ${JSON.stringify(pin.version)}, integrity: ${JSON.stringify(pin.integrity)} },`; }); return [ - '// Generated from pnpm-lock.yaml by src/lockfilePins.ts — do not edit.', + '// Generated from pnpm-lock.yaml by src/install/lockfilePins.ts — do not edit.', "// Regenerate with this package's `gen:platform-packages` script.", - "import type { PackagePin } from '../lockfilePins';", + "import type { PackagePin } from '../install/lockfilePins';", '', 'export const PLATFORM_PACKAGES: Readonly> = {', ...rows, @@ -281,9 +281,9 @@ export function renderDshPackages(entries: TreePackageEntry[]): string { return ` { ${fields.join(', ')} },`; }); return [ - '// Generated from pnpm-lock.yaml by src/lockfilePins.ts — do not edit.', + '// Generated from pnpm-lock.yaml by src/install/lockfilePins.ts — do not edit.', "// Regenerate with this package's `gen:platform-packages` script.", - "import type { TreePackageEntry } from '../lockfilePins';", + "import type { TreePackageEntry } from '../install/lockfilePins';", '', 'export const DSH_PACKAGES: readonly TreePackageEntry[] = [', ...rows, diff --git a/packages/agent-host/src/main.ts b/packages/agent-host/src/main.ts deleted file mode 100644 index 51b45293..00000000 --- a/packages/agent-host/src/main.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * The agent host process: the bridge spawns `node main.js`, writes driver - * protocol frames to its stdin and reads them from its stdout, one per line. - * Everything else — logs from the host, the drivers or the SDKs — goes to - * stderr, which the bridge copies into its own log. - * - * Configuration is the environment the bridge passes down: - * CODEDECK_AGENT_HOST_DRIVERS comma-separated drivers to load - * (default `claude-code,opencode,deepseek-harness`; - * `fake` for tests) - * CODEDECK_CLAUDE_PATH the `claude` executable - * CODEDECK_TEST_MODE=1 Claude Code sessions answer canned /test-* commands - * CODEDECK_OPENCODE_SERVER_URL an OpenCode server to use - * CODEDECK_OPENCODE_AUTO_START=1, CODEDECK_OPENCODE_PATH, CODEDECK_OPENCODE_PORT - * spawn and manage an OpenCode server instead - * CODEDECK_DEEPSEEK_PATH the DeepSeek Harness CLI to run (its - * `lib/bin.js`, or an executable of your own); - * unset = the runtime this build pins - * CODEDECK_DEEPSEEK_HOME `$DSH_HOME`, the harness's state root - * (the bridge passes `/dsh`) - * CODEDECK_AGENT_HOST_WARM=1 install the enabled agents' runtimes and - * exit, without serving anything (an image - * build, or a first-run warm-up) - * CODEDECK_AGENT_CACHE where agent binaries installed on demand live - * (the bridge passes `/agents`; `bin/` - * in it links each one under a stable name) - * CODEDECK_NPM_REGISTRY an npm mirror to install them from - */ -import * as readline from 'node:readline'; -import pkg from '../package.json'; -import { agentCacheDir, installBinary, type PackagedBinary, registryUrl, withoutAgentBin } from './agentInstall'; -import type { Driver } from './driver'; -import { ClaudeDriver } from './drivers/claude/driver'; -import { RealSdkFacade, resolveClaudeExecutable } from './drivers/claude/facade'; -import { bundledClaudeExecutable, claudeBinary } from './drivers/claude/install'; -import { TestModeSdkFacade } from './drivers/claude/testModeFacade'; -import { DeepSeekDriver } from './drivers/deepseek/driver'; -import { takeDeepSeekEnv } from './drivers/deepseek/env'; -import { installDshTree } from './drivers/deepseek/install'; -import { DeepSeekMcp } from './drivers/deepseek/mcp'; -import { DeepSeekRuntime, dshHomeDir, dshProfileDir } from './drivers/deepseek/runtime'; -import { FakeDriver } from './drivers/fake'; -import { OpenCodeDriver } from './drivers/opencode/driver'; -import { openCodeBinary } from './drivers/opencode/install'; -import { resolveOpenCodePath } from './drivers/opencode/server'; -import { isFile } from './executable'; -import { AgentHost, type HostIo } from './host'; -import { httpGet, httpPost } from './net'; - -// stdout carries protocol frames only; a stray console.log from any library -// would corrupt the stream, so every console method writes to stderr. -for (const method of ['log', 'info', 'debug', 'warn'] as const) { - console[method] = (...args: unknown[]) => console.error(...args); -} - -const log = (message: string): void => { - process.stderr.write(`${message}\n`); -}; - -process.on('uncaughtException', (err) => log(`[agent-host] uncaught exception: ${err instanceof Error ? err.stack : String(err)}`)); -process.on('unhandledRejection', (err) => log(`[agent-host] unhandled rejection: ${err instanceof Error ? err.stack : String(err)}`)); - -/** The drivers this host runs, in the order they were asked for. */ -function driverNames(env: NodeJS.ProcessEnv): string[] { - return (env.CODEDECK_AGENT_HOST_DRIVERS ?? 'claude-code,opencode,deepseek-harness') - .split(',') - .map((n) => n.trim()) - .filter(Boolean); -} - -/** - * `CODEDECK_AGENT_HOST_WARM=1` with a driver list: fetch what those agents - * run from (the same installs a first session would trigger) and exit. An - * image built with BUNDLE_AGENTS=1 runs this at build time, so a host with no - * internet has every runtime already; an operator can run it to have the - * first session start without waiting for a download. - * - * Nothing is installed twice: each agent's own lookup decides, so an agent - * already on the machine (or bundled beside the host) is left alone. - */ -async function warmAgents(env: NodeJS.ProcessEnv, names: string[], cacheDir: string): Promise { - const cache = { cacheDir, registry: registryUrl(env), log }; - for (const name of names) { - switch (name) { - case 'claude-code': { - const found = env.CODEDECK_TEST_MODE === '1' ? 'test mode' : (resolveClaudeExecutable(undefined, env) ?? bundledClaudeExecutable()); - if (found) { - log(`[warm] Claude Code is already available (${found})`); - break; - } - log(`[warm] Claude Code: ${await installBinary(claudeBinary(), cache)}`); - break; - } - case 'opencode': { - const found = resolveOpenCodePath(undefined, env); - if (found) { - log(`[warm] OpenCode is already available (${found})`); - break; - } - log(`[warm] OpenCode: ${await installBinary(openCodeBinary(), cache)}`); - break; - } - case 'deepseek-harness': { - const explicit = env.CODEDECK_DEEPSEEK_PATH?.trim(); - if (explicit) { - log(`[warm] the DeepSeek Harness runs the CLI at ${explicit}`); - break; - } - log(`[warm] DeepSeek Harness: ${await installDshTree(cache)}`); - break; - } - case 'fake': - break; - default: - log(`[warm] unknown driver '${name}' — skipped`); - } - } - log('[warm] every agent this host was asked for is installed'); -} - -async function loadDrivers(env: NodeJS.ProcessEnv): Promise { - const names = driverNames(env); - // Before any driver starts a process: every agent's process inherits this - // environment, and the harness's endpoint and key are its driver's alone. - const harnessEnv = names.includes('deepseek-harness') ? takeDeepSeekEnv(env) : env; - const drivers: Driver[] = []; - const cacheDir = agentCacheDir(env); - const install = (binary: PackagedBinary) => (): Promise => - installBinary(binary, { cacheDir, registry: registryUrl(env), log }); - // Installed binaries are found through installBinary (the pinned version), - // never through their links in /bin on PATH. - const lookupEnv = withoutAgentBin(env, cacheDir); - for (const name of names) { - switch (name) { - case 'claude-code': { - const testMode = env.CODEDECK_TEST_MODE === '1'; - const claudePath = testMode ? null : (resolveClaudeExecutable(undefined, lookupEnv) ?? bundledClaudeExecutable()); - drivers.push( - new ClaudeDriver({ - facade: testMode ? new TestModeSdkFacade() : new RealSdkFacade(), - ...(claudePath ? { claudePath } : {}), - // Nothing on the machine: the driver installs it in the - // background, so the agent is listed right away and the first - // session waits for it. - ...(!testMode && !claudePath ? { installClaude: install(claudeBinary()) } : {}), - httpPost, - discoverModels: !testMode, - managePlugins: !testMode, - manageMcp: !testMode, - }), - ); - break; - } - case 'opencode': { - const port = env.CODEDECK_OPENCODE_PORT ? Number(env.CODEDECK_OPENCODE_PORT) : undefined; - drivers.push( - await OpenCodeDriver.create({ - ...(env.CODEDECK_OPENCODE_SERVER_URL ? { serverUrl: env.CODEDECK_OPENCODE_SERVER_URL } : {}), - autoStart: env.CODEDECK_OPENCODE_AUTO_START === '1' || env.CODEDECK_OPENCODE_AUTO_START === 'true', - ...(env.CODEDECK_OPENCODE_PATH ? { binaryPath: env.CODEDECK_OPENCODE_PATH } : {}), - installOpenCode: install(openCodeBinary()), - lookupEnv, - ...(port !== undefined && Number.isInteger(port) ? { port } : {}), - log, - }), - ); - break; - } - case 'deepseek-harness': { - const home = dshHomeDir(lookupEnv); - const dshPath = env.CODEDECK_DEEPSEEK_PATH?.trim(); - // The MCP servers live in this profile layer, and a harness process - // is told the layer's version as it starts: the same manager serves - // both, so a change after a process started is configuration it has - // not loaded. - const mcp = new DeepSeekMcp({ profileDir: dshProfileDir(home), log }); - const driver = DeepSeekDriver.create({ - runtime: new DeepSeekRuntime({ - // An explicit path is the operator's own harness; without one the - // pinned tree is installed on demand, at the version and sha512 - // pnpm-lock.yaml holds. - ...(dshPath ? { dshPath } : {}), - home, - cacheDir, - registry: registryUrl(env), - installDsh: installDshTree, - configVersion: () => mcp.version, - log, - }), - home, - mcp, - baseEnv: harnessEnv, - httpGet, - log, - }); - if (dshPath && !isFile(dshPath)) { - driver.setUnavailable(`CODEDECK_DEEPSEEK_PATH points at ${dshPath}, which is not a file.`); - } - drivers.push(driver); - break; - } - case 'fake': - drivers.push(new FakeDriver()); - break; - default: - log(`[agent-host] unknown driver '${name}' — skipped`); - } - } - return drivers; -} - -async function main(): Promise { - const warm = process.env.CODEDECK_AGENT_HOST_WARM?.trim(); - if (warm === '1' || warm === 'true') { - // A build step or a first-run warm-up: install, then exit without ever - // reading stdin. - await warmAgents(process.env, driverNames(process.env), agentCacheDir(process.env)); - return; - } - const io: HostIo = { - write: (line) => { - process.stdout.write(`${line}\n`); - }, - log, - }; - const host = new AgentHost(await loadDrivers(process.env), io, pkg.version); - const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); - // Lines are handled concurrently: a slow request (a model list, a mode - // switch) must not hold up the replies that unblock a waiting agent. - lines.on('line', (line) => void host.handleLine(line)); - lines.on('close', () => { - // stdin closed: the bridge is gone or restarting us. Stop every agent. - void host.shutdown().finally(() => process.exit(0)); - }); -} - -main().catch((error: unknown) => { - log(`[agent-host] could not start: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`); - process.exitCode = 1; -}); diff --git a/packages/agent-host/src/__tests__/commands.test.ts b/packages/agent-host/src/sdk/__tests__/commands.test.ts similarity index 100% rename from packages/agent-host/src/__tests__/commands.test.ts rename to packages/agent-host/src/sdk/__tests__/commands.test.ts diff --git a/packages/agent-host/src/__tests__/context.ts b/packages/agent-host/src/sdk/__tests__/context.ts similarity index 100% rename from packages/agent-host/src/__tests__/context.ts rename to packages/agent-host/src/sdk/__tests__/context.ts diff --git a/packages/agent-host/src/__tests__/executable.test.ts b/packages/agent-host/src/sdk/__tests__/executable.test.ts similarity index 100% rename from packages/agent-host/src/__tests__/executable.test.ts rename to packages/agent-host/src/sdk/__tests__/executable.test.ts diff --git a/packages/agent-host/src/commands.ts b/packages/agent-host/src/sdk/commands.ts similarity index 100% rename from packages/agent-host/src/commands.ts rename to packages/agent-host/src/sdk/commands.ts diff --git a/packages/agent-host/src/driver.ts b/packages/agent-host/src/sdk/driver.ts similarity index 97% rename from packages/agent-host/src/driver.ts rename to packages/agent-host/src/sdk/driver.ts index 3bc67d1d..55c38b0d 100644 --- a/packages/agent-host/src/driver.ts +++ b/packages/agent-host/src/sdk/driver.ts @@ -7,8 +7,9 @@ * bridge only through the `SessionContext` it is handed, never through the * pipe directly — the host does the framing, request ids and routing. * - * Adding an agent = one class implementing `Driver` plus a line in - * `main.ts`; the bridge needs no change. + * Adding an agent = a folder under `drivers/` with a class implementing + * `Driver` and a `DriverModule` registering it (`module.ts`), plus a line in + * `host/modules.ts`; the bridge needs no change. */ import type { AgentInfo, diff --git a/packages/agent-host/src/executable.ts b/packages/agent-host/src/sdk/executable.ts similarity index 100% rename from packages/agent-host/src/executable.ts rename to packages/agent-host/src/sdk/executable.ts diff --git a/packages/agent-host/src/mcp.ts b/packages/agent-host/src/sdk/mcp.ts similarity index 100% rename from packages/agent-host/src/mcp.ts rename to packages/agent-host/src/sdk/mcp.ts diff --git a/packages/agent-host/src/sdk/module.ts b/packages/agent-host/src/sdk/module.ts new file mode 100644 index 00000000..030be6a1 --- /dev/null +++ b/packages/agent-host/src/sdk/module.ts @@ -0,0 +1,57 @@ +/** + * A driver module: an agent's whole registration with the host — its id, how + * its driver is built from the environment, and where its runtime comes + * from. The host keeps a list of modules and knows no agent beyond it: + * loading, the warm-up mode and installing on demand all walk that list, so + * adding an agent is one folder under `drivers/` exporting one module, plus + * one line in the list. + */ +import type { Driver } from './driver'; + +/** What the host hands a module to build its driver or install its agent. */ +export interface DriverEnv { + /** The host's environment, with every loaded module's own variables + * taken out of it (see `claimEnv`). */ + env: NodeJS.ProcessEnv; + /** `env` for finding executables: without the links in the agent cache's + * `bin/`, so an installed agent is found through its pinned install, + * never through a link on PATH. */ + lookupEnv: NodeJS.ProcessEnv; + /** The environment this module's `claimEnv` answered, else `env`. */ + ownEnv: NodeJS.ProcessEnv; + /** Where agents installed on demand live. */ + cacheDir: string; + /** The npm registry they are installed from. */ + registry: string; + /** A line for the bridge log (stderr). Never pass secrets. */ + log(message: string): void; +} + +/** Where an agent's runtime comes from. */ +export interface AgentRuntime { + /** The runtime this machine already has — its path, or why none is needed + * — or null when it has to be installed. */ + find(ctx: DriverEnv): string | null; + /** Install the runtime at the version this build pins; resolves to where + * it is. Installing what is already installed is a lookup. */ + install(ctx: DriverEnv): Promise; +} + +export interface DriverModule { + /** The agent's catalog id, and its name in `CODEDECK_AGENT_HOST_DRIVERS`. */ + readonly id: string; + /** How logs name it. */ + readonly label: string; + /** Loaded only when named in `CODEDECK_AGENT_HOST_DRIVERS` (test drivers). */ + readonly explicitOnly?: boolean; + /** + * Take the variables that are this agent's alone out of the host's + * environment, which every agent process inherits, and answer the + * environment this driver runs with (`ownEnv`). Called for every loaded + * module before any driver is built. + */ + claimEnv?(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv; + create(ctx: DriverEnv): Driver | Promise; + /** Absent: the agent needs nothing installed. */ + readonly runtime?: AgentRuntime; +} diff --git a/packages/agent-host/src/net.ts b/packages/agent-host/src/sdk/net.ts similarity index 100% rename from packages/agent-host/src/net.ts rename to packages/agent-host/src/sdk/net.ts diff --git a/packages/agent-host/src/provider.ts b/packages/agent-host/src/sdk/provider.ts similarity index 100% rename from packages/agent-host/src/provider.ts rename to packages/agent-host/src/sdk/provider.ts diff --git a/packages/agent-host/src/tools.ts b/packages/agent-host/src/sdk/tools.ts similarity index 100% rename from packages/agent-host/src/tools.ts rename to packages/agent-host/src/sdk/tools.ts diff --git a/packages/agent-host/src/transcript.ts b/packages/agent-host/src/sdk/transcript.ts similarity index 100% rename from packages/agent-host/src/transcript.ts rename to packages/agent-host/src/sdk/transcript.ts diff --git a/packages/agent-host/src/types.ts b/packages/agent-host/src/sdk/types.ts similarity index 97% rename from packages/agent-host/src/types.ts rename to packages/agent-host/src/sdk/types.ts index f2e6a7c0..97c3022d 100644 --- a/packages/agent-host/src/types.ts +++ b/packages/agent-host/src/sdk/types.ts @@ -5,7 +5,7 @@ * bridge writes, `_Deserialize` what the bridge accepts. The host reads the * first and writes the second. */ -import type * as G from './generated/protocol'; +import type * as G from '../generated/protocol'; // What the host receives. export type BridgeFrame = G.Frame_Serialize;