Skip to content
Closed
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
13 changes: 13 additions & 0 deletions docs/plans/hooks-and-feature-packs.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,19 @@ revisiting this document, not silently diverging in an implementation PR.
tabs and durable session remain.
Direct network, arbitrary IPC/Electron access, generic host calls, and unused renderer
contribution placeholders remain unavailable.
**External function-hook registration (SDK stage 1):** a selected user plugin may
declare `runtime.hooks: [{ id, event }]`, including a hook-only runtime. Events
use the canonical catalogue; IDs are unique per plugin. API v1 adds
`registerHook(definition, handler)` during activation and an `invoke-hook`
request carrying the registration ID, event, and opaque JSON input. Both the
host and worker check the registration/event pair; startup rejects missing,
extra, duplicate, or event-mismatched registrations. Handlers receive only
`{ event, signal }`, never first-party context, feature-chunk emission, browser
or session authority. Existing workers may omit the hooks list. This stage
establishes registration and explicit host invocation only: canonical fire
sites do not dispatch these hooks yet, and results are not interpreted as
decisions or transformations. Automatic dispatch, event-specific payload/result
validation, and `next()` composition require their own implementation stages.
16. **Async hook outputs are epoch-scoped to their emitting turn tree.** Send-now
currently aborts the active local run (`sendQueuedMessageNow` in
`src/renderer/controller/message-queue.ts`), so a late async hook from a completed
Expand Down
7 changes: 7 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ the runtime `RegisteredPlugin.contributions`, not in the serializable manifest.
The one user-code exception is an explicitly selected plugin's isolated
shared `runtime`; it never imports code into Electron main.

The isolated runtime also supports declared `runtime.hooks` registrations through
`registerHook` and explicit host invocation. Hook-only runtimes are accepted. This
is the SDK/protocol stage: the agent's canonical event fire sites do not invoke
these external functions yet, and their results cannot alter agent behavior.
See the [SDK contract](../packages/plugin-sdk/README.md#external-function-hook-registration)
for declaration matching, cancellation, and the deliberately narrow handler context.

`pluginManifestFromCursorJson()` maps a Cursor-shaped `plugin.json` into a
`PluginManifest` (a user plugin): the existing top-level `skills` / `mcpServers`
fields fold into the plugin slots (`mcpServers` → `tools.mcpServers`). The
Expand Down
9 changes: 9 additions & 0 deletions packages/agent/src/plugins/agent-plugin-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ function copse(block: Record<string, unknown>): Record<string, unknown> {
}

describe('isValidAgentPluginName', () => {
it('preserves validated runtime hook declarations in the Copse extension', () => {
const runtime = {
entrypoint: 'dist/index.mjs',
apiVersion: 1,
hooks: [{ id: 'inspect', event: 'turnStart' }],
}
const parsed = parseAgentPluginManifest(manifest(copse({ runtime })))
assert.deepEqual(parsed.manifest.runtime, runtime)
})
it('accepts the spec §5.5 examples', () => {
for (const name of ['my-plugin', 'acme.tools', 'lint3r', 'a']) {
assert.equal(isValidAgentPluginName(name), true, name)
Expand Down
3 changes: 3 additions & 0 deletions packages/agent/src/plugins/agent-plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
// Electron-free (execution-guidance rule 4): the host disk walk that feeds this
// a parsed object lives in `src/main/services/plugins/discover-user-plugins.ts`.
import { z } from 'zod'
import { zPluginHookRegistrations } from './plugin-hook.ts'
import type {
PluginBrowserDecl,
PluginCapabilityDecl,
Expand Down Expand Up @@ -173,6 +174,7 @@ const zBrowserDecl = z.strictObject({
const zRuntimeDecl = z.strictObject({
entrypoint: z.string().min(1).max(1_000),
apiVersion: z.literal(1),
hooks: zPluginHookRegistrations.min(1).optional(),
})

const zCommandHook = z.strictObject({
Expand Down Expand Up @@ -429,6 +431,7 @@ function applyCopseExtension(
const runtime: PluginToolRuntimeDecl = {
entrypoint: extension.runtime.entrypoint,
apiVersion: extension.runtime.apiVersion,
...(extension.runtime.hooks ? { hooks: extension.runtime.hooks } : {}),
}
manifest.runtime = runtime
}
Expand Down
54 changes: 54 additions & 0 deletions packages/agent/src/plugins/plugin-hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { HOOK_EVENT_NAMES } from '../hooks/canonical-events.ts'
import {
validatePluginHookRegistrations,
zPluginHookRegistration,
zPluginHookRegistrations,
} from './plugin-hook.ts'

describe('external hook declarations', () => {
it('accepts every canonical event and rejects unknown events and malformed declarations', () => {
for (const event of HOOK_EVENT_NAMES) {
assert.deepEqual(zPluginHookRegistration.parse({ id: 'hook', event }), { id: 'hook', event })
}
for (const event of [
'*',
'tool.call',
'constructor',
'toString',
...HOOK_EVENT_NAMES.map((e) => `${e} `),
]) {
assert.equal(zPluginHookRegistration.safeParse({ id: 'hook', event }).success, false)
}
for (const value of [
null,
[],
{},
{ id: '', event: 'turnStart' },
{ id: 'x', event: 'turnStart', command: 'echo x' },
]) {
assert.equal(zPluginHookRegistration.safeParse(value).success, false)
}
})

it('requires exact ids and events, allowing registration order to differ from declaration order', () => {
const a = zPluginHookRegistration.parse({ id: 'a', event: 'turnStart' })
const b = zPluginHookRegistration.parse({ id: 'b', event: 'stop' })
validatePluginHookRegistrations([a, b], [b, a])
for (const actual of [[], [a], [a, a], [a, { ...b, event: a.event }]]) {
assert.throws(() => {
validatePluginHookRegistrations([a, b], actual)
})
}
assert.throws(() => {
validatePluginHookRegistrations([], [a])
})
assert.equal(
zPluginHookRegistrations.safeParse(
Array.from({ length: 1_001 }, (_, i) => ({ ...a, id: String(i) })),
).success,
false,
)
})
})
29 changes: 29 additions & 0 deletions packages/agent/src/plugins/plugin-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { z } from 'zod'
import { HOOK_EVENT_NAMES } from '../hooks/canonical-events.ts'

/** A worker hook is declared independently of the existing command-hook dialects. */
export const zPluginHookRegistration = z.strictObject({
id: z.string().min(1).max(128),
event: z.enum(HOOK_EVENT_NAMES),
})

export const zPluginHookRegistrations = z
.array(zPluginHookRegistration)
.max(1_000)
.refine((hooks) => new Set(hooks.map((hook) => hook.id)).size === hooks.length, {
message: 'Plugin hook ids must be unique.',
})

export type PluginHookRegistration = z.infer<typeof zPluginHookRegistration>

/** A worker may register exactly the hook ids/events selected in its manifest. */
export function validatePluginHookRegistrations(
declared: readonly PluginHookRegistration[],
registered: readonly PluginHookRegistration[],
): void {
const expected = new Map(zPluginHookRegistrations.parse(declared).map((h) => [h.id, h.event]))
const actual = zPluginHookRegistrations.parse(registered)
if (expected.size !== actual.length || actual.some((h) => expected.get(h.id) !== h.event)) {
throw new Error('Plugin registered hooks not declared by its runtime behavior.')
}
}
3 changes: 3 additions & 0 deletions packages/agent/src/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// (schemas/copse-plugin.schema.json) validates the declarative manifest.
import type { AsyncHook, BlockingHook } from '../hooks/canonical-events.ts'
import type { PanelContributionDecl } from './plugin-panel.ts'
import type { PluginHookRegistration } from './plugin-hook.ts'

/** Host-assigned plugin trust class; disk manifests cannot self-promote. */
export type PluginTrust = 'first-party' | 'user'
Expand Down Expand Up @@ -215,6 +216,8 @@ export interface PluginStorageDecl {
export interface PluginToolRuntimeDecl {
entrypoint: string
apiVersion: 1
/** Isolated worker registrations; automatic canonical-event dispatch is a later stage. */
hooks?: readonly PluginHookRegistration[]
}

export interface PluginToolsDecl {
Expand Down
49 changes: 49 additions & 0 deletions packages/plugin-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,55 @@ The plugin layer's design source of truth is `docs/plans/hooks-and-feature-packs
the landed architecture is `docs/plugins.md`, and authoring is `docs/adding-a-plugin.md`.
This package is the part a plugin author, or a second host, would install.

## External function-hook registration

API v1 supports isolated hook registration and explicit host invocation. This is
the first stage of function-hook support: Copse's canonical event fire sites do
not invoke these registrations automatically yet. Results are opaque JSON, not
permission decisions or tool-result replacements. The existing command-hook path
is separate.

An explicitly selected `copse-plugin.json` may contain only a hook runtime:

```json
{
"name": "personal.inspect",
"runtime": {
"entrypoint": "index.mjs",
"apiVersion": 1,
"hooks": [{ "id": "inspect-start", "event": "turnStart" }]
}
}
```

Its module exports the existing `activate(api)` entry point:

```js
export function activate(api) {
api.registerHook({ id: 'inspect-start', event: 'turnStart' }, (input, { event, signal }) => {
signal.throwIfAborted()
return { event, received: input }
})
}
```

Hook IDs are unique within the plugin and registrations must exactly match the
declared IDs and events. The manifest order does not constrain activation order;
the handshake preserves registration order. All registrations finish before
`activate` settles; delayed registration is rejected. Events come from Copse's
canonical catalogue (`HookEventName`), with no wildcard or Claude event aliases.
The maximum is 1,000 hooks per plugin. Unknown fields and duplicate IDs fail
validation. Existing API-v1 workers can omit `hooks` in their registration reply.

The host/controller calls `invokeHook(pluginId, registrationId, event, input, signal)`
(the per-plugin `PluginToolHost` omits `pluginId`). The worker verifies the ID/event
pair and invokes that handler with only `{ event, signal }`. Input and output are
typed `unknown`; callers and handlers must validate the JSON shapes they use.
This transport grants no browser, session, Electron, feature-chunk or `next`
capability. Throws and non-serializable results reject the request; cancellation
uses the existing invocation-scoped abort channel. The runtime's existing OS
sandbox requirement and enable/disable lifecycle still apply.

## What's in it

- **`plugin-tool-sdk.ts`** — what a plugin's `runtime.entrypoint` imports:
Expand Down
89 changes: 89 additions & 0 deletions packages/plugin-sdk/src/plugin-hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { activatePluginTools, type PluginToolActivationApi } from './plugin-tool-sdk.ts'
import { zPluginToolHostRequest, zPluginToolRegistrations } from './plugin-tool-protocol.ts'

describe('external hook SDK and protocol', () => {
it('registers hooks in order and invokes only the requested handler with narrow context', async () => {
let later: (() => void) | undefined
const seen: string[] = []
const activated = await activatePluginTools(
{
activate(api: PluginToolActivationApi) {
api.registerHook({ id: 'second', event: 'stop' }, () => {
seen.push('second')
return null
})
api.registerHook({ id: 'first', event: 'turnStart' }, (input, context) => {
seen.push('first')
assert.deepEqual(Object.keys(context).sort(), ['event', 'signal'])
assert.equal(context.event, 'turnStart')
assert.equal(context.signal, signal)
return { received: input }
})
later = (): void => {
api.registerHook({ id: 'late', event: 'stop' }, () => null)
}
},
},
'personal.hooks',
1,
)
const signal = new AbortController().signal
assert.deepEqual(
activated.registrations.hooks?.map((h) => h.id),
['second', 'first'],
)
assert.deepEqual(
await activated.invokeHook('first', 'turnStart', { userText: 'hello' }, signal),
{ received: { userText: 'hello' } },
)
assert.deepEqual(seen, ['first'])
assert.throws(() => later?.(), /during activate/)
await assert.rejects(activated.invokeHook('first', 'stop', {}, signal), /event mismatch/)
await assert.rejects(activated.invokeHook('missing', 'stop', {}, signal), /Unknown plugin hook/)
const aborted = AbortSignal.abort(new Error('cancelled'))
await assert.rejects(activated.invokeHook('first', 'turnStart', {}, aborted), /cancelled/)
assert.deepEqual(seen, ['first'])
})

it('rejects duplicate hook ids even when their events differ', async () => {
await assert.rejects(
activatePluginTools(
{
activate(api: PluginToolActivationApi) {
api.registerHook({ id: 'same', event: 'turnStart' }, () => null)
api.registerHook({ id: 'same', event: 'stop' }, () => null)
},
},
'personal.hooks',
1,
),
/Duplicate plugin hook/,
)
})

it('preserves API-v1 registrations and rejects invalid hook protocol fields', () => {
assert.deepEqual(zPluginToolRegistrations.parse({ tools: [], models: [] }), {
tools: [],
models: [],
})
const request = { id: 1, op: 'invoke-hook', registrationId: 'h', event: 'turnStart', input: {} }
assert.deepEqual(zPluginToolHostRequest.parse(request), request)
for (const invalid of [
{ ...request, event: '*' },
{ ...request, next: 5 },
{ ...request, id: 0 },
]) {
assert.equal(zPluginToolHostRequest.safeParse(invalid).success, false)
}
assert.equal(
zPluginToolRegistrations.safeParse({
tools: [],
models: [],
hooks: [{ id: 'a', event: 'bogus' }],
}).success,
false,
)
})
})
20 changes: 20 additions & 0 deletions packages/plugin-sdk/src/plugin-tool-protocol.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { z } from 'zod'
import {
zPluginHookRegistration,
zPluginHookRegistrations,
} from '@copse/agent/plugins/plugin-hook.ts'

export {
zPluginHookRegistration,
zPluginHookRegistrations,
validatePluginHookRegistrations,
type PluginHookRegistration,
} from '@copse/agent/plugins/plugin-hook.ts'

const zRequestId = z.number().int().positive()
const zRegistrationId = z.string().min(1).max(128)
Expand Down Expand Up @@ -97,6 +108,8 @@ export const zPluginBrowserCall = z.discriminatedUnion('op', [
export const zPluginToolRegistrations = z.strictObject({
tools: z.array(zPluginToolRegistration).max(1_000),
models: z.array(zPluginModelRegistration).max(1_000),
// Omitted by existing API-v1 workers; omission means no function hooks.
hooks: zPluginHookRegistrations.optional(),
})

export type PluginToolRegistration = z.infer<typeof zPluginToolRegistration>
Expand All @@ -110,6 +123,13 @@ export type PluginBrowserCall = z.infer<typeof zPluginBrowserCall>
export type PluginToolRegistrations = z.infer<typeof zPluginToolRegistrations>

export const zPluginToolHostRequest = z.discriminatedUnion('op', [
z.strictObject({
id: zRequestId,
op: z.literal('invoke-hook'),
registrationId: zRegistrationId,
event: zPluginHookRegistration.shape.event,
input: z.unknown(),
}),
z.strictObject({
id: zRequestId,
op: z.literal('initialize'),
Expand Down
Loading
Loading