From 2c0512cff025e049c15f1f0e82043b602bc453c4 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 8 Aug 2026 16:23:00 -0400 Subject: [PATCH 1/5] chore(codex): scaffold @agentry/codex package and register it in the workspace New workspace package mirroring @agentry/claude; adds the tsconfig path, vitest alias, and CLI dependency so the driver resolves across typecheck, tests, and the CLI. Co-Authored-By: Claude Opus 4.8 --- packages/cli/package.json | 1 + packages/codex/package.json | 12 ++++++++++++ packages/codex/tsconfig.json | 5 +++++ pnpm-lock.yaml | 9 +++++++++ tsconfig.json | 1 + vitest.config.ts | 1 + 6 files changed, 29 insertions(+) create mode 100644 packages/codex/package.json create mode 100644 packages/codex/tsconfig.json diff --git a/packages/cli/package.json b/packages/cli/package.json index 1c6cb64..dad7536 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,7 @@ "dependencies": { "@agentry/core": "workspace:*", "@agentry/claude": "workspace:*", + "@agentry/codex": "workspace:*", "@agentry/mcp": "workspace:*" } } diff --git a/packages/codex/package.json b/packages/codex/package.json new file mode 100644 index 0000000..07613bd --- /dev/null +++ b/packages/codex/package.json @@ -0,0 +1,12 @@ +{ + "name": "@agentry/codex", + "version": "0.0.0", + "type": "module", + "description": "Agentry driver for the Codex CLI (headless exec --json).", + "exports": { ".": "./src/index.ts" }, + "publishConfig": { + "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } + }, + "scripts": { "build": "tsup src/index.ts --format esm --dts --clean" }, + "dependencies": { "@agentry/core": "workspace:*" } +} diff --git a/packages/codex/tsconfig.json b/packages/codex/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/codex/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 986a492..ddcaf81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@agentry/claude': specifier: workspace:* version: link:../claude + '@agentry/codex': + specifier: workspace:* + version: link:../codex '@agentry/core': specifier: workspace:* version: link:../core @@ -48,6 +51,12 @@ importers: specifier: workspace:* version: link:../mcp + packages/codex: + dependencies: + '@agentry/core': + specifier: workspace:* + version: link:../core + packages/core: dependencies: expect: diff --git a/tsconfig.json b/tsconfig.json index 8067e8b..03f3915 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "paths": { "@agentry/core": ["packages/core/src/index.ts"], "@agentry/claude": ["packages/claude/src/index.ts"], + "@agentry/codex": ["packages/codex/src/index.ts"], "@agentry/mcp": ["packages/mcp/src/index.ts"] } }, diff --git a/vitest.config.ts b/vitest.config.ts index 40ac880..5ebd0f5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ alias: { '@agentry/core': new URL('./packages/core/src/index.ts', import.meta.url).pathname, '@agentry/claude': new URL('./packages/claude/src/index.ts', import.meta.url).pathname, + '@agentry/codex': new URL('./packages/codex/src/index.ts', import.meta.url).pathname, '@agentry/mcp': new URL('./packages/mcp/src/index.ts', import.meta.url).pathname, }, }, From 5cdfbe431b0f144f1eafafb64f215ba365a82d58 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 8 Aug 2026 16:23:00 -0400 Subject: [PATCH 2/5] feat(codex): normalize `codex exec --json` output into the Agentry event model Pure parseCodexEvent maps the thread/item stream (thread.started, item.started/completed{agent_message,command_execution,file_change}, turn.completed) into AgentEvents. Shell runs normalize to tool 'shell', patches to 'apply_patch'; usage comes from turn.completed; run.end is synthesized on process exit since Codex emits no terminal event. Schema captured live from codex-cli 0.147. Constraint: Codex exec emits no terminal run.end and reports no per-run cost Rejected: Wire Agentry's Anthropic LLM proxy into Codex | Codex speaks the OpenAI/Responses API and intercepts via model_providers..base_url, incompatible with the Anthropic proxy\nConfidence: high\nScope-risk: narrow\nDirective: Event schema captured from codex-cli 0.147; re-verify the stream if bumping Codex\nNot-tested: live mcp_tool_call and reasoning items (best-effort / dropped in MVP)\nCo-Authored-By: Claude Opus 4.8 --- packages/codex/src/driver.ts | 256 +++++++++++++++++++++++++++++++++++ packages/codex/src/index.ts | 2 + 2 files changed, 258 insertions(+) create mode 100644 packages/codex/src/driver.ts create mode 100644 packages/codex/src/index.ts diff --git a/packages/codex/src/driver.ts b/packages/codex/src/driver.ts new file mode 100644 index 0000000..ce66dbb --- /dev/null +++ b/packages/codex/src/driver.ts @@ -0,0 +1,256 @@ +/** + * Codex CLI driver (SPEC §4.2). Drives `codex exec --json` headlessly and + * normalizes the native thread/item stream into AgentEvents. + * + * `parseCodexEvent` is a pure mapping (one native event → zero+ AgentEvents) + * so it can be unit-tested without spawning the CLI. Codex normalizes shell as + * tool `shell` and patches as `apply_patch`; LLM interception is provider-config + * (not wired to Agentry's Anthropic proxy); `reasoning` items and non-live + * `mcp_tool_call` mapping are best-effort/dropped in the MVP. + */ +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { + EventFactory, + RunRecord, + type AgentDriver, + type AgentEvent, + type DriverCapabilities, + type RunOptions, + type RunResult, + type RunEndReason, + type Usage, + isEvent, +} from '@agentry/core'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type Raw = any; + +/** Map a single Codex `exec --json` event into zero or more AgentEvents. */ +export function parseCodexEvent(raw: Raw, f: EventFactory, runId: string): AgentEvent[] { + const out: AgentEvent[] = []; + const type = raw?.type; + + if (type === 'thread.started') { + out.push( + f.make( + { type: 'run.start', runId, agent: 'codex', model: raw.model ?? '' }, + { turnId: 'init', source: 'agent', agentNativeType: 'thread.started', raw }, + ), + ); + return out; + } + + if (type === 'turn.started') { + return out; + } + + if (type === 'item.started' || type === 'item.completed') { + const item = raw.item ?? {}; + const turn = item.id ?? 'item'; + + if (item.type === 'agent_message' && type === 'item.completed') { + out.push( + f.make( + { type: 'message', role: 'assistant', text: item.text ?? '' }, + { turnId: turn, source: 'agent', agentNativeType: 'item.agent_message', raw: item }, + ), + ); + return out; + } + + if (item.type === 'command_execution') { + if (type === 'item.started') { + out.push( + f.make( + { type: 'tool_use', id: item.id, name: 'shell', args: { command: item.command } }, + { turnId: turn, source: 'agent', capability: 'tool', agentNativeType: 'item.command_execution', raw: item }, + ), + ); + } else { + out.push( + f.make( + { + type: 'tool_result', + id: item.id, + name: 'shell', + result: item.aggregated_output, + isError: typeof item.exit_code === 'number' && item.exit_code !== 0, + }, + { turnId: turn, source: 'agent', capability: 'tool', agentNativeType: 'item.command_execution', raw: item }, + ), + ); + } + return out; + } + + if (item.type === 'file_change') { + if (type === 'item.started') { + out.push( + f.make( + { type: 'tool_use', id: item.id, name: 'apply_patch', args: { changes: item.changes } }, + { turnId: turn, source: 'agent', capability: 'tool', agentNativeType: 'item.file_change', raw: item }, + ), + ); + } else { + out.push( + f.make( + { type: 'tool_result', id: item.id, name: 'apply_patch', result: item.changes, isError: false }, + { turnId: turn, source: 'agent', capability: 'tool', agentNativeType: 'item.file_change', raw: item }, + ), + ); + } + return out; + } + + if (item.type === 'mcp_tool_call' && type === 'item.started') { + out.push( + f.make( + { + type: 'mcp_request', + server: item.server ?? '', + method: 'tools/call', + params: { name: item.tool ?? item.name, arguments: item.arguments ?? item.args }, + }, + { turnId: turn, source: 'agent', capability: 'mcp', agentNativeType: 'item.mcp_tool_call', raw: item }, + ), + ); + return out; + } + + return out; + } + + if (type === 'turn.completed') { + const u = raw.usage ?? {}; + out.push( + f.make( + { + type: 'usage', + inputTokens: u.input_tokens ?? 0, + outputTokens: u.output_tokens ?? 0, + cacheReadTokens: u.cached_input_tokens, + cacheCreationTokens: u.cache_write_input_tokens, + }, + { turnId: 'turn', source: 'agent', agentNativeType: 'turn.completed', raw: u }, + ), + ); + return out; + } + + if (type === 'error') { + out.push( + f.make( + { type: 'error', kind: 'api', detail: { message: raw.message } }, + { turnId: 'error', source: 'agent', agentNativeType: 'error', raw }, + ), + ); + return out; + } + + return out; +} + +/** Aggregate usage across the stream's `usage` events (Codex reports no cost). */ +function aggregateUsage(events: AgentEvent[]): Usage { + const totals: Usage = { inputTokens: 0, outputTokens: 0, costUSD: 0 }; + for (const e of events) { + if (isEvent(e, 'usage')) { + totals.inputTokens += e.payload.inputTokens; + totals.outputTokens += e.payload.outputTokens; + totals.costUSD = (totals.costUSD ?? 0) + (e.payload.costUSD ?? 0); + } + } + return totals; +} + +export function buildArgs(opts: RunOptions): string[] { + const args = [ + 'exec', + '--json', + '--color', + 'never', + '--skip-git-repo-check', + '--ephemeral', + '-C', + opts.cwd, + '-m', + opts.model, + ]; + if (opts.permissionMode === 'bypassPermissions') { + args.push('--dangerously-bypass-approvals-and-sandbox'); + } else { + args.push('--sandbox', 'workspace-write'); + } + if (opts.extraArgs?.length) args.push(...opts.extraArgs); + args.push(opts.prompt); + return args; +} + +let runCounter = 0; + +export class CodexDriver implements AgentDriver { + readonly id = 'codex'; + constructor(private readonly bin = process.env.AGENTRY_CODEX_BIN ?? 'codex') {} + + capabilities(): DriverCapabilities { + return { + structuredStream: true, + llmInterception: 'provider-config', + mcpTransports: ['stdio'], + toolPermissionControl: true, + nativeBudgetControl: false, + }; + } + + async run(opts: RunOptions): Promise { + const runId = `codex-${runCounter++}`; + const factory = new EventFactory(runId); + const events: AgentEvent[] = []; + + const child = spawn(this.bin, buildArgs(opts), { + cwd: opts.cwd, + env: { ...process.env, ...opts.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const rl = createInterface({ input: child.stdout }); + rl.on('line', (line) => { + const s = line.trim(); + if (!s) return; + let obj: unknown; + try { + obj = JSON.parse(s); + } catch { + return; // tolerate non-JSON noise + } + for (const e of parseCodexEvent(obj, factory, runId)) events.push(e); + }); + + let stderr = ''; + child.stderr.on('data', (d) => (stderr += String(d))); + + let timer: NodeJS.Timeout | undefined; + const exitCode = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => resolve(code)); + if (opts.timeoutMs) timer = setTimeout(() => child.kill('SIGTERM'), opts.timeoutMs); + opts.signal?.addEventListener('abort', () => child.kill('SIGTERM'), { once: true }); + }).finally(() => timer && clearTimeout(timer)); + + const ended = events.find((e) => isEvent(e, 'run.end')); + const reason: RunEndReason = ended && isEvent(ended, 'run.end') ? ended.payload.reason : exitCode === 0 ? 'completed' : 'crash'; + if (!ended) { + // Codex emits no terminal event; synthesize run.end so the stream is well-formed (SPEC §6) + events.push( + factory.make( + { type: 'run.end', runId, exitCode, reason }, + { turnId: 'result', source: 'runner', agentNativeType: 'synthetic' }, + ), + ); + } + + const result: RunResult = { exitCode, reason, usage: aggregateUsage(events) }; + return new RunRecord(events, result); + } +} diff --git a/packages/codex/src/index.ts b/packages/codex/src/index.ts new file mode 100644 index 0000000..beb9426 --- /dev/null +++ b/packages/codex/src/index.ts @@ -0,0 +1,2 @@ +// @agentry/codex — codex driver. +export * from './driver'; From 9aa0c847080402945b906f505c0cc753ff32ed02 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 8 Aug 2026 16:23:01 -0400 Subject: [PATCH 3/5] test(codex): cover the event parser and buildArgs Fixtures are the live-captured codex-cli 0.147 event stream; asserts tool calls, assistant messages, usage, and the synthesized run.end. Co-Authored-By: Claude Opus 4.8 --- packages/codex/test/build-args.test.ts | 37 +++++++++ packages/codex/test/parse.test.ts | 105 +++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 packages/codex/test/build-args.test.ts create mode 100644 packages/codex/test/parse.test.ts diff --git a/packages/codex/test/build-args.test.ts b/packages/codex/test/build-args.test.ts new file mode 100644 index 0000000..6183997 --- /dev/null +++ b/packages/codex/test/build-args.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import type { RunOptions } from '@agentry/core'; +import { buildArgs } from '../src/driver'; + +const base: RunOptions = { prompt: 'create hello.txt', model: 'gpt-5-codex', cwd: '/tmp/x' }; + +describe('buildArgs', () => { + it('emits the headless exec --json base invocation with the prompt last', () => { + expect(buildArgs(base)).toEqual([ + 'exec', + '--json', + '--color', + 'never', + '--skip-git-repo-check', + '--ephemeral', + '-C', + '/tmp/x', + '-m', + 'gpt-5-codex', + '--sandbox', + 'workspace-write', + 'create hello.txt', + ]); + }); + + it('bypasses approvals/sandbox when permissionMode is bypassPermissions', () => { + const args = buildArgs({ ...base, permissionMode: 'bypassPermissions' }); + expect(args).toContain('--dangerously-bypass-approvals-and-sandbox'); + expect(args).not.toContain('--sandbox'); + expect(args[args.length - 1]).toBe('create hello.txt'); + }); + + it('appends extraArgs before the trailing prompt positional', () => { + const args = buildArgs({ ...base, extraArgs: ['--foo', 'bar'] }); + expect(args.slice(-3)).toEqual(['--foo', 'bar', 'create hello.txt']); + }); +}); diff --git a/packages/codex/test/parse.test.ts b/packages/codex/test/parse.test.ts new file mode 100644 index 0000000..457a515 --- /dev/null +++ b/packages/codex/test/parse.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { EventFactory, RunRecord, isEvent, type AgentEvent } from '@agentry/core'; +import { parseCodexEvent } from '../src/driver'; + +// Captured Codex `exec --json` events (ground truth — do not alter). +const SAMPLE = [ + { type: 'thread.started', thread_id: 'th_test' }, + { type: 'turn.started' }, + { type: 'item.completed', item: { id: 'item_0', type: 'agent_message', text: 'working' } }, + { + type: 'item.started', + item: { id: 'item_1', type: 'file_change', changes: [{ path: 'hello.txt', kind: 'add' }], status: 'in_progress' }, + }, + { + type: 'item.completed', + item: { id: 'item_1', type: 'file_change', changes: [{ path: 'hello.txt', kind: 'add' }], status: 'completed' }, + }, + { + type: 'item.started', + item: { + id: 'item_2', + type: 'command_execution', + command: "/bin/zsh -lc 'cat hello.txt'", + aggregated_output: '', + exit_code: null, + status: 'in_progress', + }, + }, + { + type: 'item.completed', + item: { + id: 'item_2', + type: 'command_execution', + command: "/bin/zsh -lc 'cat hello.txt'", + aggregated_output: 'hi\n', + exit_code: 0, + status: 'completed', + }, + }, + { type: 'item.completed', item: { id: 'item_3', type: 'agent_message', text: 'done' } }, + { + type: 'turn.completed', + usage: { + input_tokens: 76678, + cached_input_tokens: 65536, + cache_write_input_tokens: 0, + output_tokens: 214, + reasoning_output_tokens: 24, + }, + }, +]; + +function parseAll(): AgentEvent[] { + const f = new EventFactory('r', () => 0); + const events = SAMPLE.flatMap((raw) => parseCodexEvent(raw, f, 'r')); + events.push( + f.make( + { type: 'run.end', runId: 'r', exitCode: 0, reason: 'completed' }, + { turnId: 'result', source: 'runner', agentNativeType: 'synthetic' }, + ), + ); + return events; +} + +describe('parseCodexEvent', () => { + const events = parseAll(); + const rec = new RunRecord(events); + + it('maps thread.started to run.start with agent codex', () => { + const start = events.find((e) => isEvent(e, 'run.start')); + expect(start && isEvent(start, 'run.start') && start.payload.agent).toBe('codex'); + }); + + it('maps file_change and command_execution to tool_use/tool_result', () => { + expect(rec.toolCalls).toHaveLength(2); + expect(rec.findToolCalls('apply_patch')).toHaveLength(1); + expect(rec.findToolCalls('shell')).toHaveLength(1); + + const results = events.filter((e) => isEvent(e, 'tool_result')); + expect(results).toHaveLength(2); + const shellResult = results.find((e) => isEvent(e, 'tool_result') && e.payload.name === 'shell'); + expect(shellResult && isEvent(shellResult, 'tool_result') && shellResult.payload.result).toBe('hi\n'); + expect(shellResult && isEvent(shellResult, 'tool_result') && shellResult.payload.isError).toBe(false); + }); + + it('maps agent_message items to assistant messages', () => { + expect(rec.assistantMessages).toHaveLength(2); + expect(rec.lastMessage).toBe('done'); + }); + + it('maps turn.completed usage without cost', () => { + const usage = events.filter((e) => isEvent(e, 'usage')); + expect(usage).toHaveLength(1); + expect(rec.usage.inputTokens).toBe(76678); + expect(rec.usage.outputTokens).toBe(214); + expect(isEvent(usage[0]!, 'usage') && usage[0]!.payload.cacheReadTokens).toBe(65536); + expect(isEvent(usage[0]!, 'usage') && usage[0]!.payload.cacheCreationTokens).toBe(0); + expect(isEvent(usage[0]!, 'usage') && usage[0]!.payload.costUSD).toBeUndefined(); + }); + + it('yields a terminal run.end with reason completed on clean close', () => { + const end = events.find((e) => isEvent(e, 'run.end')); + expect(end && isEvent(end, 'run.end') && end.payload.reason).toBe('completed'); + }); +}); From e01bb3620ff0f7e08d49cfe9bf71fa8ff08ea044 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 8 Aug 2026 16:23:01 -0400 Subject: [PATCH 4/5] feat(cli): select the live driver by the configured agent selectDriver(config.use.agent) routes 'codex' to CodexDriver and defaults to Claude; doctor probes the codex CLI and prints its capabilities. Constraint: Parallel driver branches each extend this switch; kept minimal for clean union merges Confidence: high Scope-risk: narrow Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/doctor.ts | 15 +++++++++++++++ packages/cli/src/commands/run.ts | 14 +++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index a27df19..2166790 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import { ClaudeDriver } from '@agentry/claude'; +import { CodexDriver } from '@agentry/codex'; /** \`agentry doctor\` — probe installed agent CLIs and print driver capabilities. */ export async function cmdDoctor(): Promise { @@ -18,5 +19,19 @@ export async function cmdDoctor(): Promise { for (const [k, val] of Object.entries(caps)) { console.log(` ${k}: ${JSON.stringify(val)}`); } + + const codexBin = process.env.AGENTRY_CODEX_BIN ?? 'codex'; + const cv = spawnSync(codexBin, ['--version'], { encoding: 'utf8' }); + if (cv.status === 0) { + console.log(`\n codex ✓ ${cv.stdout.trim()}`); + } else { + console.log(`\n codex ✗ not found (looked for '${codexBin}')`); + } + + console.log('\n codex driver capabilities:'); + const codexCaps = new CodexDriver().capabilities(); + for (const [k, val] of Object.entries(codexCaps)) { + console.log(` ${k}: ${JSON.stringify(val)}`); + } return 0; } diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 550a4b7..4503d2b 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -11,10 +11,22 @@ import { reportConsole, type RunMode, type AgentryConfig, + type AgentDriver, } from '@agentry/core'; import { ClaudeDriver } from '@agentry/claude'; +import { CodexDriver } from '@agentry/codex'; import { discoverTests } from '../discover'; +function selectDriver(agent: string | undefined): AgentDriver { + switch (agent) { + case 'codex': + return new CodexDriver(); + case 'claude': + default: + return new ClaudeDriver(); + } +} + /** `agentry test` / `agentry record`. Returns a process exit code. */ export async function cmdRun(args: string[], forceMode?: RunMode): Promise { const { values } = parseArgs({ @@ -59,7 +71,7 @@ export async function cmdRun(args: string[], forceMode?: RunMode): Promise 0 ? 1 : 0; From 27af1811919f0d6c8ca8f2e23b7aef31ffae28c6 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 8 Aug 2026 16:23:01 -0400 Subject: [PATCH 5/5] docs(readme): document the codex driver Co-Authored-By: Claude Opus 4.8 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 33df4aa..95b10be 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ All TypeScript is executed directly via `tsx` — no build step required for dev |---|---| | `@agentry/core` | Event model, config, sandbox, runner, assertions | | `@agentry/claude` | Claude Code driver (`claude -p --output-format stream-json`) | +| `@agentry/codex` | Codex CLI driver (`codex exec --json`) | | `@agentry/mcp` | `MockMcpServer` (JSON-RPC + stdio shim) + MCP matchers | | `agentry` | CLI (`agentry test`, `record`, `init`, `doctor`) | @@ -245,6 +246,7 @@ The SPEC describes the full vision. What is implemented vs. planned: **Implemented** - Claude driver (`claude -p --output-format stream-json`) +- Codex driver (`codex exec --json`) - Normalized causal event model (`tool_use`, `tool_result`, `mcp_request`, `llm_request`/`llm_response`, `message`, `usage`, `run.end`, `plugin`, `skill`, `fs`) - Assertions, Tiers 1–3: `toHaveToolCall`, `toHaveCalledToolTimes`, `toUseToolsFrom`, `toHaveCalledAll`, `toHaveMcpRequest`, `toFinishWithin`, `toHaveFile`, `toMatchSchema` - Skill/plugin effect matchers: `toHaveLoadedPlugin`, `toFireHook` (CH2) and `toInjectContext`, `toRegisterTools` (CH1, via the proxy)