Compare commits

...

97 Commits

Author SHA1 Message Date
EugeneTes
809261b29e Merge branch 'feat/docker-deployment' 2026-09-27 12:38:54 +02:00
EugeneTes
7bb41f0b5b fix(docker): address review findings
Entrypoint drops the capability bounding set and sets no_new_privs. Security docs
and spec no longer claim the agent can't reach the secrets; docker exec fallbacks
use a clean environment. PLAYWRIGHT_ENABLED drops the nested compose default.
Tests pin the Dockerfile's Playwright install step and the compose model fallback.
2026-09-27 12:34:58 +02:00
EugeneTes
b9a95a57c8 docs(docker): mark the deployment spec implemented
Status implemented, verification intro rewritten, and a verification record of the final checks (dotnet build/test, both image variants, offline probes of uid, /proc environ and the diagnostics socket, playwright-mcp --version offline, headless Chromium as app, docker compose config, compose start with a fake token -> 401 -> exit 1). Open question 2 (Playwright compose default) marked resolved.
2026-09-27 12:22:06 +02:00
EugeneTes
92c881377b docs(docker): link the container setup from the README, bot and MCP docs 2026-09-27 12:17:41 +02:00
EugeneTes
f0c511f953 docs(docker): Docker and Coolify deployment guide
Image contents, build arguments, the environment table with what the agent's processes can't see, volumes, Coolify steps, first Claude login, updating (the npm layer is cached: build without cache or pin CLAUDE_CODE_VERSION), extensions, troubleshooting and security.
2026-09-27 12:15:45 +02:00
EugeneTes
2461dfebce feat(docker): compose file and .env.example for Coolify
One bot service with init: true, 1 GB /dev/shm, three named volumes and every setting as an environment variable; PLAYWRIGHT_ENABLED follows INSTALL_PLAYWRIGHT. .env is git-ignored.
2026-09-27 12:12:49 +02:00
EugeneTes
9a9e55f783 feat(docker): optional Playwright MCP and Chromium in the image
INSTALL_PLAYWRIGHT=true installs @playwright/mcp@0.0.82 globally and runs its own playwright dependency's installer (install --with-deps chromium) into /opt/ms-playwright, readable by all users. The build fails if playwright-mcp --version does not run.
2026-09-27 12:09:52 +02:00
EugeneTes
4ed3758f9c feat(docker): Dockerfile for the Telegram host
Two-stage build, agent tools, Node 24, claude CLI, data dirs owned by app, DOTNET_EnableDiagnostics=0. Adds the MCP spec's deferred test that the Dockerfile's PLAYWRIGHT_MCP_VERSION equals PlaywrightPreset.DefaultPackage.

Spec change: the image keeps root as its user and the entrypoint drops to app with setpriv. With USER app, compose's docker-init (PID 1) runs as app and its /proc/1/environ exposes every secret to the agent's bash. The spec also lists the csprojs the restore layer copies (GoogleCalendar, Mcp, Scheduler included) and sets HOME=/home/app in the image.
2026-09-27 12:04:42 +02:00
EugeneTes
80358784ac docs(plan): docker-deployment implementation plan 2026-09-27 11:58:42 +02:00
EugeneTes
1353717ca0 test(telegram): relogin test deactivates host tools such as set_timezone
The fake claude never starts the proposal bridge, so the test needs a
session with no active tools. Since the scheduler spec every chat
registers set_timezone, which made the provider wait for the bridge and
fail with mcp_startup on Linux (the test returns early on Windows).
2026-09-27 11:07:56 +02:00
EugeneTes
7879969c5f Merge branch 'feat/scheduler' 2026-09-27 11:00:31 +02:00
EugeneTes
fd6b9027b6 fix(scheduler): address review findings
- JobScheduler: a stored job whose zone lookup throws no longer fails StartAsync; timer re-armed under the lock;
  a delivery cut off by shutdown isn't recorded (one-offs fire again after restart); CancelJobAsync in one step
- Schedules.ParseAt: ISO 8601 with a date only (no machine-clock date for a bare 09:00)
- core: a FollowUp prompt that loses the race to a starting run joins it as a follow-up, not steering
- Telegram: TimeZoneFor tolerates non-numeric targets; RunPromptAsync waits only when queued; one agent dir
- ScriptRunner: bad workspace paths are Failed, not exceptions; zone id helpers share TimeZoneIds.ToIana's rule
- tests: no wall-clock sleeps in scheduler tests; transcript-entry, disabled-scheduler and failing-prompt coverage
- docs: spec notes for the above; README row for the scheduler
2026-09-27 10:56:45 +02:00
EugeneTes
126d3afff1 docs(scheduler): scheduling, per-chat time zones and the session zone 2026-09-27 10:40:43 +02:00
EugeneTes
709b2d2513 test(telegram): scheduled prompts, watcher matches, restart and zone changes end to end 2026-09-27 10:36:27 +02:00
EugeneTes
8dd874a83f feat(telegram): scheduler delivery, hosted service, /jobs and /cancel
Spec: /jobs and /cancel explain when scheduling is off; Scheduler options validation.
2026-09-27 10:33:38 +02:00
EugeneTes
5b780fac36 feat(telegram): per-chat time zones with /timezone, set_timezone and Telegram:DefaultTimeZone
ChatSettingsStore keeps each chat's IANA zone in <AgentDir>/telegram/chat-settings.json;
ChatTimeZoneService is the one path for a change (store, live session, scheduler re-evaluation,
reply text). ChatRegistry.TimeZoneFor now reads the store and new sessions start in the chat's zone.
/timezone, the set_timezone tool (ChatSettingsExtension), /status zone line, DEFAULT_TIMEZONE.

The store updates its cache under the write gate so the last file written is the newest state.
2026-09-27 10:29:29 +02:00
EugeneTes
a0d886eef3 feat(scheduler): schedule tool and per-session SchedulerExtension 2026-09-27 10:23:10 +02:00
EugeneTes
4bf99dc57c feat(scheduler): JobScheduler on TimeProvider with watcher, reminder and prompt firing
Spec: a skipped one-off fires after the next start; the late note's placement and no-exit-code failure wording are pinned down; IsDeliverable is part of the watcher invariant and must not create a session; saves snapshot inside a serialising gate.
2026-09-27 10:20:22 +02:00
EugeneTes
51bc0cedf1 feat(scheduler): ScriptRunner through the process spawn policy
Spec: on Windows the command goes through Arguments like the bash tool, since cmd.exe doesn't parse ArgumentList quoting; the workspace is created if missing, and a timeout vs cancellation outcome is spelled out.
2026-09-27 10:14:33 +02:00
EugeneTes
1cc60267f9 feat(scheduler): atomic JSON job store
jobs.json is { "version": 1, "jobs": [...] } in VolcanicJson camelCase,
written to jobs.json.<pid>.tmp and moved into place through one semaphore.
A corrupt file is renamed jobs.json.corrupt-<timestamp> and loading starts
empty. Each entry is deserialised on its own, so an entry with an unknown
kind or a missing required field is dropped with a warning like any other
invalid job instead of setting the whole file aside.
2026-09-27 10:10:33 +02:00
EugeneTes
8b642885f0 test(claude-code): guard SetUnixFileMode in the TZ test (CA1416) 2026-09-27 10:10:33 +02:00
EugeneTes
d699593458 feat(scheduler): extension project, job model, cron and at parsing, validation 2026-09-27 10:07:28 +02:00
EugeneTes
96a6a5518b feat(claude-code): pass the session time zone to claude -p as TZ 2026-09-27 10:03:25 +02:00
EugeneTes
64e67b00d7 feat(core): session SetTimeZone and the date and zone in the system prompt 2026-09-27 09:58:26 +02:00
EugeneTes
da60ddb536 docs(plan): scheduler implementation plan 2026-09-27 09:54:10 +02:00
EugeneTes
b49b3eea00 Merge branch 'feat/telegram-attachments' 2026-09-26 22:37:40 +02:00
EugeneTes
6ab98a8155 fix(telegram): address review findings
- A speech or download timeout (TaskCanceledException nobody asked for) is a failure
  with the user notice, not a silent shutdown; OpenAiSpeechToText maps it to
  "the request timed out."; UpdateRouter only treats the caller's cancellation as shutdown.
- send: WorkspacePaths resolves the real path component by component (realpath),
  closing the directory-link-inside-target bypass; `.` reports a directory.
- Image documents are attached inline only when their magic bytes are JPEG/PNG/GIF/WebP,
  under the detected type.
- Audio goes to STT as an ASCII audio.<ext> name.
- MCP Browser section mentions send only when it is active.
- RunInOrderAsync takes a cancellation token; queued work is skipped once disposal gives up;
  SendReplyAsync stays private behind ReplyInOrderAsync.
- Speech HttpClient handler recycles pooled connections; BuiltInTools.Send();
  IExtensionHost.Channel is abstract; WorkspacePaths in its own file.
- Tests: timeouts, symlink chains (Linux), sniffing, channel delivery failures,
  queued-work cancellation, gated voice ordering. Spec and docs updated.
2026-09-26 22:35:20 +02:00
EugeneTes
c5ecd017a0 docs(telegram): attachments, voice, send and IChatChannel
telegram-bot.md: what you can send, files from the agent, speech settings, security notes. ui-integration.md: document/voice input rows and a new section 5 on IChatChannel. architecture, extensions and CLAUDE.md list IChatChannel. Drop the interim paragraph from the message-context spec; mark the attachments spec implemented with resolved questions.
2026-09-26 22:16:29 +02:00
EugeneTes
f90731baf0 feat(telegram): transcribe voice notes and audio with an echo
Content step 3 (InboundMedia.AddSpeechAsync): voice and audio go through ISpeechToText, the transcript is echoed as '🎤 …' and prompted as '[Voice message] …'. Spec §4 edited: audio is transcribed from the file step 2 saved (one download); an oversize or undownloadable voice note sends its notice and isn't prompted.
2026-09-26 22:13:10 +02:00
EugeneTes
a0cd2975b8 feat(telegram): save documents, videos and audio to the chat inbox
Content step 2 (InboundMedia.AddFilesAsync, InboxFiles): files land in <cwd>/inbox/<date>-<safe name>[-N], image documents within Telegram:InlineImageMaxBytes are attached inline, oversize files and failed downloads get a notice and no run. Documents, videos, audio and voice are accepted; the unsupported reply is now 'I can read text, photos, files and voice messages.'

Spec: Updates helpers are named DocumentMessage/VideoMessage/VoiceMessage/AudioMessage; the getFile 'file is too big' notice reads 'over 20 MB'; the too-large inline note is a parenthesised suffix after the size; size formats spelled out.
2026-09-26 22:09:16 +02:00
EugeneTes
8758f4b3d1 feat(telegram): OpenAI speech to text service and speech options
Spec 5: the HttpClient registration is named as AddHttpClient<ISpeechToText, OpenAiSpeechToText>() (package Microsoft.Extensions.Http), which is how Program.cs registers it.
2026-09-26 22:04:27 +02:00
EugeneTes
ee3049e47a feat(telegram): TelegramChatChannel delivers send files and scheduler texts in order
Add TelegramLimits, TelegramChatChannel (every delivery through
ChatRenderer.RunInOrderAsync, auto photo falls back to a document on a 400)
and wire it into ChatRegistry as AgentSessionOptions.Channel and
ChatContext.Channel. Spec: SendTextAsync names its failure text
("Telegram did not accept the message.") and throws when any chunk fails.
2026-09-26 22:00:16 +02:00
EugeneTes
1eed03e851 feat(telegram): ordered renderer work items and file uploads 2026-09-26 21:56:05 +02:00
EugeneTes
b22d0d159e feat(core): register send for sessions with a chat channel 2026-09-26 21:52:22 +02:00
EugeneTes
7dce84ddc5 feat(core): send tool delivers workspace files through IChatChannel
SendTool (Tool<SendArgs>, sequential) confines the path to the workspace including symlinked files and parent directories (WorkspacePaths), checks caption, photo and document limits before sending, and picks Document for non-photo extensions (.gif included) or oversized images under kind auto. Not registered yet; that is the next task.
2026-09-26 21:49:02 +02:00
EugeneTes
f2d6b7e5dc feat(core): IChatChannel contract and AgentSessionOptions.Channel
Adds the host-agnostic IChatChannel contract (ChatFile, ChatDelivery, ChatChannelLimits, ChatDeliveryException) to Abstractions/Ui, AgentSessionOptions.Channel, AgentSession.Channel and IExtensionContext.Channel. IExtensionHost.Channel is an internal plumbing default member that ExtensionContext forwards. Test support: FakeChatChannel and the TestHost builder's Channel.
2026-09-26 21:45:57 +02:00
EugeneTes
a21f3be733 docs(plan): telegram-attachments implementation plan 2026-09-26 21:43:04 +02:00
EugeneTes
0b28b9627d Merge branch 'feat/google-calendar' 2026-09-26 21:24:34 +02:00
EugeneTes
4cfe3cec47 Merge branch 'feat/mcp-client-playwright' 2026-09-26 21:20:52 +02:00
EugeneTes
e726b7c220 Merge branch 'feat/telegram-message-context' 2026-09-26 21:20:40 +02:00
EugeneTes
2a201f7d95 fix(mcp): address review findings
- redact /mcp output, and stderr lines as they arrive (log, errors, tail)
- take host servers by name: configuration binding sorts keys anyway
- dispose kills the tree only without an exit code and only if the pid's
  start time matches the spawn (no kill of a reused pid)
- idle timer generation guards stale callbacks; catalogue check runs in
  the background with a 10 s cap; shutdown stops servers concurrently
- stop resets a failed status; /mcp shows an exited process; ordinal names
- spec: ${workspace} in command, ordering, dispose and /mcp wording
2026-09-26 21:18:12 +02:00
EugeneTes
c9fd58372a docs(mcp): MCP client and Playwright preset 2026-09-26 21:05:17 +02:00
EugeneTes
7900b26e28 test(mcp): give the hang-on-start discovery test a 3 s startup timeout
The fake logs its start only after the .NET runtime is up; under full-suite
load that took over 1 s, the client killed it first and the start entry was
missing.
2026-09-26 21:05:16 +02:00
EugeneTes
4fb8f39ab4 feat(telegram): MCP servers and the Playwright preset in the bot 2026-09-26 21:05:16 +02:00
EugeneTes
963301efc4 test(mcp): environment, redaction and end to end through the core
The TZ test checks TZ is absent on Windows: the process policy never sets TZ there (process-environment-hook spec §3). Spec testing bullet and plan updated to match.
2026-09-26 20:54:20 +02:00
EugeneTes
2c5d5dfcb4 feat(mcp): extension activation, prompt section, lifecycle and /mcp
Spec: /mcp shows no pid (the SDK doesn't expose it) and never shows env values; the prompt section follows the server's own tools; the built-in collision check covers inactive built-ins.
2026-09-26 20:49:21 +02:00
EugeneTes
83ad683bd1 feat(mcp): per-session server connection and MCP tools
McpServerConnection starts the session's server lazily through
PrepareProcessAsync, applies the call timeout, reports exits with the
stderr tail and restarts on the next call, stops when idle, and supports
StopAsync (reusable) and idempotent DisposeAsync. McpTool exposes a
catalogue entry as an ITool and maps results with McpResultMapper.

Spec change (§5, Decisions): ModelContextProtocol.Core 2.2.0 does not send
notifications/cancelled when a request's token fires (verified against the
fake server), so calls go out through SendRequestAsync with their own
request id and the connection sends notifications/cancelled itself on abort
and timeout. Also documents exit detection, StopAsync vs DisposeAsync and
JSON-RPC errors as ordinary error results, per the plan.
2026-09-26 20:44:15 +02:00
EugeneTes
ed026ac6c8 feat(mcp): process-wide tool catalogue discovery
McpProcess starts a server through the SDK stdio client under the startup
timeout and keeps a stderr tail for its errors. McpCatalogCache runs one shared
discovery per server config in a temp dir, with the environment from the process
spawn policy. It keeps successes for the process lifetime and failures for 60 s.

Deviation from the plan: the discovery process is disposed and its temp dir
deleted in the background, after the catalogue is returned. SDK 2.2 waits the
whole ShutdownTimeout (5 s) with stdin still open before it kills the process,
so an awaited dispose held every first activation for 5 s. A new test
(Discovery_returns_without_waiting_for_the_server_shutdown) covers this. Spec
section 3 gets the new GetAsync signature, the detached discovery and the
background cleanup. Section 5 now describes the SDK's shutdown order correctly.
2026-09-26 20:31:43 +02:00
EugeneTes
881e7f0be9 fix(google-calendar): address review findings
- log an unknown stored zone once even when the tool description reads it first
- serialise the flow's token write with /calendar_disconnect under the flow gate
- a cancelled or expired pending flow is cleared, not re-sent or shown
- chat keys anchor with \z so a trailing newline is hashed
- a timeMax date includes that whole day (spec updated)
- dispose the flow's CancellationTokenSource, drop ConnectFlow.Token, Begin is a plain write
- GoogleCalendarOptions.IsConfigured replaces the host's copy of the rule; StoredToken is internal
- test BotSetupService's menu through DI, tokens absent from tool results
- Telegram.Tests csproj reference placed as in telegram-message-context; ChatHost passes calendar by name
- docs: per-event timeZone, both config sources for /calendar_*
2026-09-26 20:30:56 +02:00
EugeneTes
52f9d88575 test(mcp): fake stdio MCP server
Spec: the fake uses the SDK's server API from ModelContextProtocol.Core and gains the tools and modes the tests need (weird_name_x, a >48-char name, builtin-clash, --output-dir browser tools, die message and exit code, count-starts details, slow cancellation log).
2026-09-26 20:24:42 +02:00
EugeneTes
8ece6f303d docs(google-calendar): setup guide, Telegram configuration and architecture notes
Spec: the unverified-app screen is described in text; open question 1 resolved (full calendar scope); status implemented.
2026-09-26 20:20:32 +02:00
EugeneTes
fb314a5d87 docs(mcp): align plan's truncation note separator with the mapper 2026-09-26 20:19:17 +02:00
EugeneTes
da6068c5dc feat(mcp): map MCP tool results to core tool results
The truncation note follows the kept text after a single newline, as the built-in read/grep tools do (plan snippet updated).
2026-09-26 20:19:03 +02:00
EugeneTes
a635e9761b feat(telegram): Google Calendar per chat (config, secret value, menu, help)
Spec §8: extension added when either value is set, menu and /help only when both are; host code in GoogleCalendarSetup.cs.
2026-09-26 20:18:00 +02:00
EugeneTes
b30c71d30a feat(mcp): server launch plumbing and tool naming
Spec: the complete environment goes to the SDK with InheritEnvironmentVariables=false instead of nulls; the cache key also covers cwd and prefix.
2026-09-26 20:17:37 +02:00
EugeneTes
2ec941bf2a fix(telegram): address review findings
- /resume discards the inbox once the id names one conversation, before any await
- /stop discards input still waiting in the window
- a forwarded message is never a command or a pending answer
- ChatInbox.DrainAsync is an internal test helper
- tests: /resume and /stop drop buffered text, forwarded /new is content,
  a failing flush doesn't stop later batches; no wall-clock sleep in the drain test
- spec and user docs: timestamp exception for extension commands, command
  ordering, InputDebounce=0 wording
2026-09-26 20:15:57 +02:00
EugeneTes
5b7d593427 feat(mcp): Playwright preset and server resolution
Spec: the preset defaults --browser to chromium and drops --user-data-dir with --isolated; source order and the send-less prompt line are spelled out.
2026-09-26 20:15:35 +02:00
EugeneTes
149b932ca1 feat(google-calendar): /calendar_status and /calendar_disconnect, per-chat isolation tests 2026-09-26 20:15:16 +02:00
EugeneTes
e6b09fef1e feat(mcp): extension project and configuration
McpOptions/McpServerConfig/PlaywrightOptions with ApplyEnvironment (PLAYWRIGHT_* variables, quote-aware ExtraArgs split) and Validate; ShellWords; mcpServers from the user settings.json. New project in the solution and referenced by the extension tests.
2026-09-26 20:13:50 +02:00
EugeneTes
59d404ed08 feat(google-calendar): /calendar_connect device flow with automatic polling 2026-09-26 20:13:36 +02:00
EugeneTes
aaca3ad15a refactor(tools): move ToolExecutionException to Abstractions 2026-09-26 20:12:19 +02:00
EugeneTes
c2048a822e feat(google-calendar): create, update and delete with recurring scope resolution
Spec §7: the reference's calendar.ts is not in this repository, so the error and reply texts are now listed in the spec.
2026-09-26 20:11:47 +02:00
EugeneTes
f39b8a84c1 docs(plan): mcp-client-playwright implementation plan 2026-09-26 20:10:36 +02:00
EugeneTes
edcfa690bd feat(google-calendar): calendar tool read actions over Calendar v3 2026-09-26 20:09:53 +02:00
EugeneTes
0c1778e741 docs(telegram): message context, grouping and presence
InputDebounce config row, the Using it section (replies, quotes, forwards, local-time header, grouping window, eyes reaction, online message, forwards never count as the login code), the Inbound paragraph in CLAUDE.md, and spec status set to implemented.
2026-09-26 20:09:20 +02:00
EugeneTes
a30a4e4d03 feat(google-calendar): calendar time rules and rendering
Offset date-times are normalised to full RFC 3339 (seconds, T separator)
before they are sent; the spec's time rules say so.
2026-09-26 20:08:10 +02:00
EugeneTes
98faaf3a12 feat(telegram): send 🟢 Online. to the owner once after bot setup 2026-09-26 20:07:58 +02:00
EugeneTes
c3f1bbd463 feat(telegram): react to steering messages, discard the inbox on /new and /resume
A flushed batch that joins a running request gets a đź‘€ reaction on each message. /new and a successful /resume drop buffered and in-flight inbox parts. A forwarded message never answers a pending TelegramUi question. /help mentions replies and quotes.
2026-09-26 20:06:43 +02:00
EugeneTes
8baa79f074 feat(google-calendar): per-chat connections with cached, single-flight token refresh
Spec §4: connections are keyed by the chat's token directory; For(settings, scope).
2026-09-26 20:06:00 +02:00
EugeneTes
7bd3bb340c feat(telegram): route messages through the chat inbox with context headers
Spec: unhandled /commands bypass the inbox verbatim; the header zone comes from ChatRegistry.TimeZoneFor (session zone until ChatSettingsStore); only text and photos until the attachments spec.
2026-09-26 20:04:06 +02:00
EugeneTes
ccfa3c1ade feat(google-calendar): OAuth device flow, refresh and revoke client
Spec §3: DeviceAuthorization/PollStatus shapes, Success carries the access token, 5xx without error is a transport error, RefreshAsync returns AccessToken, RevokeAsync returns the failure reason (null on success).
2026-09-26 20:03:09 +02:00
EugeneTes
199ad47f64 feat(google-calendar): extension project, options and per-chat token store
Adds the GoogleCalendar extension project (Abstractions only, Private=false), GoogleCalendarOptions with
env fallbacks and a secret-free ToString, GoogleCalendarException/Messages, ChatKeys (scope -> safe directory
name), StoredToken and GoogleTokenStore (atomic write, owner-only on Unix, reads never throw), and an empty
GoogleCalendarConnections stub for Task 3. The test project gains the extension reference and
Microsoft.Extensions.TimeProvider.Testing 10.9.0.
2026-09-26 20:01:18 +02:00
EugeneTes
5a8c4e6b4b feat(telegram): inbound composer with the text and photo content step 2026-09-26 20:00:23 +02:00
EugeneTes
6b3ee82489 docs(plan): google-calendar implementation plan 2026-09-26 19:59:30 +02:00
EugeneTes
7eb9f84faa feat(telegram): InputDebounce option and message reactions 2026-09-26 19:58:55 +02:00
EugeneTes
8e31dc61b9 feat(telegram): per-chat inbox debouncer with ordered leases
Spec: ChatInbox takes an optional ILogger (dispose count, detached leases, flush failures).
2026-09-26 19:57:23 +02:00
EugeneTes
b82dd859c6 feat(telegram): inbound parts rendered as one prompt per batch 2026-09-26 19:52:59 +02:00
EugeneTes
d8c698b326 feat(telegram): reply, forward and timestamp header text 2026-09-26 19:51:36 +02:00
EugeneTes
651924544a docs(plan): telegram-message-context implementation plan 2026-09-26 19:49:01 +02:00
EugeneTes
29a41e8882 Merge branch 'feat/process-environment-hook' 2026-09-26 19:33:54 +02:00
EugeneTes
76bda6cf55 fix(processes): address review findings
- Fill TimeZoneId only for a configured session/host zone with a resolvable IANA id
  (IExtensionHost.TimeZoneId); otherwise children inherit the host's TZ instead of TZ=Local/UTC.
- Host hardening logs a warning instead of throwing when libc/prctl can't be resolved.
- A hook or process_spawn handler returning a null or incomplete context fails the spawn with
  InvalidOperationException, so bash reports "process environment hook failed".
- Move tool-result redaction into the Agent slice with a behavioural no-change check.
- ProcessSpawnContext.ApplyTo(psi) sets cwd and replaces the environment; used by bash and ExecAsync.
- Split the Telegram process environment types into their own files; move the Extensions.Tests
  NoHostHardening under Support/; check the Claude Code assembly never references the spawn policy.
- Spec and docs: TZ inheritance, incomplete contexts, ApplyTo, async spawn handlers, root skip.
2026-09-26 19:32:47 +02:00
EugeneTes
5f33d139b3 docs(processes): process spawn policy, hardening and redaction
extensions.md: api.Processes and api.Redactor, ExecAsync on the policy,
ProcessSpawnEvent, ctx.TimeZone and PrepareProcessAsync, and a Spawning
processes section. architecture.md: Processes slice, the Abstractions
contract, a Processes and secrets section, and where the loop redacts.
CLAUDE.md: Processes slice and bullet.

Spec: status implemented (scheduler and MCP spawners come with their specs);
a hook returning null fails the spawn; test projects consume the one-shot
hardening from a module initializer; the Hardener seam; 'allow: none' in the
Telegram log line.
2026-09-26 19:23:09 +02:00
EugeneTes
694ef3594e feat(telegram): process environment policy, secret values and host hardening
Spec 6 now says this spec adds only the bot token to SecretValues; the speech key and Google client secret are added by the telegram-attachments and google-calendar specs.
2026-09-26 19:20:43 +02:00
EugeneTes
c8e1e796ef test(claude-code): provider environment ignores process spawn hooks 2026-09-26 19:18:28 +02:00
EugeneTes
4f97db0643 feat(tools): bash spawns through the process spawn policy 2026-09-26 19:17:04 +02:00
EugeneTes
187414a459 feat(extensions): session-bound process preparation and ProcessSpawnEvent
IExtensionContext.PrepareProcessAsync fills scope, session id and zone, runs the host policy, then ProcessSpawnEvent handlers (a throwing handler fails the spawn). IExtensionApi.Processes/Redactor are available during activation. ExecAsync spawns through the policy. Spec section 4 notes the default interface members and the fail-closed dispatch.
2026-09-26 19:15:26 +02:00
EugeneTes
52439b0dc4 feat(processes): redact known secret values from tool output
ISecretRedactor (Abstractions) and SecretRedactor (ordinal, longest first,
values under 8 characters ignored) registered as a singleton over
ProcessEnvironment.SecretValues. AgentSession.RunOneAsync redacts progress
snapshots, every result and error path before ToolResultEvent, and again
after the handlers run.
2026-09-26 19:12:16 +02:00
EugeneTes
76e0f8c0e2 feat(processes): make the host process non-dumpable on Linux
HostProcessHardening calls prctl(PR_SET_DUMPABLE, 0) through LibraryImport, once per process, from the ProcessSpawnPolicy constructor, and warns when DOTNET_EnableDiagnostics is not 0. Test projects consume the one-shot call with the flag off in a module initializer. A HardeningProbe helper backs a Linux-only /proc integration test.
2026-09-26 19:09:45 +02:00
EugeneTes
0000266ead feat(processes): process spawn contract and default policy
Add the Abstractions spawn contract (ProcessPurpose, ProcessSpawnRequest,
ProcessSpawnContext, ProcessSpawnHook, IProcessSpawnPolicy, ProcessSpawnEvent),
ProcessEnvironmentOptions with DefaultDeny and Validate, EnvironmentFilter,
ProcessSpawnPolicy, AgenticCoreOptions.ProcessEnvironment and the
TryAddSingleton registration in AddAgenticCore.

Spec: section 2 says Deny/Allow match case-insensitively and names Validate,
DefaultDeny and MinimumSecretLength; section 3 describes the factory
registration and that the constructor throws on validation errors.
2026-09-26 19:07:33 +02:00
EugeneTes
399762669e feat(agent): session time zone contract
Adds AgentSessionOptions.TimeZone, AgenticCoreOptions.DefaultTimeZone, AgentSession.TimeZone, IExtensionHost.TimeZone, IExtensionContext.TimeZone (default member returning TimeZoneInfo.Local) and TimeZoneIds.ToIana, ahead of the rest of scheduler spec section 8. Spec sentences added to process-environment-hook section 4 and scheduler section 8.
2026-09-26 19:05:45 +02:00
EugeneTes
f3590cecd2 docs(plan): process-environment-hook implementation plan 2026-09-26 19:03:38 +02:00
EugeneTes
e169124de1 Merge branch 'main' of https://gitea.tes.gd/admin/VolcanicAgenticCore 2026-09-26 18:42:12 +02:00
EugeneTes
ddbed79e45 Merge branch 'main' of https://gitea.tes.gd/admin/VolcanicAgenticCore 2026-09-25 20:27:52 +02:00
EugeneTes
16a5b83076 Merge branch 'main' of https://gitea.tes.gd/admin/VolcanicAgenticCore 2026-09-25 18:15:54 +02:00
EugeneTes
8463da15e6 Merge branch 'main' of https://gitea.tes.gd/admin/VolcanicAgenticCore 2026-09-25 17:47:47 +02:00
EugeneTes
7da203f368 Merge branch 'main' of https://gitea.tes.gd/admin/VolcanicAgenticCore 2026-09-25 16:44:05 +02:00
EugeneTes
8da9789afc headless run 2026-09-25 16:43:57 +02:00
250 changed files with 40659 additions and 339 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
**/bin
**/obj
**/TestResults
**/*.user
**/Properties/launchSettings.json
.git
.vs
.idea
.claude
.volcanic
.env
.env.*
tests
samples
docs
*.md
docker-compose.yml

39
.env.example Normal file
View File

@@ -0,0 +1,39 @@
# Copy to .env for `docker compose up -d --build`. On Coolify, set these as the resource's environment variables.
# Never add ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN,
# CLAUDE_CODE_USE_BEDROCK or CLAUDE_CODE_USE_VERTEX: the Claude Code provider is subscription-only and refuses to run.
# Required. Allowed Telegram user ids, comma separated; the first one is the owner (re-login prompts, "Online").
TELEGRAM_BOT_TOKEN=
TELEGRAM_ALLOWED_USER_IDS=
# Model every chat uses (empty = the compose default), log level, container time zone.
TELEGRAM_MODEL=
LOG_LEVEL=Information
TZ=UTC
# Build: Playwright MCP + Chromium in the image (~+400 MB); Claude Code version (2.1 = newest 2.1.x at build time).
INSTALL_PLAYWRIGHT=true
CLAUDE_CODE_VERSION=2.1
# Speech to text for voice notes (empty = off).
OPENAI_API_KEY=
STT_MODEL=
# Google Calendar (both set = on; each chat connects its own account with /calendar_connect).
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Default time zone for chats that never ran /timezone (IANA name, e.g. Europe/Berlin; empty = TZ).
DEFAULT_TIMEZONE=
# Deny-listed variables the agent's processes may see anyway, comma separated (e.g. GH_TOKEN).
PROCESS_ENV_ALLOW=
# Playwright MCP preset; empty = compose defaults (enabled, headless, chromium, --no-sandbox, playwright-mcp).
# An override of PLAYWRIGHT_EXTRA_ARGS must keep --no-sandbox.
# An image built with INSTALL_PLAYWRIGHT=false also needs PLAYWRIGHT_ENABLED=false.
PLAYWRIGHT_ENABLED=
PLAYWRIGHT_HEADLESS=
PLAYWRIGHT_BROWSER=
PLAYWRIGHT_EXTRA_ARGS=
PLAYWRIGHT_COMMAND=

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
Dockerfile text eol=lf
.dockerignore text eol=lf
docker-compose.yml text eol=lf
.env.example text eol=lf

4
.gitignore vendored
View File

@@ -14,3 +14,7 @@ artifacts/
.volcanic/
# Claude Code worktrees and local state
.claude/
# Local compose configuration (secrets)
.env
.env.*
!.env.example

View File

@@ -45,17 +45,19 @@ 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`, and `AgentSessionEvent`. Extensions reference **only** this assembly, so it stays small and
stable. Don't let runtime types leak into it.
`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`). Each slice exposes one entry type
`Tools`, `Extensions`, `Commands`, `SystemPrompt`, `Compaction`, `Processes`). Each slice exposes one entry type
(`AgentSession`, `SessionManager`, `ModelRegistry`, `ToolRegistry`, `ExtensionRunner`, `CommandRegistry`,
`SystemPromptBuilder`, `Compactor`). Slices talk only through those entry types and the Abstractions.
`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.
- **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.
@@ -63,6 +65,10 @@ Read `docs/architecture.md` first. Key points:
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.
@@ -74,8 +80,14 @@ Read `docs/architecture.md` first. Key points:
### Extensions (`extensions/`)
Provider extensions built on the public API only: `Anthropic` (Messages API), `OpenAI` (chat-completions compatible),
and `ClaudeCode` + `ClaudeCode.Bridge`. The Claude Code provider is the most intricate piece (`docs/claude-code-provider.md`):
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 under
`<agentDir>/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide
`JobScheduler` on `TimeProvider`, `jobs.json` under `<agentDir>/scheduler/`, the per-chat `schedule` tool, watcher
scripts through `IProcessSpawnPolicy`; `docs/telegram-bot.md#scheduling`). 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
@@ -85,13 +97,26 @@ and `ClaudeCode` + `ClaudeCode.Bridge`. The Claude Code provider is the most int
- 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`.
### Telegram host (`hosts/Volcanic.AgenticCore.Telegram`)
Long polling (`Polling/`, with `ReconnectPolicy` backoff). Each private chat gets its own `AgentSession`
(`Chats/ChatRegistry`), with workspace `<WorkspaceRoot>/<chatId>` and session scope `telegram-<chatId>`. Access is
limited by `AllowedUserIds`. The first allowed id is the "owner": `OwnerLoginChannel` sends Claude Code re-login
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) 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.
Answers go out complete, not streamed (`Rendering/`). Tests drive the host through `Support/FakeBotClient` and `ChatHost`.
## Conventions

88
Dockerfile Normal file
View File

@@ -0,0 +1,88 @@
# The Telegram host as one container. User guide: docs/docker.md.
# Spec: docs/superpowers/specs/2026-09-26-docker-deployment-design.md
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
WORKDIR /src
# Restore layer: only the project files the host needs, cached until one of them changes.
COPY Directory.Build.props VolcanicAgenticCore.slnx ./
COPY src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj src/Volcanic.AgenticCore.Abstractions/
COPY src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj src/Volcanic.AgenticCore/
COPY extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/
COPY extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/
COPY extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/
COPY extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj extensions/Volcanic.AgenticCore.Extensions.Mcp/
COPY extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj extensions/Volcanic.AgenticCore.Extensions.Scheduler/
COPY hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj hosts/Volcanic.AgenticCore.Telegram/
RUN dotnet restore hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj
COPY src/ src/
COPY extensions/ extensions/
COPY hosts/ hosts/
# Framework-dependent: claude starts the bridge as `dotnet <Bridge>.dll`, so the loose dll and its runtimeconfig must exist.
RUN dotnet publish hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj -c Release --no-restore -o /out \
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll \
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble
ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_MAJOR=24
ARG CLAUDE_CODE_VERSION=2.1
ARG INSTALL_PLAYWRIGHT=false
# Must equal the version in PlaywrightPreset.DefaultPackage (the single source of truth);
# PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage fails when they differ.
ARG PLAYWRIGHT_MCP_VERSION=0.0.82
# What the agent's bash tool can run, then Node from NodeSource (Ubuntu noble's nodejs is too old).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash git ripgrep python3 python-is-python3 python3-venv curl ca-certificates tzdata jq less procps openssh-client gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# The global prefix stays root-owned, so claude's auto-updater can't replace it at runtime: an upgrade changes what
# system/init validation sees and invalidates the prompt cache, so it only arrives with a rebuild.
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
&& claude --version \
&& npm cache clean --force
# Set even without Playwright, so an MCP server started with the host environment finds browsers here.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
# Playwright MCP at the pinned version, and Chromium installed by that package's own playwright dependency, so browser
# build and library version match. Roughly +400 MB.
RUN if [ "$INSTALL_PLAYWRIGHT" = "true" ]; then \
npm install -g "@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}" \
&& pw="$(npm root -g)/@playwright/mcp/node_modules/playwright/cli.js" \
&& test -f "$pw" \
&& node "$pw" install --with-deps chromium \
&& chmod -R a+rX "$PLAYWRIGHT_BROWSERS_PATH" \
&& playwright-mcp --version \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*; \
fi
# The entrypoint hard-codes uid/gid 1654. Named volumes copy this ownership on first mount.
RUN test "$(id -u app)" = 1654 && test "$(id -g app)" = 1654 \
&& mkdir -p /data/workspaces /data/agent /data/claude /home/app \
&& chown -R app:app /data /home/app
COPY --from=build /out /app
WORKDIR /app
ENV Telegram__WorkspaceRoot=/data/workspaces \
VOLCANIC_AGENT_DIR=/data/agent \
CLAUDE_CONFIG_DIR=/data/claude \
HOME=/home/app \
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1 \
TZ=UTC \
DOTNET_EnableDiagnostics=0
# No USER: PID 1 (docker-init with compose's init: true) must stay root, or the agent could read every secret from
# /proc/1/environ. setpriv drops to app and execs dotnet, so Environment.ProcessPath is the muxer the bridge needs.
# An empty bounding set and no_new_privs keep the agent's processes from regaining root through setuid binaries.
ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--bounding-set=-all", "--no-new-privs", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]

View File

@@ -17,6 +17,8 @@ Claude Code as a model provider without an API key.
| Anthropic Messages API provider | `extensions/Volcanic.AgenticCore.Extensions.Anthropic` | `ANTHROPIC_API_KEY` |
| OpenAI-compatible provider | `extensions/Volcanic.AgenticCore.Extensions.OpenAI` | OpenAI, OpenRouter, Groq, Ollama, llama.cpp, vLLM |
| Claude Code (subscription) provider | `extensions/Volcanic.AgenticCore.Extensions.ClaudeCode` (+ `.Bridge`) | Spawns `claude -p`, MCP "proposal" bridge |
| Google Calendar (tool + connect commands) | `extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`; one account per chat |
| Scheduler (reminders, scheduled prompts, watcher scripts) | `extensions/Volcanic.AgenticCore.Extensions.Scheduler` | Cronos; per-chat jobs in `<agent dir>/scheduler/jobs.json`; see [docs/telegram-bot.md](docs/telegram-bot.md#scheduling) |
| Telegram bot | `hosts/Volcanic.AgenticCore.Telegram` | Chat with the agent from Telegram; long polling, Claude Code, allow-list |
| Reference headless host | `samples/Volcanic.AgenticCore.Sample.Headless` | Prompt in, JSON events out |
| Tests | `tests/` | xunit; no test calls a paid API |
@@ -40,6 +42,9 @@ dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --model cla
TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_ALLOWED_USER_IDS=111111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram
```
To run the bot in a container (Coolify or any Docker host): `cp .env.example .env`, fill in the two required values,
`docker compose up -d --build`. See [docs/docker.md](docs/docker.md).
Hosting it yourself:
```csharp
@@ -67,6 +72,8 @@ await session.PromptAsync("Explain this repository");
- [Writing extensions](docs/extensions.md): tools, commands, lifecycle hooks, providers, message injection, discovery.
- [Providers and models](docs/providers.md): model keys, authentication, the built-in provider extensions.
- [Telegram bot](docs/telegram-bot.md): setup, commands, security, connection recovery.
- [Docker and Coolify](docs/docker.md): the container image, configuration, volumes, first login.
- [Google Calendar](docs/google-calendar.md): Google Cloud setup, connecting a chat, token storage, time zones.
- [Building a UI (Telegram guide)](docs/ui-integration.md): how to put a chat front end on the core.
- [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor
command, automatic re-login when the subscription session expires.

View File

@@ -3,6 +3,9 @@
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Anthropic/Volcanic.AgenticCore.Extensions.Anthropic.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj" />
<Project Path="extensions/Volcanic.AgenticCore.Extensions.OpenAI/Volcanic.AgenticCore.Extensions.OpenAI.csproj" />
</Folder>
<Folder Name="/hosts/">
@@ -17,6 +20,8 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer.csproj" />
<Project Path="tests/Volcanic.AgenticCore.HardeningProbe/Volcanic.AgenticCore.HardeningProbe.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Telegram.Tests/Volcanic.AgenticCore.Telegram.Tests.csproj" />
<Project Path="tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj" />
</Folder>

43
docker-compose.yml Normal file
View File

@@ -0,0 +1,43 @@
services:
bot:
build:
context: .
args:
INSTALL_PLAYWRIGHT: ${INSTALL_PLAYWRIGHT:-true}
CLAUDE_CODE_VERSION: ${CLAUDE_CODE_VERSION:-2.1}
restart: unless-stopped
# dotnet does not reap orphans; killed `claude` trees and bash tool children would pile up as zombies.
# docker-init runs as root (the image user), so the agent can't read its /proc/1/environ.
init: true
# Chromium crashes with Docker's 64 MB /dev/shm.
shm_size: 1gb
stop_grace_period: 30s
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_ALLOWED_USER_IDS=${TELEGRAM_ALLOWED_USER_IDS}
- Telegram__Model=${TELEGRAM_MODEL:-claude-code/claude-opus-5-5}
- TZ=${TZ:-UTC}
- Logging__LogLevel__Default=${LOG_LEVEL:-Information}
# Optional features; empty means off / default.
- OPENAI_API_KEY=${OPENAI_API_KEY:-} # speech to text (attachments spec)
- STT_MODEL=${STT_MODEL:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec)
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ
- PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN
# Playwright MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an
# image built with INSTALL_PLAYWRIGHT=false (no nested default: Coolify parses these expressions itself).
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-true}
- PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container
- PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-}
- PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox}
- PLAYWRIGHT_COMMAND=${PLAYWRIGHT_COMMAND:-playwright-mcp}
volumes:
- workspaces:/data/workspaces
- agent:/data/agent
- claude:/data/claude
volumes:
workspaces:
agent:
claude:

View File

@@ -5,15 +5,25 @@
```
src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers)
src/Volcanic.AgenticCore runtime, organised as vertical slices
extensions/* provider extensions built on the public API only
extensions/* provider and feature extensions (MCP client, Google Calendar, Scheduler), built on the public API only
samples/* reference host
tests/* xunit
```
`Abstractions` is deliberately small and stable: messages and content blocks, `Model` and `IProvider`,
the streaming event protocol, `ITool`, the extension API and its events, session entries and stores,
`IUserInterface`, and the `AgentSessionEvent` family a UI subscribes to. Extensions reference only this
assembly, which is what allows them to be loaded from separate directories.
`IUserInterface`, `IChatChannel` (delivering files and texts into the conversation's chat), the `AgentSessionEvent`
family a UI subscribes to, and the process contract in `Processes/`
(`IProcessSpawnPolicy`, `ProcessSpawnRequest`/`ProcessSpawnContext`, `ProcessSpawnHook`, `ProcessSpawnEvent`,
`ISecretRedactor`). Extensions reference only this assembly, which is what allows them to be loaded from separate
directories.
`GoogleCalendar` is a feature extension: a tool and three commands, per-chat accounts through process-wide
`GoogleCalendarConnections` (see [google-calendar.md](google-calendar.md)).
`Scheduler` is a feature extension with a process-wide `JobScheduler` (reminders, scheduled prompts and watcher
scripts on a `TimeProvider` timer, jobs in `<AgentDir>/scheduler/jobs.json`), a per-session `schedule` tool bound to a
host target, and an `IJobDelivery` the host implements (see [telegram-bot.md](telegram-bot.md#scheduling)).
## Vertical slices
@@ -24,12 +34,13 @@ owns everything for that feature and exposes one entry type:
|---|---|---|
| `Models` | `ModelRegistry` | Provider registrations, `provider/id` lookup, API key resolution, transcript normalisation before a call |
| `Sessions` | `SessionManager` | The entry tree of one session, branch/context building, `ISessionStore` implementations |
| `Tools` | `ToolRegistry` | Known and active tools, argument validation, output truncation, the built-in tools |
| `Tools` | `ToolRegistry` | Known and active tools, argument validation, output truncation, the built-in tools (`send` only for sessions with a channel) |
| `Extensions` | `ExtensionRunner` | Activating extensions (two-phase), dispatching events, contexts, the event bus, assembly loading |
| `Commands` | `CommandRegistry` | Slash commands |
| `SystemPrompt` | `SystemPromptBuilder` | Base prompt, tools section, guidelines, project context files, environment |
| `Compaction` | `Compactor` | When to compact, what to summarise, the summary prompt |
| `Agent` | `AgentSession`, `AgentSessionFactory` | The conversation facade and the loop |
| `Processes` | `ProcessSpawnPolicy` | The environment, command and cwd of every agent-requested process (deny-list, allow-list, secret values, host hooks), host hardening, secret redaction |
Slices talk to each other only through their entry types and the Abstractions contracts. `AgentSession`
is the composition root of one conversation; a host never touches the other slices directly.
@@ -48,6 +59,15 @@ is the composition root of one conversation; a host never touches the other slic
Extensions are instantiated per session, so extension fields are per conversation. Provider registrations
also live per session (a `ModelRegistry` per session), which keeps sessions independent.
## Time zone
A session's zone is the first that is set of `SetTimeZone`, `AgentSessionOptions.TimeZone`,
`AgenticCoreOptions.DefaultTimeZone` and `TimeZoneInfo.Local`. It is exposed as `IExtensionContext.TimeZone`, becomes
`TZ` for the session's processes (`PrepareProcessAsync`) and for Claude Code's `claude -p`, and fills the system
prompt's `Current date` / `Time zone` lines. The prompt has the date only and is rendered per run, so a change applies
from the next run and the cached prompt prefix changes at most daily. The session doesn't persist its zone; a host
that lets users change it keeps it (the Telegram host in `chat-settings.json`).
## The agent loop
`PromptAsync(text)`:
@@ -70,6 +90,8 @@ loop:
context → extensions may rewrite the message list
stream the provider → message_start / message_update* / message_end (with retry + overflow recovery)
tool calls? → tool_call hook, execute (parallel by default), tool_result hook, tool result messages
(every result, progress update and error is redacted before tool_result handlers,
and again after them, before tool_execution_end and the persisted message)
turn_end
more tool calls or steering messages or continue requested? → next turn
follow-up queued? → next turn
@@ -101,6 +123,35 @@ Two independent event systems exist:
(`Block`, `Cancel`, `MarkHandled`) or setting properties (`Text`, `Payload`, `Message`, `Override`).
Handlers run sequentially in load order and see each other's changes; some events short-circuit.
## Processes and secrets
Every process started on the agent's behalf (`bash`, `IExtensionApi.ExecAsync`, MCP server processes, which are
prepared through `ctx.PrepareProcessAsync` per session and through `api.Processes` for catalogue discovery, and
scheduler watcher scripts, through the DI `IProcessSpawnPolicy` since jobs fire without a session) gets its command,
cwd and environment from one `IProcessSpawnPolicy`, configured through `AgenticCoreOptions.ProcessEnvironment`:
```
inherit the host environment → filter (Deny, Allow, SecretValues) → explicit request.Environment (never filtered)
→ TZ from TimeZoneId → host hooks → ProcessSpawnEvent to extensions (only in a session, via PrepareProcessAsync)
```
The result is the child's complete environment; spawners clear `ProcessStartInfo.Environment` and copy it in. A
throwing hook or handler fails the spawn instead of falling back to the inherited environment.
`HostProcessHardening` makes the host non-dumpable on Linux (`prctl(PR_SET_DUMPABLE, 0)`), so a same-user child
can't read `/proc/<host pid>/environ` or `ptrace` the host. It runs from the `ProcessSpawnPolicy` constructor, or
explicitly from a host at startup (the Telegram host does), and only once. It warns when `DOTNET_EnableDiagnostics=0`
is missing, since the diagnostic socket would hand out the same environment. `HardenHostProcess = false` turns it off
for debugging. Test projects make the one call with the flag off from a module initializer, so the test runner stays
dumpable.
`ISecretRedactor` replaces every `SecretValues` entry with `[REDACTED]`. `AgentSession` applies it to tool
results, progress updates and error messages (see the loop above), so every tool is covered in one place.
The Claude Code provider's own `claude` processes are outside the policy on purpose: they run the model and already
get an allow-listed environment (plus the session's `TZ`, set by the provider). None of this is a sandbox: the agent still runs as the host's OS user and can read
the files that user can read.
## Persistence
A session is a tree of entries (`message`, `model_change`, `thinking_level_change`, `compaction`, `custom`,

View File

@@ -37,8 +37,13 @@ conversation is replayed each time.
`--strict-mcp-config --mcp-config <bridge>`, `--input-format stream-json --output-format stream-json
--include-partial-messages --verbose`, `--system-prompt-file <tmp>`, `--model <id>`, `--effort <level>`,
plus settings that disable hooks, auto-memory, plugins, slash commands and the token reminder (which would
defeat prompt caching). Only an allow-list of environment variables is passed through, and compaction
inside Claude is disabled: the core owns compaction.
defeat prompt caching). Only an allow-list of environment variables is passed through, plus `TZ` set to
the session's time zone (the chat's zone on Telegram, read per call so a `/timezone` change applies from
the next model call; never the host's own `TZ`, and not on Windows, which ignores it), and compaction
inside Claude is disabled: the core owns compaction. `claude` and its login/doctor processes don't go
through the host's process spawn policy (`AgenticCoreOptions.ProcessEnvironment`): they already get a
replaced, allow-listed environment. The Bridge inherits `claude`'s environment, and the core tools it
advertises (such as `bash`) run in the core, so they go through the policy like any other tool call.
3. The bridge (a tiny console app) is the MCP server. It answers `initialize` and `tools/list` with the
catalogue and touches a "ready" marker file. It **never executes** anything: `tools/call` returns an error
and touches a "violation" marker that fails the request.

236
docs/docker.md Normal file
View File

@@ -0,0 +1,236 @@
# Running the Telegram bot in Docker
The repo's `Dockerfile` and `docker-compose.yml` run the [Telegram bot](telegram-bot.md) as one container, on
Coolify or any Docker host. The bot uses long polling, so the container needs no open port and no domain.
Local run:
```bash
cp .env.example .env # fill in TELEGRAM_BOT_TOKEN and TELEGRAM_ALLOWED_USER_IDS
docker compose up -d --build
docker compose logs -f bot
```
`dotnet run` (see [telegram-bot.md](telegram-bot.md#setup)) stays the development path.
## What's in the image
- The .NET 10 runtime and the published host in `/app`. No .NET SDK, so the agent can't build .NET projects.
- Tools for the agent's `bash`: `bash`, `git`, `ripgrep`, `python3` (with `python-is-python3` and `python3-venv`),
`curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client`.
- Node.js 24 from NodeSource, with `npm` and `npx`.
- The `claude` CLI (`@anthropic-ai/claude-code`), which the Claude Code provider drives.
- Optionally `@playwright/mcp` 0.0.82 (the `playwright-mcp` binary) and Chromium under `/opt/ms-playwright`
(`PLAYWRIGHT_BROWSERS_PATH`), for the browser tools in [mcp.md](mcp.md).
The bot and everything it starts run as `app` (uid/gid 1654, `HOME=/home/app`). The image's own user stays root:
the entrypoint `setpriv` drops to `app` before it starts `dotnet`, so PID 1 (`docker-init`, from compose's
`init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`. It also
empties the capability bounding set and sets `no_new_privs`, so nothing the agent runs regains root through a setuid
binary such as `su` or `mount`. Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the
same reason `docker exec` opens a root shell; add `-u app` to act as the bot. Any command that writes under `HOME`
(`npm`, `npx`, `git config`, `gh auth`, `claude`) must run with `-u app`: as root it leaves root-owned files in
`/home/app` (`HOME` is set image-wide), and the bot's `npx` and `git` then fail with `EACCES` until the next redeploy.
An interactive `app` session should also start from a clean environment (see
[First run and Claude login](#first-run-and-claude-login)).
## Build arguments
| Argument | Default | Purpose |
|---|---|---|
| `INSTALL_PLAYWRIGHT` | `false` in the `Dockerfile`, `true` in `docker-compose.yml` | Adds Playwright MCP and Chromium, about 400 MB |
| `CLAUDE_CODE_VERSION` | `2.1` | npm version spec for `claude`: `2.1` is the newest 2.1.x at build time; an exact version such as `2.1.3` pins it |
| `NODE_MAJOR` | `24` | Node.js major version from NodeSource |
| `PLAYWRIGHT_MCP_VERSION` | `0.0.82` | Must equal the version in `PlaywrightPreset.DefaultPackage`; `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` fails when they differ. The compose file doesn't override it |
The compose file passes `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` from `.env` (or Coolify's variables).
Without compose:
```bash
docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core .
docker run -d --init --shm-size 1g --restart unless-stopped --env-file .env \
-e PLAYWRIGHT_ENABLED=true -e PLAYWRIGHT_HEADLESS=true -e PLAYWRIGHT_EXTRA_ARGS=--no-sandbox \
-e PLAYWRIGHT_COMMAND=playwright-mcp \
-v workspaces:/data/workspaces -v agent:/data/agent -v claude:/data/claude volcanic-agentic-core
```
`--init` is required: without it `dotnet` is PID 1, so nothing reaps the zombies that killed `claude` trees and `bash`
children leave behind, and PID 1 is no longer root's. `--shm-size 1g` keeps Chromium from crashing. The compose
file's defaults (the `-e` lines, which win over `--env-file`) don't apply to a plain `docker run`, and neither does
its `TELEGRAM_MODEL` / `LOG_LEVEL` mapping: pass `-e Telegram__Model=...` instead.
## Configuration
Everything is set through environment variables: `.env` locally, the resource's environment variables on Coolify.
The compose file maps them into the container.
| Variable | Default | Purpose | Agent's processes see it |
|---|---|---|---|
| `TELEGRAM_BOT_TOKEN` | — | Required. The token from @BotFather | No, and redacted from tool output |
| `TELEGRAM_ALLOWED_USER_IDS` | — | Required. Comma-separated Telegram user ids; the first one is the owner (the `🟢 Online.` message, Claude re-login prompts) | Yes |
| `TELEGRAM_MODEL` | `claude-code/claude-opus-5-5` | Model every chat uses; compose passes it as `Telegram__Model` | Yes, as `Telegram__Model` |
| `TZ` | `UTC` | The container's time zone | The agent's `bash` gets the chat's zone as `TZ` instead |
| `LOG_LEVEL` | `Information` | Passed as `Logging__LogLevel__Default` | Yes |
| `OPENAI_API_KEY` | — | Turns on speech to text for voice notes | No, and redacted |
| `STT_MODEL` | `gpt-4o-transcribe` | Transcription model | Yes |
| `GOOGLE_CLIENT_ID` | — | With the secret, turns on Google Calendar ([google-calendar.md](google-calendar.md)) | Yes |
| `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted |
| `DEFAULT_TIMEZONE` | the container's `TZ` | IANA zone for chats that never ran `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup | Yes |
| `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through despite the deny rules, e.g. `GH_TOKEN` | Yes |
| `PLAYWRIGHT_ENABLED` | `true` | Browser tools on or off. Set it to `false` for an image built with `INSTALL_PLAYWRIGHT=false` | Yes |
| `PLAYWRIGHT_HEADLESS` | `true` | The container has no display | Yes |
| `PLAYWRIGHT_BROWSER` | `chromium` | Only Chromium is installed in the image | Yes |
| `PLAYWRIGHT_EXTRA_ARGS` | `--no-sandbox` | Extra `playwright-mcp` arguments. Keep `--no-sandbox` when you override it: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies | Yes |
| `PLAYWRIGHT_COMMAND` | `playwright-mcp` | The preinstalled server, so the first browser call doesn't wait for an `npx` download | Yes |
`TELEGRAM_MODEL` and `LOG_LEVEL` are read by the compose file only, which passes them to the bot as `Telegram__Model`
and `Logging__LogLevel__Default`; `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are build arguments (see above).
> **Never set `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`,
> `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`.** The Claude Code provider runs on a Claude subscription only
> and refuses to run when one of them is present. In Coolify, check shared, project and team variables too: they are
> the likely way one gets in.
### What the agent can't see
The process environment filter removes variables from the environment of the agent's `bash`, scheduled scripts and
MCP servers:
- anything matching the default deny rules: `*_API_KEY`, `*_APIKEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` and their
`Section__Key` forms (`*__APIKEY`, `*__*TOKEN`, `*__*SECRET`, `*__*PASSWORD`), plus `TELEGRAM_BOT_TOKEN`,
`OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN`;
- any variable whose value is the bot token, the speech API key or the Google client secret, whatever its name.
Those values are also replaced by `[REDACTED]` in tool output. The startup log names the rules, never values:
`Process environment: 15 deny rules, allow: none`.
To give the agent a credential on purpose, add the variable in Coolify, add it to `environment:` in the compose file,
and list its name in `PROCESS_ENV_ALLOW` (`PROCESS_ENV_ALLOW=GH_TOKEN` lets `gh` work). A name that is on the deny
list by name, such as `TELEGRAM_BOT_TOKEN`, can't be allowed: trying stops startup.
### Other settings
Any setting of the bot is reachable as `Section__Key`: add the variable in Coolify and a matching line under
`environment:` in `docker-compose.yml`. For example `Telegram__ReconnectMaxDelay=00:02:00`, `Telegram__InputDebounce`,
`Telegram__Speech__Language`, `Scheduler__MaxJobsPerTarget`, `GoogleCalendar__*`, `ProcessEnvironment__Deny__0`, and
the provider's `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS` / `VOLCANIC_CLAUDE_CODE_TOTAL_TIMEOUT_MS`. The settings are
listed in [telegram-bot.md](telegram-bot.md#configuration) and [claude-code-provider.md](claude-code-provider.md#configuration).
The image sets these; normally leave them alone:
| Variable | Value | |
|---|---|---|
| `Telegram__WorkspaceRoot` | `/data/workspaces` | One directory per chat |
| `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, settings, scheduler jobs, calendar tokens |
| `CLAUDE_CONFIG_DIR` | `/data/claude` | The Claude login |
| `HOME` | `/home/app` | |
| `DOTNET_EnableDiagnostics` | `0` | Turns off the .NET diagnostic socket, which would hand the bot's environment to the agent's processes. Unset it, together with `ProcessEnvironment__HardenHostProcess=false`, only to attach `dotnet-dump` or `dotnet-trace` while debugging |
| `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | |
## Volumes
| Volume | Path | Contents |
|---|---|---|
| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/<chatId>`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/` |
| `agent` | `/data/agent` | Sessions (`sessions/telegram-<chatId>/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`), `scheduler/jobs.json`, Google Calendar tokens (`google-calendar/`) |
| `claude` | `/data/claude` | The Claude login (credentials and onboarding state) |
Named volumes survive redeploys. Coolify prefixes them with the resource's uuid and deletes them only when you delete
the resource together with its volumes. Bind mounts work too if the host directories belong to uid 1654
(`chown -R 1654:1654 <dir>`).
`HOME` (`/home/app`) isn't a volume: `~/.gitconfig`, `~/.ssh`, `gh` logins and the npm cache are lost on redeploy.
## Deploying on Coolify
1. Create a resource from this repository with the *Docker Compose* build pack.
2. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ALLOWED_USER_IDS` (owner first) and any optional variables from
[Configuration](#configuration). Leave the domain empty.
3. Deploy. The log shows lines such as `Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5` and
`Allowed users: <ids>. Workspaces under /data/workspaces`, and the owner gets `🟢 Online.`.
There is no healthcheck, because the bot serves no HTTP. Lost connectivity is retried with backoff inside the
process. A rejected token (`401`) exits with code 1 and invalid configuration exits with code 2;
`restart: unless-stopped` turns either into a restart loop you can see in the logs.
## First run and Claude login
1. The owner opens the bot, sends `/start`, then `/claude-code-login`.
2. The login link and the code prompt arrive as one message. Open the link, sign in, and paste the code shown on the
page back into the chat. The credentials are stored in `/data/claude`.
3. `/claude-code-doctor` should now report that you're logged in through `claude.ai` and that the bridge works.
When the login expires later, the owner gets the same prompt automatically and the paused request continues after
the code is pasted.
Without the bot:
```bash
docker exec -u app -it <container> env -i HOME=/home/app \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm \
claude auth login --claudeai
```
The `-u app` matters: `docker exec` runs as root by default, and credentials written by root can't be read by the bot.
So does `env -i`: a `docker exec` process gets the whole container environment, bot token and API keys included, and
while it runs as `app` the agent's `bash` or a scheduled watcher can read it from `/proc/<pid>/environ`.
## Updating
Redeploy to rebuild the image. The `claude` CLI can't update itself inside the container (its install directory is
owned by root), so a new version only arrives with a build. The `npm install` layer is cached, though: to pick up a
newer 2.1.x, build without cache (Coolify's force deploy without cache, or `docker compose build --no-cache`), or set
`CLAUDE_CODE_VERSION` to an exact version, which also pins it. A Claude Code upgrade invalidates the prompt cache
once.
Bumping Playwright MCP means changing `PlaywrightPreset.DefaultPackage` and `ARG PLAYWRIGHT_MCP_VERSION` in the
`Dockerfile` in the same commit; the unit test above catches a mismatch.
## Extensions and project context
Extension assemblies go into `/data/agent/extensions/` (every chat) or `/data/workspaces/<chatId>/.volcanic/extensions/`
(one chat), then restart:
```bash
docker cp MyExtension.dll <container>:/data/agent/extensions/
docker exec <container> chown app:app /data/agent/extensions/MyExtension.dll
docker restart <container>
```
Build them against the same `Volcanic.AgenticCore.Abstractions` version as the image. See [extensions.md](extensions.md).
Project context files (`AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`) live in the chat's workspace directory.
The agent can edit them, or you can with `docker exec -u app -it <container> env -i HOME=/home/app
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin TERM=xterm bash` (clean environment, for the reason
given under [First run and Claude login](#first-run-and-claude-login)).
## Troubleshooting
| Symptom | Cause and fix |
|---|---|
| `[executable_missing]` in a reply, or `Claude Code (claude) was not found on PATH` at startup | The image has no working `claude`. Rebuild and check that `claude --version` ran in the build log |
| `[auth]` in a reply | The Claude login is missing or expired: `/claude-code-login`, or the `docker exec -u app` command above |
| `[mcp_startup]` in a reply | The bridge files are missing from `/app`: `Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll` and `Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json`. The build checks for both, so rebuild from a clean tree |
| `Permission denied` under `/data` | Bind-mounted directories owned by another user: `chown -R 1654:1654 <dir>` on the host |
| `Telegram reports another poller or a webhook for this bot (409)` | Another instance polls with the same token (an old container, a local `dotnet run`), or the bot has a webhook. Stop the other instance; remove a webhook with `https://api.telegram.org/bot<token>/deleteWebhook` |
| `Telegram rejected the bot token (401 Unauthorized)`, restart loop | Wrong or revoked `TELEGRAM_BOT_TOKEN` |
| Browser tools missing or failing | The image was built with `INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED` is off, or `PLAYWRIGHT_EXTRA_ARGS` lost `--no-sandbox`. Chromium crashes also come from a small `/dev/shm`; keep `shm_size: 1gb` |
| The provider refuses to run because of an API key | One of the `ANTHROPIC_*` / `CLAUDE_CODE_*` variables above is set, possibly as a shared Coolify variable. Remove it |
## Security
The container is the isolation boundary. Inside it, the agent's `bash` runs as `app`, the same user as the bot: it
can read the workspaces, the agent directory with every chat's sessions, and the Claude login in `/data/claude`.
The configured secrets are kept out of its way: they are filtered out of its environment, the bot process is
non-dumpable (its `/proc/<pid>/environ` is unreadable and it can't be attached to), PID 1 is root's so
`/proc/1/environ` is unreadable too, the diagnostic socket is off, setuid binaries can't raise its privileges, and
secret values are redacted from tool output.
That stops secrets leaking by accident, not an agent that goes after them on purpose. `app` owns `/data/workspaces`
and `/data/agent`, so the agent can drop a DLL into `<workspace>/.volcanic/extensions/` (loaded into the bot process,
with its unfiltered environment, at the next `/new`) or add an MCP server whose `env` uses `${env:TELEGRAM_BOT_TOKEN}`
to `/data/agent/settings.json`, then kill the bot so that `restart: unless-stopped` brings it back with that server.
Treat everyone on the allow-list as able to obtain every secret you configure. None of this is a sandbox: anyone on
the allow-list can run any command in the container.
Details in [telegram-bot.md](telegram-bot.md#security).

View File

@@ -69,12 +69,14 @@ Register it on the host (`o.AddExtension<HelloExtension>()`), pass it per sessio
| `SetModelAsync`, `GetThinkingLevel`, `SetThinkingLevel` | Session-scoped model control. |
| `GetActiveTools`, `SetActiveTools`, `GetAllTools`, `GetCommands` | Introspection and tool gating. |
| `GetSessionName`, `SetSessionName` | Display name. |
| `ExecAsync(command, args, options)` | Run a process in the session directory. |
| `ExecAsync(command, args, options)` | Run a process in the session directory. Its environment comes from the process policy; `ExecOptions.Environment` sets (or, with null, removes) variables on top of the filtered base. |
| `Events` | Bus shared by all extensions in the session (`Emit(channel, data)`, `On(channel, handler)`). |
| `Logger` | An `ILogger` named after the extension. |
| `Processes` | The host's `IProcessSpawnPolicy`: host hooks only, no extension events. Use it where there's no session, e.g. during activation. |
| `Redactor` | `Redact(text)` replaces the host's known secrets with `[REDACTED]`, for text you send to the model outside a tool result. |
Action members throw `ExtensionNotBoundException` when called during `ActivateAsync`: do registration there
and start processes or timers in a `SessionStartEvent` handler. After a reload or session replacement the old
and start processes or timers in a `SessionStartEvent` handler. `Processes` and `Redactor` work during activation. After a reload or session replacement the old
API and contexts throw `StaleExtensionContextException`.
## Events
@@ -98,20 +100,23 @@ mutable; change them to influence the runtime.
| `ToolCallEvent` | Before a tool runs | Patch `Input`, or `Block(reason)`; a throwing handler blocks |
| `ToolResultEvent` | After a tool ran | Edit `Content`, `Details`, `IsError` |
| `ToolExecutionStart/Update/EndEvent` | Tool lifecycle | notify |
| `ProcessSpawnEvent` | Before a process the agent asked for starts | Replace `Context` (command, cwd, environment); a throwing handler fails the spawn |
| `SessionBeforeCompactEvent` | Compaction about to run | `Cancel()` or set `Override` to supply your own summary |
| `SessionCompactEvent` | Compaction entry written | notify |
| `ModelSelectEvent` / `ThinkingLevelSelectEvent` | Settings changed | notify |
Dispatch rules: handlers run in extension load order, then registration order; exceptions are reported as
`extension_error` session events and dispatch continues; `Cancel`, `Block` and `MarkHandled` stop further
handlers.
`extension_error` session events and dispatch continues (except `ProcessSpawnEvent`, where the exception also
fails the spawn); `Cancel`, `Block` and `MarkHandled` stop further handlers.
## The context object
`IExtensionContext` gives handlers and tools: `Cwd`, `SessionId`, `HasUi`, `Ui` (select/confirm/input/notify/
`IExtensionContext` gives handlers and tools: `Cwd`, `SessionId`, `TimeZone` (the session's zone), `HasUi`, `Ui` (select/confirm/input/notify/
status), `SessionManager` (read-only entries and branch), `ModelRegistry` (models and provider-neutral
`CompleteAsync` for nested model calls), `Model`, `ThinkingLevel`, `Signal` (cancelled on abort), `IsIdle()`,
`Abort()`, `HasPendingMessages()`, `GetContextUsage()`, `GetSystemPrompt()`, `Compact()`.
`Abort()`, `HasPendingMessages()`, `GetContextUsage()`, `GetSystemPrompt()`, `Compact()`,
`PrepareProcessAsync(request)` (see [spawning processes](#spawning-processes)), `Channel` (the host's
`IChatChannel`, or null).
Command handlers get `IExtensionCommandContext`, which adds `WaitForIdleAsync`, `NewSessionAsync` and
`SwitchSessionAsync`.
@@ -122,14 +127,59 @@ Implement `ITool` directly or derive from `Tool<TArgs>`; the JSON Schema is gene
(records with `[Description]` attributes work well; nullable properties are optional). `DelegateTool<TArgs>`
builds a tool from a lambda.
- Throw to produce an error result. `ToolExecutionException(result)` returns content and details while
marking the result as an error.
- Throw to produce an error result. `ToolExecutionException(result)` (in `Volcanic.AgenticCore.Abstractions.Tools`)
returns content and details while marking the result as an error.
- `ToolResult.Content` is what the model sees; `Details` is structured data for hosts.
- `Terminate = true` on every result of a batch ends the run without another model call.
- `ExecutionMode = Sequential` forces the whole batch to run one at a time (the built-in `edit`/`write` do this).
- `PromptSnippet` puts one line in the system prompt's tools section; `PromptGuidelines` adds bullets.
- Report progress through `invocation.Report(partial)`; hosts see it as `tool_execution_update`.
## Spawning processes
A process started on the agent's behalf must not inherit the host's environment as it is: that's how
`TELEGRAM_BOT_TOKEN` or `OPENAI_API_KEY` ends up in tool output. Build its environment through the process policy
(`Volcanic.AgenticCore.Abstractions.Processes`):
```csharp
var spawn = await ctx.PrepareProcessAsync(new ProcessSpawnRequest
{
Purpose = ProcessPurpose.ExtensionExec,
Command = "gh",
Arguments = ["issue", "list"],
Cwd = ctx.Cwd,
Environment = new Dictionary<string, string?> { ["GH_TOKEN"] = myToken },
Origin = "my-extension",
}, ct);
var psi = new ProcessStartInfo(spawn.Command) { UseShellExecute = false };
foreach (var a in spawn.Arguments) psi.ArgumentList.Add(a);
spawn.ApplyTo(psi); // WorkingDirectory = spawn.Cwd, Environment cleared, then spawn.Environment copied in
```
- In a session, call `ctx.PrepareProcessAsync`. It fills `Scope`, `SessionId` and (unless you set it)
`TimeZoneId` from the session, runs the policy, then emits `ProcessSpawnEvent` to extensions in load order.
`TimeZoneId` is filled only when the session or host configured a zone; otherwise the child inherits the host's `TZ`.
Without a session (during activation, or from a process-wide service), call `api.Processes.Prepare(request)`:
only the host hooks run.
- The returned `ProcessSpawnContext.Environment` is the **complete** environment. `spawn.ApplyTo(psi)` clears
`ProcessStartInfo.Environment` and copies it in (and sets the working directory); don't merge it with the inherited one.
- The policy starts from the host's environment, removes what the deny-list (`*_API_KEY`, `*_TOKEN`, `*_SECRET`,
`*_PASSWORD`, their `Section__Key` forms, and a few names), `Allow` and `SecretValues` reject, then applies
`request.Environment`, then sets `TZ` from `TimeZoneId`, then runs the host hooks.
- **Variables you pass explicitly are applied after the deny-list and never filtered.** A process that needs a
token gets it only when you pass it, and no other process sees it. A null value removes the variable.
- Host hooks (`ProcessSpawnHook`) are synchronous, pure rewrites of command, arguments, cwd and environment.
`ProcessSpawnEvent` handlers are ordinary (async) extension handlers that may replace `Context`; keep them free of
slow I/O, because they delay the spawn. A hook or handler that throws fails the spawn (the exception comes out of
`PrepareProcessAsync`), and nothing falls back to the inherited environment. Report it the way you report a
failed start; the built-in `bash` returns `process environment hook failed: <message>`.
- `ExecAsync` already does all of this with `Purpose = ExtensionExec` and `Origin` = your extension's name.
- Tool output is redacted by the agent loop: every `SecretValues` entry in a tool result, progress update or
error becomes `[REDACTED]` before the model, the transcript, hosts or `ToolResultEvent` handlers see it. Tools
don't redact themselves. Text you send to the model some other way (`SendMessage`, `SendUserMessage`) isn't
redacted: pass it through `api.Redactor.Redact` first.
## Where state should live
| State | Use |

175
docs/google-calendar.md Normal file
View File

@@ -0,0 +1,175 @@
# Google Calendar
`extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar` gives the agent a Google Calendar. Each chat connects its
own Google account from the chat itself, with no browser on the bot's machine. The Telegram bot loads it in every
chat once the OAuth client is configured.
## What it does
- A `calendar` tool with the actions `list`, `search`, `get`, `list_instances`, `create`, `update`, `delete` and
`calendars`. It's registered whenever the client is configured, connected or not. Before a chat connects, the tool
tells the model to ask the user to run `/calendar_connect`.
- Three commands: `/calendar_connect`, `/calendar_status` and `/calendar_disconnect`.
- One Google account per chat. The account is keyed by the session scope, which the Telegram bot sets to
`telegram-<chatId>`. A headless host uses the default scope derived from the working directory, so it gets one
account per workspace. The tool only uses the current chat's token, so nobody sees another chat's calendar through
the agent.
This controls what the agent shows to whom. It is not a security boundary between allow-listed users: every one of
them can run shell commands as the bot's OS user, and so could read another chat's token file.
## Google Cloud setup
You need one Google Cloud OAuth client for the bot. All chats share it; each chat still approves its own account.
1. Create a project at [console.cloud.google.com](https://console.cloud.google.com). Then go to
*APIs & Services → Library → Google Calendar API* and click **Enable**.
2. Set up the *OAuth consent screen* (in the newer console: *Google Auth Platform → Branding* and *Audience*). Pick
user type **External** and fill in the app name, a support email and the developer contact address. You don't need
to add scopes there: the bot requests `https://www.googleapis.com/auth/calendar` itself.
3. **Publish the app**: *Audience → Publishing status → Publish app*, so the status reads "In production". A handful
of users doesn't need Google's verification, and refresh tokens of a published app don't expire after 7 days.
4. **Expect the "unverified app" warning.** When a user approves access, Google first shows a page titled
"Google hasn't verified this app". It says the app requests access to sensitive info in the Google account and
that the user shouldn't use it until the developer verifies it with Google. The big button on it is
**Back to safety**. To go on, click the small **Advanced** link at the bottom left, then
**Go to <app name> (unsafe)**. On the consent screen that follows, allow "See, edit, share, and permanently delete
all the calendars you can access using Google Calendar" and press **Continue**. Each Google account sees this
once.
5. Go to *Credentials → Create credentials → OAuth client ID* and pick the application type
**TVs and Limited Input devices**. The device flow the bot uses only works with that type. Copy the client id and
the client secret.
## Configure the bot
Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`, or `GoogleCalendar:ClientId` and `GoogleCalendar:ClientSecret` in
`appsettings.json` (`GoogleCalendar__ClientId` and `GoogleCalendar__ClientSecret` in the environment). A value in the
section wins over the environment variable.
```bash
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com \
GOOGLE_CLIENT_SECRET=GOCSPX-... \
TELEGRAM_BOT_TOKEN=123456:ABC... \
TELEGRAM_ALLOWED_USER_IDS=111111111 \
dotnet run --project hosts/Volcanic.AgenticCore.Telegram
```
- Empty values count as unset.
- With neither value set, the extension stays off and logs one information line:
`google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET`.
- With only one of them set, it stays off and logs a warning that names the missing one.
- With both set, every chat gets the tool and the commands, and the three commands appear in the bot's menu and in
`/help`.
The client secret is added to the process policy's `SecretValues`, so it shows up as `[REDACTED]` in tool output.
`GOOGLE_CLIENT_SECRET` is also on the default deny-list (`*_SECRET`), so `bash`, scheduled scripts and MCP servers
never see it in their environment. See [telegram-bot.md](telegram-bot.md#security).
## Connecting a chat
`/calendar_connect` sends one message with the link and the code:
```
Connect Google Calendar:
1. Open https://www.google.com/device
2. Enter the code ABCD-EFGH
3. Approve access. I'll confirm here when it's done (code valid for 30 min).
```
Open the link on any device, sign in with the Google account for this chat, enter the code and approve. The bot polls
Google on its own, so there's nothing more to type. When you approve, the chat gets:
```
âś… Google Calendar connected as you@gmail.com (time zone Europe/Berlin).
```
The account shown is the primary calendar's id, which is the account's email address.
- Sending `/calendar_connect` again while the bot waits re-sends the same link and code with the minutes left. It
doesn't start a second flow, because a new code would invalidate the one you may be typing.
- A denial ("Google Calendar access was denied."), an expired code ("The Google code expired. Run /calendar_connect
again.") and a failure ("Google Calendar connection failed: ...") each end the wait with one message.
- `/stop` doesn't cancel the wait; it only stops agent runs. `/calendar_disconnect` does cancel it.
- A restart of the bot loses a pending flow. Run `/calendar_connect` again.
- Connecting again while connected switches the chat to the newly approved account. The old token is replaced once the
new one arrives, but it is not revoked.
`/calendar_status` shows one of three things:
- `Connected as <account> since <date>, time zone <tz>.`
- `Not connected. Run /calendar_connect.`
- `Waiting for approval: open <url> and enter <code> (<N> min left).`
`/calendar_disconnect` cancels a pending flow, revokes the token at Google (best effort) and deletes the local token.
If Google can't be reached or refuses, the token is still deleted locally and the reply says so; remove the app by hand
at [myaccount.google.com/permissions](https://myaccount.google.com/permissions).
## Where the token lives
Each chat's refresh token is stored in `<agentDir>/google-calendar/<chat key>/token.json`. The agent dir is
`$VOLCANIC_AGENT_DIR`, or `~/.volcanic/agent` when that's unset. Hosts can move the root with
`GoogleCalendarOptions.TokenDirectory`.
- On Unix the file has mode 0600 and its directory 0700.
- The directory is outside every chat workspace, so the agent doesn't come across it while it reads and searches the
workspace.
- The chat key is the session scope (`telegram-<chatId>`) when it matches `[A-Za-z0-9._-]{1,100}`. Any other scope
becomes `scope-<16 hex digits>`, the start of the scope's SHA-256 hash.
- Removing a chat from the allow-list doesn't delete its directory. Run `/calendar_disconnect` first, or delete the
directory yourself.
The file holds the refresh token, the granted scope, the account, the calendar's time zone and the connection time.
The refresh token never appears in logs, tool results or error messages.
## Time zones
- `create` and `update` take an optional `timeZone` (an IANA id such as `Europe/Berlin`) for the event. Times without
an offset are then in that zone.
- Otherwise times without an offset (`2026-09-30T10:00`) are in the calendar's own time zone, which the bot reads when
the chat connects. If that's unknown, they are in the chat's zone.
- A time with an offset (`2026-09-30T10:00:00+02:00`) keeps its offset.
- Every tool result starts with the zone it uses, such as `Times in Europe/Berlin.` When the calendar's zone differs
from the chat's, the header names both:
`Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).`
- A date alone (`2026-09-30`) is an all-day event. All-day ends are exclusive, as in Google: an event on the 30th ends
on the 1st.
- In a `list` or `search` window, a `timeMax` date includes that day: `timeMin` and `timeMax` both `2026-09-30` cover
the whole 30th.
- For recurring events, `list_instances` returns the ids of single occurrences. `update` and `delete` detect whether an
id is one occurrence or a whole series, so `scope` (`instance` or `series`) only needs to be passed to be explicit.
"This and following" edits aren't supported.
## Testing status instead of publishing
You can leave the app in *Testing* status instead of publishing it:
- Add every user's Google account under *Audience → Test users*. Anyone else gets `access_denied`, which the bot
reports as "Google Calendar access was denied."
- Google expires refresh tokens of apps in testing status after 7 days. After that the tool reports that Google
rejected the stored calendar token, and the chat has to run `/calendar_connect` again, about once a week.
## Other hosts
The extension only depends on the public API, so any host can load it:
```csharp
services.AddSingleton(new GoogleCalendarOptions { ClientId = "...", ClientSecret = "..." });
services.AddAgenticCore(o => o.AddExtension<GoogleCalendarExtension>());
```
`AddExtension<T>()` creates one instance per session, which the extension needs because it keeps per-session fields.
Don't use the overload that takes a shared instance. Without the singleton, the extension reads `GOOGLE_CLIENT_ID`
and `GOOGLE_CLIENT_SECRET` from the environment. To add it for particular sessions only, pass an
`ExtensionRegistration` in `AgentSessionOptions.ExtraExtensions`, as the Telegram host does
(`GoogleCalendarSetup.Registration`). `/calendar_connect` needs a UI (`ctx.HasUi`) to show the link and code; without
one it replies that it needs an interactive UI.
## Troubleshooting
| Message | Cause and fix |
|---|---|
| "Google Calendar is not connected. Ask the user to run /calendar_connect in this chat." | This chat never connected, or it disconnected. Run `/calendar_connect` |
| "Google rejected the stored calendar token (revoked or expired)." | The access was revoked, the account's password changed, or the app is in testing status and 7 days have passed. Run `/calendar_connect` again |
| "Google Calendar connection failed: Google refused the device code request: invalid_client ..." | Wrong client id or secret, or the OAuth client isn't of type *TVs and Limited Input devices* |
| "Google returned no refresh token." | Google already had a grant for this app and didn't issue a new token. Remove the app at [myaccount.google.com/permissions](https://myaccount.google.com/permissions) and run `/calendar_connect` again |
| No calendar tool or commands at all | The client id or secret is missing. Check the startup log for the `google-calendar disabled` line |

132
docs/mcp.md Normal file
View File

@@ -0,0 +1,132 @@
# MCP servers and the Playwright browser
`extensions/Volcanic.AgenticCore.Extensions.Mcp` is an MCP **client**: it starts stdio MCP servers and exposes their
tools to the agent as ordinary tools. It uses only the public extension API and the official `ModelContextProtocol.Core`
SDK. (`ClaudeCode.Bridge` is the opposite, an MCP *server* that shows core tools to `claude -p`.)
## Configuration
Servers come from two places. The first one wins a server name:
1. the host's `Mcp` section (`appsettings.json`, or `Mcp__Servers__<name>__Command` and friends in the environment),
sorted by name (configuration binding sorts keys, so the order in `appsettings.json` doesn't count), then the
Playwright preset;
2. `"mcpServers"` in `~/.volcanic/agent/settings.json` (`VOLCANIC_AGENT_DIR` moves it), with the same entry shape in
camelCase, in file order. The extension reads this file itself, so a host that sets `AgenticCoreOptions.AgentDir`
to another directory must also set `VOLCANIC_AGENT_DIR` for `mcpServers` to be found there.
There is no per-workspace source: a Telegram workspace is writable by the agent itself.
```jsonc
"Mcp": {
"Servers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github@2025.9.1"],
"env": { "GITHUB_TOKEN": "${env:GITHUB_TOKEN}" },
"toolPrefix": "gh_",
"enabled": true,
"parallel": false,
"startupTimeoutSeconds": 60,
"callTimeoutSeconds": 120,
"idleTimeoutSeconds": 1800,
"stderrLevel": "Warning",
"promptSection": null
}
},
"Playwright": { "Enabled": false, "Headless": true, "Browser": null, "ExtraArgs": [], "Package": null, "Command": null }
}
```
- `${workspace}` in `command`, `args`, `cwd` and `env` values becomes the chat's workspace when the process starts.
The process runs in `${workspace}` unless `cwd` says otherwise.
- `${env:NAME}` in an `env` value copies a host variable (unset: empty). It's the only way a deny-listed variable such
as `GITHUB_TOKEN` reaches a server, and it reaches only that server. A `null` value removes a variable (use the user
settings file for that; configuration binding may turn `null` into an empty string).
- Every server process gets the process policy's filtered environment (see [extensions.md](extensions.md#spawning-processes)).
Host secrets (`TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, ...) are never inherited, and known secret values in tool output
become `[REDACTED]`.
- `toolPrefix` may contain letters, digits, `_` and `-`, and may not start with `mcp__`. Timeouts must be positive;
`idleTimeoutSeconds: 0` never stops an idle process. The host refuses to start on invalid configuration.
## The Playwright preset
`PLAYWRIGHT_ENABLED=true` (or `Mcp:Playwright:Enabled`) adds a server named `playwright` running `@playwright/mcp`:
```
npx -y @playwright/mcp@0.0.82 [--headless] --browser chromium
--output-dir ${workspace}/browser --user-data-dir ${workspace}/.volcanic/browser-profile <extra args>
```
`--user-data-dir` is left out when the extra arguments contain `--isolated`.
| Variable | Setting | Default | Notes |
|---|---|---|---|
| `PLAYWRIGHT_ENABLED` | `Mcp:Playwright:Enabled` | `false` | |
| `PLAYWRIGHT_HEADLESS` | `Mcp:Playwright:Headless` | `true` | |
| `PLAYWRIGHT_BROWSER` | `Mcp:Playwright:Browser` | `chromium` | Without it `@playwright/mcp` would look for Google Chrome |
| `PLAYWRIGHT_EXTRA_ARGS` | `Mcp:Playwright:ExtraArgs` | — | Shell-style split: `--device "iPhone 15"` is two arguments. `--isolated` keeps no profile on disk |
| `PLAYWRIGHT_COMMAND` | `Mcp:Playwright:Command` | `npx` | Run this binary (e.g. a global `playwright-mcp`) instead of `npx -y <package>` |
| — | `Mcp:Playwright:Package` | `@playwright/mcp@0.0.82` | npm spec used with `npx`. The pinned version is `PlaywrightPreset.DefaultPackage` |
Configured values win over the variables; an empty variable counts as unset. The bot logs
`MCP: playwright enabled (headless, chromium)` or `MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)` at startup.
- Screenshots saved with `filename: "browser/<name>.png"`, PDFs and downloads land in the workspace's `browser/`
folder, where `send` can deliver them. A screenshot without `filename` comes back to the model as an image (the
OpenAI chat-completions provider can't show tool-result images, so there it only gets `[image]`).
- Each chat has its own browser profile under `.volcanic/browser-profile`, so site logins survive idle restarts,
`/new` and bot restarts. The agent's `bash` can read that profile, like everything else in its workspace.
- The system prompt gets a short "Browser" section while the browser tools are active. It mentions `send` only when
a `send` tool is active.
### Deployment requirements
The server needs Node and the Chromium build that the pinned `@playwright/mcp` expects:
`npx playwright install --with-deps chromium`, run from that package so the versions match
(`PLAYWRIGHT_BROWSERS_PATH` is honoured). The first `npx` run downloads the package, which is why the preset allows
120 s to start. The Docker image ([docker.md](docker.md), built with `INSTALL_PLAYWRIGHT=true`) preinstalls both and sets
`PLAYWRIGHT_COMMAND=playwright-mcp` and `PLAYWRIGHT_EXTRA_ARGS=--no-sandbox`. Chromium's sandbox needs user namespaces,
which Docker's default profile blocks, so there the container is the isolation boundary. A missing browser shows up as
an error from the first `browser_*` call.
## Tool names
The agent sees `toolPrefix + name`, with characters outside `[A-Za-z0-9_-]` replaced by `_`, cut to 48 characters,
and `_2`, `_3` added to duplicates. The server is always called with its own name. Through the Claude Code provider the
tools appear as `mcp__volcanic__<name>`.
- A tool named like a built-in (`read`, `bash`, ...) is **not** registered, and a warning suggests a `toolPrefix`.
- If two servers offer the same name, the first in the order above wins (host servers by name) and a warning names
both. Give one of them a `toolPrefix` to keep both.
- MCP tools run one at a time unless the server sets `"parallel": true`: most servers (Playwright included) are stateful.
## Lifecycle
- The tool list is discovered once per bot process per server configuration, in a temp directory, while the first
session activates. Concurrent chats share that one discovery. A server that fails to start is logged, shows up in
`/mcp` as failed, contributes no tools, and is retried after 60 s. Other servers and the chat carry on.
- Each chat gets its own server process, started by the first call in the chat's workspace. It stops after
`idleTimeoutSeconds` without calls, and on `/new`, `/resume` or shutdown, so a new conversation gets a fresh browser.
- A server that dies turns the call into an error ("… MCP server exited (code): <stderr>. It restarts on the next call;
browser state was lost."), and the next call starts a new one. Failed calls are never retried automatically.
- A call runs at most `callTimeoutSeconds`. `/stop` cancels it and the server is told (`notifications/cancelled`).
## `/mcp`
| Command | |
|---|---|
| `/mcp` | Each server: `not started`, `running`, `exited`, `failed: <reason>` or `disabled`, and its tool count |
| `/mcp <name>` | Command line (without env values), env names, server version, tools, last error, stderr tail |
| `/mcp restart <name>` | Stop the chat's process; the next call starts a fresh one (clears a wedged browser) |
No model call is made. Known secret values in the output (a server's stderr, say) are shown as `[REDACTED]`.
## Manual check (not in CI)
With `PLAYWRIGHT_ENABLED=true` and Chromium installed:
1. "Open https://example.com and tell me the H1" → `Example Domain`.
2. "Screenshot example.com and send it to me" → a photo arrives in Telegram (needs the `send` tool).
3. `/mcp` shows `playwright: running`.
4. Two chats browse at the same time without seeing each other's tabs.

View File

@@ -0,0 +1,596 @@
# Docker Deployment (Coolify) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Run the Telegram host as one non-root container, built from this repo's `Dockerfile` and started by `docker-compose.yml` on Coolify or any Docker host. The container ships the agent's tools, Node 24, the `claude` CLI and optionally Playwright MCP + Chromium, and keeps workspaces, sessions and the Claude login in three named volumes.
**Architecture:** A two-stage `Dockerfile` (`sdk:10.0-noble` publishes the host framework-dependent; `runtime:10.0-noble` adds apt tools, NodeSource Node, `@anthropic-ai/claude-code`, optional `@playwright/mcp` + Chromium). The image's default user stays root so that PID 1 (compose's `init: true`, i.e. `docker-init`) is root-owned; the entrypoint `setpriv` drops to `app` (uid 1654) and execs `dotnet Volcanic.AgenticCore.Telegram.dll`. `docker-compose.yml` passes configuration through environment variables only. No C# behaviour changes; the one new unit test pins the Dockerfile's `PLAYWRIGHT_MCP_VERSION` to `PlaywrightPreset.DefaultPackage`.
**Tech Stack:** Docker (BuildKit, Compose v2), `mcr.microsoft.com/dotnet/sdk:10.0-noble` and `runtime:10.0-noble`, NodeSource Node.js 24, `@anthropic-ai/claude-code@2.1`, `@playwright/mcp@0.0.82`, `util-linux` `setpriv`, xunit 2. Spec: `docs/superpowers/specs/2026-09-26-docker-deployment-design.md`.
## Global Constraints
- Work only in the worktree `D:\Projects\Names\wt-docker-deployment` (branch `feat/docker-deployment`). Every Bash command starts with `cd /d/Projects/Names/wt-docker-deployment && ...`; Read/Edit/Write use absolute paths under `D:\Projects\Names\wt-docker-deployment`. Never switch branches, never `git worktree`/`checkout`/`reset --hard`/`push`. Never touch `D:\Projects\Names\VolcanicAgenticCore` or another `wt-*` directory.
- Wave E runs alone, but keep edits to shared host files minimal. This plan changes **no** file under `src/`, `extensions/` or `hosts/`; if a task finds it has to, it edits the spec in the same commit and says so in the commit body.
- `Directory.Build.props` sets `TreatWarningsAsErrors=true` for non-test projects; the image build runs `dotnet publish -c Release`, so a Release-only warning fails the image build too.
- Tests: xunit 2. No test calls a paid API or any network endpoint, and no test runs Docker. Known baseline on this Windows machine: 3 tests in `Volcanic.AgenticCore.Tests` fail on `main` before any change (`SystemPromptBuilderTests.Loads_AGENTS_md_in_preference_to_CLAUDE_md_then_persona_and_memory`, `ExtensionLoaderTests.Discover_loads_a_real_assembly_from_the_user_directory`, `BuiltInToolTests.Ls_find_and_grep`). "Green" = no other failures.
- Docker on this machine: Docker Desktop 29, Linux containers. Run docker commands from Git Bash with `MSYS_NO_PATHCONV=1` in front (otherwise Git Bash rewrites container paths such as `/data/...` into Windows paths), or from PowerShell. Foreground `sleep` is blocked in the Bash tool; wait with PowerShell `Start-Sleep -Seconds 20` or the Monitor tool.
- Docker hygiene: never use real secrets (only `TELEGRAM_BOT_TOKEN=123:fake`, `TELEGRAM_ALLOWED_USER_IDS=1`); never push images; never `docker system prune`, `docker volume prune` or remove volumes/images you didn't create. Image tags this plan creates: `volcanic-agentic-core:test` (no Playwright), `volcanic-agentic-core:test-pw` (Playwright). Compose project name for test runs: `volcanic-test` (its volumes are `volcanic-test_workspaces`, `volcanic-test_agent`, `volcanic-test_claude`; its image `volcanic-test-bot`). Container names: `volcanic-probe`, `volcanic-probe-pw`. T6 removes all of them.
- Never commit `.env` (a dummy one is created for `docker compose config`/`up`), `bin/`, `obj/`, `launchSettings.json`, `hosts/Volcanic.AgenticCore.Telegram/Properties/`.
- Exact values from the spec and the code (copy verbatim):
- base images `mcr.microsoft.com/dotnet/sdk:10.0-noble`, `mcr.microsoft.com/dotnet/runtime:10.0-noble`;
- build args: `NODE_MAJOR=24`, `CLAUDE_CODE_VERSION=2.1`, `INSTALL_PLAYWRIGHT=false` (Dockerfile) / `true` (compose default), `PLAYWRIGHT_MCP_VERSION=0.0.82` (must equal `PlaywrightPreset.PackageVersion`);
- apt tools: `bash git ripgrep python3 python-is-python3 python3-venv curl ca-certificates tzdata jq less procps openssh-client` (+ `gnupg` for the NodeSource key);
- user `app`, uid/gid **1654**, `HOME=/home/app`; volumes `/data/workspaces`, `/data/agent`, `/data/claude`; `PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright`;
- image env: `Telegram__WorkspaceRoot=/data/workspaces`, `VOLCANIC_AGENT_DIR=/data/agent`, `CLAUDE_CONFIG_DIR=/data/claude`, `HOME=/home/app`, `DOTNET_CLI_TELEMETRY_OPTOUT=1`, `DOTNET_NOLOGO=1`, `TZ=UTC`, `DOTNET_EnableDiagnostics=0`;
- environment variables the host reads (verified in code): `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_IDS` (`TelegramOptions.ApplyEnvironment`), `OPENAI_API_KEY`, `STT_MODEL`, `DEFAULT_TIMEZONE` (same), `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` (`GoogleCalendarSetup.Options`), `PROCESS_ENV_ALLOW` (`TelegramProcessEnvironment.AllowVariable`), `PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` (`McpOptions.ApplyEnvironment`), `VOLCANIC_AGENT_DIR` (`VolcanicPaths.DefaultAgentDir`, `GoogleCalendarOptions`), plus every `Section__Key` form through `Host.CreateApplicationBuilder`'s unprefixed environment provider (so `Telegram__WorkspaceRoot` and `Telegram__Model` bind to `TelegramOptions` exactly as the spec's compose assumes; `TELEGRAM_MODEL`, `LOG_LEVEL`, `INSTALL_PLAYWRIGHT`, `CLAUDE_CODE_VERSION` are compose-level variables only);
- the `@playwright/mcp@0.0.82` package's binary is `playwright-mcp` (`npm view @playwright/mcp@0.0.82 bin` → `{ 'playwright-mcp': 'cli.js' }`); it depends on `playwright` `1.64.0-alpha-1789764292000`;
- startup log lines to expect (from `Program.cs`): `Process environment: 15 deny rules, allow: none`, `MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)` / `MCP: playwright enabled (headless, chromium)`, `Speech to text: off (no OPENAI_API_KEY)`, `Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5`, `Scheduler: /data/agent/scheduler/jobs.json`, `Default time zone: ...`, `Allowed users: 1. Workspaces under /data/workspaces`, and **no** `The .NET diagnostic IPC socket is on ...` warning.
- **Deviation from the spec found while planning (T1 edits the spec):** the spec runs the image as `USER $APP_UID` with compose's `init: true`. Checked on Docker 29: `docker run --rm --init -u 1654 -e SECRET_PROBE=x mcr.microsoft.com/dotnet/runtime:10.0-noble sh -c 'tr "\0" "\n" </proc/1/environ | grep SECRET_PROBE'` prints `SECRET_PROBE=x`, because `docker-init` runs as the container user and is dumpable. The agent's `bash` could read every secret from `/proc/1/environ`, defeating process-environment-hook §8. Fix (also checked): leave the image user as root and use `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`; with `--init`, `docker-init` is PID 1 as root and `cat /proc/1/environ` from uid 1654 fails with `Permission denied`. `setpriv` execs `dotnet`, so `Environment.ProcessPath` is still the muxer. `docker exec` then defaults to root, so the docs use `docker exec -u app`.
- The spec is the source of truth; when the implementation has to differ, edit the spec in the same commit and say so in the commit body.
- Commits: Conventional Commits, scope `docker` (`feat(docker): ...`, `docs(docker): ...`). Stage only the files the task lists.
- Test commands (from the worktree root): `dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~PlaywrightPresetTests"`; end of T1 and T6: `dotnet build && dotnet test` (green = only the three baseline failures).
---
## File map
| File | Change | Task |
|---|---|---|
| `tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs` | `Dockerfile_version_matches_DefaultPackage` + `RepoFile` helper | T1 |
| `Dockerfile` | **new**: build stage, runtime stage, tools, Node, Claude CLI, env, `setpriv` entrypoint | T1 |
| `.dockerignore` | **new** | T1 |
| `.gitattributes` | **new**: LF for the Docker files | T1 |
| `docs/superpowers/specs/2026-09-26-docker-deployment-design.md` | root PID 1 + `setpriv` deviation, csproj list | T1 |
| `Dockerfile` | `INSTALL_PLAYWRIGHT` block | T2 |
| `docker-compose.yml`, `.env.example` | **new** | T3 |
| `.gitignore` | `.env` | T3 |
| `docs/docker.md` | **new** user doc | T4 |
| `README.md`, `docs/telegram-bot.md`, `docs/mcp.md` | links, container recommendation, `DOTNET_EnableDiagnostics` wording | T5 |
| `docs/superpowers/specs/2026-09-25-telegram-host-design.md`, `docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md` | out-of-scope line, Status note, deferred test bullet | T5 |
| `docs/superpowers/specs/2026-09-26-docker-deployment-design.md` | Status, Verification results | T6 |
## Spec Testing / Verification coverage
| Spec item | Where |
|---|---|
| MCP spec's deferred test: Dockerfile `PLAYWRIGHT_MCP_VERSION` equals `DefaultPackage` | T1 `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` |
| Verification 1: `docker compose build` with `INSTALL_PLAYWRIGHT=false` and `true`; bridge guard and `claude --version` pass | T1 (false), T2 (true), T3 (`docker compose up --build`), T6 (re-run from cache) |
| Verification 2: uid 1654, secrets not in the agent's env, `/proc/1/environ` denied, no `dotnet-diagnostic-*` socket | T3 probe container (`--network none`, `docker exec -u app`); the chat-driven parts (real bot, `/claude-code-login`, `/timezone`) are manual → report `openIssues` |
| Verification 3: `git`, `rg`, `python3`, `node`, `npx`; `playwright-mcp --version` offline; browser works | T1 tool check, T2 offline `--version` + headless Chromium launch as uid 1654; "tell me the H1" in a real chat is manual → `openIssues` |
| Verification 4: redeploy keeps chats, login, workspace files | T3 checks volume ownership/writability; a real redeploy with a logged-in bot is manual → `openIssues` |
| Verification 5: `ANTHROPIC_API_KEY` makes the provider refuse | Manual (needs a real chat) → `openIssues`; covered in code by existing ClaudeCode tests |
| Docs section (docker.md, README, telegram-bot.md, telegram-host spec line) | T4, T5 |
---
### Task 1: Dockerfile, .dockerignore and the Playwright version pin test
**Files:**
- Modify: `tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs`
- Create: `Dockerfile`, `.dockerignore`, `.gitattributes`
- Modify: `docs/superpowers/specs/2026-09-26-docker-deployment-design.md`
**Interfaces:**
- Consumes: `PlaywrightPreset.PackageVersion` (`"0.0.82"`, `extensions/Volcanic.AgenticCore.Extensions.Mcp/PlaywrightPreset.cs`).
- Produces: repo-root `Dockerfile` with the line `ARG PLAYWRIGHT_MCP_VERSION=0.0.82`, a `ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright` line followed by the comment `# INSTALL_PLAYWRIGHT block (T2)` that T2 replaces, and image `volcanic-agentic-core:test`.
- [ ] **Step 1: Write the failing test**
Append to `PlaywrightPresetTests` (the file already has `using System.Text.RegularExpressions;`):
```csharp
[Fact]
public void Dockerfile_version_matches_DefaultPackage()
{
var dockerfile = File.ReadAllText(RepoFile("Dockerfile"));
var matches = Regex.Matches(dockerfile, @"^ARG PLAYWRIGHT_MCP_VERSION=(?<version>\S+)\s*$", RegexOptions.Multiline);
var match = Assert.Single(matches);
Assert.Equal(PlaywrightPreset.PackageVersion, match.Groups["version"].Value);
}
/// <summary>A file in the repository root, found by walking up from the test output to <c>VolcanicAgenticCore.slnx</c>.</summary>
private static string RepoFile(string name)
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "VolcanicAgenticCore.slnx"))) return Path.Combine(dir.FullName, name);
}
throw new InvalidOperationException("VolcanicAgenticCore.slnx not found above " + AppContext.BaseDirectory);
}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage"`
Expected: FAIL with `FileNotFoundException` for `...\Dockerfile`.
- [ ] **Step 3: Create `.gitattributes`, `.dockerignore` and `Dockerfile`**
`.gitattributes` (the machine has `core.autocrlf=true`; keep these LF everywhere):
```
Dockerfile text eol=lf
.dockerignore text eol=lf
docker-compose.yml text eol=lf
.env.example text eol=lf
```
`.dockerignore` (Windows `obj/project.assets.json` in the context would break the Linux restore; `.env` must never reach the build):
```
**/bin
**/obj
**/TestResults
**/*.user
**/Properties/launchSettings.json
.git
.vs
.idea
.claude
.volcanic
.env
.env.*
tests
samples
docs
*.md
docker-compose.yml
```
`Dockerfile`:
```dockerfile
# The Telegram host as one container. User guide: docs/docker.md.
# Spec: docs/superpowers/specs/2026-09-26-docker-deployment-design.md
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
WORKDIR /src
# Restore layer: only the project files the host needs, cached until one of them changes.
COPY Directory.Build.props VolcanicAgenticCore.slnx ./
COPY src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj src/Volcanic.AgenticCore.Abstractions/
COPY src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj src/Volcanic.AgenticCore/
COPY extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/
COPY extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/
COPY extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/
COPY extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj extensions/Volcanic.AgenticCore.Extensions.Mcp/
COPY extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj extensions/Volcanic.AgenticCore.Extensions.Scheduler/
COPY hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj hosts/Volcanic.AgenticCore.Telegram/
RUN dotnet restore hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj
COPY src/ src/
COPY extensions/ extensions/
COPY hosts/ hosts/
# Framework-dependent: claude starts the bridge as `dotnet <Bridge>.dll`, so the loose dll and its runtimeconfig must exist.
RUN dotnet publish hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj -c Release --no-restore -o /out \
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll \
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble
ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_MAJOR=24
ARG CLAUDE_CODE_VERSION=2.1
ARG INSTALL_PLAYWRIGHT=false
# Must equal the version in PlaywrightPreset.DefaultPackage (the single source of truth);
# PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage fails when they differ.
ARG PLAYWRIGHT_MCP_VERSION=0.0.82
# What the agent's bash tool can run, then Node from NodeSource (Ubuntu noble's nodejs is too old).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash git ripgrep python3 python-is-python3 python3-venv curl ca-certificates tzdata jq less procps openssh-client gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# The global prefix stays root-owned, so claude's auto-updater can't replace it at runtime: an upgrade changes what
# system/init validation sees and invalidates the prompt cache, so it only arrives with a rebuild.
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
&& claude --version \
&& npm cache clean --force
# Set even without Playwright, so an MCP server started with the host environment finds browsers here.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
# INSTALL_PLAYWRIGHT block (T2)
# The entrypoint hard-codes uid/gid 1654. Named volumes copy this ownership on first mount.
RUN test "$(id -u app)" = 1654 && test "$(id -g app)" = 1654 \
&& mkdir -p /data/workspaces /data/agent /data/claude /home/app \
&& chown -R app:app /data /home/app
COPY --from=build /out /app
WORKDIR /app
ENV Telegram__WorkspaceRoot=/data/workspaces \
VOLCANIC_AGENT_DIR=/data/agent \
CLAUDE_CONFIG_DIR=/data/claude \
HOME=/home/app \
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1 \
TZ=UTC \
DOTNET_EnableDiagnostics=0
# No USER: PID 1 (docker-init with compose's init: true) must stay root, or the agent could read every secret from
# /proc/1/environ. setpriv drops to app and execs dotnet, so Environment.ProcessPath is the muxer the bridge needs.
ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~PlaywrightPresetTests"`
Expected: all `PlaywrightPresetTests` PASS. Sanity-check the test bites: temporarily change the ARG to `0.0.81`, re-run, expect FAIL (`Expected: 0.0.82 Actual: 0.0.81`), change it back.
- [ ] **Step 5: Build the image without Playwright**
Run (allow ~10 min; network needed for base images, apt, NodeSource and npm):
`cd /d/Projects/Names/wt-docker-deployment && MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=false -t volcanic-agentic-core:test .`
Expected: success; the log shows the bridge `test -f` step and `claude --version` (2.1.x) passing. If `publish` drops the bridge runtimeconfig, fix the ClaudeCode csproj, not the Dockerfile (a local `dotnet publish` on Windows does produce `Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json`). If `dotnet restore` misses a project, check every `ProjectReference` again (host → core, ClaudeCode, GoogleCalendar, Mcp, Scheduler; all → Abstractions; ClaudeCode → Bridge).
- [ ] **Step 6: Image sanity checks (no secrets)**
Run the orchestrator's check as given (the entrypoint shell runs as root now; that is expected):
`MSYS_NO_PATHCONV=1 docker run --rm --entrypoint sh volcanic-agentic-core:test -c 'id; claude --version; test -f /app/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json && echo bridge-ok; echo $DOTNET_EnableDiagnostics'`
Expected: `uid=0(root)`, a `2.1.x` version, `bridge-ok`, `0`.
Run the same as the bot's user, plus the agent tools:
`MSYS_NO_PATHCONV=1 docker run --rm --entrypoint setpriv volcanic-agentic-core:test --reuid=1654 --regid=1654 --init-groups --inh-caps=-all -- sh -c 'id; echo HOME=$HOME; claude --version; git --version; rg --version | head -1; python --version; python3 -m venv /tmp/v && echo venv-ok; node --version; npx --version; jq --version; ssh -V; ps --version; less --version | head -1; date; ls -ld /home/app /data /data/*; touch /data/workspaces/x /data/agent/x /data/claude/x && echo data-writable'`
Expected: `uid=1654(app) gid=1654(app)`, `HOME=/home/app`, node `v24.*`, `venv-ok`, `/home/app` and `/data/*` owned by `app`, `data-writable`.
PID 1 check (the deviation above):
`MSYS_NO_PATHCONV=1 docker run --rm --init -e SECRET_PROBE=x --entrypoint setpriv volcanic-agentic-core:test --reuid=1654 --regid=1654 --init-groups --inh-caps=-all -- sh -c 'ps -o pid,user,comm -p 1; cat /proc/1/environ >/dev/null && echo READABLE || echo denied'`
Expected: PID 1 `root docker-init`, then `Permission denied` / `denied`.
Record each command and its result for the report.
- [ ] **Step 7: Edit the spec for the deviation**
In `docs/superpowers/specs/2026-09-26-docker-deployment-design.md`:
- Decisions table, `Entry` row → ``ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]``: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script |
- `User` row → The image's default user stays root so that PID 1 (compose's `init: true`, i.e. `docker-init`) is root-owned; the bot and everything it spawns run as the base image's non-root `app` user (1654, `$APP_UID`), `HOME=/home/app`. With `USER app`, `docker-init` runs as `app` and its `/proc/1/environ` (the whole container environment, secrets included) is readable by the agent's `bash` (checked on Docker 29) |
- Dockerfile §Build stage step 1: replace "(Abstractions, core, ClaudeCode, ClaudeCode.Bridge, Telegram, plus the Scheduler and GoogleCalendar extensions once the host references them)" with "(Abstractions, core, ClaudeCode, ClaudeCode.Bridge, GoogleCalendar, Mcp, Scheduler, Telegram)".
- Runtime step 5 → "`COPY --from=build /out /app`, `WORKDIR /app`. No `USER` (see the User row); an earlier step checks that `app` is uid/gid 1654, which the entrypoint hard-codes."
- Env table: add a row `HOME` | `/home/app` | The image user is root, so Docker would otherwise set `/root`.
- docker-compose §secrets bullet: "the bot (PID 1 in the container) makes itself non-dumpable" → "the bot (the only child of the root-owned PID 1) makes itself non-dumpable, and PID 1's own `/proc/1/environ` is root-only".
- First run §Fallback: `docker exec -it <container> claude auth login --claudeai` → `docker exec -u app -it <container> claude auth login --claudeai` (`docker exec` defaults to root, whose credentials the bot couldn't read).
- [ ] **Step 8: Build, full tests, commit**
Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet build && dotnet test` → green (only the 3 baseline failures).
```bash
cd /d/Projects/Names/wt-docker-deployment && git add Dockerfile .dockerignore .gitattributes tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs docs/superpowers/specs/2026-09-26-docker-deployment-design.md && git commit -m "feat(docker): Dockerfile for the Telegram host" -m "Two-stage build, agent tools, Node 24, claude CLI, data dirs owned by app, DOTNET_EnableDiagnostics=0. Adds the MCP spec's deferred test that the Dockerfile's PLAYWRIGHT_MCP_VERSION equals PlaywrightPreset.DefaultPackage.
Spec change: the image keeps root as its user and the entrypoint drops to app with setpriv. With USER app, compose's docker-init (PID 1) runs as app and its /proc/1/environ exposes every secret to the agent's bash."
```
---
### Task 2: Playwright in the image (`INSTALL_PLAYWRIGHT=true`)
**Files:**
- Modify: `Dockerfile` (replace the `# INSTALL_PLAYWRIGHT block (T2)` line)
**Interfaces:**
- Consumes: T1's `Dockerfile` (`ARG INSTALL_PLAYWRIGHT`, `ARG PLAYWRIGHT_MCP_VERSION=0.0.82`, `ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright`).
- Produces: `playwright-mcp` on PATH (`/usr/bin/playwright-mcp`), Chromium under `/opt/ms-playwright` readable by everyone, image `volcanic-agentic-core:test-pw` (kept until T6).
- [ ] **Step 1: Replace the placeholder comment with the install block**
```dockerfile
# Playwright MCP at the pinned version, and Chromium installed by that package's own playwright dependency, so browser
# build and library version match. Roughly +400 MB.
RUN if [ "$INSTALL_PLAYWRIGHT" = "true" ]; then \
npm install -g "@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}" \
&& pw="$(npm root -g)/@playwright/mcp/node_modules/playwright/cli.js" \
&& test -f "$pw" \
&& node "$pw" install --with-deps chromium \
&& chmod -R a+rX "$PLAYWRIGHT_BROWSERS_PATH" \
&& playwright-mcp --version \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*; \
fi
```
If `test -f "$pw"` fails, list `$(npm root -g)/@playwright/mcp/node_modules` in a throwaway build step and point `pw` at the `playwright` package that `@playwright/mcp` itself resolves (never a separately installed `playwright`). If `playwright-mcp --version` isn't supported by 0.0.82, replace it with `npm ls -g @playwright/mcp` and grep for `@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`, and note that in the report.
- [ ] **Step 2: Test stays green**
Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~PlaywrightPresetTests"` → PASS (the ARG line is untouched and still unique).
- [ ] **Step 3: Build both variants**
Run: `MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=false -t volcanic-agentic-core:test .` (fast; confirms the `if` is a no-op).
Run (background or with a long timeout; up to ~20 min): `MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core:test-pw .`
Expected: both succeed; the second prints `0.0.82` from `playwright-mcp --version`. Record `docker image ls volcanic-agentic-core` sizes.
- [ ] **Step 4: Offline checks as the bot's user**
`MSYS_NO_PATHCONV=1 docker run --rm --network none --entrypoint setpriv volcanic-agentic-core:test-pw --reuid=1654 --regid=1654 --init-groups --inh-caps=-all -- sh -c 'command -v playwright-mcp; playwright-mcp --version; ls /opt/ms-playwright'`
Expected: `/usr/bin/playwright-mcp`, `0.0.82`, a `chromium-*` directory (and `chromium_headless_shell-*` if this Playwright version installs it).
Headless Chromium launch as uid 1654 with `--no-sandbox`, offline:
`MSYS_NO_PATHCONV=1 docker run --rm --network none --entrypoint setpriv volcanic-agentic-core:test-pw --reuid=1654 --regid=1654 --init-groups --inh-caps=-all -- node -e 'const { chromium } = require("/usr/lib/node_modules/@playwright/mcp/node_modules/playwright"); (async () => { const b = await chromium.launch({ args: ["--no-sandbox"] }); const p = await b.newPage(); await p.setContent("<h1>ok</h1>"); console.log(await p.textContent("h1")); await b.close(); })().catch(e => { console.error(e); process.exit(1); });'`
Expected: `ok`. (Adjust the `require` path to what Step 1 found.)
- [ ] **Step 5: Host startup with Playwright enabled, offline**
`MSYS_NO_PATHCONV=1 docker run -d --name volcanic-probe-pw --init --network none -e TELEGRAM_BOT_TOKEN=123:fake -e TELEGRAM_ALLOWED_USER_IDS=1 -e PLAYWRIGHT_ENABLED=true -e PLAYWRIGHT_COMMAND=playwright-mcp -e PLAYWRIGHT_EXTRA_ARGS=--no-sandbox volcanic-agentic-core:test-pw`, wait ~15 s, `docker logs volcanic-probe-pw`.
Expected: `MCP: playwright enabled (headless, chromium)` and the other startup lines from Global Constraints; afterwards `Telegram unreachable (attempt n)` warnings (no network), no crash. Then `docker rm -f volcanic-probe-pw`.
- [ ] **Step 6: Commit**
```bash
cd /d/Projects/Names/wt-docker-deployment && git add Dockerfile && git commit -m "feat(docker): optional Playwright MCP and Chromium in the image"
```
---
### Task 3: docker-compose.yml, .env.example and a real host start
**Files:**
- Create: `docker-compose.yml`, `.env.example`
- Modify: `.gitignore` (add `.env`)
**Interfaces:**
- Consumes: T1's image contract (entrypoint, `/data/*` volumes, image env).
- Produces: compose service `bot`, volumes `workspaces`, `agent`, `claude`; the variable names that T4's `docs/docker.md` documents.
- [ ] **Step 1: `.gitignore`**
Append:
```
# Local compose configuration (secrets)
.env
```
- [ ] **Step 2: `docker-compose.yml`** (the spec's file verbatim, plus one comment on `init`)
```yaml
services:
bot:
build:
context: .
args:
INSTALL_PLAYWRIGHT: ${INSTALL_PLAYWRIGHT:-true}
CLAUDE_CODE_VERSION: ${CLAUDE_CODE_VERSION:-2.1}
restart: unless-stopped
# dotnet does not reap orphans; killed `claude` trees and bash tool children would pile up as zombies.
# docker-init runs as root (the image user), so the agent can't read its /proc/1/environ.
init: true
# Chromium crashes with Docker's 64 MB /dev/shm.
shm_size: 1gb
stop_grace_period: 30s
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_ALLOWED_USER_IDS=${TELEGRAM_ALLOWED_USER_IDS}
- Telegram__Model=${TELEGRAM_MODEL:-claude-code/claude-opus-5-5}
- TZ=${TZ:-UTC}
- Logging__LogLevel__Default=${LOG_LEVEL:-Information}
# Optional features; empty means off / default.
- OPENAI_API_KEY=${OPENAI_API_KEY:-} # speech to text (attachments spec)
- STT_MODEL=${STT_MODEL:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec)
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ
- PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN
# Playwright MCP preset (MCP spec). Enabled when the image has it; the preinstalled binary, no npx download.
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}
- PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container
- PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-}
- PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox}
- PLAYWRIGHT_COMMAND=${PLAYWRIGHT_COMMAND:-playwright-mcp}
volumes:
- workspaces:/data/workspaces
- agent:/data/agent
- claude:/data/claude
volumes:
workspaces:
agent:
claude:
```
No `ports`, `expose`, `user:`, domain or `SERVICE_FQDN_*`; no `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_USE_*` entries.
- [ ] **Step 3: `.env.example`** (comments on their own lines; every value empty or a default)
```dotenv
# Copy to .env for `docker compose up -d --build`. On Coolify, set these as the resource's environment variables.
# Never add ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN,
# CLAUDE_CODE_USE_BEDROCK or CLAUDE_CODE_USE_VERTEX: the Claude Code provider is subscription-only and refuses to run.
# Required. Allowed Telegram user ids, comma separated; the first one is the owner (re-login prompts, "Online").
TELEGRAM_BOT_TOKEN=
TELEGRAM_ALLOWED_USER_IDS=
# Model every chat uses, log level, container time zone.
TELEGRAM_MODEL=claude-code/claude-opus-5-5
LOG_LEVEL=Information
TZ=UTC
# Build: Playwright MCP + Chromium in the image (~+400 MB); Claude Code version (2.1 = newest 2.1.x at build time).
INSTALL_PLAYWRIGHT=true
CLAUDE_CODE_VERSION=2.1
# Speech to text for voice notes (empty = off).
OPENAI_API_KEY=
STT_MODEL=
# Google Calendar (both set = on; each chat connects its own account with /calendar_connect).
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Default time zone for chats that never ran /timezone (IANA name, e.g. Europe/Berlin; empty = TZ).
DEFAULT_TIMEZONE=
# Deny-listed variables the agent's processes may see anyway, comma separated (e.g. GH_TOKEN).
PROCESS_ENV_ALLOW=
# Playwright MCP preset; empty = compose defaults (enabled if built in, headless, chromium, --no-sandbox, playwright-mcp).
# An override of PLAYWRIGHT_EXTRA_ARGS must keep --no-sandbox.
PLAYWRIGHT_ENABLED=
PLAYWRIGHT_HEADLESS=
PLAYWRIGHT_BROWSER=
PLAYWRIGHT_EXTRA_ARGS=
PLAYWRIGHT_COMMAND=
```
- [ ] **Step 4: `docker compose config` with a dummy `.env`**
Create `.env` (never committed; `git status` must not list it):
```dotenv
TELEGRAM_BOT_TOKEN=123:fake
TELEGRAM_ALLOWED_USER_IDS=1
INSTALL_PLAYWRIGHT=false
```
Run: `cd /d/Projects/Names/wt-docker-deployment && docker compose -p volcanic-test config`
Expected: valid output with `INSTALL_PLAYWRIGHT: "false"` build arg, `PLAYWRIGHT_ENABLED: "false"` (nested default follows the build arg), `PLAYWRIGHT_EXTRA_ARGS: --no-sandbox`, `PLAYWRIGHT_COMMAND: playwright-mcp`, `Telegram__Model: claude-code/claude-opus-5-5`, `init: true`, `shm_size`, three named volumes, no `ports`. Also run it once with `.env.example` copied to `.env` plus the two required values, and check `PLAYWRIGHT_ENABLED: "true"`.
- [ ] **Step 5: Real host start through compose (fake token)**
Keep the dummy `.env` (`INSTALL_PLAYWRIGHT=false`). Run `docker compose -p volcanic-test up -d --build`, wait ~20 s, then `docker compose -p volcanic-test logs bot` and `docker compose -p volcanic-test ps -a`.
Expected in the log: the startup lines from Global Constraints (`Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5`, `Allowed users: 1. Workspaces under /data/workspaces`, `Process environment: 15 deny rules, allow: none`, `MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)`), no diagnostic-socket warning, then a `401`/unauthorized failure and exit code 1; `restart: unless-stopped` restarts it (visible restart loop). Nothing else may fail.
Then: `docker compose -p volcanic-test down -v --rmi local` (removes only this project's container, its three `volcanic-test_*` volumes and the `volcanic-test-bot` image).
- [ ] **Step 6: Running-bot probe offline (`/proc` and diagnostics checks)**
`MSYS_NO_PATHCONV=1 docker run -d --name volcanic-probe --init --network none -e TELEGRAM_BOT_TOKEN=123:fake -e TELEGRAM_ALLOWED_USER_IDS=1 -e OPENAI_API_KEY=sk-fake-probe-key volcanic-agentic-core:test`, wait ~10 s (no network → the poller backs off and keeps running), then:
`MSYS_NO_PATHCONV=1 docker exec -u app volcanic-probe sh -c 'id; ps -o pid,user,comm -A; pid=$(pgrep -f Volcanic.AgenticCore.Telegram.dll); echo bot=$pid; cat /proc/1/environ >/dev/null 2>&1 && echo pid1-READABLE || echo pid1-denied; cat /proc/$pid/environ >/dev/null 2>&1 && echo bot-READABLE || echo bot-denied; ls /tmp/dotnet-diagnostic-* 2>/dev/null || echo no-diagnostic-socket'`
Expected: `uid=1654(app)`; PID 1 `docker-init` as `root`, the bot's `dotnet` as `app`; `pid1-denied`; `bot-denied`; `no-diagnostic-socket`. `docker logs volcanic-probe` shows the startup lines, `Speech to text: gpt-4o-transcribe`, and `Telegram unreachable` warnings. (The env filter itself, i.e. `TELEGRAM_BOT_TOKEN`/`OPENAI_API_KEY` missing from the agent's `bash`, needs a real chat; `docker exec` doesn't go through the spawn policy. Put it in `openIssues`.)
Then `docker rm -f volcanic-probe`, and delete the dummy `.env`.
- [ ] **Step 7: Commit**
```bash
cd /d/Projects/Names/wt-docker-deployment && git status --short && git add docker-compose.yml .env.example .gitignore && git commit -m "feat(docker): compose file and .env.example for Coolify"
```
---
### Task 4: `docs/docker.md`
**Files:**
- Create: `docs/docker.md`
**Interfaces:**
- Consumes: T1–T3's file names, build args, variable names, volume names, the `setpriv`/root PID 1 design.
- Produces: `docs/docker.md` with the anchors `#configuration`, `#first-run-and-claude-login`, `#troubleshooting` that T5 links to.
- [ ] **Step 1: Write the doc**
Sections and required content (plain prose, short; use the unslop skill's rules: no filler, no "robust/seamless"):
1. `# Running the Telegram bot in Docker` intro: one container from the repo's `Dockerfile` + `docker-compose.yml`; Coolify or any Docker host; long polling, so no ports and no domain. Local run: `cp .env.example .env`, fill the two required values, `docker compose up -d --build`. `dotnet run` (see `telegram-bot.md`) stays the development path.
2. `## What's in the image`: .NET 10 runtime + the published host; agent tools (the apt list); Node 24 (NodeSource); `claude` (`@anthropic-ai/claude-code`); optionally `@playwright/mcp` 0.0.82 + Chromium under `/opt/ms-playwright`. No .NET SDK. The bot runs as `app` (uid 1654); PID 1 (`docker-init`) stays root so the agent can't read the container's environment from `/proc/1/environ`. Don't set `user:` in compose (`setpriv` needs root to switch users).
3. `## Build arguments` table: `INSTALL_PLAYWRIGHT` (Dockerfile `false`, compose `true`), `CLAUDE_CODE_VERSION` (`2.1`; exact version to pin), `NODE_MAJOR` (`24`), `PLAYWRIGHT_MCP_VERSION` (`0.0.82`; must equal `PlaywrightPreset.DefaultPackage`, a unit test checks it; compose doesn't override it).
4. `## Configuration`: environment only. Table `Variable | Default | Purpose | Agent's processes see it` with rows: `TELEGRAM_BOT_TOKEN` (required; no, redacted), `TELEGRAM_ALLOWED_USER_IDS` (required, comma separated, first = owner; yes), `TELEGRAM_MODEL` → `Telegram__Model` (`claude-code/claude-opus-5-5`), `TZ` (`UTC`), `LOG_LEVEL` → `Logging__LogLevel__Default` (`Information`), `OPENAI_API_KEY` (empty = speech to text off; no, redacted), `STT_MODEL` (`gpt-4o-transcribe`), `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` (both = calendar on; secret: no, redacted), `DEFAULT_TIMEZONE` (empty = `TZ`), `PROCESS_ENV_ALLOW`, `PLAYWRIGHT_ENABLED` (follows `INSTALL_PLAYWRIGHT`), `PLAYWRIGHT_HEADLESS` (`true`), `PLAYWRIGHT_BROWSER` (`chromium`), `PLAYWRIGHT_EXTRA_ARGS` (`--no-sandbox`, keep it when overriding), `PLAYWRIGHT_COMMAND` (`playwright-mcp`). Then: what the agent can't see: the default deny rules (`*_API_KEY`, `*_APIKEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` and their `Section__Key` forms, plus the named secrets), any variable whose value is a configured secret, and `[REDACTED]` in tool output; `PROCESS_ENV_ALLOW=GH_TOKEN` lets one through (an explicit name on the deny list, such as `TELEGRAM_BOT_TOKEN`, can't be allowed). Other settings: any `Section__Key` (`Telegram__ReconnectMaxDelay=00:02:00`, `Scheduler__*`, `GoogleCalendar__*`, `ProcessEnvironment__*`, `Telegram__Speech__*`, `Telegram__InputDebounce`, `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS`/`..._TOTAL_TIMEOUT_MS`) added in Coolify and to `environment:` in the compose file. Set in the image, normally left alone: `Telegram__WorkspaceRoot=/data/workspaces`, `VOLCANIC_AGENT_DIR=/data/agent`, `CLAUDE_CONFIG_DIR=/data/claude`, `DOTNET_EnableDiagnostics=0` (unset it, with `ProcessEnvironment__HardenHostProcess=false`, only to attach `dotnet-dump`/`dotnet-trace`).
A warning block: **Never set `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`.** The provider is subscription-only and refuses to run. In Coolify, check shared/team variables too.
5. `## Volumes`: `workspaces` → `/data/workspaces` (one directory per chat, `AGENTS.md`/`CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/`, `browser/`, project-level `.volcanic/extensions/`), `agent` → `/data/agent` (sessions, `settings.json`, `extensions/*.dll`, chat settings, `scheduler/jobs.json`, `google-calendar/` tokens), `claude` → `/data/claude` (Claude login). Named volumes survive redeploys; Coolify prefixes them with the resource uuid and deletes them only with the resource's volumes. Bind mounts work if the host directories are owned by uid 1654 (`chown -R 1654:1654 <dir>`). `HOME` (`~/.gitconfig`, `~/.ssh`, npm cache) isn't persisted.
6. `## Deploying on Coolify`: new resource from this repo, build pack *Docker Compose*, set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ALLOWED_USER_IDS` (owner first) plus any optional ones, no domain, deploy. The log shows `Using Claude Code at /usr/bin/claude, ...` and `Allowed users: ...`. No healthcheck: the bot serves no HTTP; lost connectivity backs off, a `401` exits 1 and invalid config exits 2, which `restart: unless-stopped` turns into a visible restart loop.
7. `## First run and Claude login`: owner sends `/start`, then `/claude-code-login`; the link and code prompt arrive as one message; paste the code; credentials land in `/data/claude`; `/claude-code-doctor` should report logged in, `claude.ai`, bridge ok. Later expiries use the owner re-login prompt. Fallback: `docker exec -u app -it <container> claude auth login --claudeai` (`-u app` matters: `docker exec` defaults to root and root-owned credentials are unreadable by the bot).
8. `## Updating`: redeploy (rebuild) to get the newest `claude` 2.1.x; the CLI can't update itself in place. Set `CLAUDE_CODE_VERSION` to an exact version to pin. Bumping Playwright means changing `PlaywrightPreset.DefaultPackage` and the Dockerfile ARG in one commit.
9. `## Extensions and project context`: `docker cp my.dll <container>:/data/agent/extensions/` + restart (built against the same `Volcanic.AgenticCore.Abstractions` version), or `/data/workspaces/<chatId>/.volcanic/extensions/`. Context files live in the chat workspace.
10. `## Troubleshooting`: `[executable_missing]` → image built without `claude`? rebuild, check `claude --version` in the build log; `[auth]` → `/claude-code-login` (or the `docker exec -u app` fallback); `[mcp_startup]` → bridge files missing from `/app` (`Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll` + `.runtimeconfig.json`; the build guard should have caught it); `Permission denied` under `/data` with bind mounts → `chown -R 1654:1654`; `409 Conflict` in the log → another instance or a webhook polls the same bot token; browser errors → `INSTALL_PLAYWRIGHT=true` build, `shm_size`, `--no-sandbox` kept.
11. `## Security`: short; the container is the isolation boundary; the process environment hook, non-dumpable bot, root PID 1, redaction; link to `telegram-bot.md#security`.
- [ ] **Step 2: Check names against the code and the compose file**
Run: `cd /d/Projects/Names/wt-docker-deployment && grep -o '[A-Z][A-Z_]*[A-Z]' docs/docker.md | sort -u` and confirm every variable name exists in `docker-compose.yml`, the `Dockerfile`, or the Global Constraints list; fix typos.
- [ ] **Step 3: Commit**
```bash
cd /d/Projects/Names/wt-docker-deployment && git add docs/docker.md && git commit -m "docs(docker): Docker and Coolify deployment guide"
```
---
### Task 5: Cross-links and the sibling specs
**Files:**
- Modify: `README.md`, `docs/telegram-bot.md`, `docs/mcp.md`, `docs/superpowers/specs/2026-09-25-telegram-host-design.md`, `docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md`
**Interfaces:**
- Consumes: `docs/docker.md` from T4.
- Produces: nothing code-facing.
- [ ] **Step 1: `README.md`**
After the Quick start code block (right after the line `TELEGRAM_BOT_TOKEN=123:abc ... dotnet run --project hosts/Volcanic.AgenticCore.Telegram` and the closing fence), add:
```markdown
To run the bot in a container (Coolify or any Docker host): `cp .env.example .env`, fill in the two required values,
`docker compose up -d --build`. See [docs/docker.md](docs/docker.md).
```
In `## Documentation`, after the Telegram bot line, add: `- [Docker and Coolify](docs/docker.md): the container image, configuration, volumes, first login.`
- [ ] **Step 2: `docs/telegram-bot.md`**
In `## Security`, first paragraph: replace "Run the bot as a dedicated user or in a container, and treat the workspace root as writable by anyone on the allow-list." with "Run it in the container image ([docker.md](docker.md)), which is the recommended setup, or at least as a dedicated user, and treat the workspace root as writable by anyone on the allow-list."
Replace "Set `DOTNET_EnableDiagnostics=0` in the bot's environment too (a container image should set it): otherwise" with "Set `DOTNET_EnableDiagnostics=0` in the bot's environment too (the Docker image, [docker.md](docker.md), sets it): otherwise".
- [ ] **Step 3: `docs/mcp.md`**
Replace "The Docker image (`docker.md`, when the docker-deployment spec lands) preinstalls both" with "The Docker image ([docker.md](docker.md), built with `INSTALL_PLAYWRIGHT=true`) preinstalls both".
- [ ] **Step 4: Sibling specs**
- `2026-09-25-telegram-host-design.md` line 23: "Out of scope: webhooks, Docker/Coolify, voice and ..." → "Out of scope: webhooks, Docker/Coolify (see `2026-09-26-docker-deployment-design.md`), voice and ...".
- `2026-09-26-mcp-client-playwright-design.md` line 4: `Status: implemented (the Dockerfile version test arrives with 2026-09-26-docker-deployment-design.md)` → `Status: implemented (the Dockerfile version test, PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage, landed with 2026-09-26-docker-deployment-design.md)`. In the Testing bullet (around line 395), replace "(deferred: the Dockerfile and that test come with the docker-deployment spec)" with "(`Dockerfile_version_matches_DefaultPackage`, added with the docker-deployment spec)".
- [ ] **Step 5: Verify links and commit**
Run: `cd /d/Projects/Names/wt-docker-deployment && grep -n "docker.md" README.md docs/*.md` (each link target exists) and `grep -rn "a container image should set\|when the docker-deployment spec lands\|deferred: the Dockerfile" docs README.md` → no hits.
```bash
cd /d/Projects/Names/wt-docker-deployment && git add README.md docs/telegram-bot.md docs/mcp.md docs/superpowers/specs/2026-09-25-telegram-host-design.md docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md && git commit -m "docs(docker): link the container setup from the README, bot and MCP docs"
```
---
### Task 6: Final verification, spec status and cleanup
**Files:**
- Modify: `docs/superpowers/specs/2026-09-26-docker-deployment-design.md` (Status line, Verification section)
**Interfaces:**
- Consumes: everything above.
- Produces: the verification record for the report.
- [ ] **Step 1: Full .NET build and tests**
Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet build && dotnet test` → green (only the 3 baseline failures), including `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage`.
- [ ] **Step 2: Rebuild both images from the final tree**
`MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=false -t volcanic-agentic-core:test .` and `MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core:test-pw .` (mostly cached). Re-run T1 Step 6's three checks against `:test` and T2 Step 4's `playwright-mcp --version` against `:test-pw`. Any failure → fix in the owning file, commit as `fix(docker): ...`.
- [ ] **Step 3: Spec Status and Verification**
In `2026-09-26-docker-deployment-design.md`: `Status: draft.` → `Status: implemented (plan: docs/superpowers/plans/2026-09-26-docker-deployment.md).` Replace the Verification intro "No unit tests (nothing in C# changes). Manual checklist in the implementation plan:" with "One unit test (`PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage`); no C# behaviour changes. Checked during implementation without a real bot: both image variants build; bridge guard, `claude --version`, the agent tools, uid 1654, `/data` ownership; root-owned PID 1 whose `/proc/1/environ` and the bot's are unreadable to `app`; no `dotnet-diagnostic-*` socket; `playwright-mcp --version` offline and a headless Chromium launch as `app`; `docker compose config`; host startup log and the `401` → exit 1 path. The rest of this list needs a real bot and stays a manual checklist:" and keep items 1–5.
- [ ] **Step 4: Cleanup (only what this plan created)**
```bash
cd /d/Projects/Names/wt-docker-deployment && docker rm -f volcanic-probe volcanic-probe-pw 2>/dev/null; docker compose -p volcanic-test down -v --rmi local 2>/dev/null; docker rmi volcanic-agentic-core:test volcanic-agentic-core:test-pw; docker volume ls --filter name=volcanic-test; docker image ls volcanic-agentic-core; rm -f .env; git status --short
```
Expected: no `volcanic-test_*` volumes, no `volcanic-agentic-core` images, no `.env`, a clean tree. Do not prune build cache or other images (base images pulled by the build may stay; list them in the report).
- [ ] **Step 5: Commit**
```bash
cd /d/Projects/Names/wt-docker-deployment && git add docs/superpowers/specs/2026-09-26-docker-deployment-design.md && git commit -m "docs(docker): mark the deployment spec implemented"
```
Report every verification step with its result. `openIssues` must list at least: the chat-driven checks (Verification 2 env/`date`/`/timezone`, `/claude-code-login` + `/claude-code-doctor` green, Verification 3 "tell me the H1" in a chat, Verification 4 redeploy with a logged-in bot, Verification 5 `ANTHROPIC_API_KEY` refusal), a real Coolify deploy, and anything from T1–T3 that could not be checked.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@ Chat with the agent through a Telegram bot. A new host project in this repo, run
| Access | Mandatory user-id allow-list; empty list = refuse to start; other users are ignored silently |
| Chats | Private chats only |
Out of scope: webhooks, Docker/Coolify, voice and documents, group chats, several bot tokens, a custom polling loop.
Out of scope: webhooks, Docker/Coolify (see `2026-09-26-docker-deployment-design.md`), voice and documents, group chats, several bot tokens, a custom polling loop.
## Configuration

View File

@@ -1,6 +1,6 @@
# Docker deployment (Coolify) — design
Date: 2026-09-26. Status: draft.
Date: 2026-09-26. Status: implemented (plan: docs/superpowers/plans/2026-09-26-docker-deployment.md).
Depends on: `2026-09-26-process-environment-hook-design.md` (the secrets below stay out of agent-spawned processes),
`2026-09-26-scheduler-design.md` §7 (the default time zone).
@@ -21,12 +21,12 @@ build and to the Claude Code provider's subscription-only rules.
| Files | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.env.example` at the repo root; user doc `docs/docker.md` |
| Build | Multi-stage: `mcr.microsoft.com/dotnet/sdk:10.0-noble` → `mcr.microsoft.com/dotnet/runtime:10.0-noble` |
| Publish | Framework-dependent `dotnet publish -c Release` of the Telegram host only. No self-contained, no AOT, no single-file: `claude` launches the bridge as `dotnet <Bridge>.dll`, so the `dotnet` muxer and a loose `Bridge.dll` + `.runtimeconfig.json` must exist |
| Entry | `ENTRYPOINT ["dotnet", "Volcanic.AgenticCore.Telegram.dll"]`, so `Environment.ProcessPath` is the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script |
| Entry | `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--bounding-set=-all", "--no-new-privs", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script |
| Agent tools | `bash`, `git`, `ripgrep`, `python3` (+ `python-is-python3`, `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client` |
| Node | Node.js 24 from NodeSource (`ARG NODE_MAJOR=24`); Ubuntu noble's `nodejs` is too old |
| Claude CLI | `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, `ARG CLAUDE_CODE_VERSION=2.1` (newest 2.1.x at build time; set an exact version to pin). Updated by rebuilding, never in place |
| Playwright | `ARG INSTALL_PLAYWRIGHT=false` in the Dockerfile; compose passes `true`. Installs `@playwright/mcp` globally at the version pinned by the MCP spec, and Chromium + OS deps under `/opt/ms-playwright`. Compose points the preset at the preinstalled `playwright-mcp` (no npx download) and adds `--no-sandbox` |
| User | The runtime image's built-in non-root `app` user (`$APP_UID`, 1654), `HOME=/home/app` |
| User | The image's default user stays root so that PID 1 (compose's `init: true`, i.e. `docker-init`) is root-owned; the bot and everything it spawns run as the base image's non-root `app` user (1654, `$APP_UID`), `HOME=/home/app`. With `USER app`, `docker-init` runs as `app` and its `/proc/1/environ` (the whole container environment, secrets included) is readable by the agent's `bash` (checked on Docker 29) |
| Data | Three named volumes under `/data`: `workspaces`, `agent`, `claude` |
| Config | Environment only, set in Coolify. Required: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_IDS`. Optional: `OPENAI_API_KEY`, `STT_MODEL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `DEFAULT_TIMEZONE`, `PROCESS_ENV_ALLOW`, `TZ`, `PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` |
| Healthcheck | None (see below) |
@@ -42,8 +42,7 @@ the image build.
Build stage (`sdk:10.0-noble`):
1. `WORKDIR /src`. Copy `Directory.Build.props`, `VolcanicAgenticCore.slnx` and only the `*.csproj` files the host
needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, Telegram, plus the Scheduler and GoogleCalendar
extensions once the host references them), then
needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, GoogleCalendar, Mcp, Scheduler, Telegram), then
`dotnet restore hosts/Volcanic.AgenticCore.Telegram` — a cached layer until a project file changes.
2. Copy `src/`, `extensions/`, `hosts/` and `dotnet publish hosts/Volcanic.AgenticCore.Telegram -c Release
--no-restore -o /out`. `TreatWarningsAsErrors` applies, so a warning fails the image build as it fails `dotnet build`.
@@ -67,15 +66,17 @@ Runtime stage (`runtime:10.0-noble`), as root:
process inherits the environment. `ARG PLAYWRIGHT_MCP_VERSION` defaults to the exact version in the MCP spec's
`PlaywrightPreset.DefaultPackage`, which is the single source of truth. A unit test in that spec fails when the two
differ, so bumping the version means changing both in one commit. Compose does not override this arg.
4. `mkdir -p /data/workspaces /data/agent /data/claude && chown -R app:app /data`. Named volumes copy this
ownership on first mount, which is what lets a non-root process write to them.
5. `COPY --from=build /out /app`, `WORKDIR /app`, `USER $APP_UID`.
4. `mkdir -p /data/workspaces /data/agent /data/claude /home/app && chown -R app:app /data /home/app`. Named volumes
copy this ownership on first mount, which is what lets a non-root process write to them.
5. `COPY --from=build /out /app`, `WORKDIR /app`. No `USER` (see the User row); an earlier step checks that `app` is
uid/gid 1654, which the entrypoint hard-codes.
6. Environment baked into the image (compose and Coolify may override):
| Variable | Value | Why |
|---|---|---|
| `Telegram__WorkspaceRoot` | `/data/workspaces` | Chat `<id>` works in `/data/workspaces/<id>` |
| `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, `extensions/*.dll`, `settings.json`, `auth.json` |
| `HOME` | `/home/app` | The image user is root, so Docker would otherwise set `/root` |
| `CLAUDE_CONFIG_DIR` | `/data/claude` | Moves both `~/.claude/` and `~/.claude.json` (credentials and onboarding state) into one volume. Already on the provider's env allow-list, so every `claude` child and `claude auth login` see it |
| `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | Quiet |
| `TZ` | `UTC` | Overridable |
@@ -112,8 +113,9 @@ services:
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ
- PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN
# Playwright MCP preset (MCP spec). Enabled when the image has it; the preinstalled binary, no npx download.
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}
# Playwright MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an
# image built with INSTALL_PLAYWRIGHT=false (no nested default: Coolify parses these expressions itself).
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-true}
- PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container
- PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-}
- PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox}
@@ -153,17 +155,29 @@ volumes:
`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` or their `Section__Key` forms) are removed from the environment of
the agent's `bash`, scheduled scripts and MCP servers by the process environment hook. To give the agent a
credential on purpose (say `GH_TOKEN` for `gh`), add it in Coolify and list its name in `PROCESS_ENV_ALLOW`; an MCP
server gets one through its own `env` entry instead. On top of that, the bot (PID 1 in the container) makes itself
non-dumpable at startup, so the agent can't read `/proc/1/environ` or attach to it, and the configured secrets'
values are replaced by `[REDACTED]` in tool output (process-environment-hook spec §8–§9). The side effect is no
core dumps and no `dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off
for a debugging session. The container stays the real isolation boundary: none of this is a sandbox.
server gets one through its own `env` entry instead. On top of that, the bot (the only child of the root-owned
PID 1) makes itself non-dumpable at startup, so the agent can't read its `/proc/<pid>/environ` or attach to it,
PID 1's own `/proc/1/environ` is root-only, and the configured secrets' values are replaced by `[REDACTED]` in
tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no
`dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off for a debugging
session. The entrypoint also gives the bot an empty capability bounding set and `no_new_privs`, so nothing it
spawns regains root through the base image's setuid binaries (`su`, `mount`, `passwd`, ...).
All of this keeps secrets out of the agent's environment and tool output by accident; it does **not** stop an agent
that goes after them on purpose. `app` owns `/data/workspaces` and `/data/agent`, extensions are discovered on every
session creation, and MCP servers expand `${env:NAME}` from the host's unfiltered environment, so the agent can drop
a DLL into `<workspace>/.volcanic/extensions/` (loaded into the bot at the next `/new`) or add an MCP server with
`env: {"X": "${env:TELEGRAM_BOT_TOKEN}"}` to `/data/agent/settings.json` (loaded after it kills the same-uid bot
and `restart: unless-stopped` brings it back). Anyone on the allow-list must be treated as able to obtain every
secret configured here. The container stays the real isolation boundary: none of this is a sandbox (open question 5).
- Playwright: `PLAYWRIGHT_COMMAND=playwright-mcp` runs the globally installed, version-pinned server, so the first
browser call in a chat doesn't wait on an npx download. `PLAYWRIGHT_EXTRA_ARGS` defaults to `--no-sandbox`:
Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root `app` user,
so the container is the isolation boundary. Overriding `PLAYWRIGHT_EXTRA_ARGS` must keep `--no-sandbox`.
`PLAYWRIGHT_ENABLED` follows `INSTALL_PLAYWRIGHT` (nested default, supported by Compose v2), so an image built
without Playwright doesn't advertise tools it can't run.
`PLAYWRIGHT_ENABLED` defaults to `true`, so an image built with `INSTALL_PLAYWRIGHT=false` also needs
`PLAYWRIGHT_ENABLED=false` (otherwise the `playwright` server is logged as unavailable and its tools are missing).
It doesn't follow `INSTALL_PLAYWRIGHT` through a nested default (`${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}`):
Compose v2 supports that, but Coolify parses these expressions itself to build its variable UI, and a value cut at
the first `}` would make the host exit 2 in a restart loop.
- The owner (first id in `TELEGRAM_ALLOWED_USER_IDS`) gets the `🟢 Online.` message and the Claude Code re-login
prompts. The calendar has no owner special case: every chat connects its own account.
- Coolify prefixes named volumes with the resource uuid; they survive redeploys and are only removed when the
@@ -190,9 +204,12 @@ shows the service as running without health status; that is acceptable.
`/data/claude`. `/claude-code-doctor` should then report logged in, `claude.ai`, bridge ok.
4. Later expiries use the existing owner re-login flow; nothing Docker-specific.
Fallback without the bot: `docker exec -it <container> claude auth login --claudeai` (runs as `app`, same
`CLAUDE_CONFIG_DIR`). Copying an existing `.credentials.json` into the `claude` volume also works but is not the
documented path.
Fallback without the bot: `docker exec -u app -it <container> env -i HOME=/home/app
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm claude auth
login --claudeai` (`docker exec` defaults to root, whose credentials the bot couldn't read). `env -i` matters: a
`docker exec` process gets the whole container environment, secrets included, and an ordinary (dumpable) `app` process
has its `/proc/<pid>/environ` readable by the agent's `bash` and watcher scripts for as long as it runs. Copying an existing `.credentials.json` into
the `claude` volume also works but is not the documented path.
## Extensions and project context
@@ -213,7 +230,12 @@ documented path.
## Verification
No unit tests (nothing in C# changes). Manual checklist in the implementation plan:
Two unit tests (`PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` and `DockerComposeTests.Compose_model_fallback_matches_TelegramOptions_DefaultModel`); no C# behaviour changes. Checked
during implementation without a real bot: both image variants build; bridge guard, `claude --version`, the agent
tools, uid 1654, `/data` ownership; root-owned PID 1 whose `/proc/1/environ` and the bot's are unreadable to `app`;
no `dotnet-diagnostic-*` socket; `playwright-mcp --version` offline and a headless Chromium launch as `app`;
`docker compose config`; host startup log and the `401` → exit 1 path. The rest of this list needs a real bot and
stays a manual checklist:
1. `docker compose build` succeeds with `INSTALL_PLAYWRIGHT=false` and `true`; the bridge guard and `claude --version` pass.
2. `docker compose up` with a real bot: allowed user gets answers; `/claude-code-login` then `/claude-code-doctor`
@@ -225,13 +247,45 @@ No unit tests (nothing in C# changes). Manual checklist in the implementation pl
4. `docker compose down && docker compose up -d --build`: previous chat resumes, no re-login needed, workspace files remain.
5. Setting `ANTHROPIC_API_KEY` in compose makes the provider refuse with its documented error (then remove it).
Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containers):
- `dotnet build`: 0 warnings, 0 errors. `dotnet test`: all green except the three Windows-only baseline failures in
`Volcanic.AgenticCore.Tests`. Changing the Dockerfile's `PLAYWRIGHT_MCP_VERSION` to `0.0.81` makes
`Dockerfile_version_matches_DefaultPackage` fail (`Expected "0.0.82", Actual "0.0.81"`).
- `docker build --build-arg INSTALL_PLAYWRIGHT=false` and `=true` both succeed. `claude --version` → `2.1.283 (Claude
Code)`; bridge `runtimeconfig.json` present; `DOTNET_EnableDiagnostics=0`; `git`, `rg`, `python3`, `node` (v24.21.0),
`npx`, `jq`, `setpriv` on `PATH`; `/data/{workspaces,agent,claude}` owned by `app`. With Playwright,
`playwright-mcp --version` → `Version 0.0.82` with `--network none`, Chromium under `/opt/ms-playwright`, and a
headless Chromium launch as `app` renders a page.
- Running host, `--init --network none`: PID 1 `docker-init` as root, the bot's `dotnet` as uid/gid 1654 with no
effective capabilities; `/proc/1/environ` and the bot's `environ` are `Permission denied` for `app` (the bot's also
for a root `docker exec`, since the host is not dumpable); no `dotnet-diagnostic-*` socket. The log shows
`Host process hardened: not dumpable`, `Process environment: 15 deny rules, allow: none`, the MCP line
(`disabled` / `enabled (headless, chromium)`), `Speech to text`, `Using Claude Code at /usr/bin/claude, model
claude-code/claude-opus-5-5`, `Scheduler`, `Default time zone: UTC`, `Allowed users: 1. Workspaces under
/data/workspaces`, then `Telegram unreachable` backoff.
- `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED=false`), both are
`"false"`; with `.env.example`'s defaults both are `"true"` and `Telegram__Model` is the compose fallback;
`--no-sandbox`, `playwright-mcp`, `init: true`, `shm_size`, three named volumes, no ports. Not yet checked on
Coolify itself.
- `docker compose up -d --build` with `TELEGRAM_BOT_TOKEN=123:fake`: same startup lines, then `Telegram rejected the bot
token (401 Unauthorized)`, exit 1, and a visible `restart: unless-stopped` loop; no other warnings.
- After review (entrypoint with `--bounding-set=-all --no-new-privs`): the bot's `/proc/<pid>/status` shows
`CapEff`/`CapBnd` all zero and `NoNewPrivs: 1`; `su` as `app` fails; the host starts and logs the same lines; a
headless Chromium launch (`--no-sandbox`) under the same `setpriv` flags renders a page; the `env -i` login fallback
runs `claude --version` with only `HOME`, `PATH`, `CLAUDE_CONFIG_DIR`, `TERM` in its environment.
## Open questions
1. Persist `HOME` too? `~/.gitconfig`, `~/.ssh`, `gh` auth and the npm/npx cache are lost on redeploy with this
design. A fourth volume at `/home/app` would keep them; left out until there is a need.
2. Playwright default: compose builds it in (`true`) on the assumption that the MCP spec ships; flip the compose
default to `false` if that spec lands later. (Sandboxing and which binary is launched are settled above.)
2. ~~Playwright default~~: resolved. The MCP spec shipped first, so compose keeps building it in (`true`).
(Sandboxing and which binary is launched are settled above.)
3. `CLAUDE_CODE_VERSION=2.1` tracks the newest 2.1.x at build time. Pin an exact, doctor-verified version instead,
at the cost of manual bumps?
4. Should the agent get a .NET SDK in the image (`INSTALL_DOTNET_SDK` build arg, +~700 MB) so it can build .NET
projects in its workspace?
5. The agent can reach the configured secrets on purpose (see the secrets bullet): it owns the directories that
extension and MCP-server discovery read. Mitigate with a host option that turns off workspace/agent-dir extension
and settings discovery in the container, or with root-owned, read-only `extensions/` and `settings.json` paths?
Until then this is documented, and the allow-list is the trust boundary.

View File

@@ -1,7 +1,7 @@
# Google Calendar tool and connect flow — design
Date: 2026-09-26
Status: draft
Status: implemented
Depends on: `2026-09-26-scheduler-design.md` §7–§8 (the chat's time zone, `IExtensionContext.TimeZone`).
## Problem
@@ -57,7 +57,7 @@ reshaped for this harness: an extension on the public API, one Google account pe
- **The OAuth app is published unverified ("In production").** This is the setup the owner already uses. Publishing
needs no Google verification for a handful of users, and it avoids testing status's 7-day refresh-token expiry, so
a chat connects once instead of weekly. Each user sees Google's "unverified app" warning once, on approval
("Advanced → Go to <app> (unsafe)"); the setup docs show it. Testing status still works (every user added as a test
("Advanced → Go to <app> (unsafe)"); the setup docs describe it in text. Testing status still works (every user added as a test
user, otherwise `access_denied`, which the flow reports as a denial), but then each chat has to reconnect about
weekly; the `invalid_grant` message covers it.
- **Command names use underscores** (`/calendar_connect`, `/calendar_status`, `/calendar_disconnect`). Telegram only
@@ -110,24 +110,34 @@ and GOOGLE_CLIENT_SECRET`). If only one of the two is set, it logs a `Warning` i
### 3. OAuth client — `GoogleOAuthClient`
- `StartDeviceFlowAsync(ct) → DeviceCode(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval)`. A non-2xx
response throws `GoogleCalendarException` with Google's `error`/`error_description`.
- `PollAsync(deviceCode, ct) → PollResult`: `Success(refreshToken, scope)`, `Pending`, `SlowDown`, `Denied`, `Expired`,
`Failed(message)`. **The mapping reads the JSON `error` field whatever the status code.** Google documents 428 for
`authorization_pending` and 403 for `slow_down`/`access_denied`; the reference only looked at 400.
- `StartDeviceFlowAsync(ct) → DeviceAuthorization(DeviceCode, UserCode, VerificationUrl, ExpiresAt, Interval)`
(`verification_url`, or `verification_uri` when Google sends that; the interval defaults to 5 seconds). A non-2xx
response throws `GoogleCalendarException` with Google's `error`/`error_description`, never the client secret.
- `PollAsync(deviceCode, ct) → PollResult`, one record with a `PollStatus`: `Success` (refresh token, scope, access
token and its expiry; the connect flow uses the access token for `calendars/primary`), `Pending`, `SlowDown`,
`Denied`, `Expired`, `Failed(message)`. **The mapping reads the JSON `error` field whatever the status code.** Google
documents 428 for `authorization_pending` and 403 for `slow_down`/`access_denied`; the reference only looked at 400.
A success without `refresh_token` is `Failed("Google returned no refresh token. Remove the app at
https://myaccount.google.com/permissions and run /calendar_connect again.")`.
- `RefreshAsync(refreshToken, ct) → (accessToken, expiresAt)`. `invalid_grant` throws
https://myaccount.google.com/permissions and run /calendar_connect again.")`. A 5xx response without an `error`
field throws `HttpRequestException` and counts as a transport error in the flow (§5). `PollResult.ToString()` never
shows tokens.
- `RefreshAsync(refreshToken, ct) → AccessToken(Token, ExpiresAt)`. `invalid_grant` throws
`GoogleCalendarException(NotConnected: true, "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.")`.
- `RevokeAsync(token, ct)`: best effort; failures are reported but don't stop a disconnect.
Other errors throw `GoogleCalendarException("Google token refresh failed: <error>[: <description>]")`.
- `RevokeAsync(token, ct) → string?`: best effort; `null` when Google revoked the token, else the failure reason
(Google's error, `HTTP <status>` or the transport error). Failures are reported but don't stop a disconnect.
### 4. Connections — `GoogleCalendarConnections` (process-wide) and `GoogleCalendarConnection` (per chat)
`GoogleCalendarConnections.Shared` is explicit process-wide state, like `ClaudeLoginCoordinator.Shared`: extensions are
per session, but a chat's account outlives its sessions. `For(chatKey)` returns that chat's `GoogleCalendarConnection`
(created on first use, kept for the process lifetime) over its own `GoogleTokenStore`. Connections share nothing but
the OAuth client and the `HttpClient`, so chats are independent: separate access-token caches, separate refresh
locks, separate pending flows. The tool and the commands call `For(chatKey(ctx.SessionManager.Scope))`.
per session, but a chat's account outlives its sessions. `For(settings, scope)` returns that chat's
`GoogleCalendarConnection` (created on first use, kept for the process lifetime) over its own `GoogleTokenStore`.
Connections are keyed by the chat's full token directory (`<root>/<chat key>`, chat key = `ChatKeys.For(scope)`), and
the first caller's settings create the connection: all extension instances in a process share one configuration in
practice, and two token roots never share a connection. Connections share nothing but the OAuth client configuration
and the `HttpClient` (each holds its own stateless `GoogleOAuthClient`), so chats are independent: separate
access-token caches, separate refresh locks, separate pending flows. The tool and the commands call
`For(settings, ctx.SessionManager.Scope)`.
Per chat:
@@ -151,14 +161,15 @@ The handler (`ConnectCommand`) with `ctx.Ui`:
does) and stop.
2. A flow is already pending: re-send its link and code (`Notify`) with the remaining minutes, and stop. It does not
start a second flow: a new device code would invalidate the one the user may be typing. A flow pending in another
chat doesn't matter.
chat doesn't matter. A flow that is already cancelled or whose code has expired (still parked in its delay) doesn't
count: it is cancelled silently, cleared, and a new flow starts; `/calendar_status` doesn't show it either.
3. Otherwise `StartDeviceFlowAsync`, store it as the chat connection's `Pending`, and send **one** message:
`ui.Notify("Connect Google Calendar:\n1. Open <verification_url>\n2. Enter the code <USER-CODE>\n3. Approve access. I'll confirm here when it's done (code valid for <N> min).")`.
Link and code in one message, for the ordering reason in the re-login spec.
4. Poll with `PollAsync` every `Interval` seconds (Google says 5), delays through `TimeProvider`:
- `Pending`: keep going. `SlowDown`: add 5 s to the interval permanently (RFC 8628 §3.5).
- `Success`: call `GET calendars/primary` with the new token to learn `account` (`id`) and `timeZone`, write the
store, `Invalidate()` the cache, then `Notify("âś… Google Calendar connected as <account> (time zone <tz>).")`. If the
store (under the connection's flow gate, and only if the flow wasn't cancelled meanwhile), `Invalidate()` the cache, then `Notify("âś… Google Calendar connected as <account> (time zone <tz>).")`. If the
lookup fails, the token is still saved with those fields empty.
- `Denied`: `Notify("Google Calendar access was denied.", Warning)`.
- `Expired`, or `ExpiresAt` passed: `Notify("The Google code expired. Run /calendar_connect again.", Warning)`.
@@ -179,8 +190,9 @@ success; the old one is not revoked automatically.
- `/calendar_status`: `Connected as <account> since <date>, time zone <tz>.`, `Not connected. Run /calendar_connect.`,
or `Waiting for approval: open <url> and enter <code> (<N> min left).`
- `/calendar_disconnect`: cancels a pending flow, revokes the refresh token (best effort), deletes the file,
invalidates the cache, then `Calendar disconnected.` (with ` Revoking at Google failed: <reason>; remove the app at
- `/calendar_disconnect`: under the same flow gate a completing flow writes the store with, cancels a pending flow,
revokes the refresh token (best effort), deletes the file, invalidates the cache (so a flow that got its token just
before the disconnect can't write it afterwards), then `Calendar disconnected.` (with ` Revoking at Google failed: <reason>; remove the app at
https://myaccount.google.com/permissions.` appended on revoke failure).
The replies go through `ctx.Ui.Notify`. Without a UI they're appended as a `CustomMessage` with `triggerTurn: false`.
@@ -209,11 +221,27 @@ Behaviour follows the reference's recurring-events spec:
- **Scope resolution** for update/delete: fetch the event once. `recurringEventId` set → `instance`; `recurrence` set
→ `series`; neither → single (an explicit `scope` is ignored). An explicit `scope=instance` on a master and
`scope=series` on an occurrence are rejected with the reference's error texts. `recurrence` together with an
instance is rejected. Recurrence lines must start with `RRULE:`, `EXDATE:`, `RDATE:` or `EXRULE:`; Google is the
authority on the rest.
`scope=series` on an occurrence are rejected. `recurrence` together with an instance is rejected. Recurrence lines
must start with `RRULE:`, `EXDATE:`, `RDATE:` or `EXRULE:` (case-insensitive, trimmed); Google is the authority on
the rest. The reference implementation (`VolcanicSecretary/src/tools/calendar.ts`) is not in this repository, so
these are the texts (all thrown as `ArgumentException`, which the loop turns into an error tool result):
- missing fields: `create needs summary.`, `create needs start.`, `update needs eventId.`, `delete needs eventId.`
- any other `scope` value: `scope must be instance or series.`
- `scope=instance` on a master: `Event <id> is a recurring series. To change one date, call list_instances with
eventId=<id>, then pass that occurrence's id with scope=instance.`
- `scope=series` on an occurrence: `Event <id> is one occurrence of the recurring series <series>. To change the
whole series, pass eventId=<series> with scope=series.`
- `recurrence` on an occurrence: `recurrence can't be set on a single occurrence; update the series
(eventId=<series>) instead.`
- a bad recurrence line: `Recurrence line "<line>" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.`
- an empty patch: `update needs at least one field to change (summary, description, location, start, end or
recurrence).`
- **update** is a `PATCH` carrying only the fields given (empty patch → error). **delete** of an instance cancels one
occurrence; of a master deletes the series. The reply texts name which happened.
occurrence; of a master deletes the series. The reply texts name which happened, after the header line:
- create: `Created: <line>`
- update: `Updated this occurrence only: <line>`, `Updated the whole series: <line>` or `Updated: <line>`
- delete: `Cancelled one occurrence of "<summary>" (<range>); the rest of the series is unchanged.`,
`Deleted the recurring series "<summary>" and all its occurrences.` or `Deleted "<summary>" (<range>).`
- "This and following" edits, attendees, reminders, colours and conferencing are out of scope, as in the reference.
- `calendars` lists `calendarList` entries (`id · summary · accessRole · timeZone`, primary marked) so the model can
pass `calendarId`.
@@ -223,14 +251,17 @@ Behaviour follows the reference's recurring-events spec:
- Zone `Z` = the event's `timeZone` argument, else the resolved zone (§4).
- `YYYY-MM-DD` is an all-day date (`{date}`). All-day `end` is **exclusive**, as in Google. A missing `end` on create
means one day.
- A date-time with an offset (`2026-09-30T10:00:00+02:00` or `Z`) is sent as-is, as `dateTime`, with `timeZone: Z`.
- A date-time with an offset (`2026-09-30T10:00:00+02:00` or `Z`) keeps its instant and offset and is sent as
`dateTime`, with `timeZone: Z`. It is normalised to full RFC 3339 (`T` separator, seconds always present, so
`2026-09-30T10:00+02:00` goes out as `2026-09-30T10:00:00+02:00`); a canonical value goes out unchanged.
- A date-time without an offset (`2026-09-30T10:00`) is local wall time in `Z`: sent as `dateTime` without an offset
plus `timeZone: Z`, which Google interprets in that zone. Recurring events are expanded in `Z`, so a weekly 10:00
stays 10:00 across DST.
- A missing timed `end` on create means start + 1 hour. `start` and `end` must be the same kind (both dates or both
date-times).
- `timeMin`/`timeMax` must be RFC 3339 with an offset for Google. A date or local date-time is converted in `Z`
(`TimeZoneInfo`) before sending.
(`TimeZoneInfo`) before sending. A `timeMin` date is that day's midnight; a `timeMax` date includes that day, so it
becomes the next day's midnight (`timeMin=timeMax=2026-09-30` covers the whole 30th).
- Output times are rendered in the resolved zone with the zone in the header line of every result, e.g.
`Times in Europe/Berlin.` When the calendar's zone differs from the chat's (the system prompt shows the chat's), the
header says both: `Times in America/New_York (the calendar's zone; this chat is in Europe/Berlin).`, so the model
@@ -253,15 +284,21 @@ Behaviour follows the reference's recurring-events spec:
- Configuration: a `GoogleCalendar` section (`ClientId`, `ClientSecret`) with `GOOGLE_CLIENT_ID` and
`GOOGLE_CLIENT_SECRET` as environment shortcuts, bound into a `GoogleCalendarOptions` singleton. Empty values count
as unset (the Docker compose file passes both through, empty by default). The host adds the client secret to the
process environment hook's `SecretValues` (`2026-09-26-process-environment-hook-design.md`), and the default
deny-list already removes `GOOGLE_CLIENT_SECRET` from `bash`, scripts and MCP servers.
as unset (the Docker compose file passes both through, empty by default). The singleton is registered when
**either** value is set, so a half configuration still reaches the extension, which logs the warning naming the
missing value; with neither set nothing is registered. The host adds the client secret to the process environment
hook's `SecretValues` (`2026-09-26-process-environment-hook-design.md`), and the default deny-list already removes
`GOOGLE_CLIENT_SECRET` from `bash`, scripts and MCP servers.
- `ChatRegistry` and `BotSetupService` take the options as an optional constructor parameter (DI passes the default,
null, when the singleton isn't registered). New host code (section, options, menu, help lines, registration) lives
in `hosts/Volcanic.AgenticCore.Telegram/GoogleCalendarSetup.cs`, so the shared host files get one-line edits.
- `ChatRegistry.CreateAsync` adds the extension to `AgentSessionOptions.ExtraExtensions` for **every** chat when the
options are configured. It uses a factory registration (`ExtensionRegistration`) so each session gets its own
options are present. It uses a factory registration (`ExtensionRegistration`) so each session gets its own
instance over the shared `GoogleCalendarConnections`; the scope `telegram-<chatId>` picks the chat's account.
- `BotSetupService` adds `calendar_connect`, `calendar_status` and `calendar_disconnect` to the global command menu when
the calendar is configured. Every chat can use them.
- `/help` mentions the three commands when the extension is active.
- `BotSetupService` adds `calendar_connect`, `calendar_status` and `calendar_disconnect` to the global command menu only
when **both** values are set. Every chat can use them.
- `/help` mentions the three commands when the extension is active, detected from the session's registered commands
(`calendar_connect` present).
- Nothing else changes. The flow uses `Notify` only; the user's replies are never needed, so the pending-input
routing is untouched.
@@ -311,14 +348,16 @@ add to this test project) so polling and expiry run instantly.
store written and their own `Notify`; `/calendar_connect` twice in A reuses A's code while B gets a fresh one;
`/calendar_disconnect` in A leaves B's token and cache alone. Resolved zone: the calendar's stored zone, else
`ctx.TimeZone`; the two-zone header when they differ.
- **Telegram:** with `ChatHost`, every allowed chat lists the `calendar` tool.
`/calendar_connect` in a chat, with a fake handler that approves on the second poll, ends with the
"connected" message in that chat.
- **Telegram** (`tests/Volcanic.AgenticCore.Telegram.Tests/GoogleCalendarHostTests.cs`): with `ChatHost`, every
allowed chat lists the `calendar` tool, and none does without the options. `/calendar_connect` in a chat, with a
fake handler that approves on the second poll, ends with the "connected" message in that chat. Options come from
the section, then the environment, empty counting as unset; the client secret lands in `SecretValues`; the menu and
`/help` list the commands only when the extension is active.
## Docs
New `docs/google-calendar.md` (Google Cloud setup: enable the Calendar API, configure the OAuth consent screen and
**publish the app** without verification, with a screenshot of the "unverified app" warning users click through,
**publish the app** without verification, with a text description of the "unverified app" warning users click through,
create an OAuth client of type *TVs and Limited Input devices*, set the env vars, the three commands, where the token
lives; testing status as an alternative, with its test-user list and 7-day refresh-token expiry). Update `docs/telegram-bot.md`
(configuration, one account per chat), the extensions list in `CLAUDE.md` and `docs/architecture.md`.
@@ -334,5 +373,6 @@ lives; testing status as an alternative, with its test-user list and 7-day refre
## Open questions
1. Should `calendar.events` + `calendar.calendarlist.readonly` replace the full `calendar` scope (least privilege), if
the device flow allows them?
1. ~~Should `calendar.events` + `calendar.calendarlist.readonly` replace the full `calendar` scope (least privilege), if
the device flow allows them?~~ Resolved: kept the full `calendar` scope; revisit if Google lists narrower scopes for
the device flow.

View File

@@ -1,7 +1,7 @@
# MCP client extension and Playwright browser tools — design
Date: 2026-09-26
Status: draft
Status: implemented (the Dockerfile version test, PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage, landed with 2026-09-26-docker-deployment-design.md)
Depends on: `2026-09-26-telegram-attachments-design.md` (the `send` tool that delivers browser output);
`2026-09-26-process-environment-hook-design.md` (the environment every server process gets, and the redaction of
known secrets in tool output).
@@ -29,8 +29,8 @@ two meet only in tool naming (see "Names").
(`o.AddExtension<McpExtension>()`) or dropped into an extensions directory. A small contract gap is closed in
Abstractions (see Components §8). No runtime slice changes.
- **Official SDK, not hand-rolled JSON-RPC.** Use the `ModelContextProtocol.Core` package (the client/transport
core, without the hosting package). It handles the initialize handshake, protocol version negotiation, the
`notifications/cancelled` notification on cancellation, pagination of `tools/list`, content-block types and
core, without the hosting package). It handles the initialize handshake, protocol version negotiation,
pagination of `tools/list`, content-block types and
process shutdown (`StdioClientTransport`: `Command`, `Arguments`, `WorkingDirectory`, `EnvironmentVariables`,
`StandardErrorLines`, `ShutdownTimeout`). The `ClaudeCode.Bridge` hand-rolls its protocol because it answers only
three methods. A general client would have to reimplement much more. SDK types never leave the extension. Its
@@ -63,7 +63,9 @@ two meet only in tool naming (see "Names").
- **MCP output is redacted like any tool's.** MCP tools are ordinary `ITool`s, so the core loop replaces known secret
values (`SecretValues`) with `[REDACTED]` in their results, progress and error messages, including the
`<stderr tail>` of the "MCP server exited" error (process-environment-hook spec §9). A server that was handed a
secret on purpose and echoes it back doesn't put it in the transcript. `McpTool` does nothing itself.
secret on purpose and echoes it back doesn't put it in the transcript. `McpTool` does nothing itself. Text the
extension produces outside a tool result goes through `api.Redactor` too: stderr lines are redacted as they arrive
(before they are logged or kept in the tail), and `/mcp` output is redacted before it is shown or sent.
- **`/new` resets the browser.** Every `SessionShutdownEvent` (quit, `/new`, resume) disposes the session's server
processes, so tabs and in-memory browser state don't survive a new session. That is the intended behaviour: a new
conversation starts with a fresh browser. Logins survive through the persistent profile (next decision).
@@ -103,7 +105,7 @@ two meet only in tool naming (see "Names").
}
```
- `args`, `env` values and an optional `cwd` may contain `${workspace}`, which is expanded to the session cwd when
- `command`, `args`, `env` values and an optional `cwd` may contain `${workspace}`, which is expanded to the session cwd when
the live process starts (and to the temp discovery dir during discovery). The live process's working directory
defaults to `${workspace}`.
- `env` values may also contain `${env:NAME}`, expanded from the host environment at spawn time (unset → empty). This
@@ -111,10 +113,11 @@ two meet only in tool naming (see "Names").
the value is explicit when it reaches the policy.
- The environment a server process gets is `IProcessSpawnPolicy`'s result for a request with `Purpose = McpServer`,
`Origin` = the server name, the expanded command, args and cwd, and the expanded `env` entries as the explicit
environment. It's passed to `StdioClientTransport.EnvironmentVariables` in full, with a null for every inherited
name the policy removed, so the SDK's merge over the host environment can't reintroduce it.
- The catalogue cache key is the server name plus a digest of the **unexpanded** command, args and env, so every chat
shares one catalogue even though their expanded args differ. Secrets from `${env:…}` never enter the key.
environment. It's passed to `StdioClientTransportOptions.EnvironmentVariables` in full with
`InheritEnvironmentVariables = false` (SDK 2.x), so the SDK doesn't merge the host environment back in.
- The catalogue cache key is the server name plus a digest of the **unexpanded** command, args, env, cwd and tool
prefix (the catalogue holds exposed names, which depend on the prefix), so every chat shares one catalogue even
though their expanded args differ. Secrets from `${env:…}` never enter the key.
- `McpOptions.ApplyEnvironment(getEnv)` works like `TelegramOptions.ApplyEnvironment` and fills in the preset from
`PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS` (default true), `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`
(shell-style split honouring quotes, so `--device "iPhone 15"` is two argv entries) and `PLAYWRIGHT_COMMAND`.
@@ -122,9 +125,16 @@ two meet only in tool naming (see "Names").
them all through).
- Validation (`McpOptions.Validate()`) rejects empty commands, non-positive timeouts, and prefixes that don't match
`[A-Za-z0-9_-]*` or that start with `mcp__`.
- Order: `Mcp:Servers` by name, then the Playwright preset (skipped with a warning when a server is
explicitly named `playwright`), then the user settings in file order. The first source wins on a server name.
*Host servers are sorted by name (case-insensitive, then ordinal) because `Microsoft.Extensions.Configuration`
returns child keys sorted anyway, so the order in `appsettings.json` can't be kept; sorting in `McpServerSet`
gives options built in code the same rule.* Server names are compared ordinally everywhere, `/mcp <name>` included. `/mcp` lists a
disabled preset as `playwright: disabled (set PLAYWRIGHT_ENABLED=true)`.
- The extension has two constructors, `McpExtension(McpOptions)` for DI and a parameterless one that reads only
the environment and user settings, so a dropped-in DLL works without host code. The agent dir is
`VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`.
`VOLCANIC_AGENT_DIR` or `~/.volcanic/agent`, regardless of `AgenticCoreOptions.AgentDir` (the extension API doesn't
expose the host's agent dir; exposing it is a separate, additive change).
### 2. Playwright preset — `PlaywrightPreset`
@@ -133,7 +143,7 @@ When enabled, it expands to server `playwright`:
```
command: npx (or Playwright:Command / PLAYWRIGHT_COMMAND, e.g. the globally installed `playwright-mcp`)
args: -y @playwright/mcp@<pinned> (only with npx; Playwright:Package overrides the spec)
[--headless] [--browser <b>]
[--headless] --browser <b, default chromium>
--output-dir ${workspace}/browser
--user-data-dir ${workspace}/.volcanic/browser-profile
<ExtraArgs...>
@@ -150,6 +160,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
(hence the 120 s startup timeout for the first download). The Docker image preinstalls the pinned package globally
and compose sets `PLAYWRIGHT_COMMAND=playwright-mcp`, so a container never downloads anything and the first chat
doesn't stall.
- Without `--browser`, `@playwright/mcp` uses the Chrome channel, which the deployment doesn't install (`playwright
install chromium` provides Chromium). The preset therefore passes `--browser chromium` unless `Browser` is set.
With `--isolated` in `ExtraArgs` the preset leaves out `--user-data-dir`.
- **Chromium sandbox.** The preset doesn't add `--no-sandbox` itself. Chromium's sandbox needs user namespaces, which
Docker's default seccomp profile blocks for a non-root user, so the Docker compose file sets
`PLAYWRIGHT_EXTRA_ARGS=--no-sandbox` and the container is the isolation boundary. Bare hosts keep the sandbox.
@@ -165,8 +178,9 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
process environment hook, which keeps `PLAYWRIGHT_BROWSERS_PATH`, so it applies. In the container that's `/opt/ms-playwright`, set in the image. A missing browser
shows up as an error result from the first `browser_*` call (the MCP server itself starts fine), and the model
reports it.
- `PromptSection` (added through `BeforeAgentStartEvent` → `SystemPromptOptions.Sections["browser"]` while any
`browser_*` tool is active):
- `PromptSection` (added through `BeforeAgentStartEvent` → `SystemPromptOptions.Sections["browser"]` while any of
the server's registered tools is active (for the preset, the `browser_*` tools); a server's `promptSection` works
the same way under its own name):
```
## Browser
@@ -180,17 +194,24 @@ callTimeoutSeconds: 120, startupTimeoutSeconds: 120 (the first npx run downloads
- Don't enter passwords or payment details unless the user explicitly asks for that specific site.
```
The `send` line is included only when a tool named `send` is registered (checked at prompt time through
`GetAllTools()`), so the preset still reads correctly on hosts without attachments.
The `send` line is included only when a tool named `send` is active (checked at prompt time through
`GetActiveTools()`; a registered but inactive `send` isn't callable), so the preset still reads correctly on hosts
without attachments. Without `send`, that bullet
reads: "Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are
under browser/ in the workspace."
### 3. Catalogue — `McpCatalogCache`
`Task<McpCatalog> GetAsync(McpServerConfig, ILogger, CancellationToken)`, process-wide (`Shared`):
`Task<McpCatalog> GetAsync(name, McpServerConfig, IProcessSpawnPolicy, envLookup, ILogger, CancellationToken)` (internal;
the extension passes `api.Processes` and the host environment lookup), process-wide (`Shared`):
- Concurrent activations for the same key share one in-flight task, so ten chats starting at once make one spawn.
The shared discovery runs detached from any caller's token; each caller waits with its own.
- Discovery spawns the server in a fresh temp dir, with its environment from `api.Processes.Prepare` (activation has no
session, so only host hooks run and `TZ` isn't set), runs `initialize` and a paginated `tools/list` under
`startupTimeout`, captures `serverInfo`, then disposes the client and deletes the dir.
`startupTimeout`, captures `serverInfo`, then disposes the client and deletes the dir in the background. The
catalogue is returned without waiting for that cleanup, because SDK 2.2's dispose waits the whole `ShutdownTimeout`
before it kills the process (see §5).
- Success is cached for the process lifetime. Failure is cached for 60 s, then retried. The failure message
includes the last 20 stderr lines, as in the TS bridge, because a server that dies during the handshake explains
why only there.
@@ -208,9 +229,10 @@ provider's aliasing:
- The MCP server is always called with the original `mcpName`.
- Collisions with other tools follow the existing `ToolRegistry` rules. An MCP tool named like a built-in
(`read`, `bash`, ...) would **replace** it, so `McpExtension` refuses to register an exposed name that
`api.GetAllTools()` already reports with source `built-in`. It logs a warning suggesting a `toolPrefix`. A clash
`api.GetAllTools()` already reports with source `built-in`. It logs a warning suggesting a `toolPrefix`. The
built-in check runs against `GetAllTools()` at activation, so it covers every built-in, active or not. A clash
with another extension's tool is already ignored and recorded by the runner. Between two MCP servers, the first
configured wins, and a warning names both.
in the order of §1 wins (host servers by name, then the preset, then the user settings), and a warning names both.
- Through the Claude Code provider these tools reach the model as `mcp__volcanic__<exposedName>`. Nothing else is
needed: the Bridge advertises them from the catalogue like any other tool and never executes them. Prefixes that
start with `mcp__` are rejected (they'd read as `mcp__volcanic__mcp__x__y`). Because Claude Code runs with
@@ -224,17 +246,29 @@ One per server per extension instance (that is, per session):
start: expand `${workspace}` from `ctx.Cwd` and `${env:…}`, build the command, cwd and environment through
`ctx.PrepareProcessAsync` (so session-level `ProcessSpawnEvent` handlers run and `TZ` is the chat's zone), create `StdioClientTransport` + `McpClient` under `startupTimeout`, and route
`StandardErrorLines` to `api.Logger` at `stderrLevel` with the prefix `"<name> mcp: "`, keeping a 20-line tail.
- Calls use a linked token: the tool's `CancellationToken` (a session abort) plus `CancelAfter(callTimeout)`. The
SDK sends `notifications/cancelled` for the request. A timeout becomes
- Calls use a linked token: the tool's `CancellationToken` (a session abort) plus `CancelAfter(callTimeout)`. On an
abort or a timeout the connection sends `notifications/cancelled` for the request itself: SDK 2.2.0 doesn't send it
when a request's token fires (checked against the fake server), so each call goes out with its own request id
(`volcanic-<n>`) through `SendRequestAsync` and the notification is sent best effort. A timeout becomes
`"<tool> timed out after <n>s"`, and an abort propagates as `OperationCanceledException`, which the loop already
handles.
- If the process exits or the transport faults, the client is disposed and marked dead, and the call fails with
`"<name> MCP server exited (<code>): <stderr tail>. It restarts on the next call; browser state was lost."`. The
next call respawns it. There's no automatic retry of the failed call, because it may have had side effects.
- The idle timer (reset per call) disposes the client after `idleTimeout` with no calls in flight.
- `DisposeAsync` is idempotent: dispose the client (the SDK closes stdin, waits `ShutdownTimeout`, then kills), then
kill the process tree if it's still alive. The extension calls it on every `SessionShutdownEvent` (quit, new,
resume). A new session in the same chat starts from a fresh process, and the persistent profile keeps the logins.
next call respawns it. There's no automatic retry of the failed call, because it may have had side effects. An exit
is noticed either when the next call finds the SDK session completed or when a call fails and the process ends
within 2 s. The exit code comes from the SDK's `StdioClientCompletionDetails`.
- A JSON-RPC error from the server (e.g. invalid params) is an ordinary error result and doesn't mark the server dead.
- The idle timer (reset per call) disposes the client after `idleTimeout` with no calls in flight. The in-flight count
is taken under the start gate and the idle stop re-checks it under the same gate, so an idle stop never kills a
process a call has just been handed.
- `DisposeAsync` is idempotent: dispose the client. SDK 2.2 waits up to `ShutdownTimeout` for the process to exit
with stdin still open, then kills its tree, so disposing a live server always takes about `ShutdownTimeout`. Only
when the SDK reports no exit code does the connection kill the tree itself, and only if the process under that pid
started while this server was being spawned (a pid can be reused by an unrelated process once ours is gone). After it, calls throw `ObjectDisposedException`. `StopAsync` does the same
but leaves the connection usable, and resets a failed status to `not started` (the last error stays for
`/mcp <name>`). The extension stops connections on `SessionShutdownEvent` for `new`, `resume` and `fork` (the same
extension instance serves the next session) and disposes them for `quit` and `reload`, all servers concurrently. A new
session in the same chat starts from a fresh process, and the persistent profile keeps the logins.
- If the live server's `tools/list` differs from the cached catalogue (a package upgrade in place), a warning is
logged. Calls go by name either way.
@@ -279,8 +313,13 @@ One per server per extension instance (that is, per session):
### 9. The command — `/mcp`
Lists each configured server for this session: state (`not started` / `running pid N` / `failed: <reason>` /
`disabled`), number of tools, last error, and the stderr tail with `/mcp <name>`. It costs no model call.
Lists each configured server for this session with its state and number of tools. It costs no model call. States:
`not started` / `running` / `exited (restarts on the next call)` (the process ended since the last call) /
`failed: <reason>` / `disabled` (the preset adds `(set PLAYWRIGHT_ENABLED=true)`). The SDK
doesn't expose a live server's pid, so `/mcp` shows none. `/mcp <name>` shows the unexpanded command, the names (never
the values) of its `env` entries, server info, tools, the last error and the stderr tail. With a UI the output is a
notification; without one it is a `mcp` custom message appended without a turn. Either way the text goes through
`api.Redactor` first (identity when the API has none), because the last error and stderr tail can hold a secret.
`/mcp restart <name>` disposes the session process so the next call starts fresh (clears a wedged browser).
### 10. Host wiring (Telegram)
@@ -310,15 +349,21 @@ At startup the log reads `MCP: playwright enabled (headless, chromium)` or
No test touches the network, npm or a real browser.
- **Fake stdio server** `tests/Volcanic.AgenticCore.Extensions.Tests.FakeMcpServer`: a small console app built
with the SDK's server package and launched with `dotnet <dll>`. Its behaviour is chosen by env var
`FAKE_MCP_MODE`:
- `normal`: `echo`, `image` (1Ă—1 PNG), `fail` (returns `isError`), `slow` (awaits cancellation), `weird.name/x`, and
a tool with no `inputSchema`;
with the SDK's server API (in `ModelContextProtocol.Core`, no hosting package) and launched with `dotnet <dll>`.
Its behaviour is chosen by env var `FAKE_MCP_MODE` (comma-separated, so modes combine):
- `normal`: `echo`, `image` (1Ă—1 PNG), `fail` (returns `isError`), `slow` (awaits cancellation and logs
`{"event":"cancelled"}` to `$FAKE_MCP_LOG` when cancelled), `weird.name/x`, `weird_name_x` (collides with
`weird.name/x` after sanitising), a tool whose name is longer than 48 characters, and a tool with no `inputSchema`;
- `crash-on-start`: writes a traceback to stderr and exits 1;
- `hang-on-start`: never answers `initialize`;
- `die-after-first-call`: exits after one call;
- `die-after-first-call`: about 200 ms after answering the first call, writes `dying: $FAKE_MCP_DIE_MESSAGE` to
stderr and exits 3;
- `env`: adds a tool that returns the server's environment as text;
- `count-starts`: appends a line to `$FAKE_MCP_LOG` per start, to count spawns.
- `builtin-clash`: adds a tool named `read`;
- `count-starts`: appends a line to `$FAKE_MCP_LOG` per start, to count spawns, with the pid, cwd, args and (with
`env`) the environment.
- With `--output-dir` in its argv it also offers `browser_navigate` and `browser_take_screenshot` (a `filename`
is written relative to its cwd), so the Playwright preset can run against the fake.
- **Tests** (`tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/`):
- catalogue: names, descriptions and schemas pass through verbatim; the MCP name is used on the wire; a prefix is
applied; `weird.name/x` → `weird_name_x`; >48 chars truncated; duplicates suffixed; a missing schema becomes
@@ -331,7 +376,7 @@ No test touches the network, npm or a real browser.
- environment (`FAKE_MCP_MODE=env`, a tool that returns its environment): with `OPENAI_API_KEY` and `GITHUB_TOKEN`
in the injected host snapshot, neither reaches the server; a server `env` entry `GITHUB_TOKEN: ${env:GITHUB_TOKEN}`
delivers it (explicit beats the deny-list); discovery and the session process agree; `TZ` is the session's zone
for the session process;
for the session process (on Unix; the policy never sets `TZ` on Windows, so there the test checks it's absent);
- results: text, image → `ImageContent`, oversized image replaced, `isError` → error tool result with content,
output truncation note;
- redaction, end to end through the core: the `env` tool's output with a `GITHUB_TOKEN` configured as a secret
@@ -343,12 +388,13 @@ No test touches the network, npm or a real browser.
call succeeds on a new process; idle timeout stops the process;
- lifecycle: `SessionShutdownEvent` kills the process (checked by pid); dispose is idempotent;
- collisions: a server tool named `read` is refused and the built-in stays; two servers with the same tool → the
first wins;
first by name wins, and configuration binding delivers `Mcp:Servers` sorted by name;
- config: `ApplyEnvironment` (quoted `PLAYWRIGHT_EXTRA_ARGS`), config over env, `Validate`, user
`settings.json` merge and precedence;
- Playwright preset: argv for headless/browser/extra args, output dir and profile under the workspace, a pinned
package; with `PLAYWRIGHT_COMMAND` the binary is used and `-y <package>` is dropped; the Dockerfile's
`PLAYWRIGHT_MCP_VERSION` default equals `DefaultPackage`'s version; the prompt section appears only with `browser_*` tools active and mentions `send` only when it exists;
`PLAYWRIGHT_MCP_VERSION` default equals `DefaultPackage`'s version (`Dockerfile_version_matches_DefaultPackage`, added
with the docker-deployment spec); the prompt section appears only with `browser_*` tools active and mentions `send` only when it exists;
- end to end through the core with `FauxProvider`: the scripted model calls the fake `image` tool, and the tool
result message holds the image.
- **Claude Code**: a `ToolCatalog` test that `browser_navigate`-style names alias to themselves (no digest).

View File

@@ -1,7 +1,7 @@
# Process environment hook, host hardening and secret redaction: keep host secrets away from the agent — design
Date: 2026-09-26
Status: draft
Status: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md, the scheduler `ScriptRunner` with 2026-09-26-scheduler-design.md)
Used by: `2026-09-26-scheduler-design.md` (watcher scripts), `2026-09-26-mcp-client-playwright-design.md` (MCP
server processes), `2026-09-26-telegram-attachments-design.md` (`OPENAI_API_KEY`), `2026-09-26-google-calendar-design.md`
(`GOOGLE_CLIENT_SECRET`), `2026-09-26-docker-deployment-design.md` (the secrets set in Coolify).
@@ -120,8 +120,10 @@ public interface IProcessSpawnPolicy
```
- `ProcessSpawnContext.Environment` is the **complete** environment: spawners clear `ProcessStartInfo.Environment`
and copy it in. Names compare ordinally on Unix and case-insensitively on Windows, as the OS does.
- Hooks are synchronous and pure, as in pi. Anything needing I/O belongs in the spawner.
and copy it in, through `ProcessSpawnContextExtensions.ApplyTo(psi)` (Abstractions), which also sets the working
directory. Names compare ordinally on Unix and case-insensitively on Windows, as the OS does.
- Hooks are synchronous and pure, as in pi. Anything needing I/O belongs in the spawner. A hook that returns null, or a
context without command, arguments, cwd or environment, fails the spawn like a throwing hook.
### 2. Configuration — `AgenticCoreOptions.ProcessEnvironment`
@@ -156,6 +158,9 @@ use them (`Telegram__Speech__ApiKey`, `GoogleCalendar__ClientSecret`).
Precedence: an explicit name in `Deny` removes; else a name or glob in `Allow` keeps; else a glob in `Deny` removes;
else a `SecretValues` match removes; else kept. `Allow` of an explicitly denied name is a startup validation error, so
the conflict is visible instead of silently lost.
Names and globs in `Deny` and `Allow` both match case-insensitively (an explicit name as well as a glob).
`ProcessEnvironmentOptions.Validate()` returns the configuration errors (empty entries, an explicitly denied name that
is also allowed); `DefaultDeny` and `MinimumSecretLength` (8) are public on the options class.
### 3. The policy — `ProcessSpawnPolicy` (core, new slice `Features/Processes`)
@@ -167,11 +172,14 @@ the conflict is visible instead of silently lost.
4. If `request.TimeZoneId` is set and step 3 didn't set `TZ`, set `TZ` to it. The Docker image has `tzdata`, so an
IANA id works for `date`, Python and Node. On Windows `TZ` isn't used by the OS and the step is skipped.
5. Run `Hooks` in order. A hook may change `Command`, `Arguments`, `Cwd` and `Environment`; `Purpose`, `Scope`,
`SessionId` and `Origin` are copied back from the request whatever it returns.
`SessionId` and `Origin` are copied back from the request whatever it returns. A hook that returns null fails the
spawn like one that throws.
6. Log at Debug: purpose, origin, scope and the removed names.
`AddAgenticCore` registers it with `TryAddSingleton<IProcessSpawnPolicy, ProcessSpawnPolicy>()`, so a host can replace
it wholesale. `AgenticCoreOptions.ProcessEnvironment` is validated when the service is built.
`AddAgenticCore` registers it with `TryAddSingleton<IProcessSpawnPolicy>` (a factory over the bound options), so a
host can replace it wholesale. `AgenticCoreOptions.ProcessEnvironment` is validated when the service is built: the
`ProcessSpawnPolicy` constructor throws `InvalidOperationException` when `ProcessEnvironmentOptions.Validate()` reports
errors.
### 4. Session binding — `IExtensionContext.PrepareProcessAsync`, `IExtensionApi.Processes`, `ProcessSpawnEvent`
@@ -190,10 +198,25 @@ public sealed class ProcessSpawnEvent(ProcessSpawnContext context) : ExtensionEv
```
`PrepareProcessAsync` fills `Scope`, `SessionId` and (unless given) `TimeZoneId` from the session (the session's time
zone comes from `AgentSessionOptions.TimeZone`, scheduler spec §8), calls `Processes.Prepare`, then emits
`ProcessSpawnEvent` to extensions in load order. Each handler may replace `Context`. There's no `Block`: refusing a
zone comes from `AgentSessionOptions.TimeZone`, else `AgenticCoreOptions.DefaultTimeZone`, scheduler spec §8), calls
`Processes.Prepare`, then emits `ProcessSpawnEvent` to extensions in load order. When neither option sets a zone, or the
zone has no IANA id that resolves to a system zone, `TimeZoneId` stays null and the child inherits the host's `TZ`: the
machine's local zone may be unnamed (.NET calls an unmatched `/etc/localtime` `"Local"`) or come from a POSIX rule
string, and overwriting `TZ` with that would move the child to UTC. Each handler may replace `Context`; a handler that
leaves it null or without command, arguments, cwd or environment fails the spawn with `InvalidOperationException`. There's no `Block`: refusing a
command is `ToolCallEvent.Block`'s job, before any process exists.
`IExtensionApi.Processes`, `IExtensionApi.Redactor` and `IExtensionContext.PrepareProcessAsync` are default interface
members that throw `NotSupportedException`, so implementations outside the core (test fakes, other front ends) keep
compiling; the core's `ExtensionApi` and `ExtensionContext` implement them. A `ProcessSpawnEvent` handler that throws is
reported as `extension_error` and the exception propagates out of `PrepareProcessAsync`, failing the spawn.
The session time zone members it reads (`AgentSessionOptions.TimeZone`, `AgenticCoreOptions.DefaultTimeZone`,
`AgentSession.TimeZone`, `IExtensionContext.TimeZone`) are introduced with this spec, ahead of the rest of scheduler
spec §8. `IExtensionContext.TimeZone` is a default interface member returning `TimeZoneInfo.Local`, so other
implementations keep compiling; `TimeZoneIds.ToIana` (core, `Hosting/`) turns the zone into the IANA id for `TimeZoneId`
(null when there is none that resolves). The session hands the id to the Extensions slice as `IExtensionHost.TimeZoneId`.
Process-wide spawners without a session (the scheduler, MCP catalogue discovery) call `Processes.Prepare` directly
(or the DI singleton), so only host hooks run for them. That is deliberate: a host-wide rule belongs in a host hook.
@@ -204,9 +227,12 @@ Process-wide spawners without a session (the scheduler, MCP catalogue discovery)
| `BashTool` | `BashTool`, `Command` = the model's command, `Cwd` = `ctx.Cwd` | none | Through `ctx.PrepareProcessAsync`. Then it runs the shell as today with the returned command and cwd, and a cleared-then-copied environment. |
| `ExtensionApi.ExecAsync` | `ExtensionExec`, `Origin` = extension name | `ExecOptions.Environment` | Session-bound. `ExecOptions.Environment` keeps its meaning (set, or null to remove), now on top of the filtered base. |
| Scheduler `ScriptRunner` | `ScheduledScript`, `Scope` = target, `Origin` = job id, `TimeZoneId` = the target's zone | none | Through the DI `IProcessSpawnPolicy`: jobs fire for chats with no live session. Replaces `SchedulerOptions.ScrubbedEnvironment`. |
| MCP `McpServerConnection` | `McpServer`, `Origin` = server name, `Arguments` = expanded args | the server's `env` entries | Through `ctx.PrepareProcessAsync` from the first tool call. The result's env goes to `StdioClientTransport.EnvironmentVariables`, with a null for every inherited name it doesn't contain. |
| MCP `McpServerConnection` | `McpServer`, `Origin` = server name, `Arguments` = expanded args | the server's `env` entries | Through `ctx.PrepareProcessAsync` from the first tool call. The result's env goes to `StdioClientTransportOptions.EnvironmentVariables`, with `InheritEnvironmentVariables = false`. |
| MCP `McpCatalogCache` discovery | as above, `Cwd` = the temp dir | the server's `env` entries | Through `api.Processes.Prepare` (activation, no session). |
*The two MCP rows are implemented in `extensions/Volcanic.AgenticCore.Extensions.Mcp` (`McpServerConnection`, `McpCatalogCache`).*
*The scheduler row is implemented in `extensions/Volcanic.AgenticCore.Extensions.Scheduler` (`ScriptRunner`).*
A spawner whose `Prepare` throws reports it the way it reports a failed start: `bash` returns an error result
`process environment hook failed: <message>`, a watcher is `Failed(null, "…")`, an MCP call or discovery fails with the
message. The message comes from the exception, never from the environment.
@@ -215,13 +241,16 @@ message. The message comes from the exception, never from the environment.
- `Program.cs` adds, after reading `TelegramOptions` and the sibling options:
- `SecretValues`: the bot token, the speech API key, the Google client secret (whichever are set). This catches a
secret that arrives under a name no glob matches (for example a Coolify alias).
secret that arrives under a name no glob matches (for example a Coolify alias). This spec adds the bot token
(`TelegramProcessEnvironment.Configure`); the telegram-attachments and google-calendar specs add their own secret
to the same list when they introduce it.
- No extra `Deny` names: the defaults already cover every secret the host and the sibling specs read.
`TELEGRAM_ALLOWED_USER_IDS` isn't secret and stays.
- A `ProcessEnvironment` configuration section binds `Deny` and `Allow` (added to the defaults, not replacing them),
with the environment shortcut `PROCESS_ENV_ALLOW` (comma separated) for Coolify. A deployment that wants the agent to
use `gh` sets `PROCESS_ENV_ALLOW=GH_TOKEN`.
- The startup log gains one line: `Process environment: <n> deny rules, allow: GH_TOKEN` (names only).
- The startup log gains one line: `Process environment: <n> deny rules, allow: GH_TOKEN` (names only; `allow: none`
when nothing is allowed).
- `Program.cs` calls `HostProcessHardening.Apply` once the options are validated (§8). The `ProcessEnvironment`
section also binds `HardenHostProcess`.
- The same `SecretValues` feed the tool-output redactor (§9), so the bot token never reaches the model in a tool
@@ -258,8 +287,9 @@ advertises run in the core, so a `bash` call made through Claude Code goes throu
(so before polling, the scheduler and the Claude Code provider's first `claude` process). The call is the same, so
the policy's later call is a no-op. The headless sample relies on the policy constructor.
Tests that build a `ProcessSpawnPolicy` pass options with `HardenHostProcess = false`, so the test runner is never
made non-dumpable (see Testing).
Because the first call wins, each test project makes that call itself from a module initializer
(`NoHostHardening.cs`) with `HardenHostProcess = false`, so no policy a test builds can make the test runner
non-dumpable (see Testing).
**What.** `ProcessEnvironmentOptions` gains:
@@ -392,15 +422,20 @@ process's environment.
MCP server's `env` mode).
- **Claude Code**: a test that `ClaudeArgs.BuildEnvironment` output is unchanged when a host hook is configured (the
provider never consults the policy).
- **Host hardening** (`HostProcessHardening` takes an injectable `Func<int, int>` prctl seam and an
`IsLinux` flag for unit tests):
- **Host hardening** (the logic lives in the internal `HostProcessHardening.Hardener`, built with an injectable
`Func<int, int>` prctl seam that returns the errno, an `isLinux` flag and an environment reader; the public static
`Apply` uses a shared instance over the real libc):
- flag on + Linux → prctl called once with `PR_SET_DUMPABLE, 0`; a second `Apply` doesn't call it again; a failing
prctl logs a warning and doesn't throw; non-Linux and flag off → not called (flag off logs the warning);
prctl logs a warning and doesn't throw, and so does a prctl seam that throws `DllNotFoundException` (libc doesn't
resolve); non-Linux and flag off → not called (flag off logs the warning);
- the `DOTNET_EnableDiagnostics` warning appears when the injected environment lacks it, and not when it's `0`;
- Linux-only integration test with the real libc: a helper process (the test runner spawns
`dotnet <test helper>.dll`) applies the hardening and prints `ok`; the test then runs `cat /proc/<helper pid>/environ`
as the same user and asserts it fails with permission denied, and that the same read succeeds against a helper
started with `HardenHostProcess=false`. The test process itself is never hardened, so test debugging keeps working.
The test is skipped when the runner is root: root (`CAP_SYS_PTRACE`) reads every `/proc` entry, and a non-root
reader couldn't read a root helper's `environ` even unhardened, so there is nothing to compare. Run it as the bot's
own non-root user.
- **Redaction** (`tests/Volcanic.AgenticCore.Tests/Processes/`, `FauxProvider` + scripted test tools):
- `SecretRedactor`: a value inside text becomes `[REDACTED]`; values under 8 characters are ignored; an overlapping
longer secret wins; an empty set returns the same string instance;

View File

@@ -1,7 +1,7 @@
# Scheduler: reminders, scheduled prompts and watcher scripts (design)
Date: 2026-09-26
Status: draft
Status: implemented
Depends on: `2026-09-26-telegram-attachments-design.md` (`IChatChannel`, `ChatContext.Channel`, the renderer's
`RunInOrderAsync`); `2026-09-26-process-environment-hook-design.md` (`IProcessSpawnPolicy`, which builds the watcher
scripts' environment, and `ISecretRedactor`, which cleans their output); `2026-09-26-telegram-message-context-design.md`
@@ -61,7 +61,10 @@ same chat.
- **Scheduled agent prompts are queued as follow-ups, never as steering.** When the chat is idle a prompt job starts a
run. When a run is active (a user request, or another job), the prompt goes in with `StreamingBehavior.FollowUp`: the
current work finishes first, then the job's prompt is handled in the same run. A clock-driven prompt shouldn't
redirect what the user is doing mid-turn. User messages keep steering as they do today.
redirect what the user is doing mid-turn. User messages keep steering as they do today. This also holds when a run
starts between `PromptAsync`'s idle check and its run gate: the core's `StartRunAsync` gets the caller's
`StreamingBehavior`, and a `FollowUp` prompt joins the active run as a follow-up (with a `QueueUpdate`), not as
steering.
- **Cron parsing uses Cronos** (`Cronos` on NuGet, Hangfire, MIT, no dependencies). It parses standard 5-field
expressions and macros (`@daily`, `@hourly`, …). `GetNextOccurrence(DateTimeOffset, TimeZoneInfo)` works in a given
zone and has defined DST semantics: a time skipped when clocks go forward fires at the transition, and an ambiguous
@@ -72,7 +75,10 @@ same chat.
sets a 1-minute floor for watchers.
- **Each chat has its own time zone.** It applies to cron, to displayed times, to the session's system prompt, to the
local-time header on each prompt, and to the `TZ` of the chat's `bash`, watcher and `claude -p` processes (§8). An `at` without an offset is read in the chat's zone, because
models often leave the offset out. An `at` with an offset is used as given. The zone is a chat setting, not a
models often leave the offset out. An `at` with an offset is used as given. The date is required and only ISO 8601 is
accepted (`yyyy-MM-dd`, or `yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]` with an optional `Z`/offset): a bare `09:00` or a culture
format such as `09/10/2026` is rejected with a readable error, because it would be resolved against the machine's
clock rather than the chat's. The zone is a chat setting, not a
scheduler setting, because the system prompt and `bash` need it with the scheduler disabled too:
- **Stored by the host**, in a small per-chat settings store outside every workspace:
`<AgentDir>/telegram/chat-settings.json` (§7). Not in `jobs.json` (the scheduler can be off) and not in the session
@@ -93,7 +99,9 @@ same chat.
fires immediately. If it's more than 60 s late, direct text gets `(due <time>, delivered late)` and an agent prompt
gets `This was due at <time>; the bot was offline.` A cron job's next occurrence is computed strictly after "now".
Occurrences missed while the bot was down are dropped, which is logged at Information. A one-off is removed only
**after** its delivery completes, so a crash mid-fire re-fires it after a restart (at-least-once).
**after** its delivery completes, so a crash mid-fire re-fires it after a restart (at-least-once). The same holds for
a graceful stop: a delivery cut off by shutdown isn't recorded, so the one-off stays in `jobs.json` and fires late on
the next start.
- **Persistence is one central file:** `<AgentDir>/scheduler/jobs.json` (by default `~/.volcanic/agent/scheduler/`).
The workspace is deliberately not used: the agent's `bash` can write anywhere there, and a central file is loaded
without scanning every workspace. The format is `{ "version": 1, "jobs": [...] }` in `VolcanicJson` camelCase.
@@ -153,7 +161,9 @@ watchers. There are at most `MaxJobsPerTarget` (50) jobs per target. Every error
`LoadAsync()` returns the jobs. A missing file means an empty list. A corrupt file is renamed to
`jobs.json.corrupt-<timestamp>`, logged as an error, and loading starts empty: a bad file must not keep the host down.
Invalid jobs are dropped with a warning. `SaveAsync(jobs)` writes `jobs.json.<pid>.tmp` and then does
Invalid jobs are dropped with a warning. A stored cron job whose next occurrence can't be computed on start (for
example a target the host can't map to a zone) is logged and left unarmed; it never fails `StartAsync`. The Telegram
delivery's `TimeZoneFor` returns the default zone for a target that isn't a chat id. `SaveAsync(jobs)` writes `jobs.json.<pid>.tmp` and then does
`File.Move(overwrite: true)`. Writes go through one `SemaphoreSlim`.
### 3. `ScriptRunner` (`IScriptRunner`, faked in tests)
@@ -163,16 +173,20 @@ Invalid jobs are dropped with a warning. `SaveAsync(jobs)` writes `jobs.json.<pi
- The shell matches the built-in `bash` tool, so a script the agent tested with `bash` behaves the same when it's
scheduled: `/bin/sh -c <command>` on Unix and `cmd.exe /d /s /c` on Windows. It's overridable through
`SchedulerOptions.Shell`. The command is passed through `ArgumentList`, not quoted by hand.
- The working directory is the target's workspace. Stdin is closed.
`SchedulerOptions.Shell`. On Unix the command is passed through `ArgumentList`, not quoted by hand. On Windows
`cmd.exe` doesn't parse `ArgumentList`'s quoting, so the command goes through `Arguments` as
`/d /s /c "<command>"`, exactly as the `bash` tool does. A configured `SchedulerOptions.Shell` always uses
`ArgumentList`.
- The working directory is the target's workspace (created if missing). Stdin is closed.
- The command, cwd and environment come from `IProcessSpawnPolicy.Prepare` (DI singleton, registered by
`AddAgenticCore`) with `Purpose = ScheduledScript`, `Scope = target`, `Origin = job id` and `TimeZoneId` = the
target's zone. So the script sees the same filtered environment as `bash`, with `TZ` set to the chat's zone, and
host hooks apply. There's no scheduler-specific scrub list any more (`SchedulerOptions.ScrubbedEnvironment` is
removed). A policy that throws is `Failed(null, "process environment hook failed: …")`. Session-level
`ProcessSpawnEvent` handlers don't run: a job fires without a live session.
- Timeout or cancellation → `Process.Kill(entireProcessTree: true)`. A shell that fails to start is `Failed(null, "failed to start <shell>: …")`,
never an exception.
- Timeout or cancellation → `Process.Kill(entireProcessTree: true)`. A timeout is `Failed(null, stderrTail, timedOut: true)`;
a cancellation throws `OperationCanceledException` after the kill. A shell that fails to start (or a workspace that
can't be created) is `Failed(null, "failed to start <shell>: …")`, never an exception.
- Output capture as in the reference, then `Trim()` on stdout.
### 4. `JobScheduler` (process-wide, the slice's entry type)
@@ -185,6 +199,7 @@ public sealed class JobScheduler(SchedulerOptions options, IScriptRunner runner,
IReadOnlyList<JobView> List(string target); // JobView = job + NextRunAt (null = never)
Task<JobView> CreateAsync(string target, NewJob job, CancellationToken ct); // throws JobValidationException
Task<bool> CancelAsync(string target, string id, CancellationToken ct); // false: unknown, or another target's
Task<ScheduledJob?> CancelJobAsync(string target, string id, CancellationToken ct); // the removed job, in one step (tool, /cancel)
TimeZoneInfo TimeZoneFor(string target); // delivery.TimeZoneFor
Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken ct); // recompute cron, re-arm; counts for the reply
}
@@ -195,11 +210,11 @@ public interface IJobDelivery
Task SendTextAsync(string target, string markdown, CancellationToken ct);
/// <summary>Run a prompt in the target's session (follow-up when busy); completes when the run that consumed it settles.</summary>
Task RunPromptAsync(string target, string prompt, CancellationToken ct);
/// <summary>Working directory for the target's scripts.</summary>
/// <summary>Working directory for the target's scripts. A path computation: must not create or wake a session.</summary>
string WorkspaceFor(string target);
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted.</summary>
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted. Must not create or wake a session.</summary>
bool IsDeliverable(string target);
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached.</summary>
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached. Must not create or wake a session.</summary>
TimeZoneInfo TimeZoneFor(string target);
}
```
@@ -211,15 +226,16 @@ Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the t
| Kind | Action | Message format |
|---|---|---|
| reminder | `SendTextAsync` | `⏰ <text>` |
| prompt | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)]\n<text>` |
| reminder | `SendTextAsync` | `⏰ <text>`; fired more than 60 s late: `⏰ <text> (due <d MMM HH:mm>, delivered late)` |
| prompt | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)]\n<text>`; fired more than 60 s late, the note gets its own line after the header: `[Scheduled job "<name>" (<id>)]\nThis was due at <d MMM HH:mm>; the bot was offline.\n<text>` |
| watcher, matched (exit 0, non-empty stdout) | `RunPromptAsync` | `[Scheduled job "<name>" (<id>)] The watcher script printed:\n<stdout>` |
| watcher, silent (exit 0, empty stdout) | nothing: no model call, no session activity, no transcript entry | only `LastRunAt`/`LastResult` are updated |
| watcher, failed | `SendTextAsync` once per failure streak | `⚠️ Job "<name>" (<id>) failed (exit <n>[, timed out]):\n```\n<stderr tail>\n```` |
| watcher, failed | `SendTextAsync` once per failure streak | `⚠️ Job "<name>" (<id>) failed (exit <n>[, timed out]):\n```\n<stderr tail>\n````; with no exit code it reads `failed (timed out)` or `failed (did not start)` |
- **Watcher invariant.** Running a watcher's script touches only `IScriptRunner`, `WorkspaceFor` and `TimeZoneFor`.
- Watcher matches never get a late note (a watcher only runs when its occurrence comes due; there's no catch-up).
- **Watcher invariant.** Running a watcher's script touches only `IScriptRunner`, `IsDeliverable`, `WorkspaceFor` and `TimeZoneFor`.
It never calls `RunPromptAsync` or `SendTextAsync`, and the Telegram delivery's `WorkspaceFor`/`TimeZoneFor` don't
create or look up a chat session. Only the outcome decides what follows: `Output` → exactly one `RunPromptAsync`,
create or look up a chat session (nor does `IsDeliverable`). Only the outcome decides what follows: `Output` → exactly one `RunPromptAsync`,
`Failed` → at most one `SendTextAsync` per failure streak, `Silent` → nothing.
- **Redaction.** A watcher's stdout goes through `redactor.Redact` before it's put into the prompt, and the stderr
tail before it's put into the failure alert (direct to the chat, but still not a place for the bot token). Known
@@ -233,7 +249,9 @@ Firing (`FireAsync(job)`, tracked in an in-flight set and never awaited by the t
throws `ChatDeliveryException`. The job's state still advances: there's no redelivery queue.
- `CancelAsync` removes the job. If that job's watcher script is running it's killed and its outcome thrown away. A
prompt run that's in progress isn't aborted: that's what `/stop` is for.
- `!IsDeliverable(target)` at fire time → skip, log a warning, keep the job (a cron job advances).
- `!IsDeliverable(target)` at fire time → skip, log a warning, keep the job (a cron job advances); a skipped one-off
stays in the store and fires again after the next start.
- Saves are serialised and each one snapshots the jobs inside that gate, so a save never overwrites newer state.
- `TimeZoneChangedAsync(target)` recomputes `NextRunAt` for the target's cron jobs in the new zone (strictly after now,
no catch-up), leaves one-offs untouched, re-arms the timer and returns
`ZoneChange(int CronJobs, int OneOffs)`. A cron occurrence already in flight isn't affected.
@@ -267,7 +285,8 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
- **Options**: a new `Scheduler` section bound to `SchedulerOptions`: `Enabled` (true),
`StorePath` (`<AgentDir>/scheduler/jobs.json`), `DefaultScriptTimeout` (300 s), `MaxJobsPerTarget` (50) and
`Shell`. There's no time zone and no scrub list here: zones are per chat (§7), the environment comes from
the process environment hook.
the process environment hook. An empty `StorePath` means `<AgentDir>/scheduler/jobs.json`; `Validate` rejects a
`DefaultScriptTimeout` outside 1 s–1 h, `MaxJobsPerTarget` < 1 and empty `Shell` entries (exit 2).
- **`Program.cs`** registers `SchedulerOptions`, `IScriptRunner` → `ScriptRunner`, `TimeProvider.System` (one
`TryAddSingleton`, shared with the message-context and attachments specs), `JobScheduler`,
`TelegramJobDelivery`, `ChatSettingsStore`, `ChatTimeZoneService` (§7; registered even when the scheduler is
@@ -295,13 +314,15 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
```
**Scheduled jobs** (Europe/Berlin, now 26 Sep 14:03)
`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00
`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 29 Sep 08:00
`p3xz9` 💬 Inbox summary · `0 8 * * 1-5` · next 28 Sep 08:00
`w8r4t` 🔍 Price check · `*/10 * * * *` · next 14:10 · last: failed (exit 1)
```
The header and every time are in the chat's zone.
- `/cancel <id>` → `Cancelled k7m2q (Call the dentist).` `No job k7m2q in this chat.` also covers another chat's id.
With no id: `Usage: /cancel <id> (see /jobs).`
- `/status` gains `**Jobs:** <n>`.
- With the scheduler disabled, `/jobs` and `/cancel` stay in the menu and reply
`Scheduling is turned off on this bot (Scheduler:Enabled).`; `/status` has no Jobs line.
- **Security**: scheduled scripts run unattended as the bot's OS user, with the same trust as the `bash` tool that
every allow-listed user already has, and the same filtered environment. The existing Security section in
`docs/telegram-bot.md` gains that sentence.
@@ -336,10 +357,15 @@ schedule(action: create|list|cancel, id?, name?, kind?: reminder|prompt|watcher,
changed; confirm the new zone in your reply.` Errors from `TryParse` become error results. It runs whether or not the
scheduler is enabled.
- `/status` gains `**Time zone:** <id> (<offset>)`.
- `ChatTimeZoneService` finds the live session through a function (`ChatRegistry.Find`) so DI has no ChatRegistry ↔
service cycle; with no scheduler, `/timezone` replies without the job clause.
### 8. Session time zone (core)
The session carries its zone, so the system prompt and `bash` agree with the chat:
The session carries its zone, so the system prompt and `bash` agree with the chat. `AgentSessionOptions.TimeZone`,
`AgenticCoreOptions.DefaultTimeZone`, `AgentSession.TimeZone` and `IExtensionContext.TimeZone` already exist (added by
the process-environment-hook spec, together with `TimeZoneIds.ToIana`); this spec adds `SetTimeZone`, the prompt change
and Claude Code's `TZ`:
- `AgentSessionOptions.TimeZone` (`TimeZoneInfo?`) and `AgenticCoreOptions.DefaultTimeZone` (`TimeZoneInfo?`, null =
`TimeZoneInfo.Local`). The session's zone is the first that is set.
@@ -371,7 +397,11 @@ The session carries its zone, so the system prompt and `bash` agree with the cha
the allow-list, like its pinned `CLAUDE_CODE_*` flags. Anything Claude Code itself adds about the date then agrees
with our prompt. It isn't set on Windows (the OS ignores `TZ`, as in the process-environment-hook spec §3), nor for
`claude auth login`/`auth status` and the doctor's probes, which have no session. `TZ` stays off the inherited
allow-list: the host's own `TZ` never leaks through. This goes through the provider, not `IProcessSpawnPolicy`
allow-list: the host's own `TZ` never leaks through. When the session has no configured zone, `claude -p` gets the
IANA id of `TimeZoneInfo.Local` (it can't inherit `TZ`) while `bash` inherits the host's `TZ` (its `TimeZoneId` is
null); both end up in the host's zone. All three id conversions follow one rule, `TimeZoneIds.ToIana` (mirrored by
`ClaudeArgs.IanaZoneId` and the scheduler's `ZoneNames.Iana`, since extensions can't reference the core): null unless
the id resolves to a system zone, so an unnamed `Local` sets no `TZ`. This goes through the provider, not `IProcessSpawnPolicy`
(process-environment-hook spec §7). `docs/claude-code-provider.md` (its environment section) is updated with the
implementation.
- Headless sample: no change; it gets `TimeZoneInfo.Local` as before, now shown in the prompt.

View File

@@ -1,7 +1,7 @@
# Telegram attachments (files in, voice in, files out) — design
Date: 2026-09-26
Status: draft
Status: implemented
Depends on: `2026-09-26-telegram-message-context-design.md` (`ChatInbox`, `InboundComposer` content steps, lease
contract); `2026-09-26-process-environment-hook-design.md` (keeps `OPENAI_API_KEY` out of `bash`). Used by:
`2026-09-26-scheduler-design.md` (`IChatChannel.SendTextAsync`).
@@ -118,10 +118,14 @@ ExecutionMode: Sequential (two sends in one turn arrive in call order)
Execution:
1. `ctx.Channel` is null → throw `send is not available: this session has no chat` (defensive; see registration).
2. Resolve with the existing `Paths.Resolve(ctx.Cwd, path)`. Reject unless the full path is inside `ctx.Cwd` (compare
with a trailing separator, case-insensitively on Windows). If the file or any parent is a symlink, resolve it with
`ResolveLinkTarget(returnFinalTarget: true)` and apply the same check to the target. Errors are
`path escapes the workspace: <path>`, `file not found: <path>` and `path is a directory, not a file: <path>`.
2. Resolve with the existing `Paths.Resolve(ctx.Cwd, path)`. Reject unless the full path is inside `ctx.Cwd` or is
`ctx.Cwd` itself (compare with a trailing separator, case-insensitively on Windows). Then resolve the real path the
way `realpath(3)` does (`WorkspacePaths.RealPath`): walk it from the filesystem root and replace each link by its
target as it is met, so links inside a link's target are followed too and a `..` in a target applies to the real
parent. That real path must be inside the real path of `ctx.Cwd`. More than 40 links (a loop) also fails.
(`ResolveLinkTarget(returnFinalTarget: true)` isn't enough: on Unix it follows only the last component of each hop,
so `a/l -> ../d/secret` with `d -> /etc` would pass.) Errors are `path escapes the workspace: <path>`,
`file not found: <path>` and `path is a directory, not a file: <path>` (also for `.`, the workspace itself).
3. Caption longer than `Limits.MaxCaptionLength` → `caption is <n> characters; the limit is <m>. Shorten it or put
the text in your reply.` The check runs before anything is sent, so a failure never delivers half an attachment.
4. Kind:
@@ -151,24 +155,31 @@ it (`chat.Channel`).
- `Limits`: photo 10 MB, document 50 MB, caption 1024 (Bot API limits), in `TelegramLimits` constants.
- **Ordering through the renderer.** Session events are rendered asynchronously from `ChatRenderer`'s queue. A file
sent directly could overtake an assistant message that ended just before the tool ran. `ChatRenderer` gets
`Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work)`. It queues a host-internal work item behind the
pending events and completes when the item has run. The renderer marks the status message as buried afterwards, as
`Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)`.
It queues a host-internal work item behind the pending events and completes when the item has run. The caller's
token cancels the returned task at once, even while the item still waits behind a slow upload; an item cancelled
before it starts is skipped, and a running one sees the token through the one it's given. The renderer marks the status message as buried afterwards, as
it does for replies, so the `⚙️ Working...` message moves back to the bottom on the next tool update.
`SendTextAsync` and `SendFileAsync` both go through it. The event channel becomes a channel of render items (an
event or a work item).
event or a work item). Disposing the renderer lets it drain its queue for up to 5 s; a work item still queued after
that, or queued after disposal, is cancelled without running. `ChatRenderer.SayAsync(text, ct)` (a `RunInOrderAsync`
around `TelegramSender.SendTextAsync`) sends plain-text notices and the `🎤` echo. `ChatRenderer.ReplyInOrderAsync`
runs the (private) reply path on the loop and returns the first message id and whether every chunk was delivered.
- **Files.** `TelegramSender.SendFileAsync(chatId, path, asPhoto, caption, ct) → Task<FileSendResult>`, with
`FileSendResult(int? MessageId, string? Error, bool PhotoRejected)`. It follows the sender's never-throw convention.
Each attempt opens a fresh `FileStream` and uses `InputFile.FromStream(stream, Path.GetFileName(path))` with
`SendPhoto` or `SendDocument`. Captions go as plain text (no parse mode), so the model's caption can't break the
HTML.
- A network failure (`RequestException` that is not an `ApiRequestException`) is retried after 1 s and 3 s, like
replies.
replies. The delays are `TelegramSender.FileRetryDelays`, an internal test seam.
- An API error is not retried. A 400 on `sendPhoto` comes back as `PhotoRejected = true`.
- `Auto` whose photo upload is rejected with a 400 (`PHOTO_INVALID_DIMENSIONS`, `IMAGE_PROCESS_FAILED`,
`PHOTO_SAVE_FILE_INVALID`, or any 400 on `sendPhoto`) is resent once as a document. `SentAs = Document`.
- Anything else becomes `ChatDeliveryException("Telegram refused the file: <description>")`.
- **Text.** `SendTextAsync(markdown)` reuses the renderer's reply path (`TelegramFormatter.Split` →
`ToHtml` → `SendHtmlAsync` with the plain fallback). It returns the id of the first message, or throws
`ChatDeliveryException` when every retry failed.
`ChatDeliveryException("Telegram did not accept the message.")` when a chunk still failed after its retries (the
other chunks are still sent).
### 4. Telegram — inbound: `InboundMedia`
@@ -194,14 +205,15 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp
message-context spec describes. All slow work (download, save, transcribe) happens inside `ComposeAsync` under the
lease, so polling never blocks and commands (`/stop`) act immediately. Ordering comes from the lease slots: a text
sent right after a voice note lands in the same batch, after the transcript. There is no separate inbound queue.
- Notices and the echo are posted through the renderer (`RunInOrderAsync` → `SendTextAsync`), so they appear in order
- Notices and the echo are posted through the renderer (`ChatRenderer.SayAsync`, plain text), so they appear in order
with rendered output and, on an idle chat, before the run's `⚙️ Working...`.
**Files** (`Document`, and `Video` with `FileName ?? "video.mp4"`):
1. `FileSize > 20 MB` (`TelegramLimits.MaxDownloadBytes`) → notice `That file is <x> MB; Telegram lets bots download
at most 20 MB. Share a link or a smaller file.` The message isn't prompted, caption included (the caption is
about the file). A `getFile` error saying "file is too big" (a missing `FileSize`) is reported the same way.
about the file). A `getFile` error saying "file is too big" (a missing `FileSize`) is reported the same way, as
`That file is over 20 MB; …`.
2. Target: `<cwd>/inbox/<date>-<stem><-N><ext>`. `date` is `TimeProvider.GetLocalNow()` as `yyyy-MM-dd`. The name
is `FileName ?? "file"`: `Path.GetFileName` drops any directory part, and every character other than a Unicode
letter or digit, `.`, `-` or `_` becomes `_`. Leading dots and underscores are trimmed (no hidden files). Stem is
@@ -211,21 +223,35 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp
notice `I could not download that file.` The message isn't prompted.
4. Prompt text: the caption (if any), a blank line, then
`[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. The path is relative to the workspace
and uses `/`.
and uses `/`. Sizes read `N bytes`, `N.N KB` or `N.N MB`.
5. Image documents: when `MimeType` is `image/jpeg|png|webp|gif` and the size is ≤ `Telegram:InlineImageMaxBytes`
(default 3 750 000), the saved bytes are also attached as `ImageContent(base64, mime)`. Larger images are
path-only, and the line says `(too large to show inline; downscale it with bash if you need to see it)`.
(default 3 750 000), the saved bytes are also attached as `ImageContent(base64, mime)`. Documents aren't
re-encoded by Telegram, so the declared type can be wrong (HEIC named `.png`, a truncated file); a provider rejects
such an image, and the persisted prompt would then break every later turn. So the leading bytes decide: JPEG
`FF D8 FF`, PNG `89 50 4E 47 0D 0A 1A 0A`, GIF `47 49 46 38` or WebP `RIFF....WEBP`, and the image is attached
under the type its bytes show. Bytes that are none of these make the file path-only, with the suffix
` (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)`. Larger images are
path-only, and the line gets a parenthesised suffix after the size:
`[File received: inbox/x.png (image/png, 5.0 MB) (too large to show inline; downscale it with bash if you need to see it)]`.
**Voice and audio** (`Voice`, `Audio`):
1. No `ISpeechToText` configured → notice `Voice messages need speech-to-text: set OPENAI_API_KEY (or
Telegram:Speech:ApiKey) on the bot host.` `Audio` is still saved to the inbox as a file (below); `Voice` isn't
prompted unless it has a caption.
2. Over 20 MB → the file-size notice, as for files.
3. Download into memory. Voice arrives as `.oga`, which OpenAI rejects, so it's renamed to `voice.ogg`
(`audio/ogg`). Audio keeps `FileName ?? "audio"` and its `MimeType`.
2. Over 20 MB → the file-size notice, as for files, and the message isn't prompted (for `Audio`, step 2 has already
refused it).
3. Voice is downloaded into memory; a failed download sends `I could not download that file.` and the message isn't
prompted. Audio is read back from the file step 2 saved, so it's downloaded only once. Voice arrives as `.oga`,
which OpenAI rejects, so it's renamed to `voice.ogg` (`audio/ogg`). OpenAI picks the format from the file
extension, so audio goes as an ASCII `audio.<ext>`: the original extension when OpenAI knows it (`flac`, `m4a`,
`mp3`, `mp4`, `mpeg`, `mpga`, `ogg`, `wav`, `webm`; `.oga`/`.opus` become `.ogg`), else one from `MimeType`
(`mp3` when unknown), with its `MimeType`. A missing or non-ASCII name would otherwise lose the extension.
4. `Transcript = await stt.TranscribeAsync(bytes, fileName, mime, ct)`, trimmed. Empty → notice `I couldn't make
out any words in that voice message.` An exception → notice `Could not transcribe the voice message: <reason>`.
That includes a cancellation the message's own token didn't cause, such as the `HttpClient` timeout; only a
shutdown (the caller's token) ends composing silently. The same rule holds for downloads, and `UpdateRouter`
treats any other `OperationCanceledException` from `ComposeAsync` as a failure (`I could not read that message.`).
5. Echo `🎤 <transcript>` as plain text, cut to 4096 characters with `…`.
6. Prompt text: the caption (if any), then `[Voice message] <transcript>`. For `Audio`, the file is also saved to
the inbox like a document and gets its own `[File received: …]` line: it's something the user might want
@@ -252,7 +278,13 @@ public interface ISpeechToText
as given), `model`, `response_format=json`, and `language` when configured.
- Non-2xx → `SpeechToTextException("OpenAI transcription failed: HTTP <status> <first 200 chars of body>")`.
A non-JSON body or a missing `text` gives its own message. The key never appears in messages or logs.
- `HttpClient` comes from `AddHttpClient<OpenAiSpeechToText>()`, with a 2-minute timeout.
- The client timeout (a `TaskCanceledException` while the caller's token isn't cancelled) →
`SpeechToTextException("OpenAI transcription failed: the request timed out.")`; a transport failure
(`HttpRequestException`) → the same prefix plus its message. The caller's own cancellation still propagates.
- `HttpClient` comes from `AddHttpClient<ISpeechToText, OpenAiSpeechToText>()` (package `Microsoft.Extensions.Http`), with a 2-minute timeout.
The singleton `InboundMedia` keeps that typed client for the process lifetime, so the factory never rotates its
handler: the primary handler is a `SocketsHttpHandler` with `PooledConnectionLifetime = 5 min` (and an infinite
handler lifetime), so DNS changes are still picked up.
`SpeechOptions`, the `Telegram:Speech` section:
@@ -265,7 +297,7 @@ public interface ISpeechToText
The environment fallbacks (`OPENAI_API_KEY`, `STT_MODEL`) are read in `TelegramOptions.ApplyEnvironment`, next to
the existing `TELEGRAM_*` shortcuts, and only when the `Telegram:Speech` setting is empty. An empty variable counts
as unset (the Docker compose file passes both through, empty by default). The OpenAI provider
as unset (a deployment, such as a Docker compose file, may pass both through empty). The OpenAI provider
extension, if loaded, reads `OPENAI_API_KEY` on its own; the two don't share state.
`Program.cs` registers `ISpeechToText` only when a key is present, and logs `Speech to text: <model>` or
`Speech to text: off (no OPENAI_API_KEY)`. `OPENAI_API_KEY` isn't on the Claude Code provider's denied-variable
@@ -295,7 +327,8 @@ accepts the consequence that the agent doesn't see them.
| file > 20 MB sent to the bot | size notice, nothing reaches the agent |
| download fails | `I could not download that file.`, partial file deleted, no prompt |
| voice with no STT configured | not-configured notice; caption (if any) is still prompted |
| STT HTTP/parse error | `Could not transcribe the voice message: …`, no prompt unless there's a caption |
| STT HTTP/parse error or timeout | `Could not transcribe the voice message: …`, no prompt unless there's a caption (an audio file's `[File received: …]` line is still prompted) |
| image document whose bytes aren't JPEG/PNG/WebP/GIF | saved and prompted path-only, with the "not shown inline" note |
| `send` path outside the workspace / missing / directory | error tool result, nothing sent |
| `send` over the photo / document limit | error tool result naming the limit |
| caption over 1024 | error tool result, nothing sent |
@@ -332,7 +365,9 @@ xunit, no network, no paid API.
- `auto` `.png` passes `Auto`; `auto` `.pdf` and `.gif` pass `Document`; explicit `photo`/`document` are honoured.
- An `auto` image over the photo limit goes as `Document`; an explicit `photo` over the limit fails.
- A document over the limit, a caption over the limit, `../x`, an absolute path outside, and a symlink inside the
workspace pointing outside all fail, and the channel is never called.
workspace pointing outside all fail, and the channel is never called. So do (Unix only) a link whose target runs
through a directory link pointing outside (`a/l -> ../d/secret`, `d -> <outside>`), a link whose target has a `..`
after a directory link (`l -> x/../secret`, `x -> <outside>/deep`) and a link loop. `.` is a directory.
- Missing file and directory errors. Absolute paths inside the workspace are accepted.
- A `ChatDeliveryException` becomes an error tool result with its message.
- Result text and details. Sparse files (`FileStream.SetLength`) stand in for big files.
@@ -351,7 +386,8 @@ xunit, no network, no paid API.
tests can assert the uploaded bytes, name and caption (`Uploads`).
- `OnSend` can throw an `ApiRequestException(400, "PHOTO_INVALID_DIMENSIONS")` to exercise the fallback.
`Updates` gets `Document(...)`, `Voice(...)`, `Audio(...)` and `Sticker()`. `ChatHost` takes an optional
`Updates` gets `DocumentMessage(...)`, `VideoMessage(...)`, `VoiceMessage(...)` and `AudioMessage(...)`, and reuses
`StickerMessage()` (a member named like a Telegram type would hide that type inside the class). `ChatHost` takes an optional
`ISpeechToText` (a `FakeSpeechToText` returning a scripted transcript or throwing) and uses the optional
`TimeProvider` the message-context spec adds (`FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing`).
@@ -364,9 +400,9 @@ xunit, no network, no paid API.
note.
- Voice with STT: the `🎤` echo comes before the answer, the prompt contains `[Voice message] …`, and the file name
handed to STT ends in `.ogg`. A 5000-character transcript echo is cut to 4096.
- Voice without STT: the not-configured notice, and no run. STT throwing: the failure notice, and no run.
- Voice without STT: the not-configured notice, and no run. STT throwing, or timing out (a `TaskCanceledException` nobody asked for): the failure notice, and no run.
- Ordering: a voice note followed immediately by text produces one prompt with the transcript before the text (the
fake STT is delayed past the debounce window). `/stop` sent during a slow transcription is handled at once.
fake STT is held on a gate while a `FakeTimeProvider` moves well past the debounce window, and nothing is prompted until it is released). `/stop` sent during a slow transcription is handled at once.
- A document with a `/new` caption doesn't run `/new`. A voice note doesn't answer a pending `InputAsync`.
- A sticker gets the "I can read…" reply.
- `send` end to end: the faux provider calls `send` for `out/chart.png`, a `sendPhoto` upload with the caption
@@ -379,7 +415,7 @@ xunit, no network, no paid API.
**Speech (`OpenAiSpeechToTextTests`)** with a stub `HttpMessageHandler`: the multipart form has `file` (name and
content type), `model` and `response_format=json`, plus `language` when set; the bearer header is present; the
transcript is trimmed; non-2xx, non-JSON and missing `text` each give their own message, which never contains the
key.
key. A client timeout is a `SpeechToTextException`; the caller's cancellation stays a cancellation.
## Out of scope
@@ -394,9 +430,10 @@ key.
- Cleaning up `inbox/`: files stay until the user or agent removes them.
- Reply and forward context headers (the message-context spec).
## Open questions
## Resolved questions
1. Should `Audio` files (music, podcasts) be transcribed by default, or only saved? This spec follows
VolcanicSecretary and transcribes, which costs money on long files.
2. Is 3.75 MB the right inline threshold for image documents, or should the host downscale larger images (with a
dependency such as ImageSharp) rather than send only the path?
1. `Audio` files (music, podcasts) are transcribed by default, as in VolcanicSecretary, and also saved to the inbox.
The cost of transcribing long files is accepted.
2. 3.75 MB (`Telegram:InlineImageMaxBytes`) stays the inline threshold for image documents, and the host doesn't
downscale larger ones: no image-processing dependency. The model gets the path and can downscale with `bash` if it
needs to see the image.

View File

@@ -1,11 +1,11 @@
# Telegram message context, grouping and presence — design
Date: 2026-09-26
Status: draft
Status: implemented
Depends on: `2026-09-26-telegram-attachments-design.md`, which plugs its content steps (files, speech) into
`InboundComposer` and uses the renderer's `RunInOrderAsync` for notices and the `🎤` echo.
Also `2026-09-26-scheduler-design.md` §7 (`ChatSettingsStore`, the chat's time zone for the timestamp header; if this spec
lands first, the header uses `TimeZoneInfo.Local` until the store exists).
Also `2026-09-26-scheduler-design.md` §7 (`ChatSettingsStore`, the chat's time zone for the timestamp header; the
header's zone comes from `ChatRegistry.TimeZoneFor`, which returns `ChatSettingsStore.TimeZoneFor(chatId)`).
## Problem
@@ -36,7 +36,7 @@ spec ports that behaviour. Header wording is kept identical so both bots read th
| Topic | Decision |
|---|---|
| Header format | One English line per header in square brackets, before the body: reply header first, then forward header, then the message text. Same wording as the reference. The batch's local timestamp line (next row) comes before all of them. |
| Local timestamp | Every prompt the host sends to the agent starts with one timestamp line in the chat's zone: `[Sat 2026-09-26 14:03 Europe/Berlin]` (invariant culture, 24 h, IANA id). **One per flushed batch**, not per part, before the first part's reply/forward headers. The time is the first part's `Message.Date` (when the user sent it, not when the batch flushed or a transcript finished), converted to the zone. It's cache-safe: it's in the appended user message, not in the system prompt prefix. Steering messages get it too, since they're prompts. |
| Local timestamp | Every prompt the host sends to the agent starts with one timestamp line in the chat's zone (except a `/command` the bot doesn't handle itself, which goes to the session as typed; see *What bypasses the inbox*): `[Sat 2026-09-26 14:03 Europe/Berlin]` (invariant culture, 24 h, IANA id). **One per flushed batch**, not per part, before the first part's reply/forward headers. The time is the first part's `Message.Date` (when the user sent it, not when the batch flushed or a transcript finished), converted to the zone. It's cache-safe: it's in the appended user message, not in the system prompt prefix. Steering messages get it too, since they're prompts. |
| Where the timestamp is built | **Host-side** (Telegram), not in the core. The core doesn't know when a user message was sent, and a headless caller may not want a header; the zone it uses is the same one the core session carries (`ChatSettingsStore.TimeZoneFor(chatId)`, which also feeds `AgentSessionOptions.TimeZone`, scheduler spec §7–§8), so prompt date, header and `bash`'s `TZ` always agree. Scheduled prompts and watcher matches get the same header from `TelegramJobDelivery` (scheduler spec §6). |
| Reply content | `quote.text` if present (reply with quote), else the replied message's `text` → `caption` → media descriptor. Capped at **500** Unicode scalars (first 499 + `…`). Quote entities are dropped. |
| Who was replied to | `ReplyToMessage.From.Id == bot.BotId` → "your message". Any other `From` → "your own earlier message" (private chats only, so it's the user). No `From` → "the earlier message". The bot id comes from `ITelegramBotClient.BotId` (parsed from the token), so no `getMe` is needed. |
@@ -48,7 +48,7 @@ spec ports that behaviour. Header wording is kept identical so both bots read th
| Race handling | The reference's pending counter, as leases. A lease is taken **synchronously in update order** while routing. The window timer is only armed when no lease is open. Parts merge in lease order, which is Telegram order, not completion order. This improves on the reference, which merges in completion order. |
| Hung preparation | A lease open longer than **2 min** (`ChatInbox` constructor default, not user config) stops holding the batch. The batch flushes without it, and the late part becomes a prompt of its own. |
| Slow work off the poll loop | Downloads (and the sibling's transcription) run in a background task per message, holding the lease. `RouteAsync` returns right after taking the lease, so `/stop` still gets through. Today the photo download is awaited inline. |
| What bypasses the inbox | Bot commands, callback queries, and plain text that answers a pending `TelegramUi` input. Unchanged rules, checked before the inbox, and only for messages with no attachment: a message with an attachment is never a command or a pending answer, and its caption is prompt text (attachments spec). `/new` and `/resume` discard the buffer (open leases still finish, but their parts are dropped). |
| What bypasses the inbox | Bot commands, callback queries, and plain text that answers a pending `TelegramUi` input. Unchanged rules, checked before the inbox, and only for messages with no attachment that weren't forwarded: a message with an attachment is never a command or a pending answer, and its caption is prompt text (attachments spec); a forwarded message is content, so a forwarded `/new …` goes to the agent with its `[Forwarded from …]` label. `/new`, `/stop` and a `/resume` that names exactly one conversation discard the buffer first, before any await, so a buffered message can't flush into the old conversation (open leases still finish, but their parts are dropped). A `/resume` whose switch an extension cancels has already dropped the buffer. A `/command` the bot doesn't handle itself goes to the session directly, without headers or the window, so extension commands (`/claude-code-doctor`, `/claude-code-login`) still parse. `AgentSession.PromptAsync` only recognises a command at the start of the text. Such a command doesn't wait for the batch in the inbox, so it can overtake messages sent just before it; that is accepted, since extension commands don't depend on them. |
| Delivery | A flush calls `ChatContext.PromptInBackground(text, images)`, which is unchanged. While a run is active the merged prompt steers it (`StreamingBehavior.Steer`), as today. |
| đź‘€ reaction | At flush time, if `session.IsStreaming`, every message in the batch gets a đź‘€ reaction (`setMessageReaction`), best-effort. An album has at most 10 items. It is not sent when the batch starts a new run. |
| 🟢 Online | Once per process, after `BotSetupService`'s first successful `getMe` + `setMyCommands`, it sends `🟢 Online.` to the owner (`AllowedUserIds[0]`). Best-effort: a failure (for example 403 because the owner never started the bot) is logged and not retried. |
@@ -145,7 +145,7 @@ if the part has a body or images, as in the reference.
Unsupported kinds never reach the composer: the router checks the message kind before taking a lease. Anything other
than text, photo, document, video, audio or voice gets `I can read text, photos, files and voice messages.` (the
sibling spec's text, replacing today's "I can read text and photos.").
sibling spec's text, `InboundComposer.UnsupportedReply`), and takes no lease.
The sibling spec relies on this contract: **any slow work for a message happens inside `ComposeAsync`, while the
router holds that message's lease.** A preparer never talks to `ChatInbox` itself.
@@ -154,11 +154,11 @@ router holds that message's lease.** A preparer never talks to `ChatInbox` itsel
```csharp
public sealed class ChatInbox(Func<IReadOnlyList<InboundPart>, Task> onFlush, TimeSpan window,
TimeProvider time, TimeSpan? maxHold = null) : IAsyncDisposable
TimeProvider time, TimeSpan? maxHold = null, ILogger? logger = null) : IAsyncDisposable
{
public InboundLease Begin(int messageId); // reserves the next slot; cancels an armed timer
public void Discard(); // /new, /resume: drop buffered and in-flight parts (generation bump)
public Task DrainAsync(); // completes when nothing is buffered, no lease is open and the last flush was handed over
public void Discard(); // /new, /stop, /resume: drop buffered and in-flight parts (generation bump)
internal Task DrainAsync(); // test helper: completes when nothing is buffered, no lease is open and the last flush was handed over
}
public sealed class InboundLease : IDisposable
@@ -178,7 +178,9 @@ Rules (one lock, timer from `TimeProvider.CreateTimer`):
4. When a slot has been open for `maxHold` (default 2 min, tracked by a second timer), it is detached. The batch is
handled as if that slot were empty. Its later `Complete` is treated as a new `Begin` + `Complete` at the end.
5. `Discard` bumps a generation counter and clears the slots. Leases from an older generation complete into nothing.
6. `DisposeAsync` (from `ChatContext.DisposeAsync`) discards and logs how many parts were dropped.
6. `DisposeAsync` (from `ChatContext.DisposeAsync`) discards and logs how many parts were dropped. The optional
logger also records a hung lease being detached and a failing `onFlush`, which is caught so it can't stop later
batches.
Race walk-through (comment, then a forwarded voice that takes 3 s to transcribe):
@@ -211,17 +213,18 @@ _ = Task.Run(async () =>
```
The pending-input rule adds one condition: a message with `ForwardOrigin` never answers a pending question. A
forwarded message is content, not an answer. `ChatCommands` calls `chat.Inbox.Discard()` on `/new` and a successful
`/resume`.
forwarded message is content, not an answer, and never a command either. `ChatCommands` calls `chat.Inbox.Discard()`
on `/new`, `/stop` and a `/resume` that names exactly one conversation, before its first await.
### `ChatContext` / `ChatRegistry` changes
- `ChatContext` gets an `Inbox` property. `ChatRegistry.CreateAsync` builds the inbox with
`options.InputDebounce`, an injected `TimeProvider` (default `TimeProvider.System`, registered in DI), and an
`options.InputDebounce`, a `TimeProvider` (an optional last `ChatRegistry` constructor parameter, default
`TimeProvider.System`; `Program.cs` registers `TimeProvider.System` with `TryAddSingleton`), and an
`onFlush` that:
1. renders the parts to `(text, images)`, with the timestamp header in the chat's current zone
(`ChatSettingsStore.TimeZoneFor(chatId)`, read at flush time, so a `/timezone` change applies to the next
batch). An image-only batch still gets the header line as its text;
(`ChatRegistry.TimeZoneFor(chatId)`, which returns `ChatSettingsStore.TimeZoneFor(chatId)`; read at flush time, so
a `/timezone` change applies to the next batch). An image-only batch still gets the header line as its text;
2. if `session.IsStreaming`, calls `sender.ReactAsync(chatId, id, "đź‘€")` for each part's message id, fire-and-forget;
3. calls `chat.PromptInBackground(text, images)`.
- The check in step 2 and the steer in step 3 can race with the end of a run. In that case the message starts a new
@@ -301,6 +304,7 @@ signature, and a bot id taken from `BotId` when `From.IsBot` is false.
- `Discard` with an open lease: the late completion is dropped;
- double `Dispose` is a no-op;
- `maxHold` detaches a hung lease, and its late part flushes as its own batch;
- a throwing `onFlush` doesn't stop the next batch, and `DrainAsync` still completes;
- album header collapsing in the renderer.
**`MessageContextChatTests`** (through `ChatHost` + `FauxProvider`, asserting on `Faux.Requests[..].Messages.Last()`):
@@ -312,7 +316,8 @@ signature, and a bot id taken from `BotId` when `From.IsBot` is false.
- a three-photo album with one caption is one request with three images and one forward label;
- a message sent during a scripted long run (a `FauxTurn` tool that waits on a gate) gets a đź‘€
`SetMessageReactionRequest` and appears as a steering message. A message sent while idle gets no reaction;
- `/new` during the window drops the buffered text;
- `/new`, `/stop` and `/resume <id>` during the window drop the buffered text;
- a forwarded `/new …` reaches the model with its forward label and starts no new conversation;
- a forwarded text while `TelegramUi` waits for input goes to the agent, not to the question;
- `/stop` routed while a photo download is blocked (an `OnSend` gate on `GetFileRequest`) is handled right away,
which proves the poll loop isn't blocked.

View File

@@ -22,7 +22,8 @@ Open the bot in Telegram and say hello.
## Configuration
Environment variables, or the `Telegram` section of `appsettings.json` (`Telegram__Name` in the environment):
Environment variables, or the `Telegram` section of `appsettings.json` (`Telegram__Name` in the environment; the Google
settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section):
| Setting | Default | Notes |
|---|---|---|
@@ -31,10 +32,31 @@ Environment variables, or the `Telegram` section of `appsettings.json` (`Telegra
| `Telegram:WorkspaceRoot` | `~/.volcanic/telegram-workspaces` | Chat `<id>` works in `<root>/<id>`; put `AGENTS.md`, `PERSONA.md`, `MEMORY.md` there |
| `Telegram:Model` | `claude-code/claude-opus-5-5` | Any `provider/model` the Claude Code provider serves |
| `Telegram:EditInterval` | `00:00:01.5` | Minimum time between edits of the "Working..." status message |
| `Telegram:InputDebounce` | `00:00:00.6` | How long the bot waits after a message for more before sending them to the agent as one request. `0` disables the wait; messages still being read together (e.g. a photo still downloading and a text after it) are still sent as one request. Must not be negative |
| `Telegram:ReconnectInitialDelay` / `ReconnectMaxDelay` | `1 s` / `60 s` | Backoff after lost connection |
| `DEFAULT_TIMEZONE` / `Telegram:DefaultTimeZone` | — (the machine's zone) | IANA zone for chats that haven't set one with `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup. Also the default for the core (`AgenticCoreOptions.DefaultTimeZone`) |
| `Scheduler:Enabled` | `true` | Reminders, scheduled prompts and watcher scripts (see [Scheduling](#scheduling)) |
| `Scheduler:StorePath` | `<agent dir>/scheduler/jobs.json` | One file for every chat's jobs, outside the workspaces |
| `Scheduler:DefaultScriptTimeout` | `00:05:00` | Watcher timeout when a job sets none (1 s to 1 h) |
| `Scheduler:MaxJobsPerTarget` | `50` | Jobs per chat |
| `Scheduler:Shell` | `/bin/sh -c` (`cmd.exe /d /s /c` on Windows) | The watcher shell as a list, command appended; same as `bash` by default |
| `OPENAI_API_KEY` / `Telegram:Speech:ApiKey` | — | Turns voice transcription on. Hidden from `bash` and redacted from tool output |
| `STT_MODEL` / `Telegram:Speech:Model` | `gpt-4o-transcribe` | `gpt-4o-mini-transcribe` and `whisper-1` also work |
| `Telegram:Speech:BaseUrl` | `https://api.openai.com/v1` | Any OpenAI-compatible transcription endpoint |
| `Telegram:Speech:Language` | — | Optional ISO-639-1 hint, e.g. `de` |
| `Telegram:InlineImageMaxBytes` | `3750000` | Image files up to this size are also shown to the model; must be positive |
| `GOOGLE_CLIENT_ID` / `GoogleCalendar:ClientId` | — | Optional. With the secret, every chat gets the `calendar` tool and the `/calendar_*` commands; see [google-calendar.md](google-calendar.md) |
| `GOOGLE_CLIENT_SECRET` / `GoogleCalendar:ClientSecret` | — | Optional; redacted from tool output and hidden from `bash` |
| `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through to `bash`, scheduled scripts and MCP servers despite the deny-list (e.g. `GH_TOKEN` for `gh`). Added to `ProcessEnvironment:Allow` |
| `ProcessEnvironment:Deny` / `ProcessEnvironment:Allow` | built-in deny-list | Added to the defaults (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, their `Section__Key` forms and the named provider credentials), never replacing them. Allowing a name that is denied by name stops startup |
| `ProcessEnvironment:HardenHostProcess` | `true` | Linux: the bot process is made non-dumpable. `ProcessEnvironment__HardenHostProcess=false` for a debugging session only |
| `PLAYWRIGHT_ENABLED` / `Mcp:Playwright:Enabled` | `false` | Browser tools through `@playwright/mcp` ([mcp.md](mcp.md)) |
| `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` | `true`, `chromium`, —, `npx` | See [mcp.md](mcp.md#the-playwright-preset) |
| `Mcp:Servers:<name>` | — | Other stdio MCP servers ([mcp.md](mcp.md#configuration)) |
Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-<chatId>/`; after a restart each chat resumes
its most recent conversation.
its most recent conversation. Each chat's time zone is kept in `<agent dir>/telegram/chat-settings.json`, so it
survives `/new` and restarts.
## Using it
@@ -50,37 +72,164 @@ Write normally. While the agent works, a status message stays at the bottom of t
It lists the last five tool calls (`â–¶` running, `âś“` done, `âś—` failed) and is deleted when the run finishes.
Answers are not streamed: each one is sent complete when the model finishes it (split into several messages past
Telegram's 4096 character limit).
A message sent while the agent works steers the current run. Photos (with or without a caption) are passed to the model.
A message sent while the agent works steers the current run, and the bot reacts to it with đź‘€ so you can see it was
added. Besides text, you can send photos, files and voice messages (see [What you can send](#what-you-can-send)).
Along with your text, the agent sees:
- **Replies and quotes.** Reply to one of the bot's messages (or one of yours), or quote part of it, and the agent gets
a line such as `[Reply to your message: "Which plant do you mean?"]` before your text.
- **Forwards**, labelled with their source: `[Forwarded from Daily News (Anna K.)]`.
- **The local time** you sent the message, in the chat's time zone (`/timezone`; by default `Telegram:DefaultTimeZone`, else the zone of the machine running the bot):
`[Sat 2026-09-26 14:03 Europe/Berlin]`, once per request.
Messages sent within about 0.6 s of each other (`Telegram:InputDebounce`), such as a forward with your comment or
an album, reach the agent as one request. Every message waits that long before it's sent. Commands that an extension
handles (for example `/claude-code-doctor`) are sent at once, as typed, with no time header. `/new`, `/stop` and
`/resume` drop a message that is still waiting. A forwarded message is never taken as a command.
After a start the bot sends `🟢 Online.` to the owner (the first id in `AllowedUserIds`), once. If the owner never
started a chat with the bot, Telegram refuses the message; the bot logs that and carries on.
| Command | |
|---|---|
| `/new` | Start a new conversation |
| `/stop` | Stop the current run |
| `/status` | Model, thinking level, session, context usage, workspace |
| `/status` | Model, thinking level, session, context usage, time zone, number of scheduled jobs, workspace |
| `/sessions`, `/resume <id>` | List and resume earlier conversations (an id prefix is enough) |
| `/compact` | Summarise the conversation to free context |
| `/thinking [level]` | Show or set the thinking level (Claude Code `--effort`) |
| `/timezone [name\|reset]` | Show or set this chat's time zone (IANA name such as `Asia/Tokyo`; `reset` returns to the default) |
| `/jobs` | This chat's scheduled jobs with their next run |
| `/cancel <id>` | Cancel one of them |
| `/help` | Short help |
| `/calendar_connect` | Connect this chat's Google Calendar (link and code; the bot confirms when you approve) |
| `/calendar_status` | The connected account and time zone, or the pending code |
| `/calendar_disconnect` | Revoke and delete this chat's calendar token, or cancel a pending connect |
Any other `/command` is passed to the session, so extension commands such as `/claude-code-doctor` and
`/claude-code-login` work.
`/claude-code-login` work. `/mcp` lists the MCP servers (see [mcp.md](mcp.md#mcp)).
When an extension asks something, the question appears with buttons (or as a reply prompt for free text).
The `/calendar_*` commands exist only when both the Google client id and secret are set (`GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` or the `GoogleCalendar` section).
If the Claude subscription session behind the bot expires, the **first id in `AllowedUserIds`** (the bot owner)
gets the re-login link and code prompt in their own private chat, regardless of which chat's request triggered
it. That user must already have started a chat with the bot — Telegram bots cannot message a user first — or
the login goes unanswered and the triggering request fails ("no code was entered"). The link, the steps and the
code prompt arrive as one message. While that prompt is pending, the owner's next plain text message in that chat
is taken as the code and is not sent to the agent; photos and `/commands` still behave normally. Don't ask the bot
something else in the owner chat until the code is in: a second free-text question in that chat (for example from
an extension) replaces the pending prompt and cancels the login. See
is taken as the code and is not sent to the agent; photos, files, voice messages and `/commands` still behave
normally. A forwarded message never counts as the code. Don't ask the bot something else in the owner chat until the code is in: a second free-text
question in that chat (for example from an extension) replaces the pending prompt and cancels the login. See
[claude-code-provider.md](claude-code-provider.md#session-expiry-and-re-login).
### What you can send
- **Text and photos.** A photo, with or without a caption, is passed to the model as an image.
- **Documents, videos and audio files** are saved to `inbox/<yyyy-MM-dd>-<name>` in the chat's workspace (a number is
added when the name is taken: `-1`, `-2`, ...). The agent gets the caption plus a line such as
`[File received: inbox/2026-09-26-report.pdf (application/pdf, 1.2 MB)]`. An image sent "as file" (JPEG, PNG, WebP,
GIF) up to 3.75 MB (`Telegram:InlineImageMaxBytes`) is also shown to the model; a larger one, or one whose contents
aren't really one of those formats, reaches it as a path only.
- **Voice messages and audio files** are transcribed with OpenAI (`gpt-4o-transcribe` by default). The bot echoes
`🎤 <transcript>` and the agent gets `[Voice message] <transcript>` (an audio file also gets its `[File received: …]`
line). Without an API key the bot replies with how to set one up (`OPENAI_API_KEY`).
- Telegram lets bots download at most 20 MB. A bigger file is refused with a note, and nothing reaches the agent.
- A message with an attachment is never a command and never answers a pending question: its caption is prompt text.
- Anything else (stickers, video notes, locations, polls) gets `I can read text, photos, files and voice messages.`
### Files from the agent
The agent's `send` tool delivers a file from the chat's workspace into the chat, before the agent's reply. Images go
as photos (up to 10 MB), other files as documents (up to 50 MB), with an optional caption of up to 1024 characters. A
photo Telegram rejects (for example for its dimensions) is resent as a document. `send` accepts only files inside the
chat's workspace and delivers only to that chat.
### Scheduling
Ask the agent in plain words. It has a `schedule` tool with three kinds of job:
- "Remind me at 9 tomorrow to call the dentist" is a **reminder**: at 9 the bot sends the text as is,
`⏰ Call the dentist`, with no model call.
- "Every weekday at 8, summarise what's in `inbox/`" is a **prompt**: when it fires, the agent handles it like a
message from you, with the same time header.
- "Check this page every 10 minutes and tell me only if the price drops below 50" is a **watcher**.
For a watcher the agent writes a script (usually in `scripts/`, tested with `bash` first). Every run the bot runs the
script itself, **without the agent**:
- exit 0 with no output means "nothing yet" and costs nothing: no model call, nothing in the conversation;
- exit 0 with output means the condition is met: the output always goes to the agent, which writes you the message
(one model call per match);
- a non-zero exit or a timeout is reported once, straight to the chat, with the last 20 lines of the script's errors.
Later identical failures stay quiet until the script succeeds again.
Scripts run in the chat's workspace with the same filtered environment as `bash` and `TZ` set to the chat's zone.
Known secrets are redacted from their output.
When a job fires while the agent is working, a scheduled prompt waits until the current work is done; it never
interrupts. Occurrences that come due while the same job is still running are skipped.
Times are in the chat's zone. `/timezone Asia/Tokyo` (or telling the agent where you are; it has a `set_timezone`
tool) changes it for the date in the agent's instructions, `bash`, scheduled scripts, message timestamps and Claude
Code. Recurring jobs follow the new zone ("every day at 9" means 9 where you are now); one-off jobs keep their moment
and are just shown in the new zone. The reply says how many of each.
After a restart, a one-off that came due while the bot was down is delivered as soon as it's back, marked
`(due …, delivered late)` (for prompts: "This was due at …; the bot was offline."). Recurring jobs don't catch up:
missed occurrences are skipped.
Jobs are per chat: `/jobs` and the agent only see this chat's jobs. Direct reminders and failure alerts aren't part
of the conversation, so the agent can't see them later; use a prompt job for things that should be. With
`Scheduler:Enabled=false`, `/jobs` and `/cancel` reply that scheduling is turned off, and `/timezone` still works.
### Google Calendar
Each chat connects its own Google account with `/calendar_connect`, and the agent's `calendar` tool only uses that
chat's account, so nobody sees another chat's calendar through the agent. The bot polls Google by itself after
sending the link and code and confirms in the chat once access is approved. Setup of the Google Cloud app, token
storage and time zones are in [google-calendar.md](google-calendar.md).
## Security
The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids
are served (everyone else is ignored and logged), and only in private chats. Run the bot as a dedicated user or in a
container, and treat the workspace root as writable by anyone on the allow-list.
are served (everyone else is ignored and logged), and only in private chats. Run it in the container image
([docker.md](docker.md)), which is the recommended setup, or at least as a dedicated user, and treat the workspace root
as writable by anyone on the allow-list.
Scheduled watcher scripts run unattended as the bot's OS user, with the same trust as the `bash` tool that every
allow-listed user already has, and the same filtered environment.
`send` refuses paths outside the chat's workspace, symlinks included, so a prompt-injected "send ~/.ssh/id_rsa" fails
visibly (it is no barrier against `bash cp`). The speech API key is kept out of the agent's processes like the bot
token.
Secrets are removed from the environment of `bash`, scheduled scripts and MCP servers: every variable on the deny-list
(see `ProcessEnvironment:Deny` above) is dropped, and so is any variable whose value is the bot token, whatever its
name (a Coolify alias, for example). `PROCESS_ENV_ALLOW` lets a named variable through, for example `GH_TOKEN` so the
agent can use `gh`. At startup the bot logs which rules apply, names only:
`Process environment: 15 deny rules, allow: GH_TOKEN`.
On Linux the bot's own environment can't be read through `/proc/<pid>/environ` or ptrace: at startup the process
makes itself non-dumpable (`prctl(PR_SET_DUMPABLE, 0)`). Set `DOTNET_EnableDiagnostics=0` in the bot's environment
too (the Docker image, [docker.md](docker.md), sets it): otherwise the .NET diagnostic socket still hands the
environment to any process of the same user, and the bot logs a warning saying so.
The bot token and the other configured secrets are replaced by `[REDACTED]` in tool output, so the model never sees
them in a tool result, whichever file or process they were read from. Encoded forms (base64, URL-encoded, split
across lines) aren't matched.
`ProcessEnvironment:HardenHostProcess=false` (`ProcessEnvironment__HardenHostProcess=false` in the environment),
together with unsetting `DOTNET_EnableDiagnostics`, turns the hardening off for a debugging session. While it is on:
- the bot writes no core dumps, including .NET's own `createdump` on a crash;
- `/proc/<pid>/*` of the bot is owned by root, so tools that read it for that pid (such as `lsof -p`) see less;
- `dotnet-dump`, `dotnet-trace`, `dotnet-counters` and debuggers can't attach.
The agent's own processes are unaffected. Turn it back on once you're done.
This is not a sandbox. The agent still runs as the bot's user and can read the agent dir, session transcripts and any
file that user can read. The container is the isolation boundary.
## Connection loss

View File

@@ -5,7 +5,8 @@ The core has no user interface. A UI is a process that
1. creates one `AgentSession` per conversation,
2. turns incoming user actions into calls on the session (`PromptAsync`, `AbortAsync`, `SetModelAsync`, ...),
3. subscribes to `AgentSessionEvent`s and renders them,
4. implements `IUserInterface` so extensions can ask the user questions.
4. implements `IUserInterface` so extensions can ask the user questions,
5. optionally implements `IChatChannel` so the agent can send files (and schedulers text) into the chat.
This guide uses Telegram as the worked example; the finished bot lives in `hosts/Volcanic.AgenticCore.Telegram`
(see [telegram-bot.md](telegram-bot.md)) and is the reference for everything below. Everything transfers to Slack, Discord, a web socket
@@ -59,6 +60,8 @@ list) and set `AgenticCoreOptions.SessionStoreFactory`.
| Text message while idle | `session.PromptAsync(text, images)` |
| Text message while `session.IsStreaming` | `session.PromptAsync(text, images, StreamingBehavior.Steer)` (or `FollowUp`) |
| Photo | download it, wrap as `ImageContent(base64, "image/jpeg")`, pass in `images` |
| Document / video / audio | download (≤ 20 MB), save into the workspace, mention the path in the prompt text (image documents also as `ImageContent`) |
| Voice | transcribe on the host (the core only sees text), put the transcript in the prompt |
| `/stop` | `session.AbortAsync()` |
| `/model` | list `session.Models.ListModels()` where `IsAvailable`, then `session.SetModelAsync(key)` |
| `/thinking <level>` | `session.SetThinkingLevelAsync(level)` (`session.AvailableThinkingLevels`) |
@@ -163,7 +166,43 @@ When the user replies to a `ForceReply` prompt, route the text to `TryResolveInp
Because `Ui` is not `NullUserInterface`, `ctx.HasUi` is true and extensions like a permission gate will ask
instead of blocking.
## 5. Minimal bot skeleton (Telegram.Bot)
## 5. Implement `IChatChannel`
Optional. `IChatChannel` delivers content into the conversation's chat outside the rendering of assistant
messages. Pass one per session as `AgentSessionOptions.Channel`. The core then registers the built-in `send` tool for
that session (active by default; an explicit `ActiveTools` still wins), and tools and extensions reach the channel as
`ctx.Channel`, host code as `session.Channel`. Sessions without a channel never see `send`.
```csharp
public enum ChatFileKind { Auto, Photo, Document }
public sealed record ChatFile(string Path, ChatFileKind Kind = ChatFileKind.Auto, string? Caption = null);
public sealed record ChatDelivery(ChatFileKind? SentAs, string? MessageId);
public sealed record ChatChannelLimits(long MaxPhotoBytes, long MaxDocumentBytes, int MaxCaptionLength);
public interface IChatChannel
{
ChatChannelLimits Limits { get; }
Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken);
Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken);
}
public sealed class ChatDeliveryException(string message, Exception? inner = null) : Exception(message, inner);
```
- `Limits`: your platform's upload limits (Telegram: photo 10 MB, document 50 MB, caption 1024 characters). `send`
checks them before calling you, and its errors name them.
- `SendTextAsync`: markdown, rendered like assistant text and split when long. It is not added to the session
transcript, and it must work while no run is active: schedulers use it for reminders.
- `SendFileAsync`: the core has already confined the path to the workspace (symlinks included), checked that the file
exists and applied the limits. `Auto` lets you choose, and you may fall back (Telegram resends a rejected photo as a
document); report what you did in `SentAs`. Throw only `ChatDeliveryException`, with a message the model or the
user can read, or `OperationCanceledException`.
- Keep deliveries in order with your rendering: a file sent directly could overtake an assistant message that is
still queued for rendering. The Telegram host runs every delivery on its renderer's queue
(`ChatRenderer.RunInOrderAsync`), so the file lands after the text that preceded the tool call.
- Calls can come from any thread. Nothing delivered is persisted by the core; the `send` tool's result records it.
## 6. Minimal bot skeleton (Telegram.Bot)
```csharp
var services = new ServiceCollection();
@@ -224,7 +263,7 @@ bot.OnUpdate += async (update, _) =>
subscribed to; `NewSessionAsync`/`SwitchSessionAsync` keep the same `AgentSession` object, so one
subscription per `AgentSession` is enough.
## 6. Operational notes
## 7. Operational notes
- **Working directories**: the agent's tools (`bash`, `write`, ...) run with the bot process's permissions in
the session's `Cwd`. Run the bot in a container or with a dedicated user and one workspace per chat.

View File

@@ -40,6 +40,9 @@ public static class ClaudeArgs
public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY";
public const string MaxOutputTokensVariable = "CLAUDE_CODE_MAX_OUTPUT_TOKENS";
/// <summary>The session's zone for the child (never inherited from the parent: <c>TZ</c> isn't on the allow-list).</summary>
public const string TimeZoneVariable = "TZ";
private static readonly JsonSerializerOptions ConfigOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
/// <summary>Variables forwarded from the parent environment (compared case-insensitively). <c>LC_*</c> is forwarded as a prefix.</summary>
@@ -143,16 +146,32 @@ public static class ClaudeArgs
}
/// <summary>
/// The complete environment a Claude child receives: the allow-listed parent variables plus the pinned ones.
/// The complete environment a Claude child receives: the allow-listed parent variables, the pinned ones, and
/// <c>TZ</c> = <paramref name="timeZoneId"/> (the session's zone; not on Windows, which ignores it).
/// Throws <c>auth</c> when the parent process carries API-key or third-party-provider authentication.
/// </summary>
public static IReadOnlyDictionary<string, string> BuildEnvironment(IReadOnlyDictionary<string, string?> parentEnv, int maxOutputTokens)
public static IReadOnlyDictionary<string, string> BuildEnvironment(IReadOnlyDictionary<string, string?> parentEnv, int maxOutputTokens, string? timeZoneId = null)
{
ArgumentNullException.ThrowIfNull(parentEnv);
if (FindDeniedVariable(parentEnv) is { } denied)
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
$"{denied} is set; the Claude Code provider only supports subscription authentication and never forwards API-key or third-party credentials. Unset it to use this provider");
return AllowListed(parentEnv, maxOutputTokens);
var env = new Dictionary<string, string>(AllowListed(parentEnv, maxOutputTokens), StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(timeZoneId) && !OperatingSystem.IsWindows()) env[TimeZoneVariable] = timeZoneId;
return env;
}
/// <summary>
/// The IANA id for <c>TZ</c>: the zone's own id or a converted Windows id, or null when there is none that resolves
/// back to a system zone (an unnamed <c>Local</c> zone). Mirrors the core's <c>TimeZoneIds.ToIana</c> (the extension
/// references only the Abstractions); keep the two in step.
/// </summary>
public static string? IanaZoneId(TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
}
/// <summary>The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).</summary>

View File

@@ -28,6 +28,13 @@ public sealed record ClaudeCodeApiOptions
/// <summary>The environment the allow-list is applied to; the process environment when null.</summary>
public IReadOnlyDictionary<string, string?>? ParentEnvironment { get; init; }
/// <summary>
/// The IANA zone for the child's <c>TZ</c>, read per request (the extension reads the session's
/// <c>IExtensionContext.TimeZone</c>, so <c>SetTimeZone</c> applies from the next model call). Null or a null result
/// leaves <c>TZ</c> unset.
/// </summary>
public Func<string?>? TimeZoneId { get; init; }
/// <summary>The MCP server name the bridge is registered under (tools become <c>mcp__&lt;name&gt;__&lt;alias&gt;</c>).</summary>
public string ServerName { get; init; } = ToolCatalog.DefaultServerName;
@@ -250,7 +257,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
var stdin = ContextSerializer.BuildStdinMessage(context, catalog, images, cacheBreakpoint: streamOptions.CacheRetention != CacheRetention.None);
var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName));
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens);
var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens, Options.TimeZoneId?.Invoke());
if (streamOptions.OnPayload is not null)
{

View File

@@ -39,6 +39,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
private readonly ClaudeCodeProviderOptions _options = options ?? new ClaudeCodeProviderOptions();
private string? _ownSessionId;
private IExtensionContext? _context;
public string Name => ExtensionName;
@@ -60,6 +61,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
LoginUi = _options.LoginUi,
Coordinator = _options.Coordinator,
LoginOptions = _options.LoginOptions,
TimeZoneId = CurrentZoneId,
};
var claude = new ClaudeCodeApi(registry, apiOptions);
Api = claude;
@@ -83,6 +85,7 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
// so the second registration does not orphan the first.
if (_ownSessionId is not null && _ownSessionId != ctx.SessionId) registry.Remove(_ownSessionId);
_ownSessionId = ctx.SessionId;
_context = ctx;
registry.Register(ctx.SessionId, ctx.Cwd, ctx.HasUi ? ctx.Ui.Notify : null);
if (apiOptions.ClaudePath is null && ctx.HasUi)
ctx.Ui.Notify($"[claude-code] Claude Code (claude) was not found; run /{DoctorCommand.Name} for details", NotifyKind.Warning);
@@ -92,7 +95,11 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
api.On<SessionShutdownEvent>((_, ctx, _) =>
{
registry.Remove(ctx.SessionId);
if (_ownSessionId == ctx.SessionId) _ownSessionId = null;
if (_ownSessionId == ctx.SessionId)
{
_ownSessionId = null;
_context = null;
}
return ValueTask.CompletedTask;
});
@@ -109,4 +116,17 @@ public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? optio
return ValueTask.CompletedTask;
}
/// <summary>The session's zone as an IANA id, or null without a live session (e.g. a context gone stale after dispose).</summary>
private string? CurrentZoneId()
{
try
{
return _context is { } ctx ? ClaudeArgs.IanaZoneId(ctx.TimeZone) : null;
}
catch (Exception)
{
return null;
}
}
}

View File

@@ -0,0 +1,91 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>Replies through the UI, or as a custom message without a turn when there is none.</summary>
internal static class CommandReply
{
public static void Send(IExtensionApi api, IExtensionContext ctx, string customType, string text, NotifyKind kind = NotifyKind.Info)
{
if (ctx.HasUi) ctx.Ui.Notify(text, kind);
else api.SendMessage(CustomMessage.FromText(customType, text), MessageDelivery.Steer, triggerTurn: false);
}
}
/// <summary><c>/calendar_connect</c>: runs this chat's device flow to the end (the command already runs off the update loop).</summary>
internal static class ConnectCommand
{
public const string Name = "calendar_connect";
public const string Description = "Connect this chat's Google Calendar (link + code)";
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
{
if (!ctx.HasUi)
{
CommandReply.Send(api, ctx, Name, $"/{Name} needs an interactive UI (a chat front end such as the Telegram bot).");
return;
}
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
var flow = await ConnectFlow.StartOrResendAsync(connection, ctx.Ui, ct).ConfigureAwait(false);
if (flow is not null) await flow.RunAsync(ctx.Ui, ctx.Logger).ConfigureAwait(false);
});
}
/// <summary><c>/calendar_status</c>.</summary>
internal static class StatusCommand
{
public const string Name = "calendar_status";
public const string Description = "Show this chat's Google Calendar connection";
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, (_, ctx, _) =>
{
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
CommandReply.Send(api, ctx, Name, Describe(connection));
return Task.CompletedTask;
});
internal static string Describe(GoogleCalendarConnection connection)
{
if (connection.Pending is { } flow)
{
var d = flow.Device;
return $"Waiting for approval: open {d.VerificationUrl} and enter {d.UserCode} ({ConnectFlow.MinutesLeft(d, connection.Settings.Time)} min left).";
}
if (connection.Store.Read() is not { } token) return "Not connected. Run /calendar_connect.";
var since = token.ConnectedAt?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture) ?? "an unknown date";
return $"Connected as {token.Account ?? "an unknown account"} since {since}, time zone {token.TimeZone ?? "unknown"}.";
}
}
/// <summary><c>/calendar_disconnect</c>: cancel a pending flow, revoke (best effort), delete the file, drop the cache.</summary>
internal static class DisconnectCommand
{
public const string Name = "calendar_disconnect";
public const string Description = "Disconnect this chat's Google Calendar";
public static CommandDefinition Create(IExtensionApi api, GoogleCalendarExtension extension) => new(Description, async (_, ctx, ct) =>
{
var connection = extension.ConnectionFor(ctx.SessionManager.Scope);
string? revokeError;
// The gate a completing connect flow writes its token under: after CancelPending it can no longer write.
await connection.FlowGate.WaitAsync(ct).ConfigureAwait(false);
try
{
connection.CancelPending();
var token = connection.Store.Read();
revokeError = token is null ? null : await connection.OAuth.RevokeAsync(token.RefreshToken, ct).ConfigureAwait(false);
connection.Store.Delete();
connection.Invalidate();
}
finally
{
connection.FlowGate.Release();
}
var text = "Calendar disconnected.";
if (revokeError is not null)
text += $" Revoking at Google failed: {revokeError}; remove the app at {GoogleCalendarMessages.PermissionsUrl}.";
CommandReply.Send(api, ctx, Name, text);
});
}

View File

@@ -0,0 +1,147 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
internal enum CalendarTimeKind { Date, Local, Offset }
/// <summary>A parsed tool time. <see cref="Raw"/> is the RFC 3339 text sent for an <see cref="CalendarTimeKind.Offset"/> value.</summary>
internal readonly record struct CalendarTimeValue(CalendarTimeKind Kind, string Raw, DateOnly Date, DateTime Local, DateTimeOffset Instant);
/// <summary>
/// The tool's time rules: <c>YYYY-MM-DD</c> is all-day (end exclusive), a date-time with an offset is sent as-is, one
/// without is wall time in the event zone (sent without an offset plus <c>timeZone</c>). Query bounds are RFC 3339.
/// </summary>
internal static class CalendarTime
{
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
private static readonly string[] LocalFormats =
["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"];
private const string OffsetFormat = "yyyy-MM-dd'T'HH:mm:sszzz";
private const string LocalFormat = "yyyy-MM-dd'T'HH:mm:ss";
private const string UtcFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'";
public static CalendarTimeValue Parse(string text, string field)
{
var s = (text ?? "").Trim();
if (DateOnly.TryParseExact(s, "yyyy-MM-dd", Inv, DateTimeStyles.None, out var date))
return new(CalendarTimeKind.Date, s, date, default, default);
if (HasOffset(s))
{
if (DateTimeOffset.TryParse(s, Inv, DateTimeStyles.None, out var instant))
return new(CalendarTimeKind.Offset, Rfc3339(instant, IsUtcDesignator(s)), default, default, instant);
}
else if (DateTime.TryParseExact(s, LocalFormats, Inv, DateTimeStyles.None, out var local))
{
return new(CalendarTimeKind.Local, s, default, DateTime.SpecifyKind(local, DateTimeKind.Unspecified), default);
}
throw new ArgumentException(
$"{field} must be YYYY-MM-DD, a local date-time like 2026-09-30T10:00, or one with an offset like 2026-09-30T10:00:00+02:00 (got \"{text}\").");
}
public static JsonObject ToEventTime(CalendarTimeValue value, string zoneId) => value.Kind switch
{
CalendarTimeKind.Date => new JsonObject { ["date"] = value.Date.ToString("yyyy-MM-dd", Inv) },
CalendarTimeKind.Local => new JsonObject { ["dateTime"] = value.Local.ToString(LocalFormat, Inv), ["timeZone"] = zoneId },
_ => new JsonObject { ["dateTime"] = value.Raw, ["timeZone"] = zoneId },
};
/// <summary>Start and end for a new event; a missing end is one day (all-day) or one hour (timed) after start.</summary>
public static (JsonObject Start, JsonObject End) ForCreate(string start, string? end, string zoneId)
{
var s = Parse(start, "start");
var e = end is null ? DefaultEnd(s) : Parse(end, "end");
EnsureSameKind(s, e);
return (ToEventTime(s, zoneId), ToEventTime(e, zoneId));
}
/// <summary>Only the sides that were given; both given must be the same kind.</summary>
public static (JsonObject? Start, JsonObject? End) ForUpdate(string? start, string? end, string zoneId)
{
CalendarTimeValue? s = start is null ? null : Parse(start, "start");
CalendarTimeValue? e = end is null ? null : Parse(end, "end");
if (s is { } sv && e is { } ev) EnsureSameKind(sv, ev);
return (s is { } a ? ToEventTime(a, zoneId) : null, e is { } b ? ToEventTime(b, zoneId) : null);
}
/// <summary>
/// A query bound (<c>timeMin</c>/<c>timeMax</c>) as RFC 3339 with an offset; dates and local times are in
/// <paramref name="zone"/>. A date is its midnight, or with <paramref name="dateIsInclusiveEnd"/> (<c>timeMax</c>)
/// the next day's midnight, so <c>timeMin=timeMax=2026-09-30</c> covers the whole 30th.
/// </summary>
public static string ToRfc3339(string text, TimeZoneInfo zone, string field, bool dateIsInclusiveEnd = false)
{
var value = Parse(text, field);
return value.Kind switch
{
CalendarTimeKind.Date => InZone((dateIsInclusiveEnd ? value.Date.AddDays(1) : value.Date).ToDateTime(TimeOnly.MinValue), zone),
CalendarTimeKind.Local => InZone(value.Local, zone),
_ => value.Instant.ToString(OffsetFormat, Inv),
};
}
public static string Utc(DateTimeOffset instant) => instant.ToUniversalTime().ToString(UtcFormat, Inv);
/// <summary>An event's start/end as <c>Wed 30 Sep 10:00–11:00</c> in <paramref name="zone"/>, or an all-day range.</summary>
public static string FormatRange(JsonElement start, JsonElement end, TimeZoneInfo zone)
{
if (start.TryGetProperty("date", out var startDate))
{
var s = DateOnly.ParseExact(startDate.GetString()!, "yyyy-MM-dd", Inv);
var last = end.TryGetProperty("date", out var endDate)
? DateOnly.ParseExact(endDate.GetString()!, "yyyy-MM-dd", Inv).AddDays(-1)
: s;
return last <= s ? $"{Day(s)} (all day)" : $"{Day(s)} – {Day(last)} (all day)";
}
var from = TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(start.GetProperty("dateTime").GetString()!, Inv), zone);
var to = end.TryGetProperty("dateTime", out var endTime)
? TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(endTime.GetString()!, Inv), zone)
: from;
return from.Date == to.Date
? $"{from.ToString("ddd d MMM HH:mm", Inv)}–{to.ToString("HH:mm", Inv)}"
: $"{from.ToString("ddd d MMM HH:mm", Inv)} – {to.ToString("ddd d MMM HH:mm", Inv)}";
}
/// <summary>The header line of every tool result; names both zones when the calendar's differs from the chat's.</summary>
public static string Header(TimeZoneInfo resolved, TimeZoneInfo chat)
{
var r = ZoneIds.Name(resolved);
var c = ZoneIds.Name(chat);
return r == c ? $"Times in {r}." : $"Times in {r} (the calendar's zone; this chat is in {c}).";
}
private static string Day(DateOnly d) => d.ToString("ddd d MMM", Inv);
private static string InZone(DateTime local, TimeZoneInfo zone) =>
new DateTimeOffset(local, zone.GetUtcOffset(local)).ToString(OffsetFormat, Inv);
private static string Rfc3339(DateTimeOffset instant, bool utc) =>
utc && instant.Offset == TimeSpan.Zero ? instant.ToString(UtcFormat, Inv) : instant.ToString(OffsetFormat, Inv);
private static CalendarTimeValue DefaultEnd(CalendarTimeValue start) => start.Kind switch
{
CalendarTimeKind.Date => start with { Date = start.Date.AddDays(1) },
CalendarTimeKind.Local => start with { Local = start.Local.AddHours(1) },
_ => start with
{
Instant = start.Instant.AddHours(1),
Raw = Rfc3339(start.Instant.AddHours(1), start.Raw.EndsWith('Z')),
},
};
private static void EnsureSameKind(CalendarTimeValue start, CalendarTimeValue end)
{
if ((start.Kind == CalendarTimeKind.Date) != (end.Kind == CalendarTimeKind.Date))
throw new ArgumentException("start and end must both be dates (YYYY-MM-DD) or both date-times.");
}
private static bool IsUtcDesignator(string s) => s.EndsWith('Z') || s.EndsWith('z');
private static bool HasOffset(string s)
{
if (IsUtcDesignator(s)) return true;
var t = s.IndexOfAny(['T', ' ']);
return t > 0 && s.AsSpan(t).IndexOfAny('+', '-') >= 0;
}
}

View File

@@ -0,0 +1,322 @@
using System.ComponentModel;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
public sealed record CalendarArgs(
[property: Description("list, search, get, list_instances, create, update, delete or calendars")] string Action,
[property: Description("Calendar id; default primary. action=calendars lists the others")] string? CalendarId = null,
[property: Description("Window start (list, search, list_instances): RFC 3339, a local date-time or a date")] string? TimeMin = null,
[property: Description("Window end, same forms as timeMin; a date includes that whole day")] string? TimeMax = null,
[property: Description("Free-text search (search only, required there)")] string? Query = null,
[property: Description("Maximum events to return; default 25, at most 250")] int? MaxResults = null,
[property: Description("list only: occurrences (default; every occurrence of recurring events) or series (recurring events once, with their RRULE)")] string? ListMode = null,
[property: Description("Event id (get, list_instances, update, delete)")] string? EventId = null,
[property: Description("update/delete of a recurring event: instance (one occurrence) or series; detected from the event when omitted")] string? Scope = null,
[property: Description("Title (create, update)")] string? Summary = null,
[property: Description("Notes (create, update)")] string? Description = null,
[property: Description("Location (create, update)")] string? Location = null,
[property: Description("YYYY-MM-DD for all day, or a date-time; without an offset it is wall time in timeZone")] string? Start = null,
[property: Description("Like start. All-day ends are exclusive. Default on create: one day / one hour after start")] string? End = null,
[property: Description("IANA zone for this event, e.g. Europe/Berlin; default the calendar's zone")] string? TimeZone = null,
[property: Description("RFC 5545 lines, e.g. [\"RRULE:FREQ=WEEKLY;BYDAY=MO,WE\"]; [] on update turns a series into a single event")] IReadOnlyList<string>? Recurrence = null);
public sealed record CalendarEventDetails(string Id, string? Summary, JsonElement? Start, JsonElement? End, string? RecurringEventId,
IReadOnlyList<string>? Recurrence, string? HtmlLink);
public sealed record CalendarResultDetails(string Action, IReadOnlyList<CalendarEventDetails> Events);
/// <summary>
/// The <c>calendar</c> tool. Always registered while the OAuth client is configured; without a token for this chat it
/// fails with the "/calendar_connect" hint. Uses only the chat's own connection (scope from the context).
/// </summary>
internal sealed partial class CalendarTool(GoogleCalendarSettings settings, GoogleCalendarConnections connections, Func<string?> currentZone) : Tool<CalendarArgs>
{
public const string ToolName = "calendar";
private const int DefaultMax = 25;
private const int MaxCap = 250;
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
public override string Name => ToolName;
public override string Description =>
"Read and change the Google Calendar this chat connected with /calendar_connect. Actions: list, search, get, " +
"list_instances, create, update, delete, calendars. " + Rules;
public override string? PromptSnippet => "calendar: read and change this chat's Google Calendar";
public override IReadOnlyList<string> PromptGuidelines => ["calendar: " + Rules];
public override ToolExecutionMode ExecutionMode => ToolExecutionMode.Parallel;
private string Rules =>
$"Times without an offset are in {currentZone() ?? "the zone named in each result's header"}; all-day end dates are exclusive " +
"(a one-day event on 2026-09-30 ends 2026-10-01); to change one date of a recurring event, get that occurrence's id with list_instances first.";
protected override async Task<ToolResult> ExecuteAsync(CalendarArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var ctx = invocation.Context;
var connection = connections.For(settings, ctx.SessionManager.Scope);
var resolved = connection.ResolveTimeZone(ctx.TimeZone, ctx.Logger);
var eventZone = args.TimeZone is { Length: > 0 } requested
? ZoneIds.TryFind(requested, out var z) ? z : throw new ArgumentException($"Unknown time zone \"{requested}\". Use an IANA id such as Europe/Berlin.")
: resolved;
var run = new Run(args, new GoogleCalendarApi(connection), resolved, eventZone, CalendarTime.Header(resolved, ctx.TimeZone),
string.IsNullOrWhiteSpace(args.CalendarId) ? "primary" : args.CalendarId.Trim(), settings.Time.GetUtcNow(), cancellationToken);
var action = (args.Action ?? "").Trim().ToLowerInvariant();
return action switch
{
"list" => await ListAsync(run).ConfigureAwait(false),
"search" => await SearchAsync(run).ConfigureAwait(false),
"get" => await GetAsync(run).ConfigureAwait(false),
"list_instances" => await ListInstancesAsync(run).ConfigureAwait(false),
"calendars" => await CalendarsAsync(run).ConfigureAwait(false),
"create" or "update" or "delete" => await WriteAsync(action, run).ConfigureAwait(false),
_ => throw new ArgumentException($"Unknown action \"{args.Action}\". Use one of: list, search, get, list_instances, create, update, delete, calendars."),
};
}
/// <summary>Everything one call needs, resolved once.</summary>
private sealed record Run(CalendarArgs Args, GoogleCalendarApi Api, TimeZoneInfo Zone, TimeZoneInfo EventZone, string Header,
string CalendarId, DateTimeOffset Now, CancellationToken Ct)
{
public string EventZoneName => ZoneIds.Name(EventZone);
public int Max => Math.Clamp(Args.MaxResults ?? DefaultMax, 1, MaxCap);
public string Bound(string? given, DateTimeOffset fallback, string field) =>
given is { Length: > 0 } ? CalendarTime.ToRfc3339(given, EventZone, field, dateIsInclusiveEnd: field == "timeMax") : CalendarTime.Utc(fallback);
}
private async Task<ToolResult> ListAsync(Run run)
{
var mode = (run.Args.ListMode ?? "occurrences").Trim().ToLowerInvariant();
if (mode is not ("occurrences" or "series")) throw new ArgumentException("listMode must be occurrences or series.");
var query = new List<KeyValuePair<string, string>>
{
new("timeMin", run.Bound(run.Args.TimeMin, run.Now, "timeMin")),
new("timeMax", run.Bound(run.Args.TimeMax, run.Now.AddDays(7), "timeMax")),
new("singleEvents", mode == "occurrences" ? "true" : "false"),
};
if (mode == "occurrences") query.Add(new("orderBy", "startTime"));
var items = await PageAsync(run, GoogleCalendarApi.Events(run.CalendarId), query, new CalendarCall("list", run.CalendarId)).ConfigureAwait(false);
return Events("list", run, items);
}
private async Task<ToolResult> SearchAsync(Run run)
{
if (string.IsNullOrWhiteSpace(run.Args.Query)) throw new ArgumentException("search needs query.");
var query = new List<KeyValuePair<string, string>>
{
new("q", run.Args.Query.Trim()),
new("timeMin", run.Bound(run.Args.TimeMin, run.Now.AddDays(-30), "timeMin")),
new("timeMax", run.Bound(run.Args.TimeMax, run.Now.AddDays(365), "timeMax")),
new("singleEvents", "true"),
new("orderBy", "startTime"),
};
var items = await PageAsync(run, GoogleCalendarApi.Events(run.CalendarId), query, new CalendarCall("search", run.CalendarId)).ConfigureAwait(false);
return Events("search", run, items);
}
private async Task<ToolResult> GetAsync(Run run)
{
var id = Require(run.Args.EventId, "get needs eventId.");
var ev = await run.Api.SendAsync(HttpMethod.Get, GoogleCalendarApi.Event(run.CalendarId, id), null, new CalendarCall("get", run.CalendarId, id), run.Ct).ConfigureAwait(false);
var text = run.Header + "\n" + Line(ev, run.Zone);
if (Str(ev, "description") is { Length: > 0 } description) text += "\nDescription: " + description;
return Result("get", text, [ev]);
}
private async Task<ToolResult> ListInstancesAsync(Run run)
{
var id = Require(run.Args.EventId, "list_instances needs eventId.");
var query = new List<KeyValuePair<string, string>>();
if (run.Args.TimeMin is { Length: > 0 } min) query.Add(new("timeMin", CalendarTime.ToRfc3339(min, run.EventZone, "timeMin")));
if (run.Args.TimeMax is { Length: > 0 } max) query.Add(new("timeMax", CalendarTime.ToRfc3339(max, run.EventZone, "timeMax", dateIsInclusiveEnd: true)));
var items = await PageAsync(run, GoogleCalendarApi.Event(run.CalendarId, id) + "/instances", query, new CalendarCall("list_instances", run.CalendarId, id)).ConfigureAwait(false);
return Events("list_instances", run, items);
}
private static async Task<ToolResult> CalendarsAsync(Run run)
{
var items = await PageAsync(run, "users/me/calendarList", [], new CalendarCall("calendars", run.CalendarId)).ConfigureAwait(false);
var sb = new StringBuilder(run.Header);
foreach (var c in items)
{
sb.Append('\n').Append(Str(c, "id")).Append(" · ").Append(Str(c, "summary") ?? "(no name)")
.Append(" · ").Append(Str(c, "accessRole")).Append(" · ").Append(Str(c, "timeZone"));
if (c.TryGetProperty("primary", out var p) && p.ValueKind == JsonValueKind.True) sb.Append(" · primary");
}
return ToolResult.Text(sb.ToString(), new CalendarResultDetails("calendars", []));
}
/// <summary>Follows <c>nextPageToken</c> until <see cref="Run.Max"/> items are collected.</summary>
private static async Task<List<JsonElement>> PageAsync(Run run, string path, List<KeyValuePair<string, string>> query, CalendarCall call)
{
var items = new List<JsonElement>();
string? pageToken = null;
do
{
var pageQuery = new List<KeyValuePair<string, string>>(query) { new("maxResults", (run.Max - items.Count).ToString(Inv)) };
if (pageToken is not null) pageQuery.Add(new("pageToken", pageToken));
var page = await run.Api.SendAsync(HttpMethod.Get, path + "?" + GoogleCalendarApi.Query(pageQuery), null, call, run.Ct).ConfigureAwait(false);
if (page.TryGetProperty("items", out var list) && list.ValueKind == JsonValueKind.Array)
items.AddRange(list.EnumerateArray().Where(e => Str(e, "status") != "cancelled"));
pageToken = Str(page, "nextPageToken");
}
while (pageToken is not null && items.Count < run.Max);
return items.Take(run.Max).ToList();
}
private static ToolResult Events(string action, Run run, List<JsonElement> items)
{
var text = items.Count == 0 ? run.Header + "\nNo events." : run.Header + "\n" + string.Join("\n", items.Select(e => Line(e, run.Zone)));
return Result(action, text, items);
}
private static ToolResult Result(string action, string text, IEnumerable<JsonElement> events) =>
ToolResult.Text(text, new CalendarResultDetails(action, events.Select(Details).ToList()));
/// <summary><c>&lt;id&gt; · &lt;summary&gt; · &lt;range&gt;[ @ location][ · recurring (series=…)][ · RRULE=…]</c></summary>
internal static string Line(JsonElement ev, TimeZoneInfo zone)
{
var sb = new StringBuilder();
sb.Append(Str(ev, "id")).Append(" · ").Append(Str(ev, "summary") is { Length: > 0 } s ? s : "(no title)");
if (ev.TryGetProperty("start", out var start))
sb.Append(" · ").Append(CalendarTime.FormatRange(start, ev.TryGetProperty("end", out var end) ? end : start, zone));
if (Str(ev, "location") is { Length: > 0 } location) sb.Append(" @ ").Append(location);
if (Str(ev, "recurringEventId") is { Length: > 0 } series) sb.Append(" · recurring (series=").Append(series).Append(')');
if (Recurrence(ev)?.FirstOrDefault(r => r.StartsWith("RRULE:", StringComparison.Ordinal)) is { } rrule) sb.Append(" · RRULE=").Append(rrule[6..]);
return sb.ToString();
}
private static CalendarEventDetails Details(JsonElement ev) => new(
Str(ev, "id") ?? "", Str(ev, "summary"),
ev.TryGetProperty("start", out var s) ? s.Clone() : null, ev.TryGetProperty("end", out var e) ? e.Clone() : null,
Str(ev, "recurringEventId"), Recurrence(ev), Str(ev, "htmlLink"));
private static IReadOnlyList<string>? Recurrence(JsonElement ev) =>
ev.TryGetProperty("recurrence", out var r) && r.ValueKind == JsonValueKind.Array ? r.EnumerateArray().Select(x => x.GetString() ?? "").ToList() : null;
private static string? Str(JsonElement e, string name) =>
e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static string Require(string? value, string message) => string.IsNullOrWhiteSpace(value) ? throw new ArgumentException(message) : value.Trim();
internal enum EventKind { Single, Instance, Series }
private static readonly string[] RecurrencePrefixes = ["RRULE:", "EXDATE:", "RDATE:", "EXRULE:"];
private static Task<ToolResult> WriteAsync(string action, Run run) => action switch
{
"create" => CreateAsync(run),
"update" => UpdateAsync(run),
_ => DeleteAsync(run),
};
private static async Task<ToolResult> CreateAsync(Run run)
{
var a = run.Args;
var summary = Require(a.Summary, "create needs summary.");
var startText = Require(a.Start, "create needs start.");
if (a.Recurrence is not null) ValidateRecurrence(a.Recurrence);
var (start, end) = CalendarTime.ForCreate(startText, a.End, run.EventZoneName);
var body = new JsonObject { ["summary"] = summary, ["start"] = start, ["end"] = end };
if (a.Description is not null) body["description"] = a.Description;
if (a.Location is not null) body["location"] = a.Location;
if (a.Recurrence is { Count: > 0 }) body["recurrence"] = RecurrenceJson(a.Recurrence);
var created = await run.Api.SendAsync(HttpMethod.Post, GoogleCalendarApi.Events(run.CalendarId), body, new CalendarCall("create", run.CalendarId), run.Ct).ConfigureAwait(false);
return Result("create", $"{run.Header}\nCreated: {Line(created, run.Zone)}", [created]);
}
private static async Task<ToolResult> UpdateAsync(Run run)
{
var a = run.Args;
var id = Require(a.EventId, "update needs eventId.");
var call = new CalendarCall("update", run.CalendarId, id);
var path = GoogleCalendarApi.Event(run.CalendarId, id);
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
var kind = ResolveScope(ev, a.Scope, id);
var patch = new JsonObject();
if (a.Summary is not null) patch["summary"] = a.Summary;
if (a.Description is not null) patch["description"] = a.Description;
if (a.Location is not null) patch["location"] = a.Location;
var (start, end) = CalendarTime.ForUpdate(a.Start, a.End, run.EventZoneName);
if (start is not null) patch["start"] = start;
if (end is not null) patch["end"] = end;
if (a.Recurrence is not null)
{
if (kind == EventKind.Instance)
throw new ArgumentException($"recurrence can't be set on a single occurrence; update the series (eventId={Str(ev, "recurringEventId")}) instead.");
ValidateRecurrence(a.Recurrence);
patch["recurrence"] = RecurrenceJson(a.Recurrence);
}
if (patch.Count == 0)
throw new ArgumentException("update needs at least one field to change (summary, description, location, start, end or recurrence).");
var updated = await run.Api.SendAsync(HttpMethod.Patch, path, patch, call, run.Ct).ConfigureAwait(false);
var what = kind switch
{
EventKind.Instance => "Updated this occurrence only",
EventKind.Series => "Updated the whole series",
_ => "Updated",
};
return Result("update", $"{run.Header}\n{what}: {Line(updated, run.Zone)}", [updated]);
}
private static async Task<ToolResult> DeleteAsync(Run run)
{
var id = Require(run.Args.EventId, "delete needs eventId.");
var call = new CalendarCall("delete", run.CalendarId, id);
var path = GoogleCalendarApi.Event(run.CalendarId, id);
var ev = await run.Api.SendAsync(HttpMethod.Get, path, null, call, run.Ct).ConfigureAwait(false);
var kind = ResolveScope(ev, run.Args.Scope, id);
await run.Api.SendAsync(HttpMethod.Delete, path, null, call, run.Ct).ConfigureAwait(false);
var summary = Str(ev, "summary") is { Length: > 0 } s ? s : "(no title)";
var range = ev.TryGetProperty("start", out var start) ? CalendarTime.FormatRange(start, ev.TryGetProperty("end", out var end) ? end : start, run.Zone) : "";
var text = kind switch
{
EventKind.Instance => $"Cancelled one occurrence of \"{summary}\" ({range}); the rest of the series is unchanged.",
EventKind.Series => $"Deleted the recurring series \"{summary}\" and all its occurrences.",
_ => $"Deleted \"{summary}\" ({range}).",
};
return Result("delete", $"{run.Header}\n{text}", [ev]);
}
/// <summary><c>recurringEventId</c> → instance; <c>recurrence</c> → series; neither → single (explicit scope ignored).</summary>
internal static EventKind ResolveScope(JsonElement ev, string? requested, string eventId)
{
var scope = requested?.Trim().ToLowerInvariant();
if (scope is not (null or "" or "instance" or "series")) throw new ArgumentException("scope must be instance or series.");
if (Str(ev, "recurringEventId") is { Length: > 0 } series)
{
if (scope == "series")
throw new ArgumentException($"Event {eventId} is one occurrence of the recurring series {series}. To change the whole series, pass eventId={series} with scope=series.");
return EventKind.Instance;
}
if (Recurrence(ev) is { Count: > 0 })
{
if (scope == "instance")
throw new ArgumentException($"Event {eventId} is a recurring series. To change one date, call list_instances with eventId={eventId}, then pass that occurrence's id with scope=instance.");
return EventKind.Series;
}
return EventKind.Single;
}
/// <summary>Every line must be an RFC 5545 RRULE, EXDATE, RDATE or EXRULE property.</summary>
internal static void ValidateRecurrence(IReadOnlyList<string> lines)
{
foreach (var line in lines)
{
var trimmed = (line ?? "").Trim();
if (!RecurrencePrefixes.Any(p => trimmed.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
throw new ArgumentException($"Recurrence line \"{line}\" must start with RRULE:, EXDATE:, RDATE: or EXRULE:.");
}
}
private static JsonArray RecurrenceJson(IReadOnlyList<string> lines) => new(lines.Select(r => (JsonNode)r.Trim()).ToArray());
}

View File

@@ -0,0 +1,19 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>Maps a session scope (stable per chat) to a directory name that is safe on every file system.</summary>
internal static partial class ChatKeys
{
public static string For(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
if (Safe().IsMatch(scope) && scope is not "." and not "..") return scope;
return "scope-" + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(scope)))[..16];
}
[GeneratedRegex(@"^[A-Za-z0-9._-]{1,100}\z")] // \z, not $: '$' also matches before a trailing newline
private static partial Regex Safe();
}

View File

@@ -0,0 +1,177 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// One chat's device flow. It polls on its own cancellation source, not the command's token: <c>/stop</c> leaves it
/// alone, <c>/calendar_disconnect</c> cancels it silently, and it ends by itself at the code's expiry.
/// </summary>
internal sealed class ConnectFlow
{
private const int MaxTransportErrors = 3;
private static readonly TimeSpan SlowDownStep = TimeSpan.FromSeconds(5); // RFC 8628 §3.5
private readonly CancellationTokenSource _cancellation = new();
private readonly GoogleCalendarConnection _connection;
private int _parks;
private ConnectFlow(GoogleCalendarConnection connection, DeviceAuthorization device)
{
_connection = connection;
Device = device;
Interval = device.Interval;
}
public DeviceAuthorization Device { get; }
public TimeSpan Interval { get; private set; }
/// <summary>False once the flow was cancelled or its code expired: such a flow is never re-sent or shown.</summary>
public bool IsLive(TimeProvider time) => !_cancellation.IsCancellationRequested && time.GetUtcNow() < Device.ExpiresAt;
/// <summary>How many poll delays the flow has entered (tests drive the fake clock with it).</summary>
internal int Parks => Volatile.Read(ref _parks);
/// <summary>When the current delay ends.</summary>
internal DateTimeOffset NextPollAt { get; private set; }
public void Cancel()
{
try { _cancellation.Cancel(); } catch (ObjectDisposedException) { }
}
public static int MinutesLeft(DeviceAuthorization device, TimeProvider time) =>
Math.Max(1, (int)Math.Ceiling((device.ExpiresAt - time.GetUtcNow()).TotalMinutes));
/// <summary>Re-sends a pending flow's link and code, or starts a new flow and sends its link. Null when nothing more to do.</summary>
public static async Task<ConnectFlow?> StartOrResendAsync(GoogleCalendarConnection connection, IUserInterface ui, CancellationToken cancellationToken)
{
var time = connection.Settings.Time;
await connection.FlowGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (connection.Pending is { } pending)
{
var d = pending.Device;
ui.Notify($"A Google Calendar connection is already waiting for approval:\n1. Open {d.VerificationUrl}\n2. Enter the code {d.UserCode}\n({MinutesLeft(d, time)} min left.)");
return null;
}
DeviceAuthorization device;
try
{
device = await connection.OAuth.StartDeviceFlowAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is GoogleCalendarException or HttpRequestException || ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
ui.Notify($"Google Calendar connection failed: {ex.Message}", NotifyKind.Error);
return null;
}
var flow = new ConnectFlow(connection, device);
connection.Begin(flow);
ui.Notify($"Connect Google Calendar:\n1. Open {device.VerificationUrl}\n2. Enter the code {device.UserCode}\n3. Approve access. I'll confirm here when it's done (code valid for {MinutesLeft(device, time)} min).");
return flow;
}
finally
{
connection.FlowGate.Release();
}
}
public async Task RunAsync(IUserInterface ui, ILogger logger)
{
var time = _connection.Settings.Time;
var ct = _cancellation.Token;
var transportErrors = 0;
try
{
while (true)
{
NextPollAt = time.GetUtcNow() + Interval;
Interlocked.Increment(ref _parks);
await Task.Delay(Interval, time, ct).ConfigureAwait(false);
if (time.GetUtcNow() >= Device.ExpiresAt)
{
ui.Notify("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning);
return;
}
PollResult result;
try
{
result = await _connection.OAuth.PollAsync(Device.DeviceCode, ct).ConfigureAwait(false);
transportErrors = 0;
}
catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException && !ct.IsCancellationRequested)
{
if (++transportErrors >= MaxTransportErrors)
{
ui.Notify($"Google Calendar connection failed: {ex.Message}", NotifyKind.Error);
return;
}
continue;
}
switch (result.Status)
{
case PollStatus.Pending:
continue;
case PollStatus.SlowDown:
Interval += SlowDownStep;
continue;
case PollStatus.Denied:
ui.Notify("Google Calendar access was denied.", NotifyKind.Warning);
return;
case PollStatus.Expired:
ui.Notify("The Google code expired. Run /calendar_connect again.", NotifyKind.Warning);
return;
case PollStatus.Failed:
ui.Notify($"Google Calendar connection failed: {result.Message}", NotifyKind.Error);
return;
default:
await CompleteAsync(result, ui, logger, ct).ConfigureAwait(false);
return;
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// cancelled by /calendar_disconnect or shutdown: silent
}
finally
{
_connection.End(this);
_cancellation.Dispose(); // a late Cancel() swallows ObjectDisposedException
}
}
private async Task CompleteAsync(PollResult result, IUserInterface ui, ILogger logger, CancellationToken ct)
{
var primary = new PrimaryCalendar(null, null);
try
{
primary = await GoogleCalendarApi.GetPrimaryAsync(_connection.Settings.Http, result.AccessToken!, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is GoogleCalendarException or HttpRequestException or System.Text.Json.JsonException
|| ex is TaskCanceledException && !ct.IsCancellationRequested)
{
logger.LogWarning("google-calendar: looking up the primary calendar failed: {Error}", ex.Message);
}
// Under the gate /calendar_disconnect also takes: once it has cancelled the flow, the token is never written.
await _connection.FlowGate.WaitAsync(ct).ConfigureAwait(false);
try
{
ct.ThrowIfCancellationRequested();
_connection.Store.Write(new StoredToken(result.RefreshToken!, result.Scope, primary.Account, primary.TimeZone, _connection.Settings.Time.GetUtcNow()));
_connection.Invalidate();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
ui.Notify($"Got a Google token but could not save it: {ex.Message}", NotifyKind.Error);
return;
}
finally
{
_connection.FlowGate.Release();
}
ui.Notify($"âś… Google Calendar connected as {primary.Account ?? "an unknown account"} (time zone {primary.TimeZone ?? "unknown"}).");
}
}

View File

@@ -0,0 +1,80 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
internal readonly record struct CalendarCall(string Action, string CalendarId, string? EventId = null);
internal sealed record PrimaryCalendar(string? Account, string? TimeZone);
/// <summary>Calendar v3 over raw HTTP for one chat: bearer token from the connection, one refresh-and-retry on 401.</summary>
internal sealed class GoogleCalendarApi(GoogleCalendarConnection connection)
{
public const string BaseUrl = "https://www.googleapis.com/calendar/v3/";
public static string Events(string calendarId) => $"calendars/{Uri.EscapeDataString(calendarId)}/events";
public static string Event(string calendarId, string eventId) => $"{Events(calendarId)}/{Uri.EscapeDataString(eventId)}";
public static string Query(IEnumerable<KeyValuePair<string, string>> pairs) =>
string.Join("&", pairs.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
public async Task<JsonElement> SendAsync(HttpMethod method, string pathAndQuery, JsonObject? body, CalendarCall call, CancellationToken cancellationToken)
{
var http = connection.Settings.Http;
var response = await SendOnceAsync(http, method, pathAndQuery, body,
await connection.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
connection.Invalidate();
response = await SendOnceAsync(http, method, pathAndQuery, body,
await connection.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
}
using (response)
{
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode) return string.IsNullOrWhiteSpace(text) ? VolcanicJson.EmptyObject : VolcanicJson.Parse(text);
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone && call.EventId is not null)
throw new GoogleCalendarException($"Event {call.EventId} not found in {call.CalendarId}.");
throw new GoogleCalendarException($"Google Calendar {call.Action} failed: {(int)response.StatusCode} {ErrorMessage(text) ?? response.ReasonPhrase}");
}
}
/// <summary>The account (primary calendar id = email) and zone, with a token that isn't stored yet.</summary>
public static async Task<PrimaryCalendar> GetPrimaryAsync(HttpClient http, string accessToken, CancellationToken cancellationToken)
{
using var response = await SendOnceAsync(http, HttpMethod.Get, "calendars/primary", null, accessToken, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
throw new GoogleCalendarException($"calendars/primary failed: {(int)response.StatusCode} {ErrorMessage(text) ?? response.ReasonPhrase}");
var json = VolcanicJson.Parse(text);
return new PrimaryCalendar(
json.TryGetProperty("id", out var id) ? id.GetString() : null,
json.TryGetProperty("timeZone", out var zone) ? zone.GetString() : null);
}
private static async Task<HttpResponseMessage> SendOnceAsync(HttpClient http, HttpMethod method, string pathAndQuery, JsonObject? body, string accessToken, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, BaseUrl + pathAndQuery);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
if (body is not null) request.Content = new StringContent(body.ToJsonString(VolcanicJson.Options), Encoding.UTF8, "application/json");
return await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
private static string? ErrorMessage(string text)
{
try
{
return JsonNode.Parse(text) is JsonObject o && o["error"] is JsonObject e && e["message"] is JsonValue m && m.TryGetValue<string>(out var s) ? s : null;
}
catch (JsonException)
{
return null;
}
}
}

View File

@@ -0,0 +1,127 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// Explicit process-wide state (like <c>ClaudeLoginCoordinator.Shared</c>): extensions are per session, but a chat's
/// Google account, access-token cache and pending connect flow outlive its sessions. One
/// <see cref="GoogleCalendarConnection"/> per chat token directory, created on first use.
/// </summary>
public sealed class GoogleCalendarConnections
{
/// <summary>The process-wide instance the extension uses unless its options pass another.</summary>
public static GoogleCalendarConnections Shared { get; } = new();
private readonly ConcurrentDictionary<string, GoogleCalendarConnection> _connections = new(StringComparer.Ordinal);
/// <summary>
/// The connection for a session scope, keyed by its token directory (<c>&lt;root&gt;/&lt;chat key&gt;</c>).
/// The first caller's settings create it.
/// </summary>
internal GoogleCalendarConnection For(GoogleCalendarSettings settings, string scope)
{
ArgumentNullException.ThrowIfNull(settings);
var directory = Path.GetFullPath(Path.Combine(settings.TokenRoot, ChatKeys.For(scope)));
return _connections.GetOrAdd(directory, d => new GoogleCalendarConnection(settings, new GoogleTokenStore(Path.Combine(d, GoogleTokenStore.FileName))));
}
}
/// <summary>One chat's Google account: token file, access-token cache (single-flight refresh), pending connect flow.</summary>
internal sealed class GoogleCalendarConnection
{
private static readonly TimeSpan RefreshMargin = TimeSpan.FromSeconds(60);
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private CachedToken? _cached;
private ConnectFlow? _pending;
private int _unknownZoneLogged;
public GoogleCalendarConnection(GoogleCalendarSettings settings, GoogleTokenStore store)
{
Settings = settings;
Store = store;
OAuth = new GoogleOAuthClient(settings.Http, settings.ClientId, settings.ClientSecret, settings.Time);
}
public GoogleCalendarSettings Settings { get; }
public GoogleTokenStore Store { get; }
public GoogleOAuthClient OAuth { get; }
/// <summary>
/// Serialises "is a flow pending? else start one" so two /calendar_connect in one chat can't both start, and the
/// flow's final token write with /calendar_disconnect so a disconnected chat can't end up connected.
/// </summary>
public SemaphoreSlim FlowGate { get; } = new(1, 1);
/// <summary>This chat's in-flight device flow, if any; a cancelled or expired one is cancelled, cleared and not returned.</summary>
public ConnectFlow? Pending
{
get
{
var flow = Volatile.Read(ref _pending);
if (flow is null || flow.IsLive(Settings.Time)) return flow;
flow.Cancel(); // an expired flow still parked in its delay ends silently instead of reporting later
End(flow);
return null;
}
}
/// <summary>Registers <paramref name="flow"/> as pending. Callers hold <see cref="FlowGate"/>.</summary>
public void Begin(ConnectFlow flow) => Volatile.Write(ref _pending, flow);
/// <summary>Clears the pending flow if it is still <paramref name="flow"/>.</summary>
public void End(ConnectFlow flow) => Interlocked.CompareExchange(ref _pending, null, flow);
/// <summary>Cancels the pending flow silently (used by /calendar_disconnect).</summary>
public void CancelPending() => Volatile.Read(ref _pending)?.Cancel();
/// <summary>
/// A valid access token: the cached one while it has more than 60 s left and belongs to the stored refresh
/// token, else one refresh shared by all concurrent callers.
/// </summary>
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
{
var stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
if (Fresh(stored) is { } token) return token;
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
stored = Store.Read() ?? throw new GoogleCalendarException(GoogleCalendarMessages.NotConnected, notConnected: true);
if (Fresh(stored) is { } again) return again;
var access = await OAuth.RefreshAsync(stored.RefreshToken, cancellationToken).ConfigureAwait(false);
Volatile.Write(ref _cached, new CachedToken(stored.RefreshToken, access.Token, access.ExpiresAt));
return access.Token;
}
finally
{
_refreshLock.Release();
}
}
/// <summary>Drops the cached access token (after a 401, a reconnect or a disconnect).</summary>
public void Invalidate() => Volatile.Write(ref _cached, null);
/// <summary>The calendar's own zone (stored at connect), else the chat's zone.</summary>
public TimeZoneInfo ResolveTimeZone(TimeZoneInfo chatZone, ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(chatZone);
var id = Store.Read()?.TimeZone;
if (string.IsNullOrWhiteSpace(id)) return chatZone;
if (ZoneIds.TryFind(id, out var zone)) return zone;
// Claim the log-once flag only when there is a logger: a logger-less call (e.g. the tool description) must not use it up.
if (logger is not null && Interlocked.Exchange(ref _unknownZoneLogged, 1) == 0)
logger.LogWarning("google-calendar: the calendar's time zone {Zone} is unknown on this machine; using the chat's zone {ChatZone}", id, ZoneIds.Name(chatZone));
return chatZone;
}
private string? Fresh(StoredToken stored) =>
Volatile.Read(ref _cached) is { } cached && cached.RefreshToken == stored.RefreshToken
&& cached.ExpiresAt - Settings.Time.GetUtcNow() > RefreshMargin
? cached.AccessToken
: null;
private sealed record CachedToken(string RefreshToken, string AccessToken, DateTimeOffset ExpiresAt)
{
public override string ToString() => $"CachedToken {{ ExpiresAt = {ExpiresAt:O} }}";
}
}

View File

@@ -0,0 +1,15 @@
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>A Google Calendar failure the model or the user can read. <see cref="NotConnected"/> means "run /calendar_connect".</summary>
public sealed class GoogleCalendarException(string message, bool notConnected = false) : Exception(message)
{
public bool NotConnected { get; } = notConnected;
}
internal static class GoogleCalendarMessages
{
public const string PermissionsUrl = "https://myaccount.google.com/permissions";
public const string NotConnected = "Google Calendar is not connected. Ask the user to run /calendar_connect in this chat.";
public const string TokenRejected = "Google rejected the stored calendar token (revoked or expired). Ask the user to run /calendar_connect in this chat.";
public const string NoRefreshToken = "Google returned no refresh token. Remove the app at " + PermissionsUrl + " and run /calendar_connect again.";
}

View File

@@ -0,0 +1,67 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// Google Calendar for every chat: the <c>calendar</c> tool and <c>/calendar_connect</c>, <c>/calendar_status</c>,
/// <c>/calendar_disconnect</c>. Each chat (session scope) connects its own account. Inactive, with one log line,
/// until <c>GOOGLE_CLIENT_ID</c> and <c>GOOGLE_CLIENT_SECRET</c> are set. Instantiated per session.
/// </summary>
public sealed class GoogleCalendarExtension(GoogleCalendarOptions? options = null) : IExtension
{
public const string ExtensionName = "google-calendar";
private readonly GoogleCalendarOptions _options = options ?? new GoogleCalendarOptions();
private GoogleCalendarSettings? _settings;
private ILogger? _logger;
private volatile string? _scope;
private volatile TimeZoneInfo? _chatZone;
public string Name => ExtensionName;
internal GoogleCalendarConnections Connections => _options.Connections ?? GoogleCalendarConnections.Shared;
internal GoogleCalendarConnection ConnectionFor(string scope) =>
Connections.For(_settings ?? throw new InvalidOperationException("google-calendar is not configured."), scope);
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
var id = _options.EffectiveClientId;
var secret = _options.EffectiveClientSecret;
if (id is null || secret is null)
{
if (id is null && secret is null)
api.Logger.LogInformation("google-calendar disabled: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET");
else
api.Logger.LogWarning("google-calendar disabled: {Missing} is not set (both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are needed)",
id is null ? "GOOGLE_CLIENT_ID" : "GOOGLE_CLIENT_SECRET");
return ValueTask.CompletedTask;
}
_settings = _options.Resolve()!;
_logger = api.Logger;
api.RegisterTool(new CalendarTool(_settings, Connections, CurrentZoneName));
api.RegisterCommand(ConnectCommand.Name, ConnectCommand.Create(api, this));
api.RegisterCommand(StatusCommand.Name, StatusCommand.Create(api, this));
api.RegisterCommand(DisconnectCommand.Name, DisconnectCommand.Create(api, this));
api.On<SessionStartEvent>((_, ctx, _) => Track(ctx));
api.On<BeforeAgentStartEvent>((_, ctx, _) => Track(ctx));
return ValueTask.CompletedTask;
}
/// <summary>The zone the tool's description and guideline name: the calendar's, else the chat's. Null before the session starts.</summary>
internal string? CurrentZoneName() =>
_scope is { } scope && _settings is not null
? ZoneIds.Name(ConnectionFor(scope).ResolveTimeZone(_chatZone ?? TimeZoneInfo.Local, _logger))
: null;
private ValueTask Track(IExtensionContext ctx)
{
_chatZone = ctx.TimeZone;
_scope = ctx.SessionManager.Scope;
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,58 @@
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>
/// Configures the Google Calendar extension. The OAuth client is process-wide (one Google Cloud app for the bot);
/// accounts are per chat. Empty strings count as unset.
/// </summary>
public sealed record GoogleCalendarOptions
{
/// <summary>OAuth client id (type "TVs and Limited Input devices"). Else <c>GOOGLE_CLIENT_ID</c>.</summary>
public string? ClientId { get; init; }
/// <summary>OAuth client secret. Else <c>GOOGLE_CLIENT_SECRET</c>.</summary>
public string? ClientSecret { get; init; }
/// <summary>Root of the per-chat token directories. Else <c>&lt;agentDir&gt;/google-calendar</c>.</summary>
public string? TokenDirectory { get; init; }
/// <summary>Environment lookup; defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.</summary>
public Func<string, string?>? GetEnv { get; init; }
/// <summary>HTTP client for Google; tests pass one over a fake handler.</summary>
public HttpClient? Http { get; init; }
/// <summary>Clock for polling and token expiry; tests pass a fake.</summary>
public TimeProvider? Time { get; init; }
/// <summary>Per-chat connection state. Defaults to <see cref="GoogleCalendarConnections.Shared"/>.</summary>
public GoogleCalendarConnections? Connections { get; init; }
private Func<string, string?> Env => GetEnv ?? Environment.GetEnvironmentVariable;
internal string? EffectiveClientId => NonEmpty(ClientId) ?? NonEmpty(Env("GOOGLE_CLIENT_ID"));
internal string? EffectiveClientSecret => NonEmpty(ClientSecret) ?? NonEmpty(Env("GOOGLE_CLIENT_SECRET"));
/// <summary>True when both the client id and the secret are set (here or in the environment): the extension registers its tool and commands.</summary>
public bool IsConfigured => EffectiveClientId is not null && EffectiveClientSecret is not null;
internal string EffectiveTokenDirectory => NonEmpty(TokenDirectory) ?? Path.Combine(
NonEmpty(Env("VOLCANIC_AGENT_DIR")) ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent"),
"google-calendar");
/// <summary>The settings a connection needs, or null when the client id or secret is missing.</summary>
internal GoogleCalendarSettings? Resolve() =>
EffectiveClientId is { } id && EffectiveClientSecret is { } secret
? new GoogleCalendarSettings(id, secret, EffectiveTokenDirectory, Http ?? GoogleHttp.Default, Time ?? TimeProvider.System)
: null;
public override string ToString() =>
$"GoogleCalendarOptions {{ ClientId = {ClientId}, ClientSecret = {(string.IsNullOrEmpty(ClientSecret) ? "" : "***")}, TokenDirectory = {TokenDirectory} }}";
internal static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>Resolved, validated configuration shared by every chat's connection.</summary>
internal sealed record GoogleCalendarSettings(string ClientId, string ClientSecret, string TokenRoot, HttpClient Http, TimeProvider Time)
{
public override string ToString() => $"GoogleCalendarSettings {{ ClientId = {ClientId}, TokenRoot = {TokenRoot} }}";
}
internal static class GoogleHttp
{
/// <summary>One client for the process: connections of all chats share it.</summary>
public static readonly HttpClient Default = new() { Timeout = TimeSpan.FromSeconds(60) };
}

View File

@@ -0,0 +1,144 @@
using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
internal sealed record DeviceAuthorization(string DeviceCode, string UserCode, string VerificationUrl, DateTimeOffset ExpiresAt, TimeSpan Interval);
internal enum PollStatus { Success, Pending, SlowDown, Denied, Expired, Failed }
/// <summary>One poll of the token endpoint. <see cref="ToString"/> never shows tokens.</summary>
internal sealed record PollResult(PollStatus Status, string? RefreshToken = null, string? Scope = null, string? AccessToken = null,
DateTimeOffset AccessTokenExpiresAt = default, string? Message = null)
{
public static readonly PollResult Pending = new(PollStatus.Pending);
public static readonly PollResult SlowDown = new(PollStatus.SlowDown);
public static readonly PollResult Denied = new(PollStatus.Denied);
public static readonly PollResult Expired = new(PollStatus.Expired);
public static PollResult Succeeded(string refreshToken, string? scope, string accessToken, DateTimeOffset expiresAt) =>
new(PollStatus.Success, refreshToken, scope, accessToken, expiresAt);
public static PollResult Failed(string message) => new(PollStatus.Failed, Message: message);
public override string ToString() => Message is null ? Status.ToString() : $"{Status}: {Message}";
}
internal sealed record AccessToken(string Token, DateTimeOffset ExpiresAt)
{
public override string ToString() => $"AccessToken {{ ExpiresAt = {ExpiresAt:O} }}";
}
/// <summary>
/// Google's OAuth 2.0 device flow for limited-input devices, refresh and revoke, over raw HTTP. Error mapping reads
/// the JSON <c>error</c> field whatever the status code (Google documents 428 and 403 besides 400). Messages never
/// contain tokens or the client secret.
/// </summary>
internal sealed class GoogleOAuthClient(HttpClient http, string clientId, string clientSecret, TimeProvider time)
{
public const string DeviceCodeUrl = "https://oauth2.googleapis.com/device/code";
public const string TokenUrl = "https://oauth2.googleapis.com/token";
public const string RevokeUrl = "https://oauth2.googleapis.com/revoke";
public const string CalendarScope = "https://www.googleapis.com/auth/calendar";
public const string DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code";
public async Task<DeviceAuthorization> StartDeviceFlowAsync(CancellationToken cancellationToken)
{
var (status, json) = await PostAsync(DeviceCodeUrl, [new("client_id", clientId), new("scope", CalendarScope)], cancellationToken).ConfigureAwait(false);
if (!IsSuccess(status) || json is null || Str(json, "device_code") is not { } deviceCode || Str(json, "user_code") is not { } userCode)
throw new GoogleCalendarException($"Google refused the device code request: {Describe(json, status)}");
var url = Str(json, "verification_url") ?? Str(json, "verification_uri") ?? "https://www.google.com/device";
var expiresIn = Int(json, "expires_in") ?? 1800;
var interval = Math.Max(1, Int(json, "interval") ?? 5);
return new DeviceAuthorization(deviceCode, userCode, url, time.GetUtcNow().AddSeconds(expiresIn), TimeSpan.FromSeconds(interval));
}
public async Task<PollResult> PollAsync(string deviceCode, CancellationToken cancellationToken)
{
var (status, json) = await PostAsync(TokenUrl,
[new("client_id", clientId), new("client_secret", clientSecret), new("device_code", deviceCode), new("grant_type", DeviceGrantType)],
cancellationToken).ConfigureAwait(false);
if (json is not null && Str(json, "error") is { } error)
{
return error switch
{
"authorization_pending" => PollResult.Pending,
"slow_down" => PollResult.SlowDown,
"access_denied" => PollResult.Denied,
"expired_token" => PollResult.Expired,
_ => PollResult.Failed(Describe(json, status)),
};
}
if ((int)status >= 500) throw new HttpRequestException($"Google returned HTTP {(int)status}.");
if (!IsSuccess(status) || json is null) return PollResult.Failed(Describe(json, status));
if (Str(json, "refresh_token") is not { Length: > 0 } refreshToken) return PollResult.Failed(GoogleCalendarMessages.NoRefreshToken);
return PollResult.Succeeded(refreshToken, Str(json, "scope"), Str(json, "access_token") ?? "",
time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
}
public async Task<AccessToken> RefreshAsync(string refreshToken, CancellationToken cancellationToken)
{
var (status, json) = await PostAsync(TokenUrl,
[new("client_id", clientId), new("client_secret", clientSecret), new("refresh_token", refreshToken), new("grant_type", "refresh_token")],
cancellationToken).ConfigureAwait(false);
if (json is not null && Str(json, "error") == "invalid_grant")
throw new GoogleCalendarException(GoogleCalendarMessages.TokenRejected, notConnected: true);
if (!IsSuccess(status) || json is null || Str(json, "access_token") is not { Length: > 0 } accessToken)
throw new GoogleCalendarException($"Google token refresh failed: {Describe(json, status)}");
return new AccessToken(accessToken, time.GetUtcNow().AddSeconds(Int(json, "expires_in") ?? 3600));
}
/// <summary>Best effort. Null when Google revoked the token, else the reason.</summary>
public async Task<string?> RevokeAsync(string token, CancellationToken cancellationToken)
{
try
{
var (status, json) = await PostAsync(RevokeUrl, [new("token", token)], cancellationToken).ConfigureAwait(false);
return IsSuccess(status) ? null : Describe(json, status);
}
catch (HttpRequestException ex)
{
return ex.Message;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
return ex.Message; // HttpClient timeout
}
}
private async Task<(HttpStatusCode Status, JsonObject? Json)> PostAsync(string url, KeyValuePair<string, string>[] form, CancellationToken cancellationToken)
{
using var content = new FormUrlEncodedContent(form);
using var response = await http.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
JsonObject? json = null;
try { json = JsonNode.Parse(text) as JsonObject; }
catch (System.Text.Json.JsonException) { }
return (response.StatusCode, json);
}
private static bool IsSuccess(HttpStatusCode status) => (int)status is >= 200 and < 300;
private static string Describe(JsonObject? json, HttpStatusCode status)
{
var error = json is null ? null : Str(json, "error");
var description = json is null ? null : Str(json, "error_description");
return (error, description) switch
{
({ } e, { } d) => $"{e}: {d}",
({ } e, null) => e,
_ => $"HTTP {(int)status}",
};
}
private static string? Str(JsonObject json, string name) =>
json[name] is JsonValue value && value.TryGetValue<string>(out var s) ? s : null;
private static int? Int(JsonObject json, string name) => json[name] switch
{
JsonValue v when v.TryGetValue<int>(out var i) => i,
JsonValue v when v.TryGetValue<string>(out var s) && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) => p,
_ => null,
};
}

View File

@@ -0,0 +1,70 @@
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>What <c>token.json</c> holds for one chat. <see cref="ToString"/> hides the refresh token.</summary>
internal sealed record StoredToken(string RefreshToken, string? Scope = null, string? Account = null, string? TimeZone = null, DateTimeOffset? ConnectedAt = null)
{
public override string ToString() => $"StoredToken {{ Account = {Account}, TimeZone = {TimeZone}, ConnectedAt = {ConnectedAt:O} }}";
}
/// <summary>
/// One chat's <c>token.json</c>. Reads never throw (unreadable = not connected); writes are atomic
/// (<c>token.json.tmp</c> then a move) and owner-only on Unix.
/// </summary>
internal sealed class GoogleTokenStore(string path)
{
public const string FileName = "token.json";
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
public string Path { get; } = path;
public StoredToken? Read()
{
try
{
if (!File.Exists(Path)) return null;
var text = File.ReadAllText(Path);
if (string.IsNullOrWhiteSpace(text)) return null;
var token = VolcanicJson.Deserialize<StoredToken>(text);
return string.IsNullOrWhiteSpace(token?.RefreshToken) ? null : token;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or NotSupportedException)
{
return null;
}
}
public void Write(StoredToken token)
{
ArgumentNullException.ThrowIfNull(token);
var directory = System.IO.Path.GetDirectoryName(Path)!;
CreatePrivateDirectory(System.IO.Path.GetDirectoryName(directory)!);
CreatePrivateDirectory(directory);
var temp = Path + ".tmp";
var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
using (var stream = new FileStream(temp, options))
JsonSerializer.Serialize(stream, token, VolcanicJson.Indented);
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PrivateFile); // a leftover .tmp keeps its old mode otherwise
File.Move(temp, Path, overwrite: true);
}
public void Delete()
{
if (File.Exists(Path)) File.Delete(Path);
}
private static void CreatePrivateDirectory(string directory)
{
if (Directory.Exists(directory)) return;
if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(directory);
return;
}
Directory.CreateDirectory(directory, PrivateDirectory);
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<!-- Private="false": the host already ships Abstractions; an extension must not bring its own copy (docs/extensions.md). -->
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" Private="false" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
namespace Volcanic.AgenticCore.Extensions.GoogleCalendar;
/// <summary>Google names zones by IANA id; Windows may hand out Windows ids.</summary>
internal static class ZoneIds
{
/// <summary>The IANA id (Google's zone names), converting a Windows id when needed.</summary>
public static string Name(TimeZoneInfo zone) =>
zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : zone.Id;
public static bool TryFind(string id, out TimeZoneInfo zone) => TimeZoneInfo.TryFindSystemTimeZoneById(id, out zone!);
}

View File

@@ -0,0 +1,24 @@
using System.Text.Json;
using ModelContextProtocol.Protocol;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>One server tool: the name on the wire, the name the agent sees, and its schema verbatim.</summary>
internal sealed record McpCatalogTool(string McpName, string ExposedName, string? Description, string? Title, JsonElement InputSchema);
/// <summary>A server's <c>tools/list</c> result, discovered once per host process.</summary>
internal sealed record McpCatalog(string ServerName, string? ServerInfoName, string? ServerVersion, IReadOnlyList<McpCatalogTool> Tools)
{
public static McpCatalog From(string serverName, McpServerConfig config, Implementation? info, IReadOnlyList<Tool> tools)
{
var exposed = ToolNaming.Expose([.. tools.Select(t => t.Name)], config.ToolPrefix);
return new McpCatalog(serverName, info?.Name, info?.Version,
[.. tools.Select((t, i) => new McpCatalogTool(t.Name, exposed[i], t.Description, t.Annotations?.Title ?? t.Title, t.InputSchema.Clone()))]);
}
}
/// <summary>A server could not be started or didn't answer the handshake. The message includes its stderr tail.</summary>
internal sealed class McpServerStartException(string message, Exception? inner = null) : Exception(message, inner);
/// <summary>A running server exited; the next call starts a new process.</summary>
internal sealed class McpServerExitedException(string message) : Exception(message);

View File

@@ -0,0 +1,124 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// Process-wide cache of <c>tools/list</c> results, keyed by server name and a digest of its unexpanded configuration.
/// Concurrent requests share one discovery. Success is kept for the process lifetime, failure for <see cref="FailureCooldown"/>.
/// </summary>
public sealed class McpCatalogCache
{
/// <summary>How long a failed discovery is remembered before the next request tries again.</summary>
public static readonly TimeSpan FailureCooldown = TimeSpan.FromSeconds(60);
/// <summary>Explicit process-wide state, like <c>ClaudeLoginCoordinator.Shared</c>.</summary>
public static McpCatalogCache Shared { get; } = new(TimeProvider.System);
private readonly TimeProvider _time;
private readonly Lock _lock = new();
private readonly Dictionary<string, Task<McpCatalog>> _tasks = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _failedAt = new(StringComparer.Ordinal);
internal McpCatalogCache(TimeProvider time) => _time = time;
/// <summary>
/// The server's catalogue, discovering it if nothing is cached or a failure's cooldown has passed. The shared discovery
/// runs detached from any caller's token; each caller waits with its own. Throws <see cref="McpServerStartException"/>.
/// <paramref name="redact"/> is applied to the server's stderr before it is logged or put in an error message.
/// </summary>
internal Task<McpCatalog> GetAsync(string name, McpServerConfig config, IProcessSpawnPolicy processes, Func<string, string?> getEnv, ILogger logger, CancellationToken cancellationToken, Func<string, string>? redact = null)
{
var key = McpLaunch.CacheKey(name, config);
Task<McpCatalog> task;
lock (_lock)
{
_tasks.TryGetValue(key, out var existing);
var expired = existing is { IsFaulted: true }
&& _failedAt.TryGetValue(key, out var failedAt)
&& _time.GetUtcNow() - failedAt >= FailureCooldown;
if (existing is null || expired)
{
_failedAt.Remove(key);
existing = Task.Run(() => DiscoverAsync(key, name, config, processes, getEnv, logger, redact), CancellationToken.None);
_tasks[key] = existing;
}
task = existing;
}
return task.WaitAsync(cancellationToken);
}
private async Task<McpCatalog> DiscoverAsync(string key, string name, McpServerConfig config, IProcessSpawnPolicy processes, Func<string, string?> getEnv, ILogger logger, Func<string, string>? redact)
{
var dir = Path.Combine(Path.GetTempPath(), "volcanic-mcp-discovery", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var stderr = new StderrTail(redact);
McpProcess? process = null;
try
{
ProcessSpawnContext spawn;
try { spawn = processes.Prepare(McpLaunch.BuildRequest(name, config, dir, getEnv)); }
catch (Exception ex) { throw new McpServerStartException($"{name}: process environment hook failed: {ex.Message}", ex); }
process = await McpProcess.StartAsync(name, config, spawn, stderr, logger, CancellationToken.None).ConfigureAwait(false);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(config.StartupTimeoutSeconds));
IList<McpClientTool> tools;
try { tools = await process.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException)
{
throw new McpServerStartException($"{name} MCP server did not list its tools within {config.StartupTimeoutSeconds}s{McpProcess.TailSuffix(stderr)}");
}
catch (Exception ex)
{
throw new McpServerStartException($"{name} MCP server failed to list its tools: {ex.Message}{McpProcess.TailSuffix(stderr)}", ex);
}
var catalog = McpCatalog.From(name, config, process.Client.ServerInfo, [.. tools.Select(t => t.ProtocolTool)]);
logger.LogInformation("MCP server {Name}: {Count} tools ({Server} {Version})", name, catalog.Tools.Count, catalog.ServerInfoName, catalog.ServerVersion);
return catalog;
}
catch (Exception ex)
{
lock (_lock) _failedAt[key] = _time.GetUtcNow(); // recorded before the task faults
logger.LogWarning("MCP server {Name} is unavailable: {Message}", name, redact is null ? ex.Message : redact(ex.Message));
if (ex is McpServerStartException) throw;
throw new McpServerStartException($"{name} MCP server discovery failed: {ex.Message}", ex);
}
finally
{
// Detached: the SDK spends ShutdownTimeout on every dispose, and no caller needs to wait for it.
_ = Task.Run(() => CleanUpAsync(name, process, dir, logger), CancellationToken.None);
}
}
private static async Task CleanUpAsync(string name, McpProcess? process, string dir, ILogger logger)
{
try
{
if (process is not null) await process.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogDebug(ex, "MCP server {Name}: stopping the discovery process failed", name);
}
// Windows can hold the cwd for a moment after the process exits.
for (var attempt = 1; ; attempt++)
{
try
{
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
if (attempt == 5)
{
logger.LogDebug(ex, "MCP server {Name}: could not delete the discovery directory {Dir}", name, dir);
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt)).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,80 @@
using System.Text;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary><c>/mcp</c>: server states, <c>/mcp &lt;name&gt;</c> details, <c>/mcp restart &lt;name&gt;</c>. No model call.</summary>
internal static class McpCommand
{
public const string Description = "MCP servers: /mcp, /mcp <name> for details, /mcp restart <name>";
/// <summary>The text goes through <paramref name="redact"/>: it can hold a server's stderr and last error.</summary>
public static CommandDefinition Create(IExtensionApi api, IReadOnlyList<McpServerState> servers, Func<string, string> redact) => new(Description, async (args, ctx, _) =>
{
var text = redact(await RunAsync(args, servers).ConfigureAwait(false));
if (ctx.HasUi) ctx.Ui.Notify(text);
else api.SendMessage(CustomMessage.FromText(McpExtension.CommandName, text), MessageDelivery.Steer, triggerTurn: false);
});
public static async Task<string> RunAsync(string args, IReadOnlyList<McpServerState> servers)
{
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0) return Summary(servers);
if (parts[0] == "restart")
{
if (parts.Length < 2) return "Usage: /mcp restart <name>";
if (Find(servers, parts[1]) is not { } target) return Unknown(parts[1], servers);
if (target.Connection is not { } connection) return $"{target.Entry.Name} has no process to restart ({Status(target)}).";
await connection.StopAsync().ConfigureAwait(false);
return $"Stopped {target.Entry.Name}; the next call starts a fresh process.";
}
return Find(servers, parts[0]) is { } server ? Details(server) : Unknown(parts[0], servers);
}
public static string Status(McpServerState server)
{
if (server.Entry.Disabled) return server.Entry.DisabledHint is { } hint ? $"disabled ({hint})" : "disabled";
if (server.Error is { } error) return $"failed: {FirstLine(error)}";
return server.Connection!.Status switch
{
McpConnectionStatus.Running when server.Connection.ProcessExited => "exited (restarts on the next call)",
McpConnectionStatus.Running => "running",
McpConnectionStatus.Failed => $"failed: {FirstLine(server.Connection.LastError ?? "unknown error")}",
_ => "not started",
};
}
private static string Summary(IReadOnlyList<McpServerState> servers)
{
var sb = new StringBuilder("MCP servers:");
foreach (var server in servers)
sb.Append($"\n- {server.Entry.Name}: {Status(server)}{(server.Entry.Disabled ? "" : $" · {server.RegisteredTools.Count} tools")}");
return sb.ToString();
}
private static string Details(McpServerState server)
{
var config = server.Entry.Config;
var sb = new StringBuilder($"**{server.Entry.Name}**: {Status(server)} · {server.RegisteredTools.Count} tools");
if (!server.Entry.Disabled)
{
sb.Append($"\nCommand: {string.Join(' ', [config.Command, .. config.Args])}");
if (config.Env.Count > 0) sb.Append($"\nEnvironment: {string.Join(", ", config.Env.Keys)}"); // names only, never values
}
if (server.Catalog is { } catalog) sb.Append($"\nServer: {catalog.ServerInfoName} {catalog.ServerVersion}");
if (server.RegisteredTools.Count > 0) sb.Append($"\nTools: {string.Join(", ", server.RegisteredTools)}");
var lastError = server.Error ?? server.Connection?.LastError;
if (lastError is not null) sb.Append($"\nLast error: {lastError}");
if (server.Connection?.StderrTail is { Count: > 0 } tail) sb.Append($"\nStderr (last {tail.Count} lines):\n```\n{string.Join('\n', tail)}\n```");
return sb.ToString();
}
private static McpServerState? Find(IReadOnlyList<McpServerState> servers, string name) =>
servers.FirstOrDefault(s => string.Equals(s.Entry.Name, name, StringComparison.Ordinal));
private static string Unknown(string name, IReadOnlyList<McpServerState> servers) =>
$"No MCP server named `{name}`. Servers: {string.Join(", ", servers.Select(s => s.Entry.Name))}.";
private static string FirstLine(string text) => text.Split('\n', 2)[0];
}

View File

@@ -0,0 +1,156 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// MCP client: registers the tools of the configured stdio MCP servers. The catalogue is discovered once per host process;
/// each session gets its own server process, started on the first call.
/// </summary>
public sealed class McpExtension : IExtension
{
public const string CommandName = "mcp";
private readonly McpOptions _options;
private readonly McpCatalogCache _cache;
private readonly Func<string, string?> _getEnv;
private readonly TimeProvider _time;
private readonly List<McpServerState> _servers = [];
/// <summary>For DI: the host binds <see cref="McpOptions"/>, applies the environment and validates it.</summary>
public McpExtension(McpOptions options)
: this(options, McpCatalogCache.Shared, Environment.GetEnvironmentVariable, TimeProvider.System) { }
/// <summary>For a dropped-in assembly: only the <c>PLAYWRIGHT_*</c> variables and the user settings.</summary>
public McpExtension()
: this(FromEnvironment(Environment.GetEnvironmentVariable), McpCatalogCache.Shared, Environment.GetEnvironmentVariable, TimeProvider.System) { }
internal McpExtension(McpOptions options, McpCatalogCache cache, Func<string, string?> getEnv, TimeProvider time)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_cache = cache;
_getEnv = getEnv;
_time = time;
}
public string Name => "mcp";
internal McpOptions Options => _options;
public async ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
var logger = api.Logger;
var redact = RedactorOf(api);
var entries = McpServerSet.Resolve(_options, McpUserSettings.Load(McpUserSettings.AgentDir(_getEnv), logger), logger);
var states = await Task.WhenAll(entries.Select(e => PrepareAsync(e, api, redact, cancellationToken))).ConfigureAwait(false);
_servers.AddRange(states);
RegisterTools(api);
api.RegisterCommand(CommandName, McpCommand.Create(api, _servers, redact));
api.On<BeforeAgentStartEvent>((e, _, _) =>
{
AddPromptSections(api, e.SystemPromptOptions);
return ValueTask.CompletedTask;
});
api.On<SessionShutdownEvent>(async (e, _, _) =>
{
var final = e.Reason is SessionShutdownReason.Quit or SessionShutdownReason.Reload;
// Concurrently: SDK 2.2 spends ShutdownTimeout on every live server it disposes.
await Task.WhenAll(_servers
.Where(s => s.Connection is not null)
.Select(s => final ? s.Connection!.DisposeAsync().AsTask() : s.Connection!.StopAsync().AsTask())).ConfigureAwait(false);
});
}
/// <summary>The host's redactor, or identity for an API without one (it then has no secrets to hide).</summary>
internal static Func<string, string> RedactorOf(IExtensionApi api)
{
try
{
var redactor = api.Redactor;
return redactor.Redact;
}
catch (NotSupportedException)
{
return static text => text;
}
}
private async Task<McpServerState> PrepareAsync(McpServerEntry entry, IExtensionApi api, Func<string, string> redact, CancellationToken cancellationToken)
{
if (entry.Disabled) return new McpServerState(entry);
if (entry.Problem is { } problem)
{
api.Logger.LogError("MCP server {Name} is misconfigured: {Problem}", entry.Name, problem);
return new McpServerState(entry) { Error = problem };
}
try
{
var catalog = await _cache.GetAsync(entry.Name, entry.Config, api.Processes, _getEnv, api.Logger, cancellationToken, redact).ConfigureAwait(false);
return new McpServerState(entry)
{
Catalog = catalog,
Connection = new McpServerConnection(entry.Name, entry.Config, api.Logger, _getEnv, catalog, _time, redact),
};
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return new McpServerState(entry) { Error = ex.Message }; // the cache has logged it
}
}
private void RegisterTools(IExtensionApi api)
{
var builtIns = api.GetAllTools().Where(t => t.Source == "built-in").Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
var owners = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var server in _servers)
{
if (server.Catalog is not { } catalog || server.Connection is not { } connection) continue;
foreach (var tool in catalog.Tools)
{
if (builtIns.Contains(tool.ExposedName))
{
api.Logger.LogWarning("MCP server {Server}: tool {Tool} would replace the built-in tool of the same name and is not registered; set a toolPrefix for this server.", server.Entry.Name, tool.ExposedName);
continue;
}
if (owners.TryGetValue(tool.ExposedName, out var owner))
{
api.Logger.LogWarning("MCP tool {Tool} is offered by servers {Servers}; the first one wins.", tool.ExposedName, $"{owner} and {server.Entry.Name}");
continue;
}
owners[tool.ExposedName] = server.Entry.Name;
api.RegisterTool(new McpTool(tool, connection, server.Entry.Config.Parallel));
server.RegisteredTools.Add(tool.ExposedName);
}
}
}
private void AddPromptSections(IExtensionApi api, SystemPromptOptions options)
{
var active = api.GetActiveTools().ToHashSet(StringComparer.Ordinal);
var hasSend = active.Contains("send"); // registered but inactive: the model can't call it
foreach (var server in _servers)
{
if (!server.RegisteredTools.Any(active.Contains)) continue;
if (server.Entry.PromptSection(hasSend) is { Length: > 0 } section) options.Sections[server.Entry.SectionKey] = section;
}
}
private static McpOptions FromEnvironment(Func<string, string?> getEnv)
{
var options = new McpOptions();
options.ApplyEnvironment(getEnv);
return options;
}
}
/// <summary>One server in one session: its catalogue (if discovery worked), its connection and the tools it got.</summary>
internal sealed class McpServerState(McpServerEntry entry)
{
public McpServerEntry Entry { get; } = entry;
public McpCatalog? Catalog { get; init; }
public McpServerConnection? Connection { get; init; }
public string? Error { get; init; }
public List<string> RegisteredTools { get; } = [];
}

View File

@@ -0,0 +1,64 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using ModelContextProtocol.Client;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Turns a server config into a spawn request and the SDK's transport options.</summary>
internal static partial class McpLaunch
{
public const string WorkspaceToken = "${workspace}";
public static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5);
public static string ExpandWorkspace(string value, string workspace) =>
value.Replace(WorkspaceToken, workspace, StringComparison.Ordinal);
/// <summary><c>${workspace}</c>, then <c>${env:NAME}</c> from the host (unset → empty). Only env values get the second.</summary>
public static string ExpandEnvValue(string value, string workspace, Func<string, string?> getEnv) =>
EnvReference().Replace(ExpandWorkspace(value, workspace), m => getEnv(m.Groups[1].Value) ?? "");
public static ProcessSpawnRequest BuildRequest(string name, McpServerConfig config, string workspace, Func<string, string?> getEnv) => new()
{
Purpose = ProcessPurpose.McpServer,
Command = ExpandWorkspace(config.Command, workspace),
Arguments = [.. config.Args.Select(a => ExpandWorkspace(a, workspace))],
Cwd = config.Cwd is { Length: > 0 } cwd ? ExpandWorkspace(cwd, workspace) : workspace,
Environment = config.Env.Count == 0
? null
: config.Env.ToDictionary(kv => kv.Key, kv => kv.Value is null ? null : ExpandEnvValue(kv.Value, workspace, getEnv), StringComparer.Ordinal),
Origin = name,
};
/// <summary>The spawn's environment is complete (process policy), so the SDK must not merge the host's back in.</summary>
public static StdioClientTransportOptions TransportOptions(string name, ProcessSpawnContext spawn, Action<string> onStderrLine) => new()
{
Name = name,
Command = spawn.Command,
Arguments = [.. spawn.Arguments],
WorkingDirectory = spawn.Cwd,
InheritEnvironmentVariables = false,
EnvironmentVariables = new Dictionary<string, string?>(spawn.Environment.Select(kv => KeyValuePair.Create(kv.Key, (string?)kv.Value))),
ShutdownTimeout = ShutdownTimeout,
StandardErrorLines = onStderrLine,
};
/// <summary>Server name plus a digest of the unexpanded command, args, env, cwd and prefix: every chat shares it, and no host secret enters it.</summary>
public static string CacheKey(string name, McpServerConfig config)
{
var canonical = JsonSerializer.Serialize(new
{
config.Command,
config.Args,
Env = config.Env.OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => new[] { kv.Key, kv.Value }).ToArray(),
config.Cwd,
config.ToolPrefix,
});
return $"{name}:{Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)))}";
}
[GeneratedRegex(@"\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}")]
private static partial Regex EnvReference();
}

View File

@@ -0,0 +1,119 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>MCP client configuration: the <c>Mcp</c> section of the host configuration, plus the <c>PLAYWRIGHT_*</c> variables.</summary>
public sealed class McpOptions
{
public const string SectionName = "Mcp";
private readonly List<string> _environmentErrors = [];
/// <summary>
/// Servers by name. They are taken in name order (configuration binding sorts keys, so the order in appsettings.json
/// is lost); the first by name wins a tool name. Set a <c>toolPrefix</c> to keep both.
/// </summary>
public Dictionary<string, McpServerConfig> Servers { get; set; } = new(StringComparer.Ordinal);
/// <summary>The Playwright preset. Expands into a server named <c>playwright</c> when enabled.</summary>
public PlaywrightOptions Playwright { get; set; } = new();
/// <summary>
/// Fills unset preset values from <c>PLAYWRIGHT_ENABLED</c>, <c>PLAYWRIGHT_HEADLESS</c>, <c>PLAYWRIGHT_BROWSER</c>,
/// <c>PLAYWRIGHT_EXTRA_ARGS</c> (shell-style split) and <c>PLAYWRIGHT_COMMAND</c>. Configured values win; an empty
/// variable counts as unset. Problems are reported by <see cref="Validate"/>.
/// </summary>
public void ApplyEnvironment(Func<string, string?> getEnv)
{
ArgumentNullException.ThrowIfNull(getEnv);
string? Get(string name) => getEnv(name) is { Length: > 0 } value ? value : null;
var p = Playwright;
if (p.Enabled is null && Get("PLAYWRIGHT_ENABLED") is { } enabled) p.Enabled = ParseBool("PLAYWRIGHT_ENABLED", enabled);
if (p.Headless is null && Get("PLAYWRIGHT_HEADLESS") is { } headless) p.Headless = ParseBool("PLAYWRIGHT_HEADLESS", headless);
p.Browser ??= Get("PLAYWRIGHT_BROWSER");
p.Command ??= Get("PLAYWRIGHT_COMMAND");
if (p.ExtraArgs is not { Count: > 0 } && Get("PLAYWRIGHT_EXTRA_ARGS") is { } extra)
{
try { p.ExtraArgs = [.. ShellWords.Split(extra)]; }
catch (FormatException ex) { _environmentErrors.Add($"PLAYWRIGHT_EXTRA_ARGS: {ex.Message}."); }
}
}
private bool? ParseBool(string name, string value)
{
if (bool.TryParse(value, out var parsed)) return parsed;
if (value == "1") return true;
if (value == "0") return false;
_environmentErrors.Add($"{name} must be true or false, got '{value}'.");
return null;
}
/// <summary>Configuration errors: environment parsing problems and every server's own errors.</summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>(_environmentErrors);
foreach (var (name, server) in Servers) errors.AddRange(server.Validate(name));
return errors;
}
/// <summary>The startup log line: <c>MCP: playwright enabled (headless, chromium)</c> or how to enable it.</summary>
public string DescribePlaywright() => PlaywrightPreset.Describe(Playwright);
}
/// <summary>One stdio MCP server. <c>${workspace}</c> expands in command, args, cwd and env; <c>${env:NAME}</c> in env values.</summary>
public sealed partial class McpServerConfig
{
public string Command { get; set; } = "";
public List<string> Args { get; set; } = [];
/// <summary>Applied over the filtered environment and never filtered. A null value removes the variable.</summary>
public Dictionary<string, string?> Env { get; set; } = new(StringComparer.Ordinal);
/// <summary>Working directory of the session process. Defaults to <c>${workspace}</c>.</summary>
public string? Cwd { get; set; }
public string? ToolPrefix { get; set; }
public bool Enabled { get; set; } = true;
public bool Parallel { get; set; }
public int StartupTimeoutSeconds { get; set; } = 60;
public int CallTimeoutSeconds { get; set; } = 120;
/// <summary>0 means an idle process is never stopped.</summary>
public int IdleTimeoutSeconds { get; set; } = 1800;
public LogLevel StderrLevel { get; set; } = LogLevel.Warning;
/// <summary>Added to the system prompt while any of this server's tools is active.</summary>
public string? PromptSection { get; set; }
public IReadOnlyList<string> Validate(string name)
{
var where = $"Mcp:Servers:{name}";
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(name)) errors.Add("Mcp:Servers: a server name is empty.");
if (string.IsNullOrWhiteSpace(Command)) errors.Add($"{where}: command is required.");
if (StartupTimeoutSeconds <= 0) errors.Add($"{where}: startupTimeoutSeconds must be positive.");
if (CallTimeoutSeconds <= 0) errors.Add($"{where}: callTimeoutSeconds must be positive.");
if (IdleTimeoutSeconds < 0) errors.Add($"{where}: idleTimeoutSeconds must be 0 (never) or positive.");
if (ToolPrefix is { Length: > 0 } prefix)
{
if (!PrefixPattern().IsMatch(prefix)) errors.Add($"{where}: toolPrefix may only contain letters, digits, '_' and '-'.");
else if (prefix.StartsWith("mcp__", StringComparison.OrdinalIgnoreCase)) errors.Add($"{where}: toolPrefix must not start with mcp__.");
}
return errors;
}
[GeneratedRegex("^[A-Za-z0-9_-]*$")]
private static partial Regex PrefixPattern();
}
/// <summary>The Playwright preset. Null means "not configured" so the environment can fill it in.</summary>
public sealed class PlaywrightOptions
{
public bool? Enabled { get; set; }
public bool? Headless { get; set; }
public string? Browser { get; set; }
public List<string>? ExtraArgs { get; set; }
/// <summary>npm package spec used with npx. Defaults to <see cref="PlaywrightPreset.DefaultPackage"/>.</summary>
public string? Package { get; set; }
/// <summary>Run this binary instead of <c>npx -y &lt;package&gt;</c> (e.g. the globally installed <c>playwright-mcp</c>).</summary>
public string? Command { get; set; }
public bool IsEnabled => Enabled ?? false;
public bool IsHeadless => Headless ?? true;
}

View File

@@ -0,0 +1,103 @@
using System.ComponentModel;
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>A started server: the SDK client over a stdio transport. Used for discovery and for session processes.</summary>
internal sealed class McpProcess : IAsyncDisposable
{
private static readonly TimeSpan StderrGrace = TimeSpan.FromMilliseconds(300);
private int _disposed;
private readonly DateTime _spawnedAfter;
private readonly DateTime _spawnedBefore;
private McpProcess(McpClient client, DateTime spawnedAfter, DateTime spawnedBefore)
{
Client = client;
_spawnedAfter = spawnedAfter;
_spawnedBefore = spawnedBefore;
}
public McpClient Client { get; }
public Task<ClientCompletionDetails> Completion => Client.Completion;
public bool HasExited => Client.Completion.IsCompleted;
/// <summary>
/// Spawns the prepared process and runs the handshake under <c>startupTimeoutSeconds</c>. Stderr goes to
/// <paramref name="stderr"/>, which redacts it, and then to the log.
/// </summary>
public static async Task<McpProcess> StartAsync(string name, McpServerConfig config, ProcessSpawnContext spawn, StderrTail stderr, ILogger logger, CancellationToken cancellationToken)
{
var timeout = TimeSpan.FromSeconds(config.StartupTimeoutSeconds);
var transport = new StdioClientTransport(McpLaunch.TransportOptions(name, spawn, line =>
logger.Log(config.StderrLevel, "{Server} mcp: {Line}", name, stderr.Add(line))));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
var spawnedAfter = DateTime.Now;
try
{
var client = await McpClient.CreateAsync(
transport,
new McpClientOptions { ClientInfo = new Implementation { Name = "volcanic-agentic-core", Version = "1.0.0" }, InitializationTimeout = timeout },
loggerFactory: null,
cts.Token).ConfigureAwait(false);
return new McpProcess(client, spawnedAfter, DateTime.Now);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new McpServerStartException($"{name} MCP server did not start within {config.StartupTimeoutSeconds}s{TailSuffix(stderr)}");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await Task.Delay(StderrGrace, CancellationToken.None).ConfigureAwait(false); // let the dying process's last lines arrive
throw new McpServerStartException($"{name} MCP server failed to start: {ex.Message}{TailSuffix(stderr)}", ex);
}
}
public static string TailSuffix(StderrTail stderr) =>
stderr.Lines is { Count: > 0 } lines ? "\n" + string.Join('\n', lines) : "";
/// <summary>The completion details if the process ends within <paramref name="grace"/>, else null.</summary>
public async Task<ClientCompletionDetails?> WaitForExitAsync(TimeSpan grace)
{
var finished = await Task.WhenAny(Completion, Task.Delay(grace)).ConfigureAwait(false);
return finished == Completion ? await Completion.ConfigureAwait(false) : null;
}
/// <summary>
/// Idempotent. The SDK (2.2) waits up to <c>ShutdownTimeout</c> for the process to exit, with stdin still open, then
/// kills its tree; so a live server always costs that long. Only when the SDK reports no exit code (so the process
/// may have survived) is the tree killed here, and only if the process found under the pid started while this server
/// was being spawned: a reused pid belongs to some other process and is left alone.
/// </summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { await Client.DisposeAsync().ConfigureAwait(false); }
catch (Exception) { /* a dead transport may throw on dispose; the process is handled below */ }
if (Client.Completion.IsCompletedSuccessfully
&& Client.Completion.Result is StdioClientCompletionDetails { ProcessId: { } pid, ExitCode: null })
KillTreeIfOurs(pid);
}
private void KillTreeIfOurs(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
if (process.HasExited) return;
var started = process.StartTime;
if (started < _spawnedAfter.AddSeconds(-1) || started > _spawnedBefore.AddSeconds(1)) return;
process.Kill(entireProcessTree: true);
}
catch (ArgumentException) { }
catch (InvalidOperationException) { }
catch (Win32Exception) { }
catch (NotSupportedException) { }
}
}

View File

@@ -0,0 +1,98 @@
using System.Text;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
using CoreBlock = Volcanic.AgenticCore.Abstractions.Messages.ContentBlock;
using McpBlock = ModelContextProtocol.Protocol.ContentBlock;
using TextContent = Volcanic.AgenticCore.Abstractions.Messages.TextContent;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Maps an MCP <c>tools/call</c> result to a core <see cref="ToolResult"/> (spec §7).</summary>
internal static class McpResultMapper
{
public const int MaxImageBytes = 5 * 1024 * 1024;
public const int MaxLines = 2000;
public const int MaxBytes = 50 * 1024;
/// <summary>
/// One <see cref="TextContent"/> (the joined, truncated text) followed by the images in order.
/// Throws <see cref="ToolExecutionException"/> carrying the mapped result when the server set <c>isError</c>.
/// </summary>
public static ToolResult Map(string server, string tool, CallToolResult result)
{
var text = new List<string>();
var images = new List<ImageContent>();
foreach (var block in result.Content ?? []) Add(block, text, images);
if (text.Count == 0 && images.Count == 0 && result.StructuredContent is { } structured)
text.Add(structured.GetRawText());
var (shown, truncated) = Truncate(string.Join('\n', text));
var content = new List<CoreBlock>();
if (shown.Length > 0) content.Add(new TextContent(shown));
else if (images.Count == 0) content.Add(new TextContent("(no output)"));
content.AddRange(images);
var isError = result.IsError == true;
var mapped = new ToolResult(content, VolcanicJson.ToElement(new { server, tool, isError, truncated, images = images.Count }));
if (isError) throw new ToolExecutionException(mapped);
return mapped;
}
private static void Add(McpBlock block, List<string> text, List<ImageContent> images)
{
switch (block)
{
case TextContentBlock t:
text.Add(t.Text);
break;
case ImageContentBlock image:
var size = image.DecodedData.Length;
if (size > MaxImageBytes) text.Add($"[image omitted: {image.MimeType}, {size} bytes]");
else images.Add(new ImageContent(Encoding.UTF8.GetString(image.Data.Span), image.MimeType));
break;
case AudioContentBlock audio:
text.Add($"[audio omitted: {audio.MimeType}, {audio.DecodedData.Length} bytes]");
break;
case EmbeddedResourceBlock { Resource: TextResourceContents resource }:
text.Add($"Resource {resource.Uri}:\n{resource.Text}");
break;
case EmbeddedResourceBlock { Resource: BlobResourceContents blob }:
text.Add($"[resource {blob.Uri}: {blob.MimeType ?? "application/octet-stream"}, {blob.DecodedData.Length} bytes omitted]");
break;
case ResourceLinkBlock link:
text.Add($"Resource link: {link.Uri} ({link.Name})");
break;
default:
text.Add($"[{block.Type} content omitted]");
break;
}
}
/// <summary>
/// Head truncation with the core <c>OutputTruncation.Head</c> limits and note wording, reimplemented here because
/// that type lives in the runtime. The note follows the kept text on its own line, as in the built-in tools.
/// </summary>
private static (string Text, bool Truncated) Truncate(string text)
{
var lines = text.Split('\n');
var totalBytes = Encoding.UTF8.GetByteCount(text);
if (lines.Length <= MaxLines && totalBytes <= MaxBytes) return (text, false);
var sb = new StringBuilder();
var bytes = 0;
var shown = 0;
foreach (var line in lines)
{
var lineBytes = Encoding.UTF8.GetByteCount(line) + 1;
if (shown >= MaxLines || bytes + lineBytes > MaxBytes) break;
if (shown > 0) sb.Append('\n');
sb.Append(line);
bytes += lineBytes;
shown++;
}
var note = $"[Output truncated: showing {shown} of {lines.Length} lines ({totalBytes} bytes). Use offset/limit or a narrower query to see more.]";
return ($"{sb}\n{note}", true);
}
}

View File

@@ -0,0 +1,280 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Mcp;
internal enum McpConnectionStatus { NotStarted, Running, Failed }
/// <summary>One server process for one session: started on the first call, stopped on session shutdown or when idle.</summary>
internal sealed class McpServerConnection : IAsyncDisposable
{
private static readonly TimeSpan ExitGrace = TimeSpan.FromSeconds(2);
private static readonly TimeSpan StderrGrace = TimeSpan.FromMilliseconds(150);
private static readonly TimeSpan CatalogCheckCap = TimeSpan.FromSeconds(10);
private readonly McpServerConfig _config;
private readonly ILogger _logger;
private readonly Func<string, string?> _getEnv;
private readonly McpCatalog? _catalog;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Func<string, string> _redact;
private readonly StderrTail _stderr;
private readonly Lock _lock = new();
private McpProcess? _process;
private ITimer? _idleTimer;
private long _timerGeneration;
private int _inFlight;
private int _disposed;
private long _nextRequestId;
/// <param name="redact">Applied to the server's stderr as it arrives and to logged errors; identity when null.</param>
public McpServerConnection(string name, McpServerConfig config, ILogger logger, Func<string, string?> getEnv, McpCatalog? catalog = null, TimeProvider? time = null, Func<string, string>? redact = null)
{
Name = name;
_redact = redact ?? (static text => text);
_stderr = new StderrTail(redact);
_config = config;
_logger = logger;
_getEnv = getEnv;
_catalog = catalog;
_time = time ?? TimeProvider.System;
}
public string Name { get; }
public McpConnectionStatus Status { get; private set; }
public string? LastError { get; private set; }
public int StartCount { get; private set; }
public IReadOnlyList<string> StderrTail => _stderr.Lines;
/// <summary>True when the current process has ended but no call has noticed yet (the status still says running).</summary>
public bool ProcessExited => Volatile.Read(ref _process)?.HasExited == true;
private bool Disposed => Volatile.Read(ref _disposed) == 1;
/// <summary>
/// Calls <paramref name="mcpName"/> on the session's process, starting it first if needed. Throws
/// <see cref="TimeoutException"/>, <see cref="OperationCanceledException"/>, <see cref="McpServerExitedException"/>
/// or <see cref="McpServerStartException"/>; a JSON-RPC error from the server surfaces as the SDK's exception.
/// </summary>
public async Task<CallToolResult> CallAsync(string mcpName, JsonElement arguments, IExtensionContext context, CancellationToken cancellationToken)
{
var process = await EnsureStartedAsync(context, cancellationToken).ConfigureAwait(false);
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(_config.CallTimeoutSeconds));
var request = new CallToolRequestParams { Name = mcpName, Arguments = ToArguments(arguments) };
var id = new RequestId($"volcanic-{Interlocked.Increment(ref _nextRequestId).ToString(CultureInfo.InvariantCulture)}");
try
{
return await process.Client.SendRequestAsync<CallToolRequestParams, CallToolResult>(
RequestMethods.ToolsCall, request, McpJsonUtilities.DefaultOptions, id, timeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
await SendCancelledAsync(process, id, "aborted").ConfigureAwait(false);
throw;
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
{
await SendCancelledAsync(process, id, "timed out").ConfigureAwait(false);
throw new TimeoutException($"{mcpName} timed out after {_config.CallTimeoutSeconds}s");
}
catch (Exception ex) when (ex is not McpProtocolException)
{
if (await process.WaitForExitAsync(ExitGrace).ConfigureAwait(false) is { } exit)
throw await OnExitedAsync(process, exit).ConfigureAwait(false);
throw;
}
}
finally
{
EndCall();
}
}
/// <summary>Stops the process; the next call starts a new one. Idempotent.</summary>
public async ValueTask StopAsync()
{
await _gate.WaitAsync().ConfigureAwait(false);
try { await StopCoreAsync().ConfigureAwait(false); }
finally { _gate.Release(); }
}
/// <summary>Stops the process and refuses later calls with <see cref="ObjectDisposedException"/>. Idempotent.</summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
await StopAsync().ConfigureAwait(false);
}
/// <summary>Caller holds <see cref="_gate"/>. Resets a failed status too; <see cref="LastError"/> stays for <c>/mcp &lt;name&gt;</c>.</summary>
private async ValueTask StopCoreAsync()
{
lock (_lock) { _idleTimer?.Dispose(); _idleTimer = null; _timerGeneration++; }
if (Interlocked.Exchange(ref _process, null) is { } process) await process.DisposeAsync().ConfigureAwait(false);
Status = McpConnectionStatus.NotStarted;
}
/// <summary>Returns a live process with the call counted as in flight (so the idle timer can't stop it underneath).</summary>
private async Task<McpProcess> EnsureStartedAsync(IExtensionContext context, CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ObjectDisposedException.ThrowIf(Disposed, this);
if (_process is { } existing)
{
if (existing.HasExited) throw await OnExitedAsync(existing, await existing.Completion.ConfigureAwait(false)).ConfigureAwait(false);
BeginCall();
return existing;
}
ProcessSpawnContext spawn;
try
{
spawn = await context.PrepareProcessAsync(McpLaunch.BuildRequest(Name, _config, context.Cwd, _getEnv), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
throw Fail(new McpServerStartException($"{Name}: process environment hook failed: {ex.Message}", ex));
}
_stderr.Clear();
McpProcess process;
try { process = await McpProcess.StartAsync(Name, _config, spawn, _stderr, _logger, cancellationToken).ConfigureAwait(false); }
catch (McpServerStartException ex) { throw Fail(ex); }
_process = process;
StartCount++;
Status = McpConnectionStatus.Running;
LastError = null;
_logger.LogInformation("MCP server {Name} started in {Cwd}", Name, spawn.Cwd);
_ = Task.Run(() => WarnIfCatalogChangedAsync(process), CancellationToken.None); // only logs; never holds the gate
BeginCall();
return process;
}
finally
{
_gate.Release();
}
}
private McpServerStartException Fail(McpServerStartException ex)
{
Status = McpConnectionStatus.Failed;
LastError = ex.Message;
_logger.LogWarning("{Message}", _redact(ex.Message));
return ex;
}
private async Task<McpServerExitedException> OnExitedAsync(McpProcess process, ClientCompletionDetails? details)
{
Interlocked.CompareExchange(ref _process, null, process);
await process.DisposeAsync().ConfigureAwait(false);
await Task.Delay(StderrGrace).ConfigureAwait(false); // the last stderr lines race the exit
var code = (details as StdioClientCompletionDetails)?.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "?";
var tail = _stderr.Lines is { Count: > 0 } lines ? string.Join('\n', lines) : "no stderr output";
var message = $"{Name} MCP server exited ({code}): {tail}. It restarts on the next call; browser state was lost.";
Status = McpConnectionStatus.Failed;
LastError = message;
_logger.LogWarning("{Message}", message);
return new McpServerExitedException(message);
}
private void BeginCall()
{
lock (_lock)
{
_inFlight++;
_timerGeneration++;
_idleTimer?.Dispose();
_idleTimer = null;
}
}
private void EndCall()
{
lock (_lock)
{
if (--_inFlight > 0 || _config.IdleTimeoutSeconds == 0 || Disposed) return;
_idleTimer?.Dispose();
var generation = ++_timerGeneration;
_idleTimer = _time.CreateTimer(_ => _ = StopIfIdleAsync(generation), null, TimeSpan.FromSeconds(_config.IdleTimeoutSeconds), Timeout.InfiniteTimeSpan);
}
}
/// <summary>A callback that fired before a later call reset the timer finds a newer generation and does nothing.</summary>
private async Task StopIfIdleAsync(long generation)
{
try
{
await _gate.WaitAsync().ConfigureAwait(false);
try
{
lock (_lock) { if (_inFlight > 0 || _timerGeneration != generation) return; }
if (_process is null) return;
_logger.LogInformation("MCP server {Name} was idle for {Seconds}s; stopping it", Name, _config.IdleTimeoutSeconds);
await StopCoreAsync().ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
_logger.LogWarning("MCP server {Name}: idle stop failed: {Message}", Name, ex.Message);
}
}
private async Task WarnIfCatalogChangedAsync(McpProcess process)
{
if (_catalog is null) return;
try
{
var cap = TimeSpan.FromSeconds(_config.StartupTimeoutSeconds);
using var cts = new CancellationTokenSource(cap < CatalogCheckCap ? cap : CatalogCheckCap);
var live = await process.Client.ListToolsAsync(cancellationToken: cts.Token).ConfigureAwait(false);
var liveNames = live.Select(t => t.Name).Order(StringComparer.Ordinal);
if (!liveNames.SequenceEqual(_catalog.Tools.Select(t => t.McpName).Order(StringComparer.Ordinal), StringComparer.Ordinal))
_logger.LogWarning("MCP server {Name}: its tools changed since discovery; calls still go by name. Restart the host to pick up the new list.", Name);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
_logger.LogDebug("MCP server {Name}: could not compare its tools with the catalogue: {Message}", Name, ex.Message);
}
}
/// <summary>
/// The SDK (2.2.0) doesn't send <c>notifications/cancelled</c> when a request's token fires (checked against the fake
/// server), so the call carries its own id and the notification goes out here. Best effort: a dead server can't take it.
/// </summary>
private async Task SendCancelledAsync(McpProcess process, RequestId id, string reason)
{
try
{
using var cts = new CancellationTokenSource(ExitGrace);
await process.Client.SendNotificationAsync(NotificationMethods.CancelledNotification,
new CancelledNotificationParams { RequestId = id, Reason = reason }, McpJsonUtilities.DefaultOptions, cts.Token).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
_logger.LogDebug("MCP server {Name}: could not send notifications/cancelled: {Message}", Name, ex.Message);
}
}
private static Dictionary<string, JsonElement> ToArguments(JsonElement arguments)
{
var result = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
if (arguments.ValueKind == JsonValueKind.Object)
foreach (var property in arguments.EnumerateObject()) result[property.Name] = property.Value.Clone();
return result;
}
}

View File

@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>One configured server as a session sees it: config, prompt section and whether it can run.</summary>
internal sealed record McpServerEntry
{
public required string Name { get; init; }
public required McpServerConfig Config { get; init; }
/// <summary>Key in <c>SystemPromptOptions.Sections</c>.</summary>
public required string SectionKey { get; init; }
/// <summary>The prompt section given whether a <c>send</c> tool exists; null for none.</summary>
public required Func<bool, string?> PromptSection { get; init; }
public bool Disabled { get; init; }
public string? DisabledHint { get; init; }
/// <summary>Validation errors; a server with a problem is listed but never started.</summary>
public string? Problem { get; init; }
public static McpServerEntry For(string name, McpServerConfig config)
{
ArgumentNullException.ThrowIfNull(config);
var errors = config.Validate(name);
return new()
{
Name = name,
Config = config,
SectionKey = name,
PromptSection = _ => config.PromptSection,
Disabled = !config.Enabled,
Problem = errors.Count == 0 ? null : string.Join(" ", errors),
};
}
public static McpServerEntry Preset(PlaywrightOptions options) => new()
{
Name = PlaywrightPreset.ServerName,
Config = PlaywrightPreset.ToServer(options),
SectionKey = PlaywrightPreset.SectionKey,
PromptSection = hasSend => PlaywrightPreset.PromptSection(hasSend),
};
public static McpServerEntry DisabledPreset() => new()
{
Name = PlaywrightPreset.ServerName,
Config = new McpServerConfig(),
SectionKey = PlaywrightPreset.SectionKey,
PromptSection = _ => null,
Disabled = true,
DisabledHint = "set PLAYWRIGHT_ENABLED=true",
};
}
/// <summary>Merges the configuration sources. The first source wins a server name.</summary>
internal static class McpServerSet
{
/// <summary>
/// Host servers by name, then the Playwright preset when enabled, then the user settings in file order. A taken
/// name is skipped with a log line. A disabled preset is listed last, only while <c>playwright</c> is still free.
/// Host servers are sorted because configuration binding sorts keys anyway (<c>ConfigurationKeyComparer</c>), so
/// appsettings order can't be kept; sorting here gives options built in code the same rule.
/// </summary>
public static IReadOnlyList<McpServerEntry> Resolve(McpOptions options, IEnumerable<KeyValuePair<string, McpServerConfig>> userServers, ILogger logger)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(userServers);
ArgumentNullException.ThrowIfNull(logger);
var entries = new List<McpServerEntry>();
var names = new HashSet<string>(StringComparer.Ordinal);
var hostServers = options.Servers
.OrderBy(s => s.Key, StringComparer.OrdinalIgnoreCase)
.ThenBy(s => s.Key, StringComparer.Ordinal);
foreach (var (name, config) in hostServers)
if (names.Add(name)) entries.Add(McpServerEntry.For(name, config));
if (options.Playwright.IsEnabled)
{
if (names.Add(PlaywrightPreset.ServerName)) entries.Add(McpServerEntry.Preset(options.Playwright));
else logger.LogWarning("MCP server 'playwright' is configured explicitly; the Playwright preset is ignored.");
}
foreach (var (name, config) in userServers)
{
if (names.Add(name)) entries.Add(McpServerEntry.For(name, config));
else logger.LogInformation("MCP server '{Name}' in the user settings is ignored: the host configuration defines it.", name);
}
if (names.Add(PlaywrightPreset.ServerName)) entries.Add(McpServerEntry.DisabledPreset());
return entries;
}
}

View File

@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>An MCP server tool as an agent tool. The schema passes through; the server validates what the core doesn't.</summary>
internal sealed class McpTool : ITool
{
private static readonly JsonElement ObjectSchema = JsonSerializer.SerializeToElement(new { type = "object" });
private readonly McpCatalogTool _tool;
private readonly McpServerConnection _connection;
private readonly bool _parallel;
public McpTool(McpCatalogTool tool, McpServerConnection connection, bool parallel)
{
_tool = tool;
_connection = connection;
_parallel = parallel;
Description = !string.IsNullOrWhiteSpace(tool.Description) ? tool.Description!
: !string.IsNullOrWhiteSpace(tool.Title) ? tool.Title!
: tool.McpName;
Parameters = NormalizeSchema(tool.InputSchema);
}
public string Name => _tool.ExposedName;
public string Description { get; }
public JsonElement Parameters { get; }
public string? PromptSnippet => null;
public ToolExecutionMode ExecutionMode => _parallel ? ToolExecutionMode.Parallel : ToolExecutionMode.Sequential;
public async Task<ToolResult> ExecuteAsync(ToolCallContent call, ToolInvocation invocation, CancellationToken cancellationToken)
{
var result = await _connection.CallAsync(_tool.McpName, call.Arguments, invocation.Context, cancellationToken).ConfigureAwait(false);
return McpResultMapper.Map(_connection.Name, _tool.McpName, result);
}
/// <summary>A missing or non-object schema becomes <c>{"type":"object"}</c>; a top-level <c>$schema</c> is dropped.</summary>
internal static JsonElement NormalizeSchema(JsonElement schema)
{
if (schema.ValueKind != JsonValueKind.Object) return ObjectSchema;
if (!schema.TryGetProperty("$schema", out _)) return schema.Clone();
var node = JsonNode.Parse(schema.GetRawText())!.AsObject();
node.Remove("$schema");
return JsonSerializer.SerializeToElement(node);
}
}

View File

@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Reads <c>"mcpServers"</c> from <c>&lt;agent dir&gt;/settings.json</c> (the same file that lists extensions).</summary>
internal static class McpUserSettings
{
public const string AgentDirVariable = "VOLCANIC_AGENT_DIR";
private static readonly JsonSerializerOptions Json = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
/// <summary><c>VOLCANIC_AGENT_DIR</c>, else <c>~/.volcanic/agent</c>.</summary>
public static string AgentDir(Func<string, string?> getEnv) =>
getEnv(AgentDirVariable) is { Length: > 0 } dir
? dir
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".volcanic", "agent");
/// <summary>Servers in document order. A missing file or section is empty; a malformed file is logged and ignored.</summary>
public static IReadOnlyList<KeyValuePair<string, McpServerConfig>> Load(string agentDir, ILogger logger)
{
var path = Path.Combine(agentDir, "settings.json");
if (!File.Exists(path)) return [];
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path),
new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
if (doc.RootElement.ValueKind != JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("mcpServers", out var servers)
|| servers.ValueKind != JsonValueKind.Object) return [];
var result = new List<KeyValuePair<string, McpServerConfig>>();
foreach (var server in servers.EnumerateObject())
result.Add(new(server.Name, server.Value.Deserialize<McpServerConfig>(Json) ?? new McpServerConfig()));
return result;
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException)
{
logger.LogWarning("Ignoring mcpServers in {Path}: {Message}", path, ex.Message);
return [];
}
}
}

View File

@@ -0,0 +1,90 @@
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>The <c>@playwright/mcp</c> preset: a server named <c>playwright</c> whose output and profile live in the chat workspace.</summary>
public static class PlaywrightPreset
{
public const string ServerName = "playwright";
/// <summary>The single source of truth for the version. The Dockerfile's <c>PLAYWRIGHT_MCP_VERSION</c> must match it.</summary>
public const string DefaultPackage = "@playwright/mcp@0.0.82";
/// <summary>Without <c>--browser</c>, @playwright/mcp uses the Chrome channel, which the deployment doesn't install.</summary>
public const string DefaultBrowser = "chromium";
/// <summary>Key of the preset's section in the system prompt.</summary>
public const string SectionKey = "browser";
/// <summary>The exact version in <see cref="DefaultPackage"/>.</summary>
public static string PackageVersion => DefaultPackage[(DefaultPackage.LastIndexOf('@') + 1)..];
/// <summary>The server entry the preset expands into.</summary>
public static McpServerConfig ToServer(PlaywrightOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var args = new List<string>();
var command = options.Command;
if (string.IsNullOrWhiteSpace(command))
{
command = "npx";
args.Add("-y");
args.Add(string.IsNullOrWhiteSpace(options.Package) ? DefaultPackage : options.Package);
}
if (options.IsHeadless) args.Add("--headless");
args.Add("--browser");
args.Add(Browser(options));
args.Add("--output-dir");
args.Add("${workspace}/browser");
var extra = options.ExtraArgs ?? [];
if (!extra.Contains("--isolated"))
{
args.Add("--user-data-dir");
args.Add("${workspace}/.volcanic/browser-profile");
}
args.AddRange(extra);
return new McpServerConfig
{
Command = command,
Args = args,
Cwd = "${workspace}",
StartupTimeoutSeconds = 120,
CallTimeoutSeconds = 120,
};
}
/// <summary>The startup log line: whether the preset is on, and with which mode and browser.</summary>
public static string Describe(PlaywrightOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return options.IsEnabled
? $"MCP: playwright enabled ({(options.IsHeadless ? "headless" : "headed")}, {Browser(options)})"
: "MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)";
}
/// <summary>The <c>browser</c> prompt section. The <c>send</c> line appears only when a <c>send</c> tool is registered.</summary>
public static string PromptSection(bool hasSend)
{
string[] files = hasSend
?
[
"- To give the user a screenshot, save it with `filename: \"browser/<name>.png\"` and then call `send` with that",
" path. Files the browser saves (PDFs, downloads) are under browser/ too.",
]
:
[
"- Files the browser saves (screenshots with a `filename` such as `browser/<name>.png`, PDFs, downloads) are",
" under browser/ in the workspace.",
];
string[] lines =
[
"## Browser",
"- browser_* tools drive a real browser. Use browser_snapshot (an accessibility tree with element refs) to read a",
" page and find refs for click/type; it is cheaper and more reliable than screenshots.",
"- Take a screenshot without `filename` only when you need to see the visual layout yourself; the image comes back",
" to you.",
.. files,
"- Close the browser (browser_close) when a task is finished. Browser state may be reset after a long pause.",
"- Don't enter passwords or payment details unless the user explicitly asks for that specific site.",
];
return string.Join('\n', lines);
}
private static string Browser(PlaywrightOptions options) =>
string.IsNullOrWhiteSpace(options.Browser) ? DefaultBrowser : options.Browser;
}

View File

@@ -0,0 +1,43 @@
using System.Text;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>Splits a command-line fragment into argv the way a POSIX shell would, without expansion.</summary>
internal static class ShellWords
{
/// <summary>Whitespace separates words; '…' is literal; "…" allows \" and \\. Backslashes elsewhere are kept (Windows paths).</summary>
public static IReadOnlyList<string> Split(string text)
{
var words = new List<string>();
var current = new StringBuilder();
var inWord = false;
char? quote = null;
for (var i = 0; i < text.Length; i++)
{
var c = text[i];
if (quote == '\'')
{
if (c == '\'') quote = null; else current.Append(c);
continue;
}
if (quote == '"')
{
if (c == '"') quote = null;
else if (c == '\\' && i + 1 < text.Length && text[i + 1] is '"' or '\\') current.Append(text[++i]);
else current.Append(c);
continue;
}
if (char.IsWhiteSpace(c))
{
if (inWord) { words.Add(current.ToString()); current.Clear(); inWord = false; }
continue;
}
inWord = true;
if (c is '"' or '\'') quote = c;
else current.Append(c);
}
if (quote is { } open) throw new FormatException($"unterminated {open} quote");
if (inWord) words.Add(current.ToString());
return words;
}
}

View File

@@ -0,0 +1,27 @@
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// The last lines a server wrote to stderr: a server that dies explains why only there. Lines are redacted as they
/// arrive, so the log, error messages and <c>/mcp</c> built from them never hold a known secret.
/// </summary>
internal sealed class StderrTail(Func<string, string>? redact = null, int capacity = 20)
{
private readonly Queue<string> _lines = new();
private readonly Lock _lock = new();
/// <summary>Stores the redacted line and returns it.</summary>
public string Add(string line)
{
if (redact is not null) line = redact(line);
lock (_lock)
{
_lines.Enqueue(line);
while (_lines.Count > capacity) _lines.Dequeue();
}
return line;
}
public void Clear() { lock (_lock) _lines.Clear(); }
public IReadOnlyList<string> Lines { get { lock (_lock) return [.. _lines]; } }
}

View File

@@ -0,0 +1,40 @@
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.Mcp;
/// <summary>
/// Exposed tool names: <c>prefix + mcpName</c>, characters outside <c>[A-Za-z0-9_-]</c> replaced by <c>_</c>, at most 48
/// characters so the Claude Code provider's <c>ToolCatalog</c> keeps them readable, duplicates suffixed <c>_2</c>, <c>_3</c>.
/// </summary>
internal static partial class ToolNaming
{
public const int MaxLength = 48;
public static string Sanitize(string? prefix, string mcpName)
{
var name = InvalidCharacters().Replace((prefix ?? "") + mcpName, "_");
if (name.Length == 0) name = "tool";
return name.Length > MaxLength ? name[..MaxLength] : name;
}
public static IReadOnlyList<string> Expose(IReadOnlyList<string> mcpNames, string? prefix)
{
var used = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>(mcpNames.Count);
foreach (var mcpName in mcpNames)
{
var baseName = Sanitize(prefix, mcpName);
var name = baseName;
for (var n = 2; !used.Add(name); n++)
{
var suffix = $"_{n}";
name = (baseName.Length + suffix.Length > MaxLength ? baseName[..(MaxLength - suffix.Length)] : baseName) + suffix;
}
result.Add(name);
}
return result;
}
[GeneratedRegex("[^A-Za-z0-9_-]")]
private static partial Regex InvalidCharacters();
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>MCP client extension: stdio MCP servers as agent tools, with a Playwright browser preset.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" />
<PackageReference Include="ModelContextProtocol.Core" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>What the host does for the scheduler. Passed to <see cref="JobScheduler.StartAsync"/>, not the constructor, to break the host's DI cycle.</summary>
public interface IJobDelivery
{
/// <summary>Send Markdown to the target's chat, in order with that chat's rendered output.</summary>
Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken);
/// <summary>Run a prompt in the target's session (follow-up when busy); completes when the run that consumed it settles.</summary>
Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken);
/// <summary>Working directory for the target's scripts. A path computation: must not create or wake a session.</summary>
string WorkspaceFor(string target);
/// <summary>False when the target may no longer be served (e.g. removed from the allow-list): the job is skipped, not deleted. Must not create or wake a session.</summary>
bool IsDeliverable(string target);
/// <summary>The target's current zone (the chat's, else the host default). Read on every computation, never cached. Must not create or wake a session.</summary>
TimeZoneInfo TimeZoneFor(string target);
}

View File

@@ -0,0 +1,53 @@
using System.Globalization;
using System.Text;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>How jobs and times are shown (the <c>schedule</c> tool, <c>/jobs</c>, zone-change replies). Invariant culture, 24 h.</summary>
public static class JobFormat
{
public static string Icon(JobKind kind) => kind switch { JobKind.Reminder => "⏰", JobKind.Prompt => "💬", _ => "🔍" };
/// <summary><c>27 Sep 09:00</c> in <paramref name="zone"/>.</summary>
public static string DayTime(DateTimeOffset at, TimeZoneInfo zone) =>
TimeZoneInfo.ConvertTime(at, zone).ToString("d MMM HH:mm", CultureInfo.InvariantCulture);
/// <summary><c>14:10</c> when on the same local day as <paramref name="now"/>, else <see cref="DayTime"/>.</summary>
public static string When(DateTimeOffset at, TimeZoneInfo zone, DateTimeOffset now)
{
var local = TimeZoneInfo.ConvertTime(at, zone);
return local.Date == TimeZoneInfo.ConvertTime(now, zone).Date
? local.ToString("HH:mm", CultureInfo.InvariantCulture)
: DayTime(at, zone);
}
/// <summary><c>Sat 2026-09-26 14:03</c> (the model-facing form: weekday and full date).</summary>
public static string Stamp(DateTimeOffset at, TimeZoneInfo zone) =>
TimeZoneInfo.ConvertTime(at, zone).ToString("ddd yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
public static string ZoneName(TimeZoneInfo zone) => ZoneNames.Name(zone);
/// <summary><c>exit 1</c>, <c>exit 1, timed out</c>, <c>timed out</c>, or <c>did not start</c>.</summary>
public static string FailureReason(JobResult result) => result switch
{
{ ExitCode: { } code, TimedOut: true } => $"exit {code.ToString(CultureInfo.InvariantCulture)}, timed out",
{ ExitCode: { } code } => $"exit {code.ToString(CultureInfo.InvariantCulture)}",
{ TimedOut: true } => "timed out",
_ => "did not start",
};
/// <summary><c>`k7m2q` ⏰ Call the dentist · once 27 Sep 09:00</c>; cron jobs show the expression and the next run; a failed last run is appended.</summary>
public static string Line(JobView view, TimeZoneInfo zone, DateTimeOffset now)
{
var job = view.Job;
var sb = new StringBuilder().Append('`').Append(job.Id).Append("` ").Append(Icon(job.Kind)).Append(' ').Append(job.Name);
if (job.Schedule.At is { } at) sb.Append(" · once ").Append(DayTime(at, zone));
else
{
sb.Append(" · `").Append(job.Schedule.Cron).Append('`');
if (view.NextRunAt is { } next) sb.Append(" · next ").Append(When(next, zone, now));
}
if (job.LastResult is { Ok: false } failed) sb.Append(" · last: failed (").Append(FailureReason(failed)).Append(')');
return sb.ToString();
}
}

View File

@@ -0,0 +1,18 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>The texts a fire sends (spec §4 table).</summary>
internal static class JobMessages
{
public static string Reminder(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
$"⏰ {job.Text}" + (lateDue is { } due ? $" (due {JobFormat.DayTime(due, zone)}, delivered late)" : "");
public static string Prompt(ScheduledJob job, DateTimeOffset? lateDue, TimeZoneInfo zone) =>
$"{Header(job)}\n" + (lateDue is { } due ? $"This was due at {JobFormat.DayTime(due, zone)}; the bot was offline.\n" : "") + job.Text;
public static string WatcherMatch(ScheduledJob job, string output) => $"{Header(job)} The watcher script printed:\n{output}";
public static string Failure(ScheduledJob job, JobResult result) =>
$"⚠️ Job \"{job.Name}\" ({job.Id}) failed ({JobFormat.FailureReason(result)}):\n```\n{(string.IsNullOrWhiteSpace(result.StderrTail) ? "(no output on stderr)" : result.StderrTail)}\n```";
private static string Header(ScheduledJob job) => $"[Scheduled job \"{job.Name}\" ({job.Id})]";
}

View File

@@ -0,0 +1,498 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// The process-wide scheduler: loads <c>jobs.json</c>, arms one <see cref="TimeProvider"/> timer for the soonest job
/// (capped at 1 h, re-armed on every tick, create, cancel, fire and zone change), and fires jobs through the host's
/// <see cref="IJobDelivery"/>. A fire is tracked in flight and never awaited by the tick; an occurrence that comes due
/// while the job is still in flight is skipped. A watcher's script run touches only the runner, <c>IsDeliverable</c>,
/// <c>WorkspaceFor</c> and <c>TimeZoneFor</c>: a silent run makes no delivery call at all.
/// </summary>
public sealed class JobScheduler : IAsyncDisposable
{
public static readonly TimeSpan MaxTimerDelay = TimeSpan.FromHours(1);
public static readonly TimeSpan LateThreshold = TimeSpan.FromSeconds(60);
public static readonly TimeSpan StopGrace = TimeSpan.FromSeconds(10);
private const string IdAlphabet = "abcdefghjkmnpqrstuvwxyz23456789";
private readonly SchedulerOptions _options;
private readonly IScriptRunner _runner;
private readonly ISecretRedactor _redactor;
private readonly TimeProvider _time;
private readonly ILogger _logger;
private readonly JobStore _store;
private readonly SemaphoreSlim _saveGate = new(1, 1);
private readonly Lock _lock = new();
private readonly Dictionary<string, Entry> _jobs = new(StringComparer.Ordinal);
private readonly Dictionary<string, Fire> _inFlight = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _stopping = new();
private IJobDelivery? _delivery;
private ITimer? _timer;
private DateTimeOffset? _wakeAt;
private bool _stopped;
private Task _pendingTick = Task.CompletedTask;
private int _firesStarted;
public JobScheduler(SchedulerOptions options, IScriptRunner runner, ISecretRedactor redactor, TimeProvider time, ILogger<JobScheduler> logger)
{
ArgumentNullException.ThrowIfNull(options);
if (string.IsNullOrWhiteSpace(options.StorePath)) throw new ArgumentException("SchedulerOptions.StorePath is required.", nameof(options));
_options = options;
_runner = runner;
_redactor = redactor;
_time = time;
_logger = logger;
_store = new JobStore(options.StorePath, time, logger);
}
public bool IsStarted { get { lock (_lock) return _delivery is not null && !_stopped; } }
public DateTimeOffset Now => _time.GetUtcNow();
public string StorePath => _store.Path;
/// <summary>When the timer fires next (tests).</summary>
internal DateTimeOffset? NextWakeAt { get { lock (_lock) return _wakeAt; } }
/// <summary>How many fires have been started (tests: a skipped occurrence doesn't count).</summary>
internal int FiresStarted { get { lock (_lock) return _firesStarted; } }
/// <summary>The latest queued tick, without the fires it started (tests; unlike <see cref="IdleAsync"/> it doesn't wait for held fires).</summary>
internal Task PendingTick { get { lock (_lock) return _pendingTick; } }
/// <summary>Load the store, fire overdue one-offs (late), compute cron occurrences strictly after now (no catch-up), arm the timer.</summary>
public async Task StartAsync(IJobDelivery delivery, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(delivery);
var jobs = await _store.LoadAsync(cancellationToken).ConfigureAwait(false);
var now = _time.GetUtcNow();
lock (_lock)
{
if (_delivery is not null || _stopped) throw new InvalidOperationException("The scheduler was already started.");
_delivery = delivery;
foreach (var job in jobs)
{
var entry = new Entry(job);
if (job.Schedule.At is { } at) entry.Next = at;
else
{
try
{
entry.Next = NextCron(job, now);
var missed = Schedules.NextCron(job.Schedule.Cron!, job.LastRunAt ?? job.CreatedAt, delivery.TimeZoneFor(job.Target));
if (missed is { } m && m < now)
_logger.LogInformation("Job {Id} ({Name}): occurrences missed while the bot was down are skipped; next {Next}", job.Id, job.Name, entry.Next);
}
catch (Exception ex)
{
// One bad entry never keeps the host down: it stays listed but unarmed until a zone change recomputes it.
entry.Next = null;
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence on start failed; the job isn't armed", job.Id, job.Name);
}
}
_jobs[job.Id] = entry;
}
_timer = _time.CreateTimer(_ => OnTimer(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
Tick(); // overdue one-offs fire now
}
/// <summary>Disarm, kill running scripts, and wait at most <see cref="StopGrace"/> for in-flight fires.</summary>
public async Task StopAsync(CancellationToken cancellationToken)
{
Fire[] fires;
ITimer? timer;
lock (_lock)
{
if (_stopped) return;
_stopped = true;
fires = [.. _inFlight.Values];
timer = _timer;
}
timer?.Dispose();
await _stopping.CancelAsync().ConfigureAwait(false); // every fire's token is linked to this one
try
{
await Task.WhenAll(fires.Select(f => f.Task)).WaitAsync(StopGrace, _time, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
_logger.LogWarning("Scheduler stop: {Count} job(s) still running after {Grace}", fires.Count(f => !f.Task.IsCompleted), StopGrace);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
}
public ValueTask DisposeAsync() => new(StopAsync(CancellationToken.None));
public IReadOnlyList<JobView> List(string target)
{
lock (_lock)
return _jobs.Values.Where(e => e.Job.Target == target)
.Select(e => new JobView(e.Job, e.Next))
.OrderBy(v => v.NextRunAt ?? DateTimeOffset.MaxValue).ThenBy(v => v.Job.CreatedAt)
.ToList();
}
public TimeZoneInfo TimeZoneFor(string target) => Volatile.Read(ref _delivery)?.TimeZoneFor(target) ?? TimeZoneInfo.Local;
/// <summary>Validate (an offset-less <c>at</c> is read in the target's zone), persist, arm. Throws <see cref="JobValidationException"/>.</summary>
public async Task<JobView> CreateAsync(string target, NewJob job, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(job);
if (!IsStarted) throw new InvalidOperationException("The scheduler isn't running.");
var zone = TimeZoneFor(target);
var now = _time.GetUtcNow();
var schedule = JobValidation.ValidateNew(job, now, zone);
ScheduledJob created;
DateTimeOffset? next;
lock (_lock)
{
if (_jobs.Values.Count(e => e.Job.Target == target) >= _options.MaxJobsPerTarget)
throw new JobValidationException($"This chat already has {_options.MaxJobsPerTarget} scheduled jobs, the maximum. Cancel one first.");
created = new ScheduledJob
{
Id = NewId(), Target = target, Name = job.Name.Trim(), Kind = job.Kind, Schedule = schedule,
Text = job.Kind == JobKind.Watcher ? null : job.Text, Command = job.Kind == JobKind.Watcher ? job.Command : null,
TimeoutSeconds = job.TimeoutSeconds, CreatedAt = now,
};
next = schedule.At ?? Schedules.NextCron(schedule.Cron!, now, zone);
_jobs[created.Id] = new Entry(created) { Next = next };
}
await PersistAsync(cancellationToken).ConfigureAwait(false);
Arm();
return new JobView(created, next);
}
/// <summary>Remove a job of <paramref name="target"/>. A running watcher is killed and its outcome dropped; a prompt run in progress isn't aborted. False: unknown, or another target's.</summary>
public async Task<bool> CancelAsync(string target, string id, CancellationToken cancellationToken) =>
await CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is not null;
/// <summary><see cref="CancelAsync"/> returning the removed job (null: unknown, or another target's); looked up and removed in one step.</summary>
public async Task<ScheduledJob?> CancelJobAsync(string target, string id, CancellationToken cancellationToken)
{
Fire? running;
ScheduledJob removed;
lock (_lock)
{
if (!_jobs.TryGetValue(id, out var entry) || entry.Job.Target != target) return null;
removed = entry.Job;
_jobs.Remove(id);
_inFlight.TryGetValue(id, out running);
}
if (running is not null) await TryCancelAsync(running.Cancel).ConfigureAwait(false);
await PersistAsync(cancellationToken).ConfigureAwait(false);
Arm();
return removed;
}
/// <summary>The target's zone changed: cron jobs get their next occurrence in the new zone (strictly after now), one-offs keep their instant.</summary>
public Task<ZoneChange> TimeZoneChangedAsync(string target, CancellationToken cancellationToken)
{
var now = _time.GetUtcNow();
var zone = TimeZoneFor(target);
int cron = 0, oneOffs = 0;
lock (_lock)
{
foreach (var entry in _jobs.Values.Where(e => e.Job.Target == target))
{
if (entry.Job.Schedule.Cron is { } expression)
{
cron++;
entry.Next = Schedules.NextCron(expression, now, zone);
}
else oneOffs++;
}
}
Arm();
return Task.FromResult(new ZoneChange(cron, oneOffs));
}
/// <summary>Wait for the pending tick and every fire in flight (tests).</summary>
internal async Task IdleAsync()
{
while (true)
{
Task tick;
Task[] fires;
lock (_lock)
{
tick = _pendingTick;
fires = _inFlight.Values.Select(f => f.Task).ToArray();
}
if (tick.IsCompleted && fires.All(f => f.IsCompleted)) return;
await Task.WhenAll(fires.Append(tick)).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
}
// ---- Timer and ticks ----
/// <summary>The timer callback: queues a tick behind the previous one (it may run synchronously inside <c>ITimer.Change</c>).</summary>
private void OnTimer()
{
lock (_lock)
{
if (_stopped) return;
_pendingTick = _pendingTick.ContinueWith(_ => Tick(), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
}
}
/// <summary>Starts every due job that isn't in flight, advances each due job's next occurrence, re-arms. Never awaits a fire.</summary>
private void Tick()
{
try
{
var now = _time.GetUtcNow();
lock (_lock)
{
if (_stopped || _delivery is null) return;
foreach (var entry in _jobs.Values)
{
if (entry.Next is not { } next || next > now) continue;
if (_inFlight.ContainsKey(entry.Job.Id))
_logger.LogInformation("Job {Id} ({Name}) is still running; skipped the occurrence due {Due}", entry.Job.Id, entry.Job.Name, next);
else StartFire(entry.Job, next, now);
// Cron: the next occurrence strictly after now. One-off: none (it's removed once its fire completes).
entry.Next = null;
if (entry.Job.Schedule.Cron is null) continue;
try
{
entry.Next = NextCron(entry.Job, now);
}
catch (Exception ex)
{
// Left unarmed rather than due forever (a zero-delay timer loop); a restart or zone change recomputes it.
_logger.LogError(ex, "Job {Id} ({Name}): computing the next occurrence failed", entry.Job.Id, entry.Job.Name);
}
}
}
}
finally
{
Arm();
}
}
/// <summary>
/// Computes the wake time and applies it to the timer under the lock, so two concurrent arms can't apply an older
/// wake after a newer one. A synchronous callback from <c>Change</c> (zero delay) is safe: <see cref="Lock"/> is
/// re-entrant and <see cref="OnTimer"/> only queues a tick.
/// </summary>
private void Arm()
{
lock (_lock)
{
if (_stopped || _timer is null) return;
var now = _time.GetUtcNow();
var soonest = _jobs.Values.Select(e => e.Next).Where(n => n is not null).DefaultIfEmpty(null).Min();
var wake = soonest is { } s && s - now < MaxTimerDelay ? s : now + MaxTimerDelay;
if (wake < now) wake = now;
_wakeAt = wake;
try
{
_timer.Change(wake - now, Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException)
{
// Stopped meanwhile.
}
}
}
/// <summary>Under the lock. The next cron occurrence strictly after <paramref name="after"/> in the target's current zone.</summary>
private DateTimeOffset? NextCron(ScheduledJob job, DateTimeOffset after) =>
Schedules.NextCron(job.Schedule.Cron!, after, _delivery!.TimeZoneFor(job.Target));
// ---- Firing ----
/// <summary>Called under the lock: registers the fire in flight before it can complete.</summary>
private void StartFire(ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now)
{
var cancel = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token);
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var task = RunFireAsync(gate.Task, job, dueAt, now, cancel);
_inFlight[job.Id] = new Fire(task, cancel);
_firesStarted++;
gate.SetResult();
}
private async Task RunFireAsync(Task gate, ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now, CancellationTokenSource cancel)
{
await gate.ConfigureAwait(false);
try
{
await FireAsync(job, dueAt, now, cancel.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Job {Id} ({Name}) failed to fire", job.Id, job.Name);
}
finally
{
lock (_lock) _inFlight.Remove(job.Id);
cancel.Dispose();
}
}
private async Task FireAsync(ScheduledJob job, DateTimeOffset dueAt, DateTimeOffset now, CancellationToken scriptToken)
{
var delivery = _delivery!;
if (!delivery.IsDeliverable(job.Target))
{
// Kept: a cron job has already advanced; a one-off fires again after the next start.
_logger.LogWarning("Job {Id} ({Name}): target {Target} can't be served any more; skipped", job.Id, job.Name, job.Target);
return;
}
var zone = delivery.TimeZoneFor(job.Target);
DateTimeOffset? lateDue = now - dueAt > LateThreshold ? dueAt : null;
JobResult? result;
switch (job.Kind)
{
case JobKind.Reminder:
result = await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Reminder(job, lateDue, zone), ct)).ConfigureAwait(false)
? new JobResult(true, null, false, null, _time.GetUtcNow())
: null;
break;
case JobKind.Prompt:
result = await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.Prompt(job, lateDue, zone), ct)).ConfigureAwait(false)
? new JobResult(true, null, false, null, _time.GetUtcNow())
: null;
break;
default:
result = await RunWatcherAsync(job, delivery, zone, scriptToken).ConfigureAwait(false);
break;
}
if (result is not null) await RecordAsync(job.Id, result).ConfigureAwait(false);
}
/// <summary>Null when the run or its delivery was cancelled (job cancelled or scheduler stopping): the outcome is dropped.</summary>
private async Task<JobResult?> RunWatcherAsync(ScheduledJob job, IJobDelivery delivery, TimeZoneInfo zone, CancellationToken scriptToken)
{
var timeout = job.TimeoutSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : _options.DefaultScriptTimeout;
var request = new ScriptRequest(job.Command!, delivery.WorkspaceFor(job.Target), timeout, job.Target, job.Id, ZoneNames.Iana(zone));
ScriptOutcome outcome;
try
{
outcome = await _runner.RunAsync(request, scriptToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (scriptToken.IsCancellationRequested)
{
return null;
}
if (scriptToken.IsCancellationRequested || !Exists(job.Id)) return null;
var at = _time.GetUtcNow();
switch (outcome)
{
case ScriptOutcome.Output output:
// The condition is met: exactly one agent prompt. Redacted: watcher stdout isn't a tool result.
return await DeliverAsync(job, ct => delivery.RunPromptAsync(job.Target, JobMessages.WatcherMatch(job, _redactor.Redact(output.Text)), ct)).ConfigureAwait(false)
? new JobResult(true, 0, false, null, at)
: null;
case ScriptOutcome.Failed failed:
var result = new JobResult(false, failed.ExitCode, failed.TimedOut, _redactor.Redact(failed.StderrTail), at);
if (!IsRepeat(LastResult(job.Id), result)
&& !await DeliverAsync(job, ct => delivery.SendTextAsync(job.Target, JobMessages.Failure(job, result), ct)).ConfigureAwait(false))
return null; // the alert was cut off by shutdown: not recorded, so the next failure alerts again
return result;
default:
return new JobResult(true, 0, false, null, at); // silent: nothing but LastRunAt/LastResult
}
}
/// <summary>The previous run failed the same way (exit code and timeout): the alert isn't sent again.</summary>
private static bool IsRepeat(JobResult? previous, JobResult current) =>
previous is { Ok: false } p && p.ExitCode == current.ExitCode && p.TimedOut == current.TimedOut;
/// <summary>
/// A delivery exception is logged and swallowed; the job's state still advances (no redelivery queue). False only
/// when shutdown interrupted the delivery: the fire then isn't recorded, so a one-off stays stored and fires again
/// after the next start (at-least-once).
/// </summary>
private async Task<bool> DeliverAsync(ScheduledJob job, Func<CancellationToken, Task> deliver)
{
try
{
await deliver(_stopping.Token).ConfigureAwait(false); // a prompt run isn't aborted by a cancel, only by shutdown
return true;
}
catch (OperationCanceledException) when (_stopping.IsCancellationRequested)
{
_logger.LogInformation("Job {Id} ({Name}): delivery interrupted by shutdown; kept for the next start", job.Id, job.Name);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Job {Id} ({Name}): delivery to {Target} failed", job.Id, job.Name, job.Target);
return true;
}
}
/// <summary>Update and persist the run fields; a one-off is removed now that its fire has completed.</summary>
private async Task RecordAsync(string id, JobResult result)
{
lock (_lock)
{
if (!_jobs.TryGetValue(id, out var entry)) return; // cancelled meanwhile
if (entry.Job.Schedule.At is not null) _jobs.Remove(id);
else entry.Job = entry.Job with { LastRunAt = result.At, LastResult = result };
}
try
{
await PersistAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogError(ex, "Saving the job store failed");
}
Arm();
}
/// <summary>Saves the current jobs. The snapshot is taken inside the gate, so a later save never writes older state.</summary>
private async Task PersistAsync(CancellationToken cancellationToken)
{
await _saveGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
List<ScheduledJob> snapshot;
lock (_lock) snapshot = [.. _jobs.Values.Select(e => e.Job).OrderBy(j => j.CreatedAt).ThenBy(j => j.Id, StringComparer.Ordinal)];
await _store.SaveAsync(snapshot, cancellationToken).ConfigureAwait(false);
}
finally
{
_saveGate.Release();
}
}
private static async Task TryCancelAsync(CancellationTokenSource cancel)
{
try
{
await cancel.CancelAsync().ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
// The fire completed meanwhile.
}
}
private bool Exists(string id) { lock (_lock) return _jobs.ContainsKey(id); }
private JobResult? LastResult(string id) { lock (_lock) return _jobs.TryGetValue(id, out var e) ? e.Job.LastResult : null; }
/// <summary>Under the lock. Not reused while a cancelled job with that id is still in flight.</summary>
private string NewId()
{
string id;
do id = new string(Random.Shared.GetItems(IdAlphabet.AsSpan(), 5));
while (_jobs.ContainsKey(id) || _inFlight.ContainsKey(id));
return id;
}
private sealed class Entry(ScheduledJob job)
{
public ScheduledJob Job { get; set; } = job;
public DateTimeOffset? Next { get; set; }
}
private sealed record Fire(Task Task, CancellationTokenSource Cancel);
}

View File

@@ -0,0 +1,99 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// <c>jobs.json</c>: <c>{ "version": 1, "jobs": [...] }</c> in <see cref="VolcanicJson"/> camelCase. Written atomically
/// (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty, so a bad file never
/// keeps the host down; invalid jobs (including entries that don't deserialise) are dropped with a warning.
/// </summary>
public sealed class JobStore(string path, TimeProvider time, ILogger logger)
{
public const int Version = 1;
private readonly SemaphoreSlim _writeGate = new(1, 1);
public string Path => path;
public async Task<IReadOnlyList<ScheduledJob>> LoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(path)) return [];
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
StoredFile? file;
try
{
file = VolcanicJson.Deserialize<StoredFile>(json);
}
catch (JsonException)
{
file = null;
}
if (file is not { Version: Version, Jobs: not null })
{
var aside = $"{path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
File.Move(path, aside, overwrite: true);
logger.LogError("The job store {Path} could not be read; it was moved to {Aside} and the scheduler starts empty", path, aside);
return [];
}
var jobs = new List<ScheduledJob>();
var ids = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < file.Jobs.Count; i++)
{
var (job, problem) = Read(file.Jobs[i]);
problem ??= ids.Add(job!.Id) ? null : "duplicate id";
if (problem is not null)
{
logger.LogWarning("Dropped scheduled job #{Index} ({Id}) from {Path}: {Problem}", i, job?.Id, path, problem);
continue;
}
jobs.Add(job!);
}
return jobs;
}
public async Task SaveAsync(IReadOnlyList<ScheduledJob> jobs, CancellationToken cancellationToken)
{
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
var temp = $"{path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
try
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path))!);
var json = JsonSerializer.Serialize(new JobFile(Version, [.. jobs]), VolcanicJson.Indented);
await File.WriteAllTextAsync(temp, json, cancellationToken).ConfigureAwait(false);
File.Move(temp, path, overwrite: true);
}
catch
{
try { File.Delete(temp); } catch (IOException) { } catch (UnauthorizedAccessException) { }
throw;
}
finally
{
_writeGate.Release();
}
}
/// <summary>One entry of the file: the job, or why it's dropped. An entry that doesn't deserialise only loses itself.</summary>
private static (ScheduledJob? Job, string? Problem) Read(JsonElement entry)
{
if (entry.ValueKind != JsonValueKind.Object) return (null, "not a JSON object");
ScheduledJob? job;
try
{
job = VolcanicJson.Deserialize<ScheduledJob>(entry);
}
catch (JsonException ex)
{
return (null, ex.Message);
}
if (job is null) return (null, "empty entry");
return (job, JobValidation.CheckStored(job));
}
private sealed record JobFile(int Version, List<ScheduledJob> Jobs);
private sealed record StoredFile(int Version, List<JsonElement>? Jobs);
}

View File

@@ -0,0 +1,80 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Rules shared by the <c>schedule</c> tool (new jobs) and the store loader (stored jobs).</summary>
internal static class JobValidation
{
public const int MaxNameLength = 80;
public const int MaxTextLength = 4000;
public const int MaxTimeoutSeconds = 3600;
public static readonly TimeSpan PastGrace = TimeSpan.FromSeconds(60);
/// <summary>Validates a new job and returns its schedule (<c>at</c> resolved in <paramref name="zone"/>). Throws <see cref="JobValidationException"/>.</summary>
public static JobSchedule ValidateNew(NewJob job, DateTimeOffset now, TimeZoneInfo zone)
{
var name = job.Name?.Trim() ?? "";
if (name.Length == 0) throw new JobValidationException("name is required (a short label such as \"Call the dentist\").");
if (name.Length > MaxNameLength) throw new JobValidationException($"name is {Count(name.Length)} characters; the limit is {Count(MaxNameLength)}.");
var hasAt = !string.IsNullOrWhiteSpace(job.At);
var hasCron = !string.IsNullOrWhiteSpace(job.Cron);
if (hasAt == hasCron) throw new JobValidationException("Set exactly one of at (a one-off time) or cron (a recurring schedule).");
CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds);
if (hasCron)
{
Schedules.ParseCron(job.Cron!);
return new JobSchedule(null, job.Cron!.Trim());
}
var at = Schedules.ParseAt(job.At!, zone);
if (at < now - PastGrace)
{
var local = TimeZoneInfo.ConvertTime(at, zone);
var localNow = TimeZoneInfo.ConvertTime(now, zone);
throw new JobValidationException(string.Create(CultureInfo.InvariantCulture,
$"at {local:yyyy-MM-dd HH:mm} ({ZoneNames.Name(zone)}) is in the past; now is {localNow:yyyy-MM-dd HH:mm}. Pick a future time."));
}
return new JobSchedule(at, null);
}
/// <summary>Null when a stored job is well formed, else why it is dropped. An overdue one-off is fine (it fires late).</summary>
public static string? CheckStored(ScheduledJob job)
{
try
{
if (string.IsNullOrWhiteSpace(job.Id) || string.IsNullOrWhiteSpace(job.Target)) return "missing id or target";
if (string.IsNullOrWhiteSpace(job.Name) || job.Name.Length > MaxNameLength) return "missing or too long name";
if (job.Schedule is null || (job.Schedule.At is null) == (job.Schedule.Cron is null)) return "needs exactly one of at and cron";
if (job.Schedule.Cron is { } cron) Schedules.ParseCron(cron);
CheckFields(job.Kind, job.Text, job.Command, job.TimeoutSeconds);
return null;
}
catch (JobValidationException ex)
{
return ex.Message;
}
}
private static void CheckFields(JobKind kind, string? text, string? command, int? timeoutSeconds)
{
var hasText = !string.IsNullOrWhiteSpace(text);
var hasCommand = !string.IsNullOrWhiteSpace(command);
switch (kind)
{
case JobKind.Reminder when !hasText: throw new JobValidationException("A reminder needs text (the message to send).");
case JobKind.Prompt when !hasText: throw new JobValidationException("A prompt job needs text (what to ask the agent).");
case JobKind.Reminder or JobKind.Prompt when hasCommand: throw new JobValidationException("command is only for watchers.");
case JobKind.Watcher when !hasCommand: throw new JobValidationException("A watcher needs command (the shell command to run).");
case JobKind.Watcher when hasText: throw new JobValidationException("A watcher takes no text: its script's output becomes the prompt.");
case not (JobKind.Reminder or JobKind.Prompt or JobKind.Watcher): throw new JobValidationException("kind must be reminder, prompt or watcher.");
}
if (hasText && text!.Length > MaxTextLength) throw new JobValidationException($"text is {Count(text.Length)} characters; the limit is {Count(MaxTextLength)}.");
if (timeoutSeconds is { } seconds)
{
if (kind != JobKind.Watcher) throw new JobValidationException("timeoutSeconds is only for watchers.");
if (seconds is < 1 or > MaxTimeoutSeconds) throw new JobValidationException("timeoutSeconds must be between 1 and 3600.");
}
}
private static string Count(int n) => n.ToString(CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,42 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Reminder: fixed text to the chat, no model call. Prompt: fixed text through the agent. Watcher: a script whose output goes to the agent.</summary>
public enum JobKind { Reminder, Prompt, Watcher }
/// <summary>Exactly one is set. <see cref="At"/> is an absolute instant; <see cref="Cron"/> is 5-field wall-clock time in the target's zone.</summary>
public sealed record JobSchedule(DateTimeOffset? At, string? Cron);
/// <summary>The outcome of a job's last fire. <see cref="StderrTail"/> is stored redacted.</summary>
public sealed record JobResult(bool Ok, int? ExitCode, bool TimedOut, string? StderrTail, DateTimeOffset At);
/// <summary>A persisted job (<c>jobs.json</c>).</summary>
public sealed record ScheduledJob
{
public required string Id { get; init; }
/// <summary>Who the job belongs to: the chat id (invariant culture) on Telegram.</summary>
public required string Target { get; init; }
public required string Name { get; init; }
public required JobKind Kind { get; init; }
public required JobSchedule Schedule { get; init; }
/// <summary>Reminder and prompt only.</summary>
public string? Text { get; init; }
/// <summary>Watcher only.</summary>
public string? Command { get; init; }
/// <summary>Watcher only; default <see cref="SchedulerOptions.DefaultScriptTimeout"/>, at most 3600.</summary>
public int? TimeoutSeconds { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastRunAt { get; init; }
public JobResult? LastResult { get; init; }
}
/// <summary>What the <c>schedule</c> tool asks for. <see cref="At"/> is ISO 8601 text; without an offset it's read in the target's zone.</summary>
public sealed record NewJob(string Name, JobKind Kind, string? At = null, string? Cron = null, string? Text = null, string? Command = null, int? TimeoutSeconds = null);
/// <summary>A job and its next fire time (null: never, e.g. a one-off already firing).</summary>
public sealed record JobView(ScheduledJob Job, DateTimeOffset? NextRunAt);
/// <summary>What a zone change did to one target's jobs.</summary>
public sealed record ZoneChange(int CronJobs, int OneOffs);
/// <summary>A job the model (or a stored file) got wrong. The message is a sentence the model can act on.</summary>
public sealed class JobValidationException(string message) : Exception(message);

View File

@@ -0,0 +1,99 @@
using System.ComponentModel;
using System.Text;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
public sealed record ScheduleArgs(
[property: Description("create, list or cancel")] string Action,
[property: Description("cancel: the job id (see list)")] string? Id = null,
[property: Description("create: a short label, e.g. \"Call the dentist\"")] string? Name = null,
[property: Description("create: reminder (fixed text sent as is), prompt (text you handle when it fires) or watcher (a script; you get its output when it prints something)")] string? Kind = null,
[property: Description("create, one-off: ISO 8601 date-time; without an offset it's the chat's local time")] string? At = null,
[property: Description("create, recurring: 5-field cron in the chat's zone (minute hour day-of-month month day-of-week), e.g. \"0 8 * * 1-5\"")] string? Cron = null,
[property: Description("create, reminder/prompt: the text (at most 4000 characters)")] string? Text = null,
[property: Description("create, watcher: shell command run from the workspace")] string? Command = null,
[property: Description("create, watcher: timeout in seconds, 1-3600 (default 300)")] int? TimeoutSeconds = null);
/// <summary>The model's view of the scheduler, bound to one target (chat). It never sees another target's jobs.</summary>
public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<ScheduleArgs>
{
public const string ToolName = "schedule";
public override string Name => ToolName;
public override string Description =>
"Create, list or cancel this chat's scheduled jobs: one-off or recurring reminders (fixed text), prompts (you handle them when they fire) and watcher scripts (run without you; their output comes to you only when they print something).";
public override string? PromptSnippet => "schedule: reminders, recurring prompts and watcher scripts that message this chat";
public override IReadOnlyList<string> PromptGuidelines =>
[
"schedule: times are in the chat's time zone (see the environment section); at is ISO 8601, cron has 5 fields (minute hour day-of-month month day-of-week).",
"schedule: use kind reminder for fixed text and prompt when the job needs you to think or use tools.",
"schedule: a watcher's command runs with the same shell as bash, from the workspace, without you. It should print nothing and exit 0 while the condition isn't met (that costs nothing), and print the facts only when it is met: the output comes back to you as a prompt and you write the message. Exit non-zero only on a real error.",
"schedule: put reusable scripts in scripts/ and test them with bash before scheduling.",
];
protected override async Task<ToolResult> ExecuteAsync(ScheduleArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
try
{
return (args.Action ?? "").Trim().ToLowerInvariant() switch
{
"create" => await CreateAsync(args, cancellationToken).ConfigureAwait(false),
"list" => ToolResult.Text(List()),
"cancel" => await CancelAsync(args.Id, cancellationToken).ConfigureAwait(false),
_ => throw Error("action must be create, list or cancel."),
};
}
catch (JobValidationException ex)
{
throw Error(ex.Message);
}
catch (InvalidOperationException ex)
{
throw Error(ex.Message); // e.g. the scheduler isn't running
}
}
private async Task<ToolResult> CreateAsync(ScheduleArgs args, CancellationToken cancellationToken)
{
var kind = (args.Kind ?? "").Trim().ToLowerInvariant() switch
{
"reminder" => JobKind.Reminder,
"prompt" => JobKind.Prompt,
"watcher" => JobKind.Watcher,
_ => throw new JobValidationException("kind must be reminder, prompt or watcher."),
};
var view = await scheduler.CreateAsync(target, new NewJob(args.Name ?? "", kind, args.At, args.Cron, args.Text, args.Command, args.TimeoutSeconds), cancellationToken).ConfigureAwait(false);
var zone = scheduler.TimeZoneFor(target);
var now = scheduler.Now;
var sb = new StringBuilder("Created: ").Append(JobFormat.Line(view, zone, now));
if (view.NextRunAt is { } next) sb.Append("\nNext run: ").Append(JobFormat.Stamp(next, zone)).Append(" (").Append(JobFormat.ZoneName(zone)).Append(')');
sb.Append('\n').Append(NowLine(zone, now));
return ToolResult.Text(sb.ToString());
}
private string List()
{
var zone = scheduler.TimeZoneFor(target);
var now = scheduler.Now;
var jobs = scheduler.List(target);
var body = jobs.Count == 0 ? "No scheduled jobs." : string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)));
return body + "\n" + NowLine(zone, now);
}
private async Task<ToolResult> CancelAsync(string? id, CancellationToken cancellationToken)
{
var key = id?.Trim().ToLowerInvariant() ?? "";
if (key.Length == 0) throw Error("cancel needs id (see action=list).");
var job = await scheduler.CancelJobAsync(target, key, cancellationToken).ConfigureAwait(false)
?? throw Error($"No job {key} in this chat.");
return ToolResult.Text($"Cancelled {job.Id} ({job.Name}).");
}
private static string NowLine(TimeZoneInfo zone, DateTimeOffset now) => $"Now: {JobFormat.Stamp(now, zone)} ({JobFormat.ZoneName(zone)})";
private static ToolExecutionException Error(string message) => new(ToolResult.Text(message));
}

View File

@@ -0,0 +1,22 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// Per session: registers the <c>schedule</c> tool bound to the host's <paramref name="target"/> (a Telegram chat id).
/// The host adds it through <c>AgentSessionOptions.ExtraExtensions</c>. Loaded through discovery without a
/// host-registered <see cref="JobScheduler"/>, its constructor can't be resolved: an ordinary extension load error.
/// </summary>
public sealed class SchedulerExtension(JobScheduler scheduler, string target) : IExtension
{
public const string ExtensionName = "scheduler";
public string Name => ExtensionName;
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
api.RegisterTool(new ScheduleTool(scheduler, target));
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,34 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>The <c>Scheduler</c> configuration section. There is no zone here: zones are per target (the host's <c>IJobDelivery.TimeZoneFor</c>).</summary>
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
public bool Enabled { get; set; } = true;
/// <summary><c>jobs.json</c>. Empty: the host fills <see cref="DefaultStorePath"/> under its agent directory.</summary>
public string? StorePath { get; set; }
/// <summary>Watcher timeout when the job sets none.</summary>
public TimeSpan DefaultScriptTimeout { get; set; } = TimeSpan.FromSeconds(300);
public int MaxJobsPerTarget { get; set; } = 50;
/// <summary>Executable and leading arguments; the command is appended. Empty: <c>/bin/sh -c</c>, or <c>cmd.exe /d /s /c</c> on Windows (the <c>bash</c> tool's shell).</summary>
public List<string> Shell { get; set; } = [];
/// <summary><c>&lt;agentDir&gt;/scheduler/jobs.json</c>.</summary>
public static string DefaultStorePath(string agentDir) => Path.Combine(agentDir, "scheduler", "jobs.json");
/// <summary>Configuration problems that must stop the host, empty when valid.</summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (DefaultScriptTimeout < TimeSpan.FromSeconds(1) || DefaultScriptTimeout > TimeSpan.FromSeconds(JobValidation.MaxTimeoutSeconds))
errors.Add("Scheduler:DefaultScriptTimeout must be between 1 second and 1 hour.");
if (MaxJobsPerTarget < 1) errors.Add("Scheduler:MaxJobsPerTarget must be at least 1.");
if (Shell.Any(string.IsNullOrWhiteSpace)) errors.Add("Scheduler:Shell entries must not be empty.");
return errors;
}
}

View File

@@ -0,0 +1,93 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Cronos;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>Cron (Cronos, 5 fields, in a zone) and one-off times.</summary>
public static partial class Schedules
{
private const string FieldsHint = "Use 5 fields: minute hour day-of-month month day-of-week, e.g. \"0 8 * * 1-5\".";
/// <summary>Parses a standard 5-field expression or a macro (<c>@daily</c>). Seconds and <c>H</c> are rejected.</summary>
public static CronExpression ParseCron(string expression)
{
var text = expression?.Trim() ?? "";
if (text.Length == 0) throw new JobValidationException("cron is empty. " + FieldsHint);
if (!text.StartsWith('@'))
{
var fields = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Any(f => f.Split(',').Any(t => t == "H" || t.StartsWith("H(", StringComparison.Ordinal) || t.StartsWith("H/", StringComparison.Ordinal))))
throw new JobValidationException($"cron \"{text}\": the H (hash) syntax isn't supported; write the minute explicitly. {FieldsHint}");
if (fields.Length == 6)
throw new JobValidationException($"cron \"{text}\" has 6 fields; seconds aren't supported. {FieldsHint}");
if (fields.Length != 5)
throw new JobValidationException($"cron \"{text}\" has {fields.Length.ToString(CultureInfo.InvariantCulture)} fields. {FieldsHint}");
}
try
{
return CronExpression.Parse(text, CronFormat.Standard);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
throw new JobValidationException($"cron \"{text}\" is invalid: {ex.Message.TrimEnd('.')}. {FieldsHint}");
}
}
/// <summary>The next occurrence strictly after <paramref name="after"/>, as wall-clock time in <paramref name="zone"/> (Cronos DST rules).</summary>
public static DateTimeOffset? NextCron(string cron, DateTimeOffset after, TimeZoneInfo zone) =>
ParseCron(cron).GetNextOccurrence(after, zone, inclusive: false);
private static readonly string[] WallFormats = ["yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF"];
/// <summary>
/// ISO 8601 only, always with a date: <c>yyyy-MM-dd</c> (local midnight) or <c>yyyy-MM-dd[T| ]HH:mm[:ss[.fff]]</c>, with
/// an optional <c>Z</c> or offset. With an offset it's used as given; without one it's wall-clock time in
/// <paramref name="zone"/>. A time without a date (<c>09:00</c>) and culture formats (<c>09/10/2026</c>) are rejected:
/// they'd be resolved against the machine's clock, not the chat's.
/// </summary>
public static DateTimeOffset ParseAt(string text, TimeZoneInfo zone)
{
var trimmed = text?.Trim() ?? "";
var hint = $"at \"{trimmed}\" isn't a date-time. Use ISO 8601 with the date, e.g. 2026-09-27T09:00 (read in the chat's time zone) or 2026-09-27T09:00+02:00.";
var match = IsoAt().Match(trimmed);
if (!match.Success) throw new JobValidationException(hint);
var local = match.Groups["date"].Value + (match.Groups["time"].Success ? "T" + match.Groups["time"].Value : "T00:00");
if (!DateTime.TryParseExact(local, WallFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall))
throw new JobValidationException(hint);
wall = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
if (match.Groups["offset"] is { Success: true } group)
{
try
{
return new DateTimeOffset(wall, ParseOffset(group.Value));
}
catch (ArgumentException)
{
throw new JobValidationException(hint); // an offset beyond ±14:00
}
}
// A time skipped by a spring-forward change doesn't exist: move it past the gap (Europe/US gaps are one hour).
if (zone.IsInvalidTime(wall)) wall = wall.AddHours(1);
// An ambiguous fall-back time is read as the first of the two (the larger offset).
var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall);
return new DateTimeOffset(wall, offset);
}
/// <summary><c>Z</c>, <c>+02</c>, <c>+0200</c> or <c>+02:00</c>.</summary>
private static TimeSpan ParseOffset(string text)
{
if (text.Equals("Z", StringComparison.OrdinalIgnoreCase)) return TimeSpan.Zero;
var digits = text[1..].Replace(":", "", StringComparison.Ordinal);
var hours = int.Parse(digits[..2], NumberStyles.None, CultureInfo.InvariantCulture);
var minutes = digits.Length > 2 ? int.Parse(digits[2..], NumberStyles.None, CultureInfo.InvariantCulture) : 0;
if (minutes > 59) throw new ArgumentException("minutes", nameof(text));
var offset = new TimeSpan(hours, minutes, 0);
return text[0] == '-' ? -offset : offset;
}
/// <summary>A date, optionally a time (after <c>T</c> or a space) and then optionally <c>Z</c> or an offset.</summary>
[GeneratedRegex(@"^(?<date>\d{4}-\d{2}-\d{2})(?:[T ](?<time>\d{2}:\d{2}(?::\d{2}(?:\.\d{1,7})?)?)\s*(?<offset>Z|[+-]\d{2}(?::?\d{2})?)?)?$", RegexOptions.IgnoreCase)]
private static partial Regex IsoAt();
}

View File

@@ -0,0 +1,243 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>One watcher run. <see cref="TimeZoneId"/> becomes the script's <c>TZ</c> (the target's zone).</summary>
public sealed record ScriptRequest(string Command, string Cwd, TimeSpan Timeout, string Target, string JobId, string? TimeZoneId);
/// <summary>What a watcher run means for the job.</summary>
public abstract record ScriptOutcome
{
private ScriptOutcome() { }
/// <summary>Exit 0, empty stdout after trimming: the condition isn't met. Costs nothing.</summary>
public sealed record Silent : ScriptOutcome;
/// <summary>Exit 0 with output: the condition is met; <see cref="Text"/> (trimmed) goes to the agent.</summary>
public sealed record Output(string Text) : ScriptOutcome;
/// <summary>Non-zero exit, timeout, a hook that threw, or a shell that didn't start.</summary>
public sealed record Failed(int? ExitCode, string StderrTail, bool TimedOut) : ScriptOutcome;
}
/// <summary>Runs watcher scripts. Faked in scheduler tests.</summary>
public interface IScriptRunner
{
/// <summary>Never throws for a script problem (that's <see cref="ScriptOutcome.Failed"/>); throws <see cref="OperationCanceledException"/> after killing the script when <paramref name="cancellationToken"/> is cancelled.</summary>
Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken);
}
/// <summary>
/// Runs a watcher with the <c>bash</c> tool's shell in the target's workspace, stdin closed, with the command, cwd and
/// environment from the host's <see cref="IProcessSpawnPolicy"/> (<see cref="ProcessPurpose.ScheduledScript"/>): the same
/// filtered environment as <c>bash</c>, <c>TZ</c> = the target's zone, host hooks applied. No session-level
/// <see cref="ProcessSpawnEvent"/> runs: a job fires without a live session.
/// </summary>
public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy policy, ILogger<ScriptRunner> logger, TimeProvider? time = null) : IScriptRunner
{
public const int StderrTailLines = 20;
private static readonly TimeSpan PipeGrace = TimeSpan.FromSeconds(5);
private readonly TimeProvider _time = time ?? TimeProvider.System;
public async Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
ProcessSpawnContext spawn;
try
{
spawn = policy.Prepare(new ProcessSpawnRequest
{
Purpose = ProcessPurpose.ScheduledScript,
Command = request.Command,
Cwd = request.Cwd,
Scope = request.Target,
Origin = request.JobId,
TimeZoneId = request.TimeZoneId,
});
}
catch (Exception ex)
{
return new ScriptOutcome.Failed(null, $"process environment hook failed: {ex.Message}", false);
}
var psi = StartInfo(spawn.Command);
spawn.ApplyTo(psi); // cwd and exactly the prepared environment
using var process = new Process { StartInfo = psi };
try
{
Directory.CreateDirectory(spawn.Cwd);
process.Start();
}
catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException) // e.g. a workspace path that can't be created
{
return new ScriptOutcome.Failed(null, $"failed to start {psi.FileName}: {ex.Message}", false);
}
process.StandardInput.Close();
var stdout = new OutputCapture();
var stderr = new OutputCapture();
var pumps = Task.WhenAll(PumpAsync(process.StandardOutput, stdout), PumpAsync(process.StandardError, stderr));
using var timeout = new CancellationTokenSource(request.Timeout, _time);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
var timedOut = false;
try
{
await process.WaitForExitAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Kill(process, request);
timedOut = !cancellationToken.IsCancellationRequested;
}
await DrainAsync(pumps, request).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (timedOut) return new ScriptOutcome.Failed(null, Tail(stderr.ToString()), true);
if (process.ExitCode != 0) return new ScriptOutcome.Failed(process.ExitCode, Tail(stderr.ToString()), false);
var text = stdout.ToString().Trim();
return text.Length == 0 ? new ScriptOutcome.Silent() : new ScriptOutcome.Output(text);
}
/// <summary>
/// The <c>bash</c> tool's shell: <c>/bin/sh -c &lt;command&gt;</c> through <see cref="ProcessStartInfo.ArgumentList"/>. On
/// Windows <c>cmd.exe</c> doesn't parse the MSVC quoting <c>ArgumentList</c> produces, so the command goes through
/// <see cref="ProcessStartInfo.Arguments"/> as <c>/d /s /c "&lt;command&gt;"</c>, exactly as <c>bash</c> does.
/// A configured <see cref="SchedulerOptions.Shell"/> always uses <c>ArgumentList</c>.
/// </summary>
internal ProcessStartInfo StartInfo(string command)
{
var psi = new ProcessStartInfo
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
if (options.Shell.Count > 0)
{
psi.FileName = options.Shell[0];
foreach (var argument in options.Shell.Skip(1)) psi.ArgumentList.Add(argument);
psi.ArgumentList.Add(command);
}
else if (OperatingSystem.IsWindows())
{
psi.FileName = "cmd.exe";
psi.Arguments = $"/d /s /c \"{command}\"";
}
else
{
psi.FileName = "/bin/sh";
psi.ArgumentList.Add("-c");
psi.ArgumentList.Add(command);
}
return psi;
}
private void Kill(Process process, ScriptRequest request)
{
try
{
process.Kill(entireProcessTree: true);
process.WaitForExit(PipeGrace);
}
catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException)
{
logger.LogDebug(ex, "Job {JobId}: killing the script failed (it may have exited)", request.JobId);
}
}
/// <summary>Waits for both output pipes to close, at most <see cref="PipeGrace"/> (a surviving grandchild may hold them).</summary>
private async Task DrainAsync(Task pumps, ScriptRequest request)
{
try
{
await pumps.WaitAsync(PipeGrace, CancellationToken.None).ConfigureAwait(false);
}
catch (TimeoutException)
{
logger.LogWarning("Job {JobId}: the script's output pipes stayed open after it ended", request.JobId);
}
}
private static async Task PumpAsync(StreamReader reader, OutputCapture capture)
{
var buffer = new char[4096];
try
{
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory(), CancellationToken.None).ConfigureAwait(false)) > 0)
capture.Append(buffer.AsSpan(0, read));
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// The pipe broke or the process was disposed: keep what was read.
}
}
/// <summary>The last <see cref="StderrTailLines"/> lines.</summary>
internal static string Tail(string text)
{
var lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n').Split('\n');
return string.Join('\n', lines.TakeLast(StderrTailLines));
}
}
/// <summary>Keeps the first <see cref="HeadChars"/> and the last <see cref="TailChars"/> characters of a stream.</summary>
internal sealed class OutputCapture
{
public const int HeadChars = 48 * 1024;
public const int TailChars = 16 * 1024;
private readonly StringBuilder _head = new();
private readonly char[] _tail = new char[TailChars];
private readonly Lock _lock = new();
private int _tailStart;
private int _tailLength;
private long _dropped;
public void Append(string text) => Append(text.AsSpan());
public void Append(ReadOnlySpan<char> text)
{
lock (_lock)
{
var toHead = Math.Min(text.Length, HeadChars - _head.Length);
if (toHead > 0) _head.Append(text[..toHead]);
foreach (var c in text[Math.Max(toHead, 0)..])
{
if (_tailLength == TailChars)
{
_tail[_tailStart] = c;
_tailStart = (_tailStart + 1) % TailChars;
_dropped++;
}
else
{
_tail[(_tailStart + _tailLength) % TailChars] = c;
_tailLength++;
}
}
}
}
public override string ToString()
{
lock (_lock)
{
var sb = new StringBuilder(_head.Length + _tailLength + 64).Append(_head);
if (_dropped > 0) sb.Append(CultureInfo.InvariantCulture, $"\n[… {_dropped} characters omitted …]\n");
for (var i = 0; i < _tailLength; i++) sb.Append(_tail[(_tailStart + i) % TailChars]);
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Scheduler extension: one-off and cron reminders, scheduled agent prompts and watcher scripts.</Description>
</PropertyGroup>
<ItemGroup>
<!-- Private="false": the host already ships Abstractions; an extension must not bring its own copy (docs/extensions.md). -->
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" Private="false" />
<PackageReference Include="Cronos" Version="0.13.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
<InternalsVisibleTo Include="Volcanic.AgenticCore.Telegram.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// Zone ids for display and <c>TZ</c>. Mirrors the core's <c>Volcanic.AgenticCore.Hosting.TimeZoneIds</c> (the extension
/// references only the Abstractions); keep the two in step.
/// </summary>
internal static class ZoneNames
{
/// <summary>
/// The IANA id (converting a Windows id), or null when there is none that resolves back to a system zone (an unnamed
/// <c>Local</c> zone: <c>TZ=Local</c> would run the script in UTC). Same rule as <c>TimeZoneIds.ToIana</c>.
/// </summary>
public static string? Iana(TimeZoneInfo zone)
{
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
}
/// <summary>The IANA id when there is one, else the zone's own id (<c>TimeZoneIds.Name</c>).</summary>
public static string Name(TimeZoneInfo zone) => Iana(zone) ?? zone.Id;
}

View File

@@ -1,17 +1,37 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram;
/// <summary>
/// Best-effort start-up calls: log who the bot is and publish the command menu. Retried in the background until the
/// first success, so a host started without network still comes up.
/// Best-effort start-up calls: log who the bot is and publish the command menu, retried in the background until the
/// first success so a host started without network still comes up. Then, once per process, tell the owner
/// (<c>AllowedUserIds[0]</c>) the bot is back. That message is never retried: a 403 (the owner never started the bot)
/// is logged by <see cref="TelegramSender"/> and that's all.
/// </summary>
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, ILogger<BotSetupService> logger) : BackgroundService
public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions options, TelegramSender sender, ILogger<BotSetupService> logger,
GoogleCalendarOptions? calendar = null) : BackgroundService
{
public const string OnlineText = "🟢 Online.";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!await SetUpAsync(stoppingToken).ConfigureAwait(false) || options.AllowedUserIds.Count == 0) return;
try
{
await sender.SendTextAsync(options.AllowedUserIds[0], OnlineText, cancellationToken: stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
}
/// <summary>True once <c>getMe</c> and <c>setMyCommands</c> succeeded; false when stopped first.</summary>
private async Task<bool> SetUpAsync(CancellationToken stoppingToken)
{
var delay = options.ReconnectInitialDelay;
while (!stoppingToken.IsCancellationRequested)
@@ -19,13 +39,13 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
try
{
var me = await bot.GetMe(stoppingToken).ConfigureAwait(false);
await bot.SetMyCommands(ChatCommands.Menu, cancellationToken: stoppingToken).ConfigureAwait(false);
await bot.SetMyCommands(GoogleCalendarSetup.MenuFor(ChatCommands.Menu, calendar), cancellationToken: stoppingToken).ConfigureAwait(false);
logger.LogInformation("Bot @{Username} ({Id}) is ready", me.Username, me.Id);
return;
return true;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
return false;
}
catch (Exception ex)
{
@@ -37,9 +57,10 @@ public sealed class BotSetupService(ITelegramBotClient bot, TelegramOptions opti
}
catch (OperationCanceledException)
{
return;
return false;
}
delay = TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, options.ReconnectMaxDelay.Ticks));
}
return false;
}
}

View File

@@ -2,12 +2,15 @@ using System.Globalization;
using System.Text;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>The bot's own slash commands. Anything else starting with <c>/</c> goes to the session (extension commands).</summary>
public sealed class ChatCommands(TelegramSender sender)
public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zones = null, JobScheduler? scheduler = null)
{
public static readonly IReadOnlyList<BotCommand> Menu =
[
@@ -18,6 +21,8 @@ public sealed class ChatCommands(TelegramSender sender)
new() { Command = "resume", Description = "Resume a conversation: /resume <id>" },
new() { Command = "compact", Description = "Summarise the conversation to free context" },
new() { Command = "thinking", Description = "Show or set the thinking level" },
new() { Command = "timezone", Description = "Show or set this chat's time zone" },
.. SchedulerSetup.Menu,
new() { Command = "help", Description = "What this bot does" },
];
@@ -46,6 +51,7 @@ public sealed class ChatCommands(TelegramSender sender)
return true;
case "stop":
chat.Inbox.Discard(); // what is still waiting in the window would start the run the user just stopped
if (!session.IsStreaming && !session.IsCompacting)
{
await ReplyAsync(chat, "Nothing is running.", cancellationToken).ConfigureAwait(false);
@@ -55,6 +61,7 @@ public sealed class ChatCommands(TelegramSender sender)
return true; // the renderer reports "Stopped."
case "new":
chat.Inbox.Discard(); // drop what the user typed before /new, before a buffered message can flush
if (session.IsStreaming) await session.AbortAsync().ConfigureAwait(false);
var created = await session.NewSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
await ReplyAsync(chat, created.Cancelled ? "An extension cancelled the new session." : "🆕 New conversation.", cancellationToken).ConfigureAwait(false);
@@ -95,6 +102,18 @@ public sealed class ChatCommands(TelegramSender sender)
await ReplyAsync(chat, await ThinkingAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "timezone":
await ReplyAsync(chat, await TimeZoneAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "jobs":
await ReplyAsync(chat, Jobs(chat), cancellationToken).ConfigureAwait(false);
return true;
case "cancel":
await ReplyAsync(chat, await CancelJobAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
default:
return false;
}
@@ -109,15 +128,18 @@ public sealed class ChatCommands(TelegramSender sender)
While I am working, a new message steers the current run.
Reply to one of my messages, or quote part of it, to point at what you mean.
/new – start a new conversation
/stop – stop the current run
/status – model, session and context usage
/sessions, /resume <id> – earlier conversations
/compact – summarise the conversation to free context
/thinking [level] – show or set the thinking level
""";
/timezone [IANA name|reset] – this chat's time zone
""" + SchedulerSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands);
private static string Status(ChatContext chat)
private string Status(ChatContext chat)
{
var session = chat.Session;
var sb = new StringBuilder();
@@ -127,6 +149,10 @@ public sealed class ChatCommands(TelegramSender sender)
sb.AppendLine(CultureInfo.InvariantCulture, $"**Messages:** {session.Messages.Count}");
if (session.GetContextUsage() is { } usage)
sb.AppendLine(CultureInfo.InvariantCulture, $"**Context:** {(usage.Tokens is { } t ? t.ToString("N0", CultureInfo.InvariantCulture) : "?")} / {usage.ContextWindow.ToString("N0", CultureInfo.InvariantCulture)} tokens{(usage.Percent is { } p ? $" ({p:0}%)" : "")}");
if (scheduler is { IsStarted: true })
sb.AppendLine(CultureInfo.InvariantCulture, $"**Jobs:** {scheduler.List(TelegramJobDelivery.TargetOf(chat.ChatId)).Count}");
var zone = zones?.Describe(chat.ChatId) ?? TimeZoneIds.Name(session.TimeZone); // no service (tests): no clock read
sb.AppendLine(CultureInfo.InvariantCulture, $"**Time zone:** {zone}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`");
sb.Append(session.IsStreaming ? "**State:** running" : session.IsCompacting ? "**State:** compacting" : "**State:** idle");
return sb.ToString();
@@ -153,11 +179,45 @@ public sealed class ChatCommands(TelegramSender sender)
var sessions = await chat.Session.ListSessionsAsync(cancellationToken).ConfigureAwait(false);
var matches = sessions.Where(s => s.SessionId.StartsWith(id, StringComparison.OrdinalIgnoreCase)).ToList();
if (matches.Count != 1) return matches.Count == 0 ? $"No conversation `{id}`." : $"`{id}` matches several conversations; use more characters.";
chat.Inbox.Discard(); // what was typed before /resume belongs to neither conversation; drop it before it can flush
if (chat.Session.IsStreaming) await chat.Session.AbortAsync().ConfigureAwait(false);
var result = await chat.Session.SwitchSessionAsync(matches[0].SessionId, cancellationToken).ConfigureAwait(false);
return result.Cancelled ? "An extension cancelled the switch." : $"Resumed `{matches[0].SessionId}` ({matches[0].MessageCount} messages).";
}
private async Task<string> TimeZoneAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
if (zones is null) return "Time zones aren't configured on this bot.";
if (arg.Length == 0) return zones.Show(chat.ChatId);
if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) return await zones.SetAsync(chat.ChatId, null, cancellationToken).ConfigureAwait(false);
return ChatTimeZones.TryParse(arg, out var zone, out var error)
? await zones.SetAsync(chat.ChatId, zone, cancellationToken).ConfigureAwait(false)
: error;
}
private string Jobs(ChatContext chat)
{
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
var jobs = scheduler.List(target);
if (jobs.Count == 0) return "No scheduled jobs.";
var zone = scheduler.TimeZoneFor(target);
var now = scheduler.Now;
return $"**Scheduled jobs** ({JobFormat.ZoneName(zone)}, now {JobFormat.DayTime(now, zone)})\n" +
string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)));
}
private async Task<string> CancelJobAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
var id = arg.Trim().ToLowerInvariant();
if (id.Length == 0) return "Usage: /cancel <id> (see /jobs).";
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
return await scheduler.CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is { } job
? $"Cancelled {job.Id} ({job.Name})."
: $"No job {id} in this chat.";
}
private static async Task<string> ThinkingAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
var session = chat.Session;

View File

@@ -4,21 +4,31 @@ using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>Everything that belongs to one Telegram chat: its agent session, renderer and dialog surface.</summary>
public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui, ChatRenderer renderer, IDisposable subscription, ILogger logger)
: IAsyncDisposable
public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui, ChatRenderer renderer, IDisposable subscription, ChatInbox inbox, ILogger logger,
IChatChannel channel) : IAsyncDisposable
{
public long ChatId => chatId;
public AgentSession Session => session;
public TelegramUi Ui => ui;
public ChatRenderer Renderer => renderer;
/// <summary>Delivers files and texts into this chat in order with rendered output (the scheduler uses it too).</summary>
public IChatChannel Channel => channel;
/// <summary>Groups this chat's incoming messages into prompts (see <see cref="ChatInbox"/>).</summary>
public ChatInbox Inbox => inbox;
/// <summary>The most recent background prompt; completes when that run has settled.</summary>
public Task LastPrompt { get; private set; } = Task.CompletedTask;
@@ -44,6 +54,7 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
public async ValueTask DisposeAsync()
{
await inbox.DisposeAsync().ConfigureAwait(false);
subscription.Dispose();
await session.DisposeAsync().ConfigureAwait(false);
await renderer.DisposeAsync().ConfigureAwait(false);
@@ -54,11 +65,20 @@ public sealed class ChatContext(long chatId, AgentSession session, TelegramUi ui
/// One <see cref="ChatContext"/> per chat, created on first use. Each chat works in <c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>,
/// stores its sessions under scope <c>telegram-&lt;chatId&gt;</c> and resumes the most recent one after a restart.
/// </summary>
public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender sender, TelegramOptions options, ILoggerFactory loggerFactory)
: IAsyncDisposable
public sealed class ChatRegistry(
IAgentSessionFactory factory,
TelegramSender sender,
TelegramOptions options,
ILoggerFactory loggerFactory,
TimeProvider? time = null,
GoogleCalendarOptions? calendar = null,
ChatSettingsStore? settings = null,
ChatTimeZoneService? zones = null,
JobScheduler? scheduler = null) : IAsyncDisposable
{
private readonly ConcurrentDictionary<long, Lazy<Task<ChatContext>>> _chats = new();
private readonly ILogger _logger = loggerFactory.CreateLogger<ChatRegistry>();
private readonly TimeProvider _time = time ?? TimeProvider.System;
public static string ScopeFor(long chatId) => $"telegram-{chatId}";
@@ -92,6 +112,12 @@ public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender se
var logger = loggerFactory.CreateLogger<ChatContext>();
var renderer = new ChatRenderer(chatId, sender, options, loggerFactory.CreateLogger<ChatRenderer>());
var ui = new TelegramUi(chatId, sender, renderer);
var channel = new TelegramChatChannel(chatId, sender, renderer);
var extras = new List<ExtensionRegistration>();
if (calendar is not null) extras.Add(GoogleCalendarSetup.Registration(calendar));
if (zones is not null) extras.Add(ExtensionRegistration.FromInstance(new ChatSettingsExtension(chatId, zones)));
if (scheduler is not null)
extras.Add(ExtensionRegistration.FromInstance(new SchedulerExtension(scheduler, Scheduling.TelegramJobDelivery.TargetOf(chatId))));
AgentSession session;
try
{
@@ -102,6 +128,9 @@ public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender se
SessionId = last?.SessionId,
Model = options.Model,
Ui = ui,
Channel = channel,
TimeZone = settings?.TimeZoneFor(chatId),
ExtraExtensions = extras,
}).ConfigureAwait(false);
}
catch
@@ -113,7 +142,30 @@ public sealed class ChatRegistry(IAgentSessionFactory factory, TelegramSender se
var subscription = session.Subscribe(renderer.Post);
_logger.LogInformation("Chat {ChatId}: {Action} session {SessionId} in {Cwd}", chatId, last is null ? "new" : "resumed", session.SessionId, cwd);
foreach (var error in session.ExtensionErrors) _logger.LogWarning("Chat {ChatId}: extension error {Source}: {Error}", chatId, error.Source, error.Error);
return new ChatContext(chatId, session, ui, renderer, subscription, logger);
ChatContext? context = null;
var inbox = new ChatInbox(parts => FlushAsync(context!, parts), options.InputDebounce, _time,
logger: loggerFactory.CreateLogger<ChatInbox>());
context = new ChatContext(chatId, session, ui, renderer, subscription, inbox, logger, channel);
return context; // no lease can be taken before this returns, so the flush always sees the context
}
/// <summary>
/// The zone for a chat's timestamp headers: the chat's setting (<see cref="ChatSettingsStore.TimeZoneFor"/>), which also
/// feeds the session's zone. Without a store (bare test setups) the session's zone, else the machine's. Read at flush
/// time, so a <c>/timezone</c> change applies to the next batch. Never creates a session.
/// </summary>
public TimeZoneInfo TimeZoneFor(long chatId) => settings?.TimeZoneFor(chatId) ?? Find(chatId)?.Session.TimeZone ?? TimeZoneInfo.Local;
/// <summary>A flushed batch becomes one prompt; while a run is active it steers it and each message gets a đź‘€ reaction.</summary>
internal Task FlushAsync(ChatContext chat, IReadOnlyList<InboundPart> parts)
{
var (text, images) = InboundBatch.Render(parts, TimeZoneFor(chat.ChatId));
// Tell the user their message joined the running request (best-effort, fire-and-forget). If the run ends between
// this check and the steer, the message starts a new run and still shows đź‘€: harmless.
if (chat.Session.IsStreaming)
foreach (var part in parts) _ = sender.ReactAsync(chat.ChatId, part.MessageId, "đź‘€");
_ = chat.PromptInBackground(text, images.Count > 0 ? images : null);
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()

View File

@@ -0,0 +1,34 @@
using System.ComponentModel;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Tools;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>Arguments of <c>set_timezone</c>.</summary>
public sealed record SetTimeZoneArgs([property: Description("IANA time zone, e.g. Europe/Berlin or America/New_York")] string Zone);
/// <summary>Host extension per chat: the <c>set_timezone</c> tool, whether or not the scheduler is enabled.</summary>
public sealed class ChatSettingsExtension(long chatId, ChatTimeZoneService zones) : IExtension
{
public const string ExtensionName = "telegram-chat-settings";
public const string ToolName = "set_timezone";
public string Name => ExtensionName;
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
api.RegisterTool(new DelegateTool<SetTimeZoneArgs>(
ToolName,
"Set this chat's time zone (an IANA name). It applies to the date in your instructions, bash, scheduled jobs and message timestamps.",
async (args, _, ct) =>
{
if (!ChatTimeZones.TryParse(args.Zone, out var zone, out var error)) throw new ToolExecutionException(ToolResult.Text(error));
return ToolResult.Text(await zones.SetAsync(chatId, zone, ct).ConfigureAwait(false));
},
promptSnippet: "set_timezone: change this chat's time zone",
promptGuidelines: ["Use set_timezone only when the user tells you their location or time zone changed; confirm the new zone in your reply."],
executionMode: ToolExecutionMode.Sequential));
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,106 @@
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// Per-chat settings outside every workspace: <c>&lt;AgentDir&gt;/telegram/chat-settings.json</c>,
/// <c>{ "version": 1, "chats": { "&lt;chatId&gt;": { "timeZone": "Asia/Tokyo" } } }</c>. Loaded once and cached; written
/// atomically (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty; a stored
/// zone that no longer resolves is logged and ignored (the chat follows the default). Exists with the scheduler disabled.
/// </summary>
public sealed class ChatSettingsStore
{
public const int Version = 1;
private readonly string _path;
private readonly ILogger _logger;
private readonly SemaphoreSlim _writeGate = new(1, 1);
private readonly Lock _lock = new();
private readonly Dictionary<long, TimeZoneInfo> _zones = [];
public ChatSettingsStore(string path, TimeZoneInfo defaultZone, ILogger<ChatSettingsStore> logger, TimeProvider? time = null)
{
ArgumentNullException.ThrowIfNull(defaultZone);
_path = path;
_logger = logger;
DefaultZone = defaultZone;
Load(time ?? TimeProvider.System);
}
public static string DefaultPath(string agentDir) => Path.Combine(agentDir, "telegram", "chat-settings.json");
/// <summary><c>Telegram:DefaultTimeZone</c>, else the machine's zone. Chats without their own zone follow it.</summary>
public TimeZoneInfo DefaultZone { get; }
public TimeZoneInfo TimeZoneFor(long chatId)
{
lock (_lock) return _zones.GetValueOrDefault(chatId) ?? DefaultZone;
}
public bool HasOwnZone(long chatId)
{
lock (_lock) return _zones.ContainsKey(chatId);
}
/// <summary>Stores <paramref name="ianaId"/> for the chat; null removes the entry (back to the default).</summary>
public async Task SetTimeZoneAsync(long chatId, string? ianaId, CancellationToken cancellationToken)
{
var zone = ianaId is null ? null : TimeZoneInfo.FindSystemTimeZoneById(ianaId); // throws before anything changes
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Under the write gate, so the snapshot written last is also the newest one.
SettingsFile snapshot;
lock (_lock)
{
if (zone is null) _zones.Remove(chatId);
else _zones[chatId] = zone;
snapshot = new SettingsFile(Version, _zones.ToDictionary(
kv => kv.Key.ToString(CultureInfo.InvariantCulture),
kv => new ChatSettings(TimeZoneIds.Name(kv.Value))));
}
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
var temp = $"{_path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(snapshot, VolcanicJson.Indented), CancellationToken.None).ConfigureAwait(false);
File.Move(temp, _path, overwrite: true);
}
finally
{
_writeGate.Release();
}
}
private void Load(TimeProvider time)
{
if (!File.Exists(_path)) return;
SettingsFile? file;
try
{
file = VolcanicJson.Deserialize<SettingsFile>(File.ReadAllText(_path));
}
catch (JsonException)
{
file = null;
}
if (file is not { Version: Version, Chats: not null })
{
var aside = $"{_path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
File.Move(_path, aside, overwrite: true);
_logger.LogError("Chat settings {Path} could not be read; moved to {Aside}, every chat follows the default zone", _path, aside);
return;
}
foreach (var (key, settings) in file.Chats)
{
if (!long.TryParse(key, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var chatId) || settings?.TimeZone is not { } id) continue;
if (TimeZoneInfo.TryFindSystemTimeZoneById(id, out var zone)) _zones[chatId] = zone;
else _logger.LogWarning("Chat {ChatId}: stored time zone {Zone} is unknown here; the chat follows the default", chatId, id);
}
}
private sealed record SettingsFile(int Version, Dictionary<string, ChatSettings>? Chats);
private sealed record ChatSettings(string? TimeZone);
}

View File

@@ -0,0 +1,66 @@
using System.Globalization;
using System.Text;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Scheduling;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// The one path for a chat's zone change (<c>/timezone</c> and <c>set_timezone</c>): store it, apply it to the live
/// session (from its next run), re-evaluate the chat's cron jobs, and word the reply. <paramref name="liveSession"/>
/// finds the chat's session without creating one; it's a function so DI has no ChatRegistry ↔ service cycle.
/// </summary>
public sealed class ChatTimeZoneService(ChatSettingsStore store, Func<long, AgentSession?> liveSession, TimeProvider time, JobScheduler? scheduler = null)
{
/// <summary>Set (<paramref name="zone"/>) or reset (null) the chat's zone and return the confirmation.</summary>
public async Task<string> SetAsync(long chatId, TimeZoneInfo? zone, CancellationToken cancellationToken)
{
await store.SetTimeZoneAsync(chatId, zone is null ? null : TimeZoneIds.Name(zone), cancellationToken).ConfigureAwait(false);
var effective = store.TimeZoneFor(chatId);
liveSession(chatId)?.SetTimeZone(effective);
var now = time.GetUtcNow();
var text = new StringBuilder(zone is null
? $"Time zone reset to the default, {Describe(effective, now)}."
: $"Time zone set to {Describe(effective, now)}.");
text.Append(" Now ").Append(JobFormat.DayTime(now, effective)).Append('.');
if (scheduler is { IsStarted: true })
{
var target = TelegramJobDelivery.TargetOf(chatId);
var change = await scheduler.TimeZoneChangedAsync(target, cancellationToken).ConfigureAwait(false);
text.Append(JobClause(change, scheduler.List(target), effective));
}
return text.ToString();
}
/// <summary><c>Time zone: Europe/Berlin (UTC+02:00, default). Change it with /timezone &lt;IANA name&gt;, e.g. /timezone America/New_York.</c></summary>
public string Show(long chatId)
{
var zone = store.TimeZoneFor(chatId);
var marker = store.HasOwnZone(chatId) ? "" : ", default";
return $"Time zone: {TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(time.GetUtcNow()))}{marker}). Change it with /timezone <IANA name>, e.g. /timezone America/New_York.";
}
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>, the chat's zone now (for <c>/status</c>).</summary>
public string Describe(long chatId) => Describe(store.TimeZoneFor(chatId), time.GetUtcNow());
/// <summary><c>Asia/Tokyo (UTC+09:00)</c>.</summary>
public static string Describe(TimeZoneInfo zone, DateTimeOffset at) => $"{TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(zone.GetUtcOffset(at))})";
/// <summary><c> 2 recurring jobs now follow this zone; 1 one-off job keeps its time (now 27 Sep 16:00 here).</c> Empty without jobs.</summary>
private static string JobClause(ZoneChange change, IReadOnlyList<JobView> jobs, TimeZoneInfo zone)
{
var parts = new List<string>();
if (change.CronJobs > 0)
parts.Add(change.CronJobs == 1 ? "1 recurring job now follows this zone" : $"{change.CronJobs.ToString(CultureInfo.InvariantCulture)} recurring jobs now follow this zone");
if (change.OneOffs > 0)
{
var times = string.Join(", ", jobs.Where(v => v.Job.Schedule.At is not null).Select(v => JobFormat.DayTime(v.Job.Schedule.At!.Value, zone)));
parts.Add(change.OneOffs == 1
? $"1 one-off job keeps its time (now {times} here)"
: $"{change.OneOffs.ToString(CultureInfo.InvariantCulture)} one-off jobs keep their times (now {times} here)");
}
return parts.Count == 0 ? "" : " " + string.Join("; ", parts) + ".";
}
}

View File

@@ -0,0 +1,25 @@
using System.Text.RegularExpressions;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>Parses what a user (<c>/timezone</c>) or the agent (<c>set_timezone</c>) calls a zone. The stored form is the IANA id.</summary>
public static partial class ChatTimeZones
{
/// <summary>Accepts IANA ids and Windows ids (converted); rejects <c>Local</c> and bare offsets such as <c>+03:00</c> or <c>GMT+3</c>.</summary>
public static bool TryParse(string? input, out TimeZoneInfo zone, out string error)
{
var text = input?.Trim() ?? "";
zone = TimeZoneInfo.Utc;
error = $"Unknown time zone \"{text}\". Use an IANA name such as Europe/Berlin or America/New_York.";
if (text.Length == 0 || text.Equals("Local", StringComparison.OrdinalIgnoreCase) || BareOffset().IsMatch(text)) return false;
if (!TimeZoneInfo.TryFindSystemTimeZoneById(text, out var found)) return false;
if (TimeZoneIds.ToIana(found) is not { } iana || !TimeZoneInfo.TryFindSystemTimeZoneById(iana, out var resolved)) return false;
zone = resolved;
error = "";
return true;
}
[GeneratedRegex(@"^(utc|gmt)?\s*[+-]\s*\d", RegexOptions.IgnoreCase)]
private static partial Regex BareOffset();
}

View File

@@ -0,0 +1,48 @@
using System.Globalization;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Telegram.Rendering;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// One chat's <see cref="IChatChannel"/>. Every delivery goes through the chat renderer's queue
/// (<see cref="ChatRenderer.RunInOrderAsync{T}"/>), so it can't overtake rendered output, and works while no run is active.
/// </summary>
public sealed class TelegramChatChannel(long chatId, TelegramSender sender, ChatRenderer renderer) : IChatChannel
{
private static readonly HashSet<string> PhotoExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
public ChatChannelLimits Limits { get; } = new(TelegramLimits.MaxPhotoBytes, TelegramLimits.MaxDocumentBytes, TelegramLimits.MaxCaptionLength);
public async Task<ChatDelivery> SendTextAsync(string markdown, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(markdown);
var (first, complete) = await renderer.ReplyInOrderAsync(markdown, cancellationToken).ConfigureAwait(false);
if (!complete) throw new ChatDeliveryException("Telegram did not accept the message.");
return new ChatDelivery(null, first?.ToString(CultureInfo.InvariantCulture));
}
public Task<ChatDelivery> SendFileAsync(ChatFile file, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(file);
return renderer.RunInOrderAsync(async ct =>
{
var asPhoto = file.Kind == ChatFileKind.Photo || (file.Kind == ChatFileKind.Auto && LooksLikePhoto(file.Path));
var result = await sender.SendFileAsync(chatId, file.Path, asPhoto, file.Caption, ct).ConfigureAwait(false);
if (result.MessageId is { } id) return Delivered(asPhoto, id);
if (asPhoto && file.Kind == ChatFileKind.Auto && result.PhotoRejected)
{
// Telegram refuses some images as photos (dimensions, aspect ratio): send the file as a document instead.
result = await sender.SendFileAsync(chatId, file.Path, asPhoto: false, file.Caption, ct).ConfigureAwait(false);
if (result.MessageId is { } documentId) return Delivered(false, documentId);
}
throw new ChatDeliveryException($"Telegram refused the file: {result.Error}");
}, cancellationToken);
}
private bool LooksLikePhoto(string path) =>
PhotoExtensions.Contains(Path.GetExtension(path)) && new FileInfo(path) is { Exists: true } info && info.Length <= Limits.MaxPhotoBytes;
private static ChatDelivery Delivered(bool asPhoto, int messageId) =>
new(asPhoto ? ChatFileKind.Photo : ChatFileKind.Document, messageId.ToString(CultureInfo.InvariantCulture));
}

View File

@@ -1,8 +1,7 @@
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Rendering;
@@ -10,13 +9,16 @@ namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// Routes an update to its chat: only allow-listed users in private chats get through; button presses and answers go to
/// the chat's pending dialog, bot commands run directly, everything else becomes a prompt that runs in the background.
/// the chat's pending dialog, bot commands run directly, everything else goes through the chat's <see cref="ChatInbox"/>:
/// the router takes a lease in update order, composes the message in the background, and the inbox turns the batch into
/// a prompt that runs in the background.
/// </summary>
public sealed class UpdateRouter(
TelegramOptions options,
ChatRegistry chats,
ChatCommands commands,
TelegramSender sender,
InboundComposer composer,
ILogger<UpdateRouter> logger) : IUpdateRouter
{
public async Task RouteAsync(Update update, CancellationToken cancellationToken)
@@ -58,39 +60,48 @@ public sealed class UpdateRouter(
return;
}
var text = message.Text ?? message.Caption ?? "";
if (text.Length == 0 && message.Photo is not { Length: > 0 })
if (!InboundComposer.IsSupported(message))
{
await sender.SendTextAsync(message.Chat.Id, "I can read text and photos.", cancellationToken: cancellationToken).ConfigureAwait(false);
await sender.SendTextAsync(message.Chat.Id, InboundComposer.UnsupportedReply, cancellationToken: cancellationToken).ConfigureAwait(false);
return;
}
var chat = await chats.GetAsync(message.Chat.Id, cancellationToken).ConfigureAwait(false);
var isCommand = ChatCommands.Parse(text) is not null;
// Only plain text answers a pending question: a photo (with or without a caption) is a prompt of its own.
if (!isCommand && text.Length > 0 && message.Photo is null && chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
if (isCommand && await commands.TryHandleAsync(chat, text, cancellationToken).ConfigureAwait(false)) return;
var images = await DownloadPhotoAsync(chat, message, cancellationToken).ConfigureAwait(false);
if (text.Length == 0 && images is null) return; // photo download failed and already reported
_ = chat.PromptInBackground(text, images);
}
private async Task<IReadOnlyList<ImageContent>?> DownloadPhotoAsync(ChatContext chat, Message message, CancellationToken cancellationToken)
{
if (message.Photo is not { Length: > 0 } sizes) return null;
try
var text = message.Text ?? message.Caption ?? "";
// Commands and pending answers are checked before the inbox, and only for messages without an attachment that
// weren't forwarded: a forwarded message is content, never a command or an answer.
if (!InboundComposer.HasAttachment(message) && message.ForwardOrigin is null)
{
var largest = sizes.MaxBy(p => p.FileSize ?? (long)p.Width * p.Height)!;
using var stream = new MemoryStream();
await sender.Bot.GetInfoAndDownloadFile(largest.FileId, stream, cancellationToken).ConfigureAwait(false);
return [new ImageContent(Convert.ToBase64String(stream.ToArray()), "image/jpeg")];
if (ChatCommands.Parse(text) is not null)
{
if (await commands.TryHandleAsync(chat, text, cancellationToken).ConfigureAwait(false)) return;
// Not one of ours: straight to the session, without headers or the window, so extension commands still parse.
_ = chat.PromptInBackground(text);
return;
}
if (chat.Ui.IsWaitingForInput && chat.Ui.TryResolveInput(text)) return;
}
catch (Exception ex) when (ex is not OperationCanceledException)
// Take the lease now, in update order; compose (downloads) in the background so polling isn't blocked.
var lease = chat.Inbox.Begin(message.Id);
_ = Task.Run(async () =>
{
logger.LogWarning(ex, "Downloading a photo in chat {ChatId} failed", chat.ChatId);
chat.Report("I could not download that photo.");
return null;
}
using (lease)
{
try
{
lease.Complete(await composer.ComposeAsync(chat, message, cancellationToken).ConfigureAwait(false));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// shutting down: the lease completes empty
}
catch (Exception ex)
{
logger.LogWarning(ex, "Reading message {MessageId} in chat {ChatId} failed", message.Id, chat.ChatId);
chat.Report("I could not read that message.");
}
}
}, CancellationToken.None);
}
}

View File

@@ -0,0 +1,57 @@
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Extensions.GoogleCalendar;
using Volcanic.AgenticCore.Features.Extensions;
namespace Volcanic.AgenticCore.Telegram;
/// <summary>The <c>GoogleCalendar</c> configuration section. <c>GOOGLE_CLIENT_ID</c>/<c>GOOGLE_CLIENT_SECRET</c> fill empty values.</summary>
public sealed class GoogleCalendarSection
{
public const string SectionName = "GoogleCalendar";
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
}
/// <summary>Everything the Telegram host does for Google Calendar, kept out of the shared host files.</summary>
public static class GoogleCalendarSetup
{
public static readonly IReadOnlyList<BotCommand> Menu =
[
new() { Command = "calendar_connect", Description = "Connect your Google Calendar" },
new() { Command = "calendar_status", Description = "Google Calendar connection" },
new() { Command = "calendar_disconnect", Description = "Disconnect Google Calendar" },
];
/// <summary>Null when neither the client id nor the secret is set; empty values count as unset.</summary>
public static GoogleCalendarOptions? Options(GoogleCalendarSection section, Func<string, string?> getEnv)
{
ArgumentNullException.ThrowIfNull(section);
ArgumentNullException.ThrowIfNull(getEnv);
var id = NonEmpty(section.ClientId) ?? NonEmpty(getEnv("GOOGLE_CLIENT_ID"));
var secret = NonEmpty(section.ClientSecret) ?? NonEmpty(getEnv("GOOGLE_CLIENT_SECRET"));
return id is null && secret is null ? null : new GoogleCalendarOptions { ClientId = id, ClientSecret = secret, GetEnv = getEnv };
}
/// <summary>True when both the client id and the secret are set, i.e. the extension will register its tool and commands.</summary>
public static bool IsActive(GoogleCalendarOptions? options) => options?.IsConfigured == true;
/// <summary>The bot's command menu, with the calendar commands appended when the calendar is active.</summary>
public static IReadOnlyList<BotCommand> MenuFor(IReadOnlyList<BotCommand> menu, GoogleCalendarOptions? options) =>
IsActive(options) ? [.. menu, .. Menu] : menu;
/// <summary>Extra <c>/help</c> lines when the session has the calendar commands.</summary>
public static string HelpLines(IReadOnlyList<CommandInfo> sessionCommands)
{
ArgumentNullException.ThrowIfNull(sessionCommands);
return sessionCommands.Any(c => c.Name == "calendar_connect")
? "\n/calendar_connect – connect this chat's Google Calendar\n/calendar_status, /calendar_disconnect – check or remove the connection"
: "";
}
/// <summary>A new extension instance per session over the shared connections; the session scope picks the chat's account.</summary>
public static ExtensionRegistration Registration(GoogleCalendarOptions options) =>
new(GoogleCalendarExtension.ExtensionName, _ => new GoogleCalendarExtension(options), "telegram");
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -0,0 +1,240 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>
/// Groups one chat's messages into batches. The router takes a lease per message, synchronously and in update order, and
/// completes it with the message's <see cref="InboundPart"/> once composed. A batch is flushed <c>window</c> after the
/// last completion, and only while no lease is open. Parts keep lease order (Telegram order), not completion order.
/// A lease open longer than <c>maxHold</c> stops holding the batch; its late part becomes a batch of its own.
/// </summary>
public sealed class ChatInbox : IAsyncDisposable
{
/// <summary>How long an open lease may hold a batch before it is detached.</summary>
public static readonly TimeSpan DefaultMaxHold = TimeSpan.FromMinutes(2);
internal sealed class Slot
{
public InboundPart? Part;
public bool Open = true;
public bool Detached;
public ITimer? Hold;
}
private readonly Func<IReadOnlyList<InboundPart>, Task> _onFlush;
private readonly TimeSpan _window;
private readonly TimeSpan _maxHold;
private readonly TimeProvider _time;
private readonly ILogger _logger;
private readonly Lock _lock = new();
private readonly List<Slot> _slots = [];
private ITimer? _timer;
private long _timerVersion;
private long _generation;
private int _flushing;
private bool _disposed;
/// <param name="onFlush">Receives each batch, in slot order. A failure is logged and doesn't stop later batches.</param>
/// <param name="window">Quiet time after the last completion before a batch is flushed.</param>
/// <param name="time">Clock and timers.</param>
/// <param name="maxHold">How long an open lease may hold the batch; <see cref="DefaultMaxHold"/> when null.</param>
/// <param name="logger">Records dropped parts on dispose, detached leases and failing flushes.</param>
public ChatInbox(Func<IReadOnlyList<InboundPart>, Task> onFlush, TimeSpan window, TimeProvider time, TimeSpan? maxHold = null, ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(onFlush);
ArgumentNullException.ThrowIfNull(time);
ArgumentOutOfRangeException.ThrowIfLessThan(window, TimeSpan.Zero);
_onFlush = onFlush;
_window = window;
_time = time;
_maxHold = maxHold ?? DefaultMaxHold;
_logger = logger ?? NullLogger.Instance;
}
/// <summary>Slots holding a part (test view).</summary>
internal int Buffered { get { lock (_lock) return _slots.Count(s => s.Part is not null); } }
/// <summary>Slots still open (test view).</summary>
internal int OpenLeases { get { lock (_lock) return _slots.Count(s => s.Open); } }
/// <summary>Reserve the next slot. Cancels an armed flush until this lease completes.</summary>
public InboundLease Begin(int messageId)
{
lock (_lock)
{
var slot = new Slot();
var lease = new InboundLease(this, slot, _generation, messageId);
if (_disposed)
{
slot.Open = false; // completes into nothing
return lease;
}
CancelTimer();
_slots.Add(slot);
slot.Hold = _time.CreateTimer(_ => Detach(lease), null, _maxHold, Timeout.InfiniteTimeSpan);
return lease;
}
}
/// <summary>Drop buffered parts and in-flight leases (<c>/new</c>, <c>/stop</c>, <c>/resume</c>). Leases already open complete into nothing.</summary>
public void Discard()
{
lock (_lock) DropLocked();
}
/// <summary>Completes when nothing is buffered, no lease is open and the last flush was handed over (test helper; polls).</summary>
internal async Task DrainAsync()
{
while (true)
{
lock (_lock)
{
if (_slots.Count == 0 && _flushing == 0) return;
}
await Task.Delay(10).ConfigureAwait(false);
}
}
/// <summary>Discards everything buffered and logs how many parts were dropped.</summary>
public ValueTask DisposeAsync()
{
int dropped;
lock (_lock)
{
if (_disposed) return ValueTask.CompletedTask;
_disposed = true;
dropped = DropLocked();
}
if (dropped > 0) _logger.LogInformation("Dropped {Count} buffered message(s) on shutdown", dropped);
return ValueTask.CompletedTask;
}
internal void Complete(InboundLease lease, InboundPart? part)
{
lock (_lock)
{
var slot = lease.Slot;
slot.Hold?.Dispose();
if (_disposed || lease.Generation != _generation) return; // discarded
if (slot.Detached)
{
if (part is null) return;
_slots.Add(new Slot { Part = part, Open = false }); // a late part: a new Begin + Complete at the end
}
else
{
if (!slot.Open) return;
slot.Open = false;
slot.Part = part;
}
SettleLocked();
}
}
private void Detach(InboundLease lease)
{
lock (_lock)
{
var slot = lease.Slot;
if (_disposed || lease.Generation != _generation || !slot.Open) return;
slot.Open = false;
slot.Detached = true;
_slots.Remove(slot);
_logger.LogWarning("Message {MessageId} took longer than {MaxHold} to prepare; sending the others without it", lease.MessageId, _maxHold);
SettleLocked();
}
}
/// <summary>Arms the flush timer when no slot is open and one holds a part; clears the slots when all are empty.</summary>
private void SettleLocked()
{
if (_slots.Any(s => s.Open)) return;
if (_slots.All(s => s.Part is null))
{
_slots.Clear();
return;
}
CancelTimer();
var version = _timerVersion;
_timer = _time.CreateTimer(_ => OnTimer(version), null, _window, Timeout.InfiniteTimeSpan);
}
/// <summary>Bumps the version so a callback from a cancelled arm can't flush.</summary>
private void CancelTimer()
{
_timerVersion++;
_timer?.Dispose();
_timer = null;
}
private void OnTimer(long version)
{
List<InboundPart> parts;
lock (_lock)
{
if (_disposed || version != _timerVersion || _slots.Any(s => s.Open)) return;
CancelTimer();
parts = _slots.Where(s => s.Part is not null).Select(s => s.Part!).ToList();
_slots.Clear();
if (parts.Count == 0) return;
_flushing++;
}
_ = DeliverAsync(parts);
}
private async Task DeliverAsync(List<InboundPart> parts)
{
try
{
await _onFlush(parts).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Handing over {Count} message(s) failed", parts.Count);
}
finally
{
lock (_lock) _flushing--;
}
}
private int DropLocked()
{
_generation++;
CancelTimer();
var dropped = _slots.Count(s => s.Part is not null);
foreach (var slot in _slots) slot.Hold?.Dispose();
_slots.Clear();
return dropped;
}
}
/// <summary>One message's reserved place in a <see cref="ChatInbox"/> batch.</summary>
public sealed class InboundLease : IDisposable
{
private readonly ChatInbox _inbox;
private int _done;
internal InboundLease(ChatInbox inbox, ChatInbox.Slot slot, long generation, int messageId)
{
_inbox = inbox;
Slot = slot;
Generation = generation;
MessageId = messageId;
}
/// <summary>The Telegram message this lease was taken for.</summary>
public int MessageId { get; }
internal ChatInbox.Slot Slot { get; }
internal long Generation { get; }
/// <summary>Fill the slot; null means the message produced nothing. Only the first call counts.</summary>
public void Complete(InboundPart? part)
{
if (Interlocked.Exchange(ref _done, 1) == 0) _inbox.Complete(this, part);
}
/// <summary><c>Complete(null)</c> if not completed yet. Safe to call twice.</summary>
public void Dispose() => Complete(null);
}

View File

@@ -0,0 +1,90 @@
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>What the content steps have gathered for one message so far.</summary>
internal sealed class InboundDraft
{
public List<string> Body { get; } = [];
public List<ImageContent> Images { get; } = [];
/// <summary>A step refused the message (and sent its own notice): nothing is prompted.</summary>
public bool Refused { get; set; }
/// <summary>Where step 2 saved the message's file, for step 3 (audio).</summary>
public string? SavedPath { get; set; }
}
/// <summary>
/// Turns one Telegram message into an <see cref="InboundPart"/>: reply and forward headers, then the content steps in a
/// fixed order: step 1 text and photo, step 2 files saved to the inbox (<see cref="InboundMedia"/>), step 3 voice and
/// audio transcription. Any slow work for a message happens here, while the router holds that message's
/// <see cref="InboundLease"/>. A step never talks to the <see cref="ChatInbox"/> itself.
/// </summary>
public sealed class InboundComposer(TelegramSender sender, InboundMedia media, ILogger<InboundComposer> logger)
{
/// <summary>The reply to a message kind the bot can't read (stickers, video notes, polls…; no lease is taken).</summary>
public const string UnsupportedReply = "I can read text, photos, files and voice messages.";
/// <summary>Kinds the content steps can read. Checked by the router before it takes a lease.</summary>
public static bool IsSupported(Message message)
{
ArgumentNullException.ThrowIfNull(message);
return message.Photo is { Length: > 0 } || message.Document is not null || message.Video is not null
|| message.Audio is not null || message.Voice is not null || (message.Text ?? message.Caption) is { Length: > 0 };
}
/// <summary>A message with an attachment is never a command or a pending answer; its caption is prompt text.</summary>
public static bool HasAttachment(Message message)
{
ArgumentNullException.ThrowIfNull(message);
return message.Photo is { Length: > 0 } || message.Document is not null || message.Video is not null
|| message.Audio is not null || message.Voice is not null || message.VideoNote is not null
|| message.Animation is not null || message.Sticker is not null;
}
/// <summary>The message's part, or null when it has nothing usable or a step refused it.</summary>
public async Task<InboundPart?> ComposeAsync(ChatContext chat, Message message, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(chat);
ArgumentNullException.ThrowIfNull(message);
var draft = new InboundDraft();
// Content steps, in order. Each may add body lines and images, send a notice, or refuse the message.
await AddTextAndPhotoAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 1
if (!draft.Refused) await media.AddFilesAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 2
if (!draft.Refused) await media.AddSpeechAsync(chat, message, draft, cancellationToken).ConfigureAwait(false); // step 3
if (draft.Refused || (draft.Body.Count == 0 && draft.Images.Count == 0)) return null;
return new InboundPart(
message.Id,
new DateTimeOffset(DateTime.SpecifyKind(message.Date, DateTimeKind.Utc)),
message.MediaGroupId,
MessageContext.ReplyHeader(message, sender.Bot.BotId),
message.ForwardOrigin is { } origin ? MessageContext.ForwardLabel(origin) : null,
draft.Body,
draft.Images);
}
/// <summary>Step 1: the text or caption, and the largest photo size.</summary>
private async Task AddTextAndPhotoAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken cancellationToken)
{
if ((message.Text ?? message.Caption) is { Length: > 0 } text) draft.Body.Add(text);
if (message.Photo is not { Length: > 0 } sizes) return;
try
{
var largest = sizes.MaxBy(p => p.FileSize ?? (long)p.Width * p.Height)!;
using var stream = new MemoryStream();
await media.DownloadAsync(largest.FileId, stream, cancellationToken).ConfigureAwait(false);
draft.Images.Add(new ImageContent(Convert.ToBase64String(stream.ToArray()), "image/jpeg"));
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(ex, "Downloading a photo in chat {ChatId} failed", chat.ChatId);
chat.Report("I could not download that photo.");
}
}
}

View File

@@ -0,0 +1,215 @@
using Microsoft.Extensions.Logging;
using Telegram.Bot;
using Telegram.Bot.Exceptions;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Speech;
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>
/// Content steps for attachments (spec: telegram-attachments §4): step 2 saves documents, videos and audio to the
/// workspace inbox, step 3 transcribes voice notes and audio through <see cref="ISpeechToText"/> (null: speech is off).
/// Notices and the echo go through the chat renderer (<see cref="ChatRenderer.SayAsync"/>) so they stay in order.
/// </summary>
public sealed class InboundMedia(TelegramSender sender, TelegramOptions options, TimeProvider time, ILogger<InboundMedia> logger,
ISpeechToText? speech = null)
{
/// <summary>Sent for a voice note or audio file when no speech-to-text service is configured.</summary>
public const string SpeechNotConfiguredNotice = "Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host.";
/// <summary>Sent when the transcript is empty.</summary>
public const string NoWordsNotice = "I couldn't make out any words in that voice message.";
/// <summary>Sent when a file's download fails (the partial file is deleted and the message isn't prompted).</summary>
public const string DownloadFailedNotice = "I could not download that file.";
/// <summary>Suffix of a <c>[File received: …]</c> line for an image too large to attach inline.</summary>
public const string TooLargeInlineNote = " (too large to show inline; downscale it with bash if you need to see it)";
/// <summary>Suffix of a <c>[File received: …]</c> line for an image document whose bytes aren't a known image.</summary>
public const string NotAnImageNote = " (not shown inline: the contents are not a JPEG, PNG, WebP or GIF image)";
private static readonly HashSet<string> InlineImageTypes = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp", "image/gif" };
/// <summary>The notice for a file over the 20 MB download limit; <c>over 20 MB</c> when the size is unknown.</summary>
public static string TooBigNotice(long? bytes) =>
$"That file is {(bytes is { } b ? TelegramLimits.Megabytes(b) + " MB" : "over 20 MB")}; Telegram lets bots download at most 20 MB. Share a link or a smaller file.";
/// <summary>Download a file by id into <paramref name="destination"/> (shared with step 1's photos).</summary>
internal Task DownloadAsync(string fileId, Stream destination, CancellationToken cancellationToken) =>
sender.Bot.GetInfoAndDownloadFile(fileId, destination, cancellationToken);
/// <summary>Telegram's <c>getFile</c> refusal for a file over the download limit.</summary>
internal static bool IsTooBig(ApiRequestException ex) => ex.Message.Contains("file is too big", StringComparison.OrdinalIgnoreCase);
/// <summary>Step 2: a document, video or audio file goes to the inbox and gets a <c>[File received: …]</c> line.</summary>
internal async Task AddFilesAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken cancellationToken)
{
var (fileId, size, name, mime) = message switch
{
{ Document: { } d } => (d.FileId, d.FileSize, d.FileName ?? "file", d.MimeType ?? "application/octet-stream"),
{ Video: { } v } => (v.FileId, v.FileSize, v.FileName ?? "video.mp4", v.MimeType ?? "video/mp4"),
{ Audio: { } a } => (a.FileId, a.FileSize, a.FileName ?? "audio", a.MimeType ?? "audio/mpeg"),
_ => ((string?)null, (long?)null, "", ""),
};
if (fileId is null) return;
if (size > TelegramLimits.MaxDownloadBytes)
{
await chat.Renderer.SayAsync(TooBigNotice(size)).ConfigureAwait(false);
draft.Refused = true;
return;
}
var workspace = chat.Session.Cwd;
var (path, stream) = InboxFiles.Create(workspace, time.GetLocalNow(), name);
try
{
await using (stream.ConfigureAwait(false))
await DownloadAsync(fileId, stream, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
TryDelete(path);
throw;
}
catch (Exception ex)
{
TryDelete(path);
logger.LogWarning(ex, "Downloading {File} in chat {ChatId} failed", name, chat.ChatId);
await chat.Renderer.SayAsync(ex is ApiRequestException api && IsTooBig(api) ? TooBigNotice(size) : DownloadFailedNotice).ConfigureAwait(false);
draft.Refused = true;
return;
}
var bytes = new FileInfo(path).Length;
draft.SavedPath = path;
var note = "";
if (InlineImageTypes.Contains(mime))
{
if (bytes <= options.InlineImageMaxBytes)
{
// Documents aren't re-encoded by Telegram, so the declared type can be wrong (HEIC named .png, a
// truncated file). A provider rejects such an image, and the persisted prompt would break every later
// turn: attach only bytes that really are one of the four types, under the type they show.
var data = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (SniffImageType(data) is { } actual) draft.Images.Add(new ImageContent(Convert.ToBase64String(data), actual));
else note = NotAnImageNote;
}
else
{
note = TooLargeInlineNote;
}
}
draft.Body.Add($"[File received: {InboxFiles.Relative(workspace, path)} ({mime}, {TelegramLimits.FormatSize(bytes)}){note}]");
}
/// <summary>
/// Step 3: a voice note or audio file is transcribed, echoed as <c>🎤 …</c> and added as <c>[Voice message] …</c>.
/// Audio is read back from the file step 2 saved (one download). Without a transcript nothing is added (the caption,
/// if any, is still prompted); an oversize or undownloadable voice note refuses the message.
/// </summary>
internal async Task AddSpeechAsync(ChatContext chat, Message message, InboundDraft draft, CancellationToken cancellationToken)
{
var (fileId, size, name, mime, isVoice) = message switch
{
{ Voice: { } v } => (v.FileId, v.FileSize, "voice.ogg", "audio/ogg", true), // Telegram's .oga is rejected by OpenAI
{ Audio: { } a } => (a.FileId, a.FileSize, SpeechFileName(a.FileName, a.MimeType), a.MimeType ?? "audio/mpeg", false),
_ => ((string?)null, (long?)null, "", "", false),
};
if (fileId is null) return;
if (speech is null)
{
await chat.Renderer.SayAsync(SpeechNotConfiguredNotice).ConfigureAwait(false);
return;
}
if (isVoice && size > TelegramLimits.MaxDownloadBytes)
{
await chat.Renderer.SayAsync(TooBigNotice(size)).ConfigureAwait(false);
draft.Refused = true;
return;
}
byte[] audio;
if (draft.SavedPath is { } saved) // audio: step 2 saved it
{
audio = await File.ReadAllBytesAsync(saved, cancellationToken).ConfigureAwait(false);
}
else
{
using var buffer = new MemoryStream();
try
{
await DownloadAsync(fileId, buffer, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested) // a client timeout is a failure, not a shutdown
{
logger.LogWarning(ex, "Downloading a voice message in chat {ChatId} failed", chat.ChatId);
await chat.Renderer.SayAsync(ex is ApiRequestException api && IsTooBig(api) ? TooBigNotice(size) : DownloadFailedNotice).ConfigureAwait(false);
draft.Refused = true;
return;
}
audio = buffer.ToArray();
}
string transcript;
try
{
transcript = (await speech.TranscribeAsync(audio, name, mime, cancellationToken).ConfigureAwait(false)).Trim();
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested) // a client timeout is a failure, not a shutdown
{
logger.LogWarning(ex, "Transcribing a voice message in chat {ChatId} failed", chat.ChatId);
await chat.Renderer.SayAsync($"Could not transcribe the voice message: {ex.Message}").ConfigureAwait(false);
return;
}
if (transcript.Length == 0)
{
await chat.Renderer.SayAsync(NoWordsNotice).ConfigureAwait(false);
return;
}
await chat.Renderer.SayAsync("🎤 " + transcript).ConfigureAwait(false); // plain text, cut to 4096 by the sender
draft.Body.Add($"[Voice message] {transcript}");
}
/// <summary>
/// The name an audio file is sent to speech to text under: OpenAI picks the format from the extension, so it's an
/// ASCII <c>audio.&lt;ext&gt;</c> from the original extension when OpenAI knows it, else from the MIME type.
/// </summary>
internal static string SpeechFileName(string? fileName, string? mimeType)
{
var ext = Path.GetExtension(fileName ?? "").ToLowerInvariant();
if (ext is ".flac" or ".m4a" or ".mp3" or ".mp4" or ".mpeg" or ".mpga" or ".ogg" or ".wav" or ".webm") return "audio" + ext;
if (ext is ".oga" or ".opus") return "audio.ogg";
return "audio" + (mimeType?.ToLowerInvariant() switch
{
"audio/mp4" or "audio/m4a" or "audio/x-m4a" => ".m4a",
"audio/ogg" or "audio/opus" => ".ogg",
"audio/wav" or "audio/x-wav" or "audio/wave" => ".wav",
"audio/flac" or "audio/x-flac" => ".flac",
"audio/webm" => ".webm",
_ => ".mp3", // audio/mpeg, Telegram's default for audio
});
}
/// <summary>The image type the leading bytes show (JPEG, PNG, GIF or WebP), or null.</summary>
internal static string? SniffImageType(ReadOnlySpan<byte> data) => data switch
{
[0xFF, 0xD8, 0xFF, ..] => "image/jpeg",
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..] => "image/png",
[0x47, 0x49, 0x46, 0x38, ..] => "image/gif",
[0x52, 0x49, 0x46, 0x46, _, _, _, _, 0x57, 0x45, 0x42, 0x50, ..] => "image/webp",
_ => null,
};
private void TryDelete(string path)
{
try { File.Delete(path); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Could not delete the partial file {Path}", path);
}
}
}

View File

@@ -0,0 +1,69 @@
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>
/// What one Telegram message contributes to a prompt. Parts stay structured until the batch is flushed, so an album's
/// repeated headers can be collapsed.
/// </summary>
/// <param name="MessageId">The Telegram message id, used to react to the message.</param>
/// <param name="SentAt">The message's <c>Date</c>, for the batch's timestamp header.</param>
/// <param name="MediaGroupId">The album id, when the message is one item of an album.</param>
/// <param name="ReplyHeader">From <see cref="MessageContext.ReplyHeader"/>, brackets included.</param>
/// <param name="ForwardLabel">From <see cref="MessageContext.ForwardLabel"/>, without the brackets.</param>
/// <param name="Body">Text or caption, then lines added by content steps (<c>[File received: …]</c>, <c>[Voice message] …</c>).</param>
/// <param name="Images">Images the message carries (its largest photo).</param>
public sealed record InboundPart(
int MessageId,
DateTimeOffset SentAt,
string? MediaGroupId,
string? ReplyHeader,
string? ForwardLabel,
IReadOnlyList<string> Body,
IReadOnlyList<ImageContent> Images);
/// <summary>Turns a flushed batch of parts into one prompt.</summary>
public static class InboundBatch
{
/// <summary>
/// One timestamp line (the first part's <see cref="InboundPart.SentAt"/> in <paramref name="zone"/>), then each part:
/// its header lines, then its body lines separated by a blank line. Parts are separated by a blank line. Consecutive
/// parts of one media group show the reply header and the forward label once. An image-only batch is just the
/// timestamp line.
/// </summary>
public static (string Text, IReadOnlyList<ImageContent> Images) Render(IReadOnlyList<InboundPart> parts, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(parts);
ArgumentNullException.ThrowIfNull(zone);
ArgumentOutOfRangeException.ThrowIfZero(parts.Count);
var blocks = new List<string>();
var images = new List<ImageContent>();
string? group = null;
bool replyShown = false, forwardShown = false;
foreach (var part in parts)
{
if (part.MediaGroupId is null || part.MediaGroupId != group)
{
group = part.MediaGroupId;
replyShown = forwardShown = false;
}
var lines = new List<string>();
if (part.ReplyHeader is { } reply && !replyShown)
{
lines.Add(reply);
replyShown = group is not null;
}
if (part.ForwardLabel is { } forward && !forwardShown)
{
lines.Add($"[Forwarded from {forward}]");
forwardShown = group is not null;
}
var body = part.Body.Where(line => line.Length > 0).ToList();
if (body.Count > 0) lines.Add(string.Join("\n\n", body));
if (lines.Count > 0) blocks.Add(string.Join('\n', lines));
images.AddRange(part.Images);
}
var header = MessageContext.TimestampHeader(parts[0].SentAt, zone);
return (blocks.Count == 0 ? header : header + "\n" + string.Join("\n\n", blocks), images);
}
}

View File

@@ -0,0 +1,65 @@
using System.Globalization;
using System.Text;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>Where received files go: <c>&lt;workspace&gt;/inbox/&lt;yyyy-MM-dd&gt;-&lt;safe name&gt;</c>, never overwriting.</summary>
public static class InboxFiles
{
/// <summary>The inbox directory under the chat's workspace.</summary>
public const string DirectoryName = "inbox";
/// <summary>Longest stem kept from a received file name, in characters.</summary>
public const int MaxStemLength = 80;
/// <summary>Longest extension kept, dot included.</summary>
public const int MaxExtensionLength = 16;
/// <summary>
/// Drops any directory part, replaces everything but Unicode letters, digits, <c>.</c>, <c>-</c> and <c>_</c> with
/// <c>_</c>, trims leading dots and underscores, caps stem (80) and extension (16, dot included); <c>file</c> when empty.
/// </summary>
public static string SafeName(string? fileName)
{
var name = Path.GetFileName((fileName ?? "").Replace('\\', '/'));
var cleaned = new StringBuilder(name.Length);
foreach (var rune in name.EnumerateRunes())
cleaned.Append(Rune.IsLetterOrDigit(rune) || rune.Value is '.' or '-' or '_' ? rune.ToString() : "_");
var safe = cleaned.ToString().TrimStart('.', '_');
var extension = Path.GetExtension(safe);
var stem = extension.Length == 0 ? safe : safe[..^extension.Length];
extension = Cap(extension, MaxExtensionLength);
stem = Cap(stem, MaxStemLength);
if (stem.Length == 0) stem = "file";
return stem + extension;
}
/// <summary>Creates the next free <c>inbox/&lt;date&gt;-&lt;stem&gt;[-N]&lt;ext&gt;</c> with <see cref="FileMode.CreateNew"/>.</summary>
public static (string Path, FileStream Stream) Create(string workspace, DateTimeOffset localNow, string? fileName)
{
var directory = Path.Combine(workspace, DirectoryName);
Directory.CreateDirectory(directory);
var safe = SafeName(fileName);
var extension = Path.GetExtension(safe);
var prefix = localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + "-" + safe[..^extension.Length];
for (var n = 0; ; n++)
{
var path = Path.Combine(directory, prefix + (n == 0 ? "" : "-" + n.ToString(CultureInfo.InvariantCulture)) + extension);
try
{
return (path, new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true));
}
catch (IOException) when (File.Exists(path) && n < 10_000)
{
// taken: try the next suffix
}
}
}
/// <summary>Workspace-relative with <c>/</c>, e.g. <c>inbox/2026-09-26-report.pdf</c>.</summary>
public static string Relative(string workspace, string path) => Path.GetRelativePath(workspace, path).Replace('\\', '/');
/// <summary>At most <paramref name="max"/> characters, never splitting a surrogate pair.</summary>
private static string Cap(string text, int max) =>
text.Length <= max ? text : text[..(char.IsHighSurrogate(text[max - 1]) ? max - 1 : max)];
}

View File

@@ -0,0 +1,106 @@
using System.Globalization;
using System.Text;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>
/// The header lines the agent reads before a message: what it replies to or quotes, where a forward came from, and the
/// local time it was sent. Wording matches the TypeScript reference bot, so both read the same to the model.
/// </summary>
public static class MessageContext
{
/// <summary>Quoted or replied text is cut to this many Unicode scalars (the last one being <c>…</c>).</summary>
public const int MaxQuoted = 500;
/// <summary>
/// <c>[Reply to …: "…"]</c> or <c>[Reply quoting …: "…"]</c> for a reply, a quote or an external reply; null when the
/// message replies to nothing. Never throws: an unknown shape is described as <c>(message)</c>.
/// </summary>
public static string? ReplyHeader(Message message, long botId)
{
ArgumentNullException.ThrowIfNull(message);
var quote = message.Quote?.Text is { Length: > 0 } q ? Truncate(q) : null;
if (message.ExternalReply is { } external)
{
var origin = external.Origin is { } o ? ForwardLabel(o) : "unknown";
return quote is not null
? $"[Reply quoting a message from {origin}: \"{quote}\"]"
: $"[Reply to a message from {origin}: \"{Truncate(Describe(external))}\"]";
}
if (message.ReplyToMessage is not { } replied) return null;
var who = replied.From is null ? "the earlier message"
: replied.From.Id == botId ? "your message"
: "your own earlier message"; // private chats only, so any other sender is the user
return quote is not null
? $"[Reply quoting {who}: \"{quote}\"]"
: $"[Reply to {who}: \"{Truncate(Describe(replied))}\"]";
}
/// <summary>Who a forward (or an external reply) came from, without the brackets.</summary>
public static string ForwardLabel(MessageOrigin origin) => origin switch
{
MessageOriginUser { SenderUser: { } user } => UserLabel(user),
MessageOriginUser => "unknown user",
MessageOriginHiddenUser hidden => hidden.SenderUserName is { Length: > 0 } n ? n : "hidden user",
MessageOriginChat chat => WithSignature(Named(chat.SenderChat?.Title, chat.SenderChat?.Username, "chat"), chat.AuthorSignature),
MessageOriginChannel channel => WithSignature(Named(channel.Chat?.Title, channel.Chat?.Username, "channel"), channel.AuthorSignature),
_ => "unknown",
};
/// <summary>The replied message's text, else its caption, else a media descriptor such as <c>(photo)</c>.</summary>
public static string Describe(Message replied)
{
ArgumentNullException.ThrowIfNull(replied);
return replied.Text is { Length: > 0 } text ? text
: replied.Caption is { Length: > 0 } caption ? caption
: Media(replied.Photo, replied.Video, replied.Voice, replied.VideoNote, replied.Audio, replied.Document, replied.Sticker);
}
internal static string Describe(ExternalReplyInfo external) =>
Media(external.Photo, external.Video, external.Voice, external.VideoNote, external.Audio, external.Document, external.Sticker);
/// <summary>Cut to <paramref name="max"/> Unicode scalars (the last one <c>…</c>), never splitting a surrogate pair.</summary>
public static string Truncate(string text, int max = MaxQuoted)
{
ArgumentNullException.ThrowIfNull(text);
ArgumentOutOfRangeException.ThrowIfLessThan(max, 1);
var runes = text.EnumerateRunes().Take(max + 1).ToList();
if (runes.Count <= max) return text;
var sb = new StringBuilder();
foreach (var rune in runes.Take(max - 1)) sb.Append(rune.ToString());
return sb.Append('…').ToString();
}
/// <summary><c>[Sat 2026-09-26 14:03 Europe/Berlin]</c>: <paramref name="at"/> in <paramref name="zone"/>, invariant culture.</summary>
public static string TimestampHeader(DateTimeOffset at, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
var local = TimeZoneInfo.ConvertTime(at, zone);
return string.Create(CultureInfo.InvariantCulture, $"[{local:ddd yyyy-MM-dd HH:mm} {TimeZoneIds.ToIana(zone) ?? zone.Id}]");
}
private static string UserLabel(User user)
{
if (user.Username is { Length: > 0 } name) return "@" + name;
var full = string.Join(' ', new[] { user.FirstName, user.LastName }.Where(s => !string.IsNullOrWhiteSpace(s)));
return full.Length > 0 ? full : "unknown user";
}
private static string Named(string? title, string? username, string fallback) =>
title is { Length: > 0 } ? title : username is { Length: > 0 } ? "@" + username : fallback;
private static string WithSignature(string label, string? signature) =>
signature is { Length: > 0 } ? $"{label} ({signature})" : label;
private static string Media(PhotoSize[]? photo, Video? video, Voice? voice, VideoNote? videoNote, Audio? audio, Document? document, Sticker? sticker) =>
photo is { Length: > 0 } ? "(photo)"
: video is not null ? "(video)"
: voice is not null ? "(voice message)"
: videoNote is not null ? "(video message)"
: audio is not null ? audio.FileName is { Length: > 0 } a ? $"(audio: {a})" : "(audio)"
: document is not null ? document.FileName is { Length: > 0 } d ? $"(document: {d})" : "(document)"
: sticker is not null ? sticker.Emoji is { Length: > 0 } e ? $"(sticker {e})" : "(sticker)"
: "(message)";
}

View File

@@ -0,0 +1,10 @@
namespace Volcanic.AgenticCore.Telegram;
/// <summary>The <c>ProcessEnvironment</c> configuration section. Its lists are added to the core defaults, not replacing them.</summary>
public sealed class ProcessEnvironmentSection
{
public const string SectionName = "ProcessEnvironment";
public List<string> Deny { get; set; } = [];
public List<string> Allow { get; set; } = [];
public bool HardenHostProcess { get; set; } = true;
}

View File

@@ -4,21 +4,40 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Telegram.Bot;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
using Volcanic.AgenticCore.Extensions.Mcp;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram;
using Volcanic.AgenticCore.Telegram.Chats;
using Volcanic.AgenticCore.Telegram.Inbound;
using Volcanic.AgenticCore.Telegram.Polling;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Speech;
var builder = Host.CreateApplicationBuilder(args);
var options = builder.Configuration.GetSection(TelegramOptions.SectionName).Get<TelegramOptions>() ?? new TelegramOptions();
options.ApplyEnvironment(Environment.GetEnvironmentVariable);
var errors = options.Validate();
var processSection = builder.Configuration.GetSection(ProcessEnvironmentSection.SectionName).Get<ProcessEnvironmentSection>() ?? new ProcessEnvironmentSection();
var calendar = GoogleCalendarSetup.Options(
builder.Configuration.GetSection(GoogleCalendarSection.SectionName).Get<GoogleCalendarSection>() ?? new GoogleCalendarSection(),
Environment.GetEnvironmentVariable);
var processEnvironment = new ProcessEnvironmentOptions();
TelegramProcessEnvironment.Configure(processEnvironment, options, processSection, Environment.GetEnvironmentVariable, calendar);
var mcp = builder.Configuration.GetSection(McpOptions.SectionName).Get<McpOptions>() ?? new McpOptions();
mcp.ApplyEnvironment(Environment.GetEnvironmentVariable);
// One agent directory for sessions, chat-settings.json and scheduler/jobs.json.
var agentDir = VolcanicPaths.DefaultAgentDir;
var scheduling = SchedulerSetup.Options(builder.Configuration.GetSection(SchedulerOptions.SectionName), agentDir);
var errors = options.Validate().Concat(processEnvironment.Validate()).Concat(mcp.Validate()).Concat(scheduling.Validate()).ToList();
if (errors.Count > 0)
{
foreach (var error in errors) Console.Error.WriteLine(error);
@@ -26,6 +45,16 @@ if (errors.Count > 0)
}
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(mcp);
builder.Services.AddSingleton(scheduling);
builder.Services.AddSingleton(options.Speech);
if (options.Speech.IsEnabled) // no key, no ISpeechToText: voice notes get the setup notice
// InboundMedia (a singleton) keeps this typed client for the process lifetime, so the factory never rotates its
// handler: let the handler recycle its pooled connections instead, so DNS changes are still picked up.
builder.Services.AddHttpClient<ISpeechToText, OpenAiSpeechToText>(client => client.Timeout = TimeSpan.FromMinutes(2))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
.SetHandlerLifetime(Timeout.InfiniteTimeSpan);
if (calendar is not null) builder.Services.AddSingleton(calendar); // ChatRegistry and BotSetupService take it optionally
builder.Services.AddSingleton<OwnerLoginChannel>();
// The extension is constructed per session through ActivatorUtilities, which injects this. LoginUi resolves
// OwnerLoginChannel lazily (at call time, not here) to avoid a construction cycle: ChatRegistry depends on the
@@ -36,26 +65,52 @@ builder.Services.AddSingleton(sp => new ClaudeCodeProviderOptions
});
builder.Services.AddAgenticCore(o =>
{
o.AgentDir = agentDir;
o.DefaultModel = options.Model;
o.ProcessEnvironment = processEnvironment;
o.DefaultTimeZone = options.ConfiguredTimeZone();
o.AddExtension<ClaudeCodeProviderExtension>();
o.AddExtension<McpExtension>();
});
builder.Services.AddSingleton<ITelegramBotClient>(_ => new TelegramBotClient(options.BotToken));
builder.Services.AddSingleton(new ReconnectPolicy(options.ReconnectInitialDelay, options.ReconnectMaxDelay));
builder.Services.AddSingleton<TelegramSender>();
builder.Services.TryAddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ChatRegistry>();
builder.Services.AddSingleton(sp => new ChatSettingsStore(
ChatSettingsStore.DefaultPath(agentDir),
options.ConfiguredTimeZone() ?? TimeZoneInfo.Local,
sp.GetRequiredService<ILogger<ChatSettingsStore>>(),
sp.GetRequiredService<TimeProvider>()));
// Finds a chat's live session lazily (at call time), so ChatRegistry <-> ChatTimeZoneService is no construction cycle.
builder.Services.AddSingleton(sp => new ChatTimeZoneService(
sp.GetRequiredService<ChatSettingsStore>(),
chatId => sp.GetRequiredService<ChatRegistry>().Find(chatId)?.Session,
sp.GetRequiredService<TimeProvider>(),
sp.GetService<JobScheduler>()));
builder.Services.AddSingleton<ChatCommands>();
builder.Services.AddSingleton<InboundMedia>();
builder.Services.AddSingleton<InboundComposer>();
builder.Services.AddSingleton<IUpdateRouter, UpdateRouter>();
builder.Services.AddSingleton<PollingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PollingService>());
if (scheduling.Enabled) SchedulerSetup.Register(builder.Services); // the hosted service starts after PollingService
builder.Services.AddHostedService<BotSetupService>();
using var host = builder.Build();
var logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Volcanic.AgenticCore.Telegram");
// Before polling and the first claude process; the spawn policy's own call later is a no-op.
HostProcessHardening.Apply(processEnvironment, logger);
logger.LogInformation("{ProcessEnvironment}", TelegramProcessEnvironment.Describe(processEnvironment));
logger.LogInformation("{Mcp}", mcp.DescribePlaywright());
logger.LogInformation("{Speech}", options.Speech.Describe());
if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
else
logger.LogWarning("Claude Code (claude) was not found on PATH; requests will fail until it is installed and logged in. Send /claude-code-doctor to the bot for details.");
logger.LogInformation("Scheduler: {State}", scheduling.Enabled ? scheduling.StorePath : "off");
logger.LogInformation("Default time zone: {Zone}", TimeZoneIds.Name(options.ConfiguredTimeZone() ?? TimeZoneInfo.Local));
logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot);
var polling = host.Services.GetRequiredService<PollingService>();

View File

@@ -15,6 +15,10 @@ namespace Volcanic.AgenticCore.Telegram.Rendering;
/// rendered by a single task. Each assistant message is sent once, complete, when it ends (split into several Telegram
/// messages past the size limit); "typing…" repeats while the agent runs.
/// <para>
/// Host work (file uploads, notices) runs on the same loop through <see cref="RunInOrderAsync{T}"/>, so it stays in
/// order with the rendered output queued before it.
/// </para>
/// <para>
/// While a run is active one status message ("⚙️ Working...") lists the last <see cref="StatusToolCount"/> tool calls
/// with their state, edited at most every <see cref="TelegramOptions.EditInterval"/>. When something was sent below it,
/// the next tool update moves it back to the bottom; it is deleted when the run settles.
@@ -30,11 +34,15 @@ public sealed class ChatRenderer : IAsyncDisposable
private readonly TelegramOptions _options;
private readonly ILogger _logger;
private readonly TimeProvider _time;
private readonly Channel<AgentSessionEvent> _events = Channel.CreateUnbounded<AgentSessionEvent>(new UnboundedChannelOptions { SingleReader = true });
private abstract record RenderItem;
private sealed record EventItem(AgentSessionEvent Event) : RenderItem;
private sealed record WorkItem(Func<CancellationToken, Task> Run, Action Cancel) : RenderItem;
private readonly Channel<RenderItem> _items = Channel.CreateUnbounded<RenderItem>(new UnboundedChannelOptions { SingleReader = true });
private readonly CancellationTokenSource _stop = new();
private readonly Task _loop;
private static readonly TimeSpan[] ReplyRetryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
/// <summary>Delays before resending a reply chunk that failed (1 s, 3 s). Settable for tests.</summary>
internal IReadOnlyList<TimeSpan> ReplyRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
private DateTimeOffset _lastFlush = DateTimeOffset.MinValue;
private CancellationTokenSource? _typing;
@@ -63,12 +71,63 @@ public sealed class ChatRenderer : IAsyncDisposable
}
/// <summary>Queue an event; safe to call from any thread (this is the session subscription).</summary>
public void Post(AgentSessionEvent e)
public void Post(AgentSessionEvent e) => Enqueue(new EventItem(e));
private bool Enqueue(RenderItem item)
{
Interlocked.Increment(ref _pending);
if (!_events.Writer.TryWrite(e)) Interlocked.Decrement(ref _pending);
if (_items.Writer.TryWrite(item)) return true;
Interlocked.Decrement(ref _pending);
return false;
}
/// <summary>
/// Run host work (an upload, a notice) on the render loop, after every event queued before it, so it stays in order
/// with rendered output. Afterwards the status message counts as buried. Cancelled when the renderer is disposed first.
/// <paramref name="cancellationToken"/> cancels the returned task at once, even while the work is still queued behind
/// a slow upload; work that hasn't started by then is skipped, and running work sees it through its token.
/// </summary>
public Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(work);
if (cancellationToken.IsCancellationRequested) return Task.FromCanceled<T>(cancellationToken);
var done = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var registration = cancellationToken.Register(() => done.TrySetCanceled(cancellationToken));
var item = new WorkItem(async ct =>
{
try
{
if (done.Task.IsCompleted) return; // cancelled while queued
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
done.TrySetResult(await work(linked.Token).ConfigureAwait(false));
}
catch (OperationCanceledException) { done.TrySetCanceled(); }
catch (Exception ex) { done.TrySetException(ex); }
finally { await registration.DisposeAsync().ConfigureAwait(false); }
}, () =>
{
registration.Dispose();
done.TrySetCanceled();
});
if (!Enqueue(item))
{
registration.Dispose();
done.TrySetCanceled();
}
return done.Task;
}
/// <summary>Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender.</summary>
public Task<int?> SayAsync(string text, CancellationToken cancellationToken = default) =>
RunInOrderAsync(ct => _sender.SendTextAsync(_chatId, text, cancellationToken: ct), cancellationToken);
/// <summary>
/// A Markdown reply rendered like an assistant message (HTML, split past 4096, each chunk retried), in order with
/// rendered output. Returns the first message id and whether every chunk was delivered.
/// </summary>
internal Task<(int? FirstId, bool Complete)> ReplyInOrderAsync(string markdown, CancellationToken cancellationToken) =>
RunInOrderAsync(ct => SendReplyAsync(markdown, ct), cancellationToken);
/// <summary>Completes once every queued event is rendered. For tests.</summary>
internal async Task DrainAsync()
{
@@ -77,7 +136,7 @@ public sealed class ChatRenderer : IAsyncDisposable
private async Task RunAsync()
{
var reader = _events.Reader;
var reader = _items.Reader;
var ct = _stop.Token;
try
{
@@ -107,15 +166,27 @@ public sealed class ChatRenderer : IAsyncDisposable
break;
}
while (reader.TryRead(out var e))
while (reader.TryRead(out var item))
{
try
{
await RenderAsync(e, ct).ConfigureAwait(false);
switch (item)
{
case EventItem { Event: var e }:
await RenderAsync(e, ct).ConfigureAwait(false);
break;
case WorkItem work when ct.IsCancellationRequested: // disposal gave up waiting
work.Cancel();
break;
case WorkItem work:
await work.Run(ct).ConfigureAwait(false);
BuryStatus();
break;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Rendering {Event} for chat {ChatId} failed", e.GetType().Name, _chatId);
_logger.LogWarning(ex, "Rendering {Item} for chat {ChatId} failed", item is EventItem ev ? ev.Event.GetType().Name : "work", _chatId);
}
finally
{
@@ -131,6 +202,11 @@ public sealed class ChatRenderer : IAsyncDisposable
finally
{
StopTyping();
while (_items.Reader.TryRead(out var left))
{
if (left is WorkItem work) work.Cancel();
Interlocked.Decrement(ref _pending);
}
}
}
@@ -153,7 +229,7 @@ public sealed class ChatRenderer : IAsyncDisposable
break;
case MessageEnd { Message: AssistantMessage message }:
await SendReplyAsync(message.Text, ct).ConfigureAwait(false);
_ = await SendReplyAsync(message.Text, ct).ConfigureAwait(false);
if (message.StopReason == StopReason.Error)
await SendLineAsync($"❌ {message.ErrorMessage ?? "The model request failed."}", ct).ConfigureAwait(false);
else if (message.StopReason == StopReason.Aborted)
@@ -204,10 +280,15 @@ public sealed class ChatRenderer : IAsyncDisposable
if (_statusDirty) await FlushStatusAsync(ct).ConfigureAwait(false);
}
/// <summary>Send a finished assistant message. A chunk that fails to send is retried a couple of times.</summary>
private async Task SendReplyAsync(string text, CancellationToken ct)
/// <summary>
/// Send a finished assistant message. A chunk that fails to send is retried a couple of times. Returns the first
/// message id and whether every chunk was delivered.
/// </summary>
private async Task<(int? FirstId, bool Complete)> SendReplyAsync(string text, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(text)) return;
if (string.IsNullOrWhiteSpace(text)) return (null, true);
int? first = null;
var complete = true;
foreach (var chunk in TelegramFormatter.Split(text))
{
var html = TelegramFormatter.ToHtml(chunk);
@@ -218,9 +299,18 @@ public sealed class ChatRenderer : IAsyncDisposable
await Task.Delay(delay, ct).ConfigureAwait(false);
sent = await _sender.SendHtmlAsync(_chatId, html, chunk, cancellationToken: ct).ConfigureAwait(false);
}
if (sent is null) _logger.LogError("Could not deliver part of a reply to chat {ChatId}", _chatId);
else BuryStatus();
if (sent is null)
{
_logger.LogError("Could not deliver part of a reply to chat {ChatId}", _chatId);
complete = false;
}
else
{
first ??= sent;
BuryStatus();
}
}
return (first, complete);
}
private async Task SendLineAsync(string text, CancellationToken ct)
@@ -339,7 +429,7 @@ public sealed class ChatRenderer : IAsyncDisposable
public async ValueTask DisposeAsync()
{
_events.Writer.TryComplete();
_items.Writer.TryComplete();
try
{
await _loop.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);

View File

@@ -0,0 +1,32 @@
using System.Globalization;
namespace Volcanic.AgenticCore.Telegram.Rendering;
/// <summary>Bot API size limits (cloud Bot API server) and size text for users and the model.</summary>
public static class TelegramLimits
{
private const long MiB = 1024 * 1024;
/// <summary>Bots can download files up to 20 MB.</summary>
public const long MaxDownloadBytes = 20 * MiB;
/// <summary>Largest photo a bot can upload with <c>sendPhoto</c>.</summary>
public const long MaxPhotoBytes = 10 * MiB;
/// <summary>Largest file a bot can upload with <c>sendDocument</c>.</summary>
public const long MaxDocumentBytes = 50 * MiB;
/// <summary>Longest photo or document caption, in characters.</summary>
public const int MaxCaptionLength = 1024;
/// <summary>Megabytes with one decimal, e.g. <c>21.0</c>.</summary>
public static string Megabytes(long bytes) => (bytes / (double)MiB).ToString("0.0", CultureInfo.InvariantCulture);
/// <summary><c>5 bytes</c>, <c>1.5 KB</c>, <c>1.2 MB</c>.</summary>
public static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes.ToString(CultureInfo.InvariantCulture)} bytes",
< MiB => $"{(bytes / 1024.0).ToString("0.0", CultureInfo.InvariantCulture)} KB",
_ => $"{Megabytes(bytes)} MB",
};
}

Some files were not shown because too many files have changed in this diff Show More