From 126d3afff1b83a5adf5de44f01871a13b46b03ea Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 10:40:43 +0200 Subject: [PATCH] docs(scheduler): scheduling, per-chat time zones and the session zone --- CLAUDE.md | 13 +++- docs/architecture.md | 23 ++++++-- ...6-09-26-process-environment-hook-design.md | 3 +- .../specs/2026-09-26-scheduler-design.md | 4 +- ...6-09-26-telegram-message-context-design.md | 8 +-- docs/telegram-bot.md | 59 +++++++++++++++++-- 6 files changed, 92 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c898718..4c9eabd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,8 @@ Read `docs/architecture.md` first. Key points: - **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`: they are mutable event args (`Block`, `Cancel`, `MarkHandled`, set `Text`/`Message`/...), run sequentially in load order, and some short-circuit. @@ -80,9 +82,11 @@ Read `docs/architecture.md` first. Key points: 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`); and the `GoogleCalendar` feature extension (`calendar` tool, +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 -`/google-calendar/`; `docs/google-calendar.md`). The Claude Code provider is the most intricate piece +`/google-calendar/`; `docs/google-calendar.md`); and the `Scheduler` feature extension (a process-wide +`JobScheduler` on `TimeProvider`, `jobs.json` under `/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. @@ -108,6 +112,11 @@ with a local-time header. Files, audio and voice are saved to `/inbox (`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` (`/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 diff --git a/docs/architecture.md b/docs/architecture.md index 79d0d8c..b9308a1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ ``` src/Volcanic.AgenticCore.Abstractions contracts (no runtime code except helpers) src/Volcanic.AgenticCore runtime, organised as vertical slices -extensions/* provider and feature extensions (MCP client, Google Calendar), 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 ``` @@ -21,6 +21,10 @@ 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 `/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 The runtime is organised by feature, not by layer. Each folder under `src/Volcanic.AgenticCore/Features` @@ -55,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)`: @@ -113,9 +126,9 @@ Two independent event systems exist: ## 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, with -its spec, scheduler scripts) gets its command, cwd and environment from one `IProcessSpawnPolicy`, configured through -`AgenticCoreOptions.ProcessEnvironment`: +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) @@ -136,7 +149,7 @@ dumpable. 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. None of this is a sandbox: the agent still runs as the host's OS user and can read +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 diff --git a/docs/superpowers/specs/2026-09-26-process-environment-hook-design.md b/docs/superpowers/specs/2026-09-26-process-environment-hook-design.md index b520045..d5e69a3 100644 --- a/docs/superpowers/specs/2026-09-26-process-environment-hook-design.md +++ b/docs/superpowers/specs/2026-09-26-process-environment-hook-design.md @@ -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: implemented (the MCP spawners in §5 came with 2026-09-26-mcp-client-playwright-design.md; the scheduler `ScriptRunner` comes with its own spec) +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). @@ -231,6 +231,7 @@ Process-wide spawners without a session (the scheduler, MCP catalogue discovery) | 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: `, a watcher is `Failed(null, "…")`, an MCP call or discovery fails with the diff --git a/docs/superpowers/specs/2026-09-26-scheduler-design.md b/docs/superpowers/specs/2026-09-26-scheduler-design.md index b0f01d4..240fb18 100644 --- a/docs/superpowers/specs/2026-09-26-scheduler-design.md +++ b/docs/superpowers/specs/2026-09-26-scheduler-design.md @@ -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` @@ -346,6 +346,8 @@ 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:** ()`. +- `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) diff --git a/docs/superpowers/specs/2026-09-26-telegram-message-context-design.md b/docs/superpowers/specs/2026-09-26-telegram-message-context-design.md index 56c2a3f..dd6f6b6 100644 --- a/docs/superpowers/specs/2026-09-26-telegram-message-context-design.md +++ b/docs/superpowers/specs/2026-09-26-telegram-message-context-design.md @@ -4,9 +4,8 @@ Date: 2026-09-26 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 the session's zone (`AgentSession.TimeZone`, which falls back to `TimeZoneInfo.Local`) -through `ChatRegistry.TimeZoneFor` 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 @@ -224,8 +223,7 @@ on `/new`, `/stop` and a `/resume` that names exactly one conversation, before i `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 - (`ChatRegistry.TimeZoneFor(chatId)`, which returns the chat session's `TimeZone` until the scheduler spec's - `ChatSettingsStore` exists and then delegates to `ChatSettingsStore.TimeZoneFor(chatId)`; read at flush time, so + (`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)`. diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 64bac0a..a12f017 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -23,7 +23,7 @@ Open the bot in Telegram and say hello. ## Configuration Environment variables, or the `Telegram` section of `appsettings.json` (`Telegram__Name` in the environment; the Google -settings use the `GoogleCalendar` section): +settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section): | Setting | Default | Notes | |---|---|---| @@ -34,6 +34,12 @@ settings use the `GoogleCalendar` section): | `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` | `/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 | @@ -49,7 +55,8 @@ settings use the `GoogleCalendar` section): | `Mcp:Servers:` | — | Other stdio MCP servers ([mcp.md](mcp.md#configuration)) | Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-/`; after a restart each chat resumes -its most recent conversation. +its most recent conversation. Each chat's time zone is kept in `/telegram/chat-settings.json`, so it +survives `/new` and restarts. ## Using it @@ -73,7 +80,7 @@ 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 (for now, the zone of the machine running the bot): +- **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 @@ -88,10 +95,13 @@ started a chat with the bot, Telegram refuses the message; the bot logs that and |---|---| | `/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 ` | 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 ` | 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 | @@ -135,6 +145,44 @@ as photos (up to 10 MB), other files as documents (up to 50 MB), with an optiona 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 @@ -148,6 +196,9 @@ The agent has the built-in tools, including `bash`, and runs them as the bot's O 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. +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.