Compare commits

...
Author SHA1 Message Date
Richard Zink 538aac9122 Release v202611-00013 2026-11-10 10:05:21 +01:00
Richard ZinkandClaude Opus 5.5 05c215f685 Automation editor with graphical schedule and assistant; movable chat list groups
Automations
- Job files hold only the instructions; name, schedule, mode, model, approval
  mode and time limit are settings in the database (jobs.settings). Existing
  files are adopted on start-up (frontmatter into the database and removed from
  the file); plain Markdown files registered later get default settings and stay
  switched off until scheduled.
- New automation: name, project and a starting point (blank example structure or
  a template) create .codecraft/jobs/<name>.md and open the editor: settings with
  a graphical cron builder (next runs in server time via /jobs/schedule), the
  instructions in the Markdown editor and an assistant chat
  (POST /jobs/{id}/assistant) whose model edits the file; the editor reloads it
  after each turn and warns about conflicts with unsaved edits.
- The schedule tool writes prompt-only files with settings.

Chat list
- Folders and projects keep a user-defined order and can be pinned (drag and
  drop, or the group's menu), stored per user via /account/sidebar. A new
  message moves the chat to the top of its group, no longer the project itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:05:15 +01:00
Richard ZinkandClaude Opus 5.5 872ebb94dd deploy.sh: image check no longer fails at random (grep -q + pipefail)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:44:51 +01:00
Richard Zink b302fa0b17 Release v202611-00012 2026-11-10 08:24:40 +01:00
Richard ZinkandClaude Opus 5.5 bd490efda5 Approval mode of automations in the list; Telegram switch per chat
- Automations: the approval mode is a selection in the list; PATCH /jobs/{id}
  writes permission_mode into the job file's frontmatter (rest of the file
  unchanged, invalid modes rejected). "Allow everything" lets nightly runs work
  without waiting for approvals via Telegram until the timeout.
- Session.telegram_quiet: approvals and questions of a chat (and its subagents)
  are not sent to Telegram; the chat open in Telegram always reports. Toggle in
  the chat's ⋯ menu when a bot is paired. New chats from the web UI and
  automation runs start quiet; chats started from Telegram and existing chats
  keep reporting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:24:34 +01:00
24 changed files with 1742 additions and 289 deletions

No files matched your search

+36
View File
@@ -3,6 +3,42 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
## [v202611-00013] – 2026-11-10
### Added
- **Automation editor:** *New automation* asks for name, project and a starting point (blank
example structure or a template), creates `.codecraft/jobs/<name>.md` and opens an editor:
the instructions in a Markdown editor, schedule and settings next to them, and an assistant
chat in which a model works out the instructions with you and edits the file (reloaded after
each answer). New automations start switched off.
- **Graphical schedule:** every few minutes, hourly, daily, on weekdays, monthly or a cron
expression, with the next runs in server time.
- **Chat list:** folders and projects can be dragged into any order or moved and pinned to the
top via their menu; the order is kept per user on all devices. A new message moves the chat
to the top of its folder or project, no longer the whole project.
### Changed
- **Automation files hold only the instructions;** name, schedule, mode, model, approval mode
and time limit are settings of the automation. Existing job files are taken over on the first
start (settings into the database, the frontmatter removed from the file). Registering a
plain Markdown file creates a switched-off automation to be scheduled in the editor.
## [v202611-00012] – 2026-11-10
### 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
+10 -6
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version v202611-00011" src="https://img.shields.io/badge/version-v202611--00011-d69e2e?style=flat-square">
<img alt="Version v202611-00013" src="https://img.shields.io/badge/version-v202611--00013-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
@@ -202,11 +202,15 @@ platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codec
<img src="docs/images/automations.png" alt="Creating an automation from a template: schedule chips, cron expression, mode, permission mode, model and the task for the agent" width="100%">
</p>
An automation is a Markdown file with a cron schedule in its front matter
(`.codecraft/jobs/<name>.md`). Each run is a normal chat session, so you can read and continue
it; if it needs a permission, you get a push notification. Ask an agent in any chat to set one
up, start from a template (dependency check, security review of recent commits, test suite
health, daily summary, TODO report, documentation freshness) or write the file yourself.
An automation is an agent that starts on a schedule. Its instructions are a Markdown file
(`.codecraft/jobs/<name>.md`, only the prompt); name, schedule, mode, model and limits are set
in the UI, with a graphical schedule editor (every few minutes, hourly, daily, on weekdays,
monthly or a cron expression). *New automation* creates the file – blank with an example
structure or from a template (dependency check, security review of recent commits, test suite
health, daily summary, TODO report, documentation freshness) – and opens an editor with an
assistant chat in which a model works out the instructions with you and writes the file. You
can also ask an agent in any chat to set one up. Each run is a normal chat session, so you can
read and continue it.
**Webhooks** start an automation from outside, e.g. on every push: the request is checked with a
shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed to the agent.
+4
View File
@@ -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 {
+3 -1
View File
@@ -73,7 +73,9 @@ case "$(remote 'uname -m')" in
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
esac
if [[ -n "$arch" ]]; then
docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null | grep -q "Platform:.*linux/$arch" \
# Captured first: `inspect | grep -q` fails under pipefail when grep exits early (SIGPIPE).
manifests=$(docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null || true)
grep -q "Platform:.*linux/$arch" <<<"$manifests" \
|| fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first"
fi
echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})"
+172 -11
View File
@@ -137,6 +137,7 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
.route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
@@ -150,7 +151,9 @@ pub fn router(app: AppState) -> Router {
.route("/account/tokens/{id}", delete(auth::delete_api_token))
.route("/jobs", get(list_jobs).post(register_job))
.route("/jobs/create", post(create_job))
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
.route("/jobs/schedule", get(schedule_preview))
.route("/jobs/{id}", get(get_job).patch(update_job).delete(delete_job))
.route("/jobs/{id}/assistant", post(job_assistant))
.route("/jobs/{id}/run", post(run_job))
.route("/jobs/{id}/webhook", post(job_webhook_settings))
.route("/terminals", get(list_terminals).post(open_terminal))
@@ -620,6 +623,8 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
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 s = app.save_session(s)?;
Ok(Json(json!(s)))
@@ -831,6 +836,8 @@ struct UpdateSession {
folder: Option<String>,
/// Instructions for this chat ("" clears them).
instructions: Option<String>,
/// Approvals and questions of this chat are not sent to Telegram.
telegram_quiet: Option<bool>,
}
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
@@ -879,6 +886,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(i) = body.instructions {
s.instructions = i.trim().to_string();
}
if let Some(q) = body.telegram_quiet {
s.telegram_quiet = q;
}
})?;
Ok(Json(json!(s)))
}
@@ -1614,15 +1624,24 @@ async fn list_jobs(Extension(user): Extension<User>, State(app): State<AppState>
Ok(Json(json!(jobs)))
}
async fn get_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
Ok(Json(crate::jobs::describe(&job)))
}
#[derive(Deserialize)]
struct RegisterJob {
path: String,
}
/// Register an existing Markdown file (older files: settings from their frontmatter).
async fn register_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<RegisterJob>) -> ApiResult {
let p = crate::files::checked_path(&app, &user, &b.path, true)?;
crate::jobs::parse(&p).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
if !p.is_file() {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("{} is not a file", p.display())));
}
let job = app.store.job_register(&user.id, &p.display().to_string())?;
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "job.register", &job.path, "");
Ok(Json(crate::jobs::describe(&job)))
}
@@ -1631,22 +1650,38 @@ async fn register_job(Extension(user): Extension<User>, State(app): State<AppSta
struct CreateJob {
/// Project folder; the file goes to <cwd>/.codecraft/jobs/<name>.md.
cwd: String,
#[serde(flatten)]
job: crate::jobs::NewJob,
name: String,
/// Initial content of the instructions file (example or template).
prompt: String,
#[serde(default)]
schedule: Option<String>,
#[serde(default)]
agent: Option<String>,
#[serde(default)]
permission_mode: Option<String>,
/// Default false: the user switches it on once prompt and schedule are ready.
#[serde(default)]
enabled: bool,
}
async fn create_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<CreateJob>) -> ApiResult {
let dir = crate::files::checked_path(&app, &user, &b.cwd, true)?;
if b.job.name.trim().is_empty() || b.job.prompt.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "name and prompt are required".into()));
if b.prompt.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST, "the instructions are empty".into()));
}
if !crate::permission::MODES.contains(&b.job.permission_mode.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
let mut settings = crate::jobs::JobSettings::new(&b.name);
if let Some(x) = b.schedule {
settings.schedule = x;
}
if let Some(x) = b.agent {
settings.agent = x;
}
if let Some(x) = b.permission_mode {
settings.permission_mode = x;
}
// Inside the checked folder: the file name is a slug of the name.
let path = crate::jobs::new_job_path(&dir, &b.job.name);
crate::jobs::write_file(&path, &b.job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let job = app.store.job_register(&user.id, &path.display().to_string())?;
let path = crate::jobs::new_job_path(&dir, &b.name);
let job = crate::jobs::create(&app.store, &user.id, &path, settings, &b.prompt, b.enabled).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "job.create", &job.path, "");
Ok(Json(crate::jobs::describe(&job)))
}
@@ -1654,10 +1689,46 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
#[derive(Deserialize)]
struct UpdateJob {
enabled: Option<bool>,
name: Option<String>,
schedule: Option<String>,
agent: Option<String>,
/// "" = the server's default model.
model: Option<String>,
permission_mode: Option<String>,
timeout_minutes: Option<u64>,
}
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some();
if changes {
let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
if let Some(x) = b.name {
st.name = x;
}
if let Some(x) = b.schedule {
st.schedule = x;
}
if let Some(x) = b.agent {
st.agent = x;
}
if let Some(x) = b.model {
if !x.is_empty() {
app.registry().resolve(&x)?;
}
st.model = Some(x);
}
if let Some(x) = b.permission_mode {
st.permission_mode = x;
}
if let Some(x) = b.timeout_minutes {
st.timeout_minutes = x;
}
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.store.job_set_settings(&job.id, &st)?;
app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
}
if let Some(e) = b.enabled {
app.store.job_set_enabled(&job.id, e)?;
}
@@ -1665,6 +1736,96 @@ async fn update_job(Extension(user): Extension<User>, State(app): State<AppState
Ok(Json(crate::jobs::describe(&job)))
}
/// Chat in which the user works out the instructions with a model (created once, reused
/// while it exists). The model edits the job file directly.
async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let mut st = job.settings.clone().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "automation without settings".into()))?;
if let Some(sid) = st.assistant_session.as_deref() {
if app.store.get_session(sid)?.is_some_and(|s| s.owner_id.as_deref() == Some(user.id.as_str())) {
return Ok(Json(json!({"session_id": sid})));
}
}
let path = std::path::Path::new(&job.path);
let cwd = crate::jobs::cwd_of(&job.path, st.cwd.as_deref());
let cfg = app.config();
let model = st
.model
.clone()
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
.or_else(|| app.registry().first_model())
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
let now = now_ms();
let instructions = format!(
"You help the user write the instructions of the automation \"{name}\". They live in the Markdown file {file} \
and are executed unattended by an agent (mode {agent}) on a schedule the user sets in the UI. That run starts a \
fresh session and sees only this file – no conversation, nobody to ask.\n\
- Work on that file directly with your edit tools and keep it up to date after every round; the user sees it next to this chat.\n\
- Find out what the automation should achieve, then shape the file into clear, self-contained instructions: \
goal, steps, expected result (e.g. a short report), limits (what must not be done).\n\
- Write only the instructions: no frontmatter, no schedule or settings in the file.\n\
- Do not carry out the automation's task yourself and do not create other files. Use the language the user writes in.",
name = st.name,
file = path.display(),
agent = st.agent,
);
let s = Session {
id: new_id(),
title: format!("{} – {}", st.name, if crate::telegram::german(&app, &user.id) { "Anweisungen" } else { "instructions" }),
cwd: cwd.display().to_string(),
model,
agent: "build".into(),
permission_mode: "auto".into(),
parent_id: None,
owner_id: Some(user.id.clone()),
created_at: now,
updated_at: now,
todos: Value::Null,
last_usage: None,
clean_room: None,
phase: None,
worktree: None,
pinned: false,
archived: false,
folder: None,
instructions,
telegram_quiet: true,
};
let s = app.save_session(s)?;
st.assistant_session = Some(s.id.clone());
app.store.job_set_settings(&job.id, &st)?;
Ok(Json(json!({"session_id": s.id})))
}
#[derive(Deserialize)]
struct ScheduleQ {
expr: String,
}
/// Check a cron expression and list its next runs (for the schedule editor).
async fn schedule_preview(Query(q): Query<ScheduleQ>) -> ApiResult {
let expr = q.expr.trim();
if let Err(e) = crate::jobs::check_schedule(expr) {
return Ok(Json(json!({"ok": false, "error": format!("{e:#}")})));
}
let mut next = Vec::new();
let mut t = now_ms();
for _ in 0..5 {
match crate::jobs::next_run(expr, t) {
Some(n) => {
next.push(n);
t = n + 1000;
}
None => break,
}
}
// Wall-clock times of the server: cron works in its time zone, the browser may be elsewhere.
let local: Vec<String> = next.iter().filter_map(|n| chrono::DateTime::from_timestamp_millis(*n)).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%dT%H:%M").to_string()).collect();
let tz = std::env::var("TZ").ok().filter(|z| !z.is_empty()).unwrap_or_else(|| chrono::Local::now().format("UTC%:z").to_string());
Ok(Json(json!({"ok": true, "next": next, "next_local": local, "tz": tz})))
}
async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
app.store.job_delete(&job.id)?;
+17
View File
@@ -231,6 +231,23 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(b))
}
/// Order and pinning of the groups (folders, projects) in the chat list – per user, so
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let v: Value = app.store.user_setting(&user.id, "sidebar")?;
Ok(Json(if v.is_null() { json!({"order": [], "pinned": []}) } else { v }))
}
pub async fn put_sidebar(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
let ids = |k: &str| b[k].as_array().is_some_and(|a| a.iter().all(|x| x.as_str().is_some_and(|s| s.len() <= 1000)));
if !ids("order") || !ids("pinned") || b.to_string().len() > 100_000 {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid chat list layout".into()));
}
let v = json!({"order": b["order"], "pinned": b["pinned"]});
app.store.set_user_setting(&user.id, "sidebar", &v)?;
Ok(Json(v))
}
/// Personal memory (applies to all projects, web and CLI).
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let m: String = app.store.user_setting(&user.id, "memory")?;
+236 -68
View File
@@ -1,20 +1,23 @@
//! Scheduled agents ("automations"): a Markdown file with a cron schedule in
//! its frontmatter starts an agent session at the given times.
//! Scheduled agents ("automations"). The instructions are a Markdown file (only the
//! prompt, e.g. `<project>/.codecraft/jobs/<name>.md`); name, schedule, mode, model and
//! limits are settings of the automation in the database, edited in the UI.
//!
//! Older job files carried the settings as frontmatter:
//!
//! ```markdown
//! ---
//! name: Nightly dependency check
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
//! agent: build # mode (default build)
//! model: provider/model # optional, default: server default model
//! permission_mode: auto # default auto; "bypass" runs without asking
//! timeout_minutes: 60 # optional, default 60
//! agent: build
//! permission_mode: auto
//! timeout_minutes: 60
//! ---
//! Check for outdated dependencies and open a summary …
//! ```
//!
//! The file is re-read on every run, so edits apply to the next run. Each run
//! is a normal chat session owned by the job's owner.
//! They are adopted once (settings into the database, the frontmatter removed from the
//! file). The file is re-read on every run, so edits apply to the next run. Each run is
//! a normal chat session owned by the job's owner.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
@@ -40,6 +43,70 @@ pub struct Job {
pub created_at: i64,
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
pub webhook: bool,
/// None: not adopted yet (settings still in the file's frontmatter).
#[serde(skip)]
pub settings: Option<JobSettings>,
}
/// Settings of an automation (database; the file holds only the prompt).
#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)]
pub struct JobSettings {
pub name: String,
/// 5-field cron in the server's time zone.
pub schedule: String,
#[serde(default = "default_agent")]
pub agent: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_mode")]
pub permission_mode: String,
#[serde(default = "default_timeout")]
pub timeout_minutes: u64,
/// Working directory (absolute or relative to the file); default: the project of the file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
/// Chat in which the user works out the prompt with a model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>,
}
impl JobSettings {
pub fn new(name: &str) -> Self {
Self {
name: name.trim().to_string(),
schedule: "0 3 * * *".into(),
agent: default_agent(),
model: None,
permission_mode: default_mode(),
timeout_minutes: default_timeout(),
cwd: None,
assistant_session: None,
}
}
pub fn validate(&mut self) -> Result<()> {
self.name = self.name.trim().chars().take(120).collect();
if self.name.is_empty() {
bail!("the automation needs a name");
}
self.schedule = self.schedule.trim().to_string();
check_schedule(&self.schedule)?;
if !crate::permission::MODES.contains(&self.permission_mode.as_str()) {
bail!("invalid permission_mode '{}'", self.permission_mode);
}
self.model = self.model.take().map(|m| m.trim().to_string()).filter(|m| !m.is_empty());
self.timeout_minutes = self.timeout_minutes.clamp(1, 24 * 60);
Ok(())
}
}
/// Valid 5-field cron expression?
pub fn check_schedule(schedule: &str) -> Result<()> {
if schedule.split_whitespace().count() != 5 {
bail!("invalid schedule '{schedule}': expected 5 fields (minute hour day month weekday)");
}
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
Ok(())
}
/// Parsed job file.
@@ -52,15 +119,89 @@ pub struct JobSpec {
pub permission_mode: String,
pub timeout_minutes: u64,
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assistant_session: Option<String>,
pub prompt: String,
}
impl JobSpec {
fn from_settings(s: &JobSettings, prompt: String) -> Self {
Self {
name: s.name.clone(),
schedule: s.schedule.clone(),
agent: s.agent.clone(),
model: s.model.clone(),
permission_mode: s.permission_mode.clone(),
timeout_minutes: s.timeout_minutes,
cwd: s.cwd.clone(),
assistant_session: s.assistant_session.clone(),
prompt,
}
}
fn settings(&self) -> JobSettings {
JobSettings {
name: self.name.clone(),
schedule: self.schedule.clone(),
agent: self.agent.clone(),
model: self.model.clone(),
permission_mode: self.permission_mode.clone(),
timeout_minutes: self.timeout_minutes,
cwd: self.cwd.clone(),
assistant_session: self.assistant_session.clone(),
}
}
}
/// Settings and prompt of a job: settings from the database, the prompt from its file
/// (a leftover frontmatter is ignored); not yet adopted jobs from the frontmatter.
pub fn spec(job: &Job) -> Result<JobSpec> {
let path = Path::new(&job.path);
let Some(settings) = &job.settings else { return parse(path) };
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (_, body) = crate::prompts::frontmatter(&text);
if body.trim().is_empty() {
bail!("{}: the instructions are empty", path.display());
}
Ok(JobSpec::from_settings(settings, body.trim().to_string()))
}
/// Move the frontmatter of an older job file into the database and remove it from the
/// file. Files without frontmatter get default settings (named after the file) and are
/// switched off until the user sets a schedule. Returns the updated job.
pub fn adopt(store: &crate::store::Store, job: Job) -> Result<Job> {
if job.settings.is_some() {
return Ok(job);
}
let path = Path::new(&job.path);
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (meta, body) = crate::prompts::frontmatter(&text);
let settings = if meta.is_empty() {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("automation").replace(['-', '_'], " ");
store.job_set_enabled(&job.id, false)?;
JobSettings::new(&stem)
} else {
let spec = parse(path)?;
// Prompt only from now on.
std::fs::write(path, format!("{}\n", body.trim()))?;
spec.settings()
};
store.job_set_settings(&job.id, &settings)?;
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
}
/// Settings from the frontmatter of an older job file.
pub fn parse(path: &Path) -> Result<JobSpec> {
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let (meta, body) = crate::prompts::frontmatter(&text);
let unquote = |s: &String| s.trim().trim_matches('"').trim_matches('\'').to_string();
// Values may carry a trailing comment (`auto # default`).
let unquote = |s: &String| {
let v = s.trim();
let v = if v.starts_with('"') || v.starts_with('\'') { v } else { v.split(" #").next().unwrap_or(v).trim() };
v.trim_matches('"').trim_matches('\'').to_string()
};
let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"<cron>\"`", path.display()))?;
croner::Cron::from_str(&schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
check_schedule(&schedule)?;
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
bail!("invalid permission_mode '{permission_mode}'");
@@ -76,6 +217,7 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
permission_mode,
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
assistant_session: None,
prompt: body.trim().to_string(),
})
}
@@ -89,7 +231,13 @@ pub fn next_run(schedule: &str, after_ms: i64) -> Option<i64> {
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
if let Some(c) = &spec.cwd {
cwd_of(&path.display().to_string(), spec.cwd.as_deref())
}
/// Working directory for a job file and its optional `cwd` setting.
pub fn cwd_of(file: &str, cwd: Option<&str>) -> PathBuf {
let path = Path::new(file);
if let Some(c) = cwd {
let p = PathBuf::from(c);
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
}
@@ -106,13 +254,19 @@ fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
/// Job with its parsed file and next run, for the UI and the tool.
pub fn describe(job: &Job) -> Value {
let mut v = json!(job);
match parse(Path::new(&job.path)) {
match spec(job) {
Ok(spec) => {
v["spec"] = json!(spec);
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000);
v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None });
}
Err(e) => v["error"] = json!(format!("{e:#}")),
Err(e) => {
v["error"] = json!(format!("{e:#}"));
// The settings stay editable when the file is missing or empty.
if let Some(st) = &job.settings {
v["spec"] = json!(JobSpec::from_settings(st, String::new()));
}
}
}
v
}
@@ -125,7 +279,7 @@ pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
/// Start a run; `payload` (webhook body) is appended to the instructions.
pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
let path = PathBuf::from(&job.path);
let spec = match parse(&path) {
let spec = match spec(job) {
Ok(s) => s,
Err(e) => {
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
@@ -165,6 +319,8 @@ pub async fn run_with(app: &Arc<App>, 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)?;
@@ -202,12 +358,20 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
/// Background scheduler: checks every 30 s which jobs are due.
pub fn spawn_scheduler(app: Arc<App>) {
tokio::spawn(async move {
// Older job files: settings from the frontmatter into the database.
for job in app.store.jobs(None).unwrap_or_default().into_iter().filter(|j| j.settings.is_none()) {
let path = job.path.clone();
match adopt(&app.store, job) {
Ok(_) => tracing::info!("automation {path}: settings moved from the file into the database"),
Err(e) => tracing::warn!("automation {path}: {e:#}"),
}
}
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
let Ok(jobs) = app.store.jobs(None) else { continue };
let now = now_ms();
for job in jobs.into_iter().filter(|j| j.enabled) {
let Ok(spec) = parse(Path::new(&job.path)) else { continue };
let Ok(spec) = spec(&job) else { continue };
// Due if a scheduled time passed since the last run (or since creation).
let base = job.last_run.unwrap_or(job.created_at);
let Some(next) = next_run(&spec.schedule, base) else { continue };
@@ -234,22 +398,6 @@ pub fn spawn_scheduler(app: Arc<App>) {
/// Tool: create, list, run and delete automations.
pub struct Schedule;
/// Fields of a job file written by the agent tool or the "from template" dialog.
#[derive(Debug, serde::Deserialize)]
pub struct NewJob {
pub name: String,
pub schedule: String,
#[serde(default = "default_agent")]
pub agent: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_mode")]
pub permission_mode: String,
#[serde(default = "default_timeout")]
pub timeout_minutes: u64,
pub prompt: String,
}
fn default_agent() -> String {
"build".into()
}
@@ -260,28 +408,20 @@ fn default_timeout() -> u64 {
60
}
/// Write (and validate) a job file.
pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
croner::Cron::from_str(&j.schedule).map_err(|e| anyhow!("invalid schedule '{}': {e}", j.schedule))?;
let q = |s: &str| format!("\"{}\"", s.replace('"', "'").replace('\n', " "));
let plain = |s: &str| s.chars().filter(|c| c.is_alphanumeric() || "-_/.:".contains(*c)).collect::<String>();
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(&j.name), q(&j.schedule), plain(&j.agent));
if let Some(m) = j.model.as_deref().filter(|m| !m.is_empty()) {
fm.push_str(&format!("model: {}\n", plain(m)));
/// Write the instructions file, register it and store the settings.
pub fn create(store: &crate::store::Store, owner: &str, path: &Path, mut settings: JobSettings, prompt: &str, enabled: bool) -> Result<Job> {
settings.validate()?;
if path.exists() {
bail!("{} already exists", path.display());
}
fm.push_str(&format!("permission_mode: {}\n", plain(&j.permission_mode)));
fm.push_str(&format!("timeout_minutes: {}\n---\n", j.timeout_minutes.clamp(1, 24 * 60)));
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, format!("{fm}{}\n", j.prompt.trim()))?;
match parse(path) {
Ok(spec) => Ok(spec),
Err(e) => {
let _ = std::fs::remove_file(path);
Err(e)
}
}
std::fs::write(path, format!("{}\n", prompt.trim()))?;
let job = store.job_register(owner, &path.display().to_string())?;
store.job_set_settings(&job.id, &settings)?;
store.job_set_enabled(&job.id, enabled)?;
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
}
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
@@ -303,10 +443,11 @@ impl Tool for Schedule {
"schedule"
}
fn description(&self) -> String {
"Manage automations: agents that run on a cron schedule from a Markdown job file \
(.codecraft/jobs/<name>.md in the working directory). Actions: \
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the job file and registers it; \
`register` (path) registers an existing job file; `list`; `run` (id) starts a run now; \
"Manage automations: agents that run on a cron schedule. The instructions are a Markdown file \
(.codecraft/jobs/<name>.md in the working directory, only the prompt); name, schedule, mode, model and \
limits are settings of the automation. Actions: \
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the file and registers it; \
`register` (path) registers an existing Markdown file (switched off until a schedule is set in the UI); `list`; `run` (id) starts a run now; \
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
The prompt must be self-contained: the run starts a fresh session without this conversation."
@@ -349,19 +490,15 @@ impl Tool for Schedule {
let prompt = arg_str(&input, "prompt")?;
let rel = format!(".codecraft/jobs/{}.md", slug(name));
let path = ctx.resolve(&rel)?;
write_file(
&path,
&NewJob {
name: name.into(),
schedule: schedule.into(),
agent: input["agent"].as_str().unwrap_or("build").into(),
model: input["model"].as_str().map(String::from),
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
prompt: prompt.into(),
},
)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
let settings = JobSettings {
schedule: schedule.into(),
agent: input["agent"].as_str().unwrap_or("build").into(),
model: input["model"].as_str().map(String::from),
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
..JobSettings::new(name)
};
let job = create(&ctx.app.store, &owner, &path, settings, prompt, true)?;
let d = describe(&job);
Ok(ToolOutput::text(format!(
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
@@ -373,8 +510,10 @@ impl Tool for Schedule {
}
"register" => {
let path = ctx.resolve(arg_str(&input, "path")?)?;
parse(&path)?;
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
if !path.is_file() {
bail!("{} is not a file", path.display());
}
let job = adopt(&ctx.app.store, ctx.app.store.job_register(&owner, &path.display().to_string())?)?;
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
}
"list" => {
@@ -448,6 +587,35 @@ mod tests {
assert_eq!(job_cwd(&p, &s), dir);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn adopts_frontmatter_and_creates_prompt_only_files() {
let dir = std::env::temp_dir().join(format!("cc-job-adopt-{}", std::process::id()));
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
// Older file: settings move into the database, the file keeps only the prompt.
let p = dir.join(".codecraft/jobs/old.md");
std::fs::write(&p, "---\nname: \"Old\"\nschedule: \"0 4 * * *\"\npermission_mode: bypass # comment\n---\nDo old things.\n").unwrap();
let job = adopt(&store, store.job_register("u", &p.display().to_string()).unwrap()).unwrap();
let st = job.settings.clone().unwrap();
assert_eq!((st.name.as_str(), st.schedule.as_str(), st.permission_mode.as_str()), ("Old", "0 4 * * *", "bypass"));
assert_eq!(std::fs::read_to_string(&p).unwrap(), "Do old things.\n");
assert_eq!(spec(&job).unwrap().prompt, "Do old things.");
assert!(job.enabled);
// Plain Markdown: default settings, switched off.
let q = dir.join(".codecraft/jobs/weekly-report.md");
std::fs::write(&q, "Write a report.\n").unwrap();
let job = adopt(&store, store.job_register("u", &q.display().to_string()).unwrap()).unwrap();
assert_eq!(job.settings.as_ref().unwrap().name, "weekly report");
assert!(!job.enabled);
// New automation.
let r = dir.join(".codecraft/jobs/new.md");
assert!(create(&store, "u", &r, JobSettings { schedule: "nope".into(), ..JobSettings::new("N") }, "x", false).is_err());
let job = create(&store, "u", &r, JobSettings::new("New"), "# New\n\nGoal", false).unwrap();
assert_eq!(spec(&job).unwrap().prompt, "# New\n\nGoal");
assert!(check_schedule("0 3 * *").is_err() && check_schedule("*/15 8-18 * * 1-5").is_ok());
std::fs::remove_dir_all(&dir).unwrap();
}
}
// --- incoming webhooks -------------------------------------------------------------
+1
View File
@@ -705,6 +705,7 @@ fn create_child(app: &Arc<App>, 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 }]);
+10 -3
View File
@@ -169,6 +169,7 @@ impl Store {
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings TEXT"),
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
] {
let exists: bool = conn
@@ -1135,13 +1136,14 @@ impl Store {
last_status: r.get(6)?,
created_at: r.get(7)?,
webhook: r.get::<_, Option<String>>(8)?.is_some(),
settings: r.get::<_, Option<String>>(9)?.and_then(|j| serde_json::from_str(&j).ok()),
})
}
pub fn jobs(&self, owner: Option<&str>) -> Result<Vec<crate::jobs::Job>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
)?;
let rows = stmt.query_map([owner], Self::job_row)?;
@@ -1151,7 +1153,7 @@ impl Store {
pub fn job(&self, id: &str) -> Result<Option<crate::jobs::Job>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE id = ?1", [id], Self::job_row)
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE id = ?1", [id], Self::job_row)
.optional()?)
}
@@ -1167,7 +1169,7 @@ impl Store {
}
let conn = self.conn.lock().unwrap();
Ok(conn.query_row(
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE path = ?1",
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE path = ?1",
[path],
Self::job_row,
)?)
@@ -1200,6 +1202,11 @@ impl Store {
}
/// Set (Some) or remove (None) the webhook secret of a job.
pub fn job_set_settings(&self, id: &str, settings: &crate::jobs::JobSettings) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE jobs SET settings = ?2 WHERE id = ?1", rusqlite::params![id, serde_json::to_string(settings)?])?;
Ok(())
}
pub fn job_set_webhook(&self, id: &str, secret: Option<&str>) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE jobs SET webhook_secret = ?2 WHERE id = ?1", rusqlite::params![id, secret])?;
Ok(())
+15 -1
View File
@@ -260,7 +260,8 @@ fn user(app: &App, user_id: &str) -> Result<User> {
app.store.user(user_id)?.filter(|u| !u.disabled).ok_or_else(|| anyhow!("account disabled"))
}
fn german(app: &App, user_id: &str) -> bool {
/// The user reads German (language preference of the web UI).
pub fn german(app: &App, user_id: &str) -> bool {
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
prefs["lang"].as_str().unwrap_or("en").starts_with("de")
}
@@ -538,6 +539,8 @@ async fn new_session(app: &Arc<App>, 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!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
@@ -869,6 +872,17 @@ async fn listen(app: Arc<App>) {
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:#}");
}
+2
View File
@@ -12,6 +12,7 @@ import { Settings } from "./components/Settings";
import { Stats } from "./components/Stats";
import { Images } from "./components/Images";
import { Jobs } from "./components/Jobs";
import { JobEditor } from "./components/JobEditor";
import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs";
import { startPrefsSync } from "./prefsSync";
@@ -95,6 +96,7 @@ export function App() {
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "jobs") content = <Jobs onMenu={() => setMenuOpen(true)} />;
else if (section === "terminal")
content = (
+17 -1
View File
@@ -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<string>();
@@ -352,6 +354,20 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
>
<Icon name="edit" /> {t("instructions.title")}
</a>
{telegramPaired && !session.parent_id && (
<a
href="#"
role="menuitemcheckbox"
aria-checked={!session.telegram_quiet}
title={t("chat.telegramHint")}
onClick={(e) => {
e.preventDefault();
void update({ telegram_quiet: !session.telegram_quiet });
}}
>
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
</a>
)}
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
<Icon name="download" /> {t("chat.exportMd")}
</a>
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useState } from "react";
import { get } from "../api";
import { getLang, useT } from "../i18n";
/**
* Graphical editor for a 5-field cron expression (minute hour day month weekday,
* server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a
* custom expression. Shows the next runs as computed by the server.
*/
type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom";
interface State {
mode: Mode;
every: number;
minute: number;
time: string; // HH:MM
days: number[]; // cron weekdays, 0 = Sunday
dom: number;
custom: string;
}
const MINUTE_STEPS = [5, 10, 15, 20, 30];
/** Monday first; cron numbers (0 = Sunday). */
const WEEK = [1, 2, 3, 4, 5, 6, 0];
const pad = (n: number) => String(n).padStart(2, "0");
const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`;
function expandDays(field: string): number[] | null {
const out = new Set<number>();
for (const part of field.split(",")) {
const m = part.match(/^(\d)(?:-(\d))?$/);
if (!m) return null;
const a = Number(m[1]);
const b = m[2] ? Number(m[2]) : a;
if (a > 7 || b > 7 || b < a) return null;
for (let d = a; d <= b; d++) out.add(d % 7);
}
return [...out];
}
export function parseCron(expr: string): State {
const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() };
const f = expr.trim().split(/\s+/);
if (f.length !== 5) return base;
const [mi, h, dom, mon, dow] = f;
const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max;
if (mon !== "*") return base;
let m;
if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) };
if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) };
if (!num(mi, 59) || !num(h, 23)) return base;
const time = hhmm(h, mi);
if (dom === "*" && dow === "*") return { ...base, mode: "daily", time };
if (dom === "*") {
const days = expandDays(dow);
return days?.length ? { ...base, mode: "weekly", time, days } : base;
}
if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) };
return base;
}
export function buildCron(s: State): string {
const [h, m] = s.time.split(":").map(Number);
switch (s.mode) {
case "minutes":
return `*/${s.every} * * * *`;
case "hourly":
return `${s.minute} * * * *`;
case "daily":
return `${m} ${h} * * *`;
case "weekly": {
const days = [...s.days].sort((a, b) => a - b);
return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`;
}
case "monthly":
return `${m} ${h} ${s.dom} * *`;
default:
return s.custom.trim();
}
}
const dayName = (d: number, style: "short" | "long") => {
// 2023-01-01 was a Sunday.
return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style });
};
/** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */
const fmtWall = (s: string) => {
const [d, hm] = s.split("T");
const [y, mo, da] = d.split("-").map(Number);
const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" });
return `${date}, ${hm}`;
};
export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) {
const t = useT();
const [s, setS] = useState<State>(() => parseCron(value));
const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>();
// A value from outside (another automation, reset) that the builder did not produce.
useEffect(() => {
if (value.trim() !== buildCron(s)) setS(parseCron(value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(() => {
const expr = value.trim();
if (!expr) return setPreview(undefined);
let alive = true;
const h = setTimeout(() => {
get<typeof preview>(`/jobs/schedule?expr=${encodeURIComponent(expr)}`)
.then((p) => alive && setPreview(p))
.catch(() => alive && setPreview(undefined));
}, 250);
return () => {
alive = false;
clearTimeout(h);
};
}, [value]);
const set = (patch: Partial<State>) => {
const next = { ...s, ...patch };
// Switching to "custom" starts from the current expression.
if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s);
setS(next);
onChange(buildCron(next));
};
const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"];
return (
<div className="cron-builder">
<div className="cron-modes" role="radiogroup" aria-label={t("cron.label")}>
{modes.map((m) => (
<button key={m} type="button" role="radio" aria-checked={s.mode === m} className={`chip-btn ${s.mode === m ? "active" : ""}`} onClick={() => set({ mode: m })}>
{t(`cron.mode.${m}` as const)}
</button>
))}
</div>
<div className="cron-fields">
{s.mode === "minutes" && (
<label>
{t("cron.every")}
<select value={s.every} onChange={(e) => set({ every: Number(e.target.value) })}>
{MINUTE_STEPS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
{t("cron.minutes")}
</label>
)}
{s.mode === "hourly" && (
<label>
{t("cron.atMinute")}
<input type="number" min={0} max={59} value={s.minute} onChange={(e) => set({ minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })} />
</label>
)}
{s.mode === "weekly" && (
<div className="cron-days" role="group" aria-label={t("cron.days")}>
{WEEK.map((d) => (
<button
key={d}
type="button"
aria-pressed={s.days.includes(d)}
className={`chip-btn ${s.days.includes(d) ? "active" : ""}`}
title={dayName(d, "long")}
onClick={() => set({ days: s.days.includes(d) ? s.days.filter((x) => x !== d) : [...s.days, d] })}
>
{dayName(d, "short")}
</button>
))}
<button type="button" className="link-btn" onClick={() => set({ days: [1, 2, 3, 4, 5] })}>
{t("cron.workdays")}
</button>
</div>
)}
{s.mode === "monthly" && (
<label>
{t("cron.onDay")}
<select value={s.dom} onChange={(e) => set({ dom: Number(e.target.value) })}>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>
{d}.
</option>
))}
</select>
</label>
)}
{(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && (
<label>
{t("cron.at")}
<input type="time" value={s.time} onChange={(e) => e.target.value && set({ time: e.target.value })} />
</label>
)}
{s.mode === "custom" && (
<label className="cron-custom">
<input className="mono" value={s.custom} placeholder="0 3 * * *" spellCheck={false} onChange={(e) => set({ custom: e.target.value })} />
<small className="hint">{t("cron.customHint")}</small>
</label>
)}
</div>
<div className="cron-preview">
<code>{value || "–"}</code>
{preview?.ok === false && <span className="err">{preview.error}</span>}
{preview?.ok && preview.next_local && preview.next_local.length > 0 && (
<span>
{t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")}
</span>
)}
{preview?.tz && s.mode !== "minutes" && <span className="hint">{t("cron.serverTz", { tz: preview.tz })}</span>}
{s.mode === "monthly" && s.dom > 28 && <span className="hint">{t("cron.shortMonths")}</span>}
</div>
</div>
);
}
+298
View File
@@ -0,0 +1,298 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { ApiError, get, patch, post, put } from "../api";
import { confirmDialog, toast } from "../dialogs";
import { fmtRelative, useT } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { Spinner } from "./Activity";
import { ChatView } from "./ChatView";
import { CronBuilder } from "./CronBuilder";
import { Icon } from "./Icon";
import type { JobInfo } from "./Jobs";
import { ModelSelect } from "./ModelSelect";
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
interface Settings {
name: string;
schedule: string;
agent: string;
model: string;
permission_mode: string;
timeout_minutes: number;
}
const settingsOf = (j: JobInfo): Settings => ({
name: j.spec?.name ?? "",
schedule: j.spec?.schedule ?? "0 3 * * *",
agent: j.spec?.agent ?? "build",
model: j.spec?.model ?? "",
permission_mode: j.spec?.permission_mode ?? "auto",
timeout_minutes: j.spec?.timeout_minutes ?? 60,
});
type Tab = "prompt" | "settings" | "assistant";
/**
* One automation: its instructions (Markdown file) in an editor, schedule and settings,
* and a chat with a model that helps to write the instructions (it edits the file).
*/
export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) {
const t = useT();
const [job, setJob] = useState<JobInfo>();
const [settings, setSettings] = useState<Settings>();
const [saved, setSaved] = useState<Settings>();
const [text, setText] = useState<string>();
const [savedText, setSavedText] = useState<string>();
const [mtime, setMtime] = useState<number>();
const [changedOnDisk, setChangedOnDisk] = useState(false);
const [saving, setSaving] = useState(false);
const [assistant, setAssistant] = useState<string>();
const [tab, setTab] = useState<Tab>("prompt");
const loadJob = useCallback(async () => {
const j = await get<JobInfo>(`/jobs/${id}`);
setJob(j);
const s = settingsOf(j);
setSettings((cur) => (cur && saved && JSON.stringify(cur) !== JSON.stringify(saved) ? cur : s));
setSaved(s);
setAssistant(j.spec?.assistant_session ?? undefined);
return j;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const loadFile = useCallback(async (path: string) => {
const f = await get<{ content?: string; mtime?: number }>(`/files/read?path=${encodeURIComponent(path)}`);
setText(f.content ?? "");
setSavedText(f.content ?? "");
setMtime(f.mtime);
setChangedOnDisk(false);
}, []);
useEffect(() => {
loadJob()
.then((j) => loadFile(j.path))
.catch((e) => toast(e.message));
}, [loadJob, loadFile]);
const fileDirty = text !== undefined && text !== savedText;
const settingsDirty = !!settings && !!saved && JSON.stringify(settings) !== JSON.stringify(saved);
const dirty = fileDirty || settingsDirty;
// The assistant edits the file: reload it when a turn ends (unless there are unsaved edits).
const busy = useStore((s) => (assistant ? !!s.busy[assistant] : false));
const wasBusy = useRef(false);
const dirtyRef = useRef(fileDirty);
dirtyRef.current = fileDirty;
useEffect(() => {
if (wasBusy.current && !busy && job) {
get<{ mtime?: number }>(`/files/stat?path=${encodeURIComponent(job.path)}`)
.then((st) => {
if (st.mtime && mtime && st.mtime <= mtime) return;
if (dirtyRef.current) setChangedOnDisk(true);
else void loadFile(job.path);
})
.catch(() => undefined);
}
wasBusy.current = busy;
}, [busy, job, mtime, loadFile]);
// Unsaved changes: ask before leaving the page.
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
const save = async (force = false) => {
if (!job || !settings) return;
setSaving(true);
try {
if (settingsDirty) {
const j = await patch<JobInfo>(`/jobs/${id}`, settings);
setJob(j);
const s = settingsOf(j);
setSettings(s);
setSaved(s);
}
if (fileDirty) {
const r = await put<{ mtime?: number }>("/files/write", { path: job.path, content: text, expected_mtime: mtime, force });
setSavedText(text);
setMtime(r.mtime);
setChangedOnDisk(false);
void loadJob();
}
toast(t("jobEditor.saved"), "success");
} catch (e: any) {
if (e instanceof ApiError && e.status === 409) {
if (await confirmDialog({ message: t("jobEditor.conflict"), confirmLabel: t("jobEditor.overwrite"), danger: true })) return save(true);
} else toast(e.message);
} finally {
setSaving(false);
}
};
const setEnabled = async (enabled: boolean) => {
try {
setJob(await patch<JobInfo>(`/jobs/${id}`, { enabled }));
} catch (e: any) {
toast(e.message);
}
};
const runNow = async () => {
if (dirty && !(await confirmDialog({ message: t("jobEditor.runUnsaved") }))) return;
try {
const r = await post<{ session_id: string }>(`/jobs/${id}/run`, {});
navigate(`/s/${r.session_id}`);
} catch (e: any) {
toast(e.message);
}
};
const startAssistant = async () => {
try {
const r = await post<{ session_id: string }>(`/jobs/${id}/assistant`, {});
setAssistant(r.session_id);
setTab("assistant");
} catch (e: any) {
toast(e.message);
}
};
const back = async () => {
if (dirty && !(await confirmDialog({ message: t("jobEditor.discard"), danger: true }))) return;
navigate("/jobs");
};
if (!job || !settings) return <div className="empty"><Spinner /></div>;
const up = (p: Partial<Settings>) => setSettings({ ...settings, ...p });
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<button className="small ghost" onClick={back} title={t("jobEditor.back")} aria-label={t("jobEditor.back")}>
<Icon name="arrow-left" />
</button>
<div className="grow">
<h1>{settings.name || t("nav.jobs")}</h1>
<div className="path mono">{job.path}</div>
</div>
<label className="switch" title={t(job.enabled ? "jobs.disable" : "jobs.enable")}>
<input type="checkbox" checked={job.enabled} onChange={(e) => void setEnabled(e.target.checked)} />
<span>{t(job.enabled ? "jobs.on" : "jobs.off")}</span>
</label>
<button className="small ghost" onClick={runNow} title={t("jobs.runNow")}>
<Icon name="play" /> <span className="hide-mobile">{t("jobs.runNow")}</span>
</button>
<button className="small primary" disabled={!dirty || saving} onClick={() => void save()}>
{saving ? <Spinner small /> : <Icon name="check" />} {t("common.save")}
</button>
</div>
<div className="job-tabs" role="tablist">
{(["prompt", "settings", "assistant"] as Tab[]).map((x) => (
<button key={x} role="tab" aria-selected={tab === x} className={tab === x ? "active" : ""} onClick={() => setTab(x)}>
{t(`jobEditor.tab.${x}` as const)}
{x === "prompt" && fileDirty && " •"}
{x === "settings" && settingsDirty && " •"}
</button>
))}
</div>
<div className={`job-editor tab-${tab}`}>
<div className="job-editor-main">
<section className="job-editor-settings panel">
<div className="job-editor-row">
<label className="field" style={{ flex: 2 }}>
<span>{t("jobs.tplName")}</span>
<input value={settings.name} onChange={(e) => up({ name: e.target.value })} />
</label>
<div className="job-editor-status">
<span>
{t("jobs.next")}: {job.enabled && job.next_run ? new Date(job.next_run).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"}
</span>
<span>
{t("jobs.last")}: {job.last_run ? fmtRelative(job.last_run) : "–"}
{job.last_session_id && (
<button className="link-btn" style={{ marginLeft: 6 }} onClick={() => navigate(`/s/${job.last_session_id}`)}>
{t("jobs.openRun")}
</button>
)}
</span>
</div>
</div>
<div className="field">
<span>{t("jobs.schedule")}</span>
<CronBuilder value={settings.schedule} onChange={(schedule) => up({ schedule })} />
</div>
<div className="job-editor-row">
<label className="field">
<span>{t("jobs.tplMode")}</span>
<select value={settings.agent} onChange={(e) => up({ agent: e.target.value })}>
<option value="build">build</option>
<option value="plan">plan</option>
</select>
</label>
<label className="field">
<span>{t("jobs.permissionMode")}</span>
<select value={settings.permission_mode} title={t("jobs.permissionModeHint")} onChange={(e) => up({ permission_mode: e.target.value })}>
<option value="default">{t("permissionMode.default")}</option>
<option value="auto">{t("permissionMode.auto")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
</label>
<label className="field">
<span>{t("composer.model")}</span>
<ModelSelect value={settings.model} onChange={(model) => up({ model })} emptyLabel={t("jobEditor.defaultModel")} />
</label>
<label className="field" style={{ maxWidth: 140 }}>
<span>{t("jobEditor.timeout")}</span>
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
</label>
</div>
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
</section>
<section className="job-editor-prompt">
<div className="job-editor-prompt-head">
<b>{t("jobEditor.instructions")}</b>
<span className="hint">{t("jobEditor.instructionsHint")}</span>
</div>
{changedOnDisk && (
<div className="notice">
{t("jobEditor.changedOnDisk")}{" "}
<button className="small" onClick={() => void loadFile(job.path)}>
{t("jobEditor.reload")}
</button>
</div>
)}
{job.error && !job.error.includes("empty") && <div className="notice err">{job.error}</div>}
{text === undefined ? (
<div className="empty"><Spinner /></div>
) : (
<Suspense fallback={<div className="empty"><Spinner /></div>}>
<CodeEditor value={text} filename={job.path} onChange={setText} onSave={() => void save()} />
</Suspense>
)}
</section>
</div>
<aside className="job-editor-chat">
{assistant ? (
<ChatView key={assistant} sessionId={assistant} onMenu={onMenu} />
) : (
<div className="job-assistant-start">
<Icon name="sparkle" />
<p>{t("jobEditor.assistantIntro")}</p>
<button className="primary" onClick={startAssistant}>
{t("jobEditor.assistantStart")}
</button>
</div>
)}
</aside>
</div>
</>
);
}
-134
View File
@@ -1,134 +0,0 @@
import { useEffect, useState } from "react";
import { useFetch } from "../hooks";
import { post } from "../api";
import { getLang, useT } from "../i18n";
import { JOB_TEMPLATES, SCHEDULES, type JobTemplate } from "../jobTemplates";
import { Icon } from "./Icon";
import { ModelSelect } from "./ModelSelect";
import { FolderPicker } from "./NewSession";
/** Create an automation from a template: pick it, choose the project, adjust, create. */
export function JobTemplateDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const t = useT();
const de = getLang() === "de";
const [tpl, setTpl] = useState<JobTemplate>();
const [name, setName] = useState("");
const [schedule, setSchedule] = useState("");
const [agent, setAgent] = useState("build");
const [mode, setMode] = useState("auto");
const [model, setModel] = useState("");
const [prompt, setPrompt] = useState("");
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
const config = useFetch<{ default_model?: string }>("/config").data;
useEffect(() => {
if (!model && config?.default_model) setModel(config.default_model);
}, [config, model]);
const choose = (x: JobTemplate) => {
setTpl(x);
setName(de ? x.name.de : x.name.en);
setSchedule(x.schedule);
setAgent(x.agent);
setPrompt(x.prompt);
};
const create = async () => {
setBusy(true);
setError(undefined);
try {
await post("/jobs/create", { cwd, name: name.trim(), schedule: schedule.trim(), agent, permission_mode: mode, model: model || undefined, prompt });
onCreated();
onClose();
} catch (e: any) {
setError(e.message);
} finally {
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.fromTemplate")} onClick={(e) => e.stopPropagation()}>
{!tpl ? (
<>
<h3>{t("jobs.fromTemplate")}</h3>
<div className="job-tpl-grid">
{JOB_TEMPLATES.map((x) => (
<button key={x.id} className="job-tpl-card" onClick={() => choose(x)}>
<Icon name={x.icon} />
<span>
<b>{de ? x.name.de : x.name.en}</b>
<small>{de ? x.desc.de : x.desc.en}</small>
</span>
</button>
))}
</div>
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button>
</div>
</>
) : (
<>
<h3>
<Icon name={tpl.icon} /> {de ? tpl.name.de : tpl.name.en}
</h3>
<label className="field">
<span>{t("jobs.tplName")}</span>
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<div className="field">
<span>{t("jobs.tplProject")}</span>
<FolderPicker path={cwd} onPick={setCwd} />
</div>
<div className="field">
<span>{t("jobs.tplSchedule")}</span>
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
{SCHEDULES.map((s) => (
<button key={s.cron} type="button" className={`chip-btn ${schedule === s.cron ? "active" : ""}`} onClick={() => setSchedule(s.cron)}>
{de ? s.de : s.en}
</button>
))}
</div>
<input className="mono" value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="0 3 * * *" />
<small className="hint">{t("jobs.tplCronHint")}</small>
</div>
<div className="row" style={{ gap: 10, flexWrap: "wrap" }}>
<label className="field">
<span>{t("jobs.tplMode")}</span>
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
<option value="build">build</option>
<option value="plan">plan</option>
</select>
</label>
<label className="field">
<span>{t("composer.permissionMode")}</span>
<select value={mode} onChange={(e) => setMode(e.target.value)}>
<option value="auto">{t("permissionMode.auto")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
</label>
<label className="field">
<span>{t("composer.model")}</span>
<ModelSelect value={model} onChange={setModel} />
</label>
</div>
<label className="field">
<span>{t("jobs.tplPrompt")}</span>
<textarea rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
</label>
{error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={() => setTpl(undefined)}>{t("common.back")}</button>
<button className="primary" disabled={busy || !cwd || !name.trim() || !schedule.trim() || !prompt.trim()} onClick={create}>
{t("jobs.tplCreate")}
</button>
</div>
</>
)}
</div>
</div>
);
}
+16 -25
View File
@@ -5,9 +5,9 @@ import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
import { JobTemplateDialog } from "./JobTemplateDialog";
import { NewJobDialog } from "./NewJobDialog";
interface JobInfo {
export interface JobInfo {
id: string;
path: string;
enabled: boolean;
@@ -17,12 +17,12 @@ interface JobInfo {
next_run?: number | null;
error?: string;
webhook?: boolean;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null };
}
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
/** Automations: agents started on a schedule from a Markdown job file. */
/** Automations: agents started on a schedule with the instructions from a Markdown file. */
export function Jobs({ onMenu }: { onMenu: () => void }) {
const t = useT();
const [jobs, setJobs] = useState<JobInfo[]>();
@@ -68,10 +68,10 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="notice">{t("jobs.intro")}</div>
<div className="row" style={{ margin: "12px 0" }}>
<button className="primary" onClick={() => setTemplates(true)}>
<Icon name="plus" /> {t("jobs.fromTemplate")}
<Icon name="plus" /> {t("jobs.new")}
</button>
</div>
{templates && <JobTemplateDialog onClose={() => setTemplates(false)} onCreated={() => void reload()} />}
{templates && <NewJobDialog onClose={() => setTemplates(false)} />}
{!jobs && <div className="empty"><Spinner /></div>}
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
{jobs?.map((j) => (
@@ -79,7 +79,11 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="job-head">
<Icon name="clock" className="job-icon" />
<div style={{ flex: 1, minWidth: 0 }}>
<h3>{j.spec?.name ?? j.path.split("/").pop()}</h3>
<h3>
<button className="link-btn job-title" onClick={() => navigate(`/jobs/${j.id}`)}>
{j.spec?.name ?? j.path.split("/").pop()}
</button>
</h3>
<div className="hint mono">{j.path}</div>
</div>
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
@@ -107,7 +111,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
</span>
<span>{t("jobs.mode")}</span>
<span>
{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")} · {t(`permissionMode.${j.spec?.permission_mode ?? "auto"}` as any)} · max {j.spec?.timeout_minutes} min
</span>
</div>
)}
@@ -163,14 +167,8 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
})}>
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
</button>
<button
className="small ghost"
onClick={() => {
sessionStorage.setItem("codecraft.openFile", j.path);
navigate("/files");
}}
>
<Icon name="edit" /> {t("jobs.editFile")}
<button className="small" onClick={() => navigate(`/jobs/${j.id}`)}>
<Icon name="edit" /> {t("jobs.edit")}
</button>
<span className="grow" />
<button
@@ -190,20 +188,13 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<div className="row" style={{ gap: 8 }}>
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
<button disabled={!path.trim()} onClick={() => act("new", async () => {
await post("/jobs", { path: path.trim() });
const j = await post<JobInfo>("/jobs", { path: path.trim() });
setPath("");
navigate(`/jobs/${j.id}`);
})}>
{t("jobs.registerBtn")}
</button>
</div>
<pre className="job-example">{`---
name: "Nightly dependency check"
schedule: "0 3 * * *"
agent: build
permission_mode: auto
timeout_minutes: 60
---
Check the project for outdated dependencies …`}</pre>
</div>
</div>
</div>
+128
View File
@@ -0,0 +1,128 @@
import { useState } from "react";
import { post } from "../api";
import { getLang, useT } from "../i18n";
import { JOB_TEMPLATES, type JobTemplate } from "../jobTemplates";
import { navigate } from "../router";
import { Icon } from "./Icon";
import { FolderPicker } from "./NewSession";
/** Starting point of a new instructions file: headings to fill in (with the assistant). */
function example(name: string, de: boolean) {
return de
? `# ${name}
## Ziel
Was soll diese Automatisierung erreichen? (ein, zwei Sätze)
## Vorgehen
1. …
2. …
## Ergebnis
Was am Ende vorliegen soll – z. B. ein kurzer Bericht, geänderte Dateien, ein Commit.
## Grenzen
- Was der Agent nicht tun darf (z. B. nichts pushen, nichts löschen).
`
: `# ${name}
## Goal
What should this automation achieve? (one or two sentences)
## Steps
1. …
2. …
## Result
What should exist at the end – e.g. a short report, changed files, a commit.
## Limits
- What the agent must not do (e.g. no pushing, no deleting).
`;
}
/**
* New automation: name, project and a starting point (example or template). Creates the
* instructions file (<project>/.codecraft/jobs/<name>.md, switched off) and opens the
* editor, where the schedule is set and the instructions are worked out.
*/
export function NewJobDialog({ onClose }: { onClose: () => void }) {
const t = useT();
const de = getLang() === "de";
const [tpl, setTpl] = useState<JobTemplate>();
const [name, setName] = useState("");
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
const choose = (x?: JobTemplate) => {
setTpl(x);
if (x && (!name.trim() || JOB_TEMPLATES.some((y) => name === y.name.de || name === y.name.en))) setName(de ? x.name.de : x.name.en);
};
const create = async () => {
setBusy(true);
setError(undefined);
try {
const n = name.trim();
const job = await post<{ id: string }>("/jobs/create", {
cwd,
name: n,
prompt: tpl ? `# ${n}\n\n${tpl.prompt}\n` : example(n, de),
schedule: tpl?.schedule,
agent: tpl?.agent,
});
onClose();
navigate(`/jobs/${job.id}`);
} catch (e: any) {
setError(e.message);
} finally {
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.new")} onClick={(e) => e.stopPropagation()}>
<h3>{t("jobs.new")}</h3>
<label className="field">
<span>{t("jobs.tplName")}</span>
<input autoFocus value={name} placeholder={t("jobs.namePlaceholder")} onChange={(e) => setName(e.target.value)} />
</label>
<div className="field">
<span>{t("jobs.tplProject")}</span>
<FolderPicker path={cwd} onPick={setCwd} />
<small className="hint">{t("jobs.fileHint")}</small>
</div>
<div className="field">
<span>{t("jobs.startWith")}</span>
<div className="job-tpl-grid">
<button className={`job-tpl-card ${!tpl ? "active" : ""}`} onClick={() => choose(undefined)}>
<Icon name="file-text" />
<span>
<b>{t("jobs.blank")}</b>
<small>{t("jobs.blankDesc")}</small>
</span>
</button>
{JOB_TEMPLATES.map((x) => (
<button key={x.id} className={`job-tpl-card ${tpl?.id === x.id ? "active" : ""}`} onClick={() => choose(x)}>
<Icon name={x.icon} />
<span>
<b>{de ? x.name.de : x.name.en}</b>
<small>{de ? x.desc.de : x.desc.en}</small>
</span>
</button>
))}
</div>
</div>
{error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button onClick={onClose}>{t("common.cancel")}</button>
<button className="primary" disabled={busy || !cwd || !name.trim()} onClick={create}>
{t("jobs.createAndEdit")}
</button>
</div>
</div>
</div>
);
}
+197 -11
View File
@@ -2,7 +2,7 @@ import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, patch, post } from "../api";
import { get, patch, post, put } from "../api";
import { promptDialog, toast } from "../dialogs";
import { fmtRelative, useT, type Key } from "../i18n";
import { navigate, openBeside } from "../router";
@@ -102,6 +102,113 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
);
}
/** Order and pinning of folders and projects in the list (per user, all devices). */
interface Layout {
order: string[];
pinned: string[];
}
/** Folders and projects can be moved and pinned; time groups and the archive cannot. */
const movable = (id: string) => id.startsWith("folder:") || id.startsWith("project:");
const DRAG_TYPE = "application/x-codecraft-group";
/** Header of a group: collapses it; folders and projects also have a menu and can be dragged. */
function GroupHeader({
g,
collapsed,
pinned,
onToggle,
onPin,
onMove,
onDrop,
}: {
g: { id: string; label: string; icon?: string; items: Session[] };
collapsed: boolean;
pinned: boolean;
onToggle: () => void;
onPin?: () => void;
onMove?: (dir: -1 | 1) => void;
onDrop?: (dragged: string, after: boolean) => void;
}) {
const t = useT();
const [menu, setMenu] = useState(false);
const [over, setOver] = useState<"before" | "after">();
useEffect(() => {
if (!menu) return;
const close = () => setMenu(false);
window.addEventListener("click", close);
window.addEventListener("keydown", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("keydown", close);
};
}, [menu]);
const canMove = !!onMove;
return (
<div
className={`session-row group-row ${menu ? "menu-open" : ""} ${over ? `drop-${over}` : ""}`}
draggable={canMove}
onDragStart={(e) => {
if (!canMove) return;
e.dataTransfer.setData(DRAG_TYPE, g.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!onDrop || !e.dataTransfer.types.includes(DRAG_TYPE)) return;
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
setOver(e.clientY > r.top + r.height / 2 ? "after" : "before");
}}
onDragLeave={() => setOver(undefined)}
onDrop={(e) => {
const id = e.dataTransfer.getData(DRAG_TYPE);
const after = over === "after";
setOver(undefined);
if (id && id !== g.id && onDrop) {
e.preventDefault();
onDrop(id, after);
}
}}
>
<button className="session-group" onClick={onToggle} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-right" : "chevron-down"} className="tiny" />
{g.icon && <Icon name={g.icon} className="tiny" />}
<span className="grow">{g.label}</span>
{pinned && <Icon name="pin" className="tiny group-pin" />}
{collapsed && <span className="count">{g.items.length}</span>}
</button>
{canMove && (
<button
className="session-more ghost"
aria-label={t("sidebar.groupMenu")}
aria-haspopup="menu"
aria-expanded={menu}
onClick={(e) => {
e.stopPropagation();
setMenu(!menu);
}}
>
<Icon name="more" />
</button>
)}
{menu && (
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
</button>
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(-1))}>
<Icon name="arrow-up" /> {t("sidebar.groupUp")}
</button>
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(1))}>
<Icon name="arrow-down" /> {t("sidebar.groupDown")}
</button>
</div>
)}
</div>
);
}
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
const t = useT();
const sessions = useStore((s) => s.sessions);
@@ -164,14 +271,15 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
const live = sessions.filter((s) => !s.archived);
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
const rest = live.filter((s) => !s.pinned);
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])];
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
const loose = rest.filter((s) => !s.folder);
if (groupBy === "project") {
const byProject = new Map<string, Session[]>();
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
// Projects with the most recent activity first.
for (const [cwd, items] of [...byProject].sort((a, b) => b[1][0].updated_at - a[1][0].updated_at)) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
// Order of the projects: see `ordered` below (not by activity – a new message
// moves the chat to the top of its project, not the project).
for (const [cwd, items] of byProject) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
} else {
for (const s of loose) {
const key = groupKey(s.updated_at);
@@ -181,6 +289,81 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
return out;
}, [sessions, groupBy, t]);
// Folders and projects: pinned first, then in the user's order. Groups not ordered yet
// (new folder or project) start at the top and are then kept where they are.
const [layout, setLayout] = useState<Layout>();
useEffect(() => {
get<Layout>("/account/sidebar")
.then((l) => setLayout({ order: l.order ?? [], pinned: l.pinned ?? [] }))
.catch(() => setLayout({ order: [], pinned: [] }));
}, []);
const ordered = useMemo(() => {
const l = layout ?? { order: [], pinned: [] };
const head = groups.filter((g) => g.id === "pinned");
const tail = groups.filter((g) => g.id !== "pinned" && !movable(g.id));
const rank = (id: string) => l.order.indexOf(id);
const mid = groups
.filter((g) => movable(g.id))
.sort((a, b) => {
const pa = l.pinned.includes(a.id);
const pb = l.pinned.includes(b.id);
if (pa !== pb) return pa ? -1 : 1;
const ra = rank(a.id);
const rb = rank(b.id);
if (ra < 0 && rb < 0) return b.items[0].updated_at - a.items[0].updated_at;
if (ra < 0 || rb < 0) return ra < 0 ? -1 : 1;
return ra - rb;
});
return { head, mid, tail };
}, [groups, layout]);
const saveLayout = (next: Layout) => {
// Forget groups that no longer exist (no chat in that folder or project).
const known = new Set(sessions.flatMap((s) => [`project:${s.cwd}`, ...(s.folder ? [`folder:${s.folder}`] : [])]));
const clean = { order: next.order.filter((id) => known.has(id)), pinned: next.pinned.filter((id) => known.has(id)) };
setLayout(clean);
void put("/account/sidebar", clean).catch((e) => toast(e.message));
};
// Freeze the position of groups that appeared since the last visit.
useEffect(() => {
if (!layout) return;
const fresh = ordered.mid.map((g) => g.id).filter((id) => !layout.order.includes(id));
if (fresh.length) saveLayout({ ...layout, order: [...fresh, ...layout.order] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ordered, layout]);
/** Order of the shown groups with `id` moved: by a step, or next to another group. */
const reorder = (ids: string[]) => {
if (!layout) return;
const shown = new Set(ids);
saveLayout({ ...layout, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
};
const moveGroup = (id: string, dir: -1 | 1) => {
if (!layout) return;
const ids = ordered.mid.map((g) => g.id);
const i = ids.indexOf(id);
const j = i + dir;
// Only within the pinned or the unpinned part.
if (j < 0 || j >= ids.length || layout.pinned.includes(ids[j]) !== layout.pinned.includes(id)) return;
[ids[i], ids[j]] = [ids[j], ids[i]];
reorder(ids);
};
const dropGroup = (dragged: string, target: string, after: boolean) => {
if (!layout || !movable(dragged)) return;
const ids = ordered.mid.map((g) => g.id).filter((id) => id !== dragged);
const at = ids.indexOf(target);
if (at < 0) return;
ids.splice(after ? at + 1 : at, 0, dragged);
// Dropped among pinned groups: pinned too (and the other way round).
const pinTarget = layout.pinned.includes(target);
const pinned = pinTarget ? [...new Set([...layout.pinned, dragged])] : layout.pinned.filter((p) => p !== dragged);
const shown = new Set(ids);
saveLayout({ pinned, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
};
const pinGroup = (id: string) => {
if (!layout) return;
const pinned = layout.pinned.includes(id) ? layout.pinned.filter((p) => p !== id) : [...layout.pinned, id];
saveLayout({ ...layout, pinned });
};
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
return (
@@ -235,14 +418,17 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
</div>
)}
{!hits &&
groups.map((g) => (
[...ordered.head, ...ordered.mid, ...ordered.tail].map((g) => (
<div key={g.id}>
<button className="session-group" onClick={() => toggle(g.id)} aria-expanded={!collapsed.has(g.id)}>
<Icon name={collapsed.has(g.id) ? "chevron-right" : "chevron-down"} className="tiny" />
{g.icon && <Icon name={g.icon} className="tiny" />}
<span className="grow">{g.label}</span>
{collapsed.has(g.id) && <span className="count">{g.items.length}</span>}
</button>
<GroupHeader
g={g}
collapsed={collapsed.has(g.id)}
pinned={!!layout?.pinned.includes(g.id)}
onToggle={() => toggle(g.id)}
onPin={movable(g.id) ? () => pinGroup(g.id) : undefined}
onMove={movable(g.id) ? (dir) => moveGroup(g.id, dir) : undefined}
onDrop={movable(g.id) ? (dragged, after) => dropGroup(dragged, g.id, after) : undefined}
/>
{!collapsed.has(g.id) &&
g.items.map((s) => (
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
+56 -9
View File
@@ -344,7 +344,7 @@ export const de: Messages = {
"agents.refineShort": "Review ×{n}",
"agents.deleteFile": "Agenten-Datei {path} löschen?",
"nav.jobs": "Automationen",
"jobs.intro": "Agenten, die nach Zeitplan starten. Jede Automation ist eine Markdown-Datei (Auftrag + Zeitplan im Frontmatter) – lass sie dir in einem Chat von einem Agenten anlegen (Tool „schedule“) oder registriere unten eine Datei. Jeder Lauf erscheint als normaler Chat; braucht er eine Freigabe, bekommst du eine Push-Benachrichtigung.",
"jobs.intro": "Agenten, die nach Zeitplan starten. Die Anweisungen jeder Automatisierung stehen in einer Markdown-Datei; Name, Zeitplan und Einstellungen legst du hier fest. Mit „Neue Automatisierung“ anlegen und die Anweisungen mit dem Assistenten erarbeiten – oder einen Agenten in einem Chat darum bitten (Tool „schedule“). Jeder Lauf erscheint als normaler Chat.",
"jobs.none": "Noch keine Automationen.",
"jobs.schedule": "Zeitplan",
"jobs.next": "Nächster Lauf",
@@ -355,13 +355,12 @@ export const de: Messages = {
"jobs.enable": "Aktivieren",
"jobs.disable": "Deaktivieren",
"jobs.runNow": "Jetzt starten",
"jobs.editFile": "Datei bearbeiten",
"jobs.remove": "Entfernen",
"jobs.deleteConfirm": "Automation „{name}“ entfernen? Die Job-Datei bleibt erhalten.",
"jobs.openRun": "Lauf öffnen",
"jobs.prompt": "Auftrag",
"jobs.register": "Job-Datei registrieren",
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Zeitplan im Frontmatter:",
"jobs.register": "Vorhandene Datei registrieren",
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Anweisungen (ältere Dateien mit Zeitplan im Frontmatter werden übernommen):",
"jobs.registerBtn": "Registrieren",
"jobs.status.running": "läuft",
"jobs.status.done": "fertig",
@@ -509,6 +508,11 @@ export const de: Messages = {
"sidebar.unpin": "Nicht mehr anpinnen",
"sidebar.openBeside": "Daneben öffnen",
"chat.closePane": "Spalte schließen",
"sidebar.groupMenu": "Ordner-Optionen",
"sidebar.groupPin": "Oben fixieren",
"sidebar.groupUnpin": "Nicht mehr fixieren",
"sidebar.groupUp": "Nach oben",
"sidebar.groupDown": "Nach unten",
"sidebar.moveTo": "In Ordner verschieben…",
"sidebar.removeFromFolder": "Aus Ordner entfernen",
"sidebar.archiveOne": "Archivieren",
@@ -545,14 +549,9 @@ export const de: Messages = {
"backup.s3Keep": "Im Bucket behalten (0 = alle)",
"backup.last": "Letztes Backup",
"backup.s3Ok": "hochgeladen",
"jobs.fromTemplate": "Neue Automation aus Vorlage",
"jobs.tplName": "Name",
"jobs.tplProject": "Projektordner",
"jobs.tplSchedule": "Wann",
"jobs.tplCronHint": "Cron: Minute Stunde Tag Monat Wochentag (Zeitzone des Servers).",
"jobs.tplMode": "Modus",
"jobs.tplPrompt": "Aufgabe für den Agenten",
"jobs.tplCreate": "Automation anlegen",
"new.title": "Neue Session",
"new.directory": "Projektverzeichnis",
"new.up": "Hoch",
@@ -593,6 +592,54 @@ 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)",
"cron.label": "Zeitplan",
"cron.mode.minutes": "Alle paar Minuten",
"cron.mode.hourly": "Stündlich",
"cron.mode.daily": "Täglich",
"cron.mode.weekly": "An Wochentagen",
"cron.mode.monthly": "Monatlich",
"cron.mode.custom": "Cron-Ausdruck",
"cron.every": "Alle",
"cron.minutes": "Minuten",
"cron.atMinute": "Zur Minute",
"cron.days": "Wochentage",
"cron.workdays": "Mo–Fr",
"cron.onDay": "Am",
"cron.at": "um",
"cron.customHint": "Minute Stunde Tag Monat Wochentag, z. B. „30 6 * * 1-5“ = Mo–Fr 06:30.",
"cron.nextRuns": "Nächste Läufe:",
"cron.shortMonths": "Monate ohne diesen Tag werden übersprungen.",
"cron.serverTz": "Uhrzeiten in Serverzeit ({tz})",
"jobEditor.saved": "Gespeichert",
"jobEditor.conflict": "Die Anweisungen wurden inzwischen geändert (z. B. vom Assistenten). Mit deiner Fassung überschreiben?",
"jobEditor.overwrite": "Überschreiben",
"jobEditor.runUnsaved": "Es gibt ungespeicherte Änderungen – der Lauf verwendet die gespeicherte Fassung. Trotzdem starten?",
"jobEditor.discard": "Ungespeicherte Änderungen verwerfen?",
"jobEditor.back": "Zurück zu den Automatisierungen",
"jobEditor.tab.prompt": "Anweisungen",
"jobEditor.tab.settings": "Zeitplan",
"jobEditor.tab.assistant": "Assistent",
"jobEditor.timeout": "Zeitlimit (Min.)",
"jobEditor.bypassHint": "Läuft nachts: Mit „Alles erlauben“ wartet der Lauf nie auf eine Freigabe.",
"jobEditor.instructions": "Anweisungen",
"jobEditor.instructionsHint": "Der Agent sieht nur diesen Text – keinen Chat, niemanden zum Nachfragen.",
"jobEditor.changedOnDisk": "Der Assistent hat die Datei geändert, während du bearbeitet hast.",
"jobEditor.reload": "Seine Fassung laden",
"jobEditor.assistantIntro": "Erarbeite die Anweisungen zusammen mit einem Modell: Beschreib, was die Automatisierung tun soll – es fragt nach und schreibt die Datei nebenan mit.",
"jobEditor.assistantStart": "Assistent starten",
"jobEditor.defaultModel": "Server-Standard",
"jobs.new": "Neue Automatisierung",
"jobs.namePlaceholder": "z. B. Nächtlicher Abhängigkeits-Check",
"jobs.fileHint": "Die Anweisungen landen in .codecraft/jobs/<name>.md in diesem Ordner.",
"jobs.startWith": "Beginnen mit",
"jobs.blank": "Leer",
"jobs.blankDesc": "Beispielgliederung zum Ausfüllen mit dem Assistenten",
"jobs.createAndEdit": "Anlegen und bearbeiten",
"jobs.edit": "Bearbeiten",
"jobs.permissionMode": "Freigabemodus",
"jobs.permissionModeHint": "Gilt ab dem nächsten Lauf. „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.",
+56 -9
View File
@@ -343,7 +343,7 @@ export const en = {
"agents.refineShort": "Review ×{n}",
"agents.deleteFile": "Delete agent file {path}?",
"nav.jobs": "Automations",
"jobs.intro": "Agents that start on a schedule. Each automation is a Markdown file (prompt + schedule in the frontmatter) – ask an agent in any chat to create one (tool “schedule”) or register a file below. Every run appears as a normal chat; if it needs permission you get a push notification.",
"jobs.intro": "Agents that start on a schedule. Each automation has its instructions in a Markdown file; name, schedule and settings are set here. Create one with “New automation” and work out the instructions with the assistant – or ask an agent in any chat (tool “schedule”). Every run appears as a normal chat.",
"jobs.none": "No automations yet.",
"jobs.schedule": "Schedule",
"jobs.next": "Next run",
@@ -354,13 +354,12 @@ export const en = {
"jobs.enable": "Enable",
"jobs.disable": "Disable",
"jobs.runNow": "Run now",
"jobs.editFile": "Edit file",
"jobs.remove": "Remove",
"jobs.deleteConfirm": "Remove the automation “{name}”? The job file stays.",
"jobs.openRun": "Open run",
"jobs.prompt": "Instructions",
"jobs.register": "Register a job file",
"jobs.registerHint": "Path to a Markdown file with a schedule in its frontmatter:",
"jobs.register": "Register an existing file",
"jobs.registerHint": "Path to a Markdown file with instructions (older files with a schedule in the frontmatter are taken over):",
"jobs.registerBtn": "Register",
"jobs.status.running": "running",
"jobs.status.done": "done",
@@ -508,6 +507,11 @@ export const en = {
"sidebar.unpin": "Unpin",
"sidebar.openBeside": "Open beside",
"chat.closePane": "Close this column",
"sidebar.groupMenu": "Folder options",
"sidebar.groupPin": "Pin to the top",
"sidebar.groupUnpin": "Unpin",
"sidebar.groupUp": "Move up",
"sidebar.groupDown": "Move down",
"sidebar.moveTo": "Move to folder…",
"sidebar.removeFromFolder": "Remove from folder",
"sidebar.archiveOne": "Archive",
@@ -544,14 +548,9 @@ export const en = {
"backup.s3Keep": "Keep in bucket (0 = all)",
"backup.last": "Last backup",
"backup.s3Ok": "uploaded",
"jobs.fromTemplate": "New automation from a template",
"jobs.tplName": "Name",
"jobs.tplProject": "Project folder",
"jobs.tplSchedule": "When",
"jobs.tplCronHint": "Cron: minute hour day month weekday (server time zone).",
"jobs.tplMode": "Mode",
"jobs.tplPrompt": "Task for the agent",
"jobs.tplCreate": "Create automation",
"new.title": "New session",
"new.directory": "Project directory",
"new.up": "Up",
@@ -592,6 +591,54 @@ 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)",
"cron.label": "Schedule",
"cron.mode.minutes": "Every few minutes",
"cron.mode.hourly": "Hourly",
"cron.mode.daily": "Daily",
"cron.mode.weekly": "On weekdays",
"cron.mode.monthly": "Monthly",
"cron.mode.custom": "Cron expression",
"cron.every": "Every",
"cron.minutes": "minutes",
"cron.atMinute": "At minute",
"cron.days": "Days of the week",
"cron.workdays": "Mon–Fri",
"cron.onDay": "On day",
"cron.at": "at",
"cron.customHint": "Minute hour day month weekday, e.g. “30 6 * * 1-5” = Mon–Fri 06:30.",
"cron.nextRuns": "Next runs:",
"cron.shortMonths": "Months without this day are skipped.",
"cron.serverTz": "Times in server time ({tz})",
"jobEditor.saved": "Saved",
"jobEditor.conflict": "The instructions were changed on disk meanwhile (e.g. by the assistant). Overwrite them with your version?",
"jobEditor.overwrite": "Overwrite",
"jobEditor.runUnsaved": "There are unsaved changes – the run uses the saved version. Start anyway?",
"jobEditor.discard": "Discard the unsaved changes?",
"jobEditor.back": "Back to automations",
"jobEditor.tab.prompt": "Instructions",
"jobEditor.tab.settings": "Schedule",
"jobEditor.tab.assistant": "Assistant",
"jobEditor.timeout": "Time limit (min)",
"jobEditor.bypassHint": "Runs at night: with “Allow everything” the run never waits for approval.",
"jobEditor.instructions": "Instructions",
"jobEditor.instructionsHint": "The agent sees only this text – no chat, nobody to ask.",
"jobEditor.changedOnDisk": "The assistant changed the file while you were editing.",
"jobEditor.reload": "Load its version",
"jobEditor.assistantIntro": "Work out the instructions together with a model: describe what the automation should do – it asks back and writes the file next to you.",
"jobEditor.assistantStart": "Start assistant",
"jobEditor.defaultModel": "Server default",
"jobs.new": "New automation",
"jobs.namePlaceholder": "e.g. Nightly dependency check",
"jobs.fileHint": "The instructions go to .codecraft/jobs/<name>.md in this folder.",
"jobs.startWith": "Start with",
"jobs.blank": "Blank",
"jobs.blankDesc": "Example structure to fill in with the assistant",
"jobs.createAndEdit": "Create and edit",
"jobs.edit": "Edit",
"jobs.permissionMode": "Approval mode",
"jobs.permissionModeHint": "Applies from the next run. “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.",
-10
View File
@@ -71,13 +71,3 @@ export const JOB_TEMPLATES: JobTemplate[] = [
"Compare the README and other documentation with the current code: commands, configuration options, APIs and examples that are outdated, missing or wrong. Do not change files. Report concrete corrections.",
},
];
/** Quick schedule choices for the dialog. */
export const SCHEDULES: { cron: string; en: string; de: string }[] = [
{ cron: "0 3 * * *", en: "daily 03:00", de: "täglich 03:00" },
{ cron: "0 8 * * 1-5", en: "weekdays 08:00", de: "werktags 08:00" },
{ cron: "0 7 * * 1", en: "Mondays 07:00", de: "montags 07:00" },
{ cron: "0 16 * * 5", en: "Fridays 16:00", de: "freitags 16:00" },
{ cron: "0 9 1 * *", en: "monthly on the 1st", de: "monatlich am 1." },
{ cron: "0 * * * *", en: "hourly", de: "stündlich" },
];
+235
View File
@@ -299,6 +299,25 @@ button.session-group:hover {
.session-row .session-item {
padding-right: 30px;
}
/* Group headers (folders, projects): menu button, drag and drop. */
.group-row .session-group {
padding-right: 30px;
}
.group-row .session-more {
top: 4px;
}
.group-row[draggable="true"] .session-group {
cursor: grab;
}
.group-row.drop-before {
box-shadow: inset 0 2px 0 var(--accent);
}
.group-row.drop-after {
box-shadow: inset 0 -2px 0 var(--accent);
}
.session-group .group-pin {
color: var(--accent);
}
.session-more {
position: absolute;
top: 6px;
@@ -3160,6 +3179,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;
}
@@ -3602,6 +3626,217 @@ button.danger-solid {
border-color: var(--accent);
color: var(--accent);
}
.job-tpl-card.active {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.job-tpl .job-tpl-grid {
max-height: 40vh;
overflow-y: auto;
}
.job-title {
font: inherit;
color: inherit;
text-align: left;
}
/* --- automation editor ------------------------------------------------------ */
.job-tabs {
display: none;
border-bottom: 1px solid var(--bg2);
}
.job-tabs button {
flex: 1;
border: none;
border-radius: 0;
background: transparent;
padding: 8px 6px;
color: var(--muted);
border-bottom: 2px solid transparent;
}
.job-tabs button.active {
color: var(--fg0);
border-bottom-color: var(--accent);
}
.job-editor {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 1fr);
}
.job-editor-main {
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.job-editor-settings {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
}
.job-editor-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
}
.job-editor-row .field {
flex: 1;
min-width: 150px;
margin: 0;
}
.job-editor-row select,
.job-editor-row input {
width: 100%;
}
.job-editor-status {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12.5px;
color: var(--fg3);
padding-bottom: 4px;
}
.job-editor-prompt {
flex: 1;
min-height: 360px;
display: flex;
flex-direction: column;
border: 1px solid var(--bg2);
border-radius: var(--radius);
overflow: hidden;
}
.job-editor-prompt-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--bg2);
background: var(--bg1);
}
.job-editor-prompt .notice {
margin: 8px;
}
.job-editor-chat {
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
border-left: 1px solid var(--bg3);
}
.job-editor-chat .topbar {
flex-wrap: nowrap;
}
.job-assistant-start {
margin: auto;
max-width: 340px;
padding: 24px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--fg2);
}
.job-assistant-start .icon {
width: 32px;
height: 32px;
color: var(--accent);
}
@media (max-width: 900px) {
.job-tabs {
display: flex;
}
.job-editor {
grid-template-columns: minmax(0, 1fr);
}
.job-editor-chat {
border-left: none;
}
.job-editor.tab-prompt .job-editor-settings,
.job-editor.tab-settings .job-editor-prompt,
.job-editor.tab-prompt .job-editor-chat,
.job-editor.tab-settings .job-editor-chat,
.job-editor.tab-assistant .job-editor-main {
display: none;
}
.job-editor.tab-prompt .job-editor-main {
padding: 0;
}
.job-editor.tab-prompt .job-editor-prompt {
border: none;
border-radius: 0;
min-height: 0;
}
}
/* --- schedule builder ---------------------------------------------------------- */
.cron-builder {
display: flex;
flex-direction: column;
gap: 8px;
}
.cron-modes,
.cron-days {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.cron-modes .chip-btn,
.cron-days .chip-btn {
padding: 4px 10px;
font-size: 13px;
}
.cron-days .chip-btn {
min-width: 44px;
}
.cron-fields {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: center;
}
.cron-fields label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13.5px;
}
.cron-fields input[type="number"] {
width: 70px;
}
.cron-custom {
flex-direction: column;
align-items: stretch !important;
flex: 1;
}
.cron-custom input {
width: 100%;
max-width: 260px;
}
.cron-preview {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
align-items: baseline;
font-size: 12.5px;
color: var(--fg3);
}
.cron-preview code {
background: var(--bg2);
padding: 1px 6px;
border-radius: 4px;
color: var(--fg1);
}
.cron-preview .err {
color: var(--red);
}
.chip.price-extra {
margin-left: 4px;
+2
View File
@@ -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 {
+14
View File
@@ -3,6 +3,20 @@
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00013": {
en: [
"New automation editor: instructions, a graphical schedule and an assistant chat that writes the instructions with you",
"Chat list: drag folders and projects into order or pin them (⋯ on the folder); new messages no longer move the whole project up",
],
de: [
"Neuer Editor für Automatisierungen: Anweisungen, grafischer Zeitplan und ein Assistent-Chat, der die Anweisungen mit dir schreibt",
"Chatliste: Ordner und Projekte per Drag & Drop sortieren oder fixieren (⋯ am Ordner); neue Nachrichten schieben nicht mehr das ganze Projekt nach oben",
],
},
"202611-00012": {
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)"],