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/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"
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 @@ -1169,13 +1170,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
134 changes: 132 additions & 2 deletions packages/agent/src/adapters/claude/session/options.test.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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");
});
});
75 changes: 65 additions & 10 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 @@ -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;
}
Expand Down Expand Up @@ -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<string, McpServerConfig> | undefined,
acpServers: Record<string, McpServerConfig>,
Expand All @@ -104,7 +132,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 @@ -121,6 +151,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 @@ -132,6 +165,8 @@ function buildHooks(
enrichmentDeps: FileEnrichmentDeps | undefined,
enrichedReadCache: EnrichedReadCache | undefined,
registeredAgents: ReadonlySet<string>,
addOnPreToolUse: HookCallback[] | undefined,
addOnPostToolUse: HookCallback[] | undefined,
cloudMode: boolean,
): Options["hooks"] {
const postToolUseHooks = [createPostToolUseHook({ onModeChange })];
Expand All @@ -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<Options["hooks"]>["PreToolUse"] = [
...(userHooks?.PreToolUse || []),
...(addOnPreToolUse?.length ? [{ hooks: addOnPreToolUse }] : []),
{ hooks: builtInPreToolUseHooks },
];

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: preToolUseHooks }],
PostToolUse: postToolUseGroups,
PreToolUse: preToolUseGroups,
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading