Add web terminal, git credentials, OpenSCAD, review loops, automations

- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay)
- Per-user git credentials and commit identity (credential store in the user's home)
- OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card
- Craft phase inferred from tool activity when the agent does not report it
- Configurable review loop per mode (rounds, reviewer model, criteria)
- Delete config and Markdown agents from the agent editor
- Automations: cron-scheduled agents from Markdown job files, schedule tool, page
- change_directory tool (only on the user's request)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 22:00:38 +01:00
1 parent a0d0e707f2
commit df1924b0a1
41 files changed
+2809 -34

No files matched your search

+116
View File
@@ -97,6 +97,22 @@ impl Store {
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
last_run INTEGER,
last_session_id TEXT,
last_status TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (user_id, key)
);
CREATE TABLE IF NOT EXISTS push_subs (
endpoint TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -881,6 +897,106 @@ impl Store {
// --- audit log & backup ----------------------------------------------------------
impl Store {
// --- scheduled jobs -------------------------------------------------------
fn job_row(r: &rusqlite::Row) -> rusqlite::Result<crate::jobs::Job> {
Ok(crate::jobs::Job {
id: r.get(0)?,
owner_id: r.get(1)?,
path: r.get(2)?,
enabled: r.get::<_, i64>(3)? != 0,
last_run: r.get(4)?,
last_session_id: r.get(5)?,
last_status: r.get(6)?,
created_at: r.get(7)?,
})
}
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 FROM jobs
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
)?;
let rows = stmt.query_map([owner], Self::job_row)?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
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 FROM jobs WHERE id = ?1", [id], Self::job_row)
.optional()?)
}
/// Register a job file (or return the existing registration of that file).
pub fn job_register(&self, owner: &str, path: &str) -> Result<crate::jobs::Job> {
{
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO jobs (id, owner_id, path, enabled, created_at) VALUES (?1, ?2, ?3, 1, ?4)
ON CONFLICT(path) DO UPDATE SET enabled = 1",
rusqlite::params![crate::types::new_id(), owner, path, now_ms()],
)?;
}
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 FROM jobs WHERE path = ?1",
[path],
Self::job_row,
)?)
}
pub fn job_started(&self, id: &str, session_id: &str, ts: i64) -> Result<()> {
self.conn.lock().unwrap().execute(
"UPDATE jobs SET last_run = ?2, last_session_id = ?3, last_status = 'running' WHERE id = ?1",
rusqlite::params![id, ts, session_id],
)?;
Ok(())
}
pub fn job_finished(&self, id: &str, session_id: Option<&str>, status: &str) -> Result<()> {
self.conn.lock().unwrap().execute(
"UPDATE jobs SET last_status = ?2, last_session_id = COALESCE(?3, last_session_id) WHERE id = ?1",
rusqlite::params![id, status, session_id],
)?;
Ok(())
}
pub fn job_touch(&self, id: &str, ts: i64) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE jobs SET last_run = ?2 WHERE id = ?1", rusqlite::params![id, ts])?;
Ok(())
}
pub fn job_set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE jobs SET enabled = ?2 WHERE id = ?1", rusqlite::params![id, enabled as i64])?;
Ok(())
}
pub fn job_delete(&self, id: &str) -> Result<()> {
self.conn.lock().unwrap().execute("DELETE FROM jobs WHERE id = ?1", [id])?;
Ok(())
}
// --- per-user settings (JSON) ------------------------------------------
pub fn user_setting<T: serde::de::DeserializeOwned + Default>(&self, user_id: &str, key: &str) -> Result<T> {
let conn = self.conn.lock().unwrap();
let v: Option<String> = conn
.query_row("SELECT value FROM user_settings WHERE user_id = ?1 AND key = ?2", [user_id, key], |r| r.get(0))
.optional()?;
Ok(v.and_then(|v| serde_json::from_str(&v).ok()).unwrap_or_default())
}
pub fn set_user_setting<T: serde::Serialize>(&self, user_id: &str, key: &str, value: &T) -> Result<()> {
self.conn.lock().unwrap().execute(
"INSERT INTO user_settings (user_id, key, value) VALUES (?1, ?2, ?3)
ON CONFLICT(user_id, key) DO UPDATE SET value = ?3",
rusqlite::params![user_id, key, serde_json::to_string(value)?],
)?;
Ok(())
}
// --- web push ---------------------------------------------------------
pub fn push_add(&self, user_id: &str, endpoint: &str, p256dh: &str, auth: &str) -> Result<()> {