A skill directory may hold INSTRUCTIONS.md, with or without SKILL.md. Its body (frontmatter cut) goes into every system prompt in <skill_instructions>, re-read on every run: 16 KB per file, 64 KB in total, truncation marked with the file's path. /skills marks skills with instructions and lists instruction-only dirs.
181 lines
14 KiB
Markdown
181 lines
14 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`) 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.
|
|
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`; `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`, 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.
|
|
`ADMIN_USER_ID` (`Telegram:AdminUserId`) may `/impersonate <user_id>` (`Chats/Impersonation`, in memory, `0` stops): the
|
|
router then handles the admin's updates as the target's, and the target chat's renderer delivers to the 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
|
|
-->
|