diff --git a/CHANGELOG.md b/CHANGELOG.md index 9079da5..9b2e95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ in this file. The format follows [Keep a Changelog](https://keepachangelog.com/e ## [Unreleased] +### Added + +- **`capabilities.enumerate` — list the calling capsule's own held capability names.** Mirrors the Rust SDK's `capabilities::enumerate`. The list dual of `capabilities.check`: returns the capability categories declared in this capsule's `[capabilities]` manifest block (`host_process`, `net_connect`, `fs_read`, …) — the names, not the scoped arguments within them (allowlists, `host:port`, paths). Argument-free (the kernel already knows the caller) and infallible — an empty array is the valid "no capabilities" answer — so a reusable capsule can ground its behaviour in what it can actually do instead of hard-coding it, avoiding code-vs-manifest drift. Backed by unicity-astrid/wit#13's `astrid:sys/host.enumerate-capabilities`; contracts submodule bumped accordingly (the `astrid:contracts` events bundle is unchanged). +- **`process` persistent-process tier — `spawnPersistent`, `PersistentProcess`, and `process.{attach, listProcesses, statusMany}`.** Mirrors the Rust SDK and the host `astrid:process@1.0.0` persistent tier: a background child that **outlives the pooled, stateless instance** that started it (unlike `BackgroundProcessHandle`, whose kernel resource is reaped on instance reset). `spawnPersistent(cmd, args, options)` takes the persistent knobs (`label`, `keepStdinOpen`, `overflow`, `logRingBytes`, `maxLifetimeMs`, `idleTimeoutMs`, `exitRetentionMs`, `limits`) and returns a `PersistentProcess` keyed by an opaque id. `PersistentProcess` exposes `status` / `readLogs` (drain) / `readSince` (non-draining cursor → byte-faithful `LogChunkResult`; start with `logCursorStart()`) / `writeStdin` / `closeStdin` / `signal` / `wait` (bounded) / `stop` (SIGTERM→grace→SIGKILL, frees the slot) / `release`. Persist `proc.id` (e.g. in KV) and `process.attach(id)` from a later invocation to reattach — `attach` is a thin id-wrapper, so it works without the host's deferred `attach` resource fn; the first id-keyed call validates ownership. `process.listProcesses` / `statusMany` enumerate the capsule+principal's persistent processes. New types: `PersistentProcessInfo`, `SpawnPersistentOptions`, `LogChunkResult`, `ResourceLimits`, `ProcessPhase`, `LogStream`, `LogCursor`, `OverflowPolicy`; `ProcessSignal` gains `"stop"` / `"cont"`. The host's `watch` / `unwatch` lifecycle-event channel and resource-limit enforcement are not yet wired (poll via `status` + bounded `wait`). Backed by unicity-astrid/wit#12; contracts submodule bumped to the merged `astrid:process@1.0.0` persistent tier (the `astrid:contracts` events bundle is unchanged). + ## [0.1.0] - 2026-05-26 First non-prerelease. `0.1.0-alpha.0` was the test version; this is the diff --git a/contracts b/contracts index 467240d..9742f80 160000 --- a/contracts +++ b/contracts @@ -1 +1 @@ -Subproject commit 467240deeba2cf7ff28ba32d12b5a88485be5b3d +Subproject commit 9742f80e83abb92e9ae954ef82664bdd2fcb89cd diff --git a/packages/astrid-sdk/src/capabilities.ts b/packages/astrid-sdk/src/capabilities.ts index bc9edf6..21a80c6 100644 --- a/packages/astrid-sdk/src/capabilities.ts +++ b/packages/astrid-sdk/src/capabilities.ts @@ -1,17 +1,20 @@ /** - * Cross-capsule capability queries. Mirrors `astrid_sdk::capabilities`. + * Capability introspection. Mirrors `astrid_sdk::capabilities`. * - * Allows a capsule to check whether another capsule (identified by its IPC - * session UUID) has a specific manifest capability. Used by the prompt - * builder to enforce `allow_prompt_injection` gating, for example. + * {@link check} asks whether a capsule (self or any other, by IPC session + * UUID) holds a specific manifest capability — used by the prompt builder to + * enforce `allow_prompt_injection` gating, for example. {@link enumerate} is + * the list dual for the calling capsule's own set: the names for which a + * self-`check` returns `true`. Capability posture is structural metadata, not + * a secret (enforce-don't-conceal), so both are ungated. * - * Fail-closed: returns `false` for unknown UUIDs, unknown capabilities, or - * registry-unavailable conditions (the host returns `registry-unavailable`, - * which `callHost` raises as a SysError — capsule code that wants to swallow - * registry outages should wrap the call). + * Fail-closed: `check` returns `false` for unknown UUIDs, unknown + * capabilities, or registry-unavailable conditions (the host returns + * `registry-unavailable`, which `callHost` raises as a SysError — capsule code + * that wants to swallow registry outages should wrap the call). */ -import { checkCapsuleCapability } from "astrid:sys/host@1.0.0"; +import { checkCapsuleCapability, enumerateCapabilities } from "astrid:sys/host@1.0.0"; import { callHost } from "./errors.js"; export function check(sourceUuid: string, capability: string): boolean { @@ -20,3 +23,21 @@ export function check(sourceUuid: string, capability: string): boolean { ); return resp.allowed; } + +/** + * Enumerate the calling capsule's own held capability names. + * + * Returns the capability categories declared in this capsule's + * `[capabilities]` manifest block (`host_process`, `net_connect`, `fs_read`, + * …) — the names, not the scoped arguments within them (allowlists, + * `host:port`, paths). This is exactly the set of names for which + * {@link check} against this capsule's own UUID returns `true`. + * + * Argument-free (the kernel already knows the caller) and infallible: an empty + * array is the valid "no capabilities" answer. Lets a reusable capsule ground + * its behaviour in what it can actually do instead of hard-coding it, avoiding + * code-vs-manifest drift. + */ +export function enumerate(): string[] { + return callHost("capabilities.enumerate", () => enumerateCapabilities()); +} diff --git a/packages/astrid-sdk/src/contracts.ts b/packages/astrid-sdk/src/contracts.ts index 3e581a9..8a36953 100644 --- a/packages/astrid-sdk/src/contracts.ts +++ b/packages/astrid-sdk/src/contracts.ts @@ -225,6 +225,35 @@ export namespace hook { data?: string; } + /** + * Payload of `hook.v1.event.` envelopes published by the + * hook bridge during fan-out to subscriber capsules. + * + * Subscribers wishing to influence the outcome (merge strategies + * `ToolCallBefore` / `LastNonNull`) reply on the per-request + * scoped topic `hook.v1.response..`. + * When `correlation-id` is absent, the dispatch is fire-and-forget + * (merge semantics `None`) and no reply topic exists. + */ + export interface HookEventRequest { + /** + * Semantic hook name (e.g. `before_tool_call`, `session_start`, + * `on_compaction_started`). Matches the mapping table in + * `astrid-capsule-hook-bridge`. + */ + hook: string; + /** + * Original lifecycle event payload, serialized as JSON. Opaque + * to the bus — subscribers parse based on the hook name. + */ + payload: string; + /** + * Per-request correlation id for scoped replies. `none` for + * fire-and-forget hooks. + */ + correlation_id?: string; + } + } /** Types generated from the `llm` WIT interface. */ diff --git a/packages/astrid-sdk/src/index.ts b/packages/astrid-sdk/src/index.ts index b1a798e..fdd717a 100644 --- a/packages/astrid-sdk/src/index.ts +++ b/packages/astrid-sdk/src/index.ts @@ -75,6 +75,15 @@ export type { SpawnOptions, ProcessSignal, EnvVar, + PersistentProcess, + PersistentProcessInfo, + SpawnPersistentOptions, + LogChunkResult, + ResourceLimits, + ProcessPhase, + LogStream, + LogCursor, + OverflowPolicy, } from "./process.js"; export type { CallerContext } from "./runtime.js"; export type { ResolvedUser, Link } from "./identity.js"; diff --git a/packages/astrid-sdk/src/process.ts b/packages/astrid-sdk/src/process.ts index cea151a..b20939f 100644 --- a/packages/astrid-sdk/src/process.ts +++ b/packages/astrid-sdk/src/process.ts @@ -14,14 +14,38 @@ import { spawn as hostSpawn, spawnBackground as hostSpawnBackground, + spawnPersistent as hostSpawnPersistent, + listProcesses as hostListProcesses, + status as hostStatus, + statusMany as hostStatusMany, + readLogs as hostReadLogsById, + readSince as hostReadSince, + writeStdin as hostWriteStdinById, + closeStdin as hostCloseStdinById, + signal as hostSignalById, + wait as hostWaitById, + stop as hostStop, + releaseProcess as hostReleaseProcess, type ProcessHandle as WitProcessHandle, type ProcessSignal, type SpawnRequest, type ExitInfo, + type ProcessInfo as WitProcessInfo, + type ProcessPhase, + type LogStream, + type LogCursor, + type OverflowPolicy, } from "astrid:process/host@1.0.0"; import { SysError, callHost } from "./errors.js"; -export type { ProcessSignal, EnvVar } from "astrid:process/host@1.0.0"; +export type { + ProcessSignal, + EnvVar, + ProcessPhase, + LogStream, + LogCursor, + OverflowPolicy, +} from "astrid:process/host@1.0.0"; export interface ProcessResult { stdout: string; @@ -70,6 +94,16 @@ function buildSpawnRequest( ? Object.entries(options.env).map(([key, value]) => ({ key, value })) : [], cwd: options?.cwd, + // Persistent-only fields — left unset for the ephemeral `spawn` / + // `spawnBackground` paths (the host ignores them there anyway). + limits: undefined, + label: undefined, + keepStdinOpen: undefined, + overflow: undefined, + logRingBytes: undefined, + maxLifetimeMs: undefined, + idleTimeoutMs: undefined, + exitRetentionMs: undefined, }; } @@ -203,3 +237,291 @@ export class BackgroundProcessHandle { return this.#inner; } } + +// ============================================================ +// Persistent tier +// +// A persistent process survives the pooled, stateless instance that spawned +// it (unlike the ephemeral `BackgroundProcessHandle`, whose kernel resource is +// reaped on instance reset). It is keyed by an opaque process id that any +// later invocation of the same capsule+principal can `attach` to. +// ============================================================ + +/** Per-child OS resource ceilings. (Host enforcement is not yet wired.) */ +export interface ResourceLimits { + maxMemoryBytes?: number; + maxCpuSecs?: number; + maxPids?: number; + maxOpenFiles?: number; +} + +/** Options for {@link spawnPersistent} — `SpawnOptions` plus persistent knobs. */ +export interface SpawnPersistentOptions extends SpawnOptions { + /** Operator-readable label surfaced in {@link listProcesses} / status. */ + label?: string; + /** Keep stdin open after the prelude so {@link PersistentProcess.writeStdin} works. */ + keepStdinOpen?: boolean; + /** Per-stream ring overflow policy (default `drop-oldest`). */ + overflow?: OverflowPolicy; + /** Per-stream output ring capacity in bytes. */ + logRingBytes?: number; + /** Wall-clock lifetime ceiling (ms). Clamped DOWN to the host ceiling. */ + maxLifetimeMs?: number; + /** Reap if untouched for this long (ms) — anti-leak backstop. */ + idleTimeoutMs?: number; + /** Retain id + log tail this long (ms) after exit. */ + exitRetentionMs?: number; + /** Per-child OS resource ceilings. */ + limits?: ResourceLimits; +} + +/** A non-draining status snapshot of one persistent process. */ +export interface PersistentProcessInfo { + /** Reattach key, stable across invocations / instances. */ + id: string; + label: string; + command: string; + /** OS PID while running; `undefined` once reaped. Advisory only. */ + osPid: number | undefined; + phase: ProcessPhase; + exitCode: number | undefined; + signal: number | undefined; + ageMs: number; + idleMs: number; + bufferedBytes: number; + bytesDropped: number; + stdinOpen: boolean; + /** Cumulative CPU ms. `undefined` until the host populates it. */ + cpuMs: number | undefined; + /** Peak resident memory. `undefined` until the host populates it. */ + memBytesPeak: number | undefined; +} + +/** A non-draining, cursor-addressed slice of a persistent process's stream. */ +export interface LogChunkResult { + /** Byte-faithful bytes in `[requested-cursor, next)`. */ + data: Uint8Array; + /** Opaque cursor to pass to the next {@link PersistentProcess.readSince}. */ + next: LogCursor; + /** Bytes evicted before delivery through this cursor (0 unless behind). */ + bytesDropped: number; + /** `true` once the child exited AND all retained output was delivered. */ + drainedEof: boolean; +} + +/** An opaque cursor positioned at the oldest retained byte. */ +export function logCursorStart(): LogCursor { + return { token: undefined }; +} + +/** + * Convert an optional millisecond / byte / count knob to a host `bigint`. + * Non-finite input (`Infinity` / `NaN`) becomes `undefined` — read as "no + * caller limit, use the host default/ceiling" — rather than throwing a cryptic + * `RangeError` from `BigInt(Infinity)`. Negatives clamp to 0. Used for the + * soft, host-clamped knobs (TTLs, resource limits, stop grace); a required + * bounded value like `wait`'s timeout validates and throws instead. + */ +function toSafeBigInt(val: number | undefined): bigint | undefined { + return val === undefined || !Number.isFinite(val) + ? undefined + : BigInt(Math.max(0, Math.floor(val))); +} + +function buildPersistentRequest( + cmd: string, + args: string[], + options: SpawnPersistentOptions | undefined, +): SpawnRequest { + const base = buildSpawnRequest(cmd, args, options); + return { + ...base, + label: options?.label, + keepStdinOpen: options?.keepStdinOpen, + overflow: options?.overflow, + logRingBytes: options?.logRingBytes, + maxLifetimeMs: toSafeBigInt(options?.maxLifetimeMs), + idleTimeoutMs: toSafeBigInt(options?.idleTimeoutMs), + exitRetentionMs: toSafeBigInt(options?.exitRetentionMs), + limits: options?.limits + ? { + maxMemoryBytes: toSafeBigInt(options.limits.maxMemoryBytes), + maxCpuSecs: toSafeBigInt(options.limits.maxCpuSecs), + maxPids: options.limits.maxPids, + maxOpenFiles: options.limits.maxOpenFiles, + } + : undefined, + }; +} + +function unpackInfo(i: WitProcessInfo): PersistentProcessInfo { + return { + id: i.id, + label: i.label, + command: i.command, + osPid: i.osPid, + phase: i.phase, + exitCode: i.exit?.exitCode, + signal: i.exit?.signal, + ageMs: Number(i.ageMs), + idleMs: Number(i.idleMs), + bufferedBytes: Number(i.bufferedBytes), + bytesDropped: Number(i.bytesDropped), + stdinOpen: i.stdinOpen, + cpuMs: i.cpuMs === undefined ? undefined : Number(i.cpuMs), + memBytesPeak: i.memBytesPeak === undefined ? undefined : Number(i.memBytesPeak), + }; +} + +/** + * Spawn a PERSISTENT background process whose lifetime is decoupled from the + * calling instance. Returns a {@link PersistentProcess} keyed by an opaque id + * that any LATER invocation of the same capsule+principal can {@link attach} + * to — unlike {@link spawnBackground}, it survives the pooled instance being + * reset between tool invocations. + */ +export function spawnPersistent( + cmd: string, + args: string[] = [], + options?: SpawnPersistentOptions, +): PersistentProcess { + const request = buildPersistentRequest(cmd, args, options); + const id = callHost(`process.spawnPersistent(${JSON.stringify(cmd)})`, () => + hostSpawnPersistent(request), + ); + return new PersistentProcess(id); +} + +/** + * A handle to a PERSISTENT background process, keyed by its opaque id. + * + * Unlike {@link BackgroundProcessHandle}, this does NOT reap the underlying + * process when it goes out of scope — it is a detached view. The process is + * reaped only by {@link stop}, {@link release}, or the host's idle / + * max-lifetime / exit-retention TTLs. Obtain one from {@link spawnPersistent} + * or {@link attach}. + */ +export class PersistentProcess { + readonly #id: string; + + constructor(id: string) { + this.#id = id; + } + + /** The opaque process id — persist it (e.g. in KV) to {@link attach} later. */ + get id(): string { + return this.#id; + } + + /** Non-draining status snapshot. */ + status(): PersistentProcessInfo { + return unpackInfo(callHost("process.status", () => hostStatus(this.#id))); + } + + /** + * Drain newly-buffered stdout/stderr since the last read. Drains the single + * shared ring — for independent multi-reader or byte-faithful reads use + * {@link readSince}. + */ + readLogs(): ProcessLogs { + const r = callHost("process.readLogs", () => hostReadLogsById(this.#id)); + return { + stdout: r.stdout, + stderr: r.stderr, + running: r.running, + exitCode: r.exit?.exitCode, + signal: r.exit?.signal, + }; + } + + /** + * Non-draining, cursor-addressed, byte-faithful read of one stream. Start + * with {@link logCursorStart}; pass {@link LogChunkResult.next} back to resume. + */ + readSince(stream: LogStream, cursor: LogCursor, maxBytes: number): LogChunkResult { + const r = callHost("process.readSince", () => + hostReadSince(this.#id, stream, cursor, maxBytes), + ); + return { + data: r.data, + next: r.next, + bytesDropped: Number(r.bytesDropped), + drainedEof: r.drainedEof, + }; + } + + /** Write to stdin (requires `keepStdinOpen`). Returns bytes written. */ + writeStdin(data: Uint8Array): number { + return callHost("process.writeStdin", () => hostWriteStdinById(this.#id, data)); + } + + /** Close stdin; the child observes EOF on read. */ + closeStdin(): void { + callHost("process.closeStdin", () => hostCloseStdinById(this.#id)); + } + + /** Send a fire-and-forget signal. */ + signal(sig: ProcessSignal): void { + callHost(`process.signal(${sig})`, () => hostSignalById(this.#id, sig)); + } + + /** + * Wait up to `timeoutMs` for the process to exit. Bounded by design — an + * unbounded wait would pin the pooled instance. Does NOT reap. + */ + wait(timeoutMs: number): { exitCode: number | undefined; signal: number | undefined } { + if (!Number.isFinite(timeoutMs)) { + throw SysError.api( + `timeoutMs must be a finite number (got ${timeoutMs}); wait is bounded by design`, + ); + } + const ms = BigInt(Math.max(0, Math.floor(timeoutMs))); + const exit = callHost(`process.wait(${timeoutMs})`, () => hostWaitById(this.#id, ms)); + return unpackExit(exit); + } + + /** + * Graceful terminal stop: SIGTERM, wait up to `graceMs`, then SIGKILL, and + * REMOVE the id (frees the slot). `graceMs` undefined uses the host default. + * To keep a child's last output, drain it with {@link readSince} BEFORE stop. + */ + stop(graceMs?: number): { exitCode: number | undefined; signal: number | undefined } { + const ms = toSafeBigInt(graceMs); + const exit = callHost(`process.stop(${graceMs ?? "default"})`, () => + hostStop(this.#id, ms), + ); + return unpackExit(exit); + } + + /** + * Drop the host's retention of an ALREADY-EXITED process (frees the slot + + * discards the buffered tail). Throws if still running — use {@link stop}. + */ + release(): void { + callHost("process.releaseProcess", () => hostReleaseProcess(this.#id)); + } +} + +/** + * Reattach to a persistent process by id — e.g. one saved in KV across tool + * invocations. Wraps the id; the first id-keyed call validates ownership, so + * an id that is unknown / not yours / reaped surfaces `no-such-process` on use. + */ +export function attach(id: string): PersistentProcess { + return new PersistentProcess(id); +} + +/** + * List the calling capsule+principal's persistent processes, optionally + * filtered by a label substring. Empty is normal (post-reap recovery signal). + */ +export function listProcesses(labelFilter?: string): PersistentProcessInfo[] { + return callHost("process.listProcesses", () => hostListProcesses(labelFilter)).map( + unpackInfo, + ); +} + +/** Batch status for many ids in one host call. Unknown / unowned ids are absent. */ +export function statusMany(ids: string[]): PersistentProcessInfo[] { + return callHost("process.statusMany", () => hostStatusMany(ids)).map(unpackInfo); +} diff --git a/packages/astrid-sdk/src/wit-imports.d.ts b/packages/astrid-sdk/src/wit-imports.d.ts index 7aaf4ee..a90837f 100644 --- a/packages/astrid-sdk/src/wit-imports.d.ts +++ b/packages/astrid-sdk/src/wit-imports.d.ts @@ -462,6 +462,7 @@ declare module "astrid:sys/host@1.0.0" { export function checkCapsuleCapability( request: CapabilityCheckRequest, ): CapabilityCheckResponse; + export function enumerateCapabilities(): string[]; } // ──────────────────────────────────────────────────────────────────── @@ -479,21 +480,52 @@ declare module "astrid:process/host@1.0.0" { | { tag: "closed" } | { tag: "cancelled" } | { tag: "wait-timeout" } + | { tag: "no-such-process" } + | { tag: "registry-full" } + | { tag: "persist-unsupported" } | { tag: "unknown"; val: string }; - export type ProcessSignal = "term" | "hup" | "usr1" | "usr2" | "int"; + export type ProcessSignal = + | "term" + | "hup" + | "usr1" + | "usr2" + | "int" + | "stop" + | "cont"; + + export type OverflowPolicy = "drop-oldest" | "backpressure"; + + export type ProcessPhase = "starting" | "running" | "exited"; + + export type LogStream = "stdout" | "stderr"; export interface EnvVar { key: string; value: string; } + export interface ResourceLimits { + maxMemoryBytes: bigint | undefined; + maxCpuSecs: bigint | undefined; + maxPids: number | undefined; + maxOpenFiles: number | undefined; + } + export interface SpawnRequest { cmd: string; args: string[]; stdin: Uint8Array | undefined; env: EnvVar[]; cwd: string | undefined; + limits: ResourceLimits | undefined; + label: string | undefined; + keepStdinOpen: boolean | undefined; + overflow: OverflowPolicy | undefined; + logRingBytes: number | undefined; + maxLifetimeMs: bigint | undefined; + idleTimeoutMs: bigint | undefined; + exitRetentionMs: bigint | undefined; } export interface ExitInfo { @@ -521,6 +553,33 @@ declare module "astrid:process/host@1.0.0" { stderr: string; } + export interface LogCursor { + token: string | undefined; + } + + export interface LogChunk { + data: Uint8Array; + next: LogCursor; + bytesDropped: bigint; + drainedEof: boolean; + } + + export interface ProcessInfo { + id: string; + label: string; + command: string; + osPid: number | undefined; + phase: ProcessPhase; + exit: ExitInfo | undefined; + ageMs: bigint; + idleMs: bigint; + bufferedBytes: bigint; + bytesDropped: bigint; + stdinOpen: boolean; + cpuMs: bigint | undefined; + memBytesPeak: bigint | undefined; + } + export class ProcessHandle { private constructor(); readLogs(): ReadLogsResult; @@ -538,6 +597,30 @@ declare module "astrid:process/host@1.0.0" { export function spawn(request: SpawnRequest): ProcessResult; export function spawnBackground(request: SpawnRequest): ProcessHandle; + + // Persistent tier — free functions. Every id-keyed call re-checks the + // caller's (principal, capsule) against the recorded creator host-side; + // unknown / wrong-owner / reaped all surface as `no-such-process`. + export function spawnPersistent(request: SpawnRequest): string; + export function attach(id: string): ProcessHandle; + export function listProcesses(labelFilter: string | undefined): ProcessInfo[]; + export function status(id: string): ProcessInfo; + export function statusMany(ids: string[]): ProcessInfo[]; + export function readLogs(id: string): ReadLogsResult; + export function readSince( + id: string, + whichStream: LogStream, + cursor: LogCursor, + maxBytes: number, + ): LogChunk; + export function writeStdin(id: string, data: Uint8Array): number; + export function closeStdin(id: string): void; + export function signal(id: string, sig: ProcessSignal): void; + export function wait(id: string, timeoutMs: bigint): ExitInfo; + export function stop(id: string, graceMs: bigint | undefined): ExitInfo; + export function releaseProcess(id: string): void; + export function watch(id: string, suffix: string | undefined): void; + export function unwatch(id: string): void; } // ──────────────────────────────────────────────────────────────────── diff --git a/packages/astrid-sdk/wit-contracts/astrid-contracts.wit b/packages/astrid-sdk/wit-contracts/astrid-contracts.wit index edd5b0e..7c18cc9 100644 --- a/packages/astrid-sdk/wit-contracts/astrid-contracts.wit +++ b/packages/astrid-sdk/wit-contracts/astrid-contracts.wit @@ -303,6 +303,27 @@ interface hook { /// Merged data from interceptor responses (opaque JSON). data: option, } + + /// Payload of `hook.v1.event.` envelopes published by the + /// hook bridge during fan-out to subscriber capsules. + /// + /// Subscribers wishing to influence the outcome (merge strategies + /// `ToolCallBefore` / `LastNonNull`) reply on the per-request + /// scoped topic `hook.v1.response..`. + /// When `correlation-id` is absent, the dispatch is fire-and-forget + /// (merge semantics `None`) and no reply topic exists. + record hook-event-request { + /// Semantic hook name (e.g. `before_tool_call`, `session_start`, + /// `on_compaction_started`). Matches the mapping table in + /// `astrid-capsule-hook-bridge`. + hook: string, + /// Original lifecycle event payload, serialized as JSON. Opaque + /// to the bus — subscribers parse based on the hook name. + payload: string, + /// Per-request correlation id for scoped replies. `none` for + /// fire-and-forget hooks. + correlation-id: option, + } } // ── llm ──────────────────────────────────────────────────────────