diff --git a/CHANGELOG.md b/CHANGELOG.md index a749342..ee40227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Approval mode of automations in the list:** *Automations* shows the approval mode of each + automation as a selection; the change is written into the job file and applies from the next + run. "Allow everything" lets nightly runs work without waiting for approvals (they used to + wait for an answer via Telegram until the timeout). +- **Telegram per chat:** *⋯ → Send questions to Telegram* (shown with a paired bot) decides + whether approvals and questions of that chat and its subagents are sent to Telegram. New chats + from the web UI and automation runs start with it switched off (they stay in the web UI); + chats started from Telegram and existing chats keep reporting. The chat currently open in + Telegram always reports. + ## [v202611-00011] – 2026-11-09 ### Added diff --git a/core/src/types.rs b/core/src/types.rs index bc42d6a..8be3794 100644 --- a/core/src/types.rs +++ b/core/src/types.rs @@ -183,6 +183,10 @@ pub struct Session { /// Instructions the user set for this chat; added to the system prompt of every turn. #[serde(default, skip_serializing_if = "String::is_empty")] pub instructions: String, + /// Approvals and questions of this chat (and its subagents) are not sent to Telegram, + /// unless it is the chat currently open in Telegram. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub telegram_quiet: bool, } impl Session { diff --git a/server/src/api.rs b/server/src/api.rs index 1770efe..66237f4 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -620,6 +620,8 @@ async fn create_session(Extension(user): Extension, State(app): State, /// Instructions for this chat ("" clears them). instructions: Option, + /// Approvals and questions of this chat are not sent to Telegram. + telegram_quiet: Option, } async fn update_session(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { @@ -879,6 +883,9 @@ async fn update_session(Extension(user): Extension, State(app): State, State(app): State, + /// Written into the job file's frontmatter. + permission_mode: Option, } async fn update_job(Extension(user): Extension, State(app): State, Path(id): Path, Json(b): Json) -> ApiResult { let job = own_job(&app, &user, &id)?; + if let Some(m) = b.permission_mode.as_deref() { + crate::jobs::set_permission_mode(std::path::Path::new(&job.path), m).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; + app.audit(Some(&user), "job.permission_mode", &job.path, m); + } if let Some(e) = b.enabled { app.store.job_set_enabled(&job.id, e)?; } diff --git a/server/src/jobs.rs b/server/src/jobs.rs index 5a3981e..4f7bd29 100644 --- a/server/src/jobs.rs +++ b/server/src/jobs.rs @@ -165,6 +165,8 @@ pub async fn run_with(app: &Arc, job: &Job, trigger: &str, payload: Option< archived: false, folder: None, instructions: String::new(), + // New chats keep their approvals and questions out of Telegram (⋯ menu turns it on). + telegram_quiet: true, }; let session = app.save_session(session)?; app.store.job_started(&job.id, &session.id, now)?; @@ -284,6 +286,49 @@ pub fn write_file(path: &Path, j: &NewJob) -> Result { } } +/// Set `permission_mode` in the frontmatter of a job file (replaces the line or adds it +/// before the closing `---`); the rest of the file stays as it is. +pub fn set_permission_mode(path: &Path, mode: &str) -> Result { + if !crate::permission::MODES.contains(&mode) { + bail!("invalid permission_mode '{mode}'"); + } + let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?; + let updated = set_frontmatter_key(&text, "permission_mode", mode).ok_or_else(|| anyhow!("{} has no frontmatter", path.display()))?; + std::fs::write(path, &updated)?; + match parse(path) { + Ok(spec) => Ok(spec), + Err(e) => { + let _ = std::fs::write(path, &text); + Err(e) + } + } +} + +fn set_frontmatter_key(text: &str, key: &str, value: &str) -> Option { + let bom = if text.starts_with('\u{feff}') { "\u{feff}" } else { "" }; + let t = &text[bom.len()..]; + let rest = t.strip_prefix("---")?; + let end = rest.find("\n---")?; + let (head, tail) = (&rest[..end], &rest[end..]); + let prefix = format!("{key}:"); + let mut found = false; + let mut lines: Vec = head + .split('\n') + .map(|l| { + if !found && l.trim_start().starts_with(&prefix) { + found = true; + format!("{key}: {value}") + } else { + l.to_string() + } + }) + .collect(); + if !found { + lines.push(format!("{key}: {value}")); + } + Some(format!("{bom}---{}{tail}", lines.join("\n"))) +} + /// Free file name for a new job in `/.codecraft/jobs/`. pub fn new_job_path(project: &Path, name: &str) -> PathBuf { let dir = project.join(".codecraft/jobs"); @@ -446,6 +491,12 @@ mod tests { let s = parse(&p).unwrap(); assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto")); assert_eq!(job_cwd(&p, &s), dir); + // Added when missing, replaced when present; comments and the prompt stay. + assert_eq!(set_permission_mode(&p, "bypass").unwrap().permission_mode, "bypass"); + std::fs::write(&p, "---\nname: N\npermission_mode: auto # comment\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n").unwrap(); + assert_eq!(set_permission_mode(&p, "accept_edits").unwrap().permission_mode, "accept_edits"); + assert_eq!(std::fs::read_to_string(&p).unwrap(), "---\nname: N\npermission_mode: accept_edits\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n"); + assert!(set_permission_mode(&p, "yolo").is_err()); std::fs::remove_dir_all(&dir).unwrap(); } } diff --git a/server/src/runner.rs b/server/src/runner.rs index fdc4e23..1604cdf 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -705,6 +705,7 @@ fn create_child(app: &Arc, ctx: &ToolCtx, agent_name: &str, description: &s archived: false, folder: None, instructions: String::new(), + telegram_quiet: false, }; let child = app.save_session(child)?; let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); diff --git a/server/src/telegram.rs b/server/src/telegram.rs index 4f7d289..886d412 100644 --- a/server/src/telegram.rs +++ b/server/src/telegram.rs @@ -538,6 +538,8 @@ async fn new_session(app: &Arc, u: &User, bot: &Bot, chat: i64, arg: &str, archived: false, folder: None, instructions: String::new(), + // Started from Telegram: keeps reporting there after switching to another chat. + telegram_quiet: false, })?; update(app, &u.id, |st| st.session = Some(s.id.clone())); bot.send(chat, &format!("{} {} · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?; @@ -869,6 +871,17 @@ async fn listen(app: Arc) { let Some(chat) = st.chat_id else { continue }; let de = german(&app, &owner); let is_current = st.session.as_deref() == Some(session_id.as_str()); + // Chats with "not to Telegram": their approvals and questions stay in the web UI + // (subagents follow their top-level chat; the chat open in Telegram always reports). + if !is_current && matches!(ev, Event::PermissionRequest { .. } | Event::QuestionRequest { .. }) { + let root = app.root_session_id(&session_id); + let quiet = if st.session.as_deref() == Some(root.as_str()) { + false + } else if root == session_id { session.telegram_quiet } else { app.store.get_session(&root).ok().flatten().is_some_and(|r| r.telegram_quiet) }; + if quiet { + continue; + } + } if let Err(e) = on_event(&app, &bot, chat, de, &session, is_current, &ev, &mut progress).await { tracing::debug!("telegram send: {e:#}"); } diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 29c832e..ad49a5a 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -3,7 +3,7 @@ import { Icon } from "./Icon"; import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { del, get, patch, post } from "../api"; -import { useModels } from "../hooks"; +import { useFetch, useModels } from "../hooks"; import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n"; import { navigate } from "../router"; import { dismissError, loadSession, loadSessions, useStore } from "../store"; @@ -152,6 +152,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string const isAdmin = useStore((s) => s.me?.role === "admin"); const tabs = useChatTabs(sessionId, session?.cwd ?? ""); const [sharing, setSharing] = useState(false); + // The Telegram switch in the ⋯ menu only matters with a paired bot. + const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false; const [editingInstructions, setEditingInstructions] = useState(false); const models = useModels(); const [loadError, setLoadError] = useState(); @@ -352,6 +354,20 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string > {t("instructions.title")} + {telegramPaired && !session.parent_id && ( + { + e.preventDefault(); + void update({ telegram_quiet: !session.telegram_quiet }); + }} + > + {t("chat.telegram")} + + )} {t("chat.exportMd")} diff --git a/web/src/components/Jobs.tsx b/web/src/components/Jobs.tsx index 54e37f4..95ef04e 100644 --- a/web/src/components/Jobs.tsx +++ b/web/src/components/Jobs.tsx @@ -107,7 +107,22 @@ export function Jobs({ onMenu }: { onMenu: () => void }) { {t("jobs.mode")} - {j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min + {j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · max {j.spec?.timeout_minutes} min + + {t("jobs.permissionMode")} + + )} diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 9687624..3dba600 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -593,6 +593,10 @@ export const de: Messages = { "chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert", "chat.compactingManual": "Unterhaltung wird komprimiert", "chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter", + "chat.telegram": "Fragen an Telegram senden", + "chat.telegramHint": "Freigaben und Rückfragen dieses Chats auch per Telegram schicken (der gerade in Telegram geöffnete Chat meldet sich immer)", + "jobs.permissionMode": "Freigabemodus", + "jobs.permissionModeHint": "Gilt ab dem nächsten Lauf (wird in die Datei geschrieben). „Alles erlauben“: der Lauf fragt nie nach und wartet nicht auf Freigaben.", "instructions.title": "Anweisungen für diesen Chat", "instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.", "instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 2c4b7c9..ad77551 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -592,6 +592,10 @@ export const en = { "chat.compactingAuto": "Context nearly full – compacting the conversation automatically", "chat.compactingManual": "Compacting the conversation", "chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards", + "chat.telegram": "Send questions to Telegram", + "chat.telegramHint": "Also send approvals and questions of this chat via Telegram (the chat currently open in Telegram always reports)", + "jobs.permissionMode": "Approval mode", + "jobs.permissionModeHint": "Applies from the next run (written to the file). “Allow everything”: the run never asks and never waits for approval.", "instructions.title": "Instructions for this chat", "instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.", "instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.", diff --git a/web/src/styles.css b/web/src/styles.css index e94d611..25cff2b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -3160,6 +3160,11 @@ button.danger-solid { .job-grid > span:nth-child(odd) { color: var(--fg3); } +.job-grid select { + max-width: 100%; + font-size: 13px; + padding: 2px 6px; +} .job-prompt { margin-top: 10px; } diff --git a/web/src/types.ts b/web/src/types.ts index b4b3297..a45db36 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -74,6 +74,8 @@ export interface Session { folder?: string | null; /** Instructions for this chat (added to the system prompt). */ instructions?: string; + /** Approvals and questions of this chat are not sent to Telegram. */ + telegram_quiet?: boolean; } export interface QuestionOption { diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 3a35f0d..679dc74 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["Automations: set the approval mode right in the list – “Allow everything” runs unattended without asking", "⋯ → Send questions to Telegram: per chat, off for new chats"], + de: ["Automatisierungen: Freigabemodus direkt in der Liste wählen – „Alles erlauben“ läuft nachts ohne Rückfrage", "⋯ → Fragen an Telegram senden: pro Chat, bei neuen Chats aus"], + }, "202611-00011": { en: ["The task list of the model can be folded (click on \"Tasks\")", "Better layout on square and short screens (e.g. Clicks Communicator, landscape)"], de: ["Die Aufgabenliste des Modells lässt sich zuklappen (Klick auf „Aufgaben“)", "Besseres Layout auf quadratischen und niedrigen Displays (z. B. Clicks Communicator, Querformat)"],