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;