From afb953065afa2339251df3cab80d6a94833c3f86 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 07:24:56 +0000 Subject: [PATCH 1/4] fix(amp): colocate standalone event workers --- .changeset/fix-amp-event-worker.md | 5 ++ packages/agent-bundle/src/build/entries.ts | 80 +++++++++++-------- .../agent-bundle/tests/amp-adapter.test.ts | 78 +++++++++++++++++- packages/agent-bundle/tests/entries.test.ts | 41 ++++++++++ 4 files changed, 169 insertions(+), 35 deletions(-) create mode 100644 .changeset/fix-amp-event-worker.md diff --git a/.changeset/fix-amp-event-worker.md b/.changeset/fix-amp-event-worker.md new file mode 100644 index 000000000..9ff0187b4 --- /dev/null +++ b/.changeset/fix-amp-event-worker.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Keep standalone event workers beside nested Amp hook wrappers so relocated plugins can execute event routes (#739). diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 3ddab87b2..f6b22f459 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { dirname, extname, join, relative, resolve } from 'node:path'; +import { dirname, extname, join, posix, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { hooksFlightWorkerPath } from '../adapters/composite-layout.ts'; @@ -570,12 +570,22 @@ const hookEntrySourceInputs = (entry: TargetHookEntry): readonly string[] => { ]); }; +const requiresStandaloneHookWorker = (entry: TargetHookEntry): boolean => + entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone'; + +const hookWorkerPath = (entry: TargetHookEntry): string => + posix.join(posix.dirname(entry.relativePath), posix.basename(hooksFlightWorkerPath)); + export const planCompiledHooks = ( entries: readonly TargetHookEntry[], options: { readonly outDir: string }, ): readonly CompiledHookEntry[] => { - const workerOwner = entries.findIndex((entry) => - entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone'); + const workerOwners = new Map(); + entries.forEach((entry, index) => { + if (requiresStandaloneHookWorker(entry) && !workerOwners.has(hookWorkerPath(entry))) { + workerOwners.set(hookWorkerPath(entry), index); + } + }); const workerSourceInputs = Object.freeze([...new Set(entries .filter((entry) => entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone') @@ -600,9 +610,9 @@ export const planCompiledHooks = ( executorSourceInputs: Object.freeze([entry.hook.provenance.sourcePath, entry.hook.source]), }), ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), - ...(index === workerOwner + ...(workerOwners.get(hookWorkerPath(entry)) === index ? { - workerOutput: resolveArtifactDestination(options.outDir, hooksFlightWorkerPath), + workerOutput: resolveArtifactDestination(options.outDir, hookWorkerPath(entry)), workerSourceInputs, } : {}), @@ -629,39 +639,41 @@ export const planHooksSurface = ( const compiled = planCompiledHooks(entries, options); const routeEntries = entries.filter((entry) => entry.hook.eventRoute !== undefined); const standaloneEventRoutes = [...new Map(routeEntries - .filter((entry) => - entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone') + .filter(requiresStandaloneHookWorker) .map((entry) => [entry.hook.id, entry.hook])).values()]; + const workerPlans = [...new Map(entries + .filter(requiresStandaloneHookWorker) + .map((entry) => [hookWorkerPath(entry), entry.hook])).entries()]; const workerArtifactEpoch = generatedRouteArtifactEpoch(options.plugin); const eventIpcRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('ipc'); const eventProjectRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('project'); const launchEnvRuntime = launchEnvRuntimePath(); - const workerEntry = standaloneEventRoutes.length === 0 - ? undefined - : { - name: 'hooks-flight', - outputRelativePath: hooksFlightWorkerPath, - reactServer: true as const, - rscManifest: true as const, - source: standaloneEventRoutes[0]!.source, - sourceInputs: Object.freeze([ - ...new Set([ - ...standaloneEventRoutes.flatMap((hook) => [hook.provenance.sourcePath, hook.source]), - ...(options.providers ?? []).map((provider) => provider.source), - ...(options.state === undefined ? [] : [options.state.provenance.sourcePath, options.state.source]), - ]), + const workerEntries = workerPlans.map(([outputRelativePath, hook]) => ({ + name: outputRelativePath === hooksFlightWorkerPath + ? 'hooks-flight' + : outputRelativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), + outputRelativePath, + reactServer: true as const, + rscManifest: true as const, + source: hook.source, + sourceInputs: Object.freeze([ + ...new Set([ + ...standaloneEventRoutes.flatMap((hook) => [hook.provenance.sourcePath, hook.source]), + ...(options.providers ?? []).map((provider) => provider.source), + ...(options.state === undefined ? [] : [options.state.provenance.sourcePath, options.state.source]), ]), - virtualSource: generatedRouteFlightWorkerSource({ - artifactEpoch: workerArtifactEpoch, - eventRoutes: standaloneEventRoutes, - ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), - providers: options.providers ?? [], - routes: [], - serverName: 'hooks', - ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), - ...(options.state === undefined ? {} : { state: options.state }), - }), - }; + ]), + virtualSource: generatedRouteFlightWorkerSource({ + artifactEpoch: workerArtifactEpoch, + eventRoutes: standaloneEventRoutes, + ...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }), + providers: options.providers ?? [], + routes: [], + serverName: 'hooks', + ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), + ...(options.state === undefined ? {} : { state: options.state }), + }), + })); return { entries: [ ...compiled.flatMap((entry, index) => { @@ -716,7 +728,7 @@ export const planHooksSurface = ( }, ]; }), - ...(workerEntry === undefined ? [] : [workerEntry]), + ...workerEntries, ], ignoredSourcePaths: [ runtimeIgnoredRoot(launchEnvRuntime), @@ -732,7 +744,7 @@ export const planHooksSurface = ( ?? (() => { throw new Error(`Missing bundled deferred hook executor evidence for ${JSON.stringify(entry.name)}.`); })(), }), ...(entry.workerOutput === undefined ? {} : { - workerSourceInputs: evidenceByPath.get(hooksFlightWorkerPath) ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(), + workerSourceInputs: evidenceByPath.get(hookWorkerPath(entries[index]!)) ?? (() => { throw new Error('Missing bundled hook Flight worker evidence.'); })(), }), }))); }, diff --git a/packages/agent-bundle/tests/amp-adapter.test.ts b/packages/agent-bundle/tests/amp-adapter.test.ts index 9d800809f..c4e70beee 100644 --- a/packages/agent-bundle/tests/amp-adapter.test.ts +++ b/packages/agent-bundle/tests/amp-adapter.test.ts @@ -13,7 +13,7 @@ import { validateArtifact } from '../src/build/validate-artifact.ts'; import type { JsonObject } from '../src/core/strict-json.ts'; import type { NormalizedHook, NormalizedPlugin } from '../src/core/types.ts'; import { projectEventDocument } from '../src/events/projection.ts'; -import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; +import { compileRouteGraph, emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { build } from './support/build.ts'; import { runNodeScript } from './support/run-node-script.ts'; @@ -745,3 +745,79 @@ it('compiles a nested Amp hook wrapper that returns the documented tool.call dec await rm(projectRoot, { force: true, recursive: true }); } }); + +it('runs a relocated standalone event route with its worker inside the Amp plugin', async () => { + const projectRoot = await mkdtemp(join(process.cwd(), 'packages', 'agent-bundle', '.amp-event-route-')); + const config = join(projectRoot, 'agent-bundle.config.ts'); + const handler = join(projectRoot, 'src', 'events', 'tool', 'before.tsx'); + const outputRoot = join(projectRoot, 'artifact'); + const relocated = join(projectRoot, 'relocated'); + await mkdir(join(projectRoot, 'src', 'events', 'tool'), { recursive: true }); + await writeFile(config, 'export default {};\n'); + await writeFile(handler, [ + "import { Agent } from '@agent-bundle/runtime';", + "import { createElement } from 'react';", + "export const config = { runtime: 'standalone' };", + "export default async function BeforeTool() {", + " return createElement(Agent.Result, { value: { outcome: 'deny', reason: 'blocked' } });", + '}', + '', + ].join('\n')); + const base = plugin(); + const hook = eventHook('tool-before', 'beforeTool', 'tool/before'); + const model: NormalizedPlugin = { + ...base, + hooks: [{ + ...hook, + id: 'hook:event-route:tool-before', + name: 'event-route-tool-before', + provenance: { kind: 'conventional', sourcePath: handler }, + source: handler, + }], + mcpServers: [], + metadata: { ...base.metadata, provenance: { kind: 'config', sourcePath: config } }, + skills: [], + targets: [{ + id: 'target:amp', + name: 'amp', + provenance: { kind: 'config', sourcePath: config }, + }], + }; + + try { + const registry = createDefaultRegistry(); + const routeGraph = await compileRouteGraph(projectRoot, { + plugin: { name: 'amp-review', version: '1.0.0' }, + }); + const built = await build({ + model, + outputRoot, + projectRoot: join(process.cwd(), 'packages', 'agent-bundle'), + registry, + routeGraph, + }); + const wrapper = '.amp/plugins/amp-review/hooks/event-route-tool-before.mjs'; + const worker = '.amp/plugins/amp-review/hooks/hooks-flight.mjs'; + expect(built.manifest.files.map((file) => file.path)).toContain(worker); + expect(await validateArtifact({ artifactRoot: outputRoot, registry })).toEqual([]); + + await rename(outputRoot, relocated); + const result = await runNodeScript({ + args: [join(relocated, wrapper)], + input: JSON.stringify({ + hook_event_name: 'tool.call', + session_id: 'thread-1', + tool_input: { command: 'pwd' }, + tool_name: 'shell', + tool_use_id: 'tool-1', + }), + }); + expect(result).toEqual({ + code: 0, + stderr: '', + stdout: '{"action":"reject-and-continue","message":"blocked"}', + }); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/entries.test.ts b/packages/agent-bundle/tests/entries.test.ts index 86c7a1255..43ad085ce 100644 --- a/packages/agent-bundle/tests/entries.test.ts +++ b/packages/agent-bundle/tests/entries.test.ts @@ -171,4 +171,45 @@ describe('event-route preflight source graph (#595)', () => { expect(executor.virtualSource).toContain('requestEventRuntime'); expect(executor.virtualSource).toContain('preflight-entries@1.0.0'); }); + + it('emits the standalone worker beside a nested host wrapper', () => { + const standalone = { + ...hook, + eventRoute: { event: 'tool/before' as const, fallback: 'none' as const, runtime: 'standalone' as const }, + targets: ['amp'], + }; + const nested = planHooks({ ...model, hooks: [standalone] }, 'amp', { + commandRoot: '', + encodePlaygroundInput: (input) => input, + encodePlaygroundOutput: (result) => result, + eventNames: {}, + eventRouteNames: { 'tool/before': 'tool.call' }, + hostContractRevision: 'test', + manifestPath: '.amp/hooks.json', + matchers: {}, + registration: 'api', + wrapperPath: (candidate) => `.amp/plugins/preflight-entries/hooks/${candidate.name}.mjs`, + wrapperSource: () => 'config-hook-only\n', + }).hookEntries; + const rootWrapper = { + ...nested[0]!, + relativePath: 'hooks/event-route-tool-before.claude.mjs', + target: 'claude', + }; + const combined = [rootWrapper, ...nested]; + const workers = [ + 'hooks/hooks-flight.mjs', + '.amp/plugins/preflight-entries/hooks/hooks-flight.mjs', + ]; + + expect(planCompiledHooks(combined, { outDir: '/tmp/artifact' }) + .flatMap((entry) => entry.workerOutput === undefined ? [] : [entry.workerOutput])) + .toEqual(workers.map((worker) => `/tmp/artifact/${worker}`)); + const outputs = planHooksSurface(combined, { + artifactEpoch: 'preflight-entries@1.0.0', + outDir: '/tmp/artifact', + plugin: { name: 'preflight-entries', version: '1.0.0' }, + }).entries.map((entry) => entry.outputRelativePath); + expect(outputs).toEqual(expect.arrayContaining(workers)); + }); }); From a00f792232cb47048b11b64a1a4b8a9805549206 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 07:25:23 +0000 Subject: [PATCH 2/4] docs(changeset): reference PR 740 --- .changeset/fix-amp-event-worker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-amp-event-worker.md b/.changeset/fix-amp-event-worker.md index 9ff0187b4..b7d0d567a 100644 --- a/.changeset/fix-amp-event-worker.md +++ b/.changeset/fix-amp-event-worker.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Keep standalone event workers beside nested Amp hook wrappers so relocated plugins can execute event routes (#739). +Keep standalone event workers beside nested Amp hook wrappers so relocated plugins can execute event routes (#740). From 51adcac8d2240a643c01de2df1dd0f311d539d7e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 09:05:36 +0000 Subject: [PATCH 3/4] fix(amp): resolve nested workers everywhere --- packages/agent-bundle/src/build/entries.ts | 3 +-- .../src/dev/routes/route-invocation-service.ts | 8 ++++++-- packages/agent-bundle/tests/amp-adapter.test.ts | 9 +++++++++ packages/agent-bundle/tests/amp-install.test.ts | 6 ++++++ .../tests/route-invocation-service.test.ts | 15 +++++++++------ website/docs/en/reference/targets-artifacts.mdx | 14 +++++++++----- website/docs/zh/reference/targets-artifacts.mdx | 12 ++++++++---- 7 files changed, 48 insertions(+), 19 deletions(-) diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index f6b22f459..6d151268b 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -587,8 +587,7 @@ export const planCompiledHooks = ( } }); const workerSourceInputs = Object.freeze([...new Set(entries - .filter((entry) => - entry.hook.eventRoute?.runtime === 'standalone' || entry.hook.eventRoute?.fallback === 'standalone') + .filter(requiresStandaloneHookWorker) .flatMap((entry) => [entry.hook.provenance.sourcePath, entry.hook.source]))]); return deepFreeze(entries.map((entry, index) => ({ event: entry.event, diff --git a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts index c3e069889..f58e4fd16 100644 --- a/packages/agent-bundle/src/dev/routes/route-invocation-service.ts +++ b/packages/agent-bundle/src/dev/routes/route-invocation-service.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { fork, type ChildProcess } from 'node:child_process'; import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, posix, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { AgentDocument, AgentDocumentNode, AgentRenderEvent } from '@agent-bundle/runtime'; @@ -603,7 +603,11 @@ const productionBindingFor = ( && candidate.hosts.some((candidateHost) => eligibleHosts.has(candidateHost)) && candidate.launch?.worker !== undefined)) .find((candidate) => candidate !== undefined); - const standalone = manifest.files.find((file) => file.path === hooksFlightWorkerPath)?.path; + const standalone = [ + ...(wrapper === undefined ? wrappers : [wrapper]) + .map((candidate) => posix.join(posix.dirname(candidate.path), posix.basename(hooksFlightWorkerPath))), + hooksFlightWorkerPath, + ].find((candidate) => manifest.files.some((file) => file.path === candidate)); const executable = execution.runtime === 'standalone' ? standalone : shared?.launch?.worker ?? (execution.fallback === 'standalone' ? standalone : undefined); diff --git a/packages/agent-bundle/tests/amp-adapter.test.ts b/packages/agent-bundle/tests/amp-adapter.test.ts index c4e70beee..568d825e0 100644 --- a/packages/agent-bundle/tests/amp-adapter.test.ts +++ b/packages/agent-bundle/tests/amp-adapter.test.ts @@ -798,7 +798,16 @@ it('runs a relocated standalone event route with its worker inside the Amp plugi }); const wrapper = '.amp/plugins/amp-review/hooks/event-route-tool-before.mjs'; const worker = '.amp/plugins/amp-review/hooks/hooks-flight.mjs'; + expect(built.manifest.projections).toEqual([{ + builtInHost: 'amp', + documents: { entry: '.amp/plugins/amp-review/index.js' }, + host: 'amp', + }]); expect(built.manifest.files.map((file) => file.path)).toContain(worker); + expect(built.manifest.files.map((file) => file.path).filter((path) => path.endsWith('/index.js'))) + .toEqual(['.amp/plugins/amp-review/index.js']); + expect(built.manifest.compiler.provenance).toContainEqual(expect.objectContaining({ path: worker })); + expect(built.compileEvidence.assets).toContainEqual(expect.objectContaining({ path: worker })); expect(await validateArtifact({ artifactRoot: outputRoot, registry })).toEqual([]); await rename(outputRoot, relocated); diff --git a/packages/agent-bundle/tests/amp-install.test.ts b/packages/agent-bundle/tests/amp-install.test.ts index 6b0964f13..7a3e34777 100644 --- a/packages/agent-bundle/tests/amp-install.test.ts +++ b/packages/agent-bundle/tests/amp-install.test.ts @@ -20,7 +20,9 @@ const writeBundle = async ( ): Promise => { const plugin = join(root, '.amp', 'plugins', name); await mkdir(join(plugin, 'skills', 'review'), { recursive: true }); + await mkdir(join(plugin, 'hooks'), { recursive: true }); await writeFile(join(plugin, 'index.js'), `export default async function () { /* ${marker} */ }\n`); + await writeFile(join(plugin, 'hooks', 'hooks-flight.mjs'), `export const marker = ${JSON.stringify(marker)};\n`); await writeFile(join(plugin, 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: Review code.\n---\n'); await writeFile(join(root, 'outside.txt'), 'must not be installed\n'); await writeInstallFixtureManifest(root, { name, version }, [{ host: 'amp' }]); @@ -65,8 +67,10 @@ it('installs, replaces, and uninstalls only the receipt-owned Amp directory', as 'Run `amp plugins list` in a shell to inspect the installed plugin.', ]); await expect(readFile(join(destination, 'index.js'), 'utf8')).resolves.toContain('first'); + await expect(readFile(join(destination, 'hooks', 'hooks-flight.mjs'), 'utf8')).resolves.toContain('first'); await expect(readFile(join(destination, 'outside.txt'), 'utf8')).rejects.toThrow(); expect(await readInstallReceipt(destination)).toMatchObject({ + files: expect.arrayContaining(['hooks/hooks-flight.mjs']), host: 'amp', mode: 'local', plugin: pluginName, @@ -94,6 +98,7 @@ it('installs, replaces, and uninstalls only the receipt-owned Amp directory', as }); expect(replaced.state).toBe('replaced'); await expect(readFile(join(destination, 'index.js'), 'utf8')).resolves.toContain('second'); + await expect(readFile(join(destination, 'hooks', 'hooks-flight.mjs'), 'utf8')).resolves.toContain('second'); await expect(readFile(join(destination, 'disabled-state.json'), 'utf8')).resolves.toBe('{"disabled":true}\n'); await expect(readFile(settings, 'utf8')).resolves.toBe('{"amp.plugins.disabled":["amp-install-fixture"]}\n'); @@ -122,6 +127,7 @@ it('installs, replaces, and uninstalls only the receipt-owned Amp directory', as state: 'uninstalled', }); await expect(readFile(join(destination, 'index.js'), 'utf8')).rejects.toThrow(); + await expect(readFile(join(destination, 'hooks', 'hooks-flight.mjs'), 'utf8')).rejects.toThrow(); await expect(readFile(join(destination, 'skills', 'review', 'SKILL.md'), 'utf8')).rejects.toThrow(); await expect(readFile(join(destination, 'disabled-state.json'), 'utf8')).resolves.toBe('{"disabled":true}\n'); await expect(readFile(settings, 'utf8')).resolves.toBe('{"amp.plugins.disabled":["amp-install-fixture"]}\n'); diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index 3d78121d4..c09ed11f0 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -463,7 +463,7 @@ it('publishes failed event invocations with native provenance', async () => { }); }); -it('keeps hostless shared events on their declared standalone fallback', async () => { +it('keeps hostless shared events on a nested host standalone fallback', async () => { const route = { config: [], event: 'tool/after', @@ -492,14 +492,14 @@ it('keeps hostless shared events on their declared standalone fallback', async ( manifest: { executables: { hooks: [{ - host: 'claude', + host: 'amp', kind: 'event-route', - path: 'hooks/event-route-tool-after.claude.mjs', + path: '.amp/plugins/fixture/hooks/event-route-tool-after.mjs', routeId: route.id, }], mcpServers: [], }, - files: [{ path: 'hooks/hooks-flight.mjs' }], + files: [{ path: '.amp/plugins/fixture/hooks/hooks-flight.mjs' }], routes: { digest: 'digest', events: [{ @@ -513,7 +513,7 @@ it('keeps hostless shared events on their declared standalone fallback', async ( }, manifest: { plugin: { name: 'fixture', version: '1.0.0' }, projectRoot: '/project' } as never, stateRoot: '/project/.agent-bundle/state', - targets: ['claude'], + targets: ['amp'], }, release: () => undefined, }), @@ -531,7 +531,10 @@ it('keeps hostless shared events on their declared standalone fallback', async ( diagnostics: [{ code: 'AB8236' }], status: 'failed', }); - expect(production).toEqual({ executable: 'hooks/hooks-flight.mjs', kind: 'direct' }); + expect(production).toEqual({ + executable: '.amp/plugins/fixture/hooks/hooks-flight.mjs', + kind: 'direct', + }); }); const echoRoute = { diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 13c60cfd4..a4fd34c81 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -32,6 +32,8 @@ directories appear only when the project authors them): artifact/ ├── .amp/plugins//index.js # Amp PluginAPI factory ├── .amp/plugins//skills/ # explicitly registered Amp skills +├── .amp/plugins//hooks/.mjs # Amp event callback wrappers +├── .amp/plugins//hooks/hooks-flight.mjs # sibling standalone event worker ├── .agents/plugins/marketplace.json # Codex marketplace ├── .claude-plugin/plugin.json # Claude Code manifest ├── .claude-plugin/marketplace.json @@ -62,11 +64,13 @@ artifact/ (`bundle`) file; `validate --artifact` re-checks a listed record against the file table (`AB6039`). -Host manifests live in their dotfolders at the root. `skills/`, `hooks/`, `mcp/`, `scripts/`, -`bin/`, and `assets/` are shared and emitted **once** — no per-host copies. Nothing else appears -at the root: no generated `AGENTS.md`, no `hooks/hooks-cursor.json`, no `web/` directory. The -browser host for configured MCP Apps ships inside `bin/.mjs` as the framework-owned -`web` command. That bin is emitted when `src/cli/**` compiled at least one command, when +Host manifests live in their dotfolders at the root. Root-level `skills/`, `hooks/`, `mcp/`, +`scripts/`, `bin/`, and `assets/` are shared and emitted **once**. Amp's registered skills, +event wrappers, and each wrapper directory's sibling standalone worker stay inside +`.amp/plugins//` so the installed plugin remains self-contained. Nothing else appears at +the root: no generated `AGENTS.md`, no `hooks/hooks-cursor.json`, no `web/` directory. The browser +host for configured MCP Apps ships inside `bin/.mjs` as the framework-owned `web` command. +That bin is emitted when `src/cli/**` compiled at least one command, when [`web`](./configuration.mdx#web) is configured (even with no authored CLI commands), or both. ### Where each host reads its documents diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index 7d85ae32f..b311a44ee 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -27,6 +27,8 @@ target 表格——各宿主投影携带什么,以及 portable 标准为何省 artifact/ ├── .amp/plugins//index.js # Amp PluginAPI 工厂 ├── .amp/plugins//skills/ # 显式注册的 Amp Skill +├── .amp/plugins//hooks/.mjs # Amp 事件回调 wrapper +├── .amp/plugins//hooks/hooks-flight.mjs # 同目录的独立事件 worker ├── .agents/plugins/marketplace.json # Codex 市场 ├── .claude-plugin/plugin.json # Claude Code 清单 ├── .claude-plugin/marketplace.json @@ -56,10 +58,12 @@ artifact/ `agent-bundle.compile-evidence.json` 是编译器对每个已编译(`bundle`)文件的记录; `validate --artifact` 把已列入清单的记录对照文件表复核(`AB6039`)。 -宿主清单位于根目录下各自的点目录中。`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 是共享的, -只输出**一次**——没有逐宿主副本。根目录下不会出现其他任何东西:没有生成的 `AGENTS.md`,也没有 -`hooks/hooks-cursor.json`,也没有 `web/` 目录。已配置 MCP App 的浏览器宿主作为框架拥有的 `web` 命令 -装在 `bin/.mjs` 里。该 bin 在 `src/cli/**` 编译出至少一条命令时、在配置了 +宿主清单位于根目录下各自的点目录中。根级 `skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` +是共享的,只输出**一次**。Amp 注册的 Skill、事件 wrapper,以及与各 wrapper 目录同级的独立 worker +都保留在 `.amp/plugins//` 内,使安装后的插件保持自包含。根目录下不会出现其他任何东西: +没有生成的 `AGENTS.md`,也没有 `hooks/hooks-cursor.json`,也没有 `web/` 目录。已配置 MCP App 的浏览器 +宿主作为框架拥有的 `web` 命令装在 `bin/.mjs` 里。该 bin 在 `src/cli/**` 编译出至少一条命令时、 +在配置了 [`web`](./configuration.mdx#web) 时(即使没有手写 CLI 命令),或两者兼有时输出。 ### 各宿主从哪里读取文档 From 1a32aaaeb71d9d849a60e4d3b67f8ef8b293e037 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 09:20:46 +0000 Subject: [PATCH 4/4] test(amp): preserve root worker coverage --- .../tests/route-invocation-service.test.ts | 25 ++++++++++++++----- .../docs/en/reference/targets-artifacts.mdx | 2 +- .../docs/zh/reference/targets-artifacts.mdx | 4 +-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/agent-bundle/tests/route-invocation-service.test.ts b/packages/agent-bundle/tests/route-invocation-service.test.ts index c09ed11f0..cc33453bb 100644 --- a/packages/agent-bundle/tests/route-invocation-service.test.ts +++ b/packages/agent-bundle/tests/route-invocation-service.test.ts @@ -463,7 +463,20 @@ it('publishes failed event invocations with native provenance', async () => { }); }); -it('keeps hostless shared events on a nested host standalone fallback', async () => { +it.each([ + { + host: 'claude', + label: 'the shared root', + worker: 'hooks/hooks-flight.mjs', + wrapper: 'hooks/event-route-tool-after.claude.mjs', + }, + { + host: 'amp', + label: 'a nested Amp plugin', + worker: '.amp/plugins/fixture/hooks/hooks-flight.mjs', + wrapper: '.amp/plugins/fixture/hooks/event-route-tool-after.mjs', + }, +] as const)('keeps hostless shared events on the standalone fallback in $label', async ({ host, worker, wrapper }) => { const route = { config: [], event: 'tool/after', @@ -492,14 +505,14 @@ it('keeps hostless shared events on a nested host standalone fallback', async () manifest: { executables: { hooks: [{ - host: 'amp', + host, kind: 'event-route', - path: '.amp/plugins/fixture/hooks/event-route-tool-after.mjs', + path: wrapper, routeId: route.id, }], mcpServers: [], }, - files: [{ path: '.amp/plugins/fixture/hooks/hooks-flight.mjs' }], + files: [{ path: worker }], routes: { digest: 'digest', events: [{ @@ -513,7 +526,7 @@ it('keeps hostless shared events on a nested host standalone fallback', async () }, manifest: { plugin: { name: 'fixture', version: '1.0.0' }, projectRoot: '/project' } as never, stateRoot: '/project/.agent-bundle/state', - targets: ['amp'], + targets: ['claude'], }, release: () => undefined, }), @@ -532,7 +545,7 @@ it('keeps hostless shared events on a nested host standalone fallback', async () status: 'failed', }); expect(production).toEqual({ - executable: '.amp/plugins/fixture/hooks/hooks-flight.mjs', + executable: worker, kind: 'direct', }); }); diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index a4fd34c81..1c58c54ef 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -66,7 +66,7 @@ file table (`AB6039`). Host manifests live in their dotfolders at the root. Root-level `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `assets/` are shared and emitted **once**. Amp's registered skills, -event wrappers, and each wrapper directory's sibling standalone worker stay inside +event wrappers, and the standalone worker beside those wrappers in each directory stay inside `.amp/plugins//` so the installed plugin remains self-contained. Nothing else appears at the root: no generated `AGENTS.md`, no `hooks/hooks-cursor.json`, no `web/` directory. The browser host for configured MCP Apps ships inside `bin/.mjs` as the framework-owned `web` command. diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index b311a44ee..fd2dbf232 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -59,8 +59,8 @@ artifact/ `validate --artifact` 把已列入清单的记录对照文件表复核(`AB6039`)。 宿主清单位于根目录下各自的点目录中。根级 `skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` -是共享的,只输出**一次**。Amp 注册的 Skill、事件 wrapper,以及与各 wrapper 目录同级的独立 worker -都保留在 `.amp/plugins//` 内,使安装后的插件保持自包含。根目录下不会出现其他任何东西: +是共享的,只输出**一次**。Amp 注册的 Skill、事件 wrapper,以及各目录内与这些 wrapper 同级的独立 +worker 都保留在 `.amp/plugins//` 内,使安装后的插件保持自包含。根目录下不会出现其他任何东西: 没有生成的 `AGENTS.md`,也没有 `hooks/hooks-cursor.json`,也没有 `web/` 目录。已配置 MCP App 的浏览器 宿主作为框架拥有的 `web` 命令装在 `bin/.mjs` 里。该 bin 在 `src/cli/**` 编译出至少一条命令时、 在配置了