Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/code/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown>>;
}

export interface Task {
id: string;
task_number: number | null;
Expand All @@ -51,6 +65,7 @@ export interface Task {
json_schema?: Record<string, unknown> | null;
signal_report?: string | null;
internal?: boolean;
options?: TaskOptions;
latest_run?: TaskRun;
}

Expand Down
16 changes: 16 additions & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/ztk": {
"types": "./dist/add-ons/ztk.d.ts",
"import": "./dist/add-ons/ztk.js"
},
"./adapters/claude/questions/utils": {
"types": "./dist/adapters/claude/questions/utils.d.ts",
"import": "./dist/adapters/claude/questions/utils.js"
Expand Down
8 changes: 8 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
POSTHOG_METHODS,
POSTHOG_NOTIFICATIONS,
} from "../../acp-extensions";
import { defaultAddOnRegistry } from "../../add-ons/default-registry";
import {
createEnrichment,
type Enrichment,
Expand Down Expand Up @@ -1140,13 +1141,20 @@ 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,
permissionMode,
canUseTool: this.createCanUseTool(sessionId, meta?.allowedDomains),
logger: this.logger,
systemPrompt,
addOnContribution,
userProvidedOptions: meta?.claudeCode?.options,
sessionId,
isResume,
Expand Down
83 changes: 67 additions & 16 deletions packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,6 +57,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;
}

export function buildSystemPrompt(
Expand Down Expand Up @@ -89,6 +97,26 @@ export function buildSystemPrompt(
return defaultPrompt;
}

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<string, McpServerConfig> | undefined,
acpServers: Record<string, McpServerConfig>,
Expand All @@ -101,7 +129,9 @@ function buildMcpServers(
};
}

function buildEnvironment(): Record<string, string> {
function buildEnvironment(
addOnEnv?: Record<string, string>,
): Record<string, string> {
const bedrockFallbackHeader = "x-posthog-use-bedrock-fallback: true";
const existingCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS;
const customHeaders = existingCustomHeaders
Expand All @@ -118,6 +148,9 @@ function buildEnvironment(): Record<string, string> {
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 ?? {}),
};
}

Expand All @@ -129,6 +162,8 @@ function buildHooks(
enrichmentDeps: FileEnrichmentDeps | undefined,
enrichedReadCache: EnrichedReadCache | undefined,
registeredAgents: ReadonlySet<string>,
addOnPreToolUse: HookCallback[] | undefined,
addOnPostToolUse: HookCallback[] | undefined,
): Options["hooks"] {
const postToolUseHooks = [createPostToolUseHook({ onModeChange })];
if (enrichmentDeps && enrichedReadCache) {
Expand All @@ -137,21 +172,30 @@ function buildHooks(
);
}

// Add-on PreToolUse hooks run BEFORE the built-in permission gate so they
// can rewrite tool input (e.g. ztk wrapping a Bash command) and have those
// changes reflected in the permission-check pass.
const preToolUseGroups: NonNullable<Options["hooks"]>["PreToolUse"] = [
...(userHooks?.PreToolUse || []),
...(addOnPreToolUse?.length ? [{ hooks: addOnPreToolUse }] : []),
{
hooks: [
createPreToolUseHook(settingsManager, logger),
createSubagentRewriteHook(logger, registeredAgents),
],
},
];

const postToolUseGroups: NonNullable<Options["hooks"]>["PostToolUse"] = [
...(userHooks?.PostToolUse || []),
{ hooks: postToolUseHooks },
...(addOnPostToolUse?.length ? [{ hooks: addOnPostToolUse }] : []),
];

return {
...userHooks,
PostToolUse: [
...(userHooks?.PostToolUse || []),
{ hooks: postToolUseHooks },
],
PreToolUse: [
...(userHooks?.PreToolUse || []),
{
hooks: [
createPreToolUseHook(settingsManager, logger),
createSubagentRewriteHook(logger, registeredAgents),
],
},
],
PostToolUse: postToolUseGroups,
PreToolUse: preToolUseGroups,
};
}

Expand Down Expand Up @@ -320,10 +364,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,
Expand All @@ -343,7 +392,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,
Expand All @@ -352,6 +401,8 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
params.enrichmentDeps,
params.enrichedReadCache,
registeredAgentNames,
params.addOnContribution?.preToolUse,
params.addOnContribution?.postToolUse,
),
outputFormat: params.outputFormat,
abortController: getAbortController(
Expand Down
8 changes: 8 additions & 0 deletions packages/agent/src/adapters/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -135,4 +136,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;
};
Loading
Loading