Claude Code engine, image viewer, colour tags, checks per turn and more
- Claude Code as an engine: chats on a "claude-code/<alias>" model run through the claude CLI (stream-json); tool approvals go through our permission system. Sign in from the web UI (URL + pasted code), CLI version and update from Settings; the Docker image contains the CLI. - Small models (8k context) for side tasks: input is cut to the model's context window; one-line chat status, "What does this do?" on approvals, "Plan as automation" (job draft from a chat). - Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark up, use in message), jump to previous own message, message menu, time under every message. - Approvals: push actions (allow/reject), "always allow in this project" (never for dangerous commands) with revoke. - Per turn: changed files with diffs and rollback; project check command with a green/red badge and "let the agent fix it". - Colour tags for chats in the colours of the active theme. - Project defaults for new chats, project page (timeline), offline queue. Tested in a throwaway container with a mock model (8k small model): 26 API checks passed (permissions, diffs, checks, defaults, job draft, side-task budget, Claude Code status/login URL, unsigned-in chat). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
084452abf6
commit
62fe5bf467
42 files changed
+3734
-55
No files matched your search
+171
-8
@@ -2,7 +2,7 @@
|
||||
//! checks), feed results back, repeat until the model stops.
|
||||
|
||||
use crate::app::{App, PermissionReply};
|
||||
use crate::permission::{Decision, Evaluator, bash_prefix, split_command};
|
||||
use crate::permission::{Decision, Evaluator, bash_prefix, is_dangerous, split_command};
|
||||
use crate::prompts::{self, Agent};
|
||||
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
||||
use crate::store::UsageRecord;
|
||||
@@ -73,6 +73,7 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
||||
.join("\n");
|
||||
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true });
|
||||
let source = source.to_string();
|
||||
let turn_started = now_ms();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let res = async {
|
||||
@@ -114,6 +115,10 @@ pub fn submit(app: Arc<App>, session_id: String, parts: Vec<Part>, source: &str)
|
||||
if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) }
|
||||
});
|
||||
app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false });
|
||||
if leftover.is_empty() && !cancel.is_cancelled() {
|
||||
spawn_summary(app.clone(), session_id.clone());
|
||||
crate::checks::spawn_after_turn(app.clone(), session_id.clone(), turn_started);
|
||||
}
|
||||
if !leftover.is_empty() {
|
||||
let _ = start_prompt(app, session_id, leftover);
|
||||
}
|
||||
@@ -145,7 +150,7 @@ pub fn abort(app: &App, session_id: &str) {
|
||||
/// Messages since the last compaction summary. Tool calls left without a
|
||||
/// result (run interrupted, e.g. by a server restart) get a synthetic result:
|
||||
/// providers reject histories with unanswered tool calls.
|
||||
fn active_history(all: Vec<Message>) -> Vec<Message> {
|
||||
pub(crate) fn active_history(all: Vec<Message>) -> Vec<Message> {
|
||||
let start = all
|
||||
.iter()
|
||||
.rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. })))
|
||||
@@ -263,6 +268,10 @@ fn run_session_boxed(
|
||||
|
||||
/// Run the loop for a session until the model stops. Returns the final text.
|
||||
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
|
||||
// Chats on a "claude-code/…" model are run by the Claude Code CLI.
|
||||
if depth == 0 && crate::claude_code::is_claude_code(&app.session(session_id)?.model) {
|
||||
return crate::claude_code::run(app, session_id, cancel).await;
|
||||
}
|
||||
let mut final_text = String::new();
|
||||
let mut stop_hooks = 0;
|
||||
let mut refine_done = 0u32;
|
||||
@@ -639,9 +648,18 @@ async fn execute_call(
|
||||
// Permission check. Approvals are tracked on the top-level session.
|
||||
let root = app.root_session_id(&session.id);
|
||||
let cfg = app.config();
|
||||
let decision = app.with_runtime(&root, |r| {
|
||||
Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input)
|
||||
let (mut decision, session_approved) = app.with_runtime(&root, |r| {
|
||||
(Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input), r.approved.clone())
|
||||
});
|
||||
// What the user allowed "for this project" earlier counts as well.
|
||||
if decision == Decision::Ask {
|
||||
let project = app.store.project_approvals(&session.cwd).unwrap_or_default();
|
||||
if !project.is_empty() {
|
||||
let mut all = session_approved;
|
||||
all.extend(project);
|
||||
decision = Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &all }.evaluate(tool.as_ref(), &input);
|
||||
}
|
||||
}
|
||||
match decision {
|
||||
Decision::Deny => {
|
||||
return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None);
|
||||
@@ -659,16 +677,27 @@ async fn execute_call(
|
||||
}
|
||||
return tool_result(call_id, call_name, text, true, None);
|
||||
}
|
||||
PermissionReply::Always => app.with_runtime(&root, |r| {
|
||||
PermissionReply::Always | PermissionReply::Project => {
|
||||
// (key, may be remembered for the whole project)
|
||||
let mut keys: Vec<(String, bool)> = Vec::new();
|
||||
if name == "bash" {
|
||||
let cmd = input["command"].as_str().unwrap_or("");
|
||||
for part in split_command(cmd).unwrap_or_default() {
|
||||
r.approved.insert(format!("bash:{}", bash_prefix(&part)));
|
||||
// Dangerous commands (rm -r, git push, docker …) are never remembered beyond this chat.
|
||||
keys.push((format!("bash:{}", bash_prefix(&part)), !is_dangerous(&part)));
|
||||
}
|
||||
} else {
|
||||
r.approved.insert(name.to_string());
|
||||
keys.push((name.to_string(), true));
|
||||
}
|
||||
}),
|
||||
app.with_runtime(&root, |r| r.approved.extend(keys.iter().map(|(k, _)| k.clone())));
|
||||
if reply == PermissionReply::Project {
|
||||
for (k, ok) in &keys {
|
||||
if *ok {
|
||||
let _ = app.store.add_project_approval(&session.cwd, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PermissionReply::Once => {}
|
||||
}
|
||||
if let Some(sel) = hunks {
|
||||
@@ -868,6 +897,8 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
||||
folder: None,
|
||||
instructions: String::new(),
|
||||
telegram_quiet: false,
|
||||
summary: String::new(),
|
||||
color: None,
|
||||
};
|
||||
let child = app.save_session(child)?;
|
||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||
@@ -1149,6 +1180,9 @@ fn small_model(app: &App, session: &Session) -> String {
|
||||
let cfg = app.config();
|
||||
if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() {
|
||||
cfg.server.small_model.clone()
|
||||
} else if crate::claude_code::is_claude_code(&session.model) {
|
||||
// Claude Code runs itself; side tasks need an ordinary model.
|
||||
app.registry().first_model_excluding(crate::claude_code::PROVIDER).unwrap_or_else(|| session.model.clone())
|
||||
} else {
|
||||
session.model.clone()
|
||||
}
|
||||
@@ -1176,6 +1210,122 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
|
||||
});
|
||||
}
|
||||
|
||||
/// Cut a text down to `max` bytes keeping the beginning (the goal) and the end (where it went).
|
||||
pub(crate) fn fit(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let head = clip(s, max * 2 / 3);
|
||||
let mut start = s.len() - max / 3;
|
||||
while !s.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
format!("{}\n…\n{}", head.split(" […").next().unwrap_or(&head), &s[start..])
|
||||
}
|
||||
|
||||
/// A short, tool-free job for the small model (summaries, explanations). The input is cut to the
|
||||
/// model's context window, so models with 8k tokens work as well as large ones: tokens left for
|
||||
/// the input = context − answer − prompt, at a conservative 2.5 bytes per token.
|
||||
async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input: &str, max_out: u64) -> Result<String> {
|
||||
let spec = small_model(app, session);
|
||||
let (provider, mut model) = app.registry().resolve(&spec)?;
|
||||
model.effort = Some("low".into());
|
||||
let ctx = model.context.unwrap_or(32_000);
|
||||
let room_tokens = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 400).max(600);
|
||||
let input = fit(input, (room_tokens as usize * 5 / 2).min(60_000));
|
||||
let req = ChatRequest {
|
||||
system: system.into(),
|
||||
messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])],
|
||||
tools: vec![],
|
||||
max_tokens: max_out,
|
||||
reasoning: false,
|
||||
session_id: Some(session.id.clone()),
|
||||
model: model.clone(),
|
||||
};
|
||||
let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?;
|
||||
record_side_usage(app, session, &spec, &model, &usage, kind);
|
||||
Ok(strip_think(&text))
|
||||
}
|
||||
|
||||
fn message_text(m: &Message) -> String {
|
||||
m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// After a turn: one line "where things stand" for the session list (small model, ~1k tokens of input).
|
||||
fn spawn_summary(app: Arc<App>, session_id: String) {
|
||||
tokio::spawn(async move {
|
||||
let Ok(session) = app.session(&session_id) else { return };
|
||||
if session.parent_id.is_some() {
|
||||
return;
|
||||
}
|
||||
let Ok(history) = app.store.messages(&session_id) else { return };
|
||||
let history = active_history(history);
|
||||
let last_user = history.iter().rev().find(|m| matches!(m.role, Role::User) && !message_text(m).trim().is_empty() && !message_text(m).trim_start().starts_with('<'));
|
||||
let last_answer = history.iter().rev().find(|m| matches!(m.role, Role::Assistant) && !message_text(m).trim().is_empty());
|
||||
let (Some(u), Some(a)) = (last_user, last_answer) else { return };
|
||||
let input = format!(
|
||||
"<previous-line>{}</previous-line>\n<user-request>\n{}\n</user-request>\n<agent-answer>\n{}\n</agent-answer>",
|
||||
session.summary,
|
||||
fit(&message_text(u), 1500),
|
||||
fit(&message_text(a), 2500)
|
||||
);
|
||||
let system = "You write the status line of a coding chat for a list. One line, at most 14 words: what was done or where things stand, concrete (file/feature names). \
|
||||
Use the language of the user's request. No quotes, no markdown, no trailing period. Reply with the line only.";
|
||||
match side_task(&app, &session, "summary", system, &input, 120).await {
|
||||
Ok(text) => {
|
||||
let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches(['"', '\'', '*', '#', ' ', '.']).chars().take(140).collect::<String>();
|
||||
if !line.is_empty() {
|
||||
let _ = app.modify_session(&session_id, false, |s| s.summary = line);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("summary failed: {e:#}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// "What does this do?" for a pending approval, in plain words (for phones: approve without reading code).
|
||||
pub async fn explain_tool_call(app: &App, session: &Session, tool: &str, title: &str, input: &Value, diff: Option<&str>, lang: &str) -> Result<String> {
|
||||
let language = if lang.starts_with("de") { "German" } else { "English" };
|
||||
let system = format!(
|
||||
"You explain a pending action of an AI coding agent to the user who has to approve it. Answer in {language}, plain words, at most 3 short sentences: \
|
||||
what it does, and whether anything is risky (deletes or overwrites data, network access, secrets, changes outside the project, hard to undo). \
|
||||
If it is harmless, say so. No markdown headings, no code blocks."
|
||||
);
|
||||
let mut body = format!("<tool>{tool}</tool>\n<title>{title}</title>\n<input>\n{}\n</input>", clip(&input.to_string(), 3000));
|
||||
if let Some(d) = diff {
|
||||
body.push_str(&format!("\n<diff>\n{}\n</diff>", clip(d, 3000)));
|
||||
}
|
||||
side_task(app, session, "explain", &system, &body, 300).await
|
||||
}
|
||||
|
||||
/// Turn a chat into the instructions of a repeatable automation: (name, instructions in Markdown).
|
||||
/// Works with a small model: the transcript is cut to its context window.
|
||||
pub async fn draft_job(app: &App, session: &Session) -> Result<(String, String)> {
|
||||
let history = active_history(app.store.messages(&session.id)?);
|
||||
let transcript = transcript_of(&history);
|
||||
if transcript.trim().is_empty() {
|
||||
bail!("the session has no messages yet");
|
||||
}
|
||||
let system = "You turn a finished coding-assistant conversation into instructions for an AUTOMATION that repeats the useful work on a schedule, without a human in the loop. \
|
||||
Reply in exactly this format. First line: NAME: <at most 5 words>. Then the instructions in Markdown with the headings '## Goal', '## Steps' (numbered), '## Result' and '## Limits'. \
|
||||
Write them standalone: never refer to 'the conversation' or 'above'; name concrete paths, commands and checks that were used. \
|
||||
Use the language of the user's messages. Keep it under 300 words.";
|
||||
let text = side_task(app, session, "job-draft", system, &format!("<conversation>\n{transcript}\n</conversation>"), 1200).await?;
|
||||
let mut lines = text.lines().skip_while(|l| l.trim().is_empty());
|
||||
let first = lines.next().unwrap_or("");
|
||||
let (name, body) = match first.trim().strip_prefix("NAME:").or_else(|| first.trim().strip_prefix("Name:")) {
|
||||
Some(n) => (n.trim().trim_matches(['"', '*', '#', ' ']).to_string(), lines.collect::<Vec<_>>().join("\n")),
|
||||
None => (String::new(), text.clone()),
|
||||
};
|
||||
let body = body.trim().to_string();
|
||||
if body.is_empty() {
|
||||
bail!("the model returned no instructions");
|
||||
}
|
||||
let name = if name.is_empty() { session.title.clone() } else { name };
|
||||
let name: String = if name.trim().is_empty() { "Automation".into() } else { name.chars().take(60).collect() };
|
||||
Ok((name.clone(), format!("# {name}\n\n{body}\n")))
|
||||
}
|
||||
|
||||
fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
||||
let Ok(session) = app.session(session_id) else { return };
|
||||
if session.parent_id.is_some() || !session.title.is_empty() {
|
||||
@@ -1524,6 +1674,19 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fit_keeps_head_and_tail_within_budget() {
|
||||
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
|
||||
let out = fit(&text, 3000);
|
||||
assert!(out.len() <= 3100, "{}", out.len());
|
||||
assert!(out.starts_with("AAAA") && out.ends_with("ZZZZ") && out.contains('…'));
|
||||
// Multi-byte text is cut on character boundaries.
|
||||
let de = "äöüß".repeat(2000);
|
||||
assert!(fit(&de, 1000).is_char_boundary(0));
|
||||
assert_eq!(fit("short", 100), "short");
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn todo_completion_and_reopening() {
|
||||
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||
|
||||
Reference in new issue
Block a user