diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index 25aca5189..ce01598cf 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -34,6 +34,20 @@ interface UserBasic { is_email_verified?: boolean | null; } +/** + * Per-task configuration stored on the Django `Task.options` JSONField. + * Open-ended so we can add new keys without OpenAPI churn; today only + * `add_ons` is consumed by the agent runtime. + * + * Server-side requires `options = models.JSONField(default=dict, blank=True)` + * on the Task model plus a matching serializer entry. Until that migration + * lands, this field will be absent on every Task returned by the API. + */ +export interface TaskOptions { + /** Keys are add-on names registered with `@posthog/agent`'s AddOnRegistry. */ + add_ons?: Record>; +} + export interface Task { id: string; task_number: number | null; @@ -51,6 +65,7 @@ export interface Task { json_schema?: Record | null; signal_report?: string | null; internal?: boolean; + options?: TaskOptions; latest_run?: TaskRun; } diff --git a/packages/agent/package.json b/packages/agent/package.json index d836afe26..cf8aa3146 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -28,6 +28,22 @@ "types": "./dist/types.d.ts", "import": "./dist/types.js" }, + "./add-ons/types": { + "types": "./dist/add-ons/types.d.ts", + "import": "./dist/add-ons/types.js" + }, + "./add-ons/registry": { + "types": "./dist/add-ons/registry.d.ts", + "import": "./dist/add-ons/registry.js" + }, + "./add-ons/default-registry": { + "types": "./dist/add-ons/default-registry.d.ts", + "import": "./dist/add-ons/default-registry.js" + }, + "./add-ons/rtk": { + "types": "./dist/add-ons/rtk.d.ts", + "import": "./dist/add-ons/rtk.js" + }, "./adapters/claude/questions/utils": { "types": "./dist/adapters/claude/questions/utils.d.ts", "import": "./dist/adapters/claude/questions/utils.js" diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index cb712669c..74a0dc3c5 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -51,6 +51,7 @@ import { POSTHOG_METHODS, POSTHOG_NOTIFICATIONS, } from "../../acp-extensions"; +import { defaultAddOnRegistry } from "../../add-ons/default-registry"; import { createEnrichment, type Enrichment, @@ -1169,6 +1170,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ? (meta.permissionMode as CodeExecutionMode) : "default"; + const addOnContribution = await defaultAddOnRegistry.collect(meta?.addOns, { + cwd, + adapter: "claude", + logger: this.logger, + }); + const options = buildSessionOptions({ cwd, mcpServers, @@ -1176,6 +1183,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { canUseTool: this.createCanUseTool(sessionId, meta?.allowedDomains), logger: this.logger, systemPrompt, + addOnContribution, userProvidedOptions: meta?.claudeCode?.options, sessionId, isResume, diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index ae0489bb8..a05e25f08 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -1,9 +1,10 @@ import * as os from "node:os"; import * as path from "node:path"; -import { describe, expect, it } from "vitest"; +import type { HookCallback } from "@anthropic-ai/claude-agent-sdk"; +import { describe, expect, it, vi } from "vitest"; import { Logger } from "../../../utils/logger"; import { SUBAGENT_REWRITES } from "../hooks"; -import { buildSessionOptions } from "./options"; +import { appendToSystemPrompt, buildSessionOptions } from "./options"; import { SettingsManager } from "./settings"; function makeParams() { @@ -69,4 +70,133 @@ describe("buildSessionOptions", () => { expect(options.agents?.["ph-explore"]).toEqual(override); }); + + describe("add-on contribution wiring", () => { + it("places add-on PreToolUse hooks BEFORE the built-in permission gate", () => { + // Ordering invariant called out in options.ts: a rewrite from rtk/etc. + // must run before createPreToolUseHook so the permission check sees the + // rewritten command, not the raw one. + const addOnHook: HookCallback = vi.fn(); + const options = buildSessionOptions({ + ...makeParams(), + addOnContribution: { preToolUse: [addOnHook] }, + }); + + const preToolUse = options.hooks?.PreToolUse; + expect(preToolUse).toBeDefined(); + // First group is the add-on group, second group is the built-ins. + expect(preToolUse?.[0].hooks).toContain(addOnHook); + expect(preToolUse?.[1].hooks?.length ?? 0).toBeGreaterThan(0); + expect(preToolUse?.[1].hooks).not.toContain(addOnHook); + }); + + it("places add-on PostToolUse hooks AFTER the built-in post-tool group", () => { + const addOnHook: HookCallback = vi.fn(); + const options = buildSessionOptions({ + ...makeParams(), + addOnContribution: { postToolUse: [addOnHook] }, + }); + + const postToolUse = options.hooks?.PostToolUse; + expect(postToolUse).toBeDefined(); + // Built-in group first, then add-on group last. + const lastGroup = postToolUse?.[postToolUse.length - 1]; + expect(lastGroup?.hooks).toContain(addOnHook); + }); + + it("omits the add-on PreToolUse group entirely when contribution is empty", () => { + // Regression guard: an empty hooks array should not insert a phantom + // group ahead of the built-in permission gate. + const options = buildSessionOptions({ + ...makeParams(), + addOnContribution: { preToolUse: [] }, + }); + + const preToolUse = options.hooks?.PreToolUse; + // Only the built-in group should be present. + expect(preToolUse?.length).toBe(1); + }); + + it("appends systemPromptAppend to the default preset's append field", () => { + const options = buildSessionOptions({ + ...makeParams(), + addOnContribution: { systemPromptAppend: "[FROM_ADDON]" }, + }); + + const prompt = options.systemPrompt; + expect(prompt).toMatchObject({ type: "preset", preset: "claude_code" }); + // The default APPENDED_INSTRUCTIONS sits before the add-on text. + const append = (prompt as { append?: string }).append ?? ""; + expect(append.endsWith("[FROM_ADDON]")).toBe(true); + }); + + it("concatenates systemPromptAppend onto a string systemPrompt", () => { + const options = buildSessionOptions({ + ...makeParams(), + systemPrompt: "BASE_PROMPT", + addOnContribution: { systemPromptAppend: "_FROM_ADDON" }, + }); + + expect(options.systemPrompt).toBe("BASE_PROMPT_FROM_ADDON"); + }); + + it("merges add-on env vars and lets them win over the defaults", () => { + const options = buildSessionOptions({ + ...makeParams(), + addOnContribution: { + env: { + // Override one default to prove last-write-wins + ENABLE_TOOL_SEARCH: "off", + // And add a brand-new key + ADDON_INJECTED: "yes", + }, + }, + }); + + expect(options.env?.ENABLE_TOOL_SEARCH).toBe("off"); + expect(options.env?.ADDON_INJECTED).toBe("yes"); + // Defaults that the add-on did not touch still come through. + expect(options.env?.ELECTRON_RUN_AS_NODE).toBe("1"); + }); + + it("does not break when addOnContribution is omitted entirely", () => { + // Regression guard: the optional contribution must not be required. + const options = buildSessionOptions(makeParams()); + + expect(options.hooks?.PreToolUse?.length).toBe(1); + expect(options.systemPrompt).toMatchObject({ type: "preset" }); + }); + }); +}); + +describe("appendToSystemPrompt", () => { + it("returns the input unchanged when there is nothing to append", () => { + expect(appendToSystemPrompt("hello", undefined)).toBe("hello"); + const preset = { type: "preset" as const, preset: "claude_code" as const }; + expect(appendToSystemPrompt(preset, undefined)).toBe(preset); + }); + + it("concatenates onto a string systemPrompt", () => { + expect(appendToSystemPrompt("base", "_extra")).toBe("base_extra"); + }); + + it("appends to the `append` field of a preset object", () => { + const result = appendToSystemPrompt( + { type: "preset", preset: "claude_code", append: "existing-" }, + "added", + ); + expect(result).toEqual({ + type: "preset", + preset: "claude_code", + append: "existing-added", + }); + }); + + it("treats a preset without an `append` field as if it were empty", () => { + const result = appendToSystemPrompt( + { type: "preset", preset: "claude_code" }, + "first-time", + ) as { append?: string }; + expect(result.append).toBe("first-time"); + }); }); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 4da0fd3af..7558d014a 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -4,12 +4,14 @@ import * as os from "node:os"; import * as path from "node:path"; import type { CanUseTool, + HookCallback, McpServerConfig, Options, OutputFormat, SpawnedProcess, SpawnOptions, } from "@anthropic-ai/claude-agent-sdk"; +import type { AddOnContribution } from "../../../add-ons/types"; import type { FileEnrichmentDeps } from "../../../enrichment/file-enricher"; import { IS_ROOT } from "../../../utils/common"; import type { Logger } from "../../../utils/logger"; @@ -56,6 +58,12 @@ export interface BuildOptionsParams { effort?: EffortLevel; enrichmentDeps?: FileEnrichmentDeps; enrichedReadCache?: EnrichedReadCache; + /** + * Pre-resolved contribution from the {@link AddOnRegistry}. Merged into + * `hooks`, `env`, and `systemPrompt` so add-ons can prepend Bash rewriters, + * inject env vars, and append system-prompt text. + */ + addOnContribution?: AddOnContribution; /** Cloud task session — enables the signed-commit guard. */ cloudMode?: boolean; } @@ -92,6 +100,26 @@ export function buildSystemPrompt( return defaultPrompt; } +export function appendToSystemPrompt( + systemPrompt: Options["systemPrompt"], + extra: string | undefined, +): Options["systemPrompt"] { + if (!extra) return systemPrompt; + if (typeof systemPrompt === "string") return systemPrompt + extra; + if ( + typeof systemPrompt === "object" && + systemPrompt !== null && + "type" in systemPrompt && + systemPrompt.type === "preset" + ) { + return { + ...systemPrompt, + append: (systemPrompt.append ?? "") + extra, + }; + } + return systemPrompt; +} + function buildMcpServers( userServers: Record | undefined, acpServers: Record, @@ -104,7 +132,9 @@ function buildMcpServers( }; } -function buildEnvironment(): Record { +function buildEnvironment( + addOnEnv?: Record, +): Record { const bedrockFallbackHeader = "x-posthog-use-bedrock-fallback: true"; const existingCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS; const customHeaders = existingCustomHeaders @@ -121,6 +151,9 @@ function buildEnvironment(): Record { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1", // Route to AWS Bedrock as a fallback when Anthropic returns 5xx ANTHROPIC_CUSTOM_HEADERS: customHeaders, + // Add-on contributions win over our defaults so they can override e.g. + // ANTHROPIC_CUSTOM_HEADERS for proxy add-ons. + ...(addOnEnv ?? {}), }; } @@ -132,6 +165,8 @@ function buildHooks( enrichmentDeps: FileEnrichmentDeps | undefined, enrichedReadCache: EnrichedReadCache | undefined, registeredAgents: ReadonlySet, + addOnPreToolUse: HookCallback[] | undefined, + addOnPostToolUse: HookCallback[] | undefined, cloudMode: boolean, ): Options["hooks"] { const postToolUseHooks = [createPostToolUseHook({ onModeChange })]; @@ -141,21 +176,34 @@ function buildHooks( ); } - const preToolUseHooks = [ + const builtInPreToolUseHooks = [ createPreToolUseHook(settingsManager, logger), createSubagentRewriteHook(logger, registeredAgents), ]; if (cloudMode) { - preToolUseHooks.push(createSignedCommitGuardHook(logger)); + builtInPreToolUseHooks.push(createSignedCommitGuardHook(logger)); } + // Add-on PreToolUse hooks run BEFORE the built-in permission gate so they + // can rewrite tool input (e.g. rtk wrapping a Bash command) and have those + // changes reflected in the permission-check pass and the signed-commit + // guard (when cloudMode is on). + const preToolUseGroups: NonNullable["PreToolUse"] = [ + ...(userHooks?.PreToolUse || []), + ...(addOnPreToolUse?.length ? [{ hooks: addOnPreToolUse }] : []), + { hooks: builtInPreToolUseHooks }, + ]; + + const postToolUseGroups: NonNullable["PostToolUse"] = [ + ...(userHooks?.PostToolUse || []), + { hooks: postToolUseHooks }, + ...(addOnPostToolUse?.length ? [{ hooks: addOnPostToolUse }] : []), + ]; + return { ...userHooks, - PostToolUse: [ - ...(userHooks?.PostToolUse || []), - { hooks: postToolUseHooks }, - ], - PreToolUse: [...(userHooks?.PreToolUse || []), { hooks: preToolUseHooks }], + PostToolUse: postToolUseGroups, + PreToolUse: preToolUseGroups, }; } @@ -324,10 +372,15 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { const agents = buildAgents(params.userProvidedOptions?.agents); const registeredAgentNames = new Set(Object.keys(agents)); + const resolvedSystemPrompt = appendToSystemPrompt( + params.systemPrompt ?? buildSystemPrompt(), + params.addOnContribution?.systemPromptAppend, + ); + const options: Options = { ...params.userProvidedOptions, betas: ["context-1m-2025-08-07"], - systemPrompt: params.systemPrompt ?? buildSystemPrompt(), + systemPrompt: resolvedSystemPrompt, settingSources: ["user", "project", "local"], stderr: (err) => params.logger.error(err), cwd: params.cwd, @@ -347,7 +400,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.mcpServers, loadUserClaudeJsonMcpServers(params.cwd, params.logger), ), - env: buildEnvironment(), + env: buildEnvironment(params.addOnContribution?.env), hooks: buildHooks( params.userProvidedOptions?.hooks, params.onModeChange, @@ -356,6 +409,8 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.enrichmentDeps, params.enrichedReadCache, registeredAgentNames, + params.addOnContribution?.preToolUse, + params.addOnContribution?.postToolUse, params.cloudMode ?? false, ), outputFormat: params.outputFormat, diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index da75bb62d..eef760eb3 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -8,6 +8,7 @@ import type { Query, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import type { AddOnConfig } from "../../add-ons/types"; import type { Pushable } from "../../utils/streams"; import type { BaseSession } from "../base-acp-agent"; import type { McpToolApprovals } from "./mcp/tool-metadata"; @@ -137,4 +138,11 @@ export type NewSessionMeta = { options?: Options; emitRawSDKMessages?: boolean | SDKMessageFilter[]; }; + /** + * Add-on configuration sourced from `task.options.add_ons`. Keys are + * add-on names registered in the default `AddOnRegistry`; values are + * opaque options blobs validated per-add-on at session start. Unknown + * or unsupported names are skipped with a warning. + */ + addOns?: AddOnConfig; }; diff --git a/packages/agent/src/adapters/codex/codex-agent.test.ts b/packages/agent/src/adapters/codex/codex-agent.test.ts index 382fe6131..aee640181 100644 --- a/packages/agent/src/adapters/codex/codex-agent.test.ts +++ b/packages/agent/src/adapters/codex/codex-agent.test.ts @@ -58,11 +58,20 @@ vi.mock("node:fs", async (importActual) => { return { ...actual, existsSync: vi.fn(actual.existsSync) }; }); +const mockCollect = vi.fn(); +vi.mock("../../add-ons/default-registry", () => ({ + defaultAddOnRegistry: { + collect: (...args: unknown[]) => mockCollect(...args), + }, +})); + import { CodexAcpAgent } from "./codex-agent"; describe("CodexAcpAgent", () => { beforeEach(() => { vi.clearAllMocks(); + // Default: no add-on contribution. Individual tests override. + mockCollect.mockResolvedValue({}); }); function createAgent( @@ -559,4 +568,169 @@ describe("CodexAcpAgent", () => { // turn is persisted even if the underlying prompt fails. expect(callOrder).toEqual(["sessionUpdate", "sessionUpdate", "prompt"]); }); + + describe("add-on contribution wiring", () => { + function stubNewSessionResponse(sessionId = "session-add-on") { + mockCodexConnection.newSession.mockResolvedValue({ + sessionId, + modes: { currentModeId: "auto", availableModes: [] }, + configOptions: [], + } satisfies Partial); + } + + function stubLoadSessionResponse() { + mockCodexConnection.loadSession.mockResolvedValue({ + modes: { currentModeId: "auto", availableModes: [] }, + configOptions: [], + } satisfies Partial); + } + + it("forwards _meta.addOns to the registry on newSession", async () => { + const { agent } = createAgent(); + stubNewSessionResponse(); + + await agent.newSession({ + cwd: "/tmp/run", + _meta: { addOns: { "some-add-on": { x: 1 } } }, + } as never); + + expect(mockCollect).toHaveBeenCalledWith( + { "some-add-on": { x: 1 } }, + expect.objectContaining({ adapter: "codex", cwd: "/tmp/run" }), + ); + }); + + it("appends systemPromptAppend to _meta.systemPrompt on newSession", async () => { + const { agent } = createAgent(); + stubNewSessionResponse(); + mockCollect.mockResolvedValue({ systemPromptAppend: "_ADDON_TAIL" }); + + await agent.newSession({ + cwd: process.cwd(), + _meta: { + systemPrompt: "BASE", + addOns: { anything: {} }, + }, + } as never); + + expect(mockCodexConnection.newSession).toHaveBeenCalledWith( + expect.objectContaining({ + _meta: expect.objectContaining({ + systemPrompt: "BASE_ADDON_TAIL", + }), + }), + ); + }); + + it("creates _meta.systemPrompt from scratch when none was supplied", async () => { + const { agent } = createAgent(); + stubNewSessionResponse(); + mockCollect.mockResolvedValue({ systemPromptAppend: "FRESH" }); + + await agent.newSession({ + cwd: process.cwd(), + _meta: { addOns: {} }, + } as never); + + expect(mockCodexConnection.newSession).toHaveBeenCalledWith( + expect.objectContaining({ + _meta: expect.objectContaining({ systemPrompt: "FRESH" }), + }), + ); + }); + + it("calls the registry from loadSession and applies the contribution", async () => { + const { agent } = createAgent(); + stubLoadSessionResponse(); + mockCollect.mockResolvedValue({ systemPromptAppend: "_LOAD" }); + + await agent.loadSession({ + sessionId: "s1", + cwd: process.cwd(), + _meta: { systemPrompt: "PRE", addOns: {} }, + } as never); + + expect(mockCollect).toHaveBeenCalledTimes(1); + expect(mockCodexConnection.loadSession).toHaveBeenCalledWith( + expect.objectContaining({ + _meta: expect.objectContaining({ systemPrompt: "PRE_LOAD" }), + }), + ); + }); + + it("calls the registry from unstable_resumeSession and applies the contribution", async () => { + const { agent } = createAgent(); + stubLoadSessionResponse(); // resume forwards to loadSession internally + mockCollect.mockResolvedValue({ systemPromptAppend: "_RESUME" }); + + await agent.unstable_resumeSession({ + sessionId: "s1", + cwd: process.cwd(), + mcpServers: [], + _meta: { systemPrompt: "PRE", addOns: {} }, + } as never); + + expect(mockCollect).toHaveBeenCalledTimes(1); + expect(mockCodexConnection.loadSession).toHaveBeenCalledWith( + expect.objectContaining({ + _meta: expect.objectContaining({ systemPrompt: "PRE_RESUME" }), + }), + ); + }); + + it("calls the registry from unstable_forkSession and applies the contribution", async () => { + const { agent } = createAgent(); + stubNewSessionResponse("forked"); // fork forwards to newSession internally + mockCollect.mockResolvedValue({ systemPromptAppend: "_FORK" }); + + await agent.unstable_forkSession({ + cwd: process.cwd(), + mcpServers: [], + _meta: { systemPrompt: "PRE", addOns: {} }, + } as never); + + expect(mockCollect).toHaveBeenCalledTimes(1); + expect(mockCodexConnection.newSession).toHaveBeenCalledWith( + expect.objectContaining({ + _meta: expect.objectContaining({ systemPrompt: "PRE_FORK" }), + }), + ); + }); + + it("drops env contributions on Codex since the subprocess is already spawned", async () => { + const { agent } = createAgent(); + stubNewSessionResponse(); + mockCollect.mockResolvedValue({ env: { ANTHROPIC_KEY: "leaked" } }); + + await agent.newSession({ + cwd: process.cwd(), + _meta: { addOns: {} }, + } as never); + + const forwardedMeta = ( + mockCodexConnection.newSession.mock.calls[0][0] as { _meta?: unknown } + )._meta; + // env never bleeds into the forwarded request. + expect(forwardedMeta).not.toEqual( + expect.objectContaining({ env: expect.anything() }), + ); + }); + + it("passes through unchanged when the contribution is empty", async () => { + const { agent } = createAgent(); + stubNewSessionResponse(); + mockCollect.mockResolvedValue({}); + + await agent.newSession({ + cwd: process.cwd(), + _meta: { systemPrompt: "UNTOUCHED", addOns: {} }, + } as never); + + expect(mockCodexConnection.newSession).toHaveBeenCalledWith( + expect.objectContaining({ + _meta: expect.objectContaining({ systemPrompt: "UNTOUCHED" }), + }), + ); + }); + }); }); diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 82746e7e5..bb079403b 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -45,6 +45,8 @@ import { POSTHOG_METHODS, POSTHOG_NOTIFICATIONS, } from "../../acp-extensions"; +import { defaultAddOnRegistry } from "../../add-ons/default-registry"; +import type { AddOnConfig, AddOnContribution } from "../../add-ons/types"; import { createEnrichment, type Enrichment, @@ -109,6 +111,13 @@ interface NewSessionMeta { disableBuiltInTools?: boolean; allowedDomains?: string[]; jsonSchema?: Record | null; + /** + * Add-on configuration sourced from `task.options.add_ons`. Only the + * `systemPromptAppend` slot is honored on Codex today; add-ons that + * require `preToolUse`/`postToolUse` hooks declare + * `supportedAdapters: ["claude"]` and are skipped here. + */ + addOns?: AddOnConfig; } export interface CodexAcpAgentOptions { @@ -383,8 +392,13 @@ export class CodexAcpAgent extends BaseAcpAgent { const meta = params._meta as NewSessionMeta | undefined; const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode); + const addOnContribution = await this.collectAddOnContribution( + meta?.addOns, + params.cwd, + ); + const withAddOns = this.applyAddOnContribution(params, addOnContribution); const injectedParams = this.applyLocalTools( - this.applyStructuredOutput(params, meta), + this.applyStructuredOutput(withAddOns, meta), meta, ); const response = await this.codexConnection.newSession(injectedParams); @@ -428,8 +442,13 @@ export class CodexAcpAgent extends BaseAcpAgent { async loadSession(params: LoadSessionRequest): Promise { const meta = params._meta as NewSessionMeta | undefined; + const addOnContribution = await this.collectAddOnContribution( + meta?.addOns, + params.cwd, + ); + const withAddOns = this.applyAddOnContribution(params, addOnContribution); const injectedParams = this.applyLocalTools( - this.applyStructuredOutput(params, meta), + this.applyStructuredOutput(withAddOns, meta), meta, ); const response = await this.codexConnection.loadSession(injectedParams); @@ -469,16 +488,21 @@ export class CodexAcpAgent extends BaseAcpAgent { params: ResumeSessionRequest, ): Promise { const meta = params._meta as NewSessionMeta | undefined; + const addOnContribution = await this.collectAddOnContribution( + meta?.addOns, + params.cwd, + ); + const withAddOns = this.applyAddOnContribution( + { + sessionId: params.sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, + }, + addOnContribution, + ); const injectedParams = this.applyLocalTools( - this.applyStructuredOutput( - { - sessionId: params.sessionId, - cwd: params.cwd, - mcpServers: params.mcpServers ?? [], - _meta: params._meta, - }, - meta, - ), + this.applyStructuredOutput(withAddOns, meta), meta, ); @@ -519,15 +543,20 @@ export class CodexAcpAgent extends BaseAcpAgent { params: ForkSessionRequest, ): Promise { const meta = params._meta as NewSessionMeta | undefined; + const addOnContribution = await this.collectAddOnContribution( + meta?.addOns, + params.cwd, + ); + const withAddOns = this.applyAddOnContribution( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, + }, + addOnContribution, + ); const injectedParams = this.applyLocalTools( - this.applyStructuredOutput( - { - cwd: params.cwd, - mcpServers: params.mcpServers ?? [], - _meta: params._meta, - }, - meta, - ), + this.applyStructuredOutput(withAddOns, meta), meta, ); @@ -556,6 +585,59 @@ export class CodexAcpAgent extends BaseAcpAgent { return newResponse; } + /** + * Resolve the add-on contribution for this session. Add-ons that declare + * `supportedAdapters: ["claude"]` (like rtk) are silently skipped here — + * `preToolUse`/`postToolUse` slots they would emit have no Codex equivalent. + */ + private collectAddOnContribution( + addOns: AddOnConfig | undefined, + cwd: string, + ): Promise { + return defaultAddOnRegistry.collect(addOns, { + cwd, + adapter: "codex", + logger: this.logger, + }); + } + + /** + * Apply the supported slots of an add-on contribution to an outbound ACP + * session request: `systemPromptAppend` is appended to `_meta.systemPrompt`. + * `env` is ignored because the `codex-acp` subprocess has already been + * spawned with its environment fixed; add-ons that need env vars in Codex + * must declare themselves Claude-only. + */ + private applyAddOnContribution( + request: T, + contribution: AddOnContribution, + ): T { + if (!contribution.systemPromptAppend && !contribution.env) { + return request; + } + if (contribution.env) { + this.logger.warn( + "Add-on contributed env vars but Codex env is fixed at spawn — ignoring", + { keys: Object.keys(contribution.env) }, + ); + } + if (!contribution.systemPromptAppend) { + return request; + } + const existingMeta = (request._meta ?? {}) as Record; + const existingSystemPrompt = + typeof existingMeta.systemPrompt === "string" + ? existingMeta.systemPrompt + : ""; + return { + ...request, + _meta: { + ...existingMeta, + systemPrompt: existingSystemPrompt + contribution.systemPromptAppend, + }, + }; + } + /** * When the caller wires up `onStructuredOutput` and provides a JSON schema * via `_meta.jsonSchema`, inject the stdio MCP server that exposes diff --git a/packages/agent/src/add-ons/default-registry.ts b/packages/agent/src/add-ons/default-registry.ts new file mode 100644 index 000000000..3db79b829 --- /dev/null +++ b/packages/agent/src/add-ons/default-registry.ts @@ -0,0 +1,9 @@ +import { AddOnRegistry } from "./registry"; +import { rtkAddOn } from "./rtk"; + +/** + * Process-wide default registry. Built-in add-ons are registered here so + * adapters can resolve them without ceremony. + */ +export const defaultAddOnRegistry = new AddOnRegistry(); +defaultAddOnRegistry.register(rtkAddOn); diff --git a/packages/agent/src/add-ons/registry.test.ts b/packages/agent/src/add-ons/registry.test.ts new file mode 100644 index 000000000..3bc51107b --- /dev/null +++ b/packages/agent/src/add-ons/registry.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from "vitest"; +import { Logger } from "../utils/logger"; +import { AddOnRegistry } from "./registry"; +import type { AddOnContext, AddOnContribution, AddOnDefinition } from "./types"; + +function makeCtx(adapter: "claude" | "codex" = "claude"): AddOnContext { + return { + cwd: "/tmp/fake-cwd", + adapter, + logger: new Logger(), + }; +} + +function makeDefinition( + overrides: Partial> = {}, +): AddOnDefinition<{ value?: string }> { + return { + name: "test", + parseOptions: (raw) => raw as { value?: string }, + contribute: () => ({}), + ...overrides, + }; +} + +describe("AddOnRegistry", () => { + it("returns an empty contribution when no config is provided", async () => { + const registry = new AddOnRegistry(); + const result = await registry.collect(undefined, makeCtx()); + expect(result).toEqual({}); + }); + + it("merges env vars from multiple add-ons (later wins on conflict)", async () => { + const registry = new AddOnRegistry(); + registry.register( + makeDefinition({ + name: "a", + contribute: () => ({ env: { SHARED: "from-a", A_ONLY: "1" } }), + }), + ); + registry.register( + makeDefinition({ + name: "b", + contribute: () => ({ env: { SHARED: "from-b", B_ONLY: "1" } }), + }), + ); + + const result = await registry.collect({ a: {}, b: {} }, makeCtx()); + expect(result.env).toEqual({ + SHARED: "from-b", + A_ONLY: "1", + B_ONLY: "1", + }); + }); + + it("concatenates systemPromptAppend across add-ons in iteration order", async () => { + const registry = new AddOnRegistry(); + registry.register( + makeDefinition({ + name: "a", + contribute: () => ({ systemPromptAppend: "AA" }), + }), + ); + registry.register( + makeDefinition({ + name: "b", + contribute: () => ({ systemPromptAppend: "BB" }), + }), + ); + + const result = await registry.collect({ a: {}, b: {} }, makeCtx()); + expect(result.systemPromptAppend).toBe("AABB"); + }); + + it("aggregates preToolUse and postToolUse hooks", async () => { + const registry = new AddOnRegistry(); + const hookA = vi.fn(); + const hookB = vi.fn(); + registry.register( + makeDefinition({ + name: "a", + contribute: (): AddOnContribution => ({ + preToolUse: [hookA], + postToolUse: [hookB], + }), + }), + ); + + const result = await registry.collect({ a: {} }, makeCtx()); + expect(result.preToolUse).toEqual([hookA]); + expect(result.postToolUse).toEqual([hookB]); + }); + + it("skips add-ons not supported on the current adapter", async () => { + const registry = new AddOnRegistry(); + registry.register( + makeDefinition({ + name: "claude-only", + supportedAdapters: ["claude"], + contribute: () => ({ systemPromptAppend: "should-not-appear" }), + }), + ); + + const result = await registry.collect( + { "claude-only": {} }, + makeCtx("codex"), + ); + expect(result.systemPromptAppend).toBeUndefined(); + }); + + it("skips unknown add-on names with a warning instead of throwing", async () => { + const registry = new AddOnRegistry(); + const ctx = makeCtx(); + const warnSpy = vi.spyOn(ctx.logger, "warn").mockImplementation(() => {}); + + await expect( + registry.collect({ "does-not-exist": {} }, ctx), + ).resolves.toEqual({}); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("does-not-exist"), + expect.objectContaining({ addOn: "does-not-exist" }), + ); + }); + + it("skips add-ons whose options fail parsing instead of throwing", async () => { + const registry = new AddOnRegistry(); + registry.register( + makeDefinition({ + name: "strict", + parseOptions: () => { + throw new Error("bad options"); + }, + contribute: () => ({ env: { SHOULD_NOT: "1" } }), + }), + ); + const ctx = makeCtx(); + const warnSpy = vi.spyOn(ctx.logger, "warn").mockImplementation(() => {}); + + const result = await registry.collect({ strict: { x: 1 } }, ctx); + expect(result.env).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("strict"), + expect.objectContaining({ addOn: "strict" }), + ); + }); + + it("awaits prepare() before contribute()", async () => { + const registry = new AddOnRegistry(); + const order: string[] = []; + registry.register( + makeDefinition({ + name: "ordered", + prepare: async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + order.push("prepare"); + }, + contribute: () => { + order.push("contribute"); + return {}; + }, + }), + ); + + await registry.collect({ ordered: {} }, makeCtx()); + expect(order).toEqual(["prepare", "contribute"]); + }); + + it("propagates prepare() failures so missing prerequisites surface early", async () => { + const registry = new AddOnRegistry(); + registry.register( + makeDefinition({ + name: "needs-binary", + prepare: () => { + throw new Error("binary missing"); + }, + }), + ); + + await expect( + registry.collect({ "needs-binary": {} }, makeCtx()), + ).rejects.toThrow("binary missing"); + }); + + it("rejects duplicate registrations", () => { + const registry = new AddOnRegistry(); + registry.register(makeDefinition({ name: "dup" })); + expect(() => registry.register(makeDefinition({ name: "dup" }))).toThrow( + /already registered/, + ); + }); +}); diff --git a/packages/agent/src/add-ons/registry.ts b/packages/agent/src/add-ons/registry.ts new file mode 100644 index 000000000..308c659d7 --- /dev/null +++ b/packages/agent/src/add-ons/registry.ts @@ -0,0 +1,106 @@ +import type { + AddOnConfig, + AddOnContext, + AddOnContribution, + AddOnDefinition, +} from "./types"; + +// biome-ignore lint/suspicious/noExplicitAny: registry erases per-definition option types +type AnyAddOnDefinition = AddOnDefinition; + +export class AddOnRegistry { + private definitions = new Map(); + + register(definition: AddOnDefinition): void { + if (this.definitions.has(definition.name)) { + throw new Error( + `AddOn "${definition.name}" is already registered. Names must be unique.`, + ); + } + this.definitions.set(definition.name, definition); + } + + get(name: string): AnyAddOnDefinition | undefined { + return this.definitions.get(name); + } + + list(): AnyAddOnDefinition[] { + return [...this.definitions.values()]; + } + + /** + * Resolve every enabled add-on for the current adapter and merge their + * contributions into a single object. Unknown names, unsupported adapters, + * and option-parse failures are logged and skipped — never throw out of + * `collect()`, since one misconfigured add-on should not break the session. + * `prepare()` failures DO throw, because they signal a missing prerequisite + * the user must fix. + */ + async collect( + config: AddOnConfig | undefined, + ctx: AddOnContext, + ): Promise { + const merged: AddOnContribution = {}; + if (!config) return merged; + + for (const [name, rawOptions] of Object.entries(config)) { + const definition = this.definitions.get(name); + if (!definition) { + ctx.logger.warn(`Unknown add-on "${name}" — skipping`, { + addOn: name, + }); + continue; + } + + if ( + definition.supportedAdapters && + !definition.supportedAdapters.includes(ctx.adapter) + ) { + ctx.logger.info( + `Add-on "${name}" is not supported on adapter "${ctx.adapter}" — skipping`, + { addOn: name, adapter: ctx.adapter }, + ); + continue; + } + + let options: unknown; + try { + options = definition.parseOptions(rawOptions ?? {}); + } catch (error) { + ctx.logger.warn(`Add-on "${name}" rejected options — skipping`, { + addOn: name, + error, + }); + continue; + } + + if (definition.prepare) { + await definition.prepare(ctx, options); + } + + const contribution = await definition.contribute(ctx, options); + mergeContribution(merged, contribution); + } + + return merged; + } +} + +function mergeContribution( + target: AddOnContribution, + source: AddOnContribution, +): void { + if (source.env) { + target.env = { ...(target.env ?? {}), ...source.env }; + } + if (source.systemPromptAppend) { + target.systemPromptAppend = + (target.systemPromptAppend ?? "") + source.systemPromptAppend; + } + if (source.preToolUse?.length) { + target.preToolUse = [...(target.preToolUse ?? []), ...source.preToolUse]; + } + if (source.postToolUse?.length) { + target.postToolUse = [...(target.postToolUse ?? []), ...source.postToolUse]; + } +} diff --git a/packages/agent/src/add-ons/rtk.test.ts b/packages/agent/src/add-ons/rtk.test.ts new file mode 100644 index 000000000..bbcc9465a --- /dev/null +++ b/packages/agent/src/add-ons/rtk.test.ts @@ -0,0 +1,189 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + HookCallback, + HookInput, + HookJSONOutput, +} from "@anthropic-ai/claude-agent-sdk"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Logger } from "../utils/logger"; +import { AddOnRegistry } from "./registry"; +import { rtkAddOn } from "./rtk"; +import type { AddOnContext, AddOnContribution } from "./types"; + +function firstHook(contribution: AddOnContribution): HookCallback { + const hook = contribution.preToolUse?.[0]; + if (!hook) { + throw new Error("expected a PreToolUse hook on contribution"); + } + return hook; +} + +function makeFakeRtkBinary(): string { + const dir = mkdtempSync(join(tmpdir(), "rtk-test-")); + const binaryPath = join(dir, "rtk"); + writeFileSync(binaryPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + return binaryPath; +} + +function makeCtx(overrides: Partial = {}): AddOnContext { + return { + cwd: "/tmp/fake", + adapter: "claude", + logger: new Logger(), + ...overrides, + }; +} + +async function runHook( + hook: HookCallback, + partial: { tool_name: string; tool_input?: Record }, +): Promise { + const input = { + hook_event_name: "PreToolUse", + session_id: "s", + transcript_path: "/tmp/t", + cwd: "/tmp", + tool_name: partial.tool_name, + tool_input: partial.tool_input ?? {}, + } as HookInput; + return hook(input, "tool-use-id", { signal: new AbortController().signal }); +} + +describe("rtk add-on", () => { + let binaryPath: string; + + beforeEach(() => { + binaryPath = makeFakeRtkBinary(); + }); + + it("rejects unknown options keys", () => { + expect(() => rtkAddOn.parseOptions({ binaryPath: 42 })).toThrow(); + }); + + it("throws from contribute() when the configured binary is missing", () => { + expect(() => + rtkAddOn.contribute( + makeCtx(), + rtkAddOn.parseOptions({ binaryPath: "/does/not/exist/rtk" }), + ), + ).toThrow(/binary not found/); + }); + + it("contributes a PreToolUse hook that rewrites Bash commands", async () => { + const contribution = await rtkAddOn.contribute( + makeCtx(), + rtkAddOn.parseOptions({ binaryPath }), + ); + expect(contribution.preToolUse).toHaveLength(1); + + const hook = firstHook(contribution); + const result = (await runHook(hook, { + tool_name: "Bash", + tool_input: { command: "ls -la" }, + })) as { + hookSpecificOutput?: { + hookEventName: string; + updatedInput: { command: string }; + }; + }; + + expect(result.hookSpecificOutput?.hookEventName).toBe("PreToolUse"); + expect(result.hookSpecificOutput?.updatedInput.command).toBe( + `'${binaryPath}' run -- ls -la`, + ); + }); + + it("passes through non-Bash tool calls unchanged", async () => { + const contribution = await rtkAddOn.contribute( + makeCtx(), + rtkAddOn.parseOptions({ binaryPath }), + ); + const hook = firstHook(contribution); + const result = (await runHook(hook, { + tool_name: "Read", + tool_input: { file_path: "/etc/hosts" }, + })) as { continue: boolean; hookSpecificOutput?: unknown }; + + expect(result.continue).toBe(true); + expect(result.hookSpecificOutput).toBeUndefined(); + }); + + it("appends --skip-permissions when configured", async () => { + const contribution = await rtkAddOn.contribute( + makeCtx(), + rtkAddOn.parseOptions({ binaryPath, skipPermissions: true }), + ); + const hook = firstHook(contribution); + const result = (await runHook(hook, { + tool_name: "Bash", + tool_input: { command: "echo hi" }, + })) as { + hookSpecificOutput?: { updatedInput: { command: string } }; + }; + expect(result.hookSpecificOutput?.updatedInput.command).toBe( + `'${binaryPath}' run --skip-permissions -- echo hi`, + ); + }); + + it("declares itself unsupported on Codex so the registry skips it", async () => { + const registry = new AddOnRegistry(); + registry.register(rtkAddOn); + + const result = await registry.collect( + { rtk: { binaryPath } }, + makeCtx({ adapter: "codex" }), + ); + expect(result.preToolUse).toBeUndefined(); + }); + + it("end-to-end: resolves through the registry on Claude", async () => { + const registry = new AddOnRegistry(); + registry.register(rtkAddOn); + + const result = await registry.collect( + { rtk: { binaryPath } }, + makeCtx({ adapter: "claude" }), + ); + expect(result.preToolUse).toHaveLength(1); + }); + + it("does not double-wrap an already-wrapped command", async () => { + const contribution = await rtkAddOn.contribute( + makeCtx(), + rtkAddOn.parseOptions({ binaryPath }), + ); + const hook = firstHook(contribution); + const wrapped = `'${binaryPath}' run -- echo hi`; + const result = (await runHook(hook, { + tool_name: "Bash", + tool_input: { command: wrapped }, + })) as { continue: boolean; hookSpecificOutput?: unknown }; + + expect(result.hookSpecificOutput).toBeUndefined(); + expect(result.continue).toBe(true); + }); + + it("escapes single quotes inside the binary path", async () => { + // Make a binary at a path that already contains a quote — verifies escaping. + const dir = mkdtempSync(join(tmpdir(), "rtk-quote-")); + const quoted = join(dir, "weird'name"); + mkdirSync(quoted, { recursive: true }); + const bp = join(quoted, "rtk"); + writeFileSync(bp, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + + const contribution = await rtkAddOn.contribute( + makeCtx(), + rtkAddOn.parseOptions({ binaryPath: bp }), + ); + const hook = firstHook(contribution); + const result = (await runHook(hook, { + tool_name: "Bash", + tool_input: { command: "ls" }, + })) as { + hookSpecificOutput?: { updatedInput: { command: string } }; + }; + expect(result.hookSpecificOutput?.updatedInput.command).toContain("'\\''"); + }); +}); diff --git a/packages/agent/src/add-ons/rtk.ts b/packages/agent/src/add-ons/rtk.ts new file mode 100644 index 000000000..7444e9ef2 --- /dev/null +++ b/packages/agent/src/add-ons/rtk.ts @@ -0,0 +1,99 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { HookCallback } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; +import type { AddOnContext, AddOnDefinition } from "./types"; + +const rtkOptionsSchema = z.object({ + /** + * Absolute path to the rtk binary. When omitted, the add-on looks on + * `$PATH` and a small set of known install locations. + */ + binaryPath: z.string().optional(), + /** + * Forwarded to `rtk run --skip-permissions`. Tells rtk to bypass its own + * approval prompts because Claude's permission model already gates Bash. + */ + skipPermissions: z.boolean().optional(), +}); + +export type RtkOptions = z.infer; + +function resolveRtkBinary(options: RtkOptions): string { + if (options.binaryPath) { + if (!existsSync(options.binaryPath)) { + throw new Error( + `rtk add-on: binary not found at configured path "${options.binaryPath}"`, + ); + } + return options.binaryPath; + } + + // $PATH is authoritative — rtk's recommended installs (cargo, homebrew, + // ~/.local/bin) all land their binaries on a directory the user's shell + // already exports. If $PATH is empty (sandbox) we surface the missing- + // binary error rather than guessing system locations. + const candidates = (process.env.PATH ?? "").split(":").filter(Boolean); + for (const dir of candidates) { + const candidate = join(dir, "rtk"); + if (existsSync(candidate)) return candidate; + } + + throw new Error( + 'rtk add-on: binary "rtk" not found on PATH. Install it from ' + + "https://github.com/rtk-ai/rtk or set add-on option `binaryPath` to an absolute path.", + ); +} + +function shellEscape(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function makeRtkBashHook( + binaryPath: string, + options: RtkOptions, +): HookCallback { + const flag = options.skipPermissions ? " --skip-permissions" : ""; + const escapedBinary = shellEscape(binaryPath); + return async (input) => { + if (input.hook_event_name !== "PreToolUse") return { continue: true }; + if (input.tool_name !== "Bash") return { continue: true }; + + const toolInput = (input.tool_input ?? {}) as { command?: unknown }; + const command = toolInput.command; + if (typeof command !== "string" || command.length === 0) { + return { continue: true }; + } + + // Already wrapped — don't double-wrap if the hook runs twice for some reason. + if (command.startsWith(`${escapedBinary} run`)) { + return { continue: true }; + } + + const rewritten = `${escapedBinary} run${flag} -- ${command}`; + return { + continue: true, + hookSpecificOutput: { + hookEventName: "PreToolUse", + updatedInput: { + ...(input.tool_input as Record), + command: rewritten, + }, + }, + }; + }; +} + +export const rtkAddOn: AddOnDefinition = { + name: "rtk", + supportedAdapters: ["claude"], + parseOptions(rawOptions) { + return rtkOptionsSchema.parse(rawOptions ?? {}); + }, + contribute(_ctx: AddOnContext, options: RtkOptions) { + const binaryPath = resolveRtkBinary(options); + return { + preToolUse: [makeRtkBashHook(binaryPath, options)], + }; + }, +}; diff --git a/packages/agent/src/add-ons/types.ts b/packages/agent/src/add-ons/types.ts new file mode 100644 index 000000000..ee1fa604f --- /dev/null +++ b/packages/agent/src/add-ons/types.ts @@ -0,0 +1,54 @@ +import type { HookCallback } from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../utils/logger"; + +/** + * Shape of `task.options.add_ons` as it travels from the Django Task model + * through `_meta.addOns` on a `newSession` ACP request. Keys are add-on names + * registered with the {@link AddOnRegistry}; values are opaque option blobs + * that each add-on validates with its own `parseOptions` implementation. + */ +export type AddOnConfig = Record>; + +export type AddOnAdapter = "claude" | "codex"; + +export interface AddOnContext { + cwd: string; + adapter: AddOnAdapter; + logger: Logger; +} + +export interface AddOnContribution { + env?: Record; + systemPromptAppend?: string; + preToolUse?: HookCallback[]; + postToolUse?: HookCallback[]; +} + +export interface AddOnDefinition> { + /** Unique name. Matches the key under `task.options.add_ons`. */ + name: string; + /** + * Adapters this add-on supports. Omit to support every adapter; specify + * a subset (e.g. `["claude"]`) to be silently skipped on unsupported ones. + * Codex cannot honor `preToolUse`/`postToolUse` slots since `codex-acp` + * has no pre/post-tool interception point — add-ons that need command + * interception declare `supportedAdapters: ["claude"]`. + */ + supportedAdapters?: AddOnAdapter[]; + /** + * Validate and shape the raw options blob. Throw to signal invalid input — + * the registry will skip the add-on and log a warning rather than abort the + * whole session. + */ + parseOptions(rawOptions: unknown): TOptions; + /** + * Idempotent setup that must complete before the session starts. Use for + * resolving binaries on disk, downloading assets, etc. Throw to fail loudly. + */ + prepare?(ctx: AddOnContext, options: TOptions): Promise | void; + /** Produce the session-level contribution. */ + contribute( + ctx: AddOnContext, + options: TOptions, + ): Promise | AddOnContribution; +} diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 37ea4d8b8..f7d19ea76 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -982,6 +982,7 @@ export class AgentServer { allowedDomains: this.config.allowedDomains, jsonSchema: preTask?.json_schema ?? null, permissionMode: initialPermissionMode, + ...(this.config.addOns && { addOns: this.config.addOns }), ...(this.config.claudeCode?.plugins?.length && { claudeCode: { options: { diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index 36bfe7a0e..d5f71f638 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -3,7 +3,11 @@ import { Command } from "commander"; import { z } from "zod/v4"; import { isSupportedReasoningEffort } from "../adapters/reasoning-effort"; import { AgentServer } from "./agent-server"; -import { claudeCodeConfigSchema, mcpServersSchema } from "./schemas"; +import { + addOnsConfigSchema, + claudeCodeConfigSchema, + mcpServersSchema, +} from "./schemas"; const envSchema = z.object({ JWT_PUBLIC_KEY: z @@ -105,6 +109,10 @@ program "--allowedDomains ", "Comma-separated list of domains allowed for web tools (WebFetch, WebSearch)", ) + .option( + "--addOns ", + "Add-on config as JSON object mapping add-on name → options (sourced from task.options.add_ons)", + ) .action(async (options) => { const envResult = envSchema.safeParse(process.env); @@ -131,6 +139,11 @@ program claudeCodeConfigSchema, "--claudeCodeConfig", ); + const addOns = parseJsonOption( + options.addOns, + addOnsConfigSchema, + "--addOns", + ); const allowedDomains = options.allowedDomains ? options.allowedDomains @@ -175,6 +188,7 @@ program runtimeAdapter: env.POSTHOG_CODE_RUNTIME_ADAPTER, model: env.POSTHOG_CODE_MODEL, reasoningEffort: env.POSTHOG_CODE_REASONING_EFFORT, + addOns, }); process.on("SIGINT", async () => { diff --git a/packages/agent/src/server/schemas.ts b/packages/agent/src/server/schemas.ts index 2dfa791a9..cbecd2620 100644 --- a/packages/agent/src/server/schemas.ts +++ b/packages/agent/src/server/schemas.ts @@ -29,6 +29,16 @@ export const mcpServersSchema = z.array(remoteMcpServerSchema); export type RemoteMcpServer = z.infer; +/** + * Per-add-on options blob; opaque at the transport layer. Each add-on + * validates its own options via its `parseOptions` implementation at + * session start. + */ +export const addOnsConfigSchema = z.record( + z.string(), + z.record(z.string(), z.unknown()), +); + export const claudeCodeConfigSchema = z.object({ systemPrompt: z .union([ diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index d11cb7748..f97c9f5b4 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -1,3 +1,4 @@ +import type { AddOnConfig } from "../add-ons/types"; import type { AgentMode } from "../types"; import type { RemoteMcpServer } from "./schemas"; @@ -29,4 +30,11 @@ export interface AgentServerConfig { runtimeAdapter?: "claude" | "codex"; model?: string; reasoningEffort?: "low" | "medium" | "high" | "xhigh" | "max"; + /** + * Add-on configuration sourced from `task.options.add_ons`. Forwarded + * verbatim onto `_meta.addOns` of the cloud `newSession` call where the + * adapter's add-on registry resolves it. Names not registered on the + * sandbox-side `defaultAddOnRegistry` are skipped with a warning. + */ + addOns?: AddOnConfig; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 6047c3e9c..e87c0bb2d 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -2,6 +2,7 @@ import type { GitHandoffCheckpoint, HandoffLocalGitState as GitHandoffLocalGitState, } from "@posthog/git/handoff"; +import type { AddOnConfig } from "./add-ons/types"; /** * Stored custom notification following ACP extensibility model. @@ -25,6 +26,20 @@ export interface StoredNotification { */ export type StoredEntry = StoredNotification; +/** + * Per-task configuration blob stored on the Django `Task.options` JSONField. + * Open-ended so we can add new keys without OpenAPI churn. Today only + * `add_ons` is consumed by the agent runtime. + * + * Server-side requires a `options = models.JSONField(default=dict, blank=True)` + * field on the Task model and a matching serializer entry. Until that migration + * lands, this field will be absent on every Task returned by the API. + */ +export interface TaskOptions { + /** Keys are add-on names; values are opaque option blobs validated per-add-on. */ + add_ons?: AddOnConfig; +} + // PostHog Task model (matches PostHog Code's OpenAPI schema) export interface Task { id: string; @@ -43,6 +58,7 @@ export interface Task { repository: string; // Format: "organization/repository" (e.g., "posthog/posthog-js") json_schema?: Record | null; // JSON schema for task output validation internal?: boolean; + options?: TaskOptions; created_at: string; updated_at: string; created_by?: { diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index d17d91e4c..19eec63cf 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -79,6 +79,10 @@ export default defineConfig([ "src/pr-url-detector.ts", "src/resume.ts", "src/types.ts", + "src/add-ons/types.ts", + "src/add-ons/registry.ts", + "src/add-ons/default-registry.ts", + "src/add-ons/rtk.ts", "src/adapters/claude/questions/utils.ts", "src/adapters/claude/permissions/permission-options.ts", "src/adapters/claude/tools.ts",