Compare commits

..
24 Commits
Author SHA1 Message Date
Richard Zink c551171c18 Release v202611-00041 2026-11-15 22:19:56 +01:00
Richard ZinkandClaude Sonnet 5.5 4000084560 Software factory (review phase): per-user Gitea/GitHub/GitLab connections, spec chat, project creation, factory page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 22:19:45 +01:00
Richard Zink b5e2ef146c Release v202611-00040 2026-11-15 15:09:13 +01:00
Richard ZinkandClaude Sonnet 5.5 a2b9528e0b Fix uploaded images reaching Claude Code empty
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 15:09:11 +01:00
Richard Zink 81d9df4e6e Release v202611-00039 2026-11-15 14:59:12 +01:00
Richard ZinkandClaude Sonnet 5.5 ead75e78e0 Neutral chat background for colour tags; answer Claude Code's AskUserQuestion with the question card
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 14:59:10 +01:00
Richard Zink 77f9d4b0e0 Release v202611-00038 2026-11-15 14:37:31 +01:00
Richard ZinkandClaude Sonnet 5.5 103132a359 Sidebar chat menu: add Delete, remove colour and icon pickers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 14:37:29 +01:00
Richard Zink 5d5c0f8124 Release v202611-00037 2026-11-15 13:53:13 +01:00
Richard ZinkandClaude Sonnet 5.5 f77b101847 Ask about the chat's own folder when deleting a chat
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 13:53:10 +01:00
Richard Zink 12a3198e77 Release v202611-00036 2026-11-15 13:04:03 +01:00
Richard ZinkandClaude Sonnet 5.5 4e6f74d477 Offer all CodeCraft tools to the Claude Code CLI over an MCP bridge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 13:04:00 +01:00
Richard Zink 658148ada7 Release v202611-00035 2026-11-15 12:33:26 +01:00
Richard ZinkandClaude Sonnet 5.5 b6e4500233 Fix SSH key creation as a uid without passwd entry (ssh-keygen, ssh-keyscan, ssh-add wrappers)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 12:33:24 +01:00
Richard Zink 126c7719b9 Release v202611-00034 2026-11-15 12:15:04 +01:00
Richard ZinkandClaude Sonnet 5.5 fccaee4b94 Fix ssh/scp/sftp for containers running as a uid without passwd entry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 12:15:02 +01:00
Richard Zink d58b23d35f Release v202611-00033 2026-11-15 11:47:34 +01:00
Richard ZinkandClaude Sonnet 5.5 6e8e739906 SSH keys in account settings; wrappers for agents, terminal and git
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 11:47:31 +01:00
Richard Zink 247ddcf14a Release v202611-00032 2026-11-15 10:52:10 +01:00
Richard ZinkandClaude Sonnet 5.5 f0cd404f59 Tell agents about stored git credentials; give Claude Code the user's git config
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 10:52:07 +01:00
Richard Zink ec95f9b8ad Release v202611-00031 2026-11-15 09:54:04 +01:00
Richard ZinkandClaude Sonnet 5.5 99ba3835f3 Show images read by agents; keep tool-result images with Claude Code
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 09:54:00 +01:00
Richard Zink 92a99c6a65 Release v202611-00030 2026-11-15 09:24:58 +01:00
Richard ZinkandClaude Sonnet 5.5 4c7d67cabf Add /clear command
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 09:24:56 +01:00
38 changed files with 4165 additions and 49 deletions

No files matched your search

+3
View File
@@ -9,6 +9,9 @@ CODECRAFT_ADMIN_PASSWORD=
# Directory with your projects, mounted as /workspace.
WORKSPACE=/path/to/your/projects
# Directory of the software factory (clones it works in), mounted as /factory.
FACTORY_DIR=./factory
# Host port and address (127.0.0.1 = only reachable through a local reverse proxy).
PORT=8787
BIND_ADDR=127.0.0.1
+108
View File
@@ -3,6 +3,114 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
## [v202611-00041] – 2026-11-15
### Added
- **Software factory** (sidebar → Factory): specs arrive as issues (label `factory`) on Gitea, GitHub or
GitLab and are worked off by agents in phases, each phase with its own model, agent, permission mode
and time limit. This version contains the **review phase**: an agent reads the spec and the code and
decides *approved*, *questions* (posted as an issue comment; your answer restarts the review) or
*rejected*, and writes acceptance scenarios that stay in the database, away from the builder. Build and
acceptance follow.
- **Connections per user**: every user sets up their own accounts (Gitea, GitHub, GitLab) with their own
token; repositories use the token of their connection or one of their own. Models are free per user,
project and phase.
- **New project with a model**: *Factory → New project* opens a spec chat. When the spec is done, the
*Create project* bar picks name, connection and owner, creates the repository (private), commits the
spec as `docs/specs/<name>.md` and opens the issue that starts the workflow.
- **Overview**: board with all issues by state, a tile per project, and per run the model, status,
duration, tokens and cost, with a link into the chat of that run.
- **Separate factory area**: repositories are cloned into their own mount `/factory` (`FACTORY_DIR` in
`.env`), not into the workspace. The API token is handed to git only as a header and never reaches
agents.
## [v202611-00040] – 2026-11-15
### Fixed
- **Images you upload reach Claude Code**: its chats read the history without the image bytes, so the
files it was pointed to were empty. Images without data are no longer written as empty files.
## [v202611-00039] – 2026-11-15
### Changed
- A chat's colour no longer tints the chat background (pane, message field, agent bubbles, top bar
fill); it still marks the top bar line, the list entry, the badge and your own bubbles.
### Fixed
- **Claude Code can ask questions again**: its `AskUserQuestion` was always rejected; now the question
appears as a question card in the chat (single and multiple choice) and the answers go back to it.
## [v202611-00038] – 2026-11-15
### Changed
- The chat menu in the sidebar has a **Delete** entry (with the question about the chat's own folder);
colour and icon selection moved out of it – they remain in the ⋯ menu inside the chat.
## [v202611-00037] – 2026-11-15
### Added
- **Deleting a chat asks about its folder**: if the chat has a folder of its own (inside your folders,
not shared with any other chat, no project folder), you can delete it with the chat – with the
number of files, size and a warning for git repositories. The server checks again before deleting.
## [v202611-00036] – 2026-11-15
### Added
- **Claude Code gets all CodeCraft tools** (image generation, 3D, OpenSCAD, browser, scheduling,
diagnostics, reverse engineering, skills, ask_user, the server's MCP tools …) over an MCP bridge,
as `mcp__codecraft__<name>`. Tools the CLI has itself (read, write, edit, bash, grep …) are not
offered twice. Calls go through the usual approval cards and rules.
## [v202611-00035] – 2026-11-15
### Fixed
- Creating an SSH key failed with "No user exists for uid 99": `ssh-keygen`, `ssh-keyscan` and
`ssh-add` get the same passwd workaround as `ssh`.
## [v202611-00034] – 2026-11-15
### Fixed
- `ssh`, `scp` and `sftp` failed with "No user exists for uid 99" because the container runs as a uid
without passwd entry; the image now wraps them with nss_wrapper (also used by the key wrappers).
## [v202611-00033] – 2026-11-15
### Added
- **SSH keys** (Settings → Account): generate (ed25519) or import keys; agents, the terminal and the
git panel use them automatically for `ssh`, `scp`, `sftp`, `rsync` and git over ssh. Hosts are
trusted on first contact; the public key can be copied for the server's `authorized_keys`.
## [v202611-00032] – 2026-11-15
### Fixed
- Agents now know that git credentials are stored (hosts, no secrets) and no longer search the
environment for them; the system prompt explains `git push` just works and how to read the token
for REST calls without printing it. Claude Code runs get the user's git credentials and identity too.
## [v202611-00031] – 2026-11-15
### Fixed
- Images read by an agent (Read tool) are shown right away; with Claude Code they were dropped.
## [v202611-00030] – 2026-11-15
### Added
- **`/clear`**: the model forgets the conversation so far; the chat keeps showing it, a divider marks
the cut. Also starts a fresh session with Claude Code.
## [v202611-00029] – 2026-11-15
### Added
+8 -1
View File
@@ -85,7 +85,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
openscad xvfb xauth \
openssh-client rsync mc \
openssh-client libnss-wrapper rsync mc \
&& rm -rf /var/lib/apt/lists/*
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
# installed by agents: rustup component add rust-analyzer, go install …).
@@ -98,6 +98,13 @@ COPY --from=dockercli /docker /usr/local/bin/docker
COPY server/ghidra /app/ghidra
# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer).
COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git
# ssh, scp, sftp and the ssh-* tools (the server creates keys with ssh-keygen) also work when the container runs as a uid without passwd entry.
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh
COPY --chmod=755 docker/ssh-nss /usr/local/bin/scp
COPY --chmod=755 docker/ssh-nss /usr/local/bin/sftp
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keygen
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keyscan
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-add
COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
+1 -1
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version v202611-00029" src="https://img.shields.io/badge/version-v202611--00029-d69e2e?style=flat-square">
<img alt="Version v202611-00041" src="https://img.shields.io/badge/version-v202611--00041-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">
+2
View File
@@ -34,6 +34,8 @@ services:
- codecraft-data:/data
# The projects the agents may work on.
- ${WORKSPACE:-./workspace}:/workspace
# Separate area for the software factory (clones of the watched repositories).
- ${FACTORY_DIR:-./factory}:/factory
# Reach self-hosted models (Ollama, vLLM, ...) on the host.
extra_hosts:
- "host.docker.internal:host-gateway"
+2
View File
@@ -30,6 +30,8 @@ services:
- codecraft-data:/data
# The projects the agents may work on.
- ${WORKSPACE:-./workspace}:/workspace
# Separate area for the software factory (clones of the watched repositories).
- ${FACTORY_DIR:-./factory}:/factory
# Reach self-hosted models (Ollama, vLLM, ...) on the host.
extra_hosts:
- "host.docker.internal:host-gateway"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# ssh, scp and sftp for containers that run as an arbitrary uid (docker-compose `user:`):
# OpenSSH refuses to start without a passwd entry for the uid ("No user exists for uid N").
# Without an entry, nss_wrapper provides one for this process; otherwise nothing changes.
if ! getent passwd "$(id -u)" >/dev/null 2>&1; then
d=${TMPDIR:-/tmp}/codecraft-nss-$(id -u)
mkdir -p "$d" 2>/dev/null
printf 'codecraft:x:%s:%s:CodeCraft:%s:/bin/sh\n' "$(id -u)" "$(id -g)" "${HOME:-/}" > "$d/passwd"
printf 'codecraft:x:%s:\n' "$(id -g)" > "$d/group"
export NSS_WRAPPER_PASSWD="$d/passwd" NSS_WRAPPER_GROUP="$d/group"
export LD_PRELOAD="libnss_wrapper.so${LD_PRELOAD:+:$LD_PRELOAD}"
fi
exec "/usr/bin/$(basename "$0")" "$@"
+287 -3
View File
@@ -86,6 +86,7 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/search", get(search_in_session))
.route("/sessions/{id}/media", get(session_media))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/folder", get(session_folder))
.route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort))
.route("/sessions/{id}/compact", post(compact))
@@ -103,6 +104,7 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/links", get(list_links).post(create_link))
.route("/sessions/{id}/links/{token}", delete(delete_link))
.route("/public/chat/{token}", get(public_chat))
.route("/mcp-bridge/{token}", post(crate::mcp_bridge::handle))
.route("/sessions/{id}/shares", get(get_shares).put(put_shares))
.route("/sessions/{id}/queue", get(get_queue))
.route("/sessions/{id}/queue/flush", post(flush_queue))
@@ -157,6 +159,16 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/factory", get(get_factory).put(put_factory))
.route("/account/factory/test", post(test_factory))
.route("/factory/overview", get(factory_overview))
.route("/factory/poll", post(factory_poll))
.route("/account/factory/connection/test", post(test_factory_connection))
.route("/factory/drafts", post(factory_new_draft))
.route("/factory/drafts/{id}", get(factory_draft))
.route("/factory/drafts/{id}/create", post(factory_create_project))
.route("/account/ssh", get(get_ssh_keys).post(add_ssh_key))
.route("/account/ssh/{id}", delete(delete_ssh_key))
.route("/account/snippets", get(crate::cli_api::get_snippets).put(crate::cli_api::put_snippets))
.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))
@@ -1053,8 +1065,83 @@ async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(json!({"ok": true})))
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
/// The folder a chat owns: inside the owner's folders (never one of them), not a link, and no
/// other chat works in it, below it or above it (then it belongs to a project, not to this chat).
/// Returns the folder and the titles of chats that prevent it.
fn own_folder(app: &App, session: &Session) -> (Option<PathBuf>, Vec<String>) {
let Ok(dir) = std::fs::canonicalize(&session.cwd) else { return (None, vec![]) };
if std::fs::symlink_metadata(&session.cwd).map(|m| m.file_type().is_symlink()).unwrap_or(true) || !dir.is_dir() {
return (None, vec![]);
}
let roots: Vec<PathBuf> = app.user_roots(session.owner_id.as_deref()).iter().filter_map(|r| std::fs::canonicalize(r).ok()).collect();
if !roots.iter().any(|r| dir.starts_with(r) && dir != *r) {
return (None, vec![]);
}
let mut blockers = Vec::new();
for (title, cwd) in app.store.other_session_cwds(&session.id).unwrap_or_default() {
let Ok(other) = std::fs::canonicalize(&cwd) else { continue };
if other.starts_with(&dir) || dir.starts_with(&other) {
blockers.push(if title.is_empty() { "(untitled)".to_string() } else { title });
}
}
if blockers.is_empty() { (Some(dir), blockers) } else { (None, blockers) }
}
/// Facts for the delete dialog: does the chat have a folder of its own, and what is in it?
async fn session_folder(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let session = writable(&app, &user, &id)?;
let (dir, blockers) = own_folder(&app, &session);
let Some(dir) = dir else { return Ok(Json(json!({"own": false, "shared_with": blockers}))) };
let (files, bytes, git, truncated) = tokio::task::spawn_blocking({
let dir = dir.clone();
move || {
let (mut files, mut bytes, mut seen) = (0u64, 0u64, 0u64);
let mut stack = vec![dir.clone()];
let mut truncated = false;
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
// Git's own files are not "the chat's files".
if e.file_name() == ".git" {
continue;
}
seen += 1;
if seen > 50_000 {
truncated = true;
stack.clear();
break;
}
let Ok(m) = e.metadata() else { continue };
if m.is_dir() && !e.file_type().map(|t| t.is_symlink()).unwrap_or(true) {
stack.push(e.path());
} else if m.is_file() {
files += 1;
bytes += m.len();
}
}
}
(files, bytes, dir.join(".git").exists(), truncated)
}
})
.await
.unwrap_or((0, 0, false, false));
Ok(Json(json!({"own": true, "path": dir, "files": files, "bytes": bytes, "git": git, "truncated": truncated})))
}
#[derive(Deserialize)]
struct DeleteQ {
/// Also delete the chat's own folder (only if it really is one – checked again here).
#[serde(default)]
delete_folder: bool,
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<DeleteQ>) -> ApiResult {
let session = writable(&app, &user, &id)?;
// Decide about the folder while the chat and all the others still exist.
let folder = if q.delete_folder { own_folder(&app, &session).0 } else { None };
if q.delete_folder && folder.is_none() {
return Err(ApiError(StatusCode::CONFLICT, "this chat has no folder of its own (other chats use it, or it is a project folder)".into()));
}
// Notify before deleting: visibility is checked against the stored session.
app.emit(Event::SessionDeleted { session_id: id.clone() });
runner::abort(&app, &id);
@@ -1068,7 +1155,20 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
tracing::warn!("image cleanup: {e:#}");
}
});
Ok(Json(json!({"ok": true})))
let mut folder_error = None;
let mut folder_deleted = false;
if let Some(dir) = folder {
let shown = dir.display().to_string();
match tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir)).await {
Ok(Ok(())) => {
folder_deleted = true;
app.audit(Some(&user), "session.delete_folder", &shown, "");
}
Ok(Err(e)) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
Err(e) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
}
}
Ok(Json(json!({"ok": true, "folder_deleted": folder_deleted, "folder_error": folder_error})))
}
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
@@ -1128,6 +1228,13 @@ pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text:
tokio::spawn(do_compact(app.clone(), id.clone()));
return Ok(json!({"ok": true, "command": "compact"}));
}
if text == "/clear" {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
runner::clear_context(app, &id).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
return Ok(json!({"ok": true, "command": "clear"}));
}
let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut prompt_text = text.clone();
@@ -2419,6 +2526,57 @@ async fn run_job(Extension(user): Extension<User>, State(app): State<AppState>,
// --- git credentials ---------------------------------------------------------------
// --- SSH keys -----------------------------------------------------------------
async fn get_ssh_keys(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
#[derive(Deserialize)]
struct NewSshKey {
name: String,
/// An existing private key to import; a new key is generated when missing.
private: Option<String>,
}
async fn add_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<NewSshKey>) -> ApiResult {
let name = b.name.trim().to_string();
if name.is_empty() || name.chars().count() > 60 {
return Err(ApiError(StatusCode::BAD_REQUEST, "give the key a name (up to 60 characters)".into()));
}
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
if s.keys.len() >= 10 {
return Err(ApiError(StatusCode::BAD_REQUEST, "at most 10 keys".into()));
}
let key = match b.private.as_deref().filter(|p| !p.trim().is_empty()) {
Some(p) => crate::sshkeys::import(&name, p).await,
None => crate::sshkeys::generate(&name).await,
}
.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
if s.keys.iter().any(|k| k.fingerprint == key.fingerprint && !key.fingerprint.is_empty()) {
return Err(ApiError(StatusCode::CONFLICT, "this key is already stored".into()));
}
s.keys.push(key);
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "ssh", &s)?;
app.audit(Some(&user), "ssh.key.add", &name, "");
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
async fn delete_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
let before = s.keys.len();
s.keys.retain(|k| k.id != id);
if s.keys.len() == before {
return Err(not_found("key"));
}
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "ssh", &s)?;
app.audit(Some(&user), "ssh.key.delete", &id, "");
Ok(Json(json!(crate::sshkeys::masked(&s))))
}
async fn get_git_settings(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?;
Ok(Json(json!(crate::gitcreds::masked(&s))))
@@ -2434,6 +2592,132 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
Ok(Json(json!(crate::gitcreds::masked(&new))))
}
// --- software factory ---------------------------------------------------------------
async fn get_factory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
Ok(Json(json!(crate::factory::masked(&s))))
}
async fn put_factory(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<crate::factory::FactorySettings>) -> ApiResult {
let old: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let mut new = crate::factory::merge(&old, b);
for r in &mut new.repos {
r.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{}: {e:#}", r.url)))?;
}
app.store.set_user_setting(&user.id, "factory", &new)?;
let urls: Vec<&str> = new.repos.iter().map(|r| r.url.as_str()).collect();
app.audit(Some(&user), "factory.settings", urls.join(", "), &crate::auth::client_ip(&headers));
Ok(Json(json!(crate::factory::masked(&new))))
}
#[derive(Deserialize)]
struct FactoryTest {
url: String,
}
/// Check token and repository and create the missing status labels.
async fn test_factory(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<FactoryTest>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let cfg = s.repos.iter().find(|r| r.url == b.url.trim()).map(|r| s.resolve(r)).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "save the repository first".into()))?;
let cfg = &cfg;
let g = crate::factory::Forge::new(cfg).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let info = g.check().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let created = g.ensure_labels().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let dir = crate::factory::checkout(&app, &user, cfg).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"ok": true, "info": info, "labels_created": created, "folder": dir.display().to_string()})))
}
/// Everything the factory page shows: repositories, issues with state, runs with tokens/cost.
async fn factory_overview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let repos: Vec<Value> = s.repos.iter().map(|r| json!({"url": r.url, "enabled": r.enabled, "label": r.label, "max_parallel": r.max_parallel, "kind": s.resolve(r).kind, "has_token": !s.resolve(r).token.is_empty(), "models": {"review": r.review.model, "build": r.build.model, "qa": r.qa.model}, "running": app.store.factory_running(&r.url).unwrap_or(0)})).collect();
Ok(Json(json!({
"area": app.factory_dir().is_some(),
"repos": repos,
"issues": app.store.factory_issues(&user.id)?,
"runs": app.store.factory_runs(&user.id, None, None, 300)?,
})))
}
/// Look for new issues right now instead of waiting for the next poll.
async fn factory_poll(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let mut errors = Vec::new();
for cfg in s.repos.iter().filter(|r| r.enabled).map(|r| s.resolve(r)).filter(|r| !r.token.is_empty()) {
if let Err(e) = crate::factory::poll_repo(&app, &user, &cfg).await {
errors.push(format!("{}: {e:#}", cfg.url));
}
}
Ok(Json(json!({"errors": errors})))
}
#[derive(Deserialize)]
struct ConnTest {
id: String,
}
/// Test a connection: who the token belongs to and which organisations/groups it can use.
async fn test_factory_connection(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ConnTest>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let conn = s.connection(&b.id).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "save the connection first".into()))?;
let g = crate::factory::Forge::for_conn(conn).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let login = g.login().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let orgs = g.orgs().await.unwrap_or_default();
Ok(Json(json!({"login": login, "orgs": orgs})))
}
#[derive(Deserialize)]
struct NewDraft {
#[serde(default)]
model: Option<String>,
}
/// Start a chat in which the user writes a spec with a model.
async fn factory_new_draft(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<NewDraft>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let model = b.model.filter(|m| !m.is_empty()).unwrap_or(s.defaults.spec_model.clone());
let session = crate::factory::start_spec_chat(&app, &user, &model).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
Ok(Json(json!(session)))
}
fn draft_session(app: &AppState, user: &User, id: &str) -> Result<(crate::types::Session, std::path::PathBuf), ApiError> {
let session = app.session(id).map_err(|_| ApiError(StatusCode::NOT_FOUND, "unknown chat".into()))?;
let dir = crate::factory::draft_of(app, user, &session).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "not a spec chat".into()))?;
Ok((session, dir))
}
/// The state of a spec chat: spec text, the model's name proposal, connections to choose from.
async fn factory_draft(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let (_, dir) = draft_session(&app, &user, &id)?;
let spec = std::fs::read_to_string(dir.join("spec.md")).unwrap_or_default();
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let conns: Vec<Value> = s.connections.iter().map(|c| json!({"id": c.id, "name": c.name, "kind": c.kind, "owner": c.owner})).collect();
Ok(Json(json!({"spec": spec, "proposal": crate::factory::proposal(&app, &id), "connections": conns})))
}
#[derive(Deserialize)]
struct CreateProject {
connection: String,
name: String,
slug: String,
#[serde(default)]
description: String,
#[serde(default)]
owner: String,
}
/// Create the repository, file the spec and open the issue that starts the workflow.
async fn factory_create_project(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, Json(b): Json<CreateProject>) -> ApiResult {
let (_, dir) = draft_session(&app, &user, &id)?;
let spec = std::fs::read_to_string(dir.join("spec.md")).unwrap_or_default();
let mut s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let res = crate::factory::create_project(&app, &user, &mut s, &spec, &b.connection, &b.name, &b.slug, &b.description, &b.owner).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "factory", &s)?;
app.audit(Some(&user), "factory.project.create", res["repo"].as_str().unwrap_or(""), &crate::auth::client_ip(&headers));
Ok(Json(res))
}
// --- web terminal ------------------------------------------------------------------
#[derive(Deserialize)]
+18 -1
View File
@@ -189,6 +189,20 @@ impl App {
std::fs::canonicalize(&ws).unwrap_or(ws)
}
/// Separate area of the software factory (mounted outside the workspace); None when absent.
pub fn factory_dir(&self) -> Option<PathBuf> {
let p = std::env::var("CODECRAFT_FACTORY_DIR").ok().filter(|p| !p.is_empty()).unwrap_or_else(|| "/factory".into());
let p = PathBuf::from(p);
p.is_dir().then(|| std::fs::canonicalize(&p).unwrap_or(p))
}
/// The user's own folder in the factory area (created on demand).
pub fn factory_home(&self, user: &crate::store::User) -> Option<PathBuf> {
let dir = self.factory_dir()?.join(&user.username);
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
/// Directories a session owner may work in: admins (and legacy sessions
/// without owner) the whole workspace, users their own folder plus shared folders.
pub fn user_roots(&self, owner_id: Option<&str>) -> Vec<PathBuf> {
@@ -196,12 +210,15 @@ impl App {
let Some(user) = owner_id.and_then(|id| self.store.user(id).ok().flatten()) else {
return vec![ws];
};
// The user's factory folder (if the area is mounted) is an additional root.
let factory = self.factory_dir().map(|d| d.join(&user.username)).filter(|d| d.is_dir());
if user.is_admin() {
return vec![ws];
return std::iter::once(ws).chain(factory).collect();
}
let own = ws.join(&user.username);
let _ = std::fs::create_dir_all(&own);
let mut roots = vec![own];
roots.extend(factory);
for p in self.store.folders_for_user(&user.id).unwrap_or_default() {
let p = PathBuf::from(p);
if p.starts_with(&ws) && p.is_dir() {
+3
View File
@@ -189,6 +189,9 @@ fn is_public(path: &str) -> bool {
// Read-only chat links (the token is the credential).
|| path.starts_with("/public/chat/")
|| path.starts_with("/api/public/chat/")
// MCP bridge for the Claude Code CLI (per-run token).
|| path.starts_with("/mcp-bridge/")
|| path.starts_with("/api/mcp-bridge/")
}
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
+167 -11
View File
@@ -11,7 +11,7 @@ use crate::config::ModelConfig;
use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command};
use crate::provider::{ChatRequest, Provider, StreamEvent};
use crate::store::UsageRecord;
use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
use crate::types::{Event, ImageData, Message, Part, Role, Session, Usage, new_id, now_ms};
use anyhow::{Result, anyhow, bail};
use async_trait::async_trait;
use serde_json::{Value, json};
@@ -97,14 +97,66 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul
c.env(k, v);
}
}
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").args(args);
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").args(args);
// The user's git credentials and identity (the CLI runs git for the agent).
if let Some(user) = owner.and_then(|id| app.store.user(id).ok().flatten()) {
if let Some(cfg) = crate::gitcreds::global_config(app, &user) {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(crate::gitcreds::identity_env(app, &user.id));
c.envs(crate::sshkeys::env(app, &user));
}
Ok(c)
}
// --- tool mapping -----------------------------------------------------------------------
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
/// Claude Code's `AskUserQuestion` input → our questions (it writes `multiSelect`).
fn cc_questions(input: &Value) -> Vec<crate::types::Question> {
input["questions"]
.as_array()
.into_iter()
.flatten()
.filter_map(|q| {
let question = q["question"].as_str()?.to_string();
let options = q["options"]
.as_array()
.into_iter()
.flatten()
.filter_map(|o| Some(crate::types::QuestionOption { label: o["label"].as_str()?.to_string(), description: o["description"].as_str().unwrap_or("").to_string() }))
.collect();
Some(crate::types::Question { question, header: q["header"].as_str().unwrap_or("").to_string(), options, multi_select: q["multiSelect"].as_bool().or(q["multi_select"].as_bool()).unwrap_or(false) })
})
.collect()
}
/// Our answers (question → text or list) as the CLI expects them: question → text, lists joined by ", ".
fn cc_answers(answers: &serde_json::Map<String, Value>) -> Value {
Value::Object(
answers
.iter()
.map(|(q, a)| {
let text = match a {
Value::String(s) => s.clone(),
Value::Array(l) => l.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "),
other => other.to_string(),
};
(q.clone(), Value::String(text))
})
.collect(),
)
}
fn map_tool(name: &str, input: &Value) -> (String, Value) {
// Questions to the user show up as our question card / ask_user call.
if name == "AskUserQuestion" {
return ("ask_user".to_string(), json!({"questions": cc_questions(input)}));
}
// Our own tools reach the CLI over MCP: shown and judged under their usual names.
if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
return (ours.to_string(), input.clone());
}
let mut v = input.clone();
let rename = |v: &mut Value, from: &str, to: &str| {
if let Some(x) = v.get(from).cloned() {
@@ -141,6 +193,8 @@ impl ToolInfo for CcTool {
}
fn read_only(&self) -> bool {
matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list")
// One of our tools (MCP bridge): as it declares itself.
|| crate::tools::builtin_tools().iter().any(|t| t.name() == self.name && t.read_only())
}
fn is_edit(&self) -> bool {
matches!(self.name.as_str(), "write" | "edit")
@@ -170,16 +224,16 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
match name {
"bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()),
"write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(),
_ => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()),
_ => match crate::tools::builtin_tools().iter().find(|t| t.name() == name) {
Some(t) => t.title(input),
None => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()),
},
}
}
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
let (name, input) = map_tool(claude_name, claude_input);
if claude_name == "AskUserQuestion" {
return (false, Some("Questions to the user are not available in this mode – decide yourself or state your assumption.".into()));
}
let info = CcTool { name: name.clone() };
let cfg = app.config();
let root = app.root_session_id(&session.id);
@@ -286,6 +340,20 @@ fn result_text(content: &Value) -> String {
}
}
/// Images inside a tool result (the CLI's Read of a picture): base64 blocks.
fn result_images(content: &Value) -> Vec<ImageData> {
content
.as_array()
.into_iter()
.flatten()
.filter(|b| b["type"] == "image" && b["source"]["type"] == "base64")
.filter_map(|b| {
let data = b["source"]["data"].as_str().filter(|d| !d.is_empty())?;
Some(ImageData { media_type: b["source"]["media_type"].as_str().unwrap_or("image/png").to_string(), data: data.to_string(), blob: None, size: None })
})
.collect()
}
/// Images of the prompt as files the CLI can read (it has no inline image input in this mode).
fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
use base64::Engine;
@@ -293,7 +361,12 @@ fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
let mut out = Vec::new();
for (i, p) in parts.iter().enumerate() {
let Part::Image { image } = p else { continue };
// An image without bytes (an unresolved reference) would become an empty file.
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue };
if bytes.is_empty() {
tracing::warn!("claude code: image {i} of the message has no data – not saved");
continue;
}
let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png");
let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms()));
if std::fs::create_dir_all(&dir).is_ok() {
@@ -311,7 +384,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
let session = app.session(session_id)?;
let owner = session.owner_id.clone();
let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string();
let history = crate::runner::active_history(app.store.messages(session_id)?);
// With the bytes of the images filled in (they are stored as references).
let history = crate::runner::active_history(app.store.messages_for_model(session_id)?);
let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else {
bail!("nothing to do: no user message");
};
@@ -343,8 +417,18 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
if let Some(r) = &resume {
args.extend(["--resume".into(), r.clone()]);
}
if !session.instructions.trim().is_empty() {
args.extend(["--append-system-prompt".into(), session.instructions.trim().to_string()]);
// CodeCraft's own tools for the CLI (MCP, per-run token).
let bridge = crate::mcp_bridge::register(session_id);
args.extend(["--mcp-config".into(), crate::mcp_bridge::config_json(&app, &bridge.token)]);
let mut appended = session.instructions.trim().to_string();
if let Some(note) = crate::mcp_bridge::prompt_section(&crate::mcp_bridge::tools_for(&app, &session)) {
appended = format!("{appended}\n{note}").trim().to_string();
}
for extra in owner.as_deref().into_iter().flat_map(|u| [crate::gitcreds::prompt_section(&app, u), crate::sshkeys::prompt_section(&app, u)]).flatten() {
appended = format!("{appended}\n{extra}").trim().to_string();
}
if !appended.is_empty() {
args.extend(["--append-system-prompt".into(), appended]);
}
let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?;
@@ -376,6 +460,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
let mut cur: Option<Cur> = None;
let mut calls: HashMap<String, String> = HashMap::new();
let mut diffs: HashMap<String, String> = HashMap::new();
// Answers to AskUserQuestion calls, kept as the result's data for the question card.
let mut asked: HashMap<String, Value> = HashMap::new();
let mut final_text = String::new();
let mut got_result = false;
let mut failure: Option<String> = None;
@@ -457,8 +543,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
}
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
let is_error = b["is_error"].as_bool().unwrap_or(false);
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) };
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: vec![], is_error, meta });
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})).or_else(|| asked.remove(&id).map(|a| json!({"answers": a}))) };
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta });
}
if !parts.is_empty() {
let m = Message::new(Role::User, parts);
@@ -470,6 +556,30 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
"control_request" if ev["request"]["subtype"] == "can_use_tool" => {
finalize(&app, session_id, &mut cur, &model_id);
let req = &ev["request"];
// A question for the user: shown as a question card; the answers go back to the CLI.
if req["tool_name"] == "AskUserQuestion" {
let questions = cc_questions(&req["input"]);
let response = if questions.is_empty() {
json!({"behavior": "deny", "message": "No usable question was given."})
} else {
match app.ask_user(session_id, "agent", questions, cancel.clone()).await {
Ok(Some(answers)) => {
let mut input = req["input"].clone();
input["answers"] = cc_answers(&answers);
if let Some(id) = req["tool_use_id"].as_str() {
asked.insert(id.to_string(), Value::Object(answers));
}
json!({"behavior": "allow", "updatedInput": input})
}
_ => json!({"behavior": "deny", "message": "The user dismissed the questions without answering."}),
}
};
let reply = json!({"type": "control_response", "response": {"subtype": "success", "request_id": ev["request_id"], "response": response}});
if stdin.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
break;
}
continue;
}
let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
if allow {
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
@@ -852,6 +962,52 @@ pub async fn update() -> Result<String> {
#[cfg(test)]
mod tests {
#[test]
fn uploaded_images_are_saved_with_their_bytes_only() {
let dir = std::env::temp_dir().join(format!("cc-uploads-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let parts = vec![
Part::Image { image: ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None } },
// A reference whose bytes were not filled in: must not become an empty file.
Part::Image { image: ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None } },
];
let saved = save_images(&dir.display().to_string(), &parts);
assert_eq!(saved.len(), 1);
assert_eq!(std::fs::read(&saved[0]).unwrap(), b"ABC");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn questions_roundtrip_with_the_cli_format() {
let input = json!({"questions": [
{"question": "Which database?", "header": "DB", "multiSelect": false, "options": [{"label": "SQLite", "description": "file"}, {"label": "Postgres"}]},
{"question": "Which extras?", "header": "Extras", "multiSelect": true, "options": [{"label": "Auth"}, {"label": "Docs"}]},
{"nonsense": true}
]});
let qs = cc_questions(&input);
assert_eq!(qs.len(), 2);
assert_eq!((qs[0].question.as_str(), qs[0].options.len(), qs[0].multi_select), ("Which database?", 2, false));
assert!(qs[1].multi_select);
// The tool card shows it as our ask_user.
let (name, shown) = map_tool("AskUserQuestion", &input);
assert_eq!(name, "ask_user");
assert_eq!(shown["questions"][1]["multi_select"], true);
// Answers: text stays, a multiple choice is joined.
let answers = json!({"Which database?": "SQLite", "Which extras?": ["Auth", "Docs"]});
let out = cc_answers(answers.as_object().unwrap());
assert_eq!(out["Which database?"], "SQLite");
assert_eq!(out["Which extras?"], "Auth, Docs");
}
#[test]
fn tool_result_images_are_kept() {
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
let imgs = result_images(&content);
assert_eq!(imgs.len(), 1);
assert_eq!((imgs[0].media_type.as_str(), imgs[0].data.as_str()), ("image/jpeg", "QUJD"));
assert!(result_images(&json!("plain text")).is_empty());
}
use super::*;
#[test]
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -174,7 +174,8 @@ fn parse_status(raw: &str) -> Value {
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
let cfg = crate::gitcreds::global_config(&app, &user);
let env = crate::gitcreds::identity_env(&app, &user.id);
let mut env = crate::gitcreds::identity_env(&app, &user.id);
env.extend(crate::sshkeys::env(&app, &user));
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
}
@@ -401,7 +402,8 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
let spec = crate::sandbox::spec_for(app, Some(&user.id));
let cfg = crate::gitcreds::global_config(app, user);
let env = crate::gitcreds::identity_env(app, &user.id);
let mut env = crate::gitcreds::identity_env(app, &user.id);
env.extend(crate::sshkeys::env(app, user));
let s = session.clone();
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
+42
View File
@@ -53,6 +53,35 @@ pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
env
}
/// What an agent needs to know about the stored git access (no secrets): git already has the
/// credentials, so it must not look for them. None when nothing is stored.
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
render_prompt(&app.store.user_setting(user_id, "git").unwrap_or_default())
}
fn render_prompt(s: &GitSettings) -> Option<String> {
let creds: Vec<&GitCredential> = s.credentials.iter().filter(|c| !c.host.trim().is_empty() && !c.token.is_empty()).collect();
if creds.is_empty() {
return None;
}
let mut t = String::from("\n# Git access\nThe user has stored git credentials for these hosts (HTTPS):\n");
for c in &creds {
t.push_str(&format!("- {} (user {})\n", c.host.trim(), c.username.trim()));
}
t.push_str(
"git uses them automatically through its credential helper: `git clone`, `fetch`, `pull` and `push` with an https:// URL of these hosts just work, no login or token needed. \
Do NOT ask the user for a token and do NOT search the environment, config files, `gh`, SSH keys or the keychain for credentials – they are not there, and the command line shows none because git keeps them in its credential store. \
Use https:// remote URLs (SSH keys are not set up). \
For a REST API of such a host (pull requests, issues, releases) take the token straight from git without printing it, e.g. \
`curl -s -H \"Authorization: token $(printf 'protocol=https\\nhost=HOST\\n\\n' | git credential fill | sed -n 's/^password=//p')\" https://HOST/api/...` \
(GitHub: `Authorization: Bearer`). Never echo the token or write it into files, commits or your answer.\n",
);
if !s.name.trim().is_empty() || !s.email.trim().is_empty() {
t.push_str(&format!("Commits are made as {} <{}> automatically.\n", s.name.trim(), s.email.trim()));
}
Some(t)
}
pub fn home_of(app: &App, user: &User) -> PathBuf {
app.data_dir().join("users").join(&user.username).join("home")
}
@@ -159,6 +188,19 @@ pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
mod tests {
use super::*;
#[test]
fn prompt_names_hosts_but_never_tokens() {
assert!(render_prompt(&GitSettings::default()).is_none());
let settings = GitSettings { name: "Ann".into(), email: "ann@x.org".into(), credentials: vec![GitCredential { host: "git.x.org".into(), username: "ann".into(), token: "s3cret-token".into(), protocol: String::new() }] };
let p = render_prompt(&settings).unwrap();
println!("{p}");
assert!(p.contains("git.x.org (user ann)") && p.contains("git credential fill") && p.contains("Ann <ann@x.org>"));
assert!(!p.contains("s3cret-token"));
// Entries without a token are not offered.
let none = GitSettings { credentials: vec![GitCredential { host: "h".into(), username: "u".into(), ..Default::default() }], ..Default::default() };
assert!(render_prompt(&none).is_none());
}
#[test]
fn keeps_masked_tokens() {
let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() };
+4
View File
@@ -13,6 +13,7 @@ mod config;
mod digest;
mod docs;
mod export;
mod factory;
mod files;
mod git;
mod health;
@@ -24,6 +25,7 @@ mod lsp;
mod mentions;
mod metrics;
mod mcp;
mod mcp_bridge;
mod media;
mod mesh3d;
mod permission;
@@ -34,6 +36,7 @@ mod reverse;
mod runner;
mod sandbox;
mod shells;
mod sshkeys;
mod stats_export;
mod store;
mod telegram;
@@ -76,6 +79,7 @@ async fn main() -> Result<()> {
push::spawn_listener(app.clone());
terminal::spawn_reaper(app.clone());
jobs::spawn_scheduler(app.clone());
factory::spawn(app.clone());
update::spawn_checker(app.clone());
health::spawn_checker(app.clone());
digest::spawn(app.clone());
+252
View File
@@ -0,0 +1,252 @@
//! CodeCraft's tools for the Claude Code CLI, as an MCP server (streamable HTTP, JSON answers).
//!
//! The CLI only knows its own tools. For every run the server registers a secret token and
//! passes `--mcp-config` with `http://127.0.0.1:<port>/api/mcp-bridge/<token>`; the CLI then
//! lists and calls the tools like any MCP tool (`mcp__codecraft__<name>`). The token is the
//! credential (the route is public) and binds the calls to the chat that started the run.
//! Permissions are asked by the CLI through the usual `can_use_tool` request, so the approval
//! cards, rules and project approvals work as for the CLI's own tools.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use tokio_util::sync::CancellationToken;
pub const SERVER: &str = "codecraft";
/// Prefix the CLI gives our tools.
pub const PREFIX: &str = "mcp__codecraft__";
/// Our tools the CLI brings itself (same job): not offered twice.
const CLI_HAS: &[&str] = &["read", "write", "edit", "list", "glob", "grep", "bash", "bash_output", "kill_shell", "web_fetch", "web_search", "todo_write", "task", "change_directory"];
static TOKENS: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(Default::default);
/// A registered run; the token stops working when this is dropped.
pub struct Registration {
pub token: String,
}
impl Drop for Registration {
fn drop(&mut self) {
TOKENS.lock().unwrap().remove(&self.token);
}
}
pub fn register(session_id: &str) -> Registration {
let token = format!("{}{}", crate::types::new_id(), crate::types::new_id());
TOKENS.lock().unwrap().insert(token.clone(), session_id.to_string());
Registration { token }
}
/// `--mcp-config` value for a run.
pub fn config_json(app: &App, token: &str) -> String {
let port = app.config().server.bind.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(8787);
json!({"mcpServers": {SERVER: {"type": "http", "url": format!("http://127.0.0.1:{port}/api/mcp-bridge/{token}")}}}).to_string()
}
/// The tools the CLI gets for a chat: what the chat's own agent would have (mode, images,
/// the server's MCP tools …) without the ones the CLI has itself.
pub fn tools_for(app: &Arc<App>, session: &crate::types::Session) -> Vec<Arc<dyn Tool>> {
let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let agent = crate::prompts::find_agent_for(app, &root, &cwd, &session.agent, session.owner_id.as_deref());
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
crate::runner::available_tools(app, &agent, owner.as_ref()).into_iter().filter(|t| !CLI_HAS.contains(&t.name())).collect()
}
/// Short note for the system prompt: what the extra tools are called.
pub fn prompt_section(tools: &[Arc<dyn Tool>]) -> Option<String> {
if tools.is_empty() {
return None;
}
let names: Vec<String> = tools.iter().map(|t| format!("{PREFIX}{}", t.name())).collect();
Some(format!(
"\n# CodeCraft tools\nBesides your own tools you have the tools of the CodeCraft server (MCP server `{SERVER}`): {}. \
Use them for what they do – e.g. image generation (generate_image), 3D models, OpenSCAD, scheduling automations, diagnostics, the browser. \
If one is not in your tool list, load it with ToolSearch (query `select:<name>`).\n",
names.join(", ")
))
}
// --- protocol ----------------------------------------------------------------------------
fn tools_list(tools: &[Arc<dyn Tool>]) -> Value {
json!({"tools": tools.iter().map(|t| json!({"name": t.name(), "description": t.description(), "inputSchema": t.schema()})).collect::<Vec<_>>()})
}
fn call_result(res: anyhow::Result<ToolOutput>) -> Value {
match res {
Ok(out) => {
let mut content = vec![json!({"type": "text", "text": out.content})];
for img in out.images.iter().filter(|i| !i.data.is_empty()) {
content.push(json!({"type": "image", "data": img.data, "mimeType": img.media_type}));
}
json!({"content": content, "isError": out.is_error})
}
Err(e) => json!({"content": [{"type": "text", "text": format!("Error: {e:#}")}], "isError": true}),
}
}
fn answer(id: &Value, result: Value) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
}
fn failure(id: &Value, code: i64, message: &str) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})).into_response()
}
pub async fn handle(State(app): State<Arc<App>>, Path(token): Path<String>, Json(req): Json<Value>) -> Response {
let Some(session_id) = TOKENS.lock().unwrap().get(&token).cloned() else {
return (StatusCode::NOT_FOUND, "unknown or finished run").into_response();
};
let id = req["id"].clone();
let method = req["method"].as_str().unwrap_or("");
// Notifications have no answer.
if req.get("id").is_none() || method.starts_with("notifications/") {
return StatusCode::ACCEPTED.into_response();
}
match method {
"initialize" => answer(
&id,
json!({
"protocolVersion": req["params"]["protocolVersion"].as_str().unwrap_or("2025-03-26"),
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")},
}),
),
"ping" => answer(&id, json!({})),
"tools/list" | "tools/call" => {
let Ok(session) = app.session(&session_id) else { return failure(&id, -32000, "the chat is gone") };
let tools = tools_for(&app, &session);
if method == "tools/list" {
return answer(&id, tools_list(&tools));
}
let name = req["params"]["name"].as_str().unwrap_or("");
let Some(tool) = tools.iter().find(|t| t.name() == name).cloned() else {
return failure(&id, -32602, &format!("unknown tool '{name}'"));
};
let cwd = PathBuf::from(&session.cwd);
let agent = crate::prompts::find_agent_for(&app, &app.root_for(session.owner_id.as_deref(), &cwd), &cwd, &session.agent, session.owner_id.as_deref());
let ctx = ToolCtx {
app: app.clone(),
session_id: session_id.clone(),
call_id: crate::types::new_id(),
cwd,
cancel: CancellationToken::new(),
agent: agent.name.clone(),
roots: app.user_roots(session.owner_id.as_deref()),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room: session.clean_room(app.config().craft.clean_room) && agent.name == "craft",
owner_id: None,
};
let input = req["params"]["arguments"].clone();
let input = if input.is_null() { json!({}) } else { input };
answer(&id, call_result(tool.run(input, &ctx).await))
}
_ => failure(&id, -32601, "method not found"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ImageData;
#[test]
fn registration_lives_as_long_as_the_run() {
let r = register("s1");
assert_eq!(TOKENS.lock().unwrap().get(&r.token).map(String::as_str), Some("s1"));
let t = r.token.clone();
drop(r);
assert!(!TOKENS.lock().unwrap().contains_key(&t));
}
#[test]
fn tool_results_carry_text_and_images() {
let out = ToolOutput {
content: "saved".into(),
images: vec![ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None }, ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None }],
..Default::default()
};
let v = call_result(Ok(out));
assert_eq!(v["isError"], false);
let c = v["content"].as_array().unwrap();
assert_eq!(c.len(), 2); // the image without data is left out
assert_eq!((c[1]["type"].as_str(), c[1]["mimeType"].as_str()), (Some("image"), Some("image/png")));
assert_eq!(call_result(Err(anyhow::anyhow!("boom")))["isError"], true);
}
#[test]
fn cli_builtins_are_not_offered_twice() {
let all = crate::tools::builtin_tools();
let ours: Vec<&str> = all.iter().map(|t| t.name()).filter(|n| !CLI_HAS.contains(n)).collect();
for n in ["generate_image", "generate_3d", "openscad", "browser", "schedule", "remember", "diagnostics", "ask_user", "skill"] {
assert!(ours.contains(&n), "{n} missing");
}
assert!(!ours.contains(&"read") && !ours.contains(&"bash"));
let list = tools_list(&all.into_iter().filter(|t| t.name() == "remember").collect::<Vec<_>>());
assert!(list["tools"][0]["inputSchema"].is_object());
}
/// The whole protocol against a real App: initialize, list, call, unknown token.
#[tokio::test]
async fn protocol_roundtrip_with_a_real_app() {
let dir = std::env::temp_dir().join(format!("cc-bridge-{}", std::process::id()));
std::fs::create_dir_all(dir.join("ws")).unwrap();
let toml = crate::config::EXAMPLE_CONFIG.lines().map(|l| {
if l.starts_with("workspace = ") { format!("workspace = \"{}\"", dir.join("ws").display()) }
else if l.starts_with("data_dir = ") { format!("data_dir = \"{}\"", dir.join("data").display()) }
else { l.to_string() }
}).collect::<Vec<_>>().join("\n");
let cfg = crate::config::Config::parse(&toml).unwrap();
let app = App::new(dir.join("config.toml"), cfg).await.unwrap();
let now = crate::types::now_ms();
let session = crate::types::Session {
id: crate::types::new_id(), title: String::new(), cwd: dir.join("ws").display().to_string(), model: "x/y".into(), agent: "build".into(), permission_mode: "bypass".into(),
parent_id: None, owner_id: None, 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: String::new(), telegram_quiet: true, summary: String::new(), color: None, icon: None,
};
let session = app.save_session(session).unwrap();
let reg = register(&session.id);
let rpc = |req: Value| {
let app = app.clone();
let token = reg.token.clone();
async move {
let resp = handle(State(app), Path(token), Json(req)).await;
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, serde_json::from_slice::<Value>(&bytes).unwrap_or(Value::Null))
}
};
let (st, init) = rpc(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert!(init["result"]["capabilities"]["tools"].is_object());
let (st, _) = rpc(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await;
assert_eq!(st, StatusCode::ACCEPTED);
let (_, list) = rpc(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).await;
let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"remember") && names.contains(&"openscad") && names.contains(&"browser"), "{names:?}");
assert!(!names.contains(&"read") && !names.contains(&"bash"), "{names:?}");
// A real tool call: `remember` writes the project memory.
let (_, call) = rpc(json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "remember", "arguments": {"note": "bridge works"}}})).await;
assert_eq!(call["result"]["isError"], false, "{call}");
let mem = std::fs::read_to_string(dir.join("ws/.codecraft/memory.md")).unwrap_or_default();
assert!(mem.contains("bridge works"), "{mem}");
let (_, bad) = rpc(json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "bash", "arguments": {"command": "id"}}})).await;
assert!(bad["error"].is_object());
// A finished run's token is dead.
let token = reg.token.clone();
drop(reg);
let resp = handle(State(app.clone()), Path(token), Json(json!({"jsonrpc": "2.0", "id": 5, "method": "ping"}))).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let _ = std::fs::remove_dir_all(&dir);
}
}
+6
View File
@@ -298,6 +298,12 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
}
}
if let Some(git) = owner.and_then(|u| crate::gitcreds::prompt_section(app, &u.id)) {
s.push_str(&git);
}
if let Some(ssh) = owner.and_then(|u| crate::sshkeys::prompt_section(app, &u.id)) {
s.push_str(&ssh);
}
match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())),
Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"),
+33
View File
@@ -1723,10 +1723,43 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
Ok(())
}
/// Text of the marker `/clear` puts into the history (the chat view recognises it).
pub const CLEARED_MARKER: &str = "[Context cleared by the user. Earlier messages are no longer part of this conversation – start fresh and do not refer to them.]";
/// `/clear`: the model forgets the conversation so far (the chat keeps showing it). Like a
/// compaction without a summary: history before the marker is not sent any more.
pub fn clear_context(app: &App, session_id: &str) -> Result<()> {
save_user_parts(app, session_id, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }])?;
app.update_session(session_id, |s| {
s.last_usage = None;
s.todos = Value::Null;
})?;
app.with_runtime(session_id, |r| r.read_files.clear());
// Claude Code continues its own conversation: start a new one.
if let Some(owner) = app.session(session_id).ok().and_then(|s| s.owner_id) {
let _ = app.store.set_user_setting(&owner, &format!("claude_sid:{session_id}"), &Option::<String>::None);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleared_marker_starts_a_new_history() {
let mk = |role, parts| Message::new(role, parts);
let all = vec![
mk(Role::User, vec![Part::Text { text: "old question".into() }]),
mk(Role::Assistant, vec![Part::Text { text: "old answer".into() }]),
mk(Role::User, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }]),
mk(Role::User, vec![Part::Text { text: "new question".into() }]),
];
let active = active_history(all);
assert_eq!(active.len(), 2);
assert!(!active.iter().any(|m| message_text(m).contains("old")));
}
#[test]
fn fit_keeps_head_and_tail_within_budget() {
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
+8 -1
View File
@@ -132,7 +132,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
if new_session {
push(&[&"--new-session"]);
}
let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin");
// SSH wrappers (user's keys); only when keys are set up.
let ssh_wrapper = spec.home.join(crate::sshkeys::SANDBOX_BIN).join("ssh");
let ssh_bin = format!("{home}/{}", crate::sshkeys::SANDBOX_BIN);
let path = format!("{}{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin", if ssh_wrapper.exists() { format!("{ssh_bin}:") } else { String::new() });
let npm = format!("{home}/.npm-global");
let pipx = format!("{home}/.local/pipx");
let pipx_bin = format!("{home}/.local/bin");
@@ -152,6 +155,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
] {
push(&[&"--setenv", &k, &v]);
}
if ssh_wrapper.exists() {
let cmd = format!("{ssh_bin}/ssh");
push(&[&"--setenv", &"GIT_SSH_COMMAND", &cmd]);
}
for (k, v) in &spec.env {
push(&[&"--setenv", k, v]);
}
+299
View File
@@ -0,0 +1,299 @@
//! Per-user SSH keys for agents, terminals and the git panel.
//!
//! The keys live in the database and are rendered into the user's home (`.ssh/`), like the git
//! credentials. OpenSSH takes `~` from the passwd entry, not from `$HOME`, so plain `ssh` would
//! not look into the sandbox home. A small `ssh`/`scp`/`sftp` wrapper directory goes first in PATH
//! and applies the managed config with `-F`; `GIT_SSH_COMMAND` points at the wrapper. Two
//! variants: one for the sandbox (paths through `${HOME}`) and one for unsandboxed runs (admins;
//! absolute host paths).
use crate::app::App;
use crate::store::User;
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshKey {
pub id: String,
pub name: String,
pub public: String,
pub fingerprint: String,
/// OpenSSH private key (never sent to the UI).
#[serde(default)]
pub private: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SshSettings {
#[serde(default)]
pub keys: Vec<SshKey>,
}
/// Keys without the private part, for the UI.
pub fn masked(s: &SshSettings) -> Vec<SshKey> {
s.keys.iter().map(|k| SshKey { private: String::new(), ..k.clone() }).collect()
}
fn ssh_dir(home: &Path) -> PathBuf {
home.join(".ssh")
}
/// Wrappers for unsandboxed runs.
fn bin_dir(home: &Path) -> PathBuf {
home.join(".codecraft-bin")
}
/// Wrappers inside the sandbox (see `sandbox::bwrap_args`).
pub const SANDBOX_BIN: &str = ".codecraft-sbin";
fn key_file(id: &str) -> String {
format!("codecraft_{id}")
}
/// SSH client config. `sandbox`: paths through `${HOME}` (the home is mounted at a different
/// place there); otherwise absolute paths below `home` (wrapper for unsandboxed runs).
pub fn render_config(keys: &[SshKey], home: &Path, sandbox: bool) -> String {
let mut c = String::from("# Managed by CodeCraft (Settings → Account → SSH keys). Do not edit.\nHost *\n");
for k in keys {
if sandbox {
c.push_str(&format!("\tIdentityFile ${{HOME}}/.ssh/{}\n", key_file(&k.id)));
} else {
c.push_str(&format!("\tIdentityFile {}\n", ssh_dir(home).join(key_file(&k.id)).display()));
}
}
// Trust a host on first contact and refuse changed keys afterwards.
c.push_str("\tStrictHostKeyChecking accept-new\n\tServerAliveInterval 30\n");
if sandbox {
c.push_str("\tUserKnownHostsFile ${HOME}/.ssh/known_hosts\n");
} else {
c.push_str(&format!("\tUserKnownHostsFile {}\n", ssh_dir(home).join("known_hosts").display()));
}
c
}
#[cfg(unix)]
fn chmod(p: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
Ok(())
}
#[cfg(not(unix))]
fn chmod(_: &Path, _: u32) -> Result<()> {
Ok(())
}
/// Write keys, config and wrappers into the user's home (removes files of deleted keys).
pub fn write_files(app: &App, user: &User, s: &SshSettings) -> Result<()> {
let home = crate::gitcreds::home_of(app, user);
let dir = ssh_dir(&home);
std::fs::create_dir_all(&dir)?;
chmod(&dir, 0o700)?;
// Old key files.
for e in std::fs::read_dir(&dir)?.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if n.starts_with("codecraft_") && !s.keys.iter().any(|k| n == key_file(&k.id) || n == format!("{}.pub", key_file(&k.id))) {
let _ = std::fs::remove_file(e.path());
}
}
if s.keys.is_empty() {
// No keys: no wrappers either, ssh behaves as usual.
let _ = std::fs::remove_dir_all(bin_dir(&home));
let _ = std::fs::remove_dir_all(home.join(SANDBOX_BIN));
for f in ["config-sandbox", "config-host"] {
let _ = std::fs::remove_file(dir.join(f));
}
return Ok(());
}
for k in &s.keys {
let p = dir.join(key_file(&k.id));
// The file is replaced, not appended: remove it first so the mode applies.
let _ = std::fs::remove_file(&p);
std::fs::write(&p, &k.private)?;
chmod(&p, 0o600)?;
let pubp = dir.join(format!("{}.pub", key_file(&k.id)));
std::fs::write(&pubp, format!("{}\n", k.public.trim()))?;
chmod(&pubp, 0o644)?;
}
// The user's own ~/.ssh/config (if any) is included after the managed part.
let own = dir.join("config");
let sandbox_cfg = dir.join("config-sandbox");
std::fs::write(&sandbox_cfg, format!("{}\nInclude /home/{}/.ssh/config\n", render_config(&s.keys, &home, true), user.username))?;
chmod(&sandbox_cfg, 0o600)?;
let host_cfg = dir.join("config-host");
std::fs::write(&host_cfg, format!("{}\nInclude {}\n", render_config(&s.keys, &home, false), own.display()))?;
chmod(&host_cfg, 0o600)?;
for (bin, config) in [(bin_dir(&home), format!("'{}'", host_cfg.display())), (home.join(SANDBOX_BIN), "\"$HOME/.ssh/config-sandbox\"".to_string())] {
std::fs::create_dir_all(&bin)?;
for name in ["ssh", "scp", "sftp"] {
// The image's /usr/local/bin wrappers cope with a uid without passwd entry.
let local = format!("/usr/local/bin/{name}");
let real = if Path::new(&local).exists() { local } else { format!("/usr/bin/{name}") };
let p = bin.join(name);
std::fs::write(&p, format!("#!/bin/sh\nexec {real} -F {config} \"$@\"\n"))?;
chmod(&p, 0o755)?;
}
}
Ok(())
}
/// Environment for processes that run without the sandbox: the wrappers first in PATH, git over
/// SSH through the wrapper. Empty when the user has no keys.
pub fn env(app: &App, user: &User) -> Vec<(String, String)> {
let s: SshSettings = app.store.user_setting(&user.id, "ssh").unwrap_or_default();
if s.keys.is_empty() {
return Vec::new();
}
let bin = bin_dir(&crate::gitcreds::home_of(app, user));
if !bin.join("ssh").exists() {
return Vec::new();
}
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()));
vec![("PATH".into(), path), ("GIT_SSH_COMMAND".into(), bin.join("ssh").display().to_string())]
}
/// What an agent needs to know (public keys only). None without keys.
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
render_prompt(&app.store.user_setting(user_id, "ssh").unwrap_or_default())
}
fn render_prompt(s: &SshSettings) -> Option<String> {
if s.keys.is_empty() {
return None;
}
let names: Vec<&str> = s.keys.iter().map(|k| k.name.as_str()).collect();
Some(format!(
"\n# SSH access\nThe user has SSH keys set up ({}). `ssh`, `scp`, `sftp`, `rsync -e ssh` and `git` over ssh:// or git@host use them automatically – \
no password, no `ssh-add`, no agent. A host is trusted on first contact. Do NOT generate new keys or search for keys or passwords; \
if a server refuses the connection, tell the user that their public key must be added to the server's authorized_keys. \
Commands run without a terminal, so password or passphrase prompts fail.\n",
names.join(", ")
))
}
// --- creating keys ---------------------------------------------------------------------
async fn keygen(args: &[&str], stdin: Option<&str>) -> Result<String> {
let mut cmd = Command::new("ssh-keygen");
cmd.args(args).env_clear().env("PATH", std::env::var("PATH").unwrap_or_default()).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().context("ssh-keygen is not installed on the server")?;
if let Some(mut si) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
if let Some(t) = stdin {
let _ = si.write_all(t.as_bytes()).await;
}
}
let out = tokio::time::timeout(std::time::Duration::from_secs(20), child.wait_with_output()).await.map_err(|_| anyhow!("ssh-keygen timed out"))??;
if !out.status.success() {
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn temp_dir() -> Result<PathBuf> {
let d = std::env::temp_dir().join(format!("cc-ssh-{}", crate::types::new_id()));
std::fs::create_dir_all(&d)?;
chmod(&d, 0o700)?;
Ok(d)
}
/// A new ed25519 key without passphrase.
pub async fn generate(name: &str) -> Result<SshKey> {
let d = temp_dir()?;
let f = d.join("key");
let comment = format!("codecraft-{}", name.replace(char::is_whitespace, "-"));
let res = async {
keygen(&["-q", "-t", "ed25519", "-N", "", "-C", &comment, "-f", &f.to_string_lossy()], None).await?;
let private = std::fs::read_to_string(&f)?;
let public = std::fs::read_to_string(d.join("key.pub"))?;
finish(name, private, public.trim().to_string()).await
}
.await;
let _ = std::fs::remove_dir_all(&d);
res
}
/// An existing private key (OpenSSH or PEM, without passphrase).
pub async fn import(name: &str, private: &str) -> Result<SshKey> {
let mut text = private.trim().replace("\r\n", "\n");
if !text.starts_with("-----BEGIN ") || !text.contains("PRIVATE KEY-----") {
bail!("that does not look like a private key (-----BEGIN … PRIVATE KEY-----)");
}
text.push('\n');
let d = temp_dir()?;
let f = d.join("key");
let res = async {
std::fs::write(&f, &text)?;
chmod(&f, 0o600)?;
let public = keygen(&["-y", "-P", "", "-f", &f.to_string_lossy()], None).await.map_err(|e| anyhow!("cannot read the key (keys with a passphrase are not supported): {e}"))?;
finish(name, text.clone(), public.trim().to_string()).await
}
.await;
let _ = std::fs::remove_dir_all(&d);
res
}
async fn finish(name: &str, private: String, public: String) -> Result<SshKey> {
let fp = keygen(&["-l", "-f", "-"], Some(&format!("{public}\n"))).await.unwrap_or_default();
let fingerprint = fp.split_whitespace().nth(1).unwrap_or("").to_string();
Ok(SshKey { id: crate::types::new_id().chars().take(10).collect(), name: name.to_string(), public, fingerprint, private })
}
#[cfg(test)]
mod tests {
use super::*;
fn key(id: &str) -> SshKey {
SshKey { id: id.into(), name: "laptop".into(), public: "ssh-ed25519 AAAA x".into(), fingerprint: "SHA256:x".into(), private: "SECRET".into() }
}
#[test]
fn config_has_both_path_styles() {
let home = Path::new("/data/users/ann/home");
let t = render_config(&[key("k1")], home, true);
assert!(t.contains("IdentityFile ${HOME}/.ssh/codecraft_k1") && t.contains("UserKnownHostsFile ${HOME}/.ssh/known_hosts"));
assert!(t.contains("StrictHostKeyChecking accept-new"));
let a = render_config(&[key("k1")], home, false);
assert!(a.contains("IdentityFile /data/users/ann/home/.ssh/codecraft_k1"));
assert!(a.contains("UserKnownHostsFile /data/users/ann/home/.ssh/known_hosts"));
}
#[test]
fn sandbox_config_resolves_through_home() {
if std::process::Command::new("ssh").arg("-V").output().is_err() {
return;
}
let d = std::env::temp_dir().join(format!("cc-sshcfg-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let cfg = d.join("cfg");
std::fs::write(&cfg, render_config(&[key("k1")], Path::new("/unused"), true)).unwrap();
let out = std::process::Command::new("ssh").arg("-F").arg(&cfg).args(["-G", "example.org"]).env("HOME", "/sandbox/home").output().unwrap();
let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
// `-G` shows identity files unexpanded (expanded when used, ssh_config(5)).
assert!(text.contains("identityfile ${home}/.ssh/codecraft_k1"), "{text}");
assert!(text.contains("userknownhostsfile /sandbox/home/.ssh/known_hosts"), "{text}");
std::fs::remove_dir_all(&d).unwrap();
}
#[test]
fn prompt_and_ui_view_never_contain_the_private_key() {
let s = SshSettings { keys: vec![key("k1")] };
assert!(masked(&s).iter().all(|k| k.private.is_empty()));
let p = render_prompt(&s).unwrap();
assert!(p.contains("laptop") && !p.contains("SECRET"));
assert!(render_prompt(&SshSettings::default()).is_none());
}
#[tokio::test]
async fn generated_and_imported_keys_match() {
if std::process::Command::new("ssh-keygen").arg("-?").output().is_err() {
return; // not installed here
}
let k = generate("test key").await.unwrap();
assert!(k.public.starts_with("ssh-ed25519 ") && k.fingerprint.starts_with("SHA256:") && k.private.contains("PRIVATE KEY"));
let again = import("copy", &k.private).await.unwrap();
assert_eq!(again.fingerprint, k.fingerprint);
assert!(import("bad", "hello").await.is_err());
}
}
+134
View File
@@ -171,6 +171,39 @@ impl Store {
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS share_links_session ON share_links(session_id);
CREATE TABLE IF NOT EXISTS factory_issues (
repo_url TEXT NOT NULL,
number INTEGER NOT NULL,
owner_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'new',
attempts INTEGER NOT NULL DEFAULT 0,
rounds INTEGER NOT NULL DEFAULT 0,
last_comment INTEGER NOT NULL DEFAULT 0,
scenarios TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
pr_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (repo_url, number)
);
CREATE TABLE IF NOT EXISTS factory_runs (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
repo_url TEXT NOT NULL,
issue INTEGER NOT NULL,
phase TEXT NOT NULL,
round INTEGER NOT NULL DEFAULT 0,
session_id TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
started_at INTEGER NOT NULL,
ended_at INTEGER,
status TEXT NOT NULL DEFAULT 'running',
result TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS factory_runs_issue ON factory_runs(repo_url, issue);
CREATE TABLE IF NOT EXISTS project_defaults (
cwd TEXT PRIMARY KEY,
data TEXT NOT NULL
@@ -409,6 +442,16 @@ impl Store {
Ok(())
}
/// (title, cwd) of all other chats (subagent sessions of this chat excluded).
pub fn other_session_cwds(&self, id: &str) -> Result<Vec<(String, String)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT COALESCE(json_extract(data, '$.title'), ''), COALESCE(json_extract(data, '$.cwd'), '') FROM sessions WHERE id != ?1 AND COALESCE(parent_id, '') != ?1",
)?;
let rows = stmt.query_map([id], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
// Images go to the blob table (encoded before taking the lock).
let mut m = m.clone();
@@ -1417,6 +1460,97 @@ impl Store {
Ok(())
}
// --- software factory ---------------------------------------------------------
pub fn factory_issue(&self, repo: &str, number: i64) -> Result<Option<Value>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT title, author, state, attempts, rounds, last_comment, scenarios, branch, pr_url, note, created_at, updated_at, owner_id FROM factory_issues WHERE repo_url = ?1 AND number = ?2", params![repo, number], |r| {
Ok(json!({"repo": repo, "number": number, "title": r.get::<_, String>(0)?, "author": r.get::<_, String>(1)?, "state": r.get::<_, String>(2)?, "attempts": r.get::<_, i64>(3)?, "rounds": r.get::<_, i64>(4)?, "last_comment": r.get::<_, i64>(5)?, "scenarios": r.get::<_, String>(6)?, "branch": r.get::<_, String>(7)?, "pr_url": r.get::<_, String>(8)?, "note": r.get::<_, String>(9)?, "created_at": r.get::<_, i64>(10)?, "updated_at": r.get::<_, i64>(11)?, "owner_id": r.get::<_, String>(12)?}))
})
.optional()?)
}
pub fn factory_issue_add(&self, repo: &str, number: i64, owner: &str, title: &str, author: &str) -> Result<()> {
let now = crate::types::now_ms();
self.conn.lock().unwrap().execute(
"INSERT OR IGNORE INTO factory_issues (repo_url, number, owner_id, title, author, created_at, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?6)",
params![repo, number, owner, title, author, now],
)?;
Ok(())
}
/// Update the given columns (whitelisted) of an issue.
pub fn factory_issue_set(&self, repo: &str, number: i64, fields: &[(&str, Value)]) -> Result<()> {
const COLS: [&str; 10] = ["title", "state", "attempts", "rounds", "last_comment", "scenarios", "branch", "pr_url", "note", "author"];
let conn = self.conn.lock().unwrap();
for (k, v) in fields {
if !COLS.contains(k) {
anyhow::bail!("unknown column {k}");
}
let sql = format!("UPDATE factory_issues SET {k} = ?3, updated_at = ?4 WHERE repo_url = ?1 AND number = ?2");
let now = crate::types::now_ms();
match v {
Value::Number(n) => conn.execute(&sql, params![repo, number, n.as_i64().unwrap_or(0), now])?,
other => conn.execute(&sql, params![repo, number, other.as_str().unwrap_or(""), now])?,
};
}
Ok(())
}
pub fn factory_issues(&self, owner: &str) -> Result<Vec<Value>> {
let nums: Vec<(String, i64)> = {
let conn = self.conn.lock().unwrap();
let mut st = conn.prepare("SELECT repo_url, number FROM factory_issues WHERE owner_id = ?1 ORDER BY updated_at DESC")?;
st.query_map([owner], |r| Ok((r.get(0)?, r.get(1)?)))?.filter_map(|r| r.ok()).collect()
};
Ok(nums.into_iter().filter_map(|(r, n)| self.factory_issue(&r, n).ok().flatten()).collect())
}
pub fn factory_run_add(&self, id: &str, owner: &str, repo: &str, issue: i64, phase: &str, round: i64, session: &str, model: &str) -> Result<()> {
self.conn.lock().unwrap().execute(
"INSERT INTO factory_runs (id, owner_id, repo_url, issue, phase, round, session_id, model, started_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
params![id, owner, repo, issue, phase, round, session, model, crate::types::now_ms()],
)?;
Ok(())
}
pub fn factory_run_end(&self, id: &str, status: &str, result: &str) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE factory_runs SET ended_at = ?2, status = ?3, result = ?4 WHERE id = ?1", params![id, crate::types::now_ms(), status, result])?;
Ok(())
}
pub fn factory_running(&self, repo: &str) -> Result<i64> {
Ok(self.conn.lock().unwrap().query_row("SELECT COUNT(*) FROM factory_runs WHERE repo_url = ?1 AND status = 'running'", [repo], |r| r.get(0))?)
}
/// Runs with token and cost totals (joined from the usage table).
pub fn factory_runs(&self, owner: &str, repo: Option<&str>, issue: Option<i64>, limit: i64) -> Result<Vec<Value>> {
let conn = self.conn.lock().unwrap();
let mut st = conn.prepare(
"SELECT f.id, f.repo_url, f.issue, f.phase, f.round, f.session_id, f.model, f.started_at, f.ended_at, f.status, f.result,
COALESCE((SELECT SUM(input_tokens + cache_read_tokens + cache_write_tokens) FROM usage WHERE session_id = f.session_id),0),
COALESCE((SELECT SUM(output_tokens) FROM usage WHERE session_id = f.session_id),0),
COALESCE((SELECT SUM(cost) FROM usage WHERE session_id = f.session_id),0.0)
FROM factory_runs f WHERE f.owner_id = ?1 AND (?2 IS NULL OR f.repo_url = ?2) AND (?3 IS NULL OR f.issue = ?3)
ORDER BY f.started_at DESC LIMIT ?4",
)?;
let rows = st.query_map(params![owner, repo, issue, limit], |r| {
Ok(json!({"id": r.get::<_, String>(0)?, "repo": r.get::<_, String>(1)?, "issue": r.get::<_, i64>(2)?, "phase": r.get::<_, String>(3)?, "round": r.get::<_, i64>(4)?, "session_id": r.get::<_, String>(5)?, "model": r.get::<_, String>(6)?, "started_at": r.get::<_, i64>(7)?, "ended_at": r.get::<_, Option<i64>>(8)?, "status": r.get::<_, String>(9)?, "result": r.get::<_, String>(10)?, "input_tokens": r.get::<_, i64>(11)?, "output_tokens": r.get::<_, i64>(12)?, "cost": r.get::<_, f64>(13)?}))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// After a restart: runs that were still going are over; their issues go back one step.
pub fn factory_recover(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n = conn.execute("UPDATE factory_runs SET status = 'interrupted', ended_at = ?1 WHERE status = 'running'", [crate::types::now_ms()])?;
conn.execute("UPDATE factory_issues SET state = 'new' WHERE state = 'review'", [])?;
conn.execute("UPDATE factory_issues SET state = 'approved' WHERE state = 'building'", [])?;
conn.execute("UPDATE factory_issues SET state = 'built' WHERE state = 'qa'", [])?;
Ok(n)
}
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(())
+2 -1
View File
@@ -162,7 +162,8 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
.current_dir(cwd)
.env("TERM", "xterm-256color")
.env("CODECRAFT", "1")
.envs(crate::gitcreds::identity_env(app, &user.id));
.envs(crate::gitcreds::identity_env(app, &user.id))
.envs(crate::sshkeys::env(app, user));
match crate::gitcreds::global_config(app, user) {
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
None => c,
+1
View File
@@ -155,6 +155,7 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
c.envs(crate::sshkeys::env(&ctx.app, &u));
}
c
}
+2
View File
@@ -13,6 +13,7 @@ import { NewSession } from "./components/NewSession";
import { Settings } from "./components/Settings";
import { Stats } from "./components/Stats";
import { Images } from "./components/Images";
import { Factory } from "./components/Factory";
import { Jobs } from "./components/Jobs";
import { JobEditor } from "./components/JobEditor";
import { Spinner, useTitleActivity } from "./components/Activity";
@@ -110,6 +111,7 @@ export function App() {
);
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "p" && arg) content = <ProjectPage key={arg} cwd={decodeURIComponent(arg)} onMenu={() => setMenuOpen(true)} />;
else if (section === "factory") content = <Factory 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)} />;
+46
View File
@@ -0,0 +1,46 @@
import { del, get } from "./api";
import { choiceDialog, confirmDialog, toast } from "./dialogs";
import { t } from "./i18n";
import { formatSize } from "./components/FileFigure";
interface FolderInfo {
own: boolean;
path?: string;
files?: number;
bytes?: number;
git?: boolean;
truncated?: boolean;
}
/**
* Delete a chat after asking. A chat with a folder of its own (not shared with other chats, not a
* project) gets the question whether the folder goes with it. Resolves to true when it was deleted.
*/
export async function deleteChat(session: { id: string; parent_id?: string | null }): Promise<boolean> {
const folder = session.parent_id ? undefined : await get<FolderInfo>(`/sessions/${session.id}/folder`).catch(() => undefined);
let withFolder = false;
if (folder?.own) {
const size = `${folder.truncated ? "> " : ""}${t("chat.folderFiles", { n: folder.files ?? 0 })}, ${formatSize(folder.bytes ?? 0)}`;
const choice = await choiceDialog({
title: t("chat.deleteTitle"),
message: `${t("chat.deleteWithFolder", { path: folder.path ?? "", size })}${folder.git ? `\n\n${t("chat.folderHasGit")}` : ""}`,
choices: [
{ value: "chat", label: t("chat.deleteOnlyChat") },
{ value: "both", label: t("chat.deleteChatAndFolder"), danger: true },
],
});
if (!choice) return false;
withFolder = choice === "both";
} else if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) {
return false;
}
try {
const r = await del<{ folder_deleted?: boolean; folder_error?: string }>(`/sessions/${session.id}${withFolder ? "?delete_folder=true" : ""}`);
if (r?.folder_error) toast(r.folder_error);
else if (r?.folder_deleted) toast(t("chat.folderDeleted"), "success");
return true;
} catch (e: any) {
toast(e.message);
return false;
}
}
+9 -6
View File
@@ -1,3 +1,4 @@
import { DraftBar, isDraftChat } from "./FactoryDraft";
import { takePrefill } from "../share";
import { Icon } from "./Icon";
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
@@ -24,7 +25,8 @@ import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs";
import { promptDialog, toast } from "../dialogs";
import { deleteChat } from "../chatActions";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
import { ColorSwatches, IconPicker } from "./ColorSwatches";
import { ProjectPermissions } from "./ProjectPermissions";
@@ -459,13 +461,15 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
return (
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
<summary>
{summary.auto
{summary.text.startsWith("[Context cleared")
? t("chat.cleared")
: summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")}
</summary>
{summaryOpen && <Markdown text={summary.text} />}
{summaryOpen && !summary.text.startsWith("[Context cleared") && <Markdown text={summary.text} />}
</details>
);
}
@@ -755,9 +759,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
};
const remove = async () => {
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
await del(`/sessions/${sessionId}`);
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
if (await deleteChat(session)) navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
};
// Context usage of the last request relative to the model's window.
@@ -1088,6 +1090,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && isDraftChat(session.cwd) && <DraftBar sessionId={sessionId} />}
{!readOnly && <OutboxBar sessionId={sessionId} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? (
+1
View File
@@ -20,6 +20,7 @@ interface Suggestion {
const BUILTIN: { name: string; key: Key }[] = [
{ name: "compact", key: "command.compact" },
{ name: "clear", key: "command.clear" },
{ name: "new", key: "command.new" },
];
+676
View File
@@ -0,0 +1,676 @@
import { useEffect, useMemo, useState } from "react";
import { get, post, put } from "../api";
import { fmtCompact, fmtCost, fmtRelative, useT } from "../i18n";
import { navigate } from "../router";
import { confirmDialog, toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
import { ModelSelect } from "./ModelSelect";
type Phase = "review" | "build" | "qa";
const PHASES: Phase[] = ["review", "build", "qa"];
interface PhaseCfg {
model: string;
agent: string;
permission_mode: string;
timeout_minutes: number;
extra_prompt: string;
}
interface RepoCfg {
url: string;
kind: string;
connection: string;
token: string;
label: string;
max_parallel: number;
allowed_users: string[];
enabled: boolean;
review: PhaseCfg;
build: PhaseCfg;
qa: PhaseCfg;
}
interface Connection {
id: string;
name: string;
kind: "gitea" | "github" | "gitlab";
url: string;
token: string;
owner: string;
}
interface Defaults {
spec_model: string;
review: PhaseCfg;
build: PhaseCfg;
qa: PhaseCfg;
}
interface Settings {
connections: Connection[];
defaults: Defaults;
repos: RepoCfg[];
}
interface Issue {
repo: string;
number: number;
title: string;
author: string;
state: string;
note: string;
pr_url: string;
rounds: number;
created_at: number;
updated_at: number;
}
interface Run {
id: string;
repo: string;
issue: number;
phase: Phase;
round: number;
session_id: string;
model: string;
started_at: number;
ended_at: number | null;
status: string;
input_tokens: number;
output_tokens: number;
cost: number;
}
interface RepoInfo {
url: string;
kind: string;
enabled: boolean;
label: string;
max_parallel: number;
has_token: boolean;
models: Record<Phase, string>;
running: number;
}
interface Overview {
area: boolean;
repos: RepoInfo[];
issues: Issue[];
runs: Run[];
}
/** Board columns: a column collects one or more issue states. */
const COLUMNS: { id: string; states: string[] }[] = [
{ id: "review", states: ["new", "review"] },
{ id: "question", states: ["question"] },
{ id: "approved", states: ["approved"] },
{ id: "build", states: ["building", "built"] },
{ id: "qa", states: ["qa"] },
{ id: "done", states: ["done"] },
{ id: "failed", states: ["failed", "rejected"] },
];
const KIND_NAME: Record<string, string> = { gitea: "Gitea", github: "GitHub", gitlab: "GitLab" };
const repoName = (url: string) => url.replace(/\.git$/, "").split("/").slice(-2).join("/");
const issueUrl = (repo: string, n: number) => `${repo.replace(/\.git$/, "")}/issues/${n}`;
function fmtDuration(ms: number) {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min`;
}
const runMs = (r: Run, now: number) => (r.ended_at ?? now) - r.started_at;
/** Software factory: spec issues in Gitea are reviewed, built and accepted by agents with a model per phase. */
export function Factory({ onMenu }: { onMenu: () => void }) {
const t = useT();
const [tab, setTab] = useState<"overview" | "settings">("overview");
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("factory.title")}</h1>
</div>
<div className="segmented">
{(["overview", "settings"] as const).map((x) => (
<button key={x} className={tab === x ? "active" : ""} onClick={() => setTab(x)}>
{t(`factory.tab.${x}` as any)}
</button>
))}
</div>
</div>
<div className="page">
<div className="page-inner" style={{ maxWidth: 1280 }}>
{tab === "overview" ? <FactoryOverview onSettings={() => setTab("settings")} /> : <FactorySettings />}
</div>
</div>
</>
);
}
function FactoryOverview({ onSettings }: { onSettings: () => void }) {
const t = useT();
const [data, setData] = useState<Overview>();
const [repo, setRepo] = useState<string>("");
const [sel, setSel] = useState<string>("");
const [now, setNow] = useState(Date.now());
const [busy, setBusy] = useState(false);
const reload = () =>
get<Overview>("/factory/overview")
.then(setData)
.catch((e) => toast(e.message));
useEffect(() => {
void reload();
const h = setInterval(() => {
void reload();
setNow(Date.now());
}, 5000);
return () => clearInterval(h);
}, []);
const newProject = async () => {
setBusy(true);
try {
const s = await post<{ id: string }>("/factory/drafts", {});
navigate(`/s/${s.id}`);
} catch (e: any) {
toast(e.message);
setBusy(false);
}
};
const issues = useMemo(() => (data?.issues ?? []).filter((i) => !repo || i.repo === repo), [data, repo]);
const runs = useMemo(() => (data?.runs ?? []).filter((r) => !repo || r.repo === repo), [data, repo]);
if (!data) return <div className="empty"><Spinner /></div>;
if (data.repos.length === 0)
return (
<div className="empty">
<p>{t("factory.empty")}</p>
<div className="row" style={{ gap: 8, justifyContent: "center" }}>
<button className="primary" onClick={newProject} disabled={busy}>
<Icon name="plus" /> {t("factory.newProject")}
</button>
<button onClick={onSettings}>{t("factory.tab.settings")}</button>
</div>
</div>
);
const sum = (f: (r: Run) => number) => runs.reduce((a, r) => a + f(r), 0);
const finished = runs.filter((r) => r.ended_at);
const running = runs.filter((r) => !r.ended_at);
const done = issues.filter((i) => i.state === "done").length;
const failed = issues.filter((i) => ["failed", "rejected"].includes(i.state)).length;
const runsOf = (i: Issue) => data.runs.filter((r) => r.repo === i.repo && r.issue === i.number);
const selected = issues.find((i) => `${i.repo}#${i.number}` === sel);
const shown = selected ? runsOf(selected) : runs.slice(0, 12);
const poll = async () => {
setBusy(true);
try {
const r = await post<{ errors: string[] }>("/factory/poll", {});
if (r.errors.length) toast(r.errors.join("\n"));
await reload();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
return (
<>
{!data.area && <div className="notice err">{t("factory.noArea")}</div>}
<div className="kpis">
<div className="kpi">
<div className="k-label">{t("factory.kpi.running")}</div>
<div className="k-value">{running.length}</div>
<div className="k-sub">{t("factory.kpi.issues", { n: issues.length })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.done")}</div>
<div className="k-value">{done}</div>
<div className="k-sub">{t("factory.kpi.failed", { n: failed })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.tokens")}</div>
<div className="k-value">{fmtCompact(sum((r) => r.input_tokens + r.output_tokens))}</div>
<div className="k-sub">
{fmtCompact(sum((r) => r.input_tokens))} {t("factory.in")} · {fmtCompact(sum((r) => r.output_tokens))} {t("factory.out")}
</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.cost")}</div>
<div className="k-value">{fmtCost(sum((r) => r.cost), "USD")}</div>
<div className="k-sub">{t("factory.kpi.runs", { n: runs.length })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("factory.kpi.time")}</div>
<div className="k-value">{finished.length ? fmtDuration(sum((r) => runMs(r, now)) / finished.length) : "–"}</div>
<div className="k-sub">{t("factory.kpi.perRun")}</div>
</div>
</div>
<div className="factory-repos">
<button className={`factory-repo ${repo === "" ? "active" : ""}`} onClick={() => setRepo("")}>
<b>{t("factory.allRepos")}</b>
<span className="hint">{t("factory.kpi.issues", { n: data.issues.length })}</span>
</button>
{data.repos.map((r) => {
const mine = data.issues.filter((i) => i.repo === r.url);
return (
<button key={r.url} className={`factory-repo ${repo === r.url ? "active" : ""} ${r.enabled ? "" : "off"}`} onClick={() => setRepo(r.url)}>
<b>{repoName(r.url)}</b>
<span className="chip">{KIND_NAME[r.kind] ?? r.kind}</span>
<span className="hint">
{r.running > 0 && <span className="factory-pulse" />}
{t("factory.repoLine", { n: mine.length, run: r.running })}
</span>
<span className="hint mono">{PHASES.map((p) => r.models[p] || "·").map((m) => m.split("/").pop()).join(" → ")}</span>
{!r.has_token && <span className="chip warn">{t("factory.noToken")}</span>}
</button>
);
})}
<button className="small primary" onClick={newProject} disabled={busy}>
<Icon name="plus" /> {t("factory.newProject")}
</button>
<button className="small" onClick={poll} disabled={busy} title={t("factory.pollNow")}>
<Icon name="refresh" /> {t("factory.pollNow")}
</button>
</div>
<div className="factory-board">
{COLUMNS.map((c) => {
const cards = issues.filter((i) => c.states.includes(i.state));
return (
<div key={c.id} className="factory-col">
<h4>
{t(`factory.col.${c.id}` as any)} <span className="chip">{cards.length}</span>
</h4>
{cards.map((i) => {
const rs = runsOf(i);
const active = rs.find((r) => !r.ended_at);
const latest = active ?? rs[0];
const tokens = rs.reduce((a, r) => a + r.input_tokens + r.output_tokens, 0);
return (
<button key={`${i.repo}#${i.number}`} className={`factory-card ${sel === `${i.repo}#${i.number}` ? "active" : ""} ${active ? "running" : ""}`} onClick={() => setSel(sel === `${i.repo}#${i.number}` ? "" : `${i.repo}#${i.number}`)}>
<span className="hint">
{repoName(i.repo)} #{i.number}
</span>
<b>{i.title || `#${i.number}`}</b>
{latest && (
<span className="hint">
{active && <span className="factory-pulse" />}
{t(`factory.phase.${latest.phase}` as any)} · {(latest.model || "").split("/").pop()} · {fmtDuration(runMs(latest, now))}
</span>
)}
{tokens > 0 && <span className="hint">{fmtCompact(tokens)} {t("factory.tokens")}</span>}
</button>
);
})}
</div>
);
})}
</div>
<div className="panel" style={{ marginTop: 16 }}>
<div className="row">
<h3 style={{ margin: 0 }}>{selected ? `${repoName(selected.repo)} #${selected.number} – ${selected.title}` : t("factory.latestRuns")}</h3>
<span className="spacer" />
{selected && (
<>
<a className="small ghost button-link" href={issueUrl(selected.repo, selected.number)} target="_blank" rel="noreferrer">
<Icon name="external" /> Gitea
</a>
{selected.pr_url && (
<a className="small ghost button-link" href={selected.pr_url} target="_blank" rel="noreferrer">
<Icon name="external" /> {t("factory.pr")}
</a>
)}
</>
)}
</div>
{selected?.note && <div className="hint" style={{ margin: "6px 0" }}>{selected.note}</div>}
{shown.length === 0 && <div className="hint">{t("factory.noRuns")}</div>}
{shown.length > 0 && (
<div className="table-wrap">
<table className="grid factory-runs">
<thead>
<tr>
{!selected && <th>{t("factory.issue")}</th>}
<th>{t("factory.phase")}</th>
<th>{t("factory.model")}</th>
<th>{t("factory.status")}</th>
<th>{t("factory.started")}</th>
<th>{t("factory.duration")}</th>
<th>{t("factory.tokens")}</th>
<th>{t("factory.cost")}</th>
<th />
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.id}>
{!selected && (
<td>
{repoName(r.repo)} #{r.issue}
</td>
)}
<td>
{t(`factory.phase.${r.phase}` as any)}
{r.round > 0 && <span className="hint"> · {t("factory.round", { n: r.round })}</span>}
</td>
<td className="mono">{r.model}</td>
<td>
<span className={`chip ${r.status === "done" ? "ok" : r.status === "running" ? "" : "warn"}`}>
{r.status === "running" && <span className="factory-pulse" />}
{t(`factory.runStatus.${r.status}` as any) || r.status}
</span>
</td>
<td>{fmtRelative(r.started_at)}</td>
<td>{fmtDuration(runMs(r, now))}</td>
<td title={`${r.input_tokens} / ${r.output_tokens}`}>
{fmtCompact(r.input_tokens)} / {fmtCompact(r.output_tokens)}
</td>
<td>{fmtCost(r.cost, "USD")}</td>
<td>
<button className="small" onClick={() => navigate(`/s/${r.session_id}`)}>
<Icon name="eye" /> {t("factory.openChat")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
const blankPhase = (): PhaseCfg => ({ model: "", agent: "codecraft", permission_mode: "auto", timeout_minutes: 60, extra_prompt: "" });
const blankRepo = (connection = "", kind = "gitea"): RepoCfg => ({ url: "", kind, connection, token: "", label: "factory", max_parallel: 1, allowed_users: [], enabled: true, review: blankPhase(), build: blankPhase(), qa: blankPhase() });
const blankConn = (): Connection => ({ id: "", name: "", kind: "gitea", url: "", token: "", owner: "" });
const MODES = ["default", "auto", "accept_edits", "bypass"];
function PhaseForm({ phase, value, onChange }: { phase: Phase; value: PhaseCfg; onChange: (patch: Partial<PhaseCfg>) => void }) {
const t = useT();
return (
<div className="factory-phase">
<h4>{t(`factory.phase.${phase}` as any)}</h4>
<label>
<span>{t("factory.model")}</span>
<ModelSelect value={value.model} onChange={(model) => onChange({ model })} emptyLabel={t("jobEditor.defaultModel")} />
</label>
<label>
<span>{t("factory.agent")}</span>
<select value={value.agent} onChange={(e) => onChange({ agent: e.target.value })}>
<option value="codecraft">CodeCraft</option>
<option value="claude-code">Claude Code</option>
</select>
</label>
<label>
<span>{t("factory.mode")}</span>
<select value={value.permission_mode} onChange={(e) => onChange({ permission_mode: e.target.value })}>
{MODES.map((m) => (
<option key={m} value={m}>
{t(`permissionMode.${m}` as any)}
</option>
))}
</select>
</label>
<label>
<span>{t("factory.timeout")}</span>
<input type="number" min={1} value={value.timeout_minutes} onChange={(e) => onChange({ timeout_minutes: Number(e.target.value) || 60 })} />
</label>
<label>
<span>{t("factory.extraPrompt")}</span>
<textarea rows={3} value={value.extra_prompt} onChange={(e) => onChange({ extra_prompt: e.target.value })} />
</label>
</div>
);
}
const URL_PLACEHOLDER: Record<string, string> = { gitea: "https://git.example.org", github: "https://github.com", gitlab: "https://gitlab.com" };
function FactorySettings() {
const t = useT();
const [st, setSt] = useState<Settings>();
const [busy, setBusy] = useState<string>();
const [orgs, setOrgs] = useState<Record<string, string[]>>({});
useEffect(() => {
get<Settings>("/account/factory")
.then(setSt)
.catch((e) => toast(e.message));
}, []);
if (!st) return <div className="empty"><Spinner /></div>;
const { connections: conns, repos } = st;
const setConns = (connections: Connection[]) => setSt({ ...st, connections });
const setRepos = (r: RepoCfg[]) => setSt({ ...st, repos: r });
const upConn = (i: number, patch: Partial<Connection>) => setConns(conns.map((c, k) => (k === i ? { ...c, ...patch } : c)));
const up = (i: number, patch: Partial<RepoCfg>) => setRepos(repos.map((r, k) => (k === i ? { ...r, ...patch } : r)));
const upPhase = (i: number, p: Phase, patch: Partial<PhaseCfg>) => up(i, { [p]: { ...repos[i][p], ...patch } } as any);
const upDefault = (p: Phase, patch: Partial<PhaseCfg>) => setSt({ ...st, defaults: { ...st.defaults, [p]: { ...st.defaults[p], ...patch } } });
const save = async () => {
const s = await put<Settings>("/account/factory", st);
setSt(s);
return s;
};
const act = async (key: string, f: () => Promise<void>) => {
setBusy(key);
try {
await f();
} catch (e: any) {
toast(e.message);
} finally {
setBusy(undefined);
}
};
return (
<>
<div className="notice">{t("factory.settingsHint")}</div>
<h3 className="factory-h">{t("factory.connections")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("factory.connectionsHint")}</div>
{conns.map((c, i) => (
<div key={i} className="panel">
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<h3 style={{ margin: 0 }}>{c.name || KIND_NAME[c.kind]}</h3>
<span className="spacer" />
<button
className="small"
disabled={busy !== undefined}
onClick={() =>
act(`c${i}`, async () => {
const saved = await save();
const id = saved.connections[i]?.id;
const r = await post<{ login: string; orgs: string[] }>("/account/factory/connection/test", { id });
setOrgs({ ...orgs, [id]: r.orgs });
toast(t("factory.connOk", { login: r.login, n: r.orgs.length }), "success");
})
}
>
{busy === `c${i}` ? <Spinner small /> : <Icon name="plug" />} {t("factory.testConn")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("factory.connDeleteConfirm", { name: c.name || KIND_NAME[c.kind] }), danger: true })) setConns(conns.filter((_, k) => k !== i));
}}
>
<Icon name="trash" />
</button>
</div>
<div className="factory-form">
<label>
<span>{t("factory.connName")}</span>
<input value={c.name} onChange={(e) => upConn(i, { name: e.target.value })} />
</label>
<label>
<span>{t("factory.kind")}</span>
<select value={c.kind} onChange={(e) => upConn(i, { kind: e.target.value as Connection["kind"] })}>
{Object.entries(KIND_NAME).map(([k, n]) => (
<option key={k} value={k}>
{n}
</option>
))}
</select>
</label>
<label>
<span>{t("factory.serviceUrl")}</span>
<input value={c.url} placeholder={URL_PLACEHOLDER[c.kind]} onChange={(e) => upConn(i, { url: e.target.value })} />
</label>
<label>
<span>{t("factory.token")}</span>
<input type="password" autoComplete="new-password" value={c.token} placeholder={t("factory.tokenPlaceholder")} onChange={(e) => upConn(i, { token: e.target.value })} onFocus={(e) => c.token.startsWith("•") && e.currentTarget.select()} />
<span className="hint">{c.token.startsWith("•") ? t("factory.tokenSet") : t(`factory.tokenHint.${c.kind}` as any)}</span>
</label>
<label>
<span>{t("factory.defaultOwner")}</span>
<input list={`orgs-${c.id}`} value={c.owner} placeholder={t("draft.ownerPlaceholder")} onChange={(e) => upConn(i, { owner: e.target.value })} />
<datalist id={`orgs-${c.id}`}>
{(orgs[c.id] ?? []).map((o) => (
<option key={o} value={o} />
))}
</datalist>
</label>
</div>
</div>
))}
<button onClick={() => setConns([...conns, blankConn()])}>
<Icon name="plus" /> {t("factory.addConn")}
</button>
<h3 className="factory-h">{t("factory.defaults")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>{t("factory.defaultsHint")}</div>
<div className="panel">
<label className="factory-form" style={{ margin: 0 }}>
<span style={{ fontSize: 12 }}>{t("factory.specModel")}</span>
<ModelSelect value={st.defaults.spec_model} onChange={(spec_model) => setSt({ ...st, defaults: { ...st.defaults, spec_model } })} emptyLabel={t("jobEditor.defaultModel")} />
</label>
<div className="factory-phases" style={{ marginTop: 10 }}>
{PHASES.map((p) => (
<PhaseForm key={p} phase={p} value={st.defaults[p]} onChange={(patch) => upDefault(p, patch)} />
))}
</div>
</div>
<h3 className="factory-h">{t("factory.repos")}</h3>
{repos.map((r, i) => (
<div key={i} className="panel">
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
<h3 style={{ margin: 0 }}>{r.url ? repoName(r.url) : t("factory.newRepo")}</h3>
<span className="chip">{KIND_NAME[conns.find((c) => c.id === r.connection)?.kind ?? r.kind] ?? r.kind}</span>
<span className="spacer" />
<label className="switch">
<input type="checkbox" checked={r.enabled} onChange={(e) => up(i, { enabled: e.target.checked })} />
<span>{t(r.enabled ? "jobs.on" : "jobs.off")}</span>
</label>
<button
className="small"
disabled={busy !== undefined}
onClick={() =>
act(`t${i}`, async () => {
await save();
const res = await post<{ info: { login: string; repo: string; can_push: boolean }; labels_created: string[]; folder: string }>("/account/factory/test", { url: r.url });
toast(t("factory.testOk", { login: res.info.login, repo: res.info.repo, n: res.labels_created.length }), "success");
})
}
>
{busy === `t${i}` ? <Spinner small /> : <Icon name="plug" />} {t("factory.test")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("factory.deleteConfirm", { name: repoName(r.url) || "?" }), danger: true })) setRepos(repos.filter((_, k) => k !== i));
}}
>
<Icon name="trash" />
</button>
</div>
<div className="factory-form">
<label>
<span>{t("factory.connection")}</span>
<select
value={r.connection}
onChange={(e) => up(i, { connection: e.target.value, kind: conns.find((c) => c.id === e.target.value)?.kind ?? r.kind })}
>
<option value="">{t("factory.noConnection")}</option>
{conns
.filter((c) => c.id)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name || KIND_NAME[c.kind]}
</option>
))}
</select>
</label>
{!r.connection && (
<label>
<span>{t("factory.kind")}</span>
<select value={r.kind} onChange={(e) => up(i, { kind: e.target.value })}>
{Object.entries(KIND_NAME).map(([k, n]) => (
<option key={k} value={k}>
{n}
</option>
))}
</select>
</label>
)}
<label>
<span>{t("factory.url")}</span>
<input value={r.url} placeholder="https://host/owner/repo.git" onChange={(e) => up(i, { url: e.target.value })} />
</label>
<label>
<span>{t("factory.repoToken")}</span>
<input type="password" autoComplete="new-password" value={r.token} placeholder={t("factory.repoTokenPlaceholder")} onChange={(e) => up(i, { token: e.target.value })} onFocus={(e) => r.token.startsWith("•") && e.currentTarget.select()} />
<span className="hint">{r.token.startsWith("•") ? t("factory.tokenSet") : t("factory.repoTokenHint")}</span>
</label>
<label>
<span>{t("factory.label")}</span>
<input value={r.label} onChange={(e) => up(i, { label: e.target.value })} />
</label>
<label>
<span>{t("factory.parallel")}</span>
<input type="number" min={1} max={4} value={r.max_parallel} onChange={(e) => up(i, { max_parallel: Number(e.target.value) || 1 })} />
</label>
<label>
<span>{t("factory.allowed")}</span>
<input value={r.allowed_users.join(", ")} placeholder={t("factory.allowedPlaceholder")} onChange={(e) => up(i, { allowed_users: e.target.value.split(",").map((u) => u.trim()).filter(Boolean) })} />
</label>
</div>
<div className="factory-phases">
{PHASES.map((p) => (
<PhaseForm key={p} phase={p} value={r[p]} onChange={(patch) => upPhase(i, p, patch)} />
))}
</div>
</div>
))}
<div className="row" style={{ gap: 8, marginTop: 12 }}>
<button onClick={() => setRepos([...repos, blankRepo(conns.find((c) => c.id)?.id ?? "", conns.find((c) => c.id)?.kind ?? "gitea")])}>
<Icon name="plus" /> {t("factory.addRepo")}
</button>
<button
className="primary"
disabled={busy !== undefined}
onClick={() =>
act("save", async () => {
await save();
toast(t("factory.saved"), "success");
})
}
>
{busy === "save" ? <Spinner small /> : <Icon name="check" />} {t("common.save")}
</button>
</div>
</>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from "react";
import { get, post } from "../api";
import { useT } from "../i18n";
import { navigate } from "../router";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
import { Spinner } from "./Activity";
interface Draft {
spec: string;
proposal: { name?: string; slug?: string; description?: string } | null;
connections: { id: string; name: string; kind: string; owner: string }[];
}
export const isDraftChat = (cwd: string) => /\/_drafts\/[^/]+$/.test(cwd);
const slugify = (s: string) =>
s
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
/** Bar above the composer of a spec chat: file the spec as a new repository and start the workflow. */
export function DraftBar({ sessionId }: { sessionId: string }) {
const t = useT();
const [open, setOpen] = useState(false);
return (
<>
<div className="draft-bar">
<Icon name="scroll" />
<span>{t("draft.hint")}</span>
<span className="spacer" />
<button className="small primary" onClick={() => setOpen(true)}>
<Icon name="check" /> {t("draft.create")}
</button>
</div>
{open && <CreateProjectDialog sessionId={sessionId} onClose={() => setOpen(false)} />}
</>
);
}
function CreateProjectDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
const t = useT();
const [draft, setDraft] = useState<Draft>();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [description, setDescription] = useState("");
const [connection, setConnection] = useState("");
const [owner, setOwner] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
get<Draft>(`/factory/drafts/${sessionId}`)
.then((d) => {
setDraft(d);
setName(d.proposal?.name ?? "");
setSlug(d.proposal?.slug ? slugify(d.proposal.slug) : "");
setSlugTouched(!!d.proposal?.slug);
setDescription(d.proposal?.description ?? "");
setConnection(d.connections[0]?.id ?? "");
setOwner(d.connections[0]?.owner ?? "");
})
.catch((e) => {
toast(e.message);
onClose();
});
}, [sessionId]);
if (!draft)
return (
<div className="modal-backdrop">
<div className="modal">
<Spinner />
</div>
</div>
);
const conn = draft.connections.find((c) => c.id === connection);
const ready = draft.spec.trim() && name.trim() && slug.length >= 2 && connection;
const create = async () => {
setBusy(true);
try {
const r = await post<{ repo: string; issue: number }>(`/factory/drafts/${sessionId}/create`, { connection, name, slug, description, owner });
await post("/factory/poll", {}).catch(() => undefined);
toast(t("draft.created", { repo: r.repo, n: r.issue }), "success");
onClose();
navigate("/factory");
} catch (e: any) {
toast(e.message);
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={busy ? undefined : onClose}>
<div className="modal draft-modal" role="dialog" aria-label={t("draft.create")} onClick={(e) => e.stopPropagation()}>
<h3>{t("draft.create")}</h3>
{draft.connections.length === 0 ? (
<div className="notice err">
{t("draft.noConnection")}{" "}
<button className="link-btn" onClick={() => navigate("/factory")}>
{t("factory.tab.settings")}
</button>
</div>
) : (
<>
{!draft.spec.trim() && <div className="notice err">{t("draft.noSpec")}</div>}
<div className="factory-form">
<label>
<span>{t("draft.name")}</span>
<input
autoFocus
value={name}
onChange={(e) => {
setName(e.target.value);
if (!slugTouched) setSlug(slugify(e.target.value));
}}
/>
</label>
<label>
<span>{t("draft.slug")}</span>
<input
value={slug}
className="mono"
onChange={(e) => {
setSlug(slugify(e.target.value));
setSlugTouched(true);
}}
/>
</label>
<label style={{ gridColumn: "1 / -1" }}>
<span>{t("draft.description")}</span>
<input value={description} onChange={(e) => setDescription(e.target.value)} />
</label>
<label>
<span>{t("draft.connection")}</span>
<select
value={connection}
onChange={(e) => {
setConnection(e.target.value);
setOwner(draft.connections.find((c) => c.id === e.target.value)?.owner ?? "");
}}
>
{draft.connections.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.kind} ({c.kind})
</option>
))}
</select>
</label>
<label>
<span>{t("draft.owner")}</span>
<input value={owner} placeholder={t("draft.ownerPlaceholder")} onChange={(e) => setOwner(e.target.value)} />
</label>
</div>
<div className="hint" style={{ margin: "6px 0" }}>
{t("draft.private", { conn: conn?.name || "" })}
</div>
<details>
<summary>{t("draft.specPreview")}</summary>
<pre className="draft-spec">{draft.spec || "–"}</pre>
</details>
</>
)}
<div className="row" style={{ justifyContent: "flex-end", gap: 8, marginTop: 12 }}>
<button onClick={onClose} disabled={busy}>
{t("common.cancel")}
</button>
<button className="primary" onClick={create} disabled={!ready || busy}>
{busy ? <Spinner small /> : <Icon name="check" />} {t("draft.createGo")}
</button>
</div>
</div>
</div>
);
}
+17 -3
View File
@@ -1,4 +1,4 @@
import { ColorSwatches, IconPicker } from "./ColorSwatches";
import { deleteChat } from "../chatActions";
import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
@@ -98,11 +98,21 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
</button>
)}
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button>
{!s.shared && (
<button
role="menuitem"
className="danger"
onClick={async () => {
setMenu(false);
if ((await deleteChat(s)) && active) navigate("/new");
}}
>
<Icon name="trash" /> {t("chat.delete")}
</button>
)}
</div>
)}
</div>
@@ -481,6 +491,10 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
<Icon name="clock" className="nav-icon" />
{t("nav.jobs")}
</button>
<button className={`ghost ${section === "factory" ? "active" : ""}`} onClick={() => navigate("/factory")}>
<Icon name="toolbox" className="nav-icon" />
{t("nav.factory")}
</button>
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
<Icon name="chart" className="nav-icon" />
{t("nav.stats")}
+1 -1
View File
@@ -121,7 +121,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
const openByDefault =
(status === "err" && !!result) ||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d"));
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || (result.images?.length ?? 0) > 0 || call.name === "generate_image" || call.name === "generate_3d"));
const open = toggled ?? openByDefault;
return (
+2
View File
@@ -3,6 +3,7 @@ import { post } from "../../api";
import { useT } from "../../i18n";
import { useStore } from "../../store";
import { GitAccount } from "./GitAccount";
import { SshKeys } from "./SshKeys";
import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory";
@@ -54,6 +55,7 @@ export function Account() {
<Memory />
<Snippets />
<GitAccount />
<SshKeys />
<CliInstall />
<ClaudeCode />
<Telegram />
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import { del, get, post } from "../../api";
import { useT } from "../../i18n";
import { confirmDialog, toast } from "../../dialogs";
import { Icon } from "../Icon";
interface Key {
id: string;
name: string;
public: string;
fingerprint: string;
}
/** SSH keys of the user: used by agents, the terminal and the git panel to reach other servers. */
export function SshKeys() {
const t = useT();
const [keys, setKeys] = useState<Key[]>();
const [name, setName] = useState("");
const [priv, setPriv] = useState("");
const [importing, setImporting] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
get<Key[]>("/account/ssh")
.then(setKeys)
.catch((e) => toast(e.message));
}, []);
if (!keys) return null;
const add = async () => {
setBusy(true);
try {
setKeys(await post<Key[]>("/account/ssh", { name, private: importing ? priv : undefined }));
setName("");
setPriv("");
toast(t("ssh.added"), "success");
} catch (e: any) {
toast(e.message);
} finally {
setBusy(false);
}
};
const copy = (text: string) => navigator.clipboard?.writeText(text).then(() => toast(t("ssh.copied"), "success"), () => toast(t("ssh.copyFailed")));
return (
<div className="panel">
<h3>
<Icon name="lock" /> {t("ssh.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("ssh.hint")}
</div>
{keys.length === 0 && <div className="hint">{t("ssh.none")}</div>}
{keys.map((k) => (
<div key={k.id} className="ssh-key">
<div className="row">
<b>{k.name}</b>
<span className="hint mono">{k.fingerprint}</span>
<span className="spacer" />
<button className="small" onClick={() => copy(k.public)}>
<Icon name="copy" /> {t("ssh.copyPublic")}
</button>
<button
className="small"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={async () => {
if (await confirmDialog({ message: t("ssh.deleteConfirm", { name: k.name }), danger: true })) {
del<Key[]>(`/account/ssh/${k.id}`).then(setKeys).catch((e) => toast(e.message));
}
}}
>
<Icon name="trash" />
</button>
</div>
<input readOnly value={k.public} onFocus={(e) => e.currentTarget.select()} aria-label={t("ssh.public")} className="mono" />
</div>
))}
<div className="row" style={{ marginTop: 10, flexWrap: "wrap" }}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("ssh.name")} maxLength={60} aria-label={t("ssh.name")} style={{ flex: "1 1 160px" }} />
<button className="primary" disabled={busy || !name.trim() || (importing && !priv.trim())} onClick={add}>
{importing ? t("ssh.import") : t("ssh.generate")}
</button>
<label className="row" style={{ gap: 6 }}>
<input type="checkbox" checked={importing} onChange={(e) => setImporting(e.target.checked)} />
{t("ssh.importExisting")}
</label>
</div>
{importing && (
<>
<textarea rows={5} className="mono" value={priv} onChange={(e) => setPriv(e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" aria-label={t("ssh.private")} style={{ width: "100%", marginTop: 8 }} spellCheck={false} autoComplete="off" />
<div className="hint">{t("ssh.importHint")}</div>
</>
)}
</div>
);
}
+36 -8
View File
@@ -20,8 +20,15 @@ interface PromptOpts {
confirmLabel?: string;
}
interface ChoiceOpts {
title?: string;
message: string;
choices: { value: string; label: string; danger?: boolean }[];
}
type Dialog =
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
| ({ kind: "choice"; id: number; resolve: (v: string | null) => void } & ChoiceOpts)
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
interface Toast {
@@ -45,6 +52,14 @@ export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
});
}
/** Several actions to choose from (plus Cancel): resolves to the chosen value, or null. */
export function choiceDialog(opts: ChoiceOpts): Promise<string | null> {
return new Promise((resolve) => {
dialogs = [...dialogs, { kind: "choice", id: ++seq, resolve, ...opts }];
emit();
});
}
export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
const o = typeof opts === "string" ? { message: opts } : opts;
return new Promise((resolve) => {
@@ -108,14 +123,27 @@ function DialogView({ d }: { d: Dialog }) {
style={{ width: "100%" }}
/>
)}
<div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")}
</button>
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
{d.confirmLabel ?? t("common.ok")}
</button>
</div>
{d.kind === "choice" ? (
<div className="row dialog-actions dialog-choices">
<button type="button" onClick={() => close(d, null)}>
{t("common.cancel")}
</button>
{d.choices.map((c, i) => (
<button key={c.value} ref={i === d.choices.length - 1 ? ok : undefined} type="button" className={c.danger ? "danger-solid" : i === d.choices.length - 1 ? "primary" : undefined} onClick={() => close(d, c.value)}>
{c.label}
</button>
))}
</div>
) : (
<div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")}
</button>
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
{d.confirmLabel ?? t("common.ok")}
</button>
</div>
)}
</form>
</div>
);
+130
View File
@@ -1144,4 +1144,134 @@ export const de: Messages = {
"stats.table.agent": "nach Modus",
"stats.table.user": "nach Benutzer",
"stats.table.session": "teuerste Chats",
"command.clear": "Bisheriges Gespräch vergessen (der Chat bleibt sichtbar)",
"chat.cleared": "Kontext geleert – das Modell sieht die Nachrichten darüber nicht mehr",
"ssh.title": "SSH-Schlüssel",
"ssh.hint": "Schlüssel, mit denen Agenten, Terminal und Git-Panel andere Server erreichen (ssh, scp, rsync, Git über ssh). Sie werden automatisch genutzt; Hosts werden beim ersten Kontakt akzeptiert. Den öffentlichen Schlüssel trägst du in die authorized_keys des Servers ein.",
"ssh.none": "Noch keine Schlüssel.",
"ssh.name": "Name (z. B. Laptop, Build-Server)",
"ssh.generate": "Neuen Schlüssel erzeugen",
"ssh.import": "Schlüssel importieren",
"ssh.importExisting": "Vorhandenen Schlüssel importieren",
"ssh.importHint": "Nur private Schlüssel ohne Passphrase. Der Schlüssel wird für dein Konto auf dem Server gespeichert.",
"ssh.private": "Privater Schlüssel",
"ssh.public": "Öffentlicher Schlüssel",
"ssh.copyPublic": "Öffentlichen Schlüssel kopieren",
"ssh.copied": "Öffentlicher Schlüssel kopiert",
"ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren",
"ssh.added": "Schlüssel hinzugefügt",
"ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.",
"chat.deleteTitle": "Chat löschen",
"chat.deleteWithFolder": "Dieser Chat hat einen eigenen Ordner:\n{path}\n({size})\n\nSoll der Ordner mit dem Chat gelöscht werden?",
"chat.deleteOnlyChat": "Nur Chat löschen",
"chat.deleteChatAndFolder": "Chat und Ordner löschen",
"chat.folderFiles": "{n} Dateien",
"chat.folderHasGit": "Der Ordner enthält ein Git-Repository – nicht gepushte Commits und Branches gehen verloren.",
"chat.folderDeleted": "Chat und Ordner gelöscht",
"nav.factory": "Factory",
"factory.title": "Software-Factory",
"factory.tab.overview": "Übersicht",
"factory.tab.settings": "Einstellungen",
"factory.empty": "Noch kein Repository verbunden. Füge in den Einstellungen eines hinzu: Issues mit dem Factory-Label werden dann von Agenten geprüft, gebaut und abgenommen.",
"factory.addRepo": "Repository hinzufügen",
"factory.newRepo": "Neues Repository",
"factory.noArea": "Der Factory-Bereich ist nicht eingebunden (FACTORY_DIR → /factory). Repositories können nicht geklont werden.",
"factory.kpi.running": "Läuft gerade",
"factory.kpi.issues": "{n} Issues",
"factory.kpi.done": "Fertig",
"factory.kpi.failed": "{n} fehlgeschlagen oder abgelehnt",
"factory.kpi.tokens": "Tokens",
"factory.kpi.cost": "Kosten",
"factory.kpi.runs": "{n} Läufe",
"factory.kpi.time": "Ø Laufzeit",
"factory.kpi.perRun": "pro beendetem Lauf",
"factory.in": "ein",
"factory.out": "aus",
"factory.allRepos": "Alle Projekte",
"factory.repoLine": "{n} Issues · {run} laufen",
"factory.noToken": "kein Token",
"factory.pollNow": "Jetzt prüfen",
"factory.col.review": "Prüfung",
"factory.col.question": "Rückfragen",
"factory.col.approved": "Freigegeben",
"factory.col.build": "Bau",
"factory.col.qa": "Abnahme",
"factory.col.done": "Fertig",
"factory.col.failed": "Fehlgeschlagen / abgelehnt",
"factory.phase": "Phase",
"factory.phase.review": "Prüfung",
"factory.phase.build": "Bau",
"factory.phase.qa": "Abnahme",
"factory.latestRuns": "Letzte Läufe",
"factory.noRuns": "Noch keine Läufe.",
"factory.issue": "Issue",
"factory.model": "Modell",
"factory.status": "Status",
"factory.started": "Gestartet",
"factory.duration": "Dauer",
"factory.tokens": "Tokens",
"factory.cost": "Kosten",
"factory.pr": "Pull Request",
"factory.round": "Runde {n}",
"factory.openChat": "Chat öffnen",
"factory.runStatus.running": "läuft",
"factory.runStatus.done": "fertig",
"factory.runStatus.timeout": "Zeitlimit",
"factory.runStatus.error": "Fehler",
"factory.runStatus.no-result": "kein Ergebnis",
"factory.runStatus.interrupted": "unterbrochen",
"factory.settingsHint": "Specs kommen als Gitea-Issues mit dem Factory-Label (\"Spec: <Pfad>\" im Text). Jede Phase läuft als eigener Chat in der Sandbox mit eigenem Modell. Repositories werden in den separaten Factory-Bereich geklont, nicht in den Workspace; die Agenten sehen den API-Token nie.",
"factory.url": "Repository-URL",
"factory.token": "Gitea-API-Token",
"factory.tokenPlaceholder": "Token mit Rechten für Issues und Pull Requests",
"factory.tokenSet": "Ein Token ist gespeichert. Gib einen neuen ein, um ihn zu ersetzen.",
"factory.label": "Issue-Label",
"factory.parallel": "Parallele Läufe",
"factory.allowed": "Erlaubte Issue-Autoren",
"factory.allowedPlaceholder": "leer = nur der Token-Besitzer",
"factory.agent": "Agent",
"factory.mode": "Berechtigungsmodus",
"factory.timeout": "Zeitlimit (Minuten)",
"factory.extraPrompt": "Zusätzliche Anweisungen",
"factory.test": "Verbindung testen",
"factory.testOk": "Verbunden als {login} mit {repo}; {n} Labels angelegt, Repository geklont.",
"factory.deleteConfirm": "{name} aus der Factory entfernen? Issues und Chats bleiben.",
"factory.newProject": "Neues Projekt",
"factory.connections": "Verbindungen",
"factory.connectionsHint": "Deine Konten bei Gitea, GitHub oder GitLab. Jeder Nutzer richtet seine eigenen ein; Repositories nutzen den Token ihrer Verbindung, sofern sie keinen eigenen haben.",
"factory.connName": "Name",
"factory.kind": "Dienst",
"factory.serviceUrl": "Adresse des Dienstes",
"factory.defaultOwner": "Standard-Besitzer für neue Repositories",
"factory.addConn": "Verbindung hinzufügen",
"factory.testConn": "Testen",
"factory.connOk": "Angemeldet als {login}; {n} Organisationen oder Gruppen gefunden.",
"factory.connDeleteConfirm": "Verbindung {name} entfernen? Repositories funktionieren danach nur mit eigenem Token.",
"factory.tokenHint.gitea": "Scopes: write:repository, write:issue, read:user – zum Anlegen von Repositories zusätzlich write:user (oder write:organization).",
"factory.tokenHint.github": "Classic-Token mit Scope \"repo\" oder fein abgestufter Token mit Contents, Issues und Pull requests (Lesen/Schreiben).",
"factory.tokenHint.gitlab": "Persönlicher oder Projekt-Zugriffstoken mit Scope \"api\".",
"factory.defaults": "Standard für neue Projekte",
"factory.defaultsHint": "Modelle und Grenzen, mit denen ein Projekt startet; du kannst sie danach pro Projekt ändern.",
"factory.specModel": "Modell des Spec-Chats",
"factory.repos": "Repositories",
"factory.connection": "Verbindung",
"factory.noConnection": "– keine (eigener Token) –",
"factory.repoToken": "Eigener Token (optional)",
"factory.repoTokenPlaceholder": "leer = Token der Verbindung",
"factory.repoTokenHint": "Nur nötig, wenn dieses Repository einen anderen Token nutzen soll.",
"draft.hint": "Spec-Chat: Wenn die Spec fertig ist, lege sie als neues Repository ab.",
"draft.create": "Projekt anlegen",
"draft.createGo": "Repository anlegen und starten",
"draft.name": "Projektname",
"draft.slug": "Repository-Name",
"draft.description": "Kurzbeschreibung",
"draft.connection": "Verbindung",
"draft.owner": "Besitzer",
"draft.ownerPlaceholder": "leer = dein eigenes Konto",
"draft.private": "Das Repository wird privat angelegt. Die Spec wird als docs/specs/<name>.md committet und ein Issue startet den Workflow.",
"draft.specPreview": "Spec anzeigen",
"draft.noSpec": "In diesem Chat gibt es noch keine spec.md – lass sie zuerst vom Modell schreiben.",
"draft.noConnection": "Richte zuerst eine Verbindung (Gitea, GitHub oder GitLab) ein:",
"draft.created": "{repo} angelegt, Issue #{n} eröffnet – die Prüfung startet gleich.",
"factory.saved": "Gespeichert",
};
+130
View File
@@ -1143,6 +1143,136 @@ export const en = {
"stats.table.agent": "by agent",
"stats.table.user": "by user",
"stats.table.session": "top chats",
"command.clear": "Forget the conversation so far (the chat stays visible)",
"chat.cleared": "Context cleared – the model no longer sees the messages above",
"ssh.title": "SSH keys",
"ssh.hint": "Keys that agents, the terminal and the git panel use to reach other servers (ssh, scp, rsync, git over ssh). They are used automatically; hosts are trusted on first contact. Add the public key to the server's authorized_keys.",
"ssh.none": "No keys yet.",
"ssh.name": "Name (e.g. laptop, build server)",
"ssh.generate": "Generate new key",
"ssh.import": "Import key",
"ssh.importExisting": "Import an existing key",
"ssh.importHint": "Private keys without a passphrase only. The key is stored on the server for your account.",
"ssh.private": "Private key",
"ssh.public": "Public key",
"ssh.copyPublic": "Copy public key",
"ssh.copied": "Public key copied",
"ssh.copyFailed": "Could not copy – select the text manually",
"ssh.added": "Key added",
"ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.",
"chat.deleteTitle": "Delete chat",
"chat.deleteWithFolder": "This chat has a folder of its own:\n{path}\n({size})\n\nDelete the folder with the chat?",
"chat.deleteOnlyChat": "Delete chat only",
"chat.deleteChatAndFolder": "Delete chat and folder",
"chat.folderFiles": "{n} files",
"chat.folderHasGit": "The folder contains a git repository – commits and branches that were not pushed are lost.",
"chat.folderDeleted": "Chat and folder deleted",
"nav.factory": "Factory",
"factory.title": "Software factory",
"factory.tab.overview": "Overview",
"factory.tab.settings": "Settings",
"factory.empty": "No repository connected yet. Add one in the settings: issues with the factory label are then reviewed, built and checked by agents.",
"factory.addRepo": "Add repository",
"factory.newRepo": "New repository",
"factory.noArea": "The factory area is not mounted (FACTORY_DIR → /factory). Repositories cannot be cloned.",
"factory.kpi.running": "Running now",
"factory.kpi.issues": "{n} issues",
"factory.kpi.done": "Done",
"factory.kpi.failed": "{n} failed or rejected",
"factory.kpi.tokens": "Tokens",
"factory.kpi.cost": "Cost",
"factory.kpi.runs": "{n} runs",
"factory.kpi.time": "Avg. run time",
"factory.kpi.perRun": "per finished run",
"factory.in": "in",
"factory.out": "out",
"factory.allRepos": "All projects",
"factory.repoLine": "{n} issues · {run} running",
"factory.noToken": "no token",
"factory.pollNow": "Check now",
"factory.col.review": "Review",
"factory.col.question": "Questions",
"factory.col.approved": "Approved",
"factory.col.build": "Build",
"factory.col.qa": "Acceptance",
"factory.col.done": "Done",
"factory.col.failed": "Failed / rejected",
"factory.phase": "Phase",
"factory.phase.review": "Review",
"factory.phase.build": "Build",
"factory.phase.qa": "Acceptance",
"factory.latestRuns": "Latest runs",
"factory.noRuns": "No runs yet.",
"factory.issue": "Issue",
"factory.model": "Model",
"factory.status": "Status",
"factory.started": "Started",
"factory.duration": "Duration",
"factory.tokens": "Tokens",
"factory.cost": "Cost",
"factory.pr": "Pull request",
"factory.round": "round {n}",
"factory.openChat": "Open chat",
"factory.runStatus.running": "running",
"factory.runStatus.done": "done",
"factory.runStatus.timeout": "timeout",
"factory.runStatus.error": "error",
"factory.runStatus.no-result": "no result",
"factory.runStatus.interrupted": "interrupted",
"factory.settingsHint": "Specs arrive as Gitea issues with the factory label (\"Spec: <path>\" in the text). Each phase runs as its own chat in the sandbox with its own model. Repositories are cloned into the separate factory area, not into the workspace; agents never see the API token.",
"factory.url": "Repository URL",
"factory.token": "Gitea API token",
"factory.tokenPlaceholder": "Token with rights for issues and pull requests",
"factory.tokenSet": "A token is stored. Type a new one to replace it.",
"factory.label": "Issue label",
"factory.parallel": "Parallel runs",
"factory.allowed": "Allowed issue authors",
"factory.allowedPlaceholder": "empty = only the token's owner",
"factory.agent": "Agent",
"factory.mode": "Permission mode",
"factory.timeout": "Timeout (minutes)",
"factory.extraPrompt": "Extra instructions",
"factory.test": "Test connection",
"factory.testOk": "Connected as {login} to {repo}; {n} labels created, repository cloned.",
"factory.deleteConfirm": "Remove {name} from the factory? Issues and chats stay.",
"factory.newProject": "New project",
"factory.connections": "Connections",
"factory.connectionsHint": "Your accounts at Gitea, GitHub or GitLab. Each user sets up their own; repositories use the token of their connection unless they have a token of their own.",
"factory.connName": "Name",
"factory.kind": "Service",
"factory.serviceUrl": "Address of the service",
"factory.defaultOwner": "Default owner for new repositories",
"factory.addConn": "Add connection",
"factory.testConn": "Test",
"factory.connOk": "Signed in as {login}; {n} organisations or groups found.",
"factory.connDeleteConfirm": "Remove the connection {name}? Repositories keep working only with a token of their own.",
"factory.tokenHint.gitea": "Scopes: write:repository, write:issue, read:user – plus write:user (or write:organization) to create repositories.",
"factory.tokenHint.github": "Classic token with scope \"repo\", or a fine-grained token with Contents, Issues and Pull requests (read/write).",
"factory.tokenHint.gitlab": "Personal or project access token with scope \"api\".",
"factory.defaults": "Defaults for new projects",
"factory.defaultsHint": "Models and limits a project starts with; you can change them per project afterwards.",
"factory.specModel": "Model of the spec chat",
"factory.repos": "Repositories",
"factory.connection": "Connection",
"factory.noConnection": "– none (own token) –",
"factory.repoToken": "Own token (optional)",
"factory.repoTokenPlaceholder": "empty = token of the connection",
"factory.repoTokenHint": "Only needed when this repository should use a different token.",
"draft.hint": "Spec chat: when the spec is done, file it as a new repository.",
"draft.create": "Create project",
"draft.createGo": "Create repository and start",
"draft.name": "Project name",
"draft.slug": "Repository name",
"draft.description": "Short description",
"draft.connection": "Connection",
"draft.owner": "Owner",
"draft.ownerPlaceholder": "empty = your own account",
"draft.private": "The repository is created private. The spec is committed as docs/specs/<name>.md and an issue starts the workflow.",
"draft.specPreview": "Show spec",
"draft.noSpec": "There is no spec.md in this chat yet – let the model write it first.",
"draft.noConnection": "Set up a connection (Gitea, GitHub or GitLab) first:",
"draft.created": "{repo} created, issue #{n} opened – the review starts shortly.",
"factory.saved": "Saved",
};
export type Messages = Record<keyof typeof en, string>;
+158 -10
View File
@@ -600,9 +600,6 @@ button.session-group:hover {
background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
border-color: color-mix(in srgb, var(--tag) 70%, transparent);
}
.chat-pane[data-color] .msg.bubble.agent {
background: color-mix(in srgb, var(--tag) 7%, var(--bg1));
}
@media (max-width: 700px) {
.msg.user-msg {
max-width: 94%;
@@ -4758,15 +4755,10 @@ span.chat-tab > button {
}
}
.chat-pane[data-color] {
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
}
.chat-pane[data-color] .composer-wrap {
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
}
/* The chat's colour marks the top bar (line), the list entry, the badge and your own bubbles –
* the chat background itself stays neutral. */
.topbar[data-color] {
border-bottom: 3px solid var(--tag);
background: color-mix(in srgb, var(--tag) 28%, var(--bg0));
}
.session-item[data-color] {
box-shadow: inset 4px 0 0 var(--tag);
@@ -5022,3 +5014,159 @@ td.fold-indent {
.share-link input { flex: 1 1 200px; min-width: 0; }
.offline-bar { background: color-mix(in srgb, var(--warn, #d79921) 25%, var(--bg0, transparent)); color: var(--fg, inherit); padding: 6px 12px; font-size: 0.85rem; text-align: center; }
.ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.ssh-key .row { align-items: center; flex-wrap: wrap; }
.ssh-key input.mono { width: 100%; font-size: 12px; }
.dialog-message { overflow-wrap: anywhere; }
.dialog-choices { flex-wrap: wrap; }
/* --- software factory ------------------------------------------------------ */
.button-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
text-decoration: none;
color: var(--fg1);
}
.table-wrap {
overflow-x: auto;
}
.factory-repos {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
align-items: stretch;
}
.factory-repo {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
background: var(--bg1);
border: 1px solid var(--bg2);
border-radius: var(--radius);
padding: 8px 12px;
min-width: 150px;
}
.factory-repo.active {
border-color: var(--accent);
}
.factory-repo.off {
opacity: 0.6;
}
.factory-board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(200px, 1fr);
gap: 10px;
overflow-x: auto;
padding-bottom: 6px;
}
.factory-col {
background: var(--bg1);
border-radius: var(--radius);
padding: 8px;
min-height: 120px;
display: flex;
flex-direction: column;
gap: 6px;
}
.factory-col h4 {
margin: 0 0 2px;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
}
.factory-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
text-align: left;
background: var(--bg0);
border: 1px solid var(--bg2);
border-radius: 8px;
padding: 8px 10px;
}
.factory-card.active {
border-color: var(--accent);
}
.factory-card.running {
border-left: 3px solid var(--accent);
}
.factory-pulse {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
margin-right: 5px;
animation: factory-pulse 1.4s ease-in-out infinite;
}
@keyframes factory-pulse {
50% {
opacity: 0.25;
}
}
.factory-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
margin: 12px 0;
}
.factory-form label,
.factory-phase label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
color: var(--fg3);
}
.factory-phases {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.factory-phase {
background: var(--bg1);
border-radius: var(--radius);
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.factory-phase h4 {
margin: 0;
}
.factory-h {
margin: 22px 0 6px;
}
.draft-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg1);
border-top: 1px solid var(--bg2);
font-size: 13px;
color: var(--fg2);
}
.draft-modal {
width: min(640px, calc(100vw - 32px));
}
.draft-spec {
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
background: var(--bg1);
border-radius: 6px;
padding: 8px;
font-size: 12px;
}
+48
View File
@@ -3,6 +3,54 @@
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00041": {
en: ["New: software factory – write a spec with a model, create the repository (Gitea, GitHub or GitLab) and let agents review it, a model per phase", "Factory overview with board, tokens, run time and links into the runs"],
de: ["Neu: Software-Factory – Spec mit einem Modell schreiben, Repository anlegen (Gitea, GitHub oder GitLab) und von Agenten prüfen lassen, ein Modell pro Phase", "Factory-Übersicht mit Board, Tokens, Laufzeit und Links in die Läufe"],
},
"202611-00040": {
en: ["Images you upload are now visible to Claude Code (they arrived empty before)"],
de: ["Hochgeladene Bilder sind für Claude Code jetzt sichtbar (sie kamen vorher leer an)"],
},
"202611-00039": {
en: ["The chat colour no longer tints the chat background", "Claude Code can ask you questions again (question card in the chat)"],
de: ["Die Chat-Farbe färbt den Hintergrund des Chats nicht mehr ein", "Claude Code kann dir wieder Fragen stellen (Fragenkarte im Chat)"],
},
"202611-00038": {
en: ["Sidebar chat menu: Delete added; colour and icon are now chosen in the chat's own menu"],
de: ["Chat-Menü in der Sidebar: Löschen hinzugefügt; Farbe und Icon wählst du jetzt im Menü des Chats"],
},
"202611-00037": {
en: ["Deleting a chat now asks whether to delete its own folder too"],
de: ["Beim Löschen eines Chats wirst du gefragt, ob sein eigener Ordner mitgelöscht werden soll"],
},
"202611-00036": {
en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"],
de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"],
},
"202611-00035": {
en: ["Creating SSH keys works in the container now (fixes \"No user exists for uid\")"],
de: ["SSH-Schlüssel lassen sich im Container jetzt erzeugen (behebt \"No user exists for uid\")"],
},
"202611-00034": {
en: ["ssh/scp/sftp now work in the container (fixes \"No user exists for uid\")"],
de: ["ssh/scp/sftp funktionieren jetzt im Container (behebt \"No user exists for uid\")"],
},
"202611-00033": {
en: ["SSH keys in Settings → Account: agents, terminal and git use them to reach other servers"],
de: ["SSH-Schlüssel unter Einstellungen → Account: Agenten, Terminal und Git nutzen sie für den Zugriff auf andere Server"],
},
"202611-00032": {
en: ["Agents now use your stored git credentials instead of searching for them (also Claude Code)"],
de: ["Agenten nutzen deine hinterlegten Git-Zugangsdaten, statt danach zu suchen (auch Claude Code)"],
},
"202611-00031": {
en: ["Images an agent reads are now shown in the chat (also with Claude Code)"],
de: ["Bilder, die ein Agent liest, werden jetzt im Chat angezeigt (auch mit Claude Code)"],
},
"202611-00030": {
en: ["/clear: the model forgets the conversation so far, the chat stays visible"],
de: ["/clear: das Modell vergisst das bisherige Gespräch, der Chat bleibt sichtbar"],
},
"202611-00029": {
en: [
"Statistics: foldable sections, years of history with a year filter, export as XLSX/CSV",