Files
VolcanicAgenticCore/CLAUDE.md
EugeneTes 644f72068c feat(skills): session-start and turn-end hooks and protected paths declared in SKILL.md
A skill's SKILL.md frontmatter may declare session-start (output into
<skill_session_context>, at session start, /new, resume and after compaction),
turn-end (after every run, in the background; output or a failure reaches the
next run as a hidden notice, repeats suppressed until a clean run) and
protected-paths (write/edit refused inside them). Hooks run from the skill
directory with VOLCANIC_WORKSPACE, VOLCANIC_SKILL_DIR and the chat's TZ; waits
are bounded by each hook's timeout and failures never break a run. /skills
shows declared hooks and broken fields.
2026-10-16 12:27:44 +00:00

195 lines
16 KiB
Markdown

<!-- release-notes-config
declined: true
-->
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
<!-- release-notes-config
declined: true
-->
## What this is
VolcanicAgenticCore is a headless, extensible agent harness for .NET 10, ported from
[earendil-works/pi](https://github.com/earendil-works/pi) without the terminal UI. It also ships a C# port of
[chem/pi-claude-code-provider](https://github.com/chem/pi-claude-code-provider), which drives the local `claude`
CLI on a Claude subscription as a model provider. A Telegram bot is the first front end.
When porting behaviour, pi and pi-claude-code-provider are the reference: event names, JSON shapes and invariants
deliberately match them.
## Commands
```bash
dotnet build # whole solution (VolcanicAgenticCore.slnx)
dotnet test # all four test projects; no test calls a paid API
dotnet test tests/Volcanic.AgenticCore.Tests # one project
dotnet test tests/Volcanic.AgenticCore.A2A.Server.Tests # A2A server only
dotnet test --filter "FullyQualifiedName~AgentLoopTests" # one class
dotnet test --filter "FullyQualifiedName~AgentLoopTests.SomeTest" # one test
# reference host with the scripted faux provider (no keys)
dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --faux "Say hello"
dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --model claude-code/sonnet "List the files here"
# Telegram bot
TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_ALLOWED_USER_IDS=111111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram
```
`Directory.Build.props` sets `TreatWarningsAsErrors=true`, nullable and implicit usings for every non-test project,
so a new warning fails the build. Test projects turn warnings-as-errors off. Tests use xunit 2.
## Architecture
Read `docs/architecture.md` first. Key points:
- **`src/Volcanic.AgenticCore.Abstractions`** holds the contracts: messages, `Model`/`IProvider`, the streaming
event protocol, `ITool`/`Tool<TArgs>`, `IExtension`/`IExtensionApi` and extension events, session entries/stores,
`IUserInterface`, `IChatChannel`, and `AgentSessionEvent`. Extensions reference **only** this assembly, so it stays
small and stable. Don't let runtime types leak into it.
- **`src/Volcanic.AgenticCore`** is organised as **vertical slices** under `Features/` (`Agent`, `Sessions`, `Models`,
`Tools`, `Extensions`, `Commands`, `SystemPrompt`, `Compaction`, `Processes`). Each slice exposes one entry type
(`AgentSession`, `SessionManager`, `ModelRegistry`, `ToolRegistry`, `ExtensionRunner`, `CommandRegistry`,
`SystemPromptBuilder`, `Compactor`, `ProcessSpawnPolicy`). Slices talk only through those entry types and the Abstractions.
`Hosting/` has `AddAgenticCore(...)` and `AgenticCoreOptions`; `Testing/FauxProvider.cs` is the scripted provider
that tests and `--faux` use.
- **`src/Volcanic.AgenticCore.A2A.Server`** exposes assistants over A2A (`docs/a2a.md`) on the official SDK, built
directly on the core (`IAgentSessionFactory`), not the extension API: one `VolcanicA2AServer` per authenticated
caller (`A2AServerCache`), routed by `A2ARequestRouter`, one `AgentSession` per `(caller, contextId)`
(`A2ASessionPool`). `samples/Volcanic.AgenticCore.Sample.A2A` mounts it over one workspace for local use.
- **One conversation = one `AgentSession`**, built by `IAgentSessionFactory.CreateAsync`. Extensions are
**instantiated per session** and each session has its own `ModelRegistry`, so provider registrations and extension
fields are per conversation. Process-wide state has to be explicit (e.g. `ClaudeLoginCoordinator.Shared`).
`AgentSession.SetTimeZone` changes the session's zone (system prompt from the next run); see "Time zone" in
`docs/architecture.md`.
- **Two event systems.** `AgentSessionEvent`s go to hosts via `session.Subscribe` and are invoked synchronously on the
loop. `ExtensionEvent`s go to extensions via `api.On<T>`: they are mutable event args (`Block`, `Cancel`,
`MarkHandled`, set `Text`/`Message`/...), run sequentially in load order, and some short-circuit.
- **Loop invariants** (from pi): providers never throw, they emit `Error` events. An error or abort ends the run.
Tool preparation runs in source order, execution runs in parallel by default, results are appended in source order.
Failed assistant messages are persisted but dropped from model context. Context overflow triggers one compaction
and a retry.
- **Processes**: every agent-requested process gets its environment from `IProcessSpawnPolicy`
(`AgenticCoreOptions.ProcessEnvironment`: deny-list, `Allow`, `SecretValues`, host hooks); `AgentSession` redacts
`SecretValues` from tool output; `HostProcessHardening` makes the host non-dumpable on Linux, once per process. Test projects
apply it with `HardenHostProcess = false` from a module initializer (`NoHostHardening.cs`).
- **Persistence**: a session is a tree of JSONL entries (`id`/`parentId` + leaf pointer) under
`~/.volcanic/agent/sessions/<scope>/` (`VOLCANIC_AGENT_DIR` overrides the root). Compaction entries replace older
messages when context is built.
- **System prompt** pulls project context from the working directory: `AGENTS.md`, or `CLAUDE.md` if there is no
`AGENTS.md` (never both), plus `PERSONA.md` and `MEMORY.md`. File names match case-insensitively. `SkillCatalog`
lists the cwd's Agent Skills (`.volcanic/skills/<dir>/SKILL.md`, frontmatter `name`/`description`; every part of a
skill dir is optional; `SkillFiles` in Abstractions reads skill dirs and the shared frontmatter) in a `<skills>`
section on every run; the model reads a SKILL.md with `read`. A skill dir's optional `INSTRUCTIONS.md` (even without
SKILL.md) goes in full into `<skill_instructions>` on every run (16 KB/file, 64 KB total, truncation noted).
Only the cwd, so chats never see each other's skills.
**Skill hooks** (`Features/SkillHooks/SkillHookRunner`, fields parsed by `SkillHookFiles`): a SKILL.md frontmatter may
declare `session-start` (output into `<skill_session_context>`, run in the background at session start, `/new`,
resume and after compaction), `turn-end` (run in the background after each run, never delaying it; output or a
failure goes to the next run as a hidden `skill_hook` custom message after the prompt, repeats suppressed until a clean
run) and `protected-paths` (`write`/`edit` refused inside them, checked after `tool_call` handlers). Runs from the
skill dir with `VOLCANIC_WORKSPACE`/`VOLCANIC_SKILL_DIR`, the session's `TZ`, `ProcessPurpose.SkillHook`; waits are
bounded by each hook's timeout; failures never break a run. `AgenticCoreOptions.SkillHooks` turns it off.
The Claude Code provider keeps the CLI's own skills off (`--setting-sources ""`, `--disable-slash-commands`).
- **Extension discovery**: host code, then `<cwd>/.volcanic/settings.json` / `.volcanic/extensions/*.dll`, then the same
under `~/.volcanic/agent`. Earlier sources win name collisions. Each assembly loads in its own
`AssemblyLoadContext` and shares `Volcanic.AgenticCore.*`, `Microsoft.Extensions.*` and `System.*` with the host.
### Extensions (`extensions/`)
Extensions built on the public API only: providers `Anthropic` (Messages API), `OpenAI` (chat-completions compatible),
`ClaudeCode` + `ClaudeCode.Bridge`; `Mcp` (an MCP client: stdio servers as tools, plus the Playwright browser
preset; `docs/mcp.md`); the `GoogleCalendar` feature extension (`calendar` tool,
`/calendar_connect|status|disconnect`, one Google account per chat keyed by session scope, tokens encrypted in the chat
workspace under `.volcanic/google-calendar/`, watchers get a short-lived `GOOGLE_ACCESS_TOKEN`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
`JobScheduler` on `TimeProvider`, `jobs.json` per chat in the workspace under `.volcanic/scheduler/`, the per-chat `schedule` tool, watcher
scripts through `IProcessSpawnPolicy`; skill jobs are `.volcanic/skills/<skill>/jobs/<job>.md` files, id `<skill>/<job>`,
mirrored into the same scheduler by `SyncSkillJobsAsync` (every minute, around agent runs, before `/jobs`/`/skills`),
never in `jobs.json`, run state in `skill-jobs-state.json`, format in the `<skill_jobs>` prompt section from
`SchedulerExtension`; `docs/telegram-bot.md#scheduling`); and the `A2A` client extension (the per-session
`a2a` tool over `.volcanic/a2a/agents.json` read on every call; a process-wide `A2AWatchRegistry` follows long remote
tasks by push, subscribe or poll, persists them in `.volcanic/a2a/pending.json` and delivers results through
`IA2AResultDelivery` as follow-ups, at least once; `docs/a2a.md#client`). The Claude Code provider is the most intricate piece
(`docs/claude-code-provider.md`):
- Every model call spawns a fresh `claude -p` with built-in tools off and replays the whole transcript as stream-json.
Core tools are exposed as MCP tools `mcp__volcanic__<alias>` through the Bridge, a tiny MCP server that **never
executes** tools. On `tool_use` the provider kills the process and returns `Done(ToolUse)`, and the core runs the tool.
- `system/init` is validated strictly (fail closed). The extension refuses to run if `ANTHROPIC_API_KEY` or a similar
env var is set, because it is subscription-only.
- Session expiry → `ClaudeLoginCoordinator` runs one login flow at a time through `ClaudeCodeProviderOptions.LoginUi`,
then replays the paused request exactly once. `/claude-code-doctor` and `/claude-code-login` are its commands.
- Tests replay recorded CLI output from `tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures/*.jsonl`.
- `Mcp`: the tool catalogue is discovered once per host process (`McpCatalogCache.Shared`), each session starts its own
server process on the first call, and tests run `tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer`.
`Mcp/Remote/RemoteMcpExtension` is separate: remote servers (Streamable HTTP/SSE) per chat from the workspace's
`.volcanic/mcp/servers.json`, headers sealed (AES-GCM, chat + visible fields as associated data) with a host secret
so only the owner's `/mcp_add|remove|tools|list` change them; tools `<server>__<tool>` are registered and
(de)activated in the live session; tests use an in-memory ASP.NET MCP server (`Mcp/Remote/RemoteMcpTestHost`).
### Telegram host (`hosts/Volcanic.AgenticCore.Telegram`)
Long polling (`Polling/`, with `ReconnectPolicy` backoff; `message`, `callback_query`, `my_chat_member`). Each private chat gets its own `AgentSession`
(`Chats/ChatRegistry`), with workspace `<WorkspaceRoot>/<chatId>` and session scope `telegram-<chatId>`. Access is
limited by `AllowedUserIds`. Incoming messages go through `Inbound/`: the router takes a `ChatInbox` lease per
message in update order, `InboundComposer` builds its part (reply/forward headers from `MessageContext`, text and
photo) in the background, and the inbox flushes a batch `InputDebounce` (600 ms) after the last message as one prompt
with a local-time header. Files, audio and voice are saved to `<workspace>/inbox/` (voice transcribed by
`Speech/OpenAiSpeechToText` when `OPENAI_API_KEY` is set; the same key gives each chat the `voice` tool,
`Chats/VoiceExtension` over `Speech/OpenAiTextToSpeech`, or `Speech/ElevenLabsTextToSpeech` when `ELEVENLABS_API_KEY` and
`ELEVENLABS_VOICE_ID` are set (`TTS_PROVIDER` picks), which sends OGG/Opus voice notes) through `InboundComposer`'s content steps
(`Inbound/InboundMedia`), and `Chats/TelegramChatChannel` implements `IChatChannel` for the core `send` tool through
`ChatRenderer.RunInOrderAsync`. The first allowed id is the "owner": `OwnerLoginChannel` sends Claude Code re-login
prompts to that chat. It resolves lazily to break the DI cycle ChatRegistry → session factory → extension → ChatRegistry.
Each chat's time zone lives in `Chats/ChatSettingsStore` (`<agentDir>/telegram/chat-settings.json`, default
`Telegram:DefaultTimeZone`) and is changed through `ChatTimeZoneService` (`/timezone`, the `set_timezone` tool); it
feeds the session's zone and the timestamp header. `Scheduling/TelegramJobDelivery` implements `IJobDelivery` (texts
through the chat's `IChatChannel`, prompts as session follow-ups with the timestamp header, never through the inbox)
and `SchedulerHostedService` starts the scheduler; `/jobs` and `/cancel` list and cancel a chat's jobs.
Groups (`Groups/`, `Triggers/`, on only with `OPENROUTER_API_KEY`): the router hands group messages and
`my_chat_member` updates to `GroupFeed` before the allow-list; a group is watched once an allowed user added the bot
or posted there (`GroupRegistry`, `<agentDir>/telegram/groups.json`). Groups never get a session and the bot never
writes there. Each private chat's `trigger` tool manages its topic triggers (`TriggerStore`, workspace
`.volcanic/triggers/triggers.json`); `TriggerEngine` asks TypeSafe's Jev (`JevClient`, OpenRouter `/systemone`, a
`noul` question per trigger, 3 retries) about every message and logs one line per message; matches are batched for 10 s
by `TriggerDispatcher` and delivered as follow-up prompts (`TelegramTriggerDelivery`); snoozed triggers queue messages
(`SnoozeQueue`) and check them as one batch when the snooze ends. `/triggers` lists them.
`RemoteMcpSetup` adds `RemoteMcpExtension` to chat sessions (secret `MCP_ENCRYPTION_SECRET`, else the bot token); the
router deletes a `/mcp_add` carrying a key and any answer to a `UiRequestOptions.Secret` question.
Admins, `ADMIN_USER_IDS` (`Telegram:AdminUserIds`, comma separated; the older `ADMIN_USER_ID`/`Telegram:AdminUserId` is
merged in, see `TelegramOptions.Admins`/`ParseUserIds`), may `/impersonate <user_id>` (`Chats/Impersonation`, per admin,
one admin per target, in memory, `0` stops): the router then handles that admin's updates as the target's, and the
target chat's renderer delivers to that admin's chat
(`ChatRenderer.DeliveryChatId`); the target's own updates get `Impersonation.BlockedNotice` instead. Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`.
`A2A/` (on only with `A2A__Enabled=true`) mounts `Volcanic.AgenticCore.A2A.Server` on Kestrel (port 8080 in the container via the aspnet image's
`ASPNETCORE_HTTP_PORTS`; under `dotnet run`, `ASPNETCORE_URLS` or Kestrel's default):
`TelegramA2ASetup` wires a `WorkspaceCallerDirectory` over every allowed chat's workspace and a
`TelegramOwnerNotifier` (`notify_owner` → the owner's chat, or the admin's during `/impersonate`); off, `NoHttpServer`
replaces Kestrel so no port opens. The A2A client is separate (on only with `A2AClient__Enabled=true`):
`TelegramA2AClientSetup` (registered before polling and before `TelegramA2ASetup`) adds `A2AClientExtension` to chat
sessions only (`ChatRegistry`, never A2A server sessions), routes the server's `OnPush` into the registry and uses the
server's `PublicUrl` as push base only while the server is on; `TelegramA2AResultDelivery` runs results as follow-ups
through `ChatFollowUps` (shared with the scheduler) and `A2AWatchHostedService` restores watches in the background.
`/a2a` lists a chat's callers and recent tasks, and with the client its remote agents and watches (`docs/a2a.md`,
`docs/telegram-bot.md#a2a`).
## Deployment
Deploy to the Coolify app `volcanic-secretary-telegram` (uuid `r8b5tgty5v5svat9rnnojahb`, builds `main`) through the
Coolify API after pushing: `POST $COOLIFY_URL/api/v1/deploy?uuid=r8b5tgty5v5svat9rnnojahb`. Container setup:
`docs/docker.md`.
## Conventions
- Commits follow Conventional Commits scoped by area: `feat(claude-code): ...`, `fix(telegram): ...`, `docs(spec): ...`,
`test(...)`, `chore: ...`.
- Design specs and implementation plans live in `docs/superpowers/specs/` and `docs/superpowers/plans/`. Update the
relevant spec and the user docs in `docs/` when behaviour changes.
- By design, the port leaves out pi's terminal UI, themes, keybindings, prompt templates, OAuth logins and
session-tree navigation commands.
<!-- release-notes-config
declined: true
-->