mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
396 lines
26 KiB
Markdown
396 lines
26 KiB
Markdown
---
|
|
issue: 29
|
|
issue_title: "Re-add permission-request event channel with a proper public contract"
|
|
---
|
|
|
|
# Permission event channel with decision broadcast and RPC
|
|
|
|
## Problem Statement
|
|
|
|
Issue #20 deleted the undocumented `pi-permission-system:permission-request` event channel because it had no subscribers, no documentation, no tests, and no public type contract.
|
|
The concept is useful — three major subagent extensions (nicobailon/pi-subagents, tintinweb/pi-subagents, HazAT/pi-interactive-subagents) need to interact with the permission system at runtime, and `pi.events` is the only cross-extension communication channel Pi provides.
|
|
This plan re-introduces the channel with a proper public contract: exported types, documentation, payload-shape tests, protocol versioning, and three distinct surfaces.
|
|
|
|
## Goals
|
|
|
|
1. Emit `permissions:decision` events for every permission gate resolution so external consumers can observe decisions.
|
|
2. Expose a `permissions:rpc:check` request/reply RPC so other extensions can query the permission policy without importing this package.
|
|
3. Expose a `permissions:rpc:prompt` request/reply RPC so in-process child sessions (tintinweb) can forward permission prompts to the parent session's UI.
|
|
4. Emit `permissions:ready` on extension load so consumers can detect the extension's presence without polling.
|
|
5. Export TypeScript types from a dedicated `src/permission-events.ts` module for compile-time type safety.
|
|
6. Add payload-shape regression tests for every emitted event and RPC reply.
|
|
7. Document channel names, payload fields, protocol version, stability guarantees, and worked examples in `README.md`.
|
|
|
|
## Non-Goals
|
|
|
|
- Building a general-purpose cross-extension RPC framework — scoped to permission events only.
|
|
- Replacing the file-based permission forwarding for CLI-spawned subagents (#96) — the event bus RPC is specifically for in-process subagents.
|
|
- Proposing changes to Pi's extension API (badlogic/pi-mono#4207 tracks `registerService()`/`getService()`).
|
|
- Adopting the event API in subagent extensions (#98) — this plan builds the prerequisite API.
|
|
- Adding authentication or caller validation to RPC handlers — `pi.events` is shared within a single Node.js process; any loaded extension can emit.
|
|
|
|
## Background
|
|
|
|
### Dependency status
|
|
|
|
| Issue | Description | Status |
|
|
| ----- | -------------------------------------------- | --------------------------- |
|
|
| #20 | Deleted the original undocumented channel | ✅ Closed |
|
|
| #96 | Env var broadening for CLI-spawned subagents | ✅ Closed |
|
|
| #97 | Coexistence documentation | ✅ Closed |
|
|
| #98 | Adoption by subagent maintainers | Open — depends on this plan |
|
|
|
|
All hard prerequisites are resolved.
|
|
This plan unblocks #98.
|
|
|
|
### Pi SDK event bus
|
|
|
|
The `ExtensionAPI` provides `events: EventBus` with an untyped interface:
|
|
|
|
```typescript
|
|
interface EventBus {
|
|
emit(channel: string, data: unknown): void;
|
|
on(channel: string, handler: (data: unknown) => void): () => void;
|
|
}
|
|
```
|
|
|
|
This is the only cross-extension communication mechanism Pi provides. tintinweb/pi-subagents already built a working RPC over this bus with protocol versioning, request/reply envelopes, and scoped reply channels — a proven pattern in the ecosystem.
|
|
|
|
### Affected permission surfaces
|
|
|
|
This change touches the **event emission layer** — it does not change any allow/deny/ask decision logic.
|
|
All six permission surfaces (tools, bash, mcp, skills, special, external_directory) emit decision broadcasts.
|
|
The RPC check endpoint covers all surfaces.
|
|
The RPC prompt endpoint handles the `ask` state for any surface.
|
|
|
|
### Existing forwarding model
|
|
|
|
File-based forwarding (`src/forwarded-permissions/`) handles CLI-spawned subagents (different processes, different event buses).
|
|
The event bus RPC handles in-process subagents (same process, shared event bus) where file-based forwarding cannot work because no child process exists.
|
|
Both paths coexist — they serve different transport needs.
|
|
|
|
## Design Overview
|
|
|
|
### Channel taxonomy
|
|
|
|
| Channel | Direction | Purpose |
|
|
| ------------------------------------------ | --------- | --------------------------------------------------------- |
|
|
| `permissions:ready` | Broadcast | Emitted once on extension load; consumers detect presence |
|
|
| `permissions:decision` | Broadcast | Emitted after every permission gate resolution |
|
|
| `permissions:rpc:check` | Request | Query the permission policy (no prompting) |
|
|
| `permissions:rpc:check:reply:<requestId>` | Reply | Response to a check request |
|
|
| `permissions:rpc:prompt` | Request | Forward a permission prompt to the parent's UI |
|
|
| `permissions:rpc:prompt:reply:<requestId>` | Reply | Response to a prompt request |
|
|
|
|
### Envelope shapes
|
|
|
|
Following tintinweb's convention: success envelope `{ success: true, data?: T }`, error envelope `{ success: false, error: string }`.
|
|
All RPC requests include `requestId: string` for reply channel scoping.
|
|
All RPC replies include `protocolVersion: number` for forward compatibility.
|
|
|
|
```typescript
|
|
/** RPC protocol version — bumped on breaking envelope or method changes. */
|
|
export const PERMISSIONS_PROTOCOL_VERSION = 1;
|
|
|
|
/** RPC reply envelope. */
|
|
export type PermissionsRpcReply<T = void> =
|
|
| { success: true; protocolVersion: number; data?: T }
|
|
| { success: false; protocolVersion: number; error: string };
|
|
```
|
|
|
|
### Surface 1: Decision broadcast
|
|
|
|
Emitted after every permission gate outcome in the handler layer — not inside `applyPermissionGate()` itself, because the gate lacks the full context (surface name, command, agent name, origin).
|
|
|
|
```typescript
|
|
export interface PermissionDecisionEvent {
|
|
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
surface: string;
|
|
/** The value that was evaluated (command, tool name, skill name, path). */
|
|
value: string;
|
|
/** Final decision. */
|
|
result: "allow" | "deny";
|
|
/** How the decision was reached. */
|
|
resolution:
|
|
| "policy_allow"
|
|
| "policy_deny"
|
|
| "session_approved"
|
|
| "infrastructure_auto_allowed"
|
|
| "user_approved"
|
|
| "user_approved_for_session"
|
|
| "user_denied"
|
|
| "auto_approved"
|
|
| "confirmation_unavailable";
|
|
/** Which config scope contributed the winning rule (when available). */
|
|
origin: string | null;
|
|
/** Agent name (when known). */
|
|
agentName: string | null;
|
|
/** Matched pattern from the winning rule (when available). */
|
|
matchedPattern: string | null;
|
|
}
|
|
```
|
|
|
|
#### Emission points in handlers
|
|
|
|
Each handler site calls a shared `emitDecisionEvent(events, payload)` helper after the gate resolves.
|
|
The helper is thin — it constructs the channel name and calls `events.emit()`.
|
|
|
|
| Handler | Gate/check | Resolution mapped |
|
|
| --------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------- |
|
|
| `tool-call.ts` — session-hit fast path | `checkPermission` returns `source: "session"` | `session_approved` |
|
|
| `tool-call.ts` — infrastructure read bypass | `isPiInfrastructureRead` returns `true` | `infrastructure_auto_allowed` |
|
|
| `tool-call.ts` — skill-read gate | `applyPermissionGate` result | `policy_allow`, `policy_deny`, `user_*`, `confirmation_unavailable` |
|
|
| `tool-call.ts` — external-directory gate | `applyPermissionGate` result | Same set |
|
|
| `tool-call.ts` — bash external-directory gate | `applyPermissionGate` result | Same set |
|
|
| `tool-call.ts` — normal tool gate | `applyPermissionGate` result | Same set |
|
|
| `input.ts` — skill input gate | `applyPermissionGate` result | Same set |
|
|
|
|
#### Mapping gate outcomes to resolution
|
|
|
|
The `applyPermissionGate` return type is `{ action: "allow" | "block" }`.
|
|
The resolution is derived from the combination of:
|
|
|
|
- The input `state` ("allow", "deny", "ask")
|
|
- Whether the prompt was shown (canConfirm)
|
|
- The user's decision (approved, approved_for_session, denied, denied_with_reason)
|
|
|
|
To capture this without modifying the gate's return type, the handler wraps the gate call with pre/post tracking.
|
|
The approach: check the `state` before the gate call, check the `action` after, and reconstruct the resolution:
|
|
|
|
- `state === "allow"` → `policy_allow`
|
|
- `state === "deny"` → `policy_deny`
|
|
- `state === "ask"` and `action === "allow"` → `user_approved` or `user_approved_for_session` (distinguished by `sessionApproval` in the gate result)
|
|
- `state === "ask"` and `action === "block"` → `user_denied` or `confirmation_unavailable` (distinguished by `canConfirm`)
|
|
|
|
Auto-approved (yolo mode) is handled inside `PermissionPrompter` before the gate is called.
|
|
To capture it, the prompter's `prompt()` method emits the decision event directly for the auto-approve path, or we track the auto-approve outcome via the decision callback.
|
|
|
|
Better approach: add an optional `onDecision` callback to the `PermissionPrompter` that fires for auto-approved decisions, so the handler can distinguish yolo auto-approve from user approval.
|
|
|
|
### Surface 2: Policy query RPC
|
|
|
|
The handler listens on `permissions:rpc:check`, evaluates the policy, and replies on `permissions:rpc:check:reply:<requestId>`.
|
|
|
|
```typescript
|
|
export interface PermissionsCheckRequest {
|
|
requestId: string;
|
|
surface: string;
|
|
/** The value to evaluate: command string, tool name, skill name, or path. */
|
|
value?: string;
|
|
/** Optional agent name for per-agent policy resolution. */
|
|
agentName?: string;
|
|
}
|
|
|
|
export interface PermissionsCheckReplyData {
|
|
result: "allow" | "deny" | "ask";
|
|
matchedPattern: string | null;
|
|
origin: string | null;
|
|
}
|
|
```
|
|
|
|
Implementation: call `permissionManager.checkPermission()` with the provided surface and value, including current session rules.
|
|
The handler constructs a synthetic input object from `surface` + `value` that matches what `normalizeInput()` expects.
|
|
|
|
### Surface 3: Prompt forwarding RPC
|
|
|
|
The handler listens on `permissions:rpc:prompt`, shows a UI dialog, and replies on `permissions:rpc:prompt:reply:<requestId>`.
|
|
|
|
```typescript
|
|
export interface PermissionsPromptRequest {
|
|
requestId: string;
|
|
surface: string;
|
|
/** Value being evaluated (shown in the dialog). */
|
|
value: string;
|
|
/** Optional agent name for display. */
|
|
agentName?: string;
|
|
/** Message to display in the permission dialog. */
|
|
message: string;
|
|
/** Optional label for the "for this session" option. */
|
|
sessionLabel?: string;
|
|
}
|
|
|
|
export interface PermissionsPromptReplyData {
|
|
approved: boolean;
|
|
/** Detailed state: "approved", "approved_for_session", "denied", "denied_with_reason". */
|
|
state: string;
|
|
denialReason?: string;
|
|
}
|
|
```
|
|
|
|
#### Guard: only respond when UI is available
|
|
|
|
The handler checks `runtime.runtimeContext?.hasUI` before attempting the dialog.
|
|
If no UI context exists, it replies with `{ success: false, error: "no_ui" }`.
|
|
The caller treats error replies or timeouts as denial (graceful degradation).
|
|
|
|
#### In-process concurrency
|
|
|
|
Multiple child sessions may request prompts simultaneously.
|
|
Since the Pi UI is sequential (one dialog at a time), concurrent RPC prompt requests are serialized by the UI's own dialog queue.
|
|
No explicit concurrency control is needed in the handler.
|
|
|
|
### Ready event
|
|
|
|
```typescript
|
|
export interface PermissionsReadyEvent {
|
|
protocolVersion: number;
|
|
}
|
|
```
|
|
|
|
Emitted once in `piPermissionSystemExtension()` after RPC handlers are registered.
|
|
Consumers listen for `permissions:ready` to detect the extension's presence and protocol version.
|
|
|
|
### Versioning policy
|
|
|
|
All exported types carry the `PERMISSIONS_PROTOCOL_VERSION` constant.
|
|
RPC replies include `protocolVersion` in the envelope.
|
|
Stability guarantee: fields may be added, but existing fields will not be removed or renamed without a major version bump (semver-major change).
|
|
|
|
### Integration points
|
|
|
|
The `pi.events` bus is accessed via the `ExtensionAPI` parameter in `piPermissionSystemExtension()`.
|
|
It is passed to:
|
|
|
|
1. An `emitDecisionEvent()` helper (called from handlers)
|
|
2. `registerPermissionRpcHandlers()` (called once during setup)
|
|
3. A `permissions:ready` emit (called once during setup)
|
|
|
|
The `HandlerDeps` interface gains an `events` field (the `EventBus` reference) so handlers can call `emitDecisionEvent()`.
|
|
|
|
## Module-Level Changes
|
|
|
|
| File | Action | Detail |
|
|
| ------------------------------------------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `src/permission-events.ts` | **new** | Channel name constants, protocol version, all event/request/reply TypeScript types, `emitDecisionEvent()` helper, `emitReadyEvent()` helper |
|
|
| `src/permission-event-rpc.ts` | **new** | `registerPermissionRpcHandlers(events, deps)` — wires `permissions:rpc:check` and `permissions:rpc:prompt` handlers; returns unsubscribe functions |
|
|
| `src/index.ts` | changed | Pass `pi.events` to RPC registration and handler deps; emit `permissions:ready`; store unsubscribe handles for cleanup in `session_shutdown` |
|
|
| `src/handlers/types.ts` | changed | Add `events: EventBus` (or `emitDecision: (event) => void`) to `HandlerDeps` |
|
|
| `src/handlers/tool-call.ts` | changed | Call `emitDecisionEvent()` after each gate resolution and session/infrastructure fast path |
|
|
| `src/handlers/input.ts` | changed | Call `emitDecisionEvent()` after skill input gate resolution |
|
|
| `src/handlers/before-agent-start.ts` | unchanged | Tool filtering is a pre-start phase, not an individual decision event (no emission here) |
|
|
| `src/permission-gate.ts` | unchanged | Gate remains a pure decision function; emission stays at the handler layer |
|
|
| `src/permission-prompter.ts` | changed | Add optional `onAutoApprove` callback or return metadata so handlers can distinguish yolo auto-approve from user approval for the decision event |
|
|
| `tests/permission-events.test.ts` | **new** | Payload-shape assertions for `PermissionDecisionEvent`, `PermissionsReadyEvent`, all RPC request/reply shapes |
|
|
| `tests/permission-event-rpc.test.ts` | **new** | RPC handler tests: check returns correct result, prompt shows dialog and returns decision, error replies for missing UI, unknown surface |
|
|
| `tests/handlers/tool-call-events.test.ts` | **new** or merged into existing | Verify `permissions:decision` emitted with correct payload for allow, deny, ask→approved, session-approved, infrastructure-bypass paths |
|
|
| `tests/handlers/input-events.test.ts` | **new** or merged into existing | Verify `permissions:decision` emitted for skill input gate |
|
|
| `README.md` | changed | Add "Event API" section: channel names, payload fields, RPC protocol, stability guarantees, worked examples |
|
|
| `docs/architecture/target-architecture.md` | changed | Add `src/permission-events.ts` and `src/permission-event-rpc.ts` to module structure; add event bus section to architecture overview |
|
|
|
|
## TDD Order
|
|
|
|
### Step 1: Types, constants, and helper stubs
|
|
|
|
Define all types and channel constants in `src/permission-events.ts`.
|
|
Export the `emitDecisionEvent()` and `emitReadyEvent()` helpers (thin wrappers around `events.emit()`).
|
|
Export `PermissionsRpcReply`, `PERMISSIONS_PROTOCOL_VERSION`, all event/request/reply interfaces.
|
|
|
|
1. **Red**: `tests/permission-events.test.ts` — assert module exports expected constants (`PERMISSIONS_PROTOCOL_VERSION === 1`, channel name strings), assert `emitDecisionEvent` calls `events.emit` with the channel name and payload, assert `emitReadyEvent` emits the correct shape.
|
|
2. **Green**: Implement `src/permission-events.ts` with types, constants, and emit helpers.
|
|
3. Commit: `feat: add permission event types and emit helpers (#29)`
|
|
|
|
### Step 2: Ready event
|
|
|
|
Wire `emitReadyEvent()` in `src/index.ts` after extension setup.
|
|
|
|
1. **Red**: `tests/permission-events.test.ts` (or integration test) — assert that `permissions:ready` is emitted with `{ protocolVersion: 1 }` when extension loads. (May test via a mock `pi.events` in an integration harness or unit-test the helper directly.)
|
|
2. **Green**: Add `emitReadyEvent(pi.events)` call in `piPermissionSystemExtension()`.
|
|
3. Commit: `feat: emit permissions:ready on extension load (#29)`
|
|
|
|
### Step 3: Policy query RPC handler
|
|
|
|
Implement `registerPermissionRpcHandlers()` in `src/permission-event-rpc.ts` — starting with the `permissions:rpc:check` handler.
|
|
|
|
1. **Red**: `tests/permission-event-rpc.test.ts` — mock event bus and permission manager; emit a `permissions:rpc:check` request; assert the handler replies on `permissions:rpc:check:reply:<requestId>` with `{ success: true, protocolVersion: 1, data: { result: "allow", ... } }`.
|
|
Test deny, ask, unknown surface, and missing `requestId` cases.
|
|
2. **Green**: Implement the check handler in `src/permission-event-rpc.ts`.
|
|
Wire it in `src/index.ts`.
|
|
3. Commit: `feat: add permissions:rpc:check policy query RPC (#29)`
|
|
|
|
### Step 4: Prompt forwarding RPC handler
|
|
|
|
Add the `permissions:rpc:prompt` handler to `registerPermissionRpcHandlers()`.
|
|
|
|
1. **Red**: `tests/permission-event-rpc.test.ts` — emit a `permissions:rpc:prompt` request; assert the handler calls the UI dialog function with the message; assert the reply contains the approval decision.
|
|
Test the no-UI guard (reply with `success: false, error: "no_ui"`).
|
|
Test user-denied path.
|
|
2. **Green**: Implement the prompt handler.
|
|
It checks `runtime.runtimeContext?.hasUI`, calls `requestPermissionDecisionFromUi`, and emits the reply.
|
|
3. Commit: `feat: add permissions:rpc:prompt forwarding RPC (#29)`
|
|
|
|
### Step 5: Wire RPC cleanup on session shutdown
|
|
|
|
Store unsubscribe handles from `registerPermissionRpcHandlers()` and call them in `handleSessionShutdown()`.
|
|
|
|
1. **Red**: `tests/permission-event-rpc.test.ts` — assert unsubscribe functions are returned and callable.
|
|
2. **Green**: Store handles in `ExtensionRuntime` or `HandlerDeps`; call in shutdown.
|
|
3. Commit: `feat: clean up RPC handlers on session shutdown (#29)`
|
|
|
|
### Step 6: Decision broadcast in tool-call handler
|
|
|
|
Add `events` (or `emitDecision`) to `HandlerDeps`.
|
|
Emit `permissions:decision` after each gate resolution in `handleToolCall`.
|
|
|
|
1. **Red**: `tests/handlers/tool-call-events.test.ts` — mock `deps.events.emit`; exercise `handleToolCall` with allow, deny, ask→approved, session-approved, and infrastructure-auto-allowed scenarios; assert each emits `permissions:decision` with the correct `resolution` and `surface`.
|
|
2. **Green**: Add `events` to `HandlerDeps` in `src/handlers/types.ts`.
|
|
Add `emitDecisionEvent()` calls in `src/handlers/tool-call.ts` at each decision point.
|
|
Update `src/index.ts` to pass `pi.events` in deps.
|
|
3. **Red/Green**: Update existing tool-call handler tests that construct `HandlerDeps` to include the new `events` field (mock `{ emit: vi.fn(), on: vi.fn() }`).
|
|
This is a pre-requisite for the existing test suite to pass after the type change.
|
|
4. Commit: `feat: emit permission decision events from tool-call handler (#29)`
|
|
|
|
### Step 7: Decision broadcast in input handler
|
|
|
|
Emit `permissions:decision` after the skill input gate in `handleInput`.
|
|
|
|
1. **Red**: `tests/handlers/input-events.test.ts` — mock `deps.events.emit`; exercise skill input allow, deny, ask paths; assert emission.
|
|
2. **Green**: Add `emitDecisionEvent()` calls in `src/handlers/input.ts`.
|
|
3. Commit: `feat: emit permission decision events from input handler (#29)`
|
|
|
|
### Step 8: Auto-approve resolution tracking
|
|
|
|
Ensure yolo-mode auto-approved decisions emit with `resolution: "auto_approved"` rather than `"user_approved"`.
|
|
The `PermissionPrompter.prompt()` returns `{ approved: true, state: "approved" }` for both user approval and yolo auto-approve — they are indistinguishable at the handler level.
|
|
|
|
1. **Red**: Test that when yolo mode is enabled, the emitted decision has `resolution: "auto_approved"`.
|
|
2. **Green**: Either add a distinguishing field to `PermissionPromptDecision` (e.g. `autoApproved: true`) or have the prompter accept an `onAutoApprove` callback.
|
|
The handler sets a local flag before calling the gate and checks it when constructing the emission payload.
|
|
3. Commit: `feat: distinguish auto-approved from user-approved in decision events (#29)`
|
|
|
|
### Step 9: Documentation
|
|
|
|
Add "Event API" section to `README.md` with channel names, payload fields, protocol version, stability guarantees, worked examples for decision broadcast, check RPC, and prompt RPC.
|
|
Update `docs/architecture/target-architecture.md` module list.
|
|
|
|
1. Commit: `docs: document permission event API and RPC protocol (#29)`
|
|
|
|
## Risks and Mitigations
|
|
|
|
| Risk | Mitigation |
|
|
| --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Could this silently weaken a permission? | No. Event emission is fire-and-forget; it does not alter any allow/deny/ask decision path. The gate logic is unchanged. |
|
|
| RPC prompt handler could be exploited by a malicious extension to show unwanted dialogs | `pi.events` is process-scoped — any loaded extension already has full access to the UI. The prompt handler adds no new attack surface beyond what `pi.events.emit("input", ...)` already provides. The handler also requires `hasUI` to respond. |
|
|
| Multiple extension instances register duplicate RPC handlers on the same event bus | In-process subagent child sessions (tintinweb) do not reload extensions — only the parent instance is active. CLI-spawned subagents run in separate processes with separate event buses. The plan adds a `hasUI` guard so even if duplicates exist, only the instance with UI responds. |
|
|
| Decision event payload bloat slows down the event bus | Payloads are small (<500 bytes). The event bus is synchronous in-process `emit()` — no serialization overhead. |
|
|
| Breaking change to channel names after adoption | Channel names are constants exported from `src/permission-events.ts`. Versioning policy: no renames without semver-major. `protocolVersion` in RPC replies enables forward-compatible negotiation. |
|
|
| Existing tests break when `HandlerDeps` gains an `events` field | Step 6 explicitly folds in the type change and mock updates for existing test files before adding emission logic. |
|
|
|
|
## Open Questions
|
|
|
|
1. **Should `before_agent_start` tool-filtering decisions emit events?**
|
|
Tool filtering is a bulk pre-start phase (deny tools hidden before the agent runs).
|
|
Emitting for each filtered tool could be noisy.
|
|
Deferred — the handler can opt in later without API changes.
|
|
2. **Should the prompt RPC handler write to the permission review log?**
|
|
Currently, the review log captures all UI dialog outcomes.
|
|
The RPC prompt handler should likely log too, but the source would be `"rpc_prompt"` rather than `"tool_call"`.
|
|
Deferred to implementation.
|
|
3. **Should the check RPC normalize the `value` input the same way handlers do?**
|
|
For example, bash commands go through `normalizeInput()` which extracts the command string.
|
|
Exposing raw `checkPermission()` without normalization may surprise callers.
|
|
Deferred — start with raw passthrough and document the limitation.
|
|
4. **Should we add a `permissions:rpc:ping` channel for health checks?**
|
|
tintinweb uses `subagents:rpc:ping`.
|
|
The `permissions:ready` event serves a similar purpose, but a synchronous ping RPC could be useful for late-arriving consumers.
|
|
Low cost to add — can be folded into step 3 if desired.
|