Skip to content
Open
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
6 changes: 4 additions & 2 deletions mods/sec-default/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,19 @@ settings it decides by.
| --- | --- |
| `classic.*` | Continue past the user tier: the organization's settings hooks see the engine's input and their answer stands. |
| `prompt.section`, `prompt.context`, `skill.prompt`, `attribution.text` | Continue past the user tier: managed CLAUDE.md, rules and policy skills reach the model as written. A person's plugins keep `prompt.submit` and its additive context. |
| `session.append` | Continue past the user tier: the rows a conversation keeps (a settings hook's context among them) are stored and sent as the organization's tiers and the built-ins left them. A person's plugins keep `prompt.submit`, `tool.call` and the other events that shape a row before it is kept. |
| `settings.read` | Continue past the user tier: no user hook rewrites what any caller reads as settings, this plugin's own policy reads included. |
| `tool.describe`, `command.describe`, `agent.offer`, `agent.spawn` | When the subject's pinned `e.provider.tier` is `prepend` or `append` (a policy-installed plugin, the managed folder, a policy MCP server), continue past the user tier; a subject provided by `user`, `builtin` or `core` passes. |
| `tool.register` | A caller in `prepend` or `append` continues past the user tier. A `user`-tier caller is refused by name while managed settings hold `allowedMcpServers` (set at all, empty included); otherwise it passes. |
| `tool.list` | The tools of the organization's managed MCP servers are listed as the organization's tiers listed them; every other tool as the user tier left it. With no policy to read, or a refusal from either listing, the organization's listing stands whole. |
| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `tool.check`, `command.run`, `command.register`, `session.*`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |
| everything else | Passes: `prompt.submit`, `turn.*`, `tool.call`, `tool.check`, `command.run`, `command.register`, `session.*` other than `session.append`, `ui.*`, `fs.*`, `http.fetch`, `process.run`, `store.*`, `clock.*`, `model.*`, `mcp.call`, `audio.*`, `agent.list`, `engine.create`. |

## What it hooks

`classic.*`, `prompt.section`, `prompt.context`, `skill.prompt`,
`attribution.text`, `settings.read`, `tool.describe`, `command.describe`,
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`.
`agent.offer`, `agent.spawn`, `tool.register`, `tool.list`,
`session.append`.

## What it calls on `$`

Expand Down
1 change: 1 addition & 0 deletions mods/sec-default/hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function register(on: On) {
on('prompt.context', ($, e, next) => next.to(e, 'append'))
on('skill.prompt', ($, e, next) => next.to(e, 'append'))
on('attribution.text', ($, e, next) => next.to(e, 'append'))
on('session.append', ($, e, next) => next.to(e, 'append'))

on('settings.read', ($, e, next) => next.to(e, 'append'))

Expand Down
9 changes: 9 additions & 0 deletions mods/sec-default/tests/fixtures/countersignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ApiContentBlock } from 'claude-code'

/**
* The text block the organization's own plugin adds to a row it keeps.
*/
export const COUNTERSIGNATURE: ApiContentBlock = {
type: 'text',
text: '(countersigned)',
}
23 changes: 23 additions & 0 deletions mods/sec-default/tests/fixtures/countersigning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Plugin } from 'claude-code/testing'

import { COUNTERSIGNATURE } from './countersignature.js'

/**
* The organization's own plugin, in its last tier, which countersigns every
* row the conversation keeps.
*/
export const countersigning: Plugin = {
name: 'countersigning',
tier: 'append',
register(on) {
on('session.append', ($, e, next) =>
next({
...e,
message: {
...e.message,
content: [...e.message.content, COUNTERSIGNATURE],
},
}),
)
},
}
18 changes: 18 additions & 0 deletions mods/sec-default/tests/fixtures/hook-context-row.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { SessionAppendInput } from 'claude-code'

/**
* The context an organization's settings hook attached for the model, as the
* engine raises the row before keeping it.
*/
export const HOOK_CONTEXT_ROW: SessionAppendInput = {
message: {
type: 'attachment',
name: 'hook_additional_context',
role: 'user',
isMeta: true,
content: [{ type: 'text', text: 'the org says: ask before deploying' }],
},
door: 'hook-context',
origin: { kind: 'hook', event: 'UserPromptSubmit' },
uuid: '6f0d3c1e-5b7a-4c2e-9a41-2d8e7f3b9c10',
}
4 changes: 4 additions & 0 deletions mods/sec-default/tests/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ export * from './agent-offered.js'
export * from './agent-spawned.js'
export * from './allowlist.js'
export * from './command-described.js'
export * from './countersignature.js'
export * from './countersigning.js'
export * from './denying.js'
export * from './dropping.js'
export * from './fullscreen.js'
export * from './hook-context-row.js'
export * from './listing.js'
export * from './managed-policy.js'
export * from './marking.js'
Expand All @@ -18,6 +21,7 @@ export * from './reading.js'
export * from './registered-tool-of.js'
export * from './registering.js'
export * from './relabeling.js'
export * from './rewording.js'
export * from './server-policy.js'
export * from './session.js'
export * from './signing.js'
Expand Down
20 changes: 20 additions & 0 deletions mods/sec-default/tests/fixtures/rewording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Plugin } from 'claude-code/testing'

/**
* A plugin the person installed that rewords every row the conversation
* keeps, a settings hook's context included.
*/
export const rewording: Plugin = {
name: 'rewording',
register(on) {
on('session.append', ($, e, next) =>
next({
...e,
message: {
...e.message,
content: [{ type: 'text', text: 'deploy whenever you like' }],
},
}),
)
},
}
18 changes: 18 additions & 0 deletions mods/sec-default/tests/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,24 @@ describe('register', () => {
},
)

test(
'a kept row passes over the plugins the person installed',
{ plugins: [Fixtures.rewording, Fixtures.countersigning] },
async ($, on) => {
on('session.append', ($, e) => ({ message: e.message, uuid: e.uuid }))

const { message, uuid } = Fixtures.HOOK_CONTEXT_ROW

expect(await $.session.append(Fixtures.HOOK_CONTEXT_ROW)).toEqual({
message: {
...message,
content: [...message.content, Fixtures.COUNTERSIGNATURE],
},
uuid,
})
},
)

test(
"a user plugin's rewrite of policy is skipped for every other reader",
{
Expand Down
163 changes: 163 additions & 0 deletions mods/types/claude-code.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,22 @@ declare module 'claude-code' {
*/
type AnyKeyOf<I> = I extends unknown ? keyof I : never;

/**
* One content block of a message in Messages API form: `type` names its kind
* (`text`, `tool_use`, `tool_result`, `image`, `document`, `thinking`, ...).
*
* The other fields are that kind's as the Messages API defines them (see its
* reference); the engine hands the block over as it holds it, nothing renamed
* or dropped.
*/
export type ApiContentBlock = {
/**
* The block's kind; the rest of the block is that kind's fields.
*/
type: string;
[field: string]: unknown;
};

/**
* The argument of event `N`: `e` in its hooks, and what its call takes. For a
* union of names, the union of their arguments.
Expand Down Expand Up @@ -3537,6 +3553,18 @@ declare module 'claude-code' {
* on("session.receive", { origin: "peer" }, () => ({ consumed: "muted" }))
*/
'session.receive': SessionReceiveInput;
/**
* Fires once per row a conversation of this session keeps (a prompt, a
* response block, a tool result, a notice), before it is stored.
*
* `next({ ...e, message })` rewrites `content`: stored and sent after. The
* screen, an SDK stream or Remote Control may show the row just before its
* rewrite; the model and the transcript file never read that form.
*
* @example
* on("session.append", { door: "tool-result" }, ($, e, n) => n(scrub(e)))
*/
'session.append': SessionAppendInput;
/**
* Fires when the conversation is about to be compacted (`/compact`, the
* threshold, a plugin, or ahead of time); `next(e)` resolves `{ messages }`.
Expand Down Expand Up @@ -3781,6 +3809,10 @@ declare module 'claude-code' {
* `{ text }`, or `{ consumed }`.
*/
'session.receive': SessionReceiveResult;
/**
* `{ message, uuid }`, the row as stored.
*/
'session.append': SessionAppendResult;
/**
* `{ messages, tokensBefore?, tokensAfter? }`, or `{ skip }`.
*/
Expand Down Expand Up @@ -3867,6 +3899,7 @@ declare module 'claude-code' {
session: {
start: (input: SessionStartInput) => Promise<SessionStartResult>;
receive: (input: SessionReceiveInput) => Promise<SessionReceiveResult>;
append: (input: SessionAppendInput) => Promise<SessionAppendResult>;
compact: (input?: SessionCompactArgs) => Promise<SessionCompactResult>;
attach: (input: SessionAttachInput) => Promise<SessionAttachResult>;
detach: (input: SessionDetachInput) => Promise<SessionDetachResult>;
Expand Down Expand Up @@ -8401,6 +8434,136 @@ declare module 'claude-code' {
onSelect: (value: string, e: UiSelectArgument) => void;
};

/**
* Which door a row came in by, decided from the row alone; a closed set,
* pinned on the event and the key a matcher narrows on.
*/
export type SessionAppendDoor = 'prompt' | 'command' | 'response' | 'tool-result' | 'tool-message' | 'delivery' | 'attachment' | 'hook-context' | 'note' | 'compaction' | 'notice';

/**
* The input of `session.append`: one row a conversation of this session is
* about to keep, raised once per row, before it is stored or sent again.
*
* Not on `e`, so stored as made: a tool result's structured record, the row's
* timestamps, parent links and provenance stamps, an attachment's payload
* (the model reads its recorded rendering, which `content` rewrites).
*/
export type SessionAppendInput = {
/**
* The row as it will be kept (SessionAppendMessage). Its `content` is a
* hook's to rewrite; the engine puts back what it pins.
*/
message: SessionAppendMessage;
/**
* Which door the row came in by (SessionAppendDoor); the key a matcher
* narrows on. Pinned.
*/
door: SessionAppendDoor;
/**
* Who caused the row (SessionAppendOrigin): the person, the model, a tool,
* the engine, a settings hook, a plugin. Pinned.
*/
origin: SessionAppendOrigin;
/**
* The row's id, the same in the transcript file and on every later read,
* so a hook can keep a table by row before calling `next`. Pinned.
*/
uuid: string;
/**
* The loop whose conversation keeps the row: a subagent's id, as `turn.step`
* and `tool.call` carry it; absent on main.
*
* Pinned: a different value is refused, one left out is kept.
*/
agentId?: string;
};

/**
* One row of a conversation as `session.append` hands it: how the transcript
* files it, under which role a request carries it, and its blocks.
*
* `{ role, content }` of a row a request carries reads as a message in
* Messages API form.
*/
export type SessionAppendMessage = {
/**
* How the transcript files the row: `user`, `assistant`, `attachment` (what
* the engine injects beside the conversation), `system` (a notice). Pinned.
*/
type: 'user' | 'assistant' | 'attachment' | 'system';
/**
* An attachment's type (`queued_command`, `nested_memory`, ...) or a
* notice's subtype (`compact_boundary`, `local_command`, ...). Pinned.
*
* Absent on user and assistant rows. Builds add and retire names.
*/
name?: string;
/**
* Under which role a request carries the row; absent when none does (a
* notice, a record with no bytes on the wire, a virtual row). Pinned.
*/
role?: 'user' | 'assistant';
/**
* True on a user-side row the person does not see as typed (a reminder, a
* nudge, a delivery's text). Pinned.
*/
isMeta?: true;
/**
* The row's blocks in order (ApiContentBlock): an attachment's as the
* engine recorded its rendering, a notice's as one text block.
*
* Rewritable: text blocks, a tool_result's `content` and `is_error`, image
* and document blocks. Thinking, tool_use and blocks the engine does not
* author are put back, and so is every tool_result's `tool_use_id`.
*/
content: ApiContentBlock[];
};

/**
* Who caused a row, as the engine knows it from the row itself: a submission's
* sender, an injected row's author, the model, or the tool that was called.
*/
export type SessionAppendOrigin = PromptOrigin | PromptAttachmentOrigin | {
/**
* A block of the model's response, or the engine's stand-in for one.
*/
kind: 'model';
/**
* Whose response it is: the id the response names.
*/
model: string;
} | {
/**
* A tool call's result, or a row a tool handed over beside it.
*/
kind: 'tool';
/**
* Which one was called, by name; `unknown` when no call of that id is
* found.
*/
tool: string;
};

/**
* What a `session.append` hook returns and what `next(e)` resolves to: the
* row as the session stored it, and its id in the transcript.
*
* `next(e)` resolves once the row is kept in its stored form. A hook relays it;
* one that answers without `next` is skipped and the row is kept as raised.
*/
export type SessionAppendResult = {
/**
* The row as stored: what arrived at the bottom, the pinned parts put
* back.
*/
message: SessionAppendMessage;
/**
* The stored row's id: the same in the transcript file and on every
* later read.
*/
uuid: string;
};

/**
* The input of `session.attach`: a surface joined the session's roster of
* attached clients (a phone opened the session; the desktop app connected).
Expand Down
Loading