Compare commits

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:24:34 +01:00
Richard Zink 9339e2db41 Release v202611-00011 2026-11-09 22:16:00 +01:00
Richard ZinkandClaude Opus 5.5 d7f89ee257 Fix stale PWA service worker after updates; collapsible task list; short screens
Installed apps checked their service worker at /sw-<old build>.js. That file no
longer exists after an update and the static fallback answered with index.html,
so the update check failed and the old worker kept serving the previous app
shell. The old frontend crashed on the new image references (base64Size of a
missing `data`), leaving a blank page on phones. Old service worker URLs now get
the clean-up worker (/sw.js), which unregisters and reloads; missing scripts and
styles answer 404 instead of HTML.

- Task list above the composer folds to one line (progress, current task),
  remembered per device.
- max-height: 560px layout (square phones such as the Clicks Communicator,
  landscape): tighter top bar, composer and new-chat wizard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 22:15:45 +01:00
Richard Zink b1fa54a7b6 Release v202611-00010 2026-11-09 20:21:42 +01:00
Richard ZinkandClaude Opus 5.5 e8e455b779 Store chat images separately; JPEG for large PNGs; gzip/brotli responses
Images of messages (pasted images, screenshots, generated images) were base64
inside the message JSON, so opening a chat downloaded all of them at once
(66 MB for a chat with 120 screenshots). They now live in a `blobs` table,
addressed by SHA-256; messages keep a reference (blob, size). The UI loads
them from /api/blobs/<hash> lazily with an immutable cache header, the runner
fills the bytes in before model requests, JSON exports stay self-contained.

- Store::add_message moves inline images out; large opaque PNGs become JPEG
  (q85) when smaller. Older chats are converted once at start-up in the
  background, unused images are removed (also after deleting a chat) and the
  database is vacuumed when that frees 32 MB or more.
- Responses are compressed with gzip/brotli (not images, SSE streams or
  binary downloads).

Example (copy of production): chat load 66 MB -> 1.5 MB (370 KB brotli),
database 90 MB -> 27 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:21:24 +01:00
Richard Zink 6d656a355d Release v202611-00009 2026-11-09 19:46:54 +01:00
Richard ZinkandClaude Opus 5.5 ca55404f99 Sandbox: mount /lib64 in the bubblewrap start-up check
On x86_64 the dynamic loader lives in /lib64, so the probe failed with
"execvp /bin/true: No such file or directory" and shell commands of non-admin
users were disabled on amd64 hosts. The sandbox itself already mounted it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:46:16 +01:00
Richard ZinkandClaude Opus 5.5 0241f6031b Deploy script for remote installations; move-server: --exclude, quoted .env values
scripts/deploy.sh rolls a published image out to an installation set up with
move-server.sh (DEPLOY_TARGET in .env): checks the image for the target's
architecture, waits until no agent or terminal runs (--force skips), pulls and
recreates, runs the smoke test and goes back to the previous version on failure.

move-server.sh: --exclude leaves paths out of the workspace copy; values in .env
may be quoted (WORKSPACE='/home/...').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:46:16 +01:00
Richard Zink 4244b9370c Release v202611-00008 2026-11-09 18:43:38 +01:00
Richard ZinkandClaude Opus 5.5 dae76a5636 OpenAI wire format: no name on tool messages (opencode/Anthropic rejected it)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:42:00 +01:00
Richard ZinkandClaude Opus 5.5 ff8ff585a2 Instructions per chat (system prompt addition; UI dialog and Telegram /prompt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:14:20 +01:00
Richard ZinkandClaude Opus 5.5 434aa84d40 Telegram: find the token in pasted text, no password autofill, clear error
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:33:12 +01:00
Richard Zink 0a368e493d Release v202611-00007 2026-11-09 16:53:51 +01:00
Richard ZinkandClaude Opus 5.5 011756ca3d Telegram bot as a chat interface (per user, paired, CLI-like commands)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:50:51 +01:00
Richard Zink b7c8741a7d Release v202611-00006 2026-11-09 15:39:50 +01:00
Richard ZinkandClaude Opus 5.5 bf99292f43 Docker CLI in the image (static client for a mounted host socket)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:39:48 +01:00
Richard ZinkandClaude Opus 5.5 36de035dd8 move-server.sh: --docker-sock to give the container the target's Docker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:30:29 +01:00
Richard ZinkandClaude Opus 5.5 b59c5b8890 docs: guide for moving CodeCraft to another host (Unraid, bind mounts, tools disk)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:56:23 +01:00
Richard ZinkandClaude Opus 5.5 eef17eb591 move-server.sh: --tools-dir for agent installs and /tmp on a separate disk
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:54:21 +01:00
Richard Zink 6b1d7f91b8 Release v202611-00005 2026-11-09 12:24:12 +01:00
Richard ZinkandClaude Opus 5.5 0f4bfa0ab0 Git identity from the settings for all agent shells, terminals and the git panel
Set GIT_AUTHOR_*/GIT_COMMITTER_* from Settings → Account → Git (also inside the
sandbox), so a repository's own user.name/user.email no longer wins; tell agents
not to change the git identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:22:25 +01:00
Richard Zink 85b68d98ef Release v202611-00004 2026-11-09 12:10:08 +01:00
Richard ZinkandClaude Opus 5.5 d152ad836d Split view, personal modes, budget warnings, server move script
- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
  for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
  scripts/smoke-test.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:06:20 +01:00
Richard Zink 81ee084ff2 Release v202611-00003 2026-11-09 11:25:02 +01:00
Richard ZinkandClaude Opus 5.5 c850586a81 Chat tabs, chat mode, own modes, compaction notice, CLI binaries again
- Terminals and file managers as tabs of a chat; chat terminals keep running on
  the server (no idle timeout) and re-attach from any device
- "Just chat" in New session and a built-in chat mode with a lean prompt and only
  web tools; custom modes with kind (coding/conversation), read-only and tool picker
- Mode selector as a dropdown
- The chat shows running compactions (automatic: tokens and share of the limit)
  and labels summaries
- Docker image builds and serves the terminal client binaries again
- Fix: atomic session updates; background title/usage writes no longer undo each
  other (auto compaction triggered one turn late)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:24:58 +01:00
Richard ZinkandClaude Opus 5.5 242b80605c File manager: open a chat in a folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:38:39 +01:00
Richard ZinkandClaude Opus 5.5 c9f553dc60 Remove the one-time env-to-config migration from the settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:38:06 +01:00
Richard Zink 151a92959d Release v202611-00002 2026-11-09 10:19:17 +01:00
Richard ZinkandClaude Opus 5.5 bfa1f641d7 opencode prices and Go limits, settings out of .env
- Fetch prices for opencode (Zen and Go) incl. limits, image input, reasoning,
  long-context tiers and peak-hour/date factors; costs apply them per request
- Record the cost opencode reports per response; track Go allowance in 5h/7d/month
  windows against a provider plan limit (budget table, status overlay)
- Settings → Config file: move values of ${NAME} references into the config
- Examples, presets and forms keep keys in the config instead of .env

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:19:15 +01:00
Richard Zink 70367211c1 Release v202611-00001 2026-11-09 09:31:31 +01:00
Richard ZinkandClaude Opus 5.5 1f69d63b09 Status LEDs, image model selection, backup restore, provider test, monthly versions
- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status)
- Images: preview stage, purpose-based model choice and reference images
  (Nano Banana Pro edit refines the chosen preview)
- Settings: restore a stored or uploaded backup (staged, applied on restart)
- Provider form: test connection; saving or reloading re-checks the status at once
- opencode Zen/Go: send x-opencode-session with a stable id per conversation
- Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes
- Fix: backups made in the same second no longer overwrite each other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:31:24 +01:00
85 changed files with 6109 additions and 319 deletions

No files matched your search

+4 -11
View File
@@ -1,11 +1,11 @@
# Deployment settings only. API keys, MCP tokens, SSO secrets and everything else
# are set in the web UI (Settings) and stored in the configuration file.
# Optional: create the first admin on first start. Otherwise the server logs a
# one-time setup code and you create the admin in the web UI.
CODECRAFT_ADMIN_USER=
CODECRAFT_ADMIN_PASSWORD=
# Single sign-on client secret (see [auth.oidc] in config.toml).
OIDC_CLIENT_SECRET=
# Directory with your projects, mounted as /workspace.
WORKSPACE=/path/to/your/projects
@@ -17,17 +17,10 @@ BIND_ADDR=127.0.0.1
UID=1000
GID=1000
# Provider keys – referenced in data/config.toml as ${NAME}.
ANTHROPIC_API_KEY=
GEMINI_API_KEY=
OPENCODE_API_KEY=
FAL_KEY=
OPENROUTER_API_KEY=
# Time zone for automations (cron schedules) and timestamps, e.g. Europe/Berlin
TZ=UTC
# Image version for docker-compose.release.yml (e.g. 0.1.0); default: latest
# Image version for docker-compose.release.yml (e.g. v202611-00001); default: latest
CODECRAFT_VERSION=latest
# Log format: empty for text, "json" for one JSON object per line
+1
View File
@@ -9,3 +9,4 @@
*.db-wal
*.db-shm
docker-compose.override.yml
.env.bak*
+200
View File
@@ -3,6 +3,206 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
## [v202611-00012] – 2026-11-10
### Added
- **Approval mode of automations in the list:** *Automations* shows the approval mode of each
automation as a selection; the change is written into the job file and applies from the next
run. "Allow everything" lets nightly runs work without waiting for approvals (they used to
wait for an answer via Telegram until the timeout).
- **Telegram per chat:** *⋯ → Send questions to Telegram* (shown with a paired bot) decides
whether approvals and questions of that chat and its subagents are sent to Telegram. New chats
from the web UI and automation runs start with it switched off (they stay in the web UI);
chats started from Telegram and existing chats keep reporting. The chat currently open in
Telegram always reports.
## [v202611-00011] – 2026-11-09
### Added
- **Collapsible task list:** the model's task list above the composer folds to one line
(progress and current task) with a click on its heading; the choice is kept per device.
- **Square and short screens** (e.g. Clicks Communicator, phones in landscape): tighter top bar,
composer and new-chat wizard, so the chat keeps most of the height.
### Fixed
- **Blank page on phones after the update:** installed apps (PWA) checked their service worker
at its old URL, got the web page instead of a script and therefore kept the previous app
version forever. With the new image format of v202611-00010 that old version crashed after
showing the chat for a moment. Old service worker URLs now get the clean-up worker, which
removes the stale worker and reloads the current version. Missing scripts/styles answer
404 instead of the web page.
## [v202611-00010] – 2026-11-09
### Changed
- **Chats with many images load much faster:** images (screenshots, generated images, pasted
images) are stored separately instead of inside the messages and load on demand when they
scroll into view, cached by the browser. Large PNGs without transparency are stored as JPEG.
Older chats are converted once on the first start; the database usually shrinks a lot.
Example: a chat with 120 screenshots loads 1.5 MB instead of 66 MB.
- **Compressed responses:** the API and the web UI are sent with gzip/brotli (not images,
downloads or live streams).
## [v202611-00009] – 2026-11-09
### Fixed
- **Sandbox on x86_64:** the start-up check of bubblewrap did not mount `/lib64`, so it failed
on amd64 hosts and shell commands of non-admin users were disabled.
## [v202611-00008] – 2026-11-09
### Added
- **Instructions per chat:** *⋯ → Instructions for this chat* (Telegram: `/prompt`) sets rules
that apply to every message of that chat on top of the mode – language, tone, the part of the
project to work on. A chip in the header shows when a chat has them; forks keep them.
### Fixed
- OpenAI-compatible providers: tool results no longer carry a `name` field. Gateways that
translate to Anthropic (opencode) rejected the request with "anthropic-messages cannot
preserve named conversation participants".
- Telegram: the token field is no longer filled in by the browser's password manager, the bot
token is found in whatever is pasted (also the whole BotFather message), and a wrong format
gets a clear message instead of "HTTP 404".
## [v202611-00007] – 2026-11-09
### Added
- **Telegram bot as a chat interface:** every user can connect their own bot (*Settings →
Account → Telegram*: token from @BotFather, then `/start <code>` pairs it with their Telegram
account – other chats are ignored). Messages, photos and files go to the current session; the
agent's steps show live, the answer comes formatted, images from tools come as photos, and
approvals and questions have buttons. Commands like the terminal client: `/sessions`,
`/session`, `/new`, `/clear`, `/mode`, `/model`, `/permissions`, `/effort`, `/stop`, `/status`,
`/costs`, `/memory`, `/jobs`, `/compact`, `/help`. The server polls Telegram – no public
webhook needed.
## [v202611-00006] – 2026-11-09
### Added
- The image contains the Docker CLI (static client). With the host's `/var/run/docker.sock`
mounted (and its group), admins' agents and terminals can use the host's Docker;
`scripts/move-server.sh --docker-sock` sets this up on a new host.
- `scripts/move-server.sh --tools-dir DIR`: agent home, users' homes and `/tmp` on a separate disk.
## [v202611-00005] – 2026-11-09
### Fixed
- Commits by agents, terminals and the git panel always use the name and e-mail from
*Settings → Account → Git* – also in repositories that have their own `user.name`/`user.email`
(agents had set those as a workaround while no identity was configured). Agents are told not
to change the git identity.
## [v202611-00004] – 2026-11-09
### Added
- **Chats side by side:** *Open beside* in a chat's sidebar menu shows up to three chats as
columns, each with its own tabs and composer (desktop).
- **My modes:** every user can create personal modes under *Settings → My modes* (kind, tools,
read-only, prompt, model); they appear only for that user.
- **Budget warnings** as push notifications for admins (warning share and 100 %) for the total,
provider and image budgets and the opencode Go windows; the status overlay shows all budgets.
- `scripts/move-server.sh` moves an installation to another Docker host with bind mounts
(e.g. Unraid), `scripts/smoke-test.sh` checks a running instance.
### Changed
- File manager tabs of a chat are stored on the server, so they are the same on every device.
## [v202611-00003] – 2026-11-09
### Added
- The Docker image ships the terminal client again (static Linux x86_64 and aarch64 binaries):
`curl -fsSL https://<your-server>/cli/install.sh | sh`, download links in the settings and
`codecraft update` work without building from source.
- File manager: *Open chat here* in a folder's menu starts a chat in that folder.
- **Tabs in a chat:** open terminals and file managers next to the chat (topbar buttons or
"+" in the tab strip), as many as needed. Terminals of a chat keep running on the server until
they are closed, so they are there again after a reload or on another device (output is
replayed); they end when the chat is deleted or the server restarts.
- **Chat mode:** *New session → Just chat* (and mode `chat`) for plain conversation – a lean
prompt with date and personal memory, web search and fetch only, no project or files.
- **Own modes:** *Settings → Agents & commands → New mode* with kind (coding or conversation),
read-only switch, tool checkboxes, prompt, model and review loop; custom modes show up in
*New session* and the mode selector.
- The chat shows when the conversation is being compacted – automatically because the context
is nearly full (with tokens and share of the limit) or on request – and labels the summary.
### Changed
- The mode selector in the composer is a dropdown.
### Removed
- *Settings → Config file* no longer offers to move `${NAME}` values into the configuration (a
one-time migration); `${NAME}` references keep working.
### Fixed
- Session updates from background tasks (title, token usage, phase, todos) and from the UI
no longer overwrite each other. Often the first answer's token usage was lost, so automatic
compaction and the context display started one turn late.
## [v202611-00002] – 2026-11-09
### Added
- **opencode prices:** *Fetch prices* reads prices, context and output limits, image input and
reasoning from opencode's model list (Zen and Go), including long-context tiers and
peak-hour or date-based factors. Costs apply them per request (`price_tiers`,
`price_multipliers` on a model).
- **opencode costs and Go limits:** the cost opencode reports per response is recorded instead
of an estimate, and with a *plan limit* on the provider the Go allowance is tracked in the
5-hour, 7-day and monthly windows (budget table and status overlay; usage through CodeCraft
only).
- **Settings → Config file** lists `${NAME}` references and moves the values of set variables
into the configuration, so `.env` only needs deployment settings.
### Changed
- Examples and forms no longer suggest environment variables for keys; `.env.example` holds
deployment settings only.
## [v202611-00001] – 2026-11-09
### Added
- **Image generation picks the model:** the agent drafts cheap previews, asks which one to
refine (with the price of the final model), then creates the final image with the best model
for the purpose (photo, illustration, text, design). Models that take a reference image
(Nano Banana Pro edit) refine the chosen draft. Known fal.ai models come with sensible
defaults; `stage`, `good_for` and `reference_param` in `[images].models` override them.
- **Restore a backup** in *Settings → Audit*: a stored backup or an uploaded zip. The current
state is backed up first (`…-before-restore.zip`), then CodeCraft restarts with the restored
database and configuration.
- **Test connection** in the provider form checks URL and key before saving.
- **Status LEDs** in the sidebar for server connection, models and MCP servers (green, yellow,
red in vivid variants of the theme colors); a click opens an overview of every provider and
MCP server with its state, latency and tool count.
- `scripts/release.sh`: tag, multi-arch image and Gitea release in one step.
### Changed
- Saving a provider (or reloading providers) checks its status right away instead of at the
next five-minute check.
### Fixed
- opencode Zen/Go: requests carry the session in `x-opencode-session`; opencode Go rejected
them without it ("Request is missing x-opencode-session").
- Two backups in the same second no longer overwrite each other.
## [0.1.0] – 2026-11-09
First public release.
Generated
+68
View File
@@ -52,6 +52,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -153,6 +168,18 @@ dependencies = [
"password-hash",
]
[[package]]
name = "async-compression"
version = "0.4.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-trait"
version = "0.1.92"
@@ -363,6 +390,27 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "brotli"
version = "9.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "6.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.13.1"
@@ -682,6 +730,24 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "compression-codecs"
version = "0.4.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
[[package]]
name = "const-oid"
version = "0.10.2"
@@ -3040,6 +3106,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -4034,6 +4101,7 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [
"async-compression",
"bitflags 2.13.2",
"bytes",
"futures-core",
+31 -2
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
# Multi-arch: the web UI and the server are built once on the build
# Multi-arch: the web UI, the server and the terminal client are built once on the build
# machine (cross-compiled with cargo-zigbuild); only the runtime stage runs
# per target platform.
@@ -19,7 +19,7 @@ ARG ZIG_VERSION=0.17.1
RUN curl -fsSL https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz | tar -xJ -C /opt \
&& ln -s /opt/zig-*/zig /usr/local/bin/zig \
&& cargo install --locked cargo-zigbuild \
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu
&& rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-musl
WORKDIR /src
# --- server (for the target platform, glibc of Debian bookworm) ----------------
@@ -45,10 +45,36 @@ COPY server/resources-cli ./server/resources-cli
COPY cli/src ./cli/src
ARG GIT_COMMIT=""
ENV CODECRAFT_COMMIT=${GIT_COMMIT}
# Release version (YYYYMM-NNNNN, set by scripts/release.sh); empty = development build.
ARG CODECRAFT_VERSION=""
ENV CODECRAFT_VERSION=${CODECRAFT_VERSION}
RUN touch core/src/lib.rs server/src/main.rs \
&& cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \
&& cp "target/$(cat /target)/release/codecraft" /codecraft
# --- terminal client (static Linux binaries for x86_64 and aarch64) -----------
# Served by the server for download (/cli/install.sh, codecraft update).
FROM toolchain AS cli
COPY Cargo.toml Cargo.lock ./
COPY core ./core
COPY server/Cargo.toml ./server/
COPY server/resources ./server/resources
COPY cli ./cli
ARG CODECRAFT_VERSION=""
ENV CODECRAFT_VERSION=${CODECRAFT_VERSION}
RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
&& cargo zigbuild --release -p codecraft-cli --target x86_64-unknown-linux-musl --target aarch64-unknown-linux-musl \
&& mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \
&& cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64
# --- Docker CLI (static client; the daemon is the host's via the mounted socket) --
FROM toolchain AS dockercli
ARG TARGETARCH
ARG DOCKER_VERSION=29.9.3
RUN case "$TARGETARCH" in amd64) arch=x86_64 ;; arm64) arch=aarch64 ;; *) echo "unsupported $TARGETARCH" >&2; exit 1 ;; esac \
&& curl -fsSL "https://download.docker.com/linux/static/stable/$arch/docker-$DOCKER_VERSION.tgz" | tar -xz -C /tmp docker/docker \
&& install -m 0755 /tmp/docker/docker /docker
# --- runtime ------------------------------------------------------------------
# Node is included so agents can work on JS projects and run npx-based MCP servers.
FROM node:22-bookworm-slim
@@ -66,6 +92,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
COPY --from=server /codecraft /usr/local/bin/codecraft
COPY --from=web /web/dist /app/web
COPY --from=cli /out /app/cli
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
COPY --from=dockercli /docker /usr/local/bin/docker
COPY server/ghidra /app/ghidra
COPY server/resources /app/resources
# The agent's home lives on the data volume: tools it installs in user space
+47 -14
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version 0.1.0" src="https://img.shields.io/badge/version-0.1.0-d69e2e?style=flat-square">
<img alt="Version v202611-00012" src="https://img.shields.io/badge/version-v202611--00012-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">
@@ -74,17 +74,19 @@ curl -fsSLO https://git.familie-zink.org/zink/codecraft/raw/branch/main/docker-c
curl -fsSL --create-dirs -o docker/seccomp-sandbox.json \
https://git.familie-zink.org/zink/codecraft/raw/branch/main/docker/seccomp-sandbox.json
curl -fsSL -o .env https://git.familie-zink.org/zink/codecraft/raw/branch/main/.env.example
$EDITOR .env # workspace path, provider keys, time zone
$EDITOR .env # workspace path, port, user id, time zone
docker compose -f docker-compose.release.yml up -d
docker compose -f docker-compose.release.yml logs codecraft # one-time setup code for the first admin
```
Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a
provider under *Settings → Providers & models* – or put the keys into `.env` and reference
them in the config as `${NAME}`.
provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys,
tokens and everything else are set in the UI and stored in the configuration file
(`${NAME}` references to environment variables still work).
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
Pin a version with `CODECRAFT_VERSION=0.1.0` in `.env`. Admins see a notice when a new release is out.
Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with
`CODECRAFT_VERSION=v202611-00001` in `.env`. Admins see a notice when a new release is out.
**From source:** `git clone https://git.familie-zink.org/zink/codecraft.git && cd codecraft`,
`cp .env.example .env`, then `docker compose up -d --build`.
@@ -95,6 +97,17 @@ volume `codecraft-data`; recreating the container keeps it. Your projects are mo
WebSocket support) and set `auth.public_url` – installing the app and push notifications
require HTTPS.
**Moving to another host** (for example Unraid with bind mounts instead of the Docker volume):
`scripts/move-server.sh root@newhost:/mnt/user/appdata/codecraft --workspace /mnt/user/projects`
copies the workspace, stops CodeCraft, streams the data volume to `<dir>/data`, writes a
`docker-compose.yml` (and a `start.sh` for hosts without Compose) with bind mounts, starts the
published image there and runs `scripts/smoke-test.sh`. The old container is only stopped, so
you can roll back. `--help` lists the options (owner, port, bind address, dry run).
`--tools-dir /mnt/disks/ssd/codecraft` puts everything agents install or download – the agent
home (`/data/home`: toolchains, pip/npm/cargo packages, caches), the users' homes and `/tmp` – on
a separate disk; the data folder keeps only the database, configuration and backups. Step by step,
with rollback and troubleshooting: [docs/MOVING.md](docs/MOVING.md).
## How it works
CodeCraft runs on your server, next to your projects. You talk to it from a browser, the
@@ -152,8 +165,17 @@ output limit, reasoning effort, extra request options and prices per million tok
model's fallback.
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
process one at a time; waiting time is shown and never counted as model latency.
- **Budget warnings:** admins get a push notification when the total, a provider's or the image
budget, or an opencode Go window reaches the warning share (default 80 %) and when it is used
up; the status overlay shows all budgets with bars.
- **Prices:** *Fetch prices* fills in prices for OpenRouter and opencode (Zen and Go), including
long-context tiers and peak-hour factors, which are applied per request.
- **Status:** every five minutes each provider's model list is fetched (no tokens used), so
you see at a glance which hosts answer and which keys are rejected.
you see at a glance which hosts answer and which keys are rejected. Saving a provider checks
it right away, and *Test connection* checks URL and key before you save.
- **Images (fal.ai):** the agent drafts cheap previews (FLUX schnell), asks which one to
refine, and creates the final image with the best model for the purpose – Nano Banana Pro
refines the chosen draft, Ideogram handles lettering, Recraft design work.
## Terminal client
@@ -165,15 +187,14 @@ Anthropic, OpenAI, Gemini, OpenRouter, opencode or a local model server (Ollama,
llama.cpp, vLLM – detected automatically); both can be mixed.
```sh
cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli
curl -fsSL https://<your-codecraft-server>/cli/install.sh | sh # Linux x86_64 / aarch64
codecraft login # server URL, user name, password → creates an API token
codecraft # start in the current folder; /help lists the commands
```
There are no prebuilt binaries yet, so the client is installed from source (needs a Rust
toolchain). A server that has binaries in `CODECRAFT_CLI_DIR` (`codecraft-linux-x86_64`,
`codecraft-linux-aarch64`) also offers `curl -fsSL https://<your-server>/cli/install.sh | sh`
and `codecraft update`.
The Docker image ships static binaries for Linux x86_64 and aarch64; the server offers them
for download (*Settings → Account*) and `codecraft update` fetches new versions from it. Other
platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codecraft codecraft-cli`.
## Automations
@@ -192,6 +213,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t
## Integrations
- **Telegram:** connect your own bot (*Settings → Account → Telegram*) and use CodeCraft from
Telegram – sessions, modes, models, approvals with buttons, photos and files; `/help` in the
bot lists the commands.
- **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client.
- **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
@@ -199,7 +223,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t
- **JSON logs:** `CODECRAFT_LOG_FORMAT=json` writes one JSON object per line.
- **Backups:** daily at 03:00 into `/data/backups` (the last 30 are kept), optionally copied to
any S3-compatible bucket (AWS, MinIO, Garage, Backblaze B2, Hetzner). *Settings → Audit*
shows them, creates one on demand and downloads them.
shows them, creates one on demand, downloads them and restores one – a stored backup or an
uploaded zip, e.g. to move to another server. The current state is backed up first, then
CodeCraft restarts with the restored database and configuration.
- **Health:** `GET /api/health` for monitoring, with version and commit.
## Users and security
@@ -245,6 +271,7 @@ place and keep your comments. Secrets belong in the environment and are referenc
| `[auth]`, `[auth.oidc]` | sign-in, public URL, single sign-on |
| `[sandbox]` | shell sandbox for non-admins (`auto`, `always`, `off`) |
| `[backup]`, `[backup.s3]` | daily backups, retention, S3 target |
| `[images]` | fal.ai key, budget, `models` with `stage` (`preview` / `final`), `good_for` and `reference_param` |
| `[craft]` | headless browser, screen widths, clean room |
Compound shell commands (`&&`, `;`, `|`) are only auto-allowed when every part is allowed.
@@ -324,11 +351,17 @@ web/ React + Vite PWA
src/i18n English (reference) and German
```
The Docker build cross-compiles the server with
The Docker build cross-compiles the server and the terminal client with
[cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild), so a multi-arch image builds
quickly on either architecture:
`docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/codecraft:<version> --push .`
Releases are named `vYYYYMM-NNNNN`: year and month, then a counter that starts at 00001 every
month. Collect changes under `## [Unreleased]` in the changelog (and under `unreleased` in
`web/src/whatsnew.ts`), then `scripts/release.sh` picks the next number, renames both, commits,
tags, builds and pushes the image (`:vYYYYMM-NNNNN` and `:latest`) and publishes the Gitea
release (`--dry-run` shows the steps). The `Cargo.toml` versions are not used for releases.
Translations: copy `web/src/i18n/en.ts`, translate the values and register the language in
`web/src/i18n/index.ts` – the type checker reports missing keys.
@@ -336,7 +369,7 @@ Translations: copy `web/src/i18n/en.ts`, translate the values and register the l
Early, but in daily use. See the [changelog](CHANGELOG.md). Planned next:
- Prebuilt terminal client binaries (Linux, macOS, Windows)
- Terminal client builds for macOS and Windows
- MCP OAuth, resources and prompts
- The OpenAI Responses API
+2 -2
View File
@@ -508,7 +508,7 @@ impl Agent {
let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
for p in &m.parts {
let line = match p {
Part::Text { text } | Part::Summary { text } => text.clone(),
Part::Text { text } | Part::Summary { text, .. } => text.clone(),
Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::<String>()),
Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::<String>()),
_ => continue,
@@ -524,7 +524,7 @@ impl Agent {
if summary.trim().is_empty() {
return Err(anyhow!("compaction produced no summary"));
}
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary }]));
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary, auto: false, tokens: None }]));
self.session.context_tokens = 0;
self.read_files.lock().unwrap().clear();
let _ = self.session.save();
+3 -2
View File
@@ -421,7 +421,7 @@ impl App {
_ => None,
};
if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) {
self.images.push(ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
self.images.push(ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
}
}
}
@@ -721,6 +721,7 @@ fn provider_config(preset: &crate::models::Preset, base_url: &str, key: &str) ->
enabled: true,
max_concurrency: None,
monthly_budget: None,
plan_limit: None,
admin_key: String::new(),
}
}
@@ -839,7 +840,7 @@ impl App {
for (cmd, args) in tries {
if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() {
if out.status.success() && out.stdout.starts_with(b"\x89PNG") {
self.images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
self.images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
self.status = format!("image {} attached", self.images.len());
return;
}
+5 -6
View File
@@ -29,7 +29,7 @@ use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
#[derive(Parser)]
#[command(name = "codecraft", version, about = "CodeCraft in the terminal – a local coding agent")]
#[command(name = "codecraft", version = codecraft_core::version::VERSION, about = "CodeCraft in the terminal – a local coding agent")]
struct Cli {
/// Start with this prompt
prompt: Option<String>,
@@ -164,8 +164,7 @@ fn prompt_line(label: &str, secret: bool) -> Result<String> {
}
fn version_newer(server: &str, ours: &str) -> bool {
let parse = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
parse(server) > parse(ours)
codecraft_core::version::newer(server, ours)
}
fn target() -> String {
@@ -178,8 +177,8 @@ async fn self_update() -> Result<()> {
let http = server::http();
let info: Value = http.get(format!("{}/api/info", login.url)).send().await?.json().await?;
let latest = info["version"].as_str().unwrap_or("0");
if !version_newer(latest, env!("CARGO_PKG_VERSION")) {
println!("codecraft {} is up to date.", env!("CARGO_PKG_VERSION"));
if !version_newer(latest, codecraft_core::version::VERSION) {
println!("codecraft {} is up to date.", codecraft_core::version::VERSION);
return Ok(());
}
let url = format!("{}/cli/download/{}", login.url, target());
@@ -365,7 +364,7 @@ async fn main() -> Result<()> {
// Notice when the server has a newer CLI.
let mut status = String::new();
if let Some(v) = env.server_cfg.as_ref().and_then(|s| s["version"].as_str()) {
if version_newer(v, env!("CARGO_PKG_VERSION")) {
if version_newer(v, codecraft_core::version::VERSION) {
status = format!("update available: {v} – run `codecraft update`");
}
}
+1 -1
View File
@@ -105,7 +105,7 @@ pub async fn stream(
mc.effort = Some(e);
}
let max_tokens = mc.max_output.unwrap_or(16_000);
let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true };
let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true, session_id: Some(session.to_string()) };
provider.stream(req, tx, cancel).await
}
+1 -1
View File
@@ -19,7 +19,7 @@ pub struct Server {
pub fn http() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION")))
.user_agent(format!("codecraft-cli/{}", codecraft_core::version::VERSION))
.connect_timeout(std::time::Duration::from_secs(15))
.build()
.expect("http client")
+1 -1
View File
@@ -408,7 +408,7 @@ pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
return Ok(Output {
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
is_error: false,
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
images: vec![ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
diff: None,
});
}
+22 -15
View File
@@ -8,9 +8,9 @@
#
# Location: $CODECRAFT_CONFIG, otherwise <data_dir>/config.toml (Docker: /data/config.toml)
#
# Secrets: any string value may reference environment variables with ${NAME},
# e.g. api_key = "${ANTHROPIC_API_KEY}". Prefer this over literal keys – keys
# entered in the UI are stored here in plain text.
# Keys and tokens are entered in the web UI (Settings) and stored here, readable
# for admins and included in backups. Any string value may also reference an
# environment variable as ${NAME}.
# =============================================================================
[server]
@@ -47,7 +47,7 @@ currency = "USD"
# proxy's body limit (e.g. Nginx client_max_body_size) does not apply.
max_upload_mb = 500
# Bearer token for Prometheus at GET /metrics (empty = /metrics disabled).
# metrics_token = "${CODECRAFT_METRICS_TOKEN}"
# metrics_token = "a-long-random-string"
# -----------------------------------------------------------------------------
@@ -81,7 +81,7 @@ max_upload_mb = 500
[providers.anthropic]
kind = "anthropic"
base_url = "https://api.anthropic.com/v1"
api_key = "${ANTHROPIC_API_KEY}"
api_key = "" # set in Settings
# Current Claude models think adaptively; "effort" controls depth and cost.
# Prices: Anthropic list prices in USD per 1M tokens (Nov 2026) – adjust if needed.
models = [
@@ -97,25 +97,25 @@ models = [
[providers.gemini]
kind = "openai"
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
api_key = "${GEMINI_API_KEY}"
api_key = "" # set in Settings
models = [] # discovered automatically
enabled = false # set to true once GEMINI_API_KEY is available
enabled = false # enable once the key is set
# opencode Go subscription (OpenAI-compatible endpoint for most models).
# Current model list: https://opencode.ai/inference/go/v1/models
[providers.opencode-go]
kind = "openai"
base_url = "https://opencode.ai/inference/go/openai/v1"
api_key = "${OPENCODE_API_KEY}"
api_key = "" # set in Settings
models = []
enabled = false
# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key <FAL_KEY>".
# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key <key>".
# NOTE: verify base_url against the fal docs for your account.
[providers.fal]
kind = "openai"
base_url = "https://fal.run/openrouter/router/openai/v1"
api_key = "${FAL_KEY}"
api_key = "" # set in Settings
auth_prefix = "Key "
models = [
{ id = "google/gemini-2.5-flash", context = 1000000, max_output = 32000, vision = true },
@@ -151,7 +151,7 @@ enabled = false
# [mcp.trilium]
# type = "http"
# url = "https://notes.example.org/mcp"
# headers = { Authorization = "Bearer ${TRILIUM_TOKEN}" }
# headers = { Authorization = "Bearer <token>" }
# [mcp.filesystem]
# type = "stdio"
@@ -243,11 +243,18 @@ generate_image = "ask" # costs money per image
[images]
enabled = false
base_url = "https://fal.run"
api_key = "${FAL_KEY}"
api_key = "" # set in Settings
default_model = "fal-ai/flux/schnell"
output_dir = "generated"
# The agent drafts with a "preview" model, asks which draft to refine, then
# creates the final image with the best "final" model for the purpose
# (good_for: photo, illustration, text, design), passing the draft as reference
# to models with reference_param. The models below have these values built in;
# set stage / good_for / reference_param / price to override or for other models.
models = [
{ id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast", price = 0.003 },
{ id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast previews", price = 0.003 },
{ id = "fal-ai/nano-banana-pro/edit", name = "Nano Banana Pro – refine an image", price = 0.15 },
{ id = "fal-ai/nano-banana-pro", name = "Nano Banana Pro", price = 0.15 },
{ id = "fal-ai/flux-2-pro", name = "FLUX.2 pro" },
{ id = "fal-ai/ideogram/v3", name = "Ideogram 3 – text in images", size_param = "aspect_ratio" },
{ id = "fal-ai/recraft/v4/pro/text-to-image", name = "Recraft V4 pro – design" },
@@ -281,7 +288,7 @@ cookie_secure = "auto"
enabled = false
issuer = "https://auth.example.org/application/o/codecraft/"
client_id = "codecraft"
client_secret = "${OIDC_CLIENT_SECRET}"
client_secret = "" # set in Settings
scopes = ["openid", "profile", "email", "groups"]
username_claim = "preferred_username"
groups_claim = "groups"
@@ -302,7 +309,7 @@ button_label = "Single sign-on"
# -----------------------------------------------------------------------------
[search]
# "duckduckgo" (no key, may be rate-limited), "searxng" (self-hosted, set url),
# "brave" or "tavily" (api_key, e.g. "${BRAVE_API_KEY}")
# "brave" or "tavily" (set api_key in Settings)
provider = "duckduckgo"
url = ""
api_key = ""
+129 -6
View File
@@ -37,6 +37,10 @@ pub struct ProviderConfig {
/// Monthly budget in the configured currency (tracked from recorded costs).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub monthly_budget: Option<f64>,
/// Monthly usage limit of a subscription plan in USD (opencode Go). Usage is tracked
/// from the allowance each response reports: 5 hours 20 %, week 50 %, month 100 %.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan_limit: Option<f64>,
/// Optional admin key to read the provider's own cost report
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
#[serde(default, skip_serializing_if = "String::is_empty")]
@@ -76,6 +80,13 @@ pub struct ModelConfig {
pub cache_read_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_price: Option<f64>,
/// Other prices from a context size on (e.g. long-context pricing); the largest
/// threshold below the request's context wins, missing prices keep the base price.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub price_tiers: Vec<PriceTier>,
/// Time-based price factors (peak hours, promotions); matching factors multiply.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub price_multipliers: Vec<PriceMultiplier>,
/// Max. simultaneous requests to this model (None = unlimited).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<usize>,
@@ -87,15 +98,127 @@ pub struct ModelConfig {
pub options: serde_json::Map<String, serde_json::Value>,
}
/// Prices from a context size on (tokens of the whole prompt, cache included).
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct PriceTier {
pub above_context: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_price: Option<f64>,
}
/// A price factor that applies at certain times (all given conditions must hold, UTC).
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct PriceMultiplier {
pub factor: f64,
/// Weekdays ("mon" … "sun"); empty = every day.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub days: Vec<String>,
/// Time ranges ["HH:MM", "HH:MM") in UTC; empty = all day.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hours_utc: Vec<(String, String)>,
/// First day it applies (YYYY-MM-DD, UTC).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
/// First day it no longer applies (YYYY-MM-DD, UTC).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<String>,
}
impl PriceMultiplier {
pub fn applies(&self, at: chrono::DateTime<chrono::Utc>) -> bool {
use chrono::{Datelike, Timelike};
let day = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][at.weekday().num_days_from_monday() as usize];
if !self.days.is_empty() && !self.days.iter().any(|d| d.eq_ignore_ascii_case(day)) {
return false;
}
let minutes = |s: &str| s.split_once(':').and_then(|(h, m)| Some(h.trim().parse::<u32>().ok()? * 60 + m.trim().parse::<u32>().ok()?));
let now = at.hour() * 60 + at.minute();
if !self.hours_utc.is_empty()
&& !self.hours_utc.iter().any(|(a, b)| matches!((minutes(a), minutes(b)), (Some(a), Some(b)) if now >= a && now < b))
{
return false;
}
let date = at.format("%Y-%m-%d").to_string();
if self.from.as_deref().is_some_and(|f| date.as_str() < f) || self.until.as_deref().is_some_and(|u| date.as_str() >= u) {
return false;
}
true
}
}
impl ModelConfig {
/// Cost of one request. Cache prices default to the input price.
/// Cost of one request now. Cache prices default to the input price.
/// The provider's own figure wins when it reports one (opencode).
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
let inp = self.input_price.unwrap_or(0.0);
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
}
/// Cost of one request at a given time: context tiers and time factors applied.
pub fn cost_at(&self, u: &crate::types::Usage, at: chrono::DateTime<chrono::Utc>) -> f64 {
let context = u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
let tier = self.price_tiers.iter().filter(|t| context > t.above_context).max_by_key(|t| t.above_context);
let pick = |base: Option<f64>, tiered: fn(&PriceTier) -> Option<f64>| tier.and_then(tiered).or(base);
let inp = pick(self.input_price, |t| t.input_price).unwrap_or(0.0);
let out = pick(self.output_price, |t| t.output_price).unwrap_or(0.0);
let read = pick(self.cache_read_price, |t| t.cache_read_price).unwrap_or(inp);
let write = pick(self.cache_write_price, |t| t.cache_write_price).unwrap_or(inp);
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
per(u.input_tokens, inp)
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
let factor: f64 = self.price_multipliers.iter().filter(|m| m.applies(at)).map(|m| m.factor).product();
(per(u.input_tokens, inp) + per(u.output_tokens, out) + per(u.cache_read_tokens, read) + per(u.cache_write_tokens, write)) * factor
}
}
#[cfg(test)]
mod price_tests {
use super::*;
use crate::types::Usage;
use chrono::TimeZone;
fn usage(input: u64, output: u64, read: u64) -> Usage {
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
}
#[test]
fn tiers_and_multipliers() {
let m = ModelConfig {
input_price: Some(2.0),
output_price: Some(6.0),
cache_read_price: Some(0.5),
price_tiers: vec![PriceTier { above_context: 200_000, input_price: Some(4.0), output_price: Some(12.0), cache_read_price: Some(1.0), ..Default::default() }],
price_multipliers: vec![PriceMultiplier {
factor: 2.0,
days: vec!["mon".into(), "tue".into(), "wed".into(), "thu".into(), "fri".into()],
hours_utc: vec![("01:00".into(), "04:00".into()), ("06:00".into(), "10:00".into())],
..Default::default()
}],
..Default::default()
};
let sat = chrono::Utc.with_ymd_and_hms(2026, 11, 7, 7, 0, 0).unwrap(); // Saturday 07:00
let mon_peak = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 7, 30, 0).unwrap(); // Monday 07:30
let mon_off = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 10, 0, 0).unwrap(); // Monday 10:00 (end excluded)
let small = usage(100_000, 1_000_000, 0);
assert!((m.cost_at(&small, sat) - (0.2 + 6.0)).abs() < 1e-9);
assert!((m.cost_at(&small, mon_peak) - 2.0 * (0.2 + 6.0)).abs() < 1e-9);
assert!((m.cost_at(&small, mon_off) - (0.2 + 6.0)).abs() < 1e-9);
// 150k fresh + 100k cached = 250k context: long-context tier.
let big = usage(150_000, 0, 100_000);
assert!((m.cost_at(&big, sat) - (0.6 + 0.1)).abs() < 1e-9);
}
#[test]
fn date_ranges() {
let m = PriceMultiplier { factor: 2.0, from: Some("2026-12-01".into()), ..Default::default() };
assert!(!m.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 23, 59, 0).unwrap()));
assert!(m.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap()));
let until = PriceMultiplier { factor: 0.5, until: Some("2026-12-01".into()), ..Default::default() };
assert!(until.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 12, 0, 0).unwrap()));
assert!(!until.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap()));
}
}
+1
View File
@@ -6,3 +6,4 @@ pub mod permission;
pub mod prompts;
pub mod provider;
pub mod types;
pub mod version;
+4
View File
@@ -14,6 +14,7 @@ pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent w
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
- Git identity and credentials come from the user's settings: never set user.name, user.email or credential helpers yourself (no `git config user.*`, no `-c user.*`).
- Reference code as path:line.
- Answer in the language the user writes in."#;
@@ -34,6 +35,9 @@ Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pa
pub const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused).";
pub const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts, and analyse programs (binary_info, decompile with Ghidra) for the user's private use. The goal is a faithful RE-CREATION, not a copy. For software:\n1. STUDY: binary_info, then decompile. Find the program's structure: entry point and main loop, UI (windows, menus, dialogs, resources), input handling, file formats, algorithms and data structures, timing.\n2. SPECIFY: write .codecraft/spec.md – behaviour, screens, data and file formats, algorithms in your own words (pseudocode allowed), edge cases. This spec is the only input for the build.\n3. BUILD: implement from the spec in a modern stack (the user's choice, or a sensible default), with clean, idiomatic, documented code. Never paste or mechanically translate decompiled code; names, structure and code must be your own. Reuse only facts (file formats, constants needed for compatibility).\n4. CHECK: compare with the original – same files read/written, same outputs for the same inputs, same look where it matters – and refine.";
/// Plain conversation (mode "chat"): no project, no coding workflow.
pub const CHAT_PROMPT: &str = "You are a helpful, knowledgeable assistant having a conversation with the user. Answer directly and naturally in the user's language; keep answers as short as the question allows and go into depth when asked. Use Markdown only where it helps (lists, code, tables). You can search the web (web_search) and read pages (web_fetch) for current or specific facts – say where information comes from. You have no access to files or a shell in this mode; if the user wants something built or changed in a project, suggest switching to build mode.";
pub const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees.";
pub const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything.";
+4 -2
View File
@@ -48,7 +48,7 @@ pub fn to_wire_messages(messages: &[Message]) -> Vec<Value> {
Part::Text { text } if !text.is_empty() => {
content.push(json!({"type": "text", "text": text}))
}
Part::Summary { text } => content.push(json!({
Part::Summary { text, .. } => content.push(json!({
"type": "text",
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
})),
@@ -168,7 +168,8 @@ impl Provider for Anthropic {
for (k, v) in &req.model.options {
body[k] = v.clone();
}
let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?;
let rb = self.request(reqwest::Method::POST, "/messages").json(&body);
let resp = self.ep.with_session(rb, req.session_id.as_deref()).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
@@ -265,6 +266,7 @@ impl Provider for Anthropic {
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
usage.output_tokens = o;
}
usage.read_reported_cost(&data["usage"]);
}
"error" => {
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
+38 -1
View File
@@ -30,6 +30,9 @@ pub struct ChatRequest {
pub max_tokens: u64,
/// Enable extended thinking / reasoning when the model supports it.
pub reasoning: bool,
/// Stable id of the conversation; sent to gateways that route by session
/// (opencode: `x-opencode-session`) for routing and prompt caching.
pub session_id: Option<String>,
}
#[derive(Debug, Clone)]
@@ -84,6 +87,16 @@ impl Endpoint {
}
}
/// Session header for gateways that need one (opencode Zen/Go reject requests without it).
pub fn with_session(&self, rb: reqwest::RequestBuilder, session_id: Option<&str>) -> reqwest::RequestBuilder {
match session_id {
Some(id) if self.base_url.contains("opencode.ai") && !self.headers.keys().any(|k| k.eq_ignore_ascii_case("x-opencode-session")) => {
rb.header("x-opencode-session", id)
}
_ => rb,
}
}
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if !self.api_key.is_empty() {
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
@@ -99,7 +112,7 @@ impl Endpoint {
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(20))
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
.user_agent(format!("codecraft/{}", crate::version::VERSION))
.build()
.expect("http client")
}
@@ -128,3 +141,27 @@ pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
.unwrap_or_else(|| body.chars().take(500).collect());
anyhow!("provider returned HTTP {status}: {detail}")
}
#[cfg(test)]
mod session_header_tests {
use super::*;
fn ep(base: &str) -> Endpoint {
Endpoint { base_url: base.into(), api_key: String::new(), auth_header: "Authorization".into(), auth_prefix: "Bearer ".into(), headers: BTreeMap::new() }
}
fn header(ep: &Endpoint, id: Option<&str>) -> Option<String> {
let rb = ep.with_session(reqwest::Client::new().post(format!("{}/chat/completions", ep.base_url)), id);
rb.build().unwrap().headers().get("x-opencode-session").map(|v| v.to_str().unwrap().to_string())
}
#[test]
fn opencode_gets_the_session_header() {
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), Some("s1")).as_deref(), Some("s1"));
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), None), None);
assert_eq!(header(&ep("https://api.openai.com/v1"), Some("s1")), None);
let mut custom = ep("https://opencode.ai/zen/v1");
custom.headers.insert("X-Opencode-Session".into(), "fixed".into());
assert_eq!(header(&custom, Some("s1")), None, "a configured header wins");
}
}
+24 -4
View File
@@ -40,17 +40,20 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec<Value> {
Part::Text { text } if !text.is_empty() => {
parts.push(json!({"type": "text", "text": text}))
}
Part::Summary { text } => parts.push(json!({
Part::Summary { text, .. } => parts.push(json!({
"type": "text",
"text": format!("<conversation-summary>\n{text}\n</conversation-summary>")
})),
Part::Image { image } => parts.push(json!({
"type": "image_url", "image_url": {"url": data_url(image)}
})),
Part::ToolResult { call_id, name, content, images, is_error, .. } => {
Part::ToolResult { call_id, content, images, is_error, .. } => {
let text = if *is_error { format!("ERROR: {content}") } else { content.clone() };
// No "name": it is not part of the tool message in the Chat
// Completions API, and gateways that translate to Anthropic
// (opencode) reject named participants.
out.push(json!({
"role": "tool", "tool_call_id": call_id, "name": name,
"role": "tool", "tool_call_id": call_id,
"content": if text.is_empty() { "(no output)".into() } else { text }
}));
tool_images.extend(images.iter().cloned());
@@ -198,7 +201,7 @@ impl Provider for OpenAi {
body[k] = v.clone();
}
let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body);
let resp = self.ep.apply(rb).send().await?;
let resp = self.ep.with_session(self.ep.apply(rb), req.session_id.as_deref()).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
@@ -231,6 +234,7 @@ impl Provider for OpenAi {
usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached);
usage.cache_read_tokens = cached;
usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0);
usage.read_reported_cost(u);
}
let Some(choice) = data["choices"].get(0) else { continue };
let d = &choice["delta"];
@@ -327,6 +331,22 @@ mod tests {
(text, reasoning)
}
#[test]
fn tool_messages_have_no_name() {
use crate::types::{Message, Part, Role};
let call = Part::ToolCall { id: "c1".into(), name: "bash".into(), input: serde_json::json!({"command": "ls"}), extra: None };
let result = Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "ok".into(), images: vec![], is_error: false, meta: None };
let msgs = vec![
Message::new(Role::User, vec![Part::Text { text: "hi".into() }]),
Message::new(Role::Assistant, vec![call]),
Message::new(Role::User, vec![result]),
];
let wire = super::to_wire_messages("sys", &msgs);
let tool = wire.iter().find(|m| m["role"] == "tool").expect("tool message");
assert_eq!(tool["tool_call_id"], "c1");
assert!(wire.iter().all(|m| m.get("name").is_none()), "no message may carry a name: {wire:?}");
}
#[test]
fn splits_think_tags_across_chunks() {
let (text, reasoning) = run(&["<thi", "nk>plan ", "the fix</th", "ink>Here is ", "the answer."]);
+52 -3
View File
@@ -10,11 +10,25 @@ pub enum Role {
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ImageData {
pub media_type: String,
/// Base64-encoded bytes.
/// Base64-encoded bytes. Empty when the image is stored separately (`blob`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub data: String,
/// Server only: SHA-256 of the bytes in the image store; the UI loads
/// /api/blobs/<blob> and the server fills `data` in before a model request.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
/// Size in bytes of a stored image (shown in the UI without fetching it).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
impl ImageData {
pub fn new(media_type: impl Into<String>, data: String) -> Self {
Self { media_type: media_type.into(), data, blob: None, size: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -57,6 +71,12 @@ pub enum Part {
/// Marks the start of a compacted history; the text is the summary.
Summary {
text: String,
/// Started automatically because the context was nearly full.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
auto: bool,
/// Context size (tokens) when it was compacted.
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens: Option<u64>,
},
}
@@ -93,6 +113,24 @@ pub struct Usage {
pub cache_read_tokens: u64,
#[serde(default)]
pub cache_write_tokens: u64,
/// Cost the provider reported for the request (USD; opencode: what was charged).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
/// opencode Go: how much the request counted toward the plan's usage limits (USD).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowance: Option<f64>,
}
impl Usage {
/// Read the cost fields gateways add to `usage` (opencode: `cost`, `opencode.allowance`).
pub fn read_reported_cost(&mut self, u: &serde_json::Value) {
if let Some(c) = u["cost"].as_f64() {
self.cost = Some(c);
}
if let Some(a) = u["opencode"]["allowance"].as_f64() {
self.allowance = Some(a);
}
}
}
impl Usage {
@@ -142,6 +180,13 @@ pub struct Session {
/// User-defined group in the session list.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder: Option<String>,
/// Instructions the user set for this chat; added to the system prompt of every turn.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub instructions: String,
/// Approvals and questions of this chat (and its subagents) are not sent to Telegram,
/// unless it is the chat currently open in Telegram.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub telegram_quiet: bool,
}
impl Session {
@@ -195,6 +240,9 @@ pub enum Event {
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
/// Messages waiting for a busy session (full list).
QueueUpdated { session_id: String, items: Value },
/// Compaction of the history: state "started", "done" or "failed"; auto = triggered by
/// a nearly full context (tokens of limit).
Compaction { session_id: String, state: String, auto: bool, tokens: Option<u64>, limit: Option<u64> },
}
impl Event {
@@ -216,7 +264,8 @@ impl Event {
| Error { session_id, .. }
| Waiting { session_id, .. }
| QueueUpdated { session_id, .. }
| StepStarted { session_id, .. } => Some(session_id),
| StepStarted { session_id, .. }
| Compaction { session_id, .. } => Some(session_id),
McpStatus { .. } => None,
}
}
+43
View File
@@ -0,0 +1,43 @@
//! Release version. Releases are named vYYYYMM-NNNNN (a counter per month, from 00001);
//! the release build sets CODECRAFT_VERSION (without the leading "v"). Builds without it
//! (development) fall back to the crate version.
pub const VERSION: &str = match option_env!("CODECRAFT_VERSION") {
Some(v) if !v.is_empty() => v,
_ => env!("CARGO_PKG_VERSION"),
};
/// Sort key: monthly releases (YYYYMM-NNNNN) come after the old semantic versions.
fn key(v: &str) -> (u8, Vec<u64>) {
let v = v.trim().trim_start_matches('v');
if let Some((month, n)) = v.split_once('-')
&& month.len() == 6
&& month.chars().all(|c| c.is_ascii_digit())
&& !n.is_empty()
&& n.chars().all(|c| c.is_ascii_digit())
{
return (1, vec![month.parse().unwrap_or(0), n.parse().unwrap_or(0)]);
}
(0, v.split(['.', '-']).take(3).map(|x| x.parse::<u64>().unwrap_or(0)).collect())
}
/// Is version `a` newer than `b`? Accepts both schemes, with or without "v".
pub fn newer(a: &str, b: &str) -> bool {
key(a) > key(b)
}
#[cfg(test)]
mod tests {
use super::newer;
#[test]
fn compares_monthly_and_semver() {
assert!(newer("v202611-00002", "202611-00001"));
assert!(newer("v202612-00001", "v202611-00042"));
assert!(!newer("v202611-00001", "202611-00001"));
assert!(newer("v202611-00001", "0.1.0"));
assert!(!newer("0.9.9", "v202611-00001"));
assert!(newer("v0.2.0", "0.1.9"));
assert!(newer("1.0.0", "0.9.9"));
}
}
+1 -1
View File
@@ -15,7 +15,7 @@
# /data/home home directory of the agent (git config, caches, ...)
services:
codecraft:
# Pin a version (e.g. :0.1.0) or follow :latest.
# Pin a version (e.g. :v202611-00001) or follow :latest.
image: git.familie-zink.org/zink/codecraft:${CODECRAFT_VERSION:-latest}
container_name: codecraft
restart: unless-stopped
+143
View File
@@ -0,0 +1,143 @@
# Moving CodeCraft to another host (Unraid)
This guide moves a running installation – Docker volume `codecraft-data` on the current host –
to another Docker host that keeps its data in **bind mounts**, for example Unraid. The script
`scripts/move-server.sh` does the work; this page explains what goes where, what to prepare and
how to check and roll back.
## What lives where
| Part | Contents | Today (Docker volume) | On the new host |
|---|---|---|---|
| **Data** | database `codecraft.db`, `config.toml` (incl. API keys), daily backups | `codecraft-data:/data` | `<dir>/data` → `/data` |
| **Agent installs** | the agent's home `/data/home`: toolchains (Rust, …), pip/pipx/npm/cargo packages, downloaded programs (`~/opt`), caches | inside the volume | `<tools-dir>/home` → `/data/home` |
| **Users' homes** | homes of sandboxed users `/data/users/<name>/home` (their installs, git settings) | inside the volume | `<tools-dir>/users` → `/data/users` |
| **Temporary files** | `/tmp` of agents, builds, downloads | container layer | `<tools-dir>/tmp` → `/tmp` |
| **Workspace** | your projects | `WORKSPACE` from `.env` | `--workspace` path → `/workspace` |
| **Image** | the program | registry image | registry image |
The image never changes: the container runs without root (no `apt`, no `sudo`), so agents can
only install into their home directory. With `--tools-dir` everything agents install or
download lands on a separate disk (e.g. an SSD); the data folder keeps only the database,
configuration and backups. If the tools disk is lost, agents simply install what they need
again – nothing important is on it. (Backups contain the database and configuration, not the
agent home.)
Sizes on 2026-11-09 for orientation: agent home 2.2 GB (of which downloaded programs 1.3 GB,
Rust toolchain 0.8 GB), database/config/backups about 1.2 GB.
## Before you start
On the **new host** (Unraid):
- **SSH with a key** from the current host, without a password prompt. Check from the current
host: `ssh root@unraid true` (no output, no question = fine).
- **docker**, **rsync** and **tar** available (all three are on Unraid by default).
- **Docker Compose** is optional: without it the script starts the container with the generated
`start.sh` (Unraid: the "Docker Compose Manager" plugin adds Compose).
- The **tools disk** is mounted, e.g. an SSD via Unassigned Devices under `/mnt/disks/ssd`.
- **Registry access**: the image comes from `git.familie-zink.org/zink/codecraft`. If the pull is
refused, run `docker login git.familie-zink.org` on Unraid once.
Decide:
| Question | Option | Example |
|---|---|---|
| Folder for data, compose file and scripts | target `root@host:/dir` | `/mnt/user/appdata/codecraft` |
| Folder for agent installs and `/tmp` | `--tools-dir` | `/mnt/disks/ssd/codecraft` |
| Your projects | `--workspace` | `/mnt/user/projects` |
| Owner of files (the container runs as this user) | `--uid`, `--gid` | Unraid default `99`/`100` (nobody:users); today `1000`/`1000` |
| Docker of the new host for agents and terminals | `--docker-sock` | bind mount of `/var/run/docker.sock` plus its group (see below) |
| Port and address | `--port`, `--bind` | `8787`; `0.0.0.0` if the reverse proxy runs on another machine or in another Docker network, `127.0.0.1` if it runs on the same host with host networking |
## Moving
On the **current host**, in the repository folder (where `.env` is):
```sh
# 1. Look at the steps first (changes nothing)
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock --dry-run
# 2. Move (same command without --dry-run)
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock
```
What happens:
1. **Checks**: docker/tar/rsync on the target; the target data folder (and `home/`, `users/` in
the tools folder) must be empty – the script never overwrites.
2. **Workspace, first pass** with rsync while CodeCraft keeps running (the long part).
3. **Stops CodeCraft here.** Running agents and terminals end. The container is only stopped,
not removed.
4. **Data volume** is streamed with tar over SSH into `<dir>/data`; with `--tools-dir` the agent
home and the users' homes are then moved to the tools folder.
5. **Workspace, second pass**: only what changed since step 2.
6. **Owner** of data, tools and workspace set to `--uid:--gid` (needs root on the target).
7. **Writes** `<dir>/docker-compose.yml`, `<dir>/.env` (your `.env` with the new paths, port and
owner), `<dir>/start.sh` (the same as `docker run`, for hosts without Compose) and
`<dir>/docker/seccomp-sandbox.json`.
8. **Starts** the image `CODECRAFT_VERSION` from `.env` (Compose, else `start.sh`).
9. **Smoke test** on the target (`scripts/smoke-test.sh`): health, version, web UI, terminal
client download.
Afterwards:
- **Reverse proxy**: point the proxy host for `code.familie-zink.org` (Nginx Proxy Manager) to the
new host and port; keep WebSocket support on.
- **Check in the web UI**: sign in, open a chat, check the status LEDs (providers, MCP servers),
let an agent run `git status` in a project (credentials, identity), open a terminal tab.
Models or MCP servers on the old host's LAN address or `localhost` need new addresses.
- **Clean up** on the old host when everything works: `docker rm codecraft`, later
`docker volume rm codecraft-data`.
## Docker socket (`--docker-sock`)
With `--docker-sock` the container gets the new host's Docker: `/var/run/docker.sock` is bind
mounted and the container joins the socket's group (the script reads its group id on the
target). This is **root-equivalent access to the host**: whoever can start containers can mount
any host folder.
- Admins' agents and terminals (they run without sandbox) can use it.
- Sandboxed users do not see the socket (the sandbox does not mount `/var/run`).
- `docker` and `docker-compose` commands always need an approval, except in *bypass* mode.
- The image contains the Docker CLI (static client); the daemon is the host's.
The current host has the same setup in its local `docker-compose.override.yml` (group 997).
## Rollback
Nothing is deleted on the old host. On Unraid stop the new container (`docker stop codecraft`),
on the old host start the old one (`docker start codecraft`) and point the reverse proxy back.
Changes made on the new host in the meantime (chats, settings) are not in the old installation.
## Running it on the new host
In `<dir>` on Unraid:
| Task | With Compose | Without Compose |
|---|---|---|
| Update | set `CODECRAFT_VERSION` in `.env`, then `docker compose pull && docker compose up -d` | set `CODECRAFT_VERSION` in `.env`, `docker pull …:<version>`, then `./start.sh` |
| Restart | `docker compose restart` | `docker restart codecraft` |
| Logs | `docker compose logs -f` | `docker logs -f codecraft` |
| Check | `bash smoke-test.sh http://127.0.0.1:8787 <version>` | same |
Releases are published from the development machine with `scripts/release.sh`; the new host
only pulls them.
## Troubleshooting
- **`data on the target is not empty`**: an earlier attempt left files. Move `<dir>/data` (and
`home/`, `users/` in the tools folder) away and run the script again.
- **Image pull refused**: `docker login git.familie-zink.org` on the target, then
`cd <dir> && docker compose up -d` (or `./start.sh`).
- **Smoke test fails**: `docker logs codecraft` on the target. The old installation is stopped but
intact – `docker start codecraft` on the old host brings it back.
- **`could not chown`**: the SSH user is not root; set the owner by hand
(`chown -R 99:100 <dir>/data <tools-dir> <workspace>`).
- **Permission denied inside the container**: data, tools and workspace must belong to the
`UID`/`GID` in `<dir>/.env`.
- **Port already in use**: change `PORT` in `<dir>/.env` and start again.
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Roll out a published CodeCraft image to a remote installation that was set up with
# scripts/move-server.sh (docker-compose.yml + .env in one folder on the target).
#
# scripts/release.sh # build and publish a new version (amd64 + arm64)
# scripts/deploy.sh # roll the newest tag out to the target
#
# Steps:
# 1. check that the image exists in the registry for the target's architecture,
# 2. wait until no agent or terminal runs on the target (unless --force),
# 3. set CODECRAFT_VERSION in the target's .env, docker compose pull && up -d
# (or start.sh on hosts without Compose),
# 4. run scripts/smoke-test.sh on the target; on failure the previous version is
# started again.
#
# Usage: scripts/deploy.sh [vYYYYMM-NNNNN] [--target user@host:/dir] [--force]
# [--wait MINUTES] [--dry-run]
# vYYYYMM-NNNNN version to deploy (default: the newest vYYYYMM-NNNNN git tag)
# --target installation folder on the target (default: DEPLOY_TARGET from .env)
# --force restart even if agents or terminals are running (they end)
# --wait N how long to wait for an idle server before giving up (default 15)
# --dry-run show the steps only
#
# Environment:
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
# CONTAINER container name on the target (default codecraft)
# PORT port of CodeCraft on the target, for the smoke test (default: PORT
# from the target's .env, else 8787)
set -euo pipefail
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
CONTAINER="${CONTAINER:-codecraft}"
tag="" target="" force=0 wait_min=15 dry=0
while (($#)); do
case "$1" in
--target) target="$2"; shift ;;
--force) force=1 ;;
--wait) wait_min="$2"; shift ;;
--dry-run) dry=1 ;;
-h|--help) sed -n '2,29p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;;
*) tag="$1" ;;
esac
shift
done
cd "$(git rev-parse --show-toplevel)"
step() { printf '\n==> %s\n' "$*"; }
fail() { echo "error: $*" >&2; exit 1; }
q() { printf '%q' "$1"; }
env_get() { [[ -f .env ]] && sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
target="${target:-$(env_get DEPLOY_TARGET || true)}"
[[ "$target" == *:/* ]] || fail "no target – pass --target user@host:/dir or set DEPLOY_TARGET in .env"
host="${target%%:*}"
dir="${target#*:}"; dir="${dir%/}"
remote() { ssh -o BatchMode=yes "$host" "$1"; }
run_remote() { if (( dry )); then echo "[dry-run] on $host: $1"; else remote "$1"; fi; }
if [[ -z "$tag" ]]; then
git fetch --tags --quiet origin || true
tag=$(git tag -l 'v[0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9]' | sort | tail -1)
[[ -n "$tag" ]] || fail "no release tag found"
fi
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
step "Checking $host:$dir and the image $REGISTRY:$tag"
remote "test -f $(q "$dir/.env") && test -f $(q "$dir/docker-compose.yml")" \
|| fail "$dir on $host has no .env/docker-compose.yml – set it up with scripts/move-server.sh"
current=$(remote "sed -n 's/^CODECRAFT_VERSION=//p' $(q "$dir/.env") | tail -1")
case "$(remote 'uname -m')" in
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
esac
if [[ -n "$arch" ]]; then
# Captured first: `inspect | grep -q` fails under pipefail when grep exits early (SIGPIPE).
manifests=$(docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null || true)
grep -q "Platform:.*linux/$arch" <<<"$manifests" \
|| fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first"
fi
echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})"
[[ "$current" == "$tag" ]] && echo "note: $tag is already configured – the container is recreated anyway"
# Busy = the container runs more than the server process (agents' shells, terminals, ...).
busy() { (( $(remote "docker top $(q "$CONTAINER") -o pid 2>/dev/null | tail -n +2 | wc -l") > 1 )); }
if (( ! force )) && remote "docker inspect -f '{{.State.Running}}' $(q "$CONTAINER") 2>/dev/null" | grep -q true; then
step "Waiting until no agent or terminal runs (up to $wait_min min, --force skips this)"
deadline=$(( $(date +%s) + wait_min * 60 ))
while busy; do
(( $(date +%s) < deadline )) || fail "still busy after $wait_min min – try later or use --force"
sleep 15
done
echo "idle"
fi
step "Updating to $tag"
run_remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$tag/' .env \
&& (grep -q '^CODECRAFT_VERSION=' .env || echo 'CODECRAFT_VERSION=$tag' >> .env) \
&& docker pull -q $(q "$REGISTRY:$tag") >/dev/null \
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
step "Smoke test"
port="${PORT:-$(remote "sed -n 's/^PORT=//p' $(q "$dir/.env") | tail -1")}"; port="${port:-8787}"
if (( dry )); then
echo "[dry-run] smoke test on $host: http://127.0.0.1:$port ${tag#v}"
else
ssh -o BatchMode=yes "$host" "cat > $(q "$dir/smoke-test.sh")" <scripts/smoke-test.sh
if ! remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${tag#v}"; then
if [[ -n "$current" && "$current" != "$tag" ]]; then
echo "smoke test failed – going back to $current" >&2
remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$current/' .env \
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
fi
fail "deploy of $tag failed"
fi
fi
step "Done"
echo "$host runs CodeCraft $tag."
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env bash
# Move this CodeCraft installation to another Docker host (e.g. Unraid) that keeps its
# data in bind mounts instead of the Docker volume "codecraft-data".
#
# Run it on the CURRENT host, in the repository folder (where .env lives):
#
# scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
# --workspace /mnt/user/projects --uid 99 --gid 100 --bind 0.0.0.0
#
# What it does:
# 1. copies the workspace while CodeCraft keeps running (rsync, first pass),
# 2. stops the CodeCraft container here (it is kept for a rollback),
# 3. streams the data volume (database, config, backups, agent home) to <dir>/data,
# 4. syncs the workspace again (only what changed),
# 5. writes <dir>/docker-compose.yml, <dir>/.env and <dir>/start.sh (docker run, for hosts
# without Compose) with bind mounts, and starts the published image there,
# 6. runs scripts/smoke-test.sh on the new host.
# Afterwards point the reverse proxy to the new host. To roll back: start the old container
# here again (docker compose start) – nothing is deleted on this host.
#
# Target: user@host:/dir (SSH; rsync and docker on the target), or local:/dir to try it on
# this host (use --name/--port so it does not clash with the running installation).
#
# Options:
# --workspace DIR project folder on the target (default <dir>/workspace)
# --tools-dir DIR separate disk for what agents install and download: the agent home
# (/data/home), the users' homes (/data/users) and /tmp are bind
# mounts from DIR/home, DIR/users and DIR/tmp (default: inside <dir>/data)
# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env)
# --docker-sock give the container the target's Docker (bind mount of
# /var/run/docker.sock plus its group). Root-equivalent access to the
# target host for admins' agents and terminals; sandboxed users do not
# see it, and docker commands still need approval
# --port N host port on the target (default: PORT of .env)
# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1)
# --name NAME container name on the target (default codecraft)
# --source-container C container to stop here (default codecraft)
# --source-volume V data volume here (default codecraft-data)
# --source-workspace D project folder here (default: WORKSPACE of .env)
# --exclude PATTERN leave this out of the workspace copy (rsync pattern, relative to the
# workspace, e.g. /old-project/ or /app/target/); repeatable
# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
# --dry-run show the steps only
set -euo pipefail
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
excludes=()
while (($#)); do
case "$1" in
--workspace) ws_target="$2"; shift ;;
--tools-dir) tools_dir="$2"; shift ;;
--docker-sock) docker_sock=1 ;;
--uid) uid="$2"; shift ;;
--gid) gid="$2"; shift ;;
--port) port="$2"; shift ;;
--bind) bind="$2"; shift ;;
--name) name="$2"; shift ;;
--source-container) src_container="$2"; shift ;;
--source-volume) src_volume="$2"; shift ;;
--source-workspace) ws_source="$2"; shift ;;
--exclude) excludes+=(--exclude "$2"); shift ;;
--no-stop) stop=0 ;;
--dry-run) dry=1 ;;
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;;
*) target="$1" ;;
esac
shift
done
[[ "$target" == *:/* ]] || { echo "usage: $0 user@host:/dir | local:/dir [options] (see --help)" >&2; exit 2; }
cd "$(git rev-parse --show-toplevel)"
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
# Value of a .env variable, without surrounding quotes.
env_get() { sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
host="${target%%:*}"
dir="${target#*:}"
dir="${dir%/}"
ws_target="${ws_target:-$dir/workspace}"
uid="${uid:-$(env_get UID)}"; uid="${uid:-1000}"
gid="${gid:-$(env_get GID)}"; gid="${gid:-1000}"
port="${port:-$(env_get PORT)}"; port="${port:-8787}"
bind="${bind:-$(env_get BIND_ADDR)}"; bind="${bind:-127.0.0.1}"
version="$(env_get CODECRAFT_VERSION)"; version="${version:-latest}"
ws_source="${ws_source:-$(env_get WORKSPACE)}"; ws_source="${ws_source:-./workspace}"
image="$(docker inspect "$src_container" --format '{{.Config.Image}}' 2>/dev/null || true)"
image_repo="${image%:*}"
[[ -n "$image_repo" && "$image_repo" != codecraft ]] || image_repo="git.familie-zink.org/zink/codecraft"
step() { printf '\n==> %s\n' "$*"; }
fail() { echo "error: $*" >&2; exit 1; }
# Run a shell command on the target.
remote() {
if (( dry )); then echo "[dry-run] on $host: $1"; return 0; fi
if [[ "$host" == local ]]; then bash -c "$1"; else ssh -o BatchMode=yes "$host" "$1"; fi
}
q() { printf '%q' "$1"; }
sync_workspace() {
if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
}
put_file() { # put_file <remote path> < content
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
if [[ "$host" == local ]]; then cat >"$1"; else ssh -o BatchMode=yes "$host" "cat > $(q "$1")"; fi
}
step "Checking source and target"
docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
[[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
echo "source: container $src_container, volume $src_volume, workspace $ws_source"
tools_dir="${tools_dir%/}"
echo "target: $host:$dir (data in $dir/data, workspace $ws_target${tools_dir:+, agent installs and /tmp in $tools_dir}), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version"
remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync"
remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \
|| fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)"
if [[ -n "$tools_dir" ]]; then
remote "mkdir -p $(q "$tools_dir") && test ! -e $(q "$tools_dir/home") && test ! -e $(q "$tools_dir/users")" \
|| fail "$tools_dir already has home/ or users/ – refusing to overwrite (move them away first)"
fi
step "Copying the workspace (first pass, CodeCraft keeps running)"
sync_workspace
if (( stop )); then
step "Stopping $src_container here (running agents and terminals end)"
(( dry )) && echo "[dry-run] docker stop $src_container" || docker stop "$src_container" >/dev/null
fi
step "Copying the data volume"
if (( dry )); then
echo "[dry-run] tar of volume $src_volume -> $host:$dir/data"
else
docker run --rm --entrypoint tar --user 0:0 -v "$src_volume:/data:ro" "${image:-$image_repo:$version}" -C /data -cf - . \
| remote "tar -C $(q "$dir/data") -xpf -"
fi
if [[ -n "$tools_dir" ]]; then
step "Moving the agent home and the users' homes to $tools_dir"
remote "mkdir -p $(q "$tools_dir/tmp") $(q "$dir/data/home") $(q "$dir/data/users") \
&& mv $(q "$dir/data/home") $(q "$tools_dir/home") && mv $(q "$dir/data/users") $(q "$tools_dir/users") \
&& mkdir -p $(q "$dir/data/home") $(q "$dir/data/users") && chmod 1777 $(q "$tools_dir/tmp")"
fi
step "Copying the workspace (changes since the first pass)"
sync_workspace
remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") ${tools_dir:+$(q "$tools_dir/home") $(q "$tools_dir/users")} 2>/dev/null || echo 'note: could not chown (not root) – check file owners'"
# Bind mounts for the tools disk and the Docker socket (compose lines and docker run flags).
tool_mounts="" tool_flags="" sock_compose="" sock_flags=""
if (( docker_sock )); then
if (( dry )); then sock_gid=0; else
sock_gid=$(remote "stat -c %g /var/run/docker.sock") || fail "no /var/run/docker.sock on the target"
fi
sock_compose=$(printf ' - /var/run/docker.sock:/var/run/docker.sock\n group_add:\n - "%s"\n' "$sock_gid")
sock_flags="-v /var/run/docker.sock:/var/run/docker.sock --group-add $sock_gid "
fi
if [[ -n "$tools_dir" ]]; then
tool_mounts=$(printf ' - %s/home:/data/home\n - %s/users:/data/users\n - %s/tmp:/tmp\n' "$tools_dir" "$tools_dir" "$tools_dir")
tool_flags="-v $tools_dir/home:/data/home -v $tools_dir/users:/data/users -v $tools_dir/tmp:/tmp "
fi
step "Writing docker-compose.yml, .env and start.sh"
put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json
grep -vE '^(COMPOSE_FILE|WORKSPACE|UID|GID|PORT|BIND_ADDR)=' .env | {
cat
printf '\n# Written by scripts/move-server.sh\nWORKSPACE=%s\nUID=%s\nGID=%s\nPORT=%s\nBIND_ADDR=%s\n' "$ws_target" "$uid" "$gid" "$port" "$bind"
} | put_file "$dir/.env"
put_file "$dir/docker-compose.yml" <<EOF
# CodeCraft with bind mounts (written by scripts/move-server.sh).
# Update: set CODECRAFT_VERSION in .env, then docker compose pull && docker compose up -d
services:
codecraft:
image: $image_repo:\${CODECRAFT_VERSION:-latest}
container_name: $name
restart: unless-stopped
user: "\${UID}:\${GID}"
ports:
- "\${BIND_ADDR}:\${PORT}:8787"
env_file:
- path: .env
required: false
volumes:
- $dir/data:/data
- \${WORKSPACE}:/workspace
$tool_mounts
$sock_compose
extra_hosts:
- "host.docker.internal:host-gateway"
shm_size: "1gb"
security_opt:
- seccomp=$dir/docker/seccomp-sandbox.json
- apparmor=unconfined
- systempaths=unconfined
EOF
put_file "$dir/start.sh" <<EOF
#!/bin/sh
# Same as docker-compose.yml, for hosts without Docker Compose.
set -e
cd "\$(dirname "\$0")"
# Read values instead of sourcing .env: UID is read-only in bash.
val() { sed -n "s/^\$1=//p" .env | tail -1; }
docker rm -f $name >/dev/null 2>&1 || true
exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
-p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" $tool_flags$sock_flags\\
--add-host host.docker.internal:host-gateway --shm-size 1g \\
--security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
--security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
$image_repo:\$(val CODECRAFT_VERSION)
EOF
remote "chmod +x $(q "$dir/start.sh")"
step "Starting CodeCraft on the target"
remote "cd $(q "$dir") && docker pull -q $image_repo:$version >/dev/null && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" \
|| fail "start failed – if the image pull was refused, run 'docker login ${image_repo%%/*}' on the target and start again with: cd $dir && docker compose up -d (or ./start.sh)"
step "Smoke test on the target"
put_file "$dir/smoke-test.sh" <scripts/smoke-test.sh
remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${version#v}" || fail "smoke test failed – the old installation here is stopped but intact (docker start $src_container)"
step "Done"
cat <<EOF
CodeCraft runs on $host ($bind:$port). Next:
- point the reverse proxy (e.g. Nginx Proxy Manager) for your domain to the new host and port,
- check the agents' tools in a chat (git credentials, MCP servers, LAN addresses),
- when everything works, remove the old container here: docker rm $src_container
(the volume $src_volume stays until you delete it: docker volume rm $src_volume).
Rollback: on the target 'docker stop $name', here 'docker start $src_container'.
EOF
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Publish a CodeCraft release named vYYYYMM-NNNNN (year, month, then a counter that
# starts at 00001 every month):
# 1. pick the next number from the existing tags of this month,
# 2. rename "## [Unreleased]" in CHANGELOG.md and the "unreleased" entry in
# web/src/whatsnew.ts to the version, update the README badge, commit,
# 3. tag and push branch and tag,
# 4. build the multi-arch image (amd64 + arm64) with the version embedded and push
# :vYYYYMM-NNNNN (and :latest),
# 5. create the Gitea release with the changelog section as notes.
#
# Usage: scripts/release.sh [vYYYYMM-NNNNN] [--dry-run] [--no-latest] [--skip-image]
# (without a version the next number of the current month is used)
#
# Environment:
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
# GITEA_URL Gitea base URL (default https://git.familie-zink.org)
# GITEA_REPO owner/repo (default zink/codecraft)
# GITEA_TOKEN API token; if empty, the git credential helper is asked
# PLATFORMS buildx platforms (default linux/amd64,linux/arm64)
# BUILDER buildx builder name (default codecraft-multi, created if missing)
#
# Requires: git, docker with buildx (logged in to the registry), curl, jq.
# Building for a foreign architecture needs QEMU/binfmt:
# docker run --privileged --rm tonistiigi/binfmt --install all
set -euo pipefail
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
GITEA_URL="${GITEA_URL:-https://git.familie-zink.org}"
GITEA_REPO="${GITEA_REPO:-zink/codecraft}"
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
BUILDER="${BUILDER:-codecraft-multi}"
tag="" dry=0 latest=1 image=1
for arg in "$@"; do
case "$arg" in
--dry-run) dry=1 ;;
--no-latest) latest=0 ;;
--skip-image) image=0 ;;
-h|--help) sed -n '2,26p' "$0"; exit 0 ;;
-*) echo "unknown option $arg" >&2; exit 2 ;;
*) tag="$arg" ;;
esac
done
cd "$(git rev-parse --show-toplevel)"
step() { printf '\n==> %s\n' "$*"; }
run() { if (( dry )); then echo "[dry-run] $*"; else "$@"; fi; }
fail() { echo "error: $*" >&2; exit 1; }
step "Checking the working tree"
for cmd in git docker curl jq; do command -v "$cmd" >/dev/null || fail "$cmd is not installed"; done
[[ -z "$(git status --porcelain --untracked-files=no)" ]] || fail "uncommitted changes – commit or stash them first"
run git fetch --tags --quiet origin
if [[ -z "$tag" ]]; then
month=$(date +%Y%m)
last=$(git tag -l "v$month-*" | sed -n "s/^v$month-\([0-9]\{5\}\)$/\1/p" | sort -n | tail -1)
tag=$(printf 'v%s-%05d' "$month" $((10#${last:-0} + 1)))
fi
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
git rev-parse -q --verify "refs/tags/$tag" >/dev/null && fail "tag $tag already exists"
version=${tag#v}
today=$(date +%Y-%m-%d)
echo "release $tag"
# Release notes: the [Unreleased] section of the changelog (without its heading).
notes=$(awk '/^## \[Unreleased\]/ { found=1; next } found && /^## \[/ { exit } found { print }' CHANGELOG.md)
[[ -n "${notes//[[:space:]]/}" ]] || fail "CHANGELOG.md has no (or an empty) \"## [Unreleased]\" section"
notes+=$'\n\n'"Image: \`$REGISTRY:$tag\` ($(tr ',' ' ' <<<"$PLATFORMS"))."
step "Naming the release in CHANGELOG.md, whatsnew.ts and README.md"
if (( dry )); then
echo "[dry-run] ## [Unreleased] -> ## [$tag] – $today; whatsnew unreleased -> \"$version\"; README badge"
else
sed -i "s/^## \[Unreleased\].*/## [$tag] – $today/" CHANGELOG.md
grep -q '^ unreleased: {' web/src/whatsnew.ts && sed -i "s/^ unreleased: {/ \"$version\": {/" web/src/whatsnew.ts
# shields.io escapes "-" as "--".
sed -i -E "s|(alt=\"Version )[^\"]*(\" src=\"https://img.shields.io/badge/version-)[^?]*-([0-9a-f]{6}\?)|\1$tag\2${tag//-/--}-\3|" README.md
git add CHANGELOG.md web/src/whatsnew.ts README.md
git commit -q -m "Release $tag"
fi
step "Tagging $tag and pushing"
branch=$(git rev-parse --abbrev-ref HEAD)
run git tag -a "$tag" -m "CodeCraft $tag"
run git push origin "$branch" "$tag"
if (( image )); then
step "Building and pushing $REGISTRY:$tag for $PLATFORMS"
docker buildx inspect "$BUILDER" >/dev/null 2>&1 || run docker buildx create --name "$BUILDER" --driver docker-container >/dev/null
tags=(-t "$REGISTRY:$tag")
(( latest )) && tags+=(-t "$REGISTRY:latest")
run docker buildx build --builder "$BUILDER" --platform "$PLATFORMS" \
--build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" --build-arg CODECRAFT_VERSION="$version" \
"${tags[@]}" --push .
fi
step "Creating the Gitea release $tag"
token="${GITEA_TOKEN:-}"
user=""
if [[ -z "$token" ]]; then
host=${GITEA_URL#*://}
creds=$(printf 'protocol=%s\nhost=%s\n\n' "${GITEA_URL%%://*}" "${host%%/*}" | git credential fill 2>/dev/null || true)
user=$(sed -n 's/^username=//p' <<<"$creds")
token=$(sed -n 's/^password=//p' <<<"$creds")
fi
[[ -n "$token" ]] || fail "no Gitea credentials – set GITEA_TOKEN"
auth=(-H "Authorization: token $token")
[[ -n "$user" ]] && auth=(-u "$user:$token")
body=$(jq -n --arg tag "$tag" --arg name "CodeCraft $tag" --arg body "$notes" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
if (( dry )); then
echo "[dry-run] POST $GITEA_URL/api/v1/repos/$GITEA_REPO/releases"
else
url=$(curl -fsS "${auth[@]}" -H 'Content-Type: application/json' -X POST \
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" -d "$body" | jq -r .html_url)
echo "published: $url"
fi
step "Done"
echo "Update a deployment: set CODECRAFT_VERSION=$tag in .env, then docker compose pull && docker compose up -d"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Quick check of a running CodeCraft: health, version, web UI, terminal client download.
#
# Usage: scripts/smoke-test.sh [URL] [EXPECTED_VERSION]
# URL base URL (default http://127.0.0.1:8787)
# EXPECTED_VERSION e.g. 202611-00003 (without "v"); checked when given
#
# Exit code 0 when everything answers, 1 otherwise.
set -uo pipefail
url="${1:-http://127.0.0.1:8787}"
url="${url%/}"
want="${2:-}"
want="${want#v}"
fail=0
check() { if [[ "$2" == ok ]]; then printf ' ok %s\n' "$1"; else printf ' FAIL %s (%s)\n' "$1" "$2"; fail=1; fi; }
echo "smoke test: $url"
health=""
for _ in $(seq 1 60); do
health=$(curl -fsS -m 5 "$url/api/health" 2>/dev/null) && break
sleep 2
done
[[ -n "$health" ]] && check "health" ok || check "health" "no answer after 2 minutes"
if [[ -n "$health" ]]; then
version=$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' <<<"$health")
if [[ -n "$want" && "$version" != "$want" ]]; then check "version $version" "expected $want"; else check "version ${version:-?}" ok; fi
grep -q '"ok":true' <<<"$health" && check "server reports ok" ok || check "server reports ok" "$health"
fi
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/")
[[ "$code" == 200 ]] && check "web UI" ok || check "web UI" "HTTP $code"
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/cli/install.sh")
[[ "$code" == 200 ]] && check "terminal client install script" ok || check "terminal client install script" "HTTP $code"
code=$(curl -s -o /dev/null -m 60 -w '%{http_code}' "$url/cli/download/linux-x86_64")
[[ "$code" == 200 ]] && check "terminal client download" ok || check "terminal client download" "HTTP $code"
exit $fail
+2 -2
View File
@@ -28,7 +28,7 @@ shell-words = "1"
libc = "0.2"
ignore = "0.4"
regex = "1"
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] }
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form", "multipart"] }
rusqlite = { version = "0.41", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -38,7 +38,7 @@ tokio-tungstenite = "0.29"
tokio-util = "0.7"
toml = "1"
toml_edit = "0.25"
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
tower-http = { version = "0.7", features = ["fs", "cors", "trace", "compression-gzip", "compression-br"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4", "v7"] }
+377 -32
View File
@@ -15,7 +15,7 @@ use axum::middleware;
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post, put};
use axum::{Extension, Json, Router};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use std::path::PathBuf;
use std::sync::Arc;
@@ -69,6 +69,7 @@ pub fn router(app: AppState) -> Router {
.route("/providers/reload", post(reload_providers))
.route("/models", get(models))
.route("/agents", get(agents))
.route("/agents/tools", get(agent_tools))
.route("/agents/{name}", delete(delete_agent))
.route("/commands", get(commands))
.route("/sessions", get(list_sessions).post(create_session))
@@ -77,7 +78,9 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/title", post(suggest_title))
.route("/sessions/{id}/regenerate", post(regenerate))
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
.route("/sessions/{id}/export", get(export_session))
.route("/blobs/{hash}", get(get_blob))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort))
@@ -119,11 +122,15 @@ pub fn router(app: AppState) -> Router {
.route("/backup", get(backup))
.route("/backups", get(list_backups).post(run_backup))
.route("/backups/{name}", get(download_backup))
.route("/backups/{name}/restore", post(restore_stored_backup))
.route("/backups/restore", post(restore_uploaded_backup).layer(axum::extract::DefaultBodyLimit::max(4 * 1024 * 1024 * 1024)))
.route("/health", get(health))
.route("/tools", get(crate::reverse::status))
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
.route("/providers/{name}/prices", get(provider_prices))
.route("/providers/status", get(provider_status).post(recheck_providers))
.route("/providers/test", post(test_provider))
.route("/status", get(status_overview))
.route("/images", get(list_images).post(create_images))
.route("/images/config", get(images_config))
.route("/images/{id}", delete(delete_image))
@@ -131,6 +138,8 @@ pub fn router(app: AppState) -> Router {
.route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
.route("/templates", get(crate::cli_api::get_templates))
.route("/templates/{scope}", put(crate::cli_api::put_templates))
.route("/cli/config", get(crate::cli_api::config))
@@ -163,8 +172,7 @@ pub fn router(app: AppState) -> Router {
.find(|p| p.join("index.html").exists())
.unwrap_or_else(|| PathBuf::from("./web/dist"))
});
let index = web_dir.join("index.html");
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(missing_static_file(web_dir.clone()));
Router::new()
.nest("/api", api)
@@ -173,9 +181,54 @@ pub fn router(app: AppState) -> Router {
.route("/cli/install.sh", get(cli_install_script))
.route("/cli/download/{target}", get(cli_download))
.fallback_service(static_files)
.layer(compression())
.with_state(app)
}
/// Paths that are not a file of the web build. Usually a client-side route → index.html,
/// except for:
/// - `/sw-<build>.js` of an earlier build: installed apps check their service worker at
/// that URL for updates. Answering with index.html (not a script) makes the update
/// fail, so the old worker kept serving the old app shell forever – which broke when
/// the API changed. They get the kill switch /sw.js instead: it removes the old worker
/// and reloads, and the page then registers the current worker.
/// - other missing scripts/styles (old chunks): 404 instead of HTML.
fn missing_static_file(web_dir: PathBuf) -> axum::routing::MethodRouter {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
axum::routing::any(move |uri: axum::http::Uri| {
let web_dir = web_dir.clone();
async move {
let path = uri.path();
let name = path.trim_start_matches('/');
if name.starts_with("sw-") && name.ends_with(".js") && !name.contains('/') {
return match tokio::fs::read(web_dir.join("sw.js")).await {
Ok(js) => ([(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache, no-store")], js).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
};
}
if [".js", ".mjs", ".css", ".map"].iter().any(|ext| path.ends_with(ext)) {
return StatusCode::NOT_FOUND.into_response();
}
match tokio::fs::read(web_dir.join("index.html")).await {
Ok(html) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], html).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
})
}
/// gzip/brotli for JSON, HTML, JS and CSS. Images and SSE streams are left alone (default
/// predicate), and so are binary downloads (already compressed or large).
fn compression() -> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate + Clone> {
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
let predicate = DefaultPredicate::new()
.and(NotForContentType::const_new("application/octet-stream"))
.and(NotForContentType::const_new("application/zip"))
.and(NotForContentType::const_new("application/gzip"));
tower_http::compression::CompressionLayer::new().compress_when(predicate)
}
fn cli_dir() -> PathBuf {
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
}
@@ -317,7 +370,86 @@ fn scope(app: &App, user: &User, cwd: Option<String>) -> (PathBuf, PathBuf) {
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
let (root, cwd) = scope(&app, &user, q.cwd);
Json(json!(prompts::agents(&app, &root, &cwd)))
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id))))
}
/// Telegram bot of the user: bot name, pairing state, errors (the token stays hidden).
async fn get_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s = crate::telegram::load(&app, &user.id);
let (running, error) = crate::telegram::status(&user.id);
Ok(Json(json!({
"configured": !s.token.is_empty(),
"bot": s.bot,
"paired": s.chat_id.is_some(),
"pair_code": if s.chat_id.is_none() { s.pair_code } else { String::new() },
"running": running,
"error": error,
})))
}
#[derive(Deserialize)]
struct TelegramBody {
token: String,
}
/// Connect a bot: check the token, start polling and create a pairing code.
async fn put_telegram(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<TelegramBody>) -> ApiResult {
let token = crate::telegram::extract_token(&b.token)
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "that is not a bot token – it looks like 123456789:AAH… (paste only the token from @BotFather)".into()))?;
let bot = crate::telegram::check_token(&token).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("token rejected by Telegram: {e:#}")))?;
let s = crate::telegram::Settings { token, bot, pair_code: crate::telegram::new_pair_code(), ..Default::default() };
app.store.set_user_setting(&user.id, "telegram", &s)?;
crate::telegram::restart(&app, &user.id);
app.audit(Some(&user), "telegram.connect", &s.bot, "");
get_telegram(Extension(user), State(app)).await
}
async fn delete_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
crate::telegram::stop(&user.id);
app.store.set_user_setting(&user.id, "telegram", &crate::telegram::Settings::default())?;
app.audit(Some(&user), "telegram.disconnect", "", "");
Ok(Json(json!({"ok": true})))
}
/// The user's personal modes (name -> settings).
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
}
/// Replace the user's personal modes. Names of built-in, admin or project modes are
/// taken; tools and read-only work like in admin modes (the user's sandbox still applies).
async fn put_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>, Json(modes): Json<std::collections::BTreeMap<String, crate::config::AgentConfig>>) -> ApiResult {
if modes.len() > 30 {
return Err(ApiError(StatusCode::BAD_REQUEST, "too many personal modes (max 30)".into()));
}
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let taken: Vec<String> = prompts::agents(&app, &root, &root).into_iter().map(|a| a.name).collect();
let mut clean = std::collections::BTreeMap::new();
for (name, mut m) in modes {
if name.is_empty() || name.len() > 40 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid mode name '{name}' (letters, digits, - and _)")));
}
if taken.contains(&name) {
return Err(ApiError(StatusCode::BAD_REQUEST, format!("the name '{name}' is already used by another mode")));
}
if m.prompt.as_deref().is_some_and(|p| p.len() > 20_000) {
return Err(ApiError(StatusCode::BAD_REQUEST, "prompt too long (max 20,000 characters)".into()));
}
m.mode = Some("primary".into());
m.max_parallel = None;
clean.insert(name, m);
}
app.store.set_user_setting(&user.id, "modes", &clean)?;
Ok(Json(json!(clean)))
}
/// Built-in tools for the mode editors (name, read-only, first line of the description).
async fn agent_tools() -> ApiResult {
let list: Vec<Value> = crate::tools::builtin_tools()
.iter()
.map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()}))
.collect();
Ok(Json(json!(list)))
}
/// Delete a user-defined agent: its config entry and/or its Markdown file.
@@ -487,6 +619,9 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
pinned: false,
archived: false,
folder: None,
instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
};
let s = app.save_session(s)?;
Ok(Json(json!(s)))
@@ -656,7 +791,8 @@ struct ExportQ {
/// Download a session as Markdown (default) or JSON.
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
let s = readable(&app, &user, &id)?;
let messages = app.store.messages(&id)?;
// JSON exports are self-contained: images inline instead of store references.
let messages = if q.format == "json" { app.store.messages_for_model(&id)? } else { app.store.messages(&id)? };
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
let (body, mime, ext) = if q.format == "json" {
@@ -695,17 +831,31 @@ struct UpdateSession {
archived: Option<bool>,
/// "" removes the folder.
folder: Option<String>,
/// Instructions for this chat ("" clears them).
instructions: Option<String>,
/// Approvals and questions of this chat are not sent to Telegram.
telegram_quiet: Option<bool>,
}
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
let mut s = writable(&app, &user, &id)?;
writable(&app, &user, &id)?;
// Organising the list must not move the session to "today".
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none();
let organise_only = body.title.is_none() && body.model.is_none() && body.agent.is_none() && body.clean_room.is_none() && body.permission_mode.is_none() && body.instructions.is_none();
if body.instructions.as_ref().is_some_and(|i| i.len() > 20_000) {
return Err(ApiError(StatusCode::BAD_REQUEST, "instructions too long (max 20,000 characters)".into()));
}
if let Some(m) = &body.model {
app.registry().resolve(m)?;
}
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
// Atomic: a running turn updates the same session (usage, title, todos) meanwhile.
let s = app.modify_session(&id, !organise_only, |s| {
if let Some(t) = body.title {
s.title = t;
}
if let Some(m) = body.model {
app.registry().resolve(&m)?;
s.model = m;
}
if let Some(a) = body.agent {
@@ -727,18 +877,51 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
let f = f.trim().chars().take(60).collect::<String>();
s.folder = (!f.is_empty()).then_some(f);
}
if organise_only {
app.store.save_session(&s)?;
app.emit(Event::SessionUpdated { session: s.clone() });
return Ok(Json(json!(s)));
}
if let Some(p) = body.permission_mode {
if !crate::permission::MODES.contains(&p.as_str()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
}
s.permission_mode = p;
}
Ok(Json(json!(app.save_session(s)?)))
if let Some(i) = body.instructions {
s.instructions = i.trim().to_string();
}
if let Some(q) = body.telegram_quiet {
s.telegram_quiet = q;
}
})?;
Ok(Json(json!(s)))
}
#[derive(Deserialize, Serialize)]
struct FileTab {
id: String,
dir: String,
}
fn file_tabs_key(session_id: &str) -> String {
format!("file_tabs:{session_id}")
}
/// Tabs of a chat for this user: its terminals and file managers (kept on the server,
/// so they are the same on every device).
async fn session_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
readable(&app, &user, &id)?;
let files: Vec<FileTab> = app.store.user_setting(&user.id, &file_tabs_key(&id))?;
Ok(Json(json!({"terminals": app.terminals.for_session(&id, &user.id), "files": files})))
}
async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(tabs): Json<Vec<FileTab>>) -> ApiResult {
readable(&app, &user, &id)?;
if tabs.len() > 20 {
return Err(ApiError(StatusCode::BAD_REQUEST, "too many file manager tabs".into()));
}
let roots = app.user_roots(Some(&user.id));
for t in &tabs {
let p = crate::tools::normalize(&PathBuf::from(&t.dir));
if t.id.len() > 64 || !roots.iter().any(|r| p.starts_with(r)) {
return Err(ApiError(StatusCode::FORBIDDEN, "folder is outside your folders".into()));
}
}
app.store.set_user_setting(&user.id, &file_tabs_key(&id), &tabs)?;
Ok(Json(json!({"ok": true})))
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
@@ -747,10 +930,35 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
app.emit(Event::SessionDeleted { session_id: id.clone() });
runner::abort(&app, &id);
crate::shells::kill_session(&app, &id);
app.terminals.kill_session(&id);
app.store.delete_session(&id)?;
// Images only this session used.
let a = app.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = a.store.gc_blobs() {
tracing::warn!("image cleanup: {e:#}");
}
});
Ok(Json(json!({"ok": true})))
}
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
/// so it never changes and the browser may keep it.
async fn get_blob(Extension(_user): Extension<User>, State(app): State<AppState>, Path(hash): Path<String>) -> Result<axum::response::Response, ApiError> {
if !crate::blobs::is_hash(&hash) {
return Err(not_found("image"));
}
let (mime, bytes) = app.store.blob(&hash)?.ok_or_else(|| not_found("image"))?;
Ok((
[
(axum::http::header::CONTENT_TYPE, mime),
(axum::http::header::CACHE_CONTROL, "private, max-age=31536000, immutable".to_string()),
],
bytes,
)
.into_response())
}
#[derive(Deserialize)]
struct PromptBody {
text: String,
@@ -775,41 +983,48 @@ fn set_turn_effort(app: &App, id: &str, effort: Option<&str>) -> Result<(), ApiE
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
let session = writable(&app, &user, &id)?;
let text = body.text.trim().to_string();
send_prompt(&app, session, body.text, body.images, body.effort.as_deref()).map(Json)
}
/// Handle a user message for a session (web UI and Telegram): built-in commands,
/// slash command expansion, then start or queue the turn.
pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text: String, images: Vec<ImageData>, effort: Option<&str>) -> Result<Value, ApiError> {
let id = session.id.clone();
let text = text.trim().to_string();
// Built-in commands.
if text == "/compact" {
if app.is_busy(&id) {
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
}
tokio::spawn(do_compact(app.clone(), id.clone()));
return Ok(Json(json!({"ok": true, "command": "compact"})));
return Ok(json!({"ok": true, "command": "compact"}));
}
let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut prompt_text = text.clone();
if text.starts_with('/') {
if let Some((expanded, agent, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) {
if let Some((expanded, agent, clean_room)) = prompts::expand_command(app, &root, &cwd, &text) {
prompt_text = expanded;
if agent.is_some() || clean_room.is_some() {
let mut s = session.clone();
app.update_session(&id, |s| {
if let Some(a) = agent {
s.agent = a;
}
if clean_room.is_some() {
s.clean_room = clean_room;
}
app.save_session(s)?;
})?;
}
}
}
set_turn_effort(&app, &id, body.effort.as_deref())?;
set_turn_effort(app, &id, effort)?;
let mut parts = vec![Part::Text { text: prompt_text }];
for image in body.images {
for image in images {
parts.push(Part::Image { image });
}
match runner::submit(app.clone(), id, parts, "user")? {
runner::Submitted::Started => Ok(Json(json!({"ok": true}))),
runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))),
runner::Submitted::Started => Ok(json!({"ok": true})),
runner::Submitted::Queued(item) => Ok(json!({"ok": true, "queued": item})),
}
}
@@ -833,11 +1048,11 @@ async fn flush_queue(Extension(user): Extension<User>, State(app): State<AppStat
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
}
async fn do_compact(app: AppState, id: String) {
pub(crate) async fn do_compact(app: AppState, id: String) {
let cancel = tokio_util::sync::CancellationToken::new();
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
if let Err(e) = runner::compact(app.clone(), &id, cancel).await {
if let Err(e) = runner::compact(app.clone(), &id, cancel, None).await {
app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") });
}
app.with_runtime(&id, |r| r.cancel = None);
@@ -1116,6 +1331,33 @@ async fn run_backup(Extension(user): Extension<User>, State(app): State<AppState
Ok(Json(json!(res)))
}
/// Stage a restore and restart: the database is replaced before it is opened again.
/// In Docker the restart policy brings the server back up.
async fn restore_and_restart(app: &App, user: &User, bytes: Vec<u8>, source: &str) -> ApiResult {
crate::backup::stage_restore(app, bytes).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(user), "backup.restore", source, "");
tracing::warn!(source, "backup staged for restore – restarting");
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
std::process::exit(0);
});
Ok(Json(json!({"ok": true, "restart": true})))
}
async fn restore_stored_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
return Err(ApiError(StatusCode::NOT_FOUND, "no such backup".into()));
}
let bytes = tokio::fs::read(crate::backup::backup_dir(&app).join(&name)).await?;
restore_and_restart(&app, &user, bytes, &name).await
}
async fn restore_uploaded_backup(Extension(user): Extension<User>, State(app): State<AppState>, body: axum::body::Bytes) -> ApiResult {
require_admin(&user)?;
restore_and_restart(&app, &user, body.to_vec(), &format!("upload ({} bytes)", body.len())).await
}
async fn download_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> Result<Response, ApiError> {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
@@ -1138,10 +1380,91 @@ async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
}
/// Status overlay: every enabled provider and MCP server with its state. Error texts can
/// contain URLs or hosts, so only admins get them.
async fn status_overview(Extension(user): Extension<User>, State(app): State<AppState>) -> Json<Value> {
let admin = user.is_admin();
let cfg = app.config();
let registry = app.registry();
let health = app.health.all();
let providers: serde_json::Map<String, Value> = cfg
.providers
.iter()
.filter(|(_, p)| p.enabled)
.map(|(name, _)| {
let models = registry.providers.get(name).map(|e| e.models.len()).unwrap_or(0);
let v = match health.get(name) {
Some(h) => json!({
"state": h.state,
"latency_ms": h.latency_ms,
"checked_at": h.checked_at,
"error": if admin { h.error.clone() } else { None },
"models": models,
}),
None => json!({"state": "unknown", "models": models}),
};
let mut v = v;
if let Some(limit) = cfg.providers.get(name).and_then(|p| p.plan_limit).filter(|l| *l > 0.0) {
v["plan"] = crate::budget::plan_usage(&app, name, limit).unwrap_or(Value::Null);
}
(name.clone(), v)
})
.collect();
let mcp: serde_json::Map<String, Value> = app
.mcp
.states()
.into_iter()
.filter(|(_, st)| !matches!(st, crate::mcp::McpState::Disabled))
.map(|(name, st)| {
let v = match st {
crate::mcp::McpState::Connected { tools } => json!({"state": "connected", "tools": tools}),
crate::mcp::McpState::Failed { error } => json!({"state": "failed", "error": if admin { Some(error) } else { None }}),
_ => json!({"state": "connecting"}),
};
(name, v)
})
.collect();
// Budgets and plan windows (admins: all; plan windows are shown per provider anyway).
let budgets = if admin { crate::budget::meters(&app).unwrap_or_default() } else { vec![] };
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp, "budgets": budgets, "currency": cfg.server.currency}))
}
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
Json(json!(app.health.all()))
}
#[derive(Deserialize)]
struct TestProviderBody {
/// Existing provider: its stored key is used when `api_key` is empty.
#[serde(default)]
name: Option<String>,
kind: String,
base_url: String,
#[serde(default)]
api_key: String,
#[serde(default)]
auth_prefix: Option<String>,
}
/// Check URL and key from the edit form without saving them.
async fn test_provider(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<TestProviderBody>) -> ApiResult {
require_admin(&user)?;
let stored = body.name.as_ref().and_then(|n| app.config().providers.get(n).cloned());
let mut value = stored.as_ref().map(|p| serde_json::to_value(p)).transpose()?.unwrap_or_else(|| json!({}));
value["kind"] = json!(body.kind);
value["base_url"] = json!(body.base_url);
if !body.api_key.is_empty() {
value["api_key"] = json!(body.api_key);
}
if let Some(prefix) = body.auth_prefix {
value["auth_prefix"] = if prefix.is_empty() { Value::Null } else { json!(prefix) };
}
let pc: crate::config::ProviderConfig = serde_json::from_value(value).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("invalid provider: {e}")))?;
let provider = crate::provider::build(crate::provider::http_client(), &pc);
let (status, models) = crate::health::probe(provider).await;
Ok(Json(json!({"status": status, "models": models})))
}
async fn recheck_providers(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
crate::health::check_all(&app).await;
@@ -1220,6 +1543,7 @@ async fn create_images(Extension(user): Extension<User>, State(app): State<AppSt
dir,
user_id: Some(user.id.clone()),
session_id: None,
references: vec![],
},
)
.await?;
@@ -1337,10 +1661,16 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
#[derive(Deserialize)]
struct UpdateJob {
enabled: Option<bool>,
/// Written into the job file's frontmatter.
permission_mode: Option<String>,
}
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
let job = own_job(&app, &user, &id)?;
if let Some(m) = b.permission_mode.as_deref() {
crate::jobs::set_permission_mode(std::path::Path::new(&job.path), m).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "job.permission_mode", &job.path, m);
}
if let Some(e) = b.enabled {
app.store.job_set_enabled(&job.id, e)?;
}
@@ -1433,20 +1763,35 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
// --- web terminal ------------------------------------------------------------------
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
Ok(Json(json!(app.terminals.list(&user.id))))
#[derive(Deserialize)]
struct TerminalQuery {
session: Option<String>,
}
/// All terminals of the user, or the tabs of one chat (?session=…).
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<TerminalQuery>) -> ApiResult {
Ok(Json(json!(match q.session {
Some(s) => app.terminals.for_session(&s, &user.id),
None => app.terminals.list(&user.id),
})))
}
#[derive(Deserialize)]
struct OpenTerminal {
cwd: Option<String>,
/// Open as a tab of this chat (in its folder unless `cwd` is given).
session_id: Option<String>,
cols: Option<u16>,
rows: Option<u16>,
}
async fn open_terminal(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<OpenTerminal>) -> ApiResult {
let cwd = validate_cwd(&app, &user, b.cwd)?;
let term = crate::terminal::spawn(&app, &user, &cwd, b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let session = match &b.session_id {
Some(id) => Some(writable(&app, &user, id)?),
None => None,
};
let cwd = validate_cwd(&app, &user, b.cwd.or_else(|| session.as_ref().map(|s| s.cwd.clone())))?;
let term = crate::terminal::spawn(&app, &user, &cwd, session.map(|s| s.id), b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(&user), "terminal.open", cwd.display().to_string(), &crate::auth::client_ip(&headers));
Ok(Json(term.info()))
}
+52 -3
View File
@@ -46,6 +46,8 @@ pub struct Runtime {
}
pub struct App {
/// Serializes read-modify-write of sessions (see `update_session`).
session_write: Mutex<()>,
config_path: PathBuf,
config: RwLock<Arc<Config>>,
registry: RwLock<Arc<Registry>>,
@@ -101,6 +103,7 @@ impl App {
let registry = Registry::build(&cfg).await;
let (events, _) = broadcast::channel(4096);
let app = Arc::new(Self {
session_write: Mutex::new(()),
config_path,
config: RwLock::new(Arc::new(cfg)),
registry: RwLock::new(Arc::new(registry)),
@@ -128,9 +131,32 @@ impl App {
});
crate::auth::bootstrap(&app)?;
app.clone().start_mcp();
app.clone().start_image_migration();
Ok(app)
}
/// Move inline images of older chats to the image store, drop unused images and
/// shrink the database file (in the background; chats load meanwhile as before).
fn start_image_migration(self: Arc<Self>) {
tokio::task::spawn_blocking(move || {
match self.store.migrate_inline_images() {
Ok(0) => {}
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
}
match self.store.gc_blobs() {
Ok(0) => {}
Ok(n) => tracing::info!("removed {n} unused images"),
Err(e) => tracing::warn!("image cleanup: {e:#}"),
}
match self.store.vacuum_if_worthwhile() {
Ok(true) => tracing::info!("database compacted"),
Ok(false) => {}
Err(e) => tracing::warn!("database compaction: {e:#}"),
}
});
}
fn start_mcp(self: Arc<Self>) {
let servers = self.config().mcp.clone();
let mcp = self.mcp.clone();
@@ -247,11 +273,16 @@ impl App {
std::fs::rename(&tmp, &self.config_path)?;
let registry = Registry::build(&cfg).await;
let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?;
let providers_changed = serde_json::to_value(&cfg.providers)? != serde_json::to_value(&self.config().providers)?;
*self.config.write().unwrap() = Arc::new(cfg);
*self.registry.write().unwrap() = Arc::new(registry);
if mcp_changed {
self.clone().start_mcp();
}
// A new key or URL should show its status right away, not at the next 5-minute check.
if providers_changed {
crate::health::check_all(self).await;
}
Ok(())
}
@@ -263,6 +294,7 @@ impl App {
pub async fn reload_providers(&self) {
let registry = Registry::build(&self.config()).await;
*self.registry.write().unwrap() = Arc::new(registry);
crate::health::check_all(self).await;
}
// --- events ---------------------------------------------------------
@@ -320,10 +352,27 @@ impl App {
Ok(s)
}
/// Load, change and save a session atomically: background tasks (title, usage, phase,
/// todos) change different fields at the same time and must not undo each other.
pub fn update_session(&self, id: &str, f: impl FnOnce(&mut Session)) -> Result<Session> {
self.modify_session(id, true, f)
}
/// Like `update_session`; `touch = false` keeps `updated_at` (organising the list).
pub fn modify_session(&self, id: &str, touch: bool, f: impl FnOnce(&mut Session)) -> Result<Session> {
let _guard = self.session_write.lock().unwrap();
let mut s = self.session(id)?;
f(&mut s);
if touch {
return self.save_session(s);
}
self.store.save_session(&s)?;
self.emit(Event::SessionUpdated { session: s.clone() });
Ok(s)
}
pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> {
let mut s = self.session(session_id)?;
s.todos = todos;
self.save_session(s)?;
self.update_session(session_id, |s| s.todos = todos)?;
Ok(())
}
+63 -1
View File
@@ -56,6 +56,56 @@ pub async fn create_zip(app: &App) -> Result<Vec<u8>> {
.await?
}
/// Directory a restore is staged in; applied at the next start (see `apply_pending_restore`).
fn pending_dir(data_dir: &std::path::Path) -> PathBuf {
data_dir.join("restore-pending")
}
/// Read config.toml and codecraft.db from a backup zip and check both.
fn unpack(bytes: &[u8]) -> Result<(String, Vec<u8>)> {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).context("not a zip file")?;
let mut config = String::new();
z.by_name("config.toml").context("config.toml missing in the backup")?.read_to_string(&mut config)?;
let mut db = Vec::new();
z.by_name("codecraft.db").context("codecraft.db missing in the backup")?.read_to_end(&mut db)?;
if !db.starts_with(b"SQLite format 3\0") {
bail!("codecraft.db in the backup is not an SQLite database");
}
crate::config::Config::parse(&config).context("config.toml in the backup is invalid")?;
Ok((config, db))
}
/// Check a backup, back up the current state, and stage the backup for the next start.
pub async fn stage_restore(app: &App, bytes: Vec<u8>) -> Result<()> {
let (config, db) = tokio::task::spawn_blocking(move || unpack(&bytes)).await??;
// Safety net: the current state as a normal backup file.
run_named(app, "-before-restore").await.context("could not back up the current state first")?;
let dir = pending_dir(&app.config().data_dir());
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await?;
tokio::fs::write(dir.join("codecraft.db"), db).await?;
tokio::fs::write(dir.join("config.toml"), config).await?;
Ok(())
}
/// Called at startup before the database is opened: move a staged restore into place.
pub fn apply_pending_restore(data_dir: &std::path::Path, config_path: &std::path::Path) -> Result<bool> {
let dir = pending_dir(data_dir);
let (db, cfg) = (dir.join("codecraft.db"), dir.join("config.toml"));
if !db.is_file() || !cfg.is_file() {
return Ok(false);
}
let target = data_dir.join("codecraft.db");
for ext in ["db-wal", "db-shm"] {
let _ = std::fs::remove_file(target.with_extension(ext));
}
std::fs::rename(&db, &target)?;
std::fs::copy(&cfg, config_path)?;
std::fs::remove_dir_all(&dir)?;
Ok(true)
}
pub fn backup_dir(app: &App) -> PathBuf {
let cfg = app.config();
if cfg.backup.dir.is_empty() { cfg.data_dir().join("backups") } else { PathBuf::from(expand_env(&cfg.backup.dir)) }
@@ -89,12 +139,24 @@ pub fn list(app: &App) -> Vec<BackupFile> {
/// Create a backup now: local file (+ pruning) and S3 upload if configured.
pub async fn run(app: &App) -> Result<LastRun> {
run_named(app, "").await
}
/// Like `run`, with a suffix in the file name (e.g. "-before-restore").
pub async fn run_named(app: &App, suffix: &str) -> Result<LastRun> {
let _guard = app.backup.running.lock().await;
let cfg = app.config().backup.clone();
let dir = backup_dir(app);
tokio::fs::create_dir_all(&dir).await.with_context(|| format!("create {}", dir.display()))?;
let bytes = create_zip(app).await?;
let name = format!("codecraft-backup-{}.zip", Local::now().format("%Y%m%d-%H%M%S"));
// Names have second resolution: never overwrite a backup made in the same second.
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let mut name = format!("codecraft-backup-{stamp}{suffix}.zip");
let mut n = 1;
while dir.join(&name).exists() {
n += 1;
name = format!("codecraft-backup-{stamp}{suffix}-{n}.zip");
}
let path = dir.join(&name);
tokio::fs::write(&path, &bytes).await.with_context(|| format!("write {}", path.display()))?;
// Restrictive permissions: the zip contains the config with API keys.
+134
View File
@@ -0,0 +1,134 @@
//! Images of chat messages are stored in the `blobs` table instead of as base64 inside the
//! message JSON. A chat then loads without its images; the UI fetches them on demand from
//! /api/blobs/<hash> (cached by the browser), and the runner puts the bytes back in before
//! a model request. Large PNGs without transparency are stored as JPEG.
use crate::types::{ImageData, Message, Part};
use base64::Engine;
use sha2::{Digest, Sha256};
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
/// PNGs above this size are re-encoded as JPEG when that is smaller and loses no transparency.
const RECOMPRESS_ABOVE: usize = 100 * 1024;
const JPEG_QUALITY: u8 = 85;
/// An image to put into the store.
pub struct Blob {
pub hash: String,
pub media_type: String,
pub bytes: Vec<u8>,
}
/// Call `f` for every image of a message (pasted images and tool results).
pub fn for_each_image(m: &mut Message, mut f: impl FnMut(&mut ImageData)) {
for part in &mut m.parts {
match part {
Part::Image { image } => f(image),
Part::ToolResult { images, .. } => images.iter_mut().for_each(&mut f),
_ => {}
}
}
}
/// Move the inline images of `m` out: each becomes a reference (`blob`, `size`, no
/// `data`). Returns the blobs to store. Images that already are references stay as they are.
pub fn externalize(m: &mut Message) -> Vec<Blob> {
let mut out = Vec::new();
for_each_image(m, |img| {
if img.data.is_empty() {
return;
}
let Ok(raw) = B64.decode(img.data.as_bytes()) else { return };
let (media_type, bytes) = compact(&img.media_type, raw);
let hash = hash(&bytes);
img.media_type = media_type.clone();
img.data.clear();
img.blob = Some(hash.clone());
img.size = Some(bytes.len() as u64);
out.push(Blob { hash, media_type, bytes });
});
out
}
/// Whether `s` looks like a blob hash (64 lowercase hex digits).
pub fn is_hash(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn hash(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// Re-encode a large opaque PNG as JPEG if that is smaller; anything else is kept.
fn compact(media_type: &str, raw: Vec<u8>) -> (String, Vec<u8>) {
if media_type != "image/png" || raw.len() <= RECOMPRESS_ABOVE {
return (media_type.to_string(), raw);
}
let Ok(img) = image::load_from_memory_with_format(&raw, image::ImageFormat::Png) else {
return (media_type.to_string(), raw);
};
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p.0[3] != 255) {
return (media_type.to_string(), raw);
}
let mut jpeg = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, JPEG_QUALITY);
if img.to_rgb8().write_with_encoder(enc).is_err() || jpeg.len() >= raw.len() {
return (media_type.to_string(), raw);
}
("image/jpeg".to_string(), jpeg)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Role;
fn png(w: u32, h: u32, alpha: u8) -> Vec<u8> {
// Noise, so the PNG is large and JPEG wins.
let img = image::RgbaImage::from_fn(w, h, |x, y| {
let v = (x.wrapping_mul(7919) ^ y.wrapping_mul(104_729)).wrapping_mul(2_654_435_761) >> 24;
image::Rgba([v as u8, (v >> 1) as u8, (v >> 2) as u8, alpha])
});
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
buf
}
fn message(img: ImageData) -> Message {
let mut m = Message { id: "m1".into(), role: Role::User, parts: vec![], model: None, usage: None, created_at: 0 };
m.parts.push(Part::ToolResult { call_id: "c".into(), name: "browser".into(), content: String::new(), images: vec![img], is_error: false, meta: None });
m
}
#[test]
fn large_opaque_png_becomes_a_jpeg_reference() {
let raw = png(400, 400, 255);
let mut m = message(ImageData::new("image/png", B64.encode(&raw)));
let blobs = externalize(&mut m);
assert_eq!(blobs.len(), 1);
assert_eq!(blobs[0].media_type, "image/jpeg");
assert!(blobs[0].bytes.len() < raw.len());
let Part::ToolResult { images, .. } = &m.parts[0] else { panic!() };
assert!(images[0].data.is_empty());
assert_eq!(images[0].blob.as_deref(), Some(blobs[0].hash.as_str()));
assert_eq!(images[0].size, Some(blobs[0].bytes.len() as u64));
assert!(is_hash(&blobs[0].hash));
// Already a reference: nothing to do.
assert!(externalize(&mut m).is_empty());
}
#[test]
fn transparent_and_small_pngs_stay_png() {
let mut m = message(ImageData::new("image/png", B64.encode(png(400, 400, 128))));
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
let mut m = message(ImageData::new("image/png", B64.encode(png(8, 8, 255))));
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
}
#[test]
fn serialized_reference_has_no_data() {
let img = ImageData { media_type: "image/jpeg".into(), data: String::new(), blob: Some("ab".into()), size: Some(3) };
assert_eq!(serde_json::to_value(&img).unwrap(), serde_json::json!({"media_type": "image/jpeg", "blob": "ab", "size": 3}));
}
}
+301 -2
View File
@@ -129,6 +129,146 @@ fn entry(name: &str, budget: Option<f64>, spent: f64, warn: f64) -> Value {
}
/// Budget overview for the current month.
/// Subscription windows of opencode Go, as share of the plan's monthly limit: rolling
/// 5 hours (20 %) and 7 days (50 %), and the calendar month in UTC (100 %). Only usage
/// through CodeCraft is counted.
pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result<Value> {
let now = chrono::Utc::now();
let month = chrono::Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0).single().map(|d| d.timestamp_millis()).unwrap_or(0);
let ms = now.timestamp_millis();
let window = |name: &str, from: i64, share: f64| -> Result<Value> {
let used = app.store.allowance_since(provider, from)?;
let cap = limit * share;
Ok(json!({"window": name, "used": used, "limit": cap, "ratio": if cap > 0.0 { used / cap } else { 0.0 }}))
};
Ok(json!({
"limit": limit,
"windows": [
window("5h", ms - 5 * 3_600_000, 0.2)?,
window("week", ms - 7 * 86_400_000, 0.5)?,
window("month", month, 1.0)?,
],
}))
}
/// One budget gauge: what is used of a limit (monthly budgets, Go plan windows).
#[derive(Debug, Clone, serde::Serialize)]
pub struct Meter {
/// Stable id for alert state, e.g. "provider:anthropic" or "plan:opencode-go:5h".
pub key: String,
pub provider: String,
/// "total", "provider", "images" or the plan window ("5h", "week", "month").
pub kind: String,
pub used: f64,
pub limit: f64,
pub ratio: f64,
}
/// All configured budgets and plan windows with their current use (no remote calls).
pub fn meters(app: &App) -> Result<Vec<Meter>> {
let cfg = app.config();
let spent = app.store.cost_by_provider(month_start())?;
let mut out = Vec::new();
let mut push = |key: String, provider: &str, kind: &str, used: f64, limit: f64| {
if limit > 0.0 {
out.push(Meter { key, provider: provider.into(), kind: kind.into(), used, limit, ratio: used / limit });
}
};
push("total".into(), "total", "total", spent.values().sum(), cfg.server.monthly_budget);
for (name, p) in cfg.providers.iter().filter(|(_, p)| p.enabled) {
push(format!("provider:{name}"), name, "provider", spent.get(name).copied().unwrap_or(0.0), p.monthly_budget.unwrap_or(0.0));
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
let plan = plan_usage(app, name, limit)?;
for w in plan["windows"].as_array().into_iter().flatten() {
let window = w["window"].as_str().unwrap_or("");
push(format!("plan:{name}:{window}"), name, window, w["used"].as_f64().unwrap_or(0.0), w["limit"].as_f64().unwrap_or(0.0));
}
}
}
if cfg.images.enabled {
push("images".into(), "fal", "images", spent.get("fal").copied().unwrap_or(0.0), cfg.images.monthly_budget.unwrap_or(0.0));
}
Ok(out)
}
const ALERT_STATE: &str = "budget_alert_state";
/// Alert state is global; it is kept under a pseudo user id.
const SYSTEM: &str = "_system";
fn alert_text(m: &Meter, level: u8, de: bool) -> String {
let pct = (m.ratio * 100.0).round() as i64;
let what = match (m.kind.as_str(), de) {
("total", true) => "Gesamtbudget des Monats".to_string(),
("total", false) => "Monthly total budget".to_string(),
("provider", true) => format!("Monatsbudget {}", m.provider),
("provider", false) => format!("Monthly budget of {}", m.provider),
("images", true) => "Bildbudget des Monats".to_string(),
("images", false) => "Monthly image budget".to_string(),
("5h", true) => format!("{}: 5-Stunden-Limit", m.provider),
("5h", false) => format!("{}: 5-hour limit", m.provider),
("week", true) => format!("{}: Wochenlimit", m.provider),
("week", false) => format!("{}: weekly limit", m.provider),
(_, true) => format!("{}: Monatslimit des Plans", m.provider),
(_, false) => format!("{}: monthly plan limit", m.provider),
};
match (level, de) {
(2, true) => format!("{what} ausgeschöpft ({pct} %)"),
(2, false) => format!("{what} used up ({pct} %)"),
(_, true) => format!("{what} zu {pct} % verbraucht"),
(_, false) => format!("{what} {pct} % used"),
}
}
/// Push admins when a budget or plan window crosses the warning share or 100 %. Each
/// level is sent once; dropping below (new month, rolling window) re-arms it.
pub async fn check_alerts(app: &App) -> Result<()> {
let warn = app.config().server.budget_warning;
let mut state: std::collections::BTreeMap<String, u8> = app.store.user_setting(SYSTEM, ALERT_STATE)?;
let mut alerts = Vec::new();
let meters = meters(app)?;
for m in &meters {
let level = if m.ratio >= 1.0 { 2 } else if warn > 0.0 && m.ratio >= warn { 1 } else { 0 };
let before = state.get(&m.key).copied().unwrap_or(0);
if level > before {
alerts.push((m.clone(), level));
}
state.insert(m.key.clone(), level);
}
state.retain(|k, _| meters.iter().any(|m| &m.key == k));
app.store.set_user_setting(SYSTEM, ALERT_STATE, &state)?;
if alerts.is_empty() {
return Ok(());
}
for user in app.store.users()?.into_iter().filter(|u| u.is_admin() && !u.disabled) {
let prefs: Value = app.store.user_setting(&user.id, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let body = alerts.iter().map(|(m, l)| alert_text(m, *l, de)).collect::<Vec<_>>().join("\n");
let used_up = alerts.iter().any(|(_, l)| *l == 2);
let title = match (used_up, de) {
(true, true) => "CodeCraft – Budget ausgeschöpft",
(true, false) => "CodeCraft – budget used up",
(false, true) => "CodeCraft – Budget fast verbraucht",
(false, false) => "CodeCraft – budget nearly used",
};
let payload = json!({"kind": "budget", "title": title, "body": body, "tag": "budget", "url": "/#/stats"});
let (sent, _) = crate::push::send_to_user(app, &user.id, &payload).await;
tracing::info!(user = %user.username, sent, alerts = alerts.len(), "budget alert");
}
Ok(())
}
pub fn spawn_alerts(app: std::sync::Arc<App>) {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(120)).await;
loop {
if let Err(e) = check_alerts(&app).await {
tracing::warn!("budget alerts: {e:#}");
}
tokio::time::sleep(Duration::from_secs(600)).await;
}
});
}
pub async fn overview(app: &App) -> Result<Value> {
let cfg = app.config();
let spent = app.store.cost_by_provider(month_start())?;
@@ -139,7 +279,11 @@ pub async fn overview(app: &App) -> Result<Value> {
if !p.enabled {
continue;
}
rows.push(entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn));
let mut row = entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn);
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
row["plan"] = plan_usage(app, name, limit)?;
}
rows.push(row);
let p = p.clone();
tasks.push(tokio::spawn(async move { remote_balance(&p).await }));
}
@@ -174,13 +318,138 @@ pub async fn overview(app: &App) -> Result<Value> {
}
/// Per-token prices from the provider's model list, converted to per 1M tokens.
fn is_opencode(p: &ProviderConfig) -> bool {
p.base_url.contains("opencode.ai")
}
async fn get_json(url: &str) -> Result<Value> {
Ok(crate::provider::http_client().get(url).timeout(Duration::from_secs(30)).send().await?.error_for_status()?.json().await?)
}
/// One opencode condition object → (days, hours, from, until); None if it uses
/// something we do not understand.
type When = (Vec<String>, Vec<(String, String)>, Option<String>, Option<String>);
fn opencode_when(w: &Value) -> Option<When> {
let obj = w.as_object()?;
let (mut days, mut hours, mut from, mut until) = (Vec::new(), Vec::new(), None, None);
// A pair of utc_hours bounds, given as {"all": [{"utc_hours": {"gte"}}, {"utc_hours": {"lt"}}]}
// or directly as {"utc_hours": {"gte", "lt"}}.
let range = |v: &Value| -> Option<(String, String)> {
if let Some(h) = v.get("utc_hours") {
return Some((h["gte"].as_str()?.to_string(), h["lt"].as_str()?.to_string()));
}
let all = v.get("all")?.as_array()?;
let get = |k: &str| all.iter().find_map(|x| x["utc_hours"][k].as_str()).map(String::from);
Some((get("gte")?, get("lt")?))
};
for (k, v) in obj {
match k.as_str() {
"utc_days" => days = v["in"].as_array()?.iter().map(|d| d.as_str().map(String::from)).collect::<Option<_>>()?,
"any" => hours = v.as_array()?.iter().map(range).collect::<Option<_>>()?,
"all" | "utc_hours" => hours = vec![range(w)?],
"date" => {
from = v["gte"].as_str().map(String::from);
until = v["lt"].as_str().map(String::from);
if from.is_none() && until.is_none() {
return None;
}
}
_ => return None,
}
}
Some((days, hours, from, until))
}
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
fn opencode_model(m: &Value) -> Value {
let cost = &m["cost"]["text"];
let (input, output) = (&cost["input"], &cost["output"]);
// Context tiers: input and output overrides share their thresholds.
let mut tiers: std::collections::BTreeMap<u64, serde_json::Map<String, Value>> = Default::default();
for (side, field) in [(input, "input_price"), (output, "output_price")] {
for o in side["overrides"].as_array().into_iter().flatten() {
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
let t = tiers.entry(gt).or_default();
t.insert(field.into(), o["price"].clone());
if field == "input_price" {
if o["cache"]["read"].is_number() {
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
}
if o["cache"]["write"].is_number() {
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
}
}
}
}
let price_tiers: Vec<Value> = tiers.into_iter().map(|(gt, mut t)| {
t.insert("above_context".into(), json!(gt));
Value::Object(t)
}).collect();
let mut notes = Vec::new();
let price_multipliers: Vec<Value> = m["cost"]["multipliers"]
.as_array()
.into_iter()
.flatten()
.filter_map(|x| match opencode_when(&x["when"]) {
Some((days, hours, from, until)) => Some(json!({"factor": x["factor"], "days": days, "hours_utc": hours, "from": from, "until": until})),
None => {
notes.push(format!("unsupported price condition: {}", x["when"]));
None
}
})
.map(|mut v| {
// Drop empty conditions so the config stays short.
v.as_object_mut().unwrap().retain(|_, x| !(x.is_null() || x.as_array().is_some_and(|a| a.is_empty())));
v
})
.collect();
let images = m["modalities"]["input"].as_array().is_some_and(|a| a.iter().any(|x| x.as_str().is_some_and(|s| s.starts_with("image"))));
json!({
"id": m["id"],
"context": m["limit"]["context"],
"max_output": m["limit"]["output"],
"vision": images,
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
"input_price": input["price"],
"output_price": output["price"],
"cache_read_price": input["cache"]["read"],
"cache_write_price": input["cache"]["write"],
"price_tiers": price_tiers,
"price_multipliers": price_multipliers,
"notes": notes,
})
}
/// opencode (Zen and Go) publishes prices in its model list. The Anthropic-protocol
/// endpoint lists models without them, so fall back to the gateway's own /v1/models.
async fn opencode_prices(base: &str) -> Result<Value> {
let mut urls = vec![format!("{base}/models")];
for suffix in ["/openai/v1", "/anthropic/v1"] {
if let Some(root) = base.strip_suffix(suffix) {
urls.push(format!("{root}/v1/models"));
}
}
for url in &urls {
let Ok(v) = get_json(url).await else { continue };
let models: Vec<Value> = v["data"].as_array().into_iter().flatten().filter(|m| m.get("cost").is_some()).map(opencode_model).collect();
if !models.is_empty() {
return Ok(json!({"currency": "USD", "models": models}));
}
}
bail!("opencode did not return prices for this endpoint")
}
pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
let cfg = app.config();
let p = cfg.providers.get(provider).context("unknown provider")?;
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
if is_opencode(p) {
return opencode_prices(&base).await;
}
if !is_openrouter(p) {
bail!("this provider does not publish prices via its API – enter them manually");
}
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
let v: Value = crate::provider::http_client()
.get(format!("{base}/models"))
.timeout(Duration::from_secs(30))
@@ -211,6 +480,36 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
#[cfg(test)]
mod tests {
#[test]
fn opencode_cost_conversion() {
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
"modalities":{"input":["text","image/png"]},"capabilities":{"reasoning":{"supported":true}},
"cost":{"text":{"input":{"price":2,"cache":{"read":0.5},"overrides":[{"when":{"context":{"gt":200000}},"price":4,"cache":{"read":1}}]},
"output":{"price":6,"overrides":[{"when":{"context":{"gt":200000}},"price":12}]}},
"multipliers":[{"when":{"utc_days":{"in":["mon","tue"]},"any":[{"all":[{"utc_hours":{"gte":"01:00"}},{"utc_hours":{"lt":"04:00"}}]}]},"factor":2},
{"when":{"date":{"gte":"2026-12-01"}},"factor":2},
{"when":{"region":"eu"},"factor":3}]}}"#).unwrap();
let r = super::opencode_model(&m);
assert_eq!(r["input_price"], 2);
assert_eq!(r["cache_read_price"], 0.5);
assert_eq!(r["vision"], true);
assert_eq!(r["price_tiers"][0], serde_json::json!({"above_context": 200000, "input_price": 4, "cache_read_price": 1, "output_price": 12}));
assert_eq!(r["price_multipliers"][0], serde_json::json!({"factor": 2, "days": ["mon", "tue"], "hours_utc": [["01:00", "04:00"]]}));
assert_eq!(r["price_multipliers"][1], serde_json::json!({"factor": 2, "from": "2026-12-01"}));
assert_eq!(r["notes"].as_array().unwrap().len(), 1, "unknown condition is reported, not guessed");
// The result must deserialize into the model config.
let mc: crate::config::ModelConfig = serde_json::from_value(serde_json::json!({"id": "grok", "price_tiers": r["price_tiers"], "price_multipliers": r["price_multipliers"]})).unwrap();
assert_eq!(mc.price_tiers[0].above_context, 200000);
}
#[test]
fn alert_texts() {
let m = super::Meter { key: "plan:go:5h".into(), provider: "opencode-go".into(), kind: "5h".into(), used: 1.7, limit: 2.0, ratio: 0.85 };
assert_eq!(super::alert_text(&m, 1, true), "opencode-go: 5-Stunden-Limit zu 85 % verbraucht");
let t = super::Meter { key: "total".into(), provider: "total".into(), kind: "total".into(), used: 10.0, limit: 10.0, ratio: 1.0 };
assert_eq!(super::alert_text(&t, 2, false), "Monthly total budget used up (100 %)");
}
#[test]
fn budget_entry_flags() {
let e = super::entry("x", Some(10.0), 8.5, 0.8);
+4 -3
View File
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let cfg = app.config();
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
let agents: Vec<Value> = crate::prompts::agents(&app, &root, &root)
let agents: Vec<Value> = crate::prompts::agents_for(&app, &root, &root, Some(&user.id))
.into_iter()
.map(|a| {
let mut v = json!(a);
@@ -47,7 +47,7 @@ pub async fn config(Extension(user): Extension<User>, State(app): State<AppState
let memory: String = app.store.user_setting(&user.id, "memory")?;
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
Ok(Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"version": codecraft_core::version::VERSION,
"user": user,
"models": app.registry().models_json(),
"default_model": default_model,
@@ -115,8 +115,8 @@ pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppS
let provider_name = b.model.split_once('/').map(|(p, _)| p.to_string()).unwrap_or_default();
crate::budget::check(&app, &provider_name, Some(&user.id)).map_err(|e| ApiError(StatusCode::PAYMENT_REQUIRED, format!("{e:#}")))?;
let max_tokens = b.max_tokens.or(model.max_output).unwrap_or(16_000);
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning };
let session = format!("cli:{}", b.session.unwrap_or_else(|| "default".into()));
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning, session_id: Some(session.clone()) };
let agent = format!("cli:{}", b.agent.unwrap_or_else(|| "build".into()));
let spec = b.model.clone();
let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
@@ -175,6 +175,7 @@ pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppS
cost: model.cost(&usage),
error,
user_id: Some(user.id.clone()),
allowance: usage.allowance,
});
});
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
+17 -1
View File
@@ -193,6 +193,15 @@ pub struct ImageModel {
/// Extra request fields sent as-is.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub options: serde_json::Map<String, serde_json::Value>,
/// "preview" (cheap drafts) or "final" (best quality). Known fal.ai models have defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage: Option<String>,
/// What the model is good at: photo, illustration, text, design.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub good_for: Vec<String>,
/// Request field for reference images (e.g. "image_urls"); set for models that refine an image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_param: Option<String>,
}
impl Default for ImagesConfig {
@@ -376,6 +385,13 @@ pub struct AgentConfig {
pub tools: Option<Vec<String>>,
#[serde(default)]
pub prompt: Option<String>,
/// Only read-only tools (like plan mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_only: Option<bool>,
/// "coding" (default: project context and coding instructions) or "chat" (plain
/// conversation with only this prompt, like the built-in chat mode).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
/// Max. instances of this subagent running at the same time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_parallel: Option<usize>,
@@ -630,7 +646,7 @@ mod tests {
)
.unwrap();
assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint."));
assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available"));
assert!(out.contains("enabled = true # enable once the key is set"));
let cfg = Config::parse(&out).unwrap();
assert!(cfg.providers["gemini"].enabled);
}
+1 -1
View File
@@ -129,7 +129,7 @@ async fn render_page(pdf: &str, page: u32) -> Result<ImageData> {
let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle);
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
Ok(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
Ok(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
}
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
+1 -1
View File
@@ -53,7 +53,7 @@ pub fn markdown(s: &Session, messages: &[Message]) -> String {
for p in &m.parts {
match p {
Part::Text { text } => out.push_str(&format!("{}\n\n", text.trim())),
Part::Summary { text } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
Part::Summary { text, .. } => out.push_str(&format!("<details><summary>Summary of the earlier conversation</summary>\n\n{}\n\n</details>\n\n", text.trim())),
Part::Reasoning { text, .. } if !text.trim().is_empty() => {
out.push_str(&format!("<details><summary>Thinking</summary>\n\n{}\n\n</details>\n\n", text.trim()))
}
+7 -2
View File
@@ -44,6 +44,8 @@ tokio::task_local! {
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
/// The user's git config (credentials, identity) for unsandboxed runs.
static GITCONFIG: Option<std::path::PathBuf>;
/// The user's commit identity (overrides a repository's own user.name/email).
static GITENV: Vec<(String, String)>;
}
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
@@ -63,6 +65,7 @@ async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default());
c
}
};
@@ -171,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);
SANDBOX.scope(spec, GITCONFIG.scope(cfg, handle_inner(user, app, b))).await
let env = crate::gitcreds::identity_env(&app, &user.id);
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
}
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
@@ -388,8 +392,9 @@ 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 s = session.clone();
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
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()) {
session.cwd = cwd.to_string();
}
+18
View File
@@ -35,6 +35,24 @@ pub struct GitSettings {
pub credentials: Vec<GitCredential>,
}
/// The user's commit identity as environment variables. They take precedence over a
/// repository's own `user.name`/`user.email`, so commits made by agents, terminals and
/// the git panel always carry the identity from the settings.
pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default();
let mut env = Vec::new();
let (name, email) = (s.name.trim(), s.email.trim());
if !name.is_empty() {
env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string()));
env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string()));
}
if !email.is_empty() {
env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string()));
env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string()));
}
env
}
pub fn home_of(app: &App, user: &User) -> PathBuf {
app.data_dir().join("users").join(&user.username).join("home")
}
+15 -12
View File
@@ -33,19 +33,14 @@ impl Health {
}
}
pub async fn check_all(app: &App) {
let registry = app.registry();
let mut found = BTreeMap::new();
let checks = registry.providers.iter().map(|(name, entry)| {
let provider = entry.provider.clone();
let name = name.clone();
async move {
/// Fetch the model list once and classify the answer.
pub async fn probe(provider: Arc<dyn crate::provider::Provider>) -> (ProviderStatus, usize) {
let start = Instant::now();
let res = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await;
let error: Option<String> = match res {
Ok(Ok(_)) => None,
Ok(Err(e)) => Some(e.to_string().chars().take(300).collect()),
Err(_) => Some("timed out".to_string()),
let (error, count): (Option<String>, usize) = match res {
Ok(Ok(m)) => (None, m.len()),
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
Err(_) => (Some("timed out".to_string()), 0),
};
// The host answered but has no model list: reachable.
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
@@ -54,8 +49,16 @@ pub async fn check_all(app: &App) {
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
Some(_) => "down",
};
(name, ProviderStatus { up: error.is_none(), state, error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() })
(ProviderStatus { up: error.is_none(), state, error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() }, count)
}
pub async fn check_all(app: &App) {
let registry = app.registry();
let mut found = BTreeMap::new();
let checks = registry.providers.iter().map(|(name, entry)| {
let provider = entry.provider.clone();
let name = name.clone();
async move { (name, probe(provider).await.0) }
});
for (name, st) in futures::future::join_all(checks).await {
if !st.up {
+180 -18
View File
@@ -34,6 +34,8 @@ pub struct GenerateRequest {
pub dir: PathBuf,
pub user_id: Option<String>,
pub session_id: Option<String>,
/// Images to refine (sent to models with a `reference_param`).
pub references: Vec<PathBuf>,
}
fn slug(s: &str) -> String {
@@ -47,14 +49,90 @@ fn slug(s: &str) -> String {
out.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string()
}
/// Purposes the agent can ask for; models list theirs in `good_for`.
pub const PURPOSES: &[&str] = &["photo", "illustration", "text", "design"];
/// Defaults for well-known fal.ai models: (id, stage, good_for, reference_param, size_param, price).
const KNOWN: &[(&str, &str, &[&str], Option<&str>, Option<&str>, f64)] = &[
("fal-ai/flux/schnell", "preview", &["photo", "illustration"], None, None, 0.003),
("fal-ai/nano-banana-pro", "final", &["photo", "illustration", "text"], None, Some("aspect_ratio"), 0.15),
("fal-ai/nano-banana-pro/edit", "final", &["photo", "illustration", "text", "design"], Some("image_urls"), Some("aspect_ratio"), 0.15),
("fal-ai/flux-2-pro", "final", &["photo"], None, None, 0.03),
("fal-ai/ideogram/v3", "final", &["text", "design"], None, Some("aspect_ratio"), 0.06),
("fal-ai/recraft/v4/pro/text-to-image", "final", &["design", "illustration"], None, None, 0.05),
];
/// A configured model with the known defaults filled in where the config leaves them out.
fn with_defaults(mut m: ImageModel) -> ImageModel {
if let Some((_, stage, good_for, reference, size, price)) = KNOWN.iter().find(|k| k.0 == m.id) {
m.stage.get_or_insert_with(|| stage.to_string());
if m.good_for.is_empty() {
m.good_for = good_for.iter().map(|s| s.to_string()).collect();
}
if m.reference_param.is_none() {
m.reference_param = reference.map(String::from);
}
if m.size_param.is_none() {
m.size_param = size.map(String::from);
}
m.price.get_or_insert(*price);
}
m
}
fn models(app: &App) -> Vec<ImageModel> {
app.config().images.models.iter().cloned().map(with_defaults).collect()
}
fn model_config(app: &App, id: &str) -> ImageModel {
app.config()
.images
.models
.iter()
.find(|m| m.id == id)
.cloned()
.unwrap_or_else(|| ImageModel { id: id.to_string(), ..Default::default() })
models(app).into_iter().find(|m| m.id == id).unwrap_or_else(|| with_defaults(ImageModel { id: id.to_string(), ..Default::default() }))
}
/// Pick the model for a request: an explicit model wins; previews use the first preview model
/// (else the default); final images use the best-matching final model, preferring one that can
/// take the reference image when there is one.
pub fn choose_model(app: &App, stage: &str, purpose: Option<&str>, with_reference: bool) -> ImageModel {
let default = app.config().images.default_model.clone();
pick(&models(app), stage, purpose, with_reference).unwrap_or_else(|| model_config(app, &default))
}
fn pick(all: &[ImageModel], stage: &str, purpose: Option<&str>, with_reference: bool) -> Option<ImageModel> {
if stage == "preview" && !with_reference {
return all.iter().find(|m| m.stage.as_deref() == Some("preview")).cloned();
}
let score = |m: &ImageModel| {
let mut s = 0;
if m.stage.as_deref() == Some("final") {
s += 4;
}
match (with_reference, m.reference_param.is_some()) {
(true, true) => s += 8,
// Without a reference, plain text-to-image endpoints come first.
(false, true) => s -= 1,
_ => {}
}
if purpose.is_some_and(|p| m.good_for.iter().any(|g| g == p)) {
s += 2;
}
s
};
// max_by_key keeps the last of equal scores; reverse so the config order wins ties.
all.iter().rev().max_by_key(|m| score(m)).cloned()
}
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
let mut out = Vec::new();
for p in paths {
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
let mime = match p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
_ => "image/png",
};
out.push(format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)));
}
Ok(out)
}
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
@@ -82,15 +160,22 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
crate::budget::check(app, "fal", req.user_id.as_deref())?;
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
let model = model_config(app, &model_id);
let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into());
let explicit_size = req.size.clone().filter(|s| SIZES.contains(&s.as_str()));
let size = explicit_size.clone().unwrap_or_else(|| "landscape_4_3".into());
let count = req.count.clamp(1, 4);
let mut body = json!({"prompt": req.prompt, "num_images": count, "output_format": "png"});
let refining = !req.references.is_empty() && model.reference_param.is_some();
match model.size_param.as_deref() {
// When refining without an explicit size, keep the reference's aspect ratio.
Some("aspect_ratio") if refining && explicit_size.is_none() => body["aspect_ratio"] = json!("auto"),
Some("aspect_ratio") => body["aspect_ratio"] = json!(aspect_ratio(&size)),
Some("none") => {}
_ => body["image_size"] = json!(size),
}
if refining && let Some(param) = &model.reference_param {
body[param.as_str()] = json!(reference_uris(&req.references).await?);
}
for (k, v) in &model.options {
body[k] = v.clone();
}
@@ -162,6 +247,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
cost,
error: false,
user_id: None,
allowance: None,
});
Ok(records)
}
@@ -172,7 +258,7 @@ fn preview_for_model(path: &Path) -> Option<ImageData> {
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
Some(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
}
pub struct GenerateImage;
@@ -184,9 +270,13 @@ impl Tool for GenerateImage {
}
fn description(&self) -> String {
"Generate images from a text prompt (fal.ai) and save them in the project. Use for \
illustrations, icons, backgrounds or placeholder photos the user needs. Write a detailed \
visual prompt in English (subject, style, composition, colours, lighting). Each image \
costs money – generate only what is needed. Returns the file paths and a preview."
illustrations, icons, backgrounds or photos the user needs. Write a detailed visual prompt \
in English (subject, style, composition, colours, lighting). The server picks the model. \
Work in two steps: first stage \"preview\" (cheap drafts, count 2-4); show them and ask the \
user with ask_user which one to refine – include the final model and price the preview \
result names. Then call again with stage \"final\", the chosen file in \"reference\" and \
the same or an improved prompt. Skip the preview only if the user asks for a final image \
directly. Each image costs money – generate only what is needed."
.into()
}
fn schema(&self) -> Value {
@@ -194,37 +284,55 @@ impl Tool for GenerateImage {
"type": "object",
"properties": {
"prompt": {"type": "string"},
"size": {"type": "string", "enum": SIZES},
"stage": {"type": "string", "enum": ["preview", "final"], "description": "preview = cheap drafts (default), final = best model for the purpose"},
"purpose": {"type": "string", "enum": PURPOSES, "description": "What the image is: photo (realistic), illustration, text (lettering, posters, signs), design (logos, icons, UI, vector style)"},
"reference": {"type": "array", "items": {"type": "string"}, "maxItems": 4, "description": "Image files (relative paths) to refine, e.g. the preview the user chose"},
"size": {"type": "string", "enum": SIZES, "description": "Default: landscape_4_3; when refining, the reference's aspect ratio"},
"count": {"type": "integer", "minimum": 1, "maximum": 4},
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: images.output_dir)"},
"model": {"type": "string", "description": "fal.ai model id (optional, default from the settings)"}
"model": {"type": "string", "description": "fal.ai model id – only if the user asks for a specific model"}
},
"required": ["prompt"]
})
}
fn title(&self, input: &Value) -> String {
format!("Generate image: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
let stage = if input["stage"].as_str() == Some("final") { "Generate final image" } else { "Generate image" };
format!("{stage}: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let prompt = arg_str(&input, "prompt")?.to_string();
let stage = if input["stage"].as_str() == Some("final") { "final" } else { "preview" };
let purpose = input["purpose"].as_str().filter(|p| PURPOSES.contains(p));
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| ctx.app.config().images.output_dir.clone());
let dir = ctx.resolve(&dir_arg)?;
let mut references = Vec::new();
for r in input["reference"].as_array().into_iter().flatten().filter_map(|v| v.as_str()).take(4) {
references.push(ctx.resolve(r)?);
}
let model = match input["model"].as_str().filter(|m| !m.is_empty()) {
Some(m) => model_config(&ctx.app, m),
None => choose_model(&ctx.app, stage, purpose, !references.is_empty()),
};
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
let refined = !references.is_empty() && model.reference_param.is_some();
let records = generate(
&ctx.app,
GenerateRequest {
prompt,
model: input["model"].as_str().map(String::from),
model: Some(model.id.clone()),
size: input["size"].as_str().map(String::from),
count: input["count"].as_u64().unwrap_or(1) as u32,
count: input["count"].as_u64().unwrap_or(if stage == "preview" { 2 } else { 1 }) as u32,
dir,
user_id: owner,
session_id: Some(ctx.session_id.clone()),
references,
},
)
.await
.map_err(|e| anyhow!("{e:#}"))?;
let mut text = String::from("Generated:\n");
let price = |m: &ImageModel| m.price.map(|p| format!(" (${p:.3} per image)")).unwrap_or_default();
let label = |m: &ImageModel| m.name.clone().unwrap_or_else(|| m.id.clone());
let mut text = format!("Model: {}{}\nGenerated:\n", label(&model), price(&model));
let mut images = Vec::new();
for r in &records {
let rel = Path::new(&r.path).strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| r.path.clone());
@@ -233,7 +341,61 @@ impl Tool for GenerateImage {
images.push(p);
}
}
if stage == "preview" && !refined {
let fin = choose_model(&ctx.app, "final", purpose, true);
if fin.id != model.id {
let how = if fin.reference_param.is_some() { "refines the chosen preview" } else { "generates it again from the prompt" };
text.push_str(&format!(
"\nThese are previews. Ask the user (ask_user) which one to refine. The final image would use {}{} and {how}.",
label(&fin),
price(&fin)
));
}
} else if !input["reference"].as_array().is_none_or(|a| a.is_empty()) && !refined {
text.push_str("\nNote: this model takes no reference image, so it was generated from the prompt only.");
}
let ids: Vec<&str> = records.iter().map(|r| r.id.as_str()).collect();
Ok(ToolOutput { content: text, images, meta: Some(json!({"image_ids": ids})), ..Default::default() })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn m(id: &str) -> ImageModel {
with_defaults(ImageModel { id: id.into(), ..Default::default() })
}
#[test]
fn picks_by_stage_purpose_and_reference() {
let all = vec![
m("fal-ai/flux/schnell"),
m("fal-ai/nano-banana-pro/edit"),
m("fal-ai/nano-banana-pro"),
m("fal-ai/flux-2-pro"),
m("fal-ai/ideogram/v3"),
m("fal-ai/recraft/v4/pro/text-to-image"),
];
let id = |stage, purpose, r| pick(&all, stage, purpose, r).unwrap().id;
assert_eq!(id("preview", Some("photo"), false), "fal-ai/flux/schnell");
// Refining a preview: the edit model, whatever the purpose.
assert_eq!(id("final", Some("photo"), true), "fal-ai/nano-banana-pro/edit");
assert_eq!(id("final", Some("design"), true), "fal-ai/nano-banana-pro/edit");
// From the prompt only: the first fitting text-to-image model (config order).
assert_eq!(id("final", Some("photo"), false), "fal-ai/nano-banana-pro");
assert_eq!(id("final", Some("text"), false), "fal-ai/nano-banana-pro");
assert_eq!(id("final", Some("design"), false), "fal-ai/ideogram/v3");
let no_edit: Vec<_> = all.iter().filter(|m| m.reference_param.is_none()).cloned().collect();
assert_eq!(pick(&no_edit, "final", Some("design"), true).unwrap().id, "fal-ai/ideogram/v3");
assert_eq!(pick(&no_edit, "final", Some("photo"), false).unwrap().id, "fal-ai/nano-banana-pro");
}
#[test]
fn config_overrides_known_defaults() {
let custom = with_defaults(ImageModel { id: "fal-ai/flux/schnell".into(), stage: Some("final".into()), price: Some(0.01), ..Default::default() });
assert_eq!(custom.stage.as_deref(), Some("final"));
assert_eq!(custom.price, Some(0.01));
assert!(m("some/unknown-model").stage.is_none());
}
}
+52
View File
@@ -164,6 +164,9 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
pinned: false,
archived: false,
folder: None,
instructions: String::new(),
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
telegram_quiet: true,
};
let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?;
@@ -283,6 +286,49 @@ pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
}
}
/// Set `permission_mode` in the frontmatter of a job file (replaces the line or adds it
/// before the closing `---`); the rest of the file stays as it is.
pub fn set_permission_mode(path: &Path, mode: &str) -> Result<JobSpec> {
if !crate::permission::MODES.contains(&mode) {
bail!("invalid permission_mode '{mode}'");
}
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let updated = set_frontmatter_key(&text, "permission_mode", mode).ok_or_else(|| anyhow!("{} has no frontmatter", path.display()))?;
std::fs::write(path, &updated)?;
match parse(path) {
Ok(spec) => Ok(spec),
Err(e) => {
let _ = std::fs::write(path, &text);
Err(e)
}
}
}
fn set_frontmatter_key(text: &str, key: &str, value: &str) -> Option<String> {
let bom = if text.starts_with('\u{feff}') { "\u{feff}" } else { "" };
let t = &text[bom.len()..];
let rest = t.strip_prefix("---")?;
let end = rest.find("\n---")?;
let (head, tail) = (&rest[..end], &rest[end..]);
let prefix = format!("{key}:");
let mut found = false;
let mut lines: Vec<String> = head
.split('\n')
.map(|l| {
if !found && l.trim_start().starts_with(&prefix) {
found = true;
format!("{key}: {value}")
} else {
l.to_string()
}
})
.collect();
if !found {
lines.push(format!("{key}: {value}"));
}
Some(format!("{bom}---{}{tail}", lines.join("\n")))
}
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
pub fn new_job_path(project: &Path, name: &str) -> PathBuf {
let dir = project.join(".codecraft/jobs");
@@ -445,6 +491,12 @@ mod tests {
let s = parse(&p).unwrap();
assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto"));
assert_eq!(job_cwd(&p, &s), dir);
// Added when missing, replaced when present; comments and the prompt stay.
assert_eq!(set_permission_mode(&p, "bypass").unwrap().permission_mode, "bypass");
std::fs::write(&p, "---\nname: N\npermission_mode: auto # comment\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n").unwrap();
assert_eq!(set_permission_mode(&p, "accept_edits").unwrap().permission_mode, "accept_edits");
assert_eq!(std::fs::read_to_string(&p).unwrap(), "---\nname: N\npermission_mode: accept_edits\nschedule: \"0 3 * * *\"\n---\nPrompt\n---\nmore\n");
assert!(set_permission_mode(&p, "yolo").is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}
+10 -1
View File
@@ -4,6 +4,7 @@ mod api;
mod app;
mod auth;
mod backup;
mod blobs;
mod cli_api;
mod budget;
mod config;
@@ -30,6 +31,7 @@ mod runner;
mod sandbox;
mod shells;
mod store;
mod telegram;
mod terminal;
mod tools;
mod types;
@@ -54,7 +56,12 @@ async fn main() -> Result<()> {
}
let path = config::config_path();
let (cfg, _) = config::load_or_init(&path)?;
let (mut cfg, _) = config::load_or_init(&path)?;
// A backup restored from the settings is applied here, before the database is opened.
if backup::apply_pending_restore(&cfg.data_dir(), &path)? {
tracing::warn!("restored database and config from a backup");
cfg = config::load_or_init(&path)?.0;
}
tracing::info!("config: {}", path.display());
tracing::info!("workspace: {}", cfg.workspace().display());
let _ = sandbox::probe().await;
@@ -67,6 +74,8 @@ async fn main() -> Result<()> {
update::spawn_checker(app.clone());
health::spawn_checker(app.clone());
digest::spawn(app.clone());
budget::spawn_alerts(app.clone());
telegram::spawn(app.clone());
backup::spawn(app.clone());
let router = api::router(app);
let listener = tokio::net::TcpListener::bind(&bind).await?;
+8 -1
View File
@@ -160,7 +160,7 @@ impl McpServer {
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"elicitation": {}, "roots": {"listChanged": false}},
"clientInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")}
"clientInfo": {"name": "codecraft", "version": codecraft_core::version::VERSION}
}),
)
.await?;
@@ -212,6 +212,8 @@ impl McpServer {
match c["type"].as_str().unwrap_or_default() {
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
"image" => out.images.push(ImageData {
blob: None,
size: None,
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
data: c["data"].as_str().unwrap_or_default().into(),
}),
@@ -452,6 +454,11 @@ pub struct McpManager {
}
impl McpManager {
/// Connection state per server (for the status overlay).
pub fn states(&self) -> BTreeMap<String, McpState> {
self.states.lock().unwrap().clone()
}
pub fn status_json(&self) -> Value {
let states = self.states.lock().unwrap();
let servers = self.servers.lock().unwrap();
+74 -9
View File
@@ -23,6 +23,8 @@ pub struct Agent {
/// Allowed tools; None = all.
pub tools: Option<Vec<String>>,
pub read_only: bool,
/// "coding" or "chat" (plain conversation prompt).
pub style: String,
pub model: Option<String>,
/// "builtin", "config" or "file".
pub source: String,
@@ -41,11 +43,12 @@ pub use codecraft_core::prompts::*;
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
let craft = craft_prompt(clean_room);
vec![
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
]
}
@@ -113,7 +116,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
prompt: body,
tools,
read_only: false,
read_only: meta.get("read_only").is_some_and(|v| v == "true"),
style: meta.get("style").filter(|v| *v == "chat").cloned().unwrap_or_else(|| "coding".into()),
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
source: "file".into(),
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
@@ -131,7 +135,8 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
read_only: base.as_ref().is_some_and(|b| b.read_only),
read_only: a.read_only.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.read_only)),
style: a.style.clone().filter(|s| s == "chat" || s == "coding").or(base.as_ref().map(|b| b.style.clone())).unwrap_or_else(|| "coding".into()),
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
source: "config".into(),
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
@@ -142,8 +147,40 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
map.into_values().collect()
}
pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent {
agents(app, root, cwd)
/// Personal modes a user created for themselves (user setting "modes").
pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap<String, crate::config::AgentConfig> {
app.store.user_setting(user_id, "modes").unwrap_or_default()
}
/// All agents plus the user's personal modes. Personal modes never replace built-in,
/// admin or project ones of the same name.
pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> Vec<Agent> {
let mut list = agents(app, root, cwd);
let Some(uid) = user_id else { return list };
for (name, a) in personal_modes(app, uid) {
if list.iter().any(|x| x.name == name) {
continue;
}
list.push(Agent {
name,
description: a.description.unwrap_or_default(),
mode: "primary".into(),
prompt: a.prompt.unwrap_or_default(),
tools: a.tools,
read_only: a.read_only.unwrap_or(false),
style: a.style.filter(|s| s == "chat").unwrap_or_else(|| "coding".into()),
model: a.model.filter(|m| !m.is_empty()),
source: "personal".into(),
max_parallel: None,
refine: a.refine,
path: None,
});
}
list
}
pub fn find_agent_for(app: &App, root: &Path, cwd: &Path, name: &str, user_id: Option<&str>) -> Agent {
agents_for(app, root, cwd, user_id)
.into_iter()
.find(|a| a.name == name)
.unwrap_or_else(|| builtin_agents(true).remove(0))
@@ -177,7 +214,35 @@ fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String
out
}
/// The plain chat mode: no coding instructions, project files or memory – only the
/// date, the model and what the user keeps in their personal memory.
/// Add the instructions the user set for this chat (they come last, so they win).
pub fn with_chat_instructions(mut system: String, instructions: &str) -> String {
if !instructions.trim().is_empty() {
system.push_str(&format!(
"\n\n# Instructions for this chat\nThe user set these for this chat. They apply to every message and override the defaults above:\n{}\n",
instructions.trim()
));
}
system
}
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
let mut s = agent.prompt.clone();
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
if let Some(u) = owner {
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
if !memory.trim().is_empty() {
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
}
}
s
}
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
if agent.style == "chat" {
return chat_prompt(app, agent, model, owner);
}
let mut s = String::from(BASE_PROMPT);
s.push_str("\n\n");
s.push_str(&agent.prompt);
+44 -23
View File
@@ -268,7 +268,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
let cwd = PathBuf::from(&session.cwd);
let roots = app.user_roots(session.owner_id.as_deref());
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let mut agent = prompts::find_agent(&app, &root, &cwd, &session.agent);
let mut agent = prompts::find_agent_for(&app, &root, &cwd, &session.agent, session.owner_id.as_deref());
let clean_room = session.clean_room(app.config().craft.clean_room);
// The craft prompt follows the session's clean room / deep switch.
if agent.name == "craft" && agent.source == "builtin" {
@@ -284,20 +284,21 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
let threshold = app.config().server.auto_compact_threshold;
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
compact(app.clone(), session_id, cancel.clone()).await?;
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
continue;
}
}
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
let tools = available_tools(&app, &agent, owner.as_ref());
let history = active_history(app.store.messages(session_id)?);
let history = active_history(app.store.messages_for_model(session_id)?);
let req = ChatRequest {
system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()),
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
messages: history,
tools: tools.iter().map(|t| t.def()).collect(),
max_tokens: model.max_output.unwrap_or(16_000),
reasoning: true,
session_id: Some(session_id.to_string()),
model: model.clone(),
};
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
@@ -666,7 +667,7 @@ pub async fn run_subagent(app: Arc<App>, ctx: &ToolCtx, agent: &str, description
fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> {
let parent = app.session(&ctx.session_id)?;
let root = ctx.root();
let parent_agent = prompts::find_agent(app, &root, &ctx.cwd, &parent.agent);
let parent_agent = prompts::find_agent_for(app, &root, &ctx.cwd, &parent.agent, parent.owner_id.as_deref());
let agents = prompts::agents(app, &root, &ctx.cwd);
let mut agent = agents
.iter()
@@ -703,6 +704,8 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
pinned: false,
archived: false,
folder: None,
instructions: String::new(),
telegram_quiet: false,
};
let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
@@ -745,7 +748,7 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
for m in &req.messages {
for p in &m.parts {
match p {
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text } => chars += text.len(),
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => chars += text.len(),
Part::ToolCall { input, .. } => chars += input.to_string().len(),
Part::ToolResult { content, images: imgs, .. } => {
chars += content.len();
@@ -881,6 +884,7 @@ async fn stream_step(
cost: req.model.cost(usage),
error,
user_id: None,
allowance: usage.allowance,
};
if let Err(e) = result {
@@ -911,9 +915,7 @@ async fn stream_step(
app.store.add_message(&session.id, &message)?;
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
}
let mut s = app.session(&session.id)?;
s.last_usage = Some(usage);
app.save_session(s)?;
app.update_session(&session.id, |s| s.last_usage = Some(usage))?;
return Ok(Step { message, stop_reason });
}
}
@@ -966,6 +968,7 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
cost: model.cost(usage),
error: false,
user_id: None,
allowance: usage.allowance,
});
}
@@ -985,17 +988,19 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
tools: vec![],
max_tokens: 2000,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
match complete_text(&app, &spec, provider, req, CancellationToken::new()).await {
Ok((title, usage)) => {
record_side_usage(&app, &session, &spec, &model, &usage, "title");
let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string();
if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) {
if !title.is_empty() {
let _ = app.update_session(&session.id, |s| {
if s.title.is_empty() {
s.title = title.chars().take(80).collect();
let _ = app.save_session(s);
}
});
}
}
Err(e) => tracing::debug!("title generation failed: {e:#}"),
@@ -1032,6 +1037,7 @@ pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<S
tools: vec![],
max_tokens: 2000,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(&app, &spec, provider, req, CancellationToken::new()).await?;
@@ -1091,7 +1097,7 @@ fn infer_phase(tool: &str, input: &Value, deep: bool) -> Option<&'static str> {
/// Move the craft phase forward (never backwards); `only_from` restricts the transition.
fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) {
let root = app.root_session_id(session_id);
let Ok(mut s) = app.session(&root) else { return };
let Ok(s) = app.session(&root) else { return };
let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p));
if let Some(from) = only_from {
if s.phase.as_deref() != Some(from) {
@@ -1108,8 +1114,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
if s.phase.as_deref() == Some(phase) {
return;
}
s.phase = Some(phase.to_string());
let _ = app.save_session(s);
let _ = app.update_session(&root, |s| s.phase = Some(phase.to_string()));
}
/// Review the last turn against the user's request. Returns feedback for the
@@ -1122,7 +1127,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
model.effort = Some("medium".into());
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
let result = async {
let history = active_history(app.store.messages(&session.id)?);
let history = active_history(app.store.messages_for_model(&session.id)?);
// The user's request: the last user message with text that is not feedback/tool output.
let req_idx = history
.iter()
@@ -1159,7 +1164,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
agent.name,
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
);
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone() };
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) };
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
record_side_usage(app, session, &spec, &model, &usage, "review");
let text = strip_think(&text);
@@ -1212,7 +1217,7 @@ pub fn transcript_of(history: &[Message]) -> String {
for p in &m.parts {
match p {
Part::Text { text } => body.push_str(&format!("{text}\n")),
Part::Summary { text } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
Part::Summary { text, .. } => body.push_str(&format!("[Summary of the earlier conversation]\n{text}\n")),
Part::ToolCall { name, input, .. } => body.push_str(&format!("[tool call {name}: {}]\n", clip(&input.to_string(), 1500))),
Part::ToolResult { name, content, is_error, .. } => {
body.push_str(&format!("[{} of {name}]\n{}\n", if *is_error { "error" } else { "result" }, clip(content, 3000)))
@@ -1229,9 +1234,25 @@ pub fn transcript_of(history: &[Message]) -> String {
}
/// Replace the active history with a summary (the old messages stay visible in the UI).
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken) -> Result<()> {
/// Summarize the history. `auto` = (context tokens, context limit) when triggered by a
/// nearly full context; the chat shows the progress and the reason.
pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
let event = |state: &str| Event::Compaction {
session_id: session_id.to_string(),
state: state.into(),
auto: auto.is_some(),
tokens: auto.map(|a| a.0),
limit: auto.map(|a| a.1),
};
app.emit(event("started"));
let res = compact_inner(app.clone(), session_id, cancel, auto).await;
app.emit(event(if res.is_ok() { "done" } else { "failed" }));
res
}
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
let session = app.session(session_id)?;
let history = active_history(app.store.messages(session_id)?);
let history = active_history(app.store.messages_for_model(session_id)?);
if history.is_empty() {
return Ok(());
}
@@ -1254,6 +1275,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
tools: vec![],
max_tokens: 16_000,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(&app, &spec, provider.clone(), req, cancel.clone()).await?;
@@ -1267,12 +1289,11 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
if summary.trim().is_empty() {
bail!("compaction produced an empty summary");
}
save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?;
let mut s = app.session(session_id)?;
s.last_usage = None;
let tokens = auto.map(|a| a.0).or_else(|| session.last_usage.as_ref().map(|u| u.context_tokens()));
save_user_parts(&app, session_id, vec![Part::Summary { text: summary, auto: auto.is_some(), tokens }])?;
app.update_session(session_id, |s| s.last_usage = None)?;
// Files must be re-read after compaction before editing.
app.with_runtime(session_id, |r| r.read_files.clear());
app.save_session(s)?;
Ok(())
}
+27 -1
View File
@@ -18,6 +18,8 @@ pub struct SandboxSpec {
pub roots: Vec<PathBuf>,
/// Host directory mounted as the user's home.
pub home: PathBuf,
/// Extra environment (the user's git identity).
pub env: Vec<(String, String)>,
}
impl SandboxSpec {
@@ -35,6 +37,8 @@ pub async fn probe() -> Result<(), String> {
}
let res = match Command::new("bwrap")
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
// The dynamic loader lives in /lib64 on x86_64 (/lib on arm64).
.args(["--ro-bind-try", "/lib64", "/lib64", "--ro-bind-try", "/lib32", "/lib32"])
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
.env_clear()
.output()
@@ -69,7 +73,7 @@ pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
}
let home = app.data_dir().join("users").join(&user.username).join("home");
let _ = std::fs::create_dir_all(&home);
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home, env: crate::gitcreds::identity_env(app, &user.id) })
}
/// Build a command that runs `program args` inside the sandbox in `cwd`.
@@ -148,9 +152,31 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
] {
push(&[&"--setenv", &k, &v]);
}
for (k, v) in &spec.env {
push(&[&"--setenv", k, v]);
}
push(&[&"--chdir", &cwd, &program]);
for a in args {
push(&[a]);
}
c
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_identity_reaches_the_sandbox() {
let spec = SandboxSpec {
username: "ann".into(),
roots: vec![],
home: PathBuf::from("/tmp/ann"),
env: vec![("GIT_AUTHOR_NAME".into(), "Ann Example".into())],
};
let args: Vec<String> = bwrap_args(&spec, Path::new("/tmp"), &[], true, "bash", &[]).into_iter().map(|a| a.to_string_lossy().into_owned()).collect();
let i = args.iter().position(|a| a == "GIT_AUTHOR_NAME").expect("identity set");
assert_eq!(args[i - 1], "--setenv");
assert_eq!(args[i + 1], "Ann Example");
}
}
+143 -6
View File
@@ -1,6 +1,6 @@
//! SQLite persistence for sessions and messages.
use crate::types::{Message, Part, Session, now_ms};
use crate::types::{ImageData, Message, Part, Session, now_ms};
use serde::Serialize;
use serde_json::{Value, json};
use anyhow::Result;
@@ -90,6 +90,13 @@ impl Store {
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
-- Images of messages, referenced by hash from the message JSON (see blobs.rs).
CREATE TABLE IF NOT EXISTS blobs (
hash TEXT PRIMARY KEY,
media_type TEXT NOT NULL,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS queue (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
@@ -162,6 +169,7 @@ impl Store {
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
] {
let exists: bool = conn
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
@@ -238,7 +246,7 @@ impl Store {
if let Some(m) = mdata.and_then(|d| serde_json::from_str::<Message>(&d).ok()) {
for p in &m.parts {
let text = match p {
Part::Text { text } | Part::Summary { text } | Part::Reasoning { text, .. } => text.as_str(),
Part::Text { text } | Part::Summary { text, .. } | Part::Reasoning { text, .. } => text.as_str(),
Part::ToolResult { content, .. } => content.as_str(),
_ => continue,
};
@@ -273,12 +281,21 @@ impl Store {
}
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();
let blobs = crate::blobs::externalize(&mut m);
let conn = self.conn.lock().unwrap();
for b in &blobs {
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![b.hash, b.media_type, b.bytes, now_ms()],
)?;
}
conn.execute(
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
ON CONFLICT(id) DO UPDATE SET data = excluded.data",
params![m.id, session_id, serde_json::to_string(m)?],
params![m.id, session_id, serde_json::to_string(&m)?],
)?;
conn.execute(
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
@@ -299,6 +316,103 @@ impl Store {
Ok(out)
}
/// Messages with the bytes of their images filled in – for model requests and exports.
pub fn messages_for_model(&self, session_id: &str) -> Result<Vec<Message>> {
let mut out = self.messages(session_id)?;
let conn = self.conn.lock().unwrap();
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for m in &mut out {
crate::blobs::for_each_image(m, |img| fill_image(&conn, &mut cache, img));
}
Ok(out)
}
/// Fill in the bytes of one image reference (no-op for inline images).
pub fn resolve_image(&self, img: &ImageData) -> ImageData {
let mut img = img.clone();
let conn = self.conn.lock().unwrap();
fill_image(&conn, &mut std::collections::HashMap::new(), &mut img);
img
}
/// One stored image: (media type, bytes).
pub fn blob(&self, hash: &str) -> Result<Option<(String, Vec<u8>)>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT media_type, data FROM blobs WHERE hash = ?1", [hash], |r| Ok((r.get(0)?, r.get(1)?)))
.optional()?)
}
/// Move inline images of older messages to the blob table (once, after the update
/// that introduced it). Does not touch `updated_at` of the sessions.
pub fn migrate_inline_images(&self) -> Result<usize> {
let ids: Vec<String> = {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id FROM messages WHERE data LIKE '%\"media_type\"%\"data\":\"%'")?;
let rows = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
rows
};
let mut moved = 0;
for id in ids {
let data: Option<String> = {
let conn = self.conn.lock().unwrap();
conn.query_row("SELECT data FROM messages WHERE id = ?1", [&id], |r| r.get(0)).optional()?
};
let Some(data) = data else { continue };
let Ok(mut m) = serde_json::from_str::<Message>(&data) else { continue };
let blobs = crate::blobs::externalize(&mut m);
if blobs.is_empty() {
continue;
}
let conn = self.conn.lock().unwrap();
for b in &blobs {
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
params![b.hash, b.media_type, b.bytes, now_ms()],
)?;
}
conn.execute("UPDATE messages SET data = ?2 WHERE id = ?1", params![id, serde_json::to_string(&m)?])?;
moved += blobs.len();
}
Ok(moved)
}
/// Delete images no message refers to any more (deleted or truncated sessions).
pub fn gc_blobs(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let mut used = std::collections::HashSet::new();
let mut stmt = conn.prepare("SELECT data FROM messages WHERE data LIKE '%\"blob\":\"%'")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let data: String = row.get(0)?;
for (i, _) in data.match_indices("\"blob\":\"") {
if let Some(h) = data.get(i + 8..i + 72) {
used.insert(h.to_string());
}
}
}
drop(rows);
drop(stmt);
let all: Vec<String> = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
let mut removed = 0;
for h in all.iter().filter(|h| !used.contains(*h)) {
removed += conn.execute("DELETE FROM blobs WHERE hash = ?1", [h])?;
}
Ok(removed)
}
/// Give the space of deleted data back to the file system when it is worth it.
pub fn vacuum_if_worthwhile(&self) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let free: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
let page: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
if free * page < 32 * 1024 * 1024 {
return Ok(false);
}
conn.execute_batch("VACUUM")?;
Ok(true)
}
/// Remove all messages after (and including) the given one – used for "edit & resend".
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
@@ -311,6 +425,20 @@ impl Store {
}
}
/// Put the bytes of a stored image into `img.data`.
fn fill_image(conn: &Connection, cache: &mut std::collections::HashMap<String, String>, img: &mut ImageData) {
use base64::Engine;
let Some(hash) = img.blob.clone().filter(|_| img.data.is_empty()) else { return };
if !cache.contains_key(&hash) {
let Ok(bytes) = conn.query_row("SELECT data FROM blobs WHERE hash = ?1", [&hash], |r| r.get::<_, Vec<u8>>(0)) else {
tracing::warn!("image {hash} is missing in the store");
return;
};
cache.insert(hash.clone(), base64::engine::general_purpose::STANDARD.encode(bytes));
}
img.data = cache[&hash].clone();
}
/// One model request, as recorded for statistics.
#[derive(Debug, Clone, Serialize)]
pub struct UsageRecord {
@@ -331,6 +459,8 @@ pub struct UsageRecord {
pub error: bool,
/// User to bill when there is no session (CLI requests); else the session owner.
pub user_id: Option<String>,
/// opencode Go: share of the plan's usage limits (USD), as reported by the provider.
pub allowance: Option<f64>,
}
/// Aggregation SQL shared by all statistics queries.
@@ -376,13 +506,14 @@ impl Store {
conn.execute(
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
ttft_ms, duration_ms, cost, error, user_id)
ttft_ms, duration_ms, cost, error, user_id, allowance)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)))",
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)), ?16)",
params![
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id,
u.allowance
],
)?;
Ok(())
@@ -886,6 +1017,12 @@ impl Store {
impl Store {
/// Cost per provider since `from` (ms).
/// opencode Go: sum of reported allowance of one provider since `from` (ms).
pub fn allowance_since(&self, provider: &str, from: i64) -> Result<f64> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT COALESCE(SUM(allowance), 0) FROM usage WHERE provider = ?1 AND ts >= ?2", params![provider, from], |r| r.get(0))?)
}
pub fn cost_by_provider(&self, from: i64) -> Result<std::collections::BTreeMap<String, f64>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?;
File diff suppressed because it is too large. Load diff
+39 -5
View File
@@ -18,13 +18,16 @@ use tokio::sync::broadcast;
/// Output kept for re-attaching clients.
const SCROLLBACK: usize = 256 * 1024;
/// Detached terminals are stopped after this time.
/// Detached terminals outside a chat are stopped after this time; terminals that
/// belong to a chat stay open until they are closed, exit or the chat is deleted.
const DETACHED_TTL: Duration = Duration::from_secs(30 * 60);
const MAX_PER_USER: usize = 8;
const MAX_PER_USER: usize = 16;
pub struct Term {
pub id: String,
pub owner_id: String,
/// Chat the terminal is a tab of.
pub session_id: Option<String>,
pub cwd: PathBuf,
pub created_at: i64,
writer: tokio::sync::Mutex<pty_process::OwnedWritePty>,
@@ -57,6 +60,7 @@ impl Term {
json!({
"id": self.id,
"cwd": self.cwd,
"session_id": self.session_id,
"created_at": self.created_at,
"alive": self.alive(),
"attached": *self.attached.lock().unwrap() > 0,
@@ -76,6 +80,30 @@ impl Terminals {
v.into_iter().map(|(_, i)| i).collect()
}
/// Terminals of one chat (oldest first).
pub fn for_session(&self, session_id: &str, owner_id: &str) -> Vec<Value> {
let mut v: Vec<_> = self
.map
.lock()
.unwrap()
.values()
.filter(|t| t.owner_id == owner_id && t.session_id.as_deref() == Some(session_id))
.map(|t| (t.created_at, t.info()))
.collect();
v.sort_by_key(|(c, _)| *c);
v.into_iter().map(|(_, i)| i).collect()
}
/// Close all terminals of a deleted chat.
pub fn kill_session(&self, session_id: &str) {
let gone: Vec<Arc<Term>> = {
let mut map = self.map.lock().unwrap();
let ids: Vec<String> = map.values().filter(|t| t.session_id.as_deref() == Some(session_id)).map(|t| t.id.clone()).collect();
ids.iter().filter_map(|id| map.remove(id)).collect()
};
gone.iter().for_each(|t| t.kill());
}
pub fn get(&self, id: &str, owner_id: &str) -> Option<Arc<Term>> {
self.map.lock().unwrap().get(id).filter(|t| t.owner_id == owner_id).cloned()
}
@@ -100,7 +128,7 @@ impl Terminals {
pub fn reap(&self) {
let mut map = self.map.lock().unwrap();
map.retain(|_, t| {
let stale = t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
let stale = t.session_id.is_none() && t.detached_since.lock().unwrap().is_some_and(|d| d.elapsed() > DETACHED_TTL);
let gone = !t.alive() && *t.attached.lock().unwrap() == 0;
if stale || gone {
t.kill();
@@ -113,7 +141,7 @@ impl Terminals {
}
/// Start a shell for `user` in `cwd` (already access-checked).
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> Result<Arc<Term>> {
pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>, cols: u16, rows: u16) -> Result<Arc<Term>> {
if app.terminals.list(&user.id).iter().filter(|t| t["alive"] == true).count() >= MAX_PER_USER {
anyhow::bail!("too many open terminals (max {MAX_PER_USER}) – close one first");
}
@@ -129,7 +157,12 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
}
None => {
let c = pty_process::Command::new(shell[0]).args(&shell[1..]).current_dir(cwd).env("TERM", "xterm-256color").env("CODECRAFT", "1");
let c = pty_process::Command::new(shell[0])
.args(&shell[1..])
.current_dir(cwd)
.env("TERM", "xterm-256color")
.env("CODECRAFT", "1")
.envs(crate::gitcreds::identity_env(app, &user.id));
match crate::gitcreds::global_config(app, user) {
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
None => c,
@@ -142,6 +175,7 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, cols: u16, rows: u16) -> R
let term = Arc::new(Term {
id: crate::types::new_id(),
owner_id: user.id.clone(),
session_id,
cwd: cwd.to_path_buf(),
created_at: crate::types::now_ms(),
writer: tokio::sync::Mutex::new(writer),
+4 -1
View File
@@ -150,9 +150,12 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
// The session owner's git credentials and identity.
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
if let Some(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) {
if let Some(u) = owner {
if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) {
c.env("GIT_CONFIG_GLOBAL", cfg);
}
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
}
c
}
})
+1 -1
View File
@@ -186,7 +186,7 @@ impl Tool for OpenScad {
continue;
}
if let Ok(bytes) = std::fs::read(&out) {
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
files.push(out);
}
}
+2
View File
@@ -27,6 +27,8 @@ pub(crate) fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
Ok(ImageData {
blob: None,
size: None,
media_type: "image/png".into(),
data: base64::engine::general_purpose::STANDARD.encode(&buf),
})
+2
View File
@@ -107,6 +107,8 @@ impl Tool for Read {
return Ok(ToolOutput {
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
images: vec![ImageData {
blob: None,
size: None,
media_type: mt.into(),
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
}],
+3 -14
View File
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub use codecraft_core::version::VERSION;
/// Git commit the image was built from (Docker build arg GIT_COMMIT).
pub fn commit() -> &'static str {
@@ -17,10 +17,7 @@ pub struct UpdateState {
pub latest: Mutex<Option<Value>>,
}
fn newer(a: &str, b: &str) -> bool {
let p = |v: &str| v.trim_start_matches('v').split(['.', '-']).take(3).map(|x| x.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
p(a) > p(b)
}
use codecraft_core::version::newer;
async fn check(app: &App) -> Option<Value> {
let url = app.config().server.update_url.clone();
@@ -46,12 +43,4 @@ pub fn spawn_checker(app: Arc<App>) {
});
}
#[cfg(test)]
mod tests {
#[test]
fn version_compare() {
assert!(super::newer("v0.2.0", "0.1.9"));
assert!(super::newer("1.0.0", "0.9.9"));
assert!(!super::newer("v0.1.0", "0.1.0"));
}
}
+14 -3
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { connectEvents, get, setUnauthorizedHandler } from "./api";
import { useT } from "./i18n";
import { navigate, useRoute } from "./router";
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
import { applyEvent, loadSessions, resync, setState } from "./store";
import type { Info } from "./types";
import { Sidebar } from "./components/Sidebar";
@@ -80,7 +80,18 @@ export function App() {
const [section, arg] = route;
let content;
if (section === "s" && arg) content = <ChatView key={arg} sessionId={arg} onMenu={() => setMenuOpen(true)} />;
const chatIds = section === "s" && arg ? arg.split("+").filter(Boolean).slice(0, MAX_PANES) : [];
if (chatIds.length === 1) content = <ChatView key={chatIds[0]} sessionId={chatIds[0]} onMenu={() => setMenuOpen(true)} />;
else if (chatIds.length > 1)
content = (
<div className="split-view">
{chatIds.map((id) => (
<div key={id} className="split-pane">
<ChatView sessionId={id} onMenu={() => setMenuOpen(true)} onClosePane={() => closePane(id)} />
</div>
))}
</div>
);
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
@@ -102,7 +113,7 @@ export function App() {
return (
<div className="app">
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
<main className="main">{content}</main>
<WhatsNew version={info.version} />
<Palette />
+36 -2
View File
@@ -1,3 +1,4 @@
import { Fragment } from "react";
import { Icon } from "./Icon";
import { useFetch } from "../hooks";
import { fmtCost, fmtPercent, useT } from "../i18n";
@@ -11,16 +12,39 @@ interface BudgetRow {
warning: boolean;
exceeded: boolean;
images?: boolean;
plan?: Plan;
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
}
/** opencode Go: usage windows against the plan's limits (USD). */
export interface Plan {
limit: number;
windows: { window: "5h" | "week" | "month"; used: number; limit: number; ratio: number }[];
}
/** Three small meters (5 hours, week, month) for a subscription plan. */
export function PlanUsage({ plan }: { plan: Plan }) {
const t = useT();
return (
<div className="plan-usage">
{plan.windows.map((w) => (
<div key={w.window} className="plan-window" title={`${fmtCost(w.used, "USD")} / ${fmtCost(w.limit, "USD")}`}>
<span className="plan-label">{t(`budget.window.${w.window}`)}</span>
<Bar share={w.ratio} warning={w.ratio >= 0.8} exceeded={w.ratio >= 1} />
<span className="plan-pct">{fmtPercent(w.ratio)}</span>
</div>
))}
</div>
);
}
interface BudgetData {
currency: string;
total: BudgetRow;
providers: BudgetRow[];
}
function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
export function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
return (
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
@@ -62,7 +86,8 @@ export function BudgetPanel() {
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.provider}>
<Fragment key={r.provider}>
<tr>
<td>
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
@@ -76,6 +101,15 @@ export function BudgetPanel() {
</td>
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
</tr>
{r.plan && (
<tr className="plan-row">
<td className="muted">{t("budget.plan", { v: fmtCost(r.plan.limit, "USD") })}</td>
<td colSpan={5}>
<PlanUsage plan={r.plan} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
+180
View File
@@ -0,0 +1,180 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { del, get, post, put } from "../api";
import { confirmDialog, toast } from "../dialogs";
import { useT } from "../i18n";
import { Icon } from "./Icon";
import type { TermInfo } from "./Terminal";
// xterm and the editor are large: load them only when such a tab is shown.
const TermView = lazy(() => import("./Terminal").then((m) => ({ default: m.TermView })));
const Files = lazy(() => import("./Files").then((m) => ({ default: m.Files })));
const POLL_MS = 15_000;
const tabKey = (sessionId: string) => `codecraft.chatTab.${sessionId}`;
/** A file manager tab (stored per user and chat on the server). */
interface FileTab {
id: string;
dir: string;
}
/**
* Tabs of a chat: the chat itself, its terminals and file managers. Both are kept on the
* server, so they show up again after a reload or on another device (the list is
* refreshed periodically); which tab is active is per browser tab.
*/
export function useChatTabs(sessionId: string, cwd: string) {
const t = useT();
const [terms, setTerms] = useState<TermInfo[]>([]);
const [files, setFilesState] = useState<FileTab[]>([]);
// Local file tab changes not yet saved: polling must not undo them.
const pending = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [tab, setTabState] = useState<string>(() => sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
const setTab = useCallback(
(v: string) => {
setTabState(v);
sessionStorage.setItem(tabKey(sessionId), v);
},
[sessionId],
);
const setFiles = useCallback(
(update: (f: FileTab[]) => FileTab[], delay = 0) =>
setFilesState((cur) => {
const next = update(cur);
clearTimeout(pending.current);
pending.current = setTimeout(() => {
pending.current = undefined;
void put(`/sessions/${sessionId}/tabs`, next).catch((e) => toast(e));
}, delay);
return next;
}),
[sessionId],
);
const reload = useCallback(
() =>
get<{ terminals: TermInfo[]; files: FileTab[] }>(`/sessions/${sessionId}/tabs`)
.then((r) => {
setTerms(r.terminals);
if (!pending.current) setFilesState(r.files);
})
.catch(() => undefined),
[sessionId],
);
useEffect(() => {
setTabState(sessionStorage.getItem(tabKey(sessionId)) ?? "chat");
setFilesState([]);
setTerms([]);
void reload();
const id = setInterval(reload, POLL_MS);
const onFocus = () => void reload();
window.addEventListener("focus", onFocus);
return () => {
clearInterval(id);
window.removeEventListener("focus", onFocus);
};
}, [sessionId, reload]);
// A tab closed elsewhere (or a stale stored tab) falls back to the chat.
const activeTerm = terms.find((x) => x.id === tab);
const activeFiles = files.find((f) => f.id === tab);
const current = activeTerm || activeFiles ? tab : "chat";
const openTerminal = async () => {
try {
const info = await post<TermInfo>("/terminals", { session_id: sessionId, cols: 100, rows: 30 });
await reload();
setTab(info.id);
} catch (e) {
toast(e);
}
};
const closeTerminal = async (x: TermInfo, n: number) => {
if (x.alive && !(await confirmDialog({ message: t("terminal.closeConfirm", { name: t("terminal.tab", { n }) }), danger: true, confirmLabel: t("terminal.close") }))) return;
await del(`/terminals/${x.id}`).catch(() => undefined);
if (tab === x.id) setTab("chat");
await reload();
};
const openFiles = () => {
const id = `files:${Date.now().toString(36)}`;
setFiles((f) => [...f, { id, dir: cwd }]);
setTab(id);
};
const closeFiles = (id: string) => {
setFiles((f) => f.filter((x) => x.id !== id));
if (tab === id) setTab("chat");
};
// Browsing saves the folder after a short pause, not on every click.
const fileDirChanged = useCallback((id: string, dir: string) => setFiles((f) => f.map((x) => (x.id === id ? { ...x, dir } : x)), 1000), [setFiles]);
return { terms, files, tab: current, activeTerm, setTab, openTerminal, closeTerminal, openFiles, closeFiles, fileDirChanged };
}
type Tabs = ReturnType<typeof useChatTabs>;
const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? "/";
/** Tab strip above the chat: the chat, one tab per terminal and file manager, and "+". */
export function ChatTabBar({ state }: { state: Tabs }) {
const t = useT();
const { terms, files, tab, setTab, openTerminal, closeTerminal, openFiles, closeFiles } = state;
if (terms.length === 0 && files.length === 0) return null;
return (
<div className="chat-tabs" role="tablist">
<button role="tab" aria-selected={tab === "chat"} className={`chat-tab ${tab === "chat" ? "active" : ""}`} onClick={() => setTab("chat")}>
<Icon name="sparkle" /> {t("terminal.chatTab")}
</button>
{terms.map((x, i) => (
<span key={x.id} className={`chat-tab ${tab === x.id ? "active" : ""} ${x.alive ? "" : "dead"}`}>
<button role="tab" aria-selected={tab === x.id} className="ghost" onClick={() => setTab(x.id)} title={x.cwd}>
<Icon name="terminal" /> {t("terminal.tab", { n: i + 1 })}
</button>
<button className="ghost close" onClick={() => void closeTerminal(x, i + 1)} aria-label={t("terminal.close")} title={t("terminal.close")}>
<Icon name="close" size={12} />
</button>
</span>
))}
{files.map((f) => (
<span key={f.id} className={`chat-tab ${tab === f.id ? "active" : ""}`}>
<button role="tab" aria-selected={tab === f.id} className="ghost" onClick={() => setTab(f.id)} title={f.dir}>
<Icon name="folder" /> {basename(f.dir)}
</button>
<button className="ghost close" onClick={() => closeFiles(f.id)} aria-label={t("files.closeTab")} title={t("files.closeTab")}>
<Icon name="close" size={12} />
</button>
</span>
))}
<button className="ghost chat-tab-add" onClick={() => void openTerminal()} title={t("terminal.new")} aria-label={t("terminal.new")}>
<Icon name="plus" /> <Icon name="terminal" />
</button>
<button className="ghost chat-tab-add" onClick={openFiles} title={t("files.newTab")} aria-label={t("files.newTab")}>
<Icon name="plus" /> <Icon name="folder" />
</button>
</div>
);
}
/**
* Tab contents besides the chat. The active terminal is mounted (it re-attaches and
* replays its output); file managers stay mounted while hidden, so unsaved edits survive.
*/
export function ChatTabPanes({ state }: { state: Tabs }) {
const t = useT();
const { tab, activeTerm, files, fileDirChanged } = state;
return (
<Suspense fallback={<div className="empty">{t("common.loading")}</div>}>
{activeTerm && <TermView key={activeTerm.id} info={activeTerm} />}
{files.map((f) => (
<div key={f.id} className="chat-pane chat-files" hidden={tab !== f.id}>
<Files embed={{ startDir: f.dir, onDirChange: (d) => fileDirChanged(f.id, d) }} />
</div>
))}
</Suspense>
);
}
+100 -17
View File
@@ -1,18 +1,19 @@
import { takePrefill } from "../share";
import { Icon } from "./Icon";
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { del, get, patch, post } from "../api";
import { useModels } from "../hooks";
import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { dismissError, loadSession, loadSessions, useStore } from "../store";
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
import { ActivityLine, Dots, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode } from "../prefs";
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
import { useReasoningMode, useTodosCollapsed } from "../prefs";
import { Composer } from "./Composer";
import { ShareDialog } from "./ShareDialog";
import { RenameDialog } from "./RenameDialog";
import { InstructionsDialog } from "./InstructionsDialog";
import { RegenerateButton } from "./Regenerate";
import { WorktreeChip } from "./WorktreeChip";
import { QueueList } from "./QueueList";
@@ -24,6 +25,7 @@ import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
type ToolResult = Extract<Part, { type: "tool_result" }>;
@@ -135,7 +137,7 @@ function RefineFeedback({ text }: { text: string }) {
);
}
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
const t = useT();
const session = useStore((s) => s.sessionById[sessionId]);
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
@@ -145,9 +147,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
const allPermissions = useStore((s) => s.permissions);
const allQuestions = useStore((s) => s.questions);
const waiting = useStore((s) => s.waiting[sessionId]);
const compacting = useStore((s) => s.compacting[sessionId]);
const readOnly = useStore((s) => !!s.readOnly[sessionId]);
const isAdmin = useStore((s) => s.me?.role === "admin");
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
const [sharing, setSharing] = useState(false);
// The Telegram switch in the ⋯ menu only matters with a paired bot.
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
const [editingInstructions, setEditingInstructions] = useState(false);
const models = useModels();
const [loadError, setLoadError] = useState<string>();
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
@@ -304,6 +311,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<ShellsChip sessionId={sessionId} busy={busy} />
{isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
{!!session.instructions && (
<button className="chip" style={{ border: "none" }} onClick={() => !readOnly && setEditingInstructions(true)} title={session.instructions}>
<Icon name="edit" /> {t("instructions.chip")}
</button>
)}
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
{!readOnly && (
<>
@@ -314,14 +326,14 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
)}
<button
className="small ghost hide-mobile"
onClick={() => {
sessionStorage.setItem("codecraft.termCwd", session.cwd);
navigate("/terminal/new");
}}
onClick={() => void tabs.openTerminal()}
title={t("terminal.here")}
>
<Icon name="terminal" />
</button>
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
<Icon name="folder" />
</button>
{!session.parent_id && (
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
<Icon name="fork" />
@@ -332,6 +344,30 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<Icon name="more" />
</summary>
<div className="menu-pop-body">
<a
href="#"
onClick={(e) => {
e.preventDefault();
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
setEditingInstructions(true);
}}
>
<Icon name="edit" /> {t("instructions.title")}
</a>
{telegramPaired && !session.parent_id && (
<a
href="#"
role="menuitemcheckbox"
aria-checked={!session.telegram_quiet}
title={t("chat.telegramHint")}
onClick={(e) => {
e.preventDefault();
void update({ telegram_quiet: !session.telegram_quiet });
}}
>
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
</a>
)}
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
<Icon name="download" /> {t("chat.exportMd")}
</a>
@@ -365,7 +401,15 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</button>
</>
)}
{onClosePane && (
<button className="small ghost" onClick={onClosePane} title={t("chat.closePane")} aria-label={t("chat.closePane")}>
<Icon name="close" />
</button>
)}
</div>
<ChatTabBar state={tabs} />
<ChatTabPanes state={tabs} />
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
<div
@@ -402,7 +446,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
if (summary && summary.type === "summary") {
return (
<details key={m.id} className="summary-divider">
<summary>{t("chat.compacted")}</summary>
<summary>
{summary.auto
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
: summary.tokens
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
: t("chat.compacted")}
</summary>
<Markdown text={summary.text} />
</details>
);
@@ -437,8 +487,8 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{imgs.length > 0 && (
<div className="user-files">
{imgs.map((p, i) => (
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
</FileFigure>
))}
</div>
@@ -452,14 +502,24 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div>
)}
{compacting && (
<div className={`compacting-card ${compacting.auto ? "auto" : ""}`} role="status">
<Spinner small />
<div>
<b>{t(compacting.auto ? "chat.compactingAuto" : "chat.compactingManual")}</b>
{compacting.auto && compacting.tokens && compacting.limit && (
<div className="hint">
{t("chat.compactingDetail", { tokens: fmtCompact(compacting.tokens), limit: fmtCompact(compacting.limit), pct: fmtPercent(compacting.tokens / compacting.limit) })}
</div>
)}
</div>
</div>
)}
{(busy || waiting) && (
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<div className="todos">
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
<TodoList todos={todos} />
</div>
<TodoPanel todos={todos} />
)}
{permissions.map((p) => (
<PermissionCard key={p.id} req={p} currentId={sessionId} />
@@ -497,6 +557,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</div>
)}
</div>
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
{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)} />}
@@ -514,6 +575,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
onUpdate={update}
/>
)}
</div>
</>
);
}
@@ -584,3 +646,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
</div>
);
}
/** Tasks of the model above the composer; folds to one line (current task, progress). */
function TodoPanel({ todos }: { todos: Todo[] }) {
const t = useT();
const [collapsed, setCollapsed] = useTodosCollapsed();
const done = todos.filter((x) => x.status === "completed").length;
const current = todos.find((x) => x.status === "in_progress");
return (
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
<span>{t("tool.todos")}</span>
<span className="todos-count">
{done}/{todos.length}
</span>
{collapsed && current && <span className="todos-current">{current.content}</span>}
</button>
{!collapsed && <TodoList todos={todos} />}
</div>
);
}
+13 -13
View File
@@ -1,6 +1,6 @@
import { TemplatePicker } from "./TemplatePicker";
import { Icon } from "./Icon";
import { formatSize } from "./FileFigure";
import { formatSize, imageSrc } from "./FileFigure";
import { uploadFile } from "../api";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAgents, useFetch, useModels } from "../hooks";
@@ -257,7 +257,7 @@ export function Composer({
)}
{images.map((img, i) => (
<div className="att" key={i}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<img src={imageSrc(img)} alt="" />
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
@@ -288,20 +288,20 @@ export function Composer({
/>
<div className="composer-bar">
<div className="composer-options">
<div className="mode-tabs" role="tablist" aria-label={t("composer.mode")}>
{agents.map((a) => (
<button
key={a.name}
role="tab"
aria-selected={session.agent === a.name}
className={session.agent === a.name ? "active" : ""}
title={a.description}
onClick={() => onUpdate({ agent: a.name })}
<select
className="mode-select"
value={session.agent}
onChange={(e) => onUpdate({ agent: e.target.value })}
aria-label={t("composer.mode")}
title={agents.find((a) => a.name === session.agent)?.description ?? t("composer.mode")}
>
{!agents.some((a) => a.name === session.agent) && <option value={session.agent}>{session.agent}</option>}
{agents.map((a) => (
<option key={a.name} value={a.name} title={a.description}>
{a.name}
</button>
</option>
))}
</div>
</select>
{session.agent === "craft" && (
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
+11
View File
@@ -1,5 +1,6 @@
import { Icon } from "./Icon";
import { useEffect, useState, type ReactNode } from "react";
import type { ImageData } from "../types";
/** Human-readable file size. */
export function formatSize(n: number) {
@@ -32,6 +33,16 @@ export function basename(p: string) {
}
/** Bytes of a base64 payload. */
/** URL of an image of a message: inline (data URL) or from the image store. */
export function imageSrc(img: ImageData) {
return img.data ? `data:${img.media_type};base64,${img.data}` : `/api/blobs/${img.blob}`;
}
/** Size in bytes of an image of a message. */
export function imageSize(img: ImageData) {
return img.data ? base64Size(img.data) : img.size;
}
export function base64Size(b64: string) {
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
return Math.floor((b64.length * 3) / 4) - pad;
+29 -6
View File
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
import { GitPanel, type GitStatus } from "./GitPanel";
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
import { confirmDialog, promptDialog, toast } from "../dialogs";
import { navigate } from "../router";
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
@@ -55,10 +56,16 @@ function icon(e: Entry) {
return <Icon name="file" className="ficon" />;
}
export function Files({ onMenu }: { onMenu: () => void }) {
/** Embedded in a chat tab: own start folder, no page header, folder kept by the caller. */
export interface FilesEmbed {
startDir: string;
onDirChange?: (dir: string) => void;
}
export function Files({ onMenu, embed }: { onMenu?: () => void; embed?: FilesEmbed }) {
const t = useT();
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const [dir, setDir] = useState<string>(() => localStorage.getItem("codecraft.filesDir") ?? "");
const [dir, setDir] = useState<string>(() => embed?.startDir ?? localStorage.getItem("codecraft.filesDir") ?? "");
const [listing, setListing] = useState<Listing>();
const [hidden, setHidden] = useState(false);
const [error, setError] = useState<string>();
@@ -78,7 +85,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
setListing(l);
setDir(l.path);
localStorage.setItem("codecraft.filesDir", l.path);
if (embed) embed.onDirChange?.(l.path);
else localStorage.setItem("codecraft.filesDir", l.path);
setError(undefined);
} catch (e: any) {
setError(e.message);
@@ -142,7 +150,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
// "Edit file" links from other pages (e.g. automations) open the file directly.
useEffect(() => {
const target = sessionStorage.getItem("codecraft.openFile");
const target = embed ? null : sessionStorage.getItem("codecraft.openFile");
if (!target) return;
sessionStorage.removeItem("codecraft.openFile");
setDir(target.slice(0, target.lastIndexOf("/")) || "/");
@@ -186,6 +194,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
});
};
/** New chat with the folder as working directory (default model and mode). */
const chatHere = async (e: Entry) => {
try {
const s = await post<{ id: string }>("/sessions", { cwd: e.path });
navigate(`/s/${s.id}`);
} catch (err) {
toast(err);
}
};
const rename = async (e: Entry) => {
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
@@ -324,6 +342,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<details className="entry-menu">
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
<div className="menu">
{e.is_dir && (
<button onClick={() => void chatHere(e)}>{t("files.chatHere")}</button>
)}
<button onClick={() => rename(e)}>{t("files.rename")}</button>
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
{!e.is_dir && (
@@ -418,12 +439,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
return (
<>
<div className="topbar">
<div className={embed ? "files-embed-bar" : "topbar"}>
{!embed && (
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
)}
<div className="grow">
<h1>{t("nav.files")}</h1>
{!embed && <h1>{t("nav.files")}</h1>}
<div className="path">{listing?.path}</div>
</div>
{listing?.repo && (
+55
View File
@@ -0,0 +1,55 @@
import { useState } from "react";
import { patch } from "../api";
import { useT } from "../i18n";
/** Instructions for one chat: added to the system prompt of every turn. */
export function InstructionsDialog({ sessionId, value, onClose }: { sessionId: string; value: string; onClose: () => void }) {
const t = useT();
const [text, setText] = useState(value);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const save = async (next: string) => {
setBusy(true);
try {
await patch(`/sessions/${sessionId}`, { instructions: next });
onClose();
} catch (e: any) {
setError(e.message);
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal instructions-modal" role="dialog" aria-label={t("instructions.title")} onClick={(e) => e.stopPropagation()}>
<h3>{t("instructions.title")}</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("instructions.hint")}
</div>
<textarea
autoFocus
rows={10}
style={{ width: "100%" }}
value={text}
placeholder={t("instructions.placeholder")}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => (e.ctrlKey || e.metaKey) && e.key === "Enter" && !busy && void save(text)}
/>
{error && <div className="notice err">{error}</div>}
<div className="row" style={{ marginTop: 10, justifyContent: "flex-end", gap: 6 }}>
{value && (
<button className="danger" onClick={() => void save("")} disabled={busy}>
{t("instructions.clear")}
</button>
)}
<span className="spacer" />
<button onClick={onClose}>{t("common.cancel")}</button>
<button className="primary" onClick={() => void save(text)} disabled={busy || text.trim() === value.trim()}>
{t("common.save")}
</button>
</div>
</div>
</div>
);
}
+16 -1
View File
@@ -107,7 +107,22 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
</span>
<span>{t("jobs.mode")}</span>
<span>
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · max {j.spec?.timeout_minutes} min
</span>
<span>{t("jobs.permissionMode")}</span>
<span>
<select
value={j.spec?.permission_mode ?? "auto"}
disabled={busy === j.id}
title={t("jobs.permissionModeHint")}
aria-label={t("jobs.permissionMode")}
onChange={(e) => act(j.id, () => patch(`/jobs/${j.id}`, { permission_mode: e.target.value }))}
>
<option value="default">{t("permissionMode.default")}</option>
<option value="auto">{t("permissionMode.auto")}</option>
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
<option value="bypass">{t("permissionMode.bypass")}</option>
</select>
</span>
</div>
)}
+18 -9
View File
@@ -5,7 +5,7 @@ import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData } from "../types";
import { readImage } from "./Composer";
import { formatSize } from "./FileFigure";
import { formatSize, imageSrc } from "./FileFigure";
import { ModelSelect } from "./ModelSelect";
import { takePrefill } from "../share";
@@ -103,6 +103,7 @@ const ICONS: Record<string, ReactNode> = {
),
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
chat: svg(<path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm4 6h.01M12 11h.01M16 11h.01" />),
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
};
@@ -185,7 +186,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const [creating, setCreating] = useState<string>();
const fileInput = useRef<HTMLInputElement>(null);
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
const custom = agents.filter((a) => !["build", "plan", "craft"].includes(a.name));
const custom = agents.filter((a) => !["build", "plan", "craft", "chat"].includes(a.name));
useEffect(() => {
if (!model && config?.default_model) setModel(config.default_model);
@@ -244,7 +245,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
const canStart =
!creating &&
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
(folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
(task === "chat" ? !!rootPath : folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
/** The first message(s) for the agent. */
const prompts = (uploaded: string[]): string[] => {
@@ -275,7 +276,10 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
try {
// 1. Folder
let cwd = picked;
if (folderMode === "git") {
if (task === "chat") {
// A plain conversation needs no project: it runs in the user's own folder.
cwd = rootPath;
} else if (folderMode === "git") {
setCreating(t("wizard.cloning"));
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
@@ -295,7 +299,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
}
if (!cwd) throw new Error("could not create the folder");
}
localStorage.setItem(LAST_DIR, cwd);
if (task !== "chat") localStorage.setItem(LAST_DIR, cwd);
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
const uploaded: string[] = [];
const mkdirs = async (dir: string) => {
@@ -327,7 +331,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined,
});
if (isolated && folderMode !== "new") {
if (isolated && folderMode !== "new" && task !== "chat") {
setCreating(t("worktree.creating"));
await post(`/sessions/${s.id}/worktree`, { action: "create" });
}
@@ -359,6 +363,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<div className="panel wiz-panel">
<Step n={n++} title={t("wizard.whatToDo")}>
<div className="wiz-cards">
<Card icon="chat" title={t("wizard.task.chat")} desc={t("wizard.task.chatDesc")} active={task === "chat"} onClick={() => setTask("chat")} />
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
@@ -454,7 +459,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
>
<textarea
value={text}
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : "wizard.placeholder.task")}
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : task === "chat" ? "wizard.placeholder.chat" : "wizard.placeholder.task")}
onChange={(e) => setText(e.target.value)}
onPaste={(e) => {
if (e.clipboardData.files.length) {
@@ -473,7 +478,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<div className="attachments">
{images.map((img, i) => (
<div className="att" key={`i${i}`}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<img src={imageSrc(img)} alt="" />
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
@@ -512,9 +517,11 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
<div className="wiz-foot">
<div className="wiz-foot-row">
<label>{t("wizard.builtBy")}</label>
<label>{task === "chat" ? t("composer.model") : t("wizard.builtBy")}</label>
<ModelSelect value={model} onChange={setModel} />
</div>
{task !== "chat" && (
<>
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
{(["new", "existing", "git"] as const).map((m) => (
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
@@ -564,6 +571,8 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
</span>
</label>
)}
</>
)}
{error && <div className="notice err">{error}</div>}
<div className="wiz-start-row">
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
+1 -1
View File
@@ -28,7 +28,7 @@ function recent() {
/** Move to the next/previous session relative to the open one. */
function step(dir: 1 | -1) {
const list = recent();
const cur = location.hash.match(/^#\/s\/([^/]+)/)?.[1];
const cur = location.hash.match(/^#\/s\/([^/+]+)/)?.[1];
const i = list.findIndex((s) => s.id === cur);
const next = list[i < 0 ? 0 : Math.min(Math.max(i + dir, 0), list.length - 1)];
if (next) navigate(`/s/${next.id}`);
+2 -1
View File
@@ -3,6 +3,7 @@ import { del, post } from "../api";
import { useT } from "../i18n";
import { useStore } from "../store";
import type { QueueItem } from "../types";
import { imageSrc } from "./FileFigure";
import { confirmDialog, promptDialog, toast } from "../dialogs";
const EMPTY: QueueItem[] = [];
@@ -30,7 +31,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
{it.source === "background" && <span className="chip">{t("tool.background")}</span>}
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
{images.map((p, i) => (
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
<img key={i} src={imageSrc(p.image)} alt="" />
))}
{it.source !== "background" && (
<button
+93 -14
View File
@@ -12,6 +12,7 @@ import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs
import { ModelSelect } from "./ModelSelect";
import { Account } from "./settings/Account";
import { Templates } from "./settings/Templates";
import { PersonalModes } from "./settings/PersonalModes";
import { AgentsEditor } from "./settings/AgentsEditor";
import { Folders } from "./settings/Folders";
import { Users } from "./settings/Users";
@@ -257,7 +258,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
chromium: c.chromium ?? "chromium",
widths: (c.widths ?? []).join(", "),
images_enabled: cfg.config.images?.enabled ?? false,
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? "${FAL_KEY}"),
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? ""),
images_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell",
images_dir: cfg.config.images?.output_dir ?? "generated",
});
@@ -344,7 +345,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<label>{t("settings.imagesEnabled")}</label>
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
<label>{t("settings.imagesKey")}</label>
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "${FAL_KEY}"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "fal.ai API key"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
<label>{t("settings.imagesDefault")}</label>
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
{(cfg.config.images?.models ?? []).map((m: any) => (
@@ -385,14 +386,16 @@ interface ModelRow {
max_concurrency?: number;
fallback?: string;
options?: Record<string, unknown>;
price_tiers?: { above_context: number; input_price?: number; output_price?: number; cache_read_price?: number; cache_write_price?: number }[];
price_multipliers?: { factor: number; days?: string[]; hours_utc?: [string, string][]; from?: string; until?: string }[];
}
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "${ANTHROPIC_API_KEY}" },
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "${GEMINI_API_KEY}" },
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "${OPENCODE_API_KEY}" },
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "${FAL_KEY}", auth_prefix: "Key " },
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "${OPENROUTER_API_KEY}" },
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "" },
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "" },
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "" },
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "", auth_prefix: "Key " },
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "" },
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
custom: { kind: "openai", base_url: "http://", api_key: "" },
};
@@ -401,11 +404,23 @@ function normalizeModels(models: Json[]): ModelRow[] {
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
}
/** Readable summary of long-context tiers and time factors (tooltip); empty if there are none. */
function priceExtras(m: ModelRow): string {
const lines = [
...(m.price_tiers ?? []).map((t) => `> ${Math.round(t.above_context / 1000)}k context: ${[t.input_price, t.output_price].map((x) => x ?? "–").join(" / ")}`),
...(m.price_multipliers ?? []).map((x) =>
[`×${x.factor}`, x.days?.join(","), x.hours_utc?.map(([a, b]) => `${a}–${b} UTC`).join(", "), x.from && `from ${x.from}`, x.until && `until ${x.until}`].filter(Boolean).join(" "),
),
];
return lines.join("\n");
}
function cleanModel(m: ModelRow) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(m)) {
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
if (v === false && (k === "reasoning" || k === "vision")) continue;
if (Array.isArray(v) && v.length === 0) continue;
out[k] = v;
}
return out;
@@ -428,7 +443,20 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
const f = byId.get(m.id);
if (!f) return m;
n++;
return { ...m, input_price: f.input_price ?? m.input_price, output_price: f.output_price ?? m.output_price, cache_read_price: f.cache_read_price ?? m.cache_read_price, cache_write_price: f.cache_write_price ?? m.cache_write_price, context: m.context ?? f.context };
return {
...m,
input_price: f.input_price ?? m.input_price,
output_price: f.output_price ?? m.output_price,
cache_read_price: f.cache_read_price ?? m.cache_read_price,
cache_write_price: f.cache_write_price ?? m.cache_write_price,
// Long-context tiers and time factors replace the stored ones when the provider publishes them.
price_tiers: f.price_tiers ?? m.price_tiers,
price_multipliers: f.price_multipliers ?? m.price_multipliers,
context: m.context ?? f.context,
max_output: m.max_output ?? f.max_output,
vision: m.vision || !!f.vision,
reasoning: m.reasoning || !!f.reasoning,
};
}),
);
setPriceNote(t("settings.pricesFetched", { n }));
@@ -454,6 +482,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
{ path: ["providers", name, "max_concurrency"], value: form.max_concurrency ? Number(form.max_concurrency) : null },
{ path: ["providers", name, "monthly_budget"], value: form.monthly_budget ? Number(form.monthly_budget) : null },
{ path: ["providers", name, "plan_limit"], value: form.plan_limit ? Number(form.plan_limit) : null },
];
// Empty key field = keep the stored (masked) key.
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
@@ -461,6 +490,33 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
return savePatches(patches);
});
// Check URL and key as entered, without saving.
const [testResult, setTestResult] = useState<{ ok: boolean; text: string }>();
const [testing, setTesting] = useState(false);
const test = async () => {
setTesting(true);
setTestResult(undefined);
try {
const r = await post<{ status: ProviderStatus; models: number }>("/providers/test", {
name,
kind: form.kind,
base_url: form.base_url,
api_key: form.api_key,
auth_prefix: form.auth_prefix ?? "",
});
const st = r.status;
setTestResult(
st.up
? { ok: true, text: t("settings.testOk", { ms: st.latency_ms, n: r.models }) }
: { ok: false, text: `${st.state === "auth" ? t("settings.providerAuth") : t("settings.providerDown")}: ${st.error ?? ""}` },
);
} catch (e: any) {
setTestResult({ ok: false, text: e.message });
} finally {
setTesting(false);
}
};
const remove = async () => {
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
void run(() => savePatches([{ path: ["providers", name], value: null }]));
@@ -506,7 +562,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<input
type="password"
value={form.api_key}
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : "${ENV_VAR}"}
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : ""}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
/>
<div className="hint">{t("settings.apiKeyHint")}</div>
@@ -514,10 +570,17 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
<label>{t("settings.monthlyBudget")}</label>
<input type="number" min={0} step="1" value={form.monthly_budget ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
{/opencode\.ai\/(inference|zen)\/go/.test(form.base_url ?? "") && (
<>
<label>{t("settings.planLimit")}</label>
<input type="number" min={0} step="1" value={form.plan_limit ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, plan_limit: e.target.value })} />
<div className="hint">{t("settings.planLimitHint")}</div>
</>
)}
{form.kind === "anthropic" && (
<>
<label>{t("settings.adminKey")}</label>
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "${ANTHROPIC_ADMIN_KEY}"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "sk-ant-admin01-…"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
<div className="hint">{t("settings.adminKeyHint")}</div>
</>
)}
@@ -528,7 +591,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<h3 style={{ marginTop: 16 }}>
{t("settings.models")}
<span className="spacer" />
{form.base_url?.includes("openrouter.ai") && (
{(form.base_url?.includes("openrouter.ai") || form.base_url?.includes("opencode.ai")) && (
<button className="small" onClick={fetchPrices}>
{t("settings.fetchPrices")}
</button>
@@ -594,6 +657,11 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
{(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => (
<td key={k}>
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
{k === "cache_write_price" && priceExtras(m) && (
<span className="chip price-extra" title={priceExtras(m)}>
+{(m.price_tiers?.length ?? 0) + (m.price_multipliers?.length ?? 0)}
</span>
)}
</td>
))}
<td>
@@ -614,7 +682,15 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
</div>
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
{view}
{testResult && (
<div className={`notice ${testResult.ok ? "ok" : "err"}`} style={{ marginTop: 12 }}>
{testResult.text}
</div>
)}
<div className="row" style={{ marginTop: 12 }}>
<button onClick={test} disabled={testing || !form.base_url}>
<Icon name="plug" /> {testing ? t("settings.testing") : t("settings.testConnection")}
</button>
<button className="primary" onClick={save}>
{t("common.save")}
</button>
@@ -637,9 +713,10 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
const providers = cfg.config.providers ?? {};
const [status, setStatus] = useState<Record<string, ProviderStatus>>({});
const [checking, setChecking] = useState(false);
// Saving a provider re-checks it on the server, so refresh whenever the config changes.
useEffect(() => {
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
}, []);
}, [cfg]);
const recheck = () => {
setChecking(true);
post<Record<string, ProviderStatus>>("/providers/status")
@@ -963,8 +1040,8 @@ function Raw({ reload }: { reload: () => void }) {
// --- page -------------------------------------------------------------------
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "account", "raw"] as const;
const USER_TABS = ["general", "templates", "account"] as const;
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "templates", "modes", "account", "raw"] as const;
const USER_TABS = ["general", "templates", "modes", "account"] as const;
const TAB_ICONS: Record<string, string> = {
general: "gear",
providers: "brain",
@@ -976,6 +1053,7 @@ const TAB_ICONS: Record<string, string> = {
tools: "toolbox",
audit: "scroll",
templates: "template",
modes: "sparkle",
account: "user",
raw: "file-text",
};
@@ -1012,6 +1090,7 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
{!isAdmin && active === "general" && <InterfaceSettings />}
{active === "account" && <Account />}
{active === "templates" && <Templates />}
{active === "modes" && <PersonalModes />}
{cfg && active === "users" && <Users />}
{cfg && active === "folders" && <Folders />}
{cfg && active === "audit" && <Audit />}
+9 -6
View File
@@ -1,10 +1,11 @@
import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, patch, post } from "../api";
import { promptDialog, toast } from "../dialogs";
import { fmtRelative, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { navigate, openBeside } from "../router";
import { useStore } from "../store";
import type { Session } from "../types";
@@ -81,6 +82,9 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
<Icon name="pin" /> {t(s.pinned ? "sidebar.unpin" : "sidebar.pin")}
</button>
)}
<button role="menuitem" className="hide-mobile" onClick={() => (setMenu(false), openBeside(s.id))}>
<Icon name="external" /> {t("sidebar.openBeside")}
</button>
<button role="menuitem" onClick={() => (setMenu(false), void moveTo())}>
<Icon name="folder" /> {t("sidebar.moveTo")}
</button>
@@ -98,11 +102,10 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
);
}
export function Sidebar({ open, activeId, section }: { open: boolean; activeId?: string; section: string }) {
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
const t = useT();
const sessions = useStore((s) => s.sessions);
const busy = useStore((s) => s.busy);
const connected = useStore((s) => s.connected);
const permissions = useStore((s) => s.permissions);
const questions = useStore((s) => s.questions);
const me = useStore((s) => s.me);
@@ -187,7 +190,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
<img src="/icon.svg" alt="" />
CodeCraft
</div>
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
<StatusLeds />
</div>
<div style={{ padding: "0 12px 8px" }}>
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
@@ -210,7 +213,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
<div className="session-group">{t("sidebar.searchResults", { n: hits.length })}</div>
{hits.length === 0 && <div className="empty">{t("sidebar.noResults")}</div>}
{hits.map((h) => (
<button key={h.id} className={`session-item ${h.id === activeId ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
<button key={h.id} className={`session-item ${activeIds.includes(h.id) ? "active" : ""}`} onClick={() => navigate(`/s/${h.id}`)}>
<div className="title">
<span>{h.title || t("session.untitled")}</span>
</div>
@@ -242,7 +245,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</button>
{!collapsed.has(g.id) &&
g.items.map((s) => (
<SessionRow key={s.id} s={s} active={s.id === activeId} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
))}
</div>
))}
+243
View File
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { get, post } from "../api";
import { fmtCost, fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { Icon } from "./Icon";
import { Bar, type Plan } from "./Budget";
type Level = "ok" | "warn" | "err" | "off";
interface ProviderState {
state: "ok" | "auth" | "down" | "unknown";
latency_ms?: number;
checked_at?: number;
error?: string | null;
models: number;
plan?: Plan | null;
}
interface McpEntry {
state: "connected" | "connecting" | "failed";
tools?: number;
error?: string | null;
}
interface Meter {
key: string;
provider: string;
kind: "total" | "provider" | "images" | "5h" | "week" | "month";
used: number;
limit: number;
ratio: number;
}
interface Overview {
version: string;
providers: Record<string, ProviderState>;
mcp: Record<string, McpEntry>;
/** Budgets and plan windows (admins only). */
budgets?: Meter[];
currency?: string;
}
const meterLevel = (m: Meter): Level => (m.ratio >= 1 ? "err" : m.ratio >= 0.8 ? "warn" : "ok");
const POLL_MS = 30_000;
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
const RECONNECT_GRACE_MS = 10_000;
/** Combined level of a group: all fine → ok, some broken → warn, none working → err. */
function combine(levels: Level[]): Level {
const active = levels.filter((l) => l !== "off");
if (active.length === 0) return "off";
if (active.every((l) => l === "ok")) return "ok";
if (active.some((l) => l === "ok" || l === "warn")) return "warn";
return "err";
}
/** The fullest window of a subscription plan (5 hours, week, month). */
const fullest = (plan?: Plan | null) => plan?.windows.reduce((a, b) => (b.ratio > a.ratio ? b : a));
const providerLevel = (p: ProviderState): Level => {
const health: Level = p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err";
const w = fullest(p.plan);
// A used-up plan blocks requests just like a rejected key.
const plan: Level = !w ? "ok" : w.ratio >= 1 ? "err" : w.ratio >= 0.8 ? "warn" : "ok";
return health === "err" || plan === "err" ? "err" : health === "warn" || plan === "warn" ? "warn" : "ok";
};
const mcpLevel = (m: McpEntry): Level => (m.state === "connected" ? "ok" : m.state === "connecting" ? "warn" : "err");
function Led({ level, title }: { level: Level; title?: string }) {
return <span className={`led led-${level}`} title={title} />;
}
/** Three status LEDs (server, models, MCP) in the sidebar; a click opens the details. */
export function StatusLeds() {
const t = useT();
const connected = useStore((s) => s.connected);
const admin = useStore((s) => s.me?.role === "admin");
const [data, setData] = useState<Overview>();
const [open, setOpen] = useState(false);
const [checking, setChecking] = useState(false);
const [lostAt, setLostAt] = useState<number>();
const [pos, setPos] = useState<{ left: number; top: number }>();
const [, tick] = useState(0);
const root = useRef<HTMLDivElement>(null);
const load = useCallback(() => get<Overview>("/status").then(setData).catch(() => {}), []);
useEffect(() => {
void load();
const id = setInterval(load, POLL_MS);
return () => clearInterval(id);
}, [load]);
// Track when the event connection dropped (yellow while reconnecting, red after a while).
useEffect(() => {
if (connected) {
setLostAt(undefined);
void load();
return;
}
setLostAt((v) => v ?? Date.now());
const id = setTimeout(() => tick((n) => n + 1), RECONNECT_GRACE_MS + 100);
return () => clearTimeout(id);
}, [connected, load]);
// Close on outside click and Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => !root.current?.contains(e.target as Node) && setOpen(false);
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const server: Level = connected ? "ok" : lostAt && Date.now() - lostAt > RECONNECT_GRACE_MS ? "err" : "warn";
const providers = Object.entries(data?.providers ?? {});
const mcp = Object.entries(data?.mcp ?? {});
const models: Level = data ? (providers.length ? combine(providers.map(([, p]) => providerLevel(p))) : "err") : "off";
const tools: Level = combine(mcp.map(([, m]) => mcpLevel(m)));
const label = (l: Level) => t(`status.level.${l}` as Key);
const summary = `${t("status.server")}: ${label(server)} · ${t("status.models")}: ${label(models)} · ${t("status.mcp")}: ${label(tools)}`;
const recheck = async () => {
setChecking(true);
try {
await post("/providers/status");
await load();
} finally {
setChecking(false);
}
};
const go = (tab: string) => {
setOpen(false);
navigate(`/settings/${tab}`);
};
const providerText = (p: ProviderState) =>
p.state === "ok"
? t("status.providerOk", { n: p.models, ms: p.latency_ms ?? 0 })
: p.state === "auth"
? t("settings.providerAuth")
: p.state === "down"
? t("settings.providerDown")
: t("status.notChecked");
return (
<div className="status-leds" ref={root}>
<button
className="ghost status-leds-btn"
onClick={(e) => {
// Fixed position below the LEDs, kept inside the window.
const r = e.currentTarget.getBoundingClientRect();
setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - 328)), top: r.bottom + 6 });
setOpen(!open);
}} title={summary} aria-label={summary} aria-expanded={open}>
<Led level={server} />
<Led level={models} />
<Led level={tools} />
</button>
{open && (
<div className="status-pop" role="dialog" aria-label={t("status.title")} style={pos}>
<div className="status-section">
<div className="status-head">
<Led level={server} /> {t("status.server")}
</div>
<div className="status-row">
<span className="grow">{connected ? t("sidebar.connected") : t("sidebar.disconnected")}</span>
{data && <span className="hint">v{data.version}</span>}
</div>
</div>
<div className="status-section">
<div className="status-head">
<Led level={models} /> {t("status.models")}
<span className="spacer" />
{admin && (
<button className="small ghost" onClick={recheck} disabled={checking} title={t("status.recheck")}>
<Icon name="refresh" size={13} />
</button>
)}
</div>
{providers.length === 0 && <div className="status-row hint">{t("status.noProviders")}</div>}
{providers.map(([name, p]) => (
<div key={name} className="status-row" title={p.error ?? undefined}>
<Led level={providerLevel(p)} />
<span className="grow mono">{name}</span>
<span className="hint">
{providerText(p)}
{fullest(p.plan) && ` · ${t("status.plan", { pct: fmtPercent(fullest(p.plan)!.ratio), window: t(`budget.window.${fullest(p.plan)!.window}` as Key) })}`}
</span>
</div>
))}
</div>
<div className="status-section">
<div className="status-head">
<Led level={tools} /> {t("status.mcp")}
</div>
{mcp.length === 0 && <div className="status-row hint">{t("status.noMcp")}</div>}
{mcp.map(([name, m]) => (
<div key={name} className="status-row" title={m.error ?? undefined}>
<Led level={mcpLevel(m)} />
<span className="grow mono">{name}</span>
<span className="hint">{m.state === "connected" ? t("mcp.tools", { n: m.tools ?? 0 }) : t(`mcp.state.${m.state}` as Key)}</span>
</div>
))}
</div>
{!!data?.budgets?.length && (
<div className="status-section">
<div className="status-head">
<Led level={combine(data.budgets.map(meterLevel))} /> {t("status.budgets")}
</div>
{data.budgets.map((m) => (
<div key={m.key} className="status-row status-meter" title={`${fmtCost(m.used, data.currency ?? "USD")} / ${fmtCost(m.limit, data.currency ?? "USD")}`}>
<Led level={meterLevel(m)} />
<span className="grow">{m.kind === "total" ? t("stats.total") : m.kind === "images" ? t("nav.images") : m.kind === "provider" ? m.provider : `${m.provider} · ${t(`budget.window.${m.kind}` as Key)}`}</span>
<Bar share={m.ratio} warning={m.ratio >= 0.8} exceeded={m.ratio >= 1} />
<span className="hint pct">{fmtPercent(m.ratio)}</span>
</div>
))}
</div>
)}
{admin && (
<div className="status-foot">
<button className="small ghost" onClick={() => go("providers")}>
<Icon name="brain" size={13} /> {t("status.providerSettings")}
</button>
<button className="small ghost" onClick={() => go("mcp")}>
<Icon name="plug" size={13} /> {t("status.mcpSettings")}
</button>
</div>
)}
</div>
)}
</div>
);
}
+3 -2
View File
@@ -10,9 +10,10 @@ import { onThemeChange } from "../theme";
import { toast } from "../dialogs";
import { Icon } from "./Icon";
interface TermInfo {
export interface TermInfo {
id: string;
cwd: string;
session_id?: string | null;
created_at: number;
alive: boolean;
attached: boolean;
@@ -132,7 +133,7 @@ export function TerminalPage({ id, onMenu }: { id?: string; onMenu: () => void }
);
}
function TermView({ info }: { info: TermInfo }) {
export function TermView({ info }: { info: TermInfo }) {
const t = useT();
const host = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
+3 -3
View File
@@ -1,5 +1,5 @@
import { Icon } from "./Icon";
import { base64Size, FileFigure } from "./FileFigure";
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
import { useT } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
@@ -166,8 +166,8 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
</div>
) : (
result?.images?.map((img, i) => (
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
<img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
</FileFigure>
))
)}
+2
View File
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
import { ApiTokens } from "./ApiTokens";
import { CliInstall } from "./CliInstall";
import { Memory } from "./Memory";
import { Telegram } from "./Telegram";
export function Account() {
const t = useT();
@@ -51,6 +52,7 @@ export function Account() {
<Memory />
<GitAccount />
<CliInstall />
<Telegram />
<ApiTokens />
</>
);
+70 -10
View File
@@ -9,25 +9,60 @@ import { confirmDialog, promptDialog, toast } from "../../dialogs";
type Patch = { path: string[]; value: unknown };
export interface ToolInfo {
name: string;
read_only: boolean;
description: string;
}
/** Tool checkboxes: nothing checked = all tools. */
export function ToolPicker({ tools, value, onChange, readOnly }: { tools: ToolInfo[]; value: string[]; onChange: (v: string[]) => void; readOnly: boolean }) {
const t = useT();
const toggle = (n: string) => onChange(value.includes(n) ? value.filter((x) => x !== n) : [...value, n]);
// Names configured by hand (e.g. MCP patterns) stay selected even if not listed.
const extra = value.filter((v) => !tools.some((x) => x.name === v));
return (
<div className="tool-picker">
<div className="hint">{value.length ? t("agents.toolsSome", { n: value.length }) : t("agents.toolsAll")}</div>
<div className="tool-grid">
{tools.map((x) => (
<label key={x.name} className={`tool-item ${readOnly && !x.read_only ? "disabled" : ""}`} title={x.description}>
<input type="checkbox" checked={value.includes(x.name)} disabled={readOnly && !x.read_only} onChange={() => toggle(x.name)} />
<span className="mono">{x.name}</span>
</label>
))}
{extra.map((n) => (
<label key={n} className="tool-item">
<input type="checkbox" checked onChange={() => toggle(n)} />
<span className="mono">{n}</span>
</label>
))}
</div>
</div>
);
}
const WRITER_PROMPT = `You are a technical writer. Produce clear, well-structured text (Markdown) for
documentation, summaries, step-by-step guides and translations.
Return only the finished text.`;
function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: boolean, text: string) => void }) {
function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: ToolInfo[]; onSaved: (ok: boolean, text: string) => void }) {
const t = useT();
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(!!raw && !raw.prompt && !raw.description && raw.mode === "primary");
const [form, setForm] = useState({
model: a.model ?? "",
max_parallel: a.max_parallel ? String(a.max_parallel) : "",
description: raw?.description ?? "",
tools: (raw?.tools ?? []).join(", "),
tools: (raw?.tools ?? []) as string[],
read_only: !!(raw?.read_only ?? a.read_only),
style: (raw?.style ?? a.style ?? "coding") as "coding" | "chat",
prompt: raw?.prompt ?? "",
mode: raw?.mode ?? a.mode,
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
refine_model: raw?.refine?.model ?? "",
refine_criteria: raw?.refine?.criteria ?? "",
});
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
const save = async () => {
const base = ["agents", a.name];
@@ -45,7 +80,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
if (!builtin) {
patches.push({ path: [...base, "mode"], value: form.mode });
patches.push({ path: [...base, "tools"], value: form.tools.trim() ? form.tools.split(/[\s,]+/).filter(Boolean) : null });
patches.push({ path: [...base, "tools"], value: form.tools.length ? form.tools : null });
patches.push({ path: [...base, "read_only"], value: form.read_only || null });
patches.push({ path: [...base, "style"], value: form.style === "chat" ? "chat" : null });
patches.push({ path: [...base, "prompt"], value: form.prompt || null });
}
try {
@@ -133,8 +170,23 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
<option value="subagent">{t("agents.modeSubagent")}</option>
<option value="primary">{t("agents.modePrimary")}</option>
</select>
{form.mode === "primary" && (
<>
<label>{t("agents.style")}</label>
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
<option value="coding">{t("agents.styleCoding")}</option>
<option value="chat">{t("agents.styleChat")}</option>
</select>
<div className="hint">{t("agents.styleHint")}</div>
</>
)}
<label>{t("agents.readOnlyLabel")}</label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
<span className="hint">{t("agents.readOnlyHint")}</span>
</label>
<label>{t("agents.tools")}</label>
<input value={form.tools} placeholder={t("agents.toolsAll")} onChange={(e) => setForm({ ...form, tools: e.target.value })} className="mono" />
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
<label>{t("agents.prompt")}</label>
<textarea rows={6} value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
</>
@@ -159,7 +211,10 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<string, any>; reloadConfig: () => void }) {
const t = useT();
const { data: agents, reload } = useFetch<AgentInfo[]>("/agents");
const { data: all, reload } = useFetch<AgentInfo[]>("/agents");
// Personal modes are managed under "My modes".
const agents = all?.filter((a) => a.source !== "personal");
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
const cmds = useFetch<{ commands: any[]; skills: any[] }>("/commands").data;
const [notice, setNotice] = useState<{ ok: boolean; text: string }>();
const [name, setName] = useState("");
@@ -172,12 +227,14 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
}
};
const add = async (template: "writer" | "blank") => {
const add = async (template: "writer" | "blank" | "mode") => {
const n = name.trim() || (template === "writer" ? "writer" : "");
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
const value =
template === "writer"
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
: template === "mode"
? { mode: "primary" }
: { mode: "subagent", description: "", prompt: "" };
try {
await patch("/config", { patches: [{ path: ["agents", n], value }] });
@@ -194,13 +251,16 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
<div className="notice">{t("agents.intro")}</div>
{notice && <div className={`notice ${notice.ok ? "ok" : "err"}`}>{notice.text}</div>}
<h3 style={{ fontSize: 14 }}>{t("agents.primaryTitle")}</h3>
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
{agents?.filter((a) => a.mode === "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
<h3 style={{ fontSize: 14 }}>{t("agents.subagentTitle")}</h3>
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} onSaved={saved} />)}
{agents?.filter((a) => a.mode !== "primary").map((a) => <AgentRow key={key(a)} a={a} raw={rawAgents[a.name]} tools={tools} onSaved={saved} />)}
<div className="panel">
<h3>{t("agents.add")}</h3>
<div className="row">
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} />
<button className="primary" onClick={() => add("mode")} disabled={!name.trim()}>
+ {t("agents.addMode")}
</button>
<button onClick={() => add("writer")}>+ {t("agents.addWriter")}</button>
<button onClick={() => add("blank")} disabled={!name.trim()}>
+ {t("agents.addBlank")}
+49 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { get, patch, post } from "../../api";
import { toast } from "../../dialogs";
import { useEffect, useRef, useState } from "react";
import { api, get, patch, post } from "../../api";
import { confirmDialog, toast } from "../../dialogs";
import { useFetch } from "../../hooks";
import { fmtDate, useT } from "../../i18n";
import { formatSize } from "../FileFigure";
@@ -90,6 +90,32 @@ export function Backups() {
}
};
// Restore: the server stages the backup, restarts and applies it before opening the database.
const fileInput = useRef<HTMLInputElement>(null);
const [restoring, setRestoring] = useState(false);
const restore = async (source: { name: string } | { file: File }) => {
const label = "name" in source ? source.name : source.file.name;
const ok = await confirmDialog({ title: t("backup.restoreTitle"), message: t("backup.restoreConfirm", { file: label }), confirmLabel: t("backup.restore"), danger: true });
if (!ok) return;
setRestoring(true);
try {
if ("name" in source) await post(`/backups/${encodeURIComponent(source.name)}/restore`);
else await api("/backups/restore", { method: "POST", body: source.file, headers: { "Content-Type": "application/zip" } });
toast(t("backup.restarting"), "info");
// Wait until the server is gone and back, then reload with the restored data.
await new Promise((r) => setTimeout(r, 2500));
for (let i = 0; i < 120; i++) {
if (await fetch("/api/health").then((r) => r.ok).catch(() => false)) return location.reload();
await new Promise((r) => setTimeout(r, 1000));
}
toast(t("backup.notBack"));
} catch (e: any) {
toast(e.message);
} finally {
setRestoring(false);
}
};
const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") =>
form && (
<label className="field">
@@ -111,6 +137,20 @@ export function Backups() {
<a className="button small" href="/api/backup">
<Icon name="download" /> {t("audit.download")}
</a>
<button className="small" onClick={() => fileInput.current?.click()} disabled={restoring}>
<Icon name="upload" /> {restoring ? t("backup.restoring") : t("backup.restoreFile")}
</button>
<input
ref={fileInput}
type="file"
accept=".zip,application/zip"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) void restore({ file });
}}
/>
<button className="small primary" onClick={runNow} disabled={busy}>
{busy ? t("backup.running") : t("backup.now")}
</button>
@@ -176,12 +216,17 @@ export function Backups() {
{data && data.files.length > 0 && (
<div className="backup-list">
{data.files.map((f) => (
<a key={f.name} href={`/api/backups/${encodeURIComponent(f.name)}`} className="backup-file">
<div key={f.name} className="backup-file">
<Icon name="file" />
<span className="grow">{f.name}</span>
<span className="hint">{formatSize(f.size)}</span>
<button className="small" title={t("backup.restore")} onClick={() => void restore({ name: f.name })} disabled={restoring}>
<Icon name="undo" />
</button>
<a className="button small" title={t("audit.download")} href={`/api/backups/${encodeURIComponent(f.name)}`}>
<Icon name="download" />
</a>
</div>
))}
</div>
)}
@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import { get, put } from "../../api";
import { confirmDialog, toast } from "../../dialogs";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import { Icon } from "../Icon";
import { ModelSelect } from "../ModelSelect";
import { ToolPicker, type ToolInfo } from "./AgentsEditor";
interface Mode {
description?: string | null;
prompt?: string | null;
model?: string | null;
tools?: string[] | null;
read_only?: boolean | null;
style?: "coding" | "chat" | null;
}
/** Modes a user creates for themselves; they appear in New session and the mode selector. */
export function PersonalModes() {
const t = useT();
const tools = useFetch<ToolInfo[]>("/agents/tools").data ?? [];
const [modes, setModes] = useState<Record<string, Mode>>();
const [open, setOpen] = useState<string>();
const [name, setName] = useState("");
useEffect(() => {
get<Record<string, Mode>>("/account/modes")
.then(setModes)
.catch((e) => toast(e));
}, []);
const save = async (next: Record<string, Mode>) => {
try {
setModes(await put<Record<string, Mode>>("/account/modes", next));
toast(t("settings.saved"), "success");
return true;
} catch (e) {
toast(e);
return false;
}
};
const add = async () => {
const n = name.trim();
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
if (modes?.[n]) return toast(t("modes.exists", { name: n }));
if (await save({ ...modes, [n]: { style: "chat", description: "", prompt: "" } })) {
setName("");
setOpen(n);
}
};
const remove = async (n: string) => {
if (!(await confirmDialog({ message: t("agents.deleteConfirm", { name: n }), danger: true, confirmLabel: t("common.delete") }))) return;
const next = { ...modes };
delete next[n];
void save(next);
};
if (!modes) return <div className="empty">{t("common.loading")}</div>;
const names = Object.keys(modes).sort();
return (
<>
<div className="notice">{t("modes.intro")}</div>
{names.map((n) => (
<ModeRow key={n + JSON.stringify(modes[n])} name={n} mode={modes[n]} tools={tools} open={open === n} onToggle={() => setOpen(open === n ? undefined : n)} onSave={(m) => save({ ...modes, [n]: m })} onRemove={() => remove(n)} />
))}
{names.length === 0 && <div className="empty">{t("modes.none")}</div>}
<div className="panel">
<h3>{t("agents.addMode")}</h3>
<div className="row">
<input placeholder={t("settings.name")} value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && void add()} />
<button className="primary" onClick={() => void add()} disabled={!name.trim()}>
+ {t("agents.addMode")}
</button>
</div>
</div>
</>
);
}
function ModeRow({ name, mode, tools, open, onToggle, onSave, onRemove }: { name: string; mode: Mode; tools: ToolInfo[]; open: boolean; onToggle: () => void; onSave: (m: Mode) => Promise<boolean>; onRemove: () => void }) {
const t = useT();
const [form, setForm] = useState({
description: mode.description ?? "",
prompt: mode.prompt ?? "",
model: mode.model ?? "",
tools: mode.tools ?? [],
read_only: !!mode.read_only,
style: (mode.style ?? "coding") as "coding" | "chat",
});
const submit = () =>
void onSave({
description: form.description || null,
prompt: form.prompt || null,
model: form.model || null,
tools: form.tools.length ? form.tools : null,
read_only: form.read_only || null,
style: form.style === "chat" ? "chat" : null,
});
return (
<div className="panel">
<h3>
{name}
<span className="chip">{form.style === "chat" ? t("agents.styleChatShort") : t("agents.styleCodingShort")}</span>
{form.read_only && <span className="chip">{t("agents.readOnly")}</span>}
<span className="spacer" />
<button className="small ghost danger" onClick={onRemove} title={t("common.delete")} aria-label={t("common.delete")}>
<Icon name="trash" />
</button>
<button className="small" onClick={onToggle}>
{open ? t("common.collapse") : t("common.edit")}
</button>
</h3>
{!open && mode.description && <div className="hint">{mode.description}</div>}
{open && (
<>
<div className="form">
<label>{t("agents.description")}</label>
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<label>{t("agents.style")}</label>
<select value={form.style} onChange={(e) => setForm({ ...form, style: e.target.value as "coding" | "chat" })}>
<option value="coding">{t("agents.styleCoding")}</option>
<option value="chat">{t("agents.styleChat")}</option>
</select>
<label>{t("agents.model")}</label>
<ModelSelect value={form.model} onChange={(v) => setForm({ ...form, model: v })} emptyLabel={t("agents.sessionModel")} />
<label>{t("agents.readOnlyLabel")}</label>
<label className="row" style={{ gap: 6, alignItems: "center" }}>
<input type="checkbox" checked={form.read_only} onChange={(e) => setForm({ ...form, read_only: e.target.checked })} />
<span className="hint">{t("agents.readOnlyHint")}</span>
</label>
<label>{t("agents.tools")}</label>
<ToolPicker tools={tools} value={form.tools} onChange={(v) => setForm({ ...form, tools: v })} readOnly={form.read_only} />
<label>{t("agents.prompt")}</label>
<textarea rows={8} value={form.prompt} placeholder={t("modes.promptPlaceholder")} onChange={(e) => setForm({ ...form, prompt: e.target.value })} />
</div>
<div className="row" style={{ marginTop: 12 }}>
<button className="primary" onClick={submit}>
{t("common.save")}
</button>
</div>
</>
)}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useState } from "react";
import { del, put } from "../../api";
import { confirmDialog, toast } from "../../dialogs";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import { Icon } from "../Icon";
interface Status {
configured: boolean;
bot: string;
paired: boolean;
pair_code: string;
running: boolean;
error?: string | null;
}
/** Connect a personal Telegram bot as a chat interface. */
export function Telegram() {
const t = useT();
const { data, reload } = useFetch<Status>("/account/telegram");
const [token, setToken] = useState("");
const [busy, setBusy] = useState(false);
const connect = async () => {
setBusy(true);
try {
await put("/account/telegram", { token: token.trim() });
setToken("");
reload();
} catch (e) {
toast(e);
} finally {
setBusy(false);
}
};
const disconnect = async () => {
if (!(await confirmDialog({ message: t("telegram.disconnectConfirm"), danger: true, confirmLabel: t("telegram.disconnect") }))) return;
await del("/account/telegram").catch((e) => toast(e));
reload();
};
return (
<div className="panel">
<h3>
<Icon name="share" /> {t("telegram.title")}
<span className="spacer" />
{data?.configured && (
<button className="small ghost danger" onClick={disconnect}>
{t("telegram.disconnect")}
</button>
)}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("telegram.intro")}
</div>
{data && !data.configured && (
<>
<ol className="cli-steps">
<li>{t("telegram.step1")}</li>
<li>{t("telegram.step2")}</li>
</ol>
<div className="row" style={{ gap: 6 }}>
<input
type="text"
name="telegram-bot-token"
value={token}
placeholder="123456789:AAH…"
autoComplete="off"
spellCheck={false}
className="mono"
onChange={(e) => setToken(e.target.value)}
style={{ flex: 1 }}
/>
<button className="primary" onClick={() => void connect()} disabled={busy || !token.trim()}>
{busy ? t("telegram.checking") : t("telegram.connect")}
</button>
</div>
</>
)}
{data?.configured && (
<>
<div className="row" style={{ gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<span className="chip">@{data.bot}</span>
<span className={`chip ${data.running && !data.error ? "ok" : "warn"}`}>{data.running && !data.error ? t("telegram.running") : t("telegram.stopped")}</span>
{data.paired && <span className="chip ok">{t("telegram.paired")}</span>}
</div>
{!data.paired && data.pair_code && (
<div className="notice" style={{ marginTop: 10 }}>
{t("telegram.pairHint", { bot: data.bot })}{" "}
<a href={`https://t.me/${data.bot}?start=${data.pair_code}`} target="_blank" rel="noreferrer">
t.me/{data.bot}
</a>
<div style={{ marginTop: 6 }}>
<code className="token-value">/start {data.pair_code}</code>
</div>
</div>
)}
{data.error && <div className="notice err" style={{ marginTop: 10 }}>{data.error}</div>}
{data.paired && <div className="hint" style={{ marginTop: 10 }}>{t("telegram.commands")}</div>}
</>
)}
</div>
);
}
+89 -2
View File
@@ -40,6 +40,13 @@ export const de: Messages = {
"settings.imagesDefault": "Standardmodell",
"settings.imagesDir": "Ordner im Projekt",
"settings.imagesHint": "Modelle und Preise pro Bild werden in der Config-Datei bearbeitet ([images]).",
"budget.plan": "Go-Plan ({v}/Monat)",
"budget.window.5h": "5 Stunden",
"budget.window.week": "7 Tage",
"budget.window.month": "Monat",
"settings.planLimit": "Planlimit (USD/Monat)",
"settings.planLimitHint": "opencode Go: das monatliche Nutzungslimit deines Plans. CodeCraft zählt den Verbrauch, den jede Antwort meldet (5 Stunden 20 %, Woche 50 %, Monat 100 %) – nur Nutzung über CodeCraft.",
"status.plan": "Go {pct} ({window})",
"budget.title": "Budgets in diesem Monat",
"budget.spent": "Verbraucht",
"budget.budget": "Budget",
@@ -78,6 +85,9 @@ export const de: Messages = {
"files.newFolderName": "Ordnername:",
"files.renameTo": "Neuer Name:",
"files.copyTo": "Name der Kopie:",
"files.chatHere": "Chat hier öffnen",
"files.newTab": "Dateimanager öffnen",
"files.closeTab": "Dateimanager schließen",
"files.rename": "Umbenennen",
"files.duplicate": "Duplizieren",
"files.download": "Herunterladen",
@@ -204,6 +214,22 @@ export const de: Messages = {
"sidebar.yesterday": "Gestern",
"sidebar.week": "Letzte 7 Tage",
"sidebar.older": "Älter",
"status.title": "Status",
"status.budgets": "Budgets",
"status.server": "Server",
"status.models": "Modelle",
"status.mcp": "MCP",
"status.level.ok": "alles in Ordnung",
"status.level.warn": "teilweise nicht verfügbar",
"status.level.err": "nicht verfügbar",
"status.level.off": "keine konfiguriert",
"status.providerOk": "{n} Modelle · {ms} ms",
"status.notChecked": "noch nicht geprüft",
"status.recheck": "Erneut prüfen",
"status.noProviders": "Keine Provider aktiv.",
"status.noMcp": "Keine MCP-Server konfiguriert.",
"status.providerSettings": "Provider",
"status.mcpSettings": "MCP-Server",
"sidebar.connected": "Verbunden",
"sidebar.disconnected": "Getrennt – verbinde neu…",
"sidebar.needsInput": "Wartet auf deine Eingabe",
@@ -213,6 +239,9 @@ export const de: Messages = {
"wizard.title": "Was möchtest du machen?",
"wizard.subtitle": "Aufgabe wählen, beschreiben, was du willst, dann auf Start drücken.",
"wizard.whatToDo": "Was möchtest du machen?",
"wizard.task.chat": "Einfach chatten",
"wizard.task.chatDesc": "Fragen, besprechen, nachschlagen – ohne Projekt und Dateien",
"wizard.placeholder.chat": "Worüber möchtest du sprechen?",
"wizard.task.craft": "Etwas craften",
"wizard.task.craftDesc": "Website, App, Spiel oder altes Programm nachbauen – oder etwas Neues bauen",
"wizard.task.build": "Am Code arbeiten",
@@ -280,6 +309,9 @@ export const de: Messages = {
"rename.generate": "Generieren",
"nav.terminal": "Terminal",
"terminal.new": "Neues Terminal",
"terminal.chatTab": "Chat",
"terminal.tab": "Terminal {n}",
"terminal.closeConfirm": "{name} schließen? Die Shell und alles, was darin läuft, wird beendet.",
"terminal.close": "Terminal schließen",
"terminal.here": "Terminal in diesem Ordner öffnen",
"terminal.connecting": "Verbinde…",
@@ -373,6 +405,19 @@ export const de: Messages = {
"tokens.title": "API-Tokens",
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
"telegram.title": "Telegram",
"telegram.intro": "CodeCraft über Telegram mit deinem eigenen Bot nutzen: Nachrichten gehen an deine aktuelle Sitzung, Befehle wechseln Sitzungen, Modi und Modelle, Freigaben und Rückfragen kommen mit Knöpfen. Nur dein Telegram-Konto kann den Bot nutzen.",
"telegram.step1": "In Telegram @BotFather öffnen, /newbot senden und den Schritten folgen.",
"telegram.step2": "Das Token von BotFather hier einfügen.",
"telegram.connect": "Verbinden",
"telegram.checking": "Prüfe…",
"telegram.running": "läuft",
"telegram.stopped": "läuft nicht",
"telegram.paired": "gekoppelt",
"telegram.pairHint": "Letzter Schritt: deinen Bot öffnen und den Code senden (oder den Link antippen) – danach bedient er nur noch dein Telegram-Konto:",
"telegram.commands": "In Telegram: /help zeigt die Befehle – /sessions, /new, /mode, /model, /stop, /status, /costs …",
"telegram.disconnect": "Trennen",
"telegram.disconnectConfirm": "Telegram-Bot trennen? Er antwortet dann nicht mehr; mit seinem Token kannst du ihn wieder verbinden.",
"tokens.copy": "Kopieren",
"tokens.copied": "Kopiert",
"tokens.name": "Name",
@@ -423,6 +468,13 @@ export const de: Messages = {
"shareIn.newChat": "Neuen Chat starten",
"shareIn.appendTo": "oder an eine letzte Session anhängen",
"common.back": "Zurück",
"settings.tab.modes": "Meine Modi",
"modes.intro": "Deine eigenen Modi: ein Prompt, eine Art (Programmieren oder Unterhaltung), Werkzeuge und optional ein Modell. Sie erscheinen in „Neue Sitzung“ und in der Modus-Auswahl deiner Chats – nur für dich.",
"modes.none": "Du hast noch keine eigenen Modi.",
"modes.exists": "Ein Modus namens {name} existiert schon.",
"modes.promptPlaceholder": "z. B. Du bist ein geduldiger Englischlehrer. Korrigiere meine Sätze und erkläre kurz, warum.",
"agents.styleChatShort": "Unterhaltung",
"agents.styleCodingShort": "Programmieren",
"settings.tab.templates": "Vorlagen",
"templates.title": "Prompt-Vorlagen",
"templates.filter": "Vorlagen durchsuchen…",
@@ -455,6 +507,8 @@ export const de: Messages = {
"sidebar.organise": "Session organisieren",
"sidebar.pin": "Oben anpinnen",
"sidebar.unpin": "Nicht mehr anpinnen",
"sidebar.openBeside": "Daneben öffnen",
"chat.closePane": "Spalte schließen",
"sidebar.moveTo": "In Ordner verschieben…",
"sidebar.removeFromFolder": "Aus Ordner entfernen",
"sidebar.archiveOne": "Archivieren",
@@ -464,6 +518,13 @@ export const de: Messages = {
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
"sidebar.folderName": "Ordnername",
"sidebar.move": "Verschieben",
"backup.restore": "Wiederherstellen",
"backup.restoreFile": "Aus Datei wiederherstellen…",
"backup.restoring": "Wird wiederhergestellt…",
"backup.restoreTitle": "Backup wiederherstellen?",
"backup.restoreConfirm": "Datenbank und Konfiguration durch {file} ersetzen? Alle Sitzungen, Benutzer, Statistiken und Einstellungen werden durch den Stand im Backup ersetzt; Dateien im Workspace bleiben unverändert. Der aktuelle Stand wird vorher gesichert. CodeCraft startet neu – laufende Agents werden beendet.",
"backup.restarting": "Backup übernommen – CodeCraft startet neu…",
"backup.notBack": "Der Server ist nicht zurückgekommen. Er braucht eine Restart-Policy (Docker: restart: unless-stopped) – bitte von Hand starten.",
"backup.now": "Jetzt sichern",
"backup.running": "Sichere…",
"backup.created": "Backup {file} erstellt.",
@@ -527,6 +588,20 @@ export const de: Messages = {
"chat.exportMd": "Markdown (.md)",
"chat.exportJson": "JSON (.json)",
"chat.empty": "Schreib eine Nachricht, um zu starten.",
"chat.compactedAuto": "Kontext fast voll ({tokens} Tokens) – Unterhaltung automatisch komprimiert · Zusammenfassung",
"chat.compactedManual": "Unterhaltung bei {tokens} Tokens komprimiert · Zusammenfassung",
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
"chat.compactingManual": "Unterhaltung wird komprimiert",
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
"chat.telegram": "Fragen an Telegram senden",
"chat.telegramHint": "Freigaben und Rückfragen dieses Chats auch per Telegram schicken (der gerade in Telegram geöffnete Chat meldet sich immer)",
"jobs.permissionMode": "Freigabemodus",
"jobs.permissionModeHint": "Gilt ab dem nächsten Lauf (wird in die Datei geschrieben). „Alles erlauben“: der Lauf fragt nie nach und wartet nicht auf Freigaben.",
"instructions.title": "Anweisungen für diesen Chat",
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
"instructions.clear": "Entfernen",
"instructions.chip": "Anweisungen",
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
"chat.context": "Kontext",
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
@@ -625,7 +700,7 @@ export const de: Messages = {
"settings.baseUrl": "Basis-URL",
"settings.apiKey": "API-Key",
"settings.apiKeyStored": "Gespeichert – leer lassen zum Beibehalten",
"settings.apiKeyHint": "Am besten ${ENV_VAR}-Verweise nutzen; direkte Keys stehen im Klartext in der Config-Datei.",
"settings.apiKeyHint": "Wird in der Konfigurationsdatei gespeichert (für Admins lesbar, in Backups enthalten). ${NAME}-Verweise auf Umgebungsvariablen funktionieren weiterhin.",
"settings.authPrefix": "Auth-Präfix",
"settings.enabled": "Aktiv",
"settings.modelCount": "{n} Modelle",
@@ -647,6 +722,9 @@ export const de: Messages = {
"settings.providerName": "Name (optional)",
"settings.providerUp": "erreichbar · {ms} ms",
"settings.providerDown": "nicht erreichbar",
"settings.testConnection": "Verbindung testen",
"settings.testing": "Teste…",
"settings.testOk": "Verbindung klappt: {n} Modelle, {ms} ms. Noch nicht gespeichert.",
"settings.providerAuth": "API-Key fehlt oder ungültig",
"settings.providerCheck": "Provider prüfen",
"settings.providerChecking": "Prüfe…",
@@ -800,6 +878,7 @@ export const de: Messages = {
"agents.modePrimary": "Modus",
"agents.modeSubagent": "Subagent",
"agents.source.builtin": "eingebaut",
"agents.source.personal": "persönlich",
"agents.source.config": "Config",
"agents.source.file": "Datei",
"agents.model": "Modell",
@@ -810,8 +889,16 @@ export const de: Messages = {
"agents.description": "Beschreibung",
"agents.descriptionHint": "Anhand dieser Beschreibung entscheidet der Hauptagent, wann er delegiert.",
"agents.mode": "Art",
"agents.addMode": "Neuer Modus",
"agents.style": "Art",
"agents.styleCoding": "Programmieren – Projekt-Kontext und Coding-Ablauf",
"agents.styleChat": "Unterhaltung – nur dieser Prompt, kein Projekt",
"agents.styleHint": "Unterhaltungs-Modi bekommen nur deinen Prompt plus Datum und persönliches Gedächtnis, wie der eingebaute Chat-Modus.",
"agents.readOnlyLabel": "Nur lesen",
"agents.readOnlyHint": "Nur Werkzeuge, die nichts verändern (wie der Plan-Modus)",
"agents.toolsSome": "{n} Werkzeuge ausgewählt – nur diese sind verfügbar",
"agents.tools": "Tools",
"agents.toolsAll": "alle Tools (z. B. read, glob, grep)",
"agents.toolsAll": "Nichts ausgewählt = alle Werkzeuge",
"agents.prompt": "Anweisungen",
"agents.reset": "Zurücksetzen",
"agents.resetConfirm": "„{name}“ auf die eingebauten Standardwerte zurücksetzen?",
+89 -2
View File
@@ -39,6 +39,13 @@ export const en = {
"settings.imagesDefault": "Default model",
"settings.imagesDir": "Folder in the project",
"settings.imagesHint": "Models and prices per image are edited in the config file ([images]).",
"budget.plan": "Go plan ({v}/month)",
"budget.window.5h": "5 hours",
"budget.window.week": "7 days",
"budget.window.month": "Month",
"settings.planLimit": "Plan limit (USD/month)",
"settings.planLimitHint": "opencode Go: the monthly usage limit of your plan. CodeCraft tracks the allowance each response reports (5 hours 20 %, week 50 %, month 100 %) – only usage through CodeCraft.",
"status.plan": "Go {pct} ({window})",
"budget.title": "Budgets this month",
"budget.spent": "Spent",
"budget.budget": "Budget",
@@ -77,6 +84,9 @@ export const en = {
"files.newFolderName": "Folder name:",
"files.renameTo": "New name:",
"files.copyTo": "Name of the copy:",
"files.chatHere": "Open chat here",
"files.newTab": "Open file manager",
"files.closeTab": "Close file manager",
"files.rename": "Rename",
"files.duplicate": "Duplicate",
"files.download": "Download",
@@ -203,6 +213,22 @@ export const en = {
"sidebar.yesterday": "Yesterday",
"sidebar.week": "Last 7 days",
"sidebar.older": "Older",
"status.title": "Status",
"status.budgets": "Budgets",
"status.server": "Server",
"status.models": "Models",
"status.mcp": "MCP",
"status.level.ok": "all fine",
"status.level.warn": "partly unavailable",
"status.level.err": "unavailable",
"status.level.off": "none configured",
"status.providerOk": "{n} models · {ms} ms",
"status.notChecked": "not checked yet",
"status.recheck": "Check again",
"status.noProviders": "No providers enabled.",
"status.noMcp": "No MCP servers configured.",
"status.providerSettings": "Providers",
"status.mcpSettings": "MCP servers",
"sidebar.connected": "Connected",
"sidebar.disconnected": "Disconnected – reconnecting…",
"sidebar.needsInput": "Waiting for your input",
@@ -212,6 +238,9 @@ export const en = {
"wizard.title": "What do you want to do?",
"wizard.subtitle": "Pick a task, say what you want, then press Start.",
"wizard.whatToDo": "What do you want to do?",
"wizard.task.chat": "Just chat",
"wizard.task.chatDesc": "Ask, discuss, look things up – no project, no files",
"wizard.placeholder.chat": "What would you like to talk about?",
"wizard.task.craft": "Craft something",
"wizard.task.craftDesc": "Recreate a website, app, game or old program – or build something new",
"wizard.task.build": "Work on code",
@@ -279,6 +308,9 @@ export const en = {
"rename.generate": "Generate",
"nav.terminal": "Terminal",
"terminal.new": "New terminal",
"terminal.chatTab": "Chat",
"terminal.tab": "Terminal {n}",
"terminal.closeConfirm": "Close {name}? The shell and everything running in it are stopped.",
"terminal.close": "Close terminal",
"terminal.here": "Open a terminal in this folder",
"terminal.connecting": "Connecting…",
@@ -372,6 +404,19 @@ export const en = {
"tokens.title": "API tokens",
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
"tokens.copyNow": "Copy the token now – it is shown only once:",
"telegram.title": "Telegram",
"telegram.intro": "Use CodeCraft from Telegram with your own bot: messages go to your current session, commands switch sessions, modes and models, approvals and questions come with buttons. Only your Telegram account can use the bot.",
"telegram.step1": "In Telegram, open @BotFather, send /newbot and follow the steps.",
"telegram.step2": "Paste the token BotFather gives you here.",
"telegram.connect": "Connect",
"telegram.checking": "Checking…",
"telegram.running": "running",
"telegram.stopped": "not running",
"telegram.paired": "paired",
"telegram.pairHint": "Last step: open your bot and send the code (or tap the link) – from then on only your Telegram account is served:",
"telegram.commands": "In Telegram: /help lists the commands – /sessions, /new, /mode, /model, /stop, /status, /costs …",
"telegram.disconnect": "Disconnect",
"telegram.disconnectConfirm": "Disconnect the Telegram bot? It stops answering; you can connect it again with its token.",
"tokens.copy": "Copy",
"tokens.copied": "Copied",
"tokens.name": "Name",
@@ -422,6 +467,13 @@ export const en = {
"shareIn.newChat": "Start a new chat",
"shareIn.appendTo": "or add to a recent session",
"common.back": "Back",
"settings.tab.modes": "My modes",
"modes.intro": "Your own modes: a prompt, a kind (coding or conversation), tools and an optional model. They appear in New session and in the mode selector of your chats – only for you.",
"modes.none": "You have no modes of your own yet.",
"modes.exists": "A mode named {name} already exists.",
"modes.promptPlaceholder": "e.g. You are a patient German teacher. Correct my sentences and explain briefly why.",
"agents.styleChatShort": "conversation",
"agents.styleCodingShort": "coding",
"settings.tab.templates": "Templates",
"templates.title": "Prompt templates",
"templates.filter": "Search templates…",
@@ -454,6 +506,8 @@ export const en = {
"sidebar.organise": "Organise session",
"sidebar.pin": "Pin to top",
"sidebar.unpin": "Unpin",
"sidebar.openBeside": "Open beside",
"chat.closePane": "Close this column",
"sidebar.moveTo": "Move to folder…",
"sidebar.removeFromFolder": "Remove from folder",
"sidebar.archiveOne": "Archive",
@@ -463,6 +517,13 @@ export const en = {
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
"sidebar.folderName": "Folder name",
"sidebar.move": "Move",
"backup.restore": "Restore",
"backup.restoreFile": "Restore from file…",
"backup.restoring": "Restoring…",
"backup.restoreTitle": "Restore backup?",
"backup.restoreConfirm": "Replace the database and the configuration with {file}? All sessions, users, statistics and settings are replaced by the state in the backup; files in the workspace stay as they are. The current state is backed up first. CodeCraft restarts – running agents are stopped.",
"backup.restarting": "Backup accepted – CodeCraft is restarting…",
"backup.notBack": "The server did not come back. It needs a restart policy (Docker: restart: unless-stopped) – start it again by hand.",
"backup.now": "Back up now",
"backup.running": "Backing up…",
"backup.created": "Backup {file} created.",
@@ -526,6 +587,20 @@ export const en = {
"chat.exportMd": "Markdown (.md)",
"chat.exportJson": "JSON (.json)",
"chat.empty": "Send a message to start.",
"chat.compactedAuto": "Context nearly full ({tokens} tokens) – conversation compacted automatically · summary",
"chat.compactedManual": "Conversation compacted at {tokens} tokens · summary",
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
"chat.compactingManual": "Compacting the conversation",
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
"chat.telegram": "Send questions to Telegram",
"chat.telegramHint": "Also send approvals and questions of this chat via Telegram (the chat currently open in Telegram always reports)",
"jobs.permissionMode": "Approval mode",
"jobs.permissionModeHint": "Applies from the next run (written to the file). “Allow everything”: the run never asks and never waits for approval.",
"instructions.title": "Instructions for this chat",
"instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.",
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
"instructions.clear": "Remove",
"instructions.chip": "Instructions",
"chat.compacted": "Conversation compacted – summary",
"chat.context": "Context",
"chat.contextHint": "{tokens} tokens in the last request",
@@ -624,7 +699,7 @@ export const en = {
"settings.baseUrl": "Base URL",
"settings.apiKey": "API key",
"settings.apiKeyStored": "Stored – leave empty to keep",
"settings.apiKeyHint": "Prefer ${ENV_VAR} references; literal keys are stored in plain text in the config file.",
"settings.apiKeyHint": "Stored in the configuration file (readable for admins, included in backups). ${NAME} references to environment variables still work.",
"settings.authPrefix": "Auth prefix",
"settings.enabled": "Enabled",
"settings.modelCount": "{n} models",
@@ -646,6 +721,9 @@ export const en = {
"settings.providerName": "Name (optional)",
"settings.providerUp": "reachable · {ms} ms",
"settings.providerDown": "unreachable",
"settings.testConnection": "Test connection",
"settings.testing": "Testing…",
"settings.testOk": "Connection works: {n} models, {ms} ms. Not saved yet.",
"settings.providerAuth": "API key missing or rejected",
"settings.providerCheck": "Check providers",
"settings.providerChecking": "Checking…",
@@ -799,6 +877,7 @@ export const en = {
"agents.modePrimary": "mode",
"agents.modeSubagent": "subagent",
"agents.source.builtin": "built-in",
"agents.source.personal": "personal",
"agents.source.config": "config",
"agents.source.file": "file",
"agents.model": "Model",
@@ -809,8 +888,16 @@ export const en = {
"agents.description": "Description",
"agents.descriptionHint": "The main agent decides by this description when to delegate.",
"agents.mode": "Type",
"agents.addMode": "New mode",
"agents.style": "Kind",
"agents.styleCoding": "Coding – project context and coding workflow",
"agents.styleChat": "Conversation – only this prompt, no project",
"agents.styleHint": "Conversation modes get just your prompt plus date and personal memory, like the built-in chat mode.",
"agents.readOnlyLabel": "Read-only",
"agents.readOnlyHint": "Only tools that change nothing (like plan mode)",
"agents.toolsSome": "{n} tools selected – only these are available",
"agents.tools": "Tools",
"agents.toolsAll": "all tools (e.g. read, glob, grep)",
"agents.toolsAll": "Nothing selected = all tools",
"agents.prompt": "Instructions",
"agents.reset": "Reset",
"agents.resetConfirm": "Reset \"{name}\" to the built-in defaults?",
+18
View File
@@ -25,3 +25,21 @@ export function useReasoningMode(): ReasoningMode {
getReasoningMode,
);
}
const TODOS_KEY = "codecraft.todosCollapsed";
/** Task list above the composer folded to a one-line summary (per device). */
export function useTodosCollapsed(): [boolean, (v: boolean) => void] {
const v = useSyncExternalStore(
(l) => {
listeners.add(l);
return () => listeners.delete(l);
},
() => localStorage.getItem(TODOS_KEY) === "1",
);
const set = (c: boolean) => {
localStorage.setItem(TODOS_KEY, c ? "1" : "0");
listeners.forEach((l) => l());
};
return [v, set];
}
+20
View File
@@ -15,3 +15,23 @@ export function useRoute(): string[] {
export function navigate(path: string) {
location.hash = path.startsWith("/") ? path : `/${path}`;
}
/** Chats shown side by side: #/s/<id>+<id>+<id> (at most 3). */
export const MAX_PANES = 3;
export function openChats(): string[] {
const m = location.hash.match(/^#\/s\/([^/]+)/);
return m ? m[1].split("+").filter(Boolean) : [];
}
/** Add a chat as another column (replaces the last one when all columns are used). */
export function openBeside(id: string) {
const ids = openChats().filter((x) => x !== id);
if (ids.length >= MAX_PANES) ids.pop();
navigate(`/s/${[...ids, id].join("+")}`);
}
export function closePane(id: string) {
const ids = openChats().filter((x) => x !== id);
navigate(ids.length ? `/s/${ids.join("+")}` : "/");
}
+11
View File
@@ -47,6 +47,8 @@ export interface State {
queue: Record<string, QueueItem[]>;
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
waiting: Record<string, string | undefined>;
/** Compaction running in a session (auto = context nearly full). */
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
}
let state: State = {
@@ -67,6 +69,7 @@ let state: State = {
queue: {},
steps: {},
waiting: {},
compacting: {},
};
const listeners = new Set<() => void>();
@@ -188,6 +191,14 @@ export function applyEvent(ev: ServerEvent) {
case "waiting":
setState((s) => ({ waiting: { ...s.waiting, [ev.session_id]: ev.reason ?? undefined } }));
break;
case "compaction":
setState((s) => ({
compacting: {
...s.compacting,
[ev.session_id]: ev.state === "started" ? { auto: ev.auto, tokens: ev.tokens, limit: ev.limit, since: Date.now() } : undefined,
},
}));
break;
case "mcp_status":
setState(() => ({ mcp: ev.servers }));
break;
+460 -7
View File
@@ -132,14 +132,120 @@ textarea:focus {
height: 22px;
}
.conn-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--red);
/* --- status LEDs (sidebar) ------------------------------------------------------ */
/* Theme hues, pushed to a vivid chroma and a readable lightness so the state is
obvious in every theme; warn takes the red's lightness/chroma at an amber hue. */
:root {
--led-ok: #22c55e;
--led-warn: #f5a700;
--led-err: #ef4444;
}
.conn-dot.on {
background: var(--green);
@supports (color: oklch(from red l c h)) {
:root {
--led-ok: oklch(from var(--green) clamp(0.7, l, 0.8) max(c, 0.2) clamp(138, h, 158));
--led-warn: oklch(from var(--red) clamp(0.78, l, 0.85) max(c, 0.17) 75);
--led-err: oklch(from var(--red) clamp(0.6, l, 0.7) max(c, 0.21) h);
}
}
.status-leds {
position: relative;
}
.status-leds-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 7px;
border-radius: 999px;
background: var(--bg2);
}
.led {
width: 9px;
height: 9px;
border-radius: 50%;
flex: none;
display: inline-block;
}
.led-ok {
background: var(--led-ok);
box-shadow: 0 0 6px color-mix(in srgb, var(--led-ok) 70%, transparent);
}
.led-warn {
background: var(--led-warn);
box-shadow: 0 0 6px color-mix(in srgb, var(--led-warn) 70%, transparent);
}
.led-err {
background: var(--led-err);
box-shadow: 0 0 7px color-mix(in srgb, var(--led-err) 80%, transparent);
}
.status-leds-btn .led-err {
animation: led-blink 1.6s ease-in-out infinite;
}
.led-off {
background: transparent;
box-shadow: inset 0 0 0 1.5px var(--muted);
}
@keyframes led-blink {
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.status-leds-btn .led-err {
animation: none;
}
}
.status-pop {
position: fixed;
z-index: 40;
width: min(320px, calc(100vw - 24px));
background: var(--bg1);
border: 1px solid var(--bg3);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 6px;
font-size: 13px;
}
.status-section + .status-section {
border-top: 1px solid var(--bg3);
margin-top: 4px;
padding-top: 4px;
}
.status-head {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 4px;
font-weight: 600;
color: var(--fg0);
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 25px;
color: var(--fg1);
}
.status-row .led {
width: 7px;
height: 7px;
margin-left: -15px;
}
.status-row .grow {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-row .hint {
white-space: nowrap;
}
.status-foot {
display: flex;
gap: 6px;
border-top: 1px solid var(--bg3);
margin-top: 4px;
padding: 6px 4px 2px;
}
.session-list {
@@ -615,6 +721,39 @@ button.session-group:hover {
margin: 8px 0;
font-size: 13px;
}
.todos-head {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
padding: 0;
background: none;
border: none;
color: var(--muted);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.todos-head:hover {
color: var(--fg1);
}
.todos-count {
font-variant-numeric: tabular-nums;
}
.todos-current {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--accent);
font-weight: 600;
}
.todos.collapsed {
padding-block: 6px;
}
.todos ul {
list-style: none;
padding: 0;
@@ -3021,6 +3160,11 @@ button.danger-solid {
.job-grid > span:nth-child(odd) {
color: var(--fg3);
}
.job-grid select {
max-width: 100%;
font-size: 13px;
padding: 2px 6px;
}
.job-prompt {
margin-top: 10px;
}
@@ -3413,6 +3557,10 @@ button.danger-solid {
.backup-file .grow {
flex: 1;
}
.backup-file button,
.backup-file .button {
padding: 2px 6px;
}
/* --- automation templates ------------------------------------------------------ */
.job-tpl {
@@ -3459,3 +3607,308 @@ button.danger-solid {
border-color: var(--accent);
color: var(--accent);
}
.chip.price-extra {
margin-left: 4px;
font-size: 11px;
cursor: help;
}
.plan-row td {
border-top: none;
padding-top: 0;
}
.plan-usage {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.plan-window {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
min-width: 170px;
}
.plan-window .budget-bar {
flex: 1;
min-width: 60px;
}
.plan-label {
color: var(--muted);
white-space: nowrap;
}
.plan-pct {
font-variant-numeric: tabular-nums;
}
/* --- chat tabs (chat + terminals) ---------------------------------------------- */
.chat-tabs {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 10px 0;
border-bottom: 1px solid var(--bg3);
overflow-x: auto;
flex: none;
}
.chat-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-bottom: 2px solid transparent;
border-radius: 6px 6px 0 0;
background: none;
color: var(--fg2);
font-size: 13px;
white-space: nowrap;
cursor: pointer;
}
span.chat-tab {
padding: 0 4px 0 0;
}
span.chat-tab > button {
padding: 6px 4px 6px 12px;
color: inherit;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chat-tab .close {
padding: 2px 4px;
opacity: 0.6;
}
.chat-tab .close:hover {
opacity: 1;
}
.chat-tab:hover {
background: var(--bg1);
color: var(--fg0);
}
.chat-tab.active {
color: var(--fg0);
border-bottom-color: var(--accent);
background: var(--bg1);
}
.chat-tab.dead {
opacity: 0.55;
}
.chat-tab-add {
padding: 4px 8px;
}
.chat-pane {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-pane[hidden] {
display: none;
}
.files-embed-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-bottom: 1px solid var(--bg3);
flex: none;
}
.files-embed-bar .path {
font-size: 12px;
color: var(--fg3);
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-picker .hint {
margin-bottom: 6px;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 4px 12px;
}
.tool-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
cursor: pointer;
}
.tool-item.disabled {
opacity: 0.45;
cursor: default;
}
.mode-select {
font-weight: 600;
color: var(--accent);
}
.compacting-card {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
padding: 10px 14px;
border: 1px dashed var(--bg3);
border-radius: var(--radius);
background: var(--bg1);
font-size: 13px;
}
.compacting-card.auto {
border-color: color-mix(in srgb, var(--accent) 60%, var(--bg3));
}
.status-meter .budget-bar {
width: 70px;
flex: none;
}
.status-meter .pct {
width: 38px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* --- split view (chats side by side) ------------------------------------------- */
.split-view {
flex: 1;
min-height: 0;
display: flex;
}
.split-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.split-pane + .split-pane {
border-left: 1px solid var(--bg3);
}
.split-pane .topbar {
flex-wrap: nowrap;
}
.split-pane .topbar .grow {
min-width: 90px;
}
.split-pane .topbar h1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Narrow columns: the context chip moves out (the cost chip opens the usage overlay). */
.split-pane .topbar span.chip.hide-mobile {
display: none;
}
@media (max-width: 900px) {
.split-pane + .split-pane {
display: none;
}
}
.instructions-modal {
width: min(640px, calc(100vw - 32px));
}
/* --- short screens ----------------------------------------------------------
* Square phones (e.g. Clicks Communicator, ~393x400 CSS px with a hardware
* keyboard) and phones in landscape: height is the scarce dimension, so bars,
* the composer and the new-chat wizard get tighter and the chat keeps the room. */
@media (max-height: 560px) {
.topbar {
min-height: 40px;
padding: 4px 10px;
padding-top: max(4px, env(safe-area-inset-top));
gap: 4px;
}
.topbar button.ghost {
padding: 4px 6px;
}
.chat {
padding: 10px 12px 14px;
}
.msg {
margin-bottom: 10px;
}
.composer-wrap {
padding: 6px 10px;
padding-bottom: max(6px, env(safe-area-inset-bottom));
}
.composer textarea {
min-height: 38px;
max-height: 30vh;
padding: 8px 10px;
}
/* One placeholder line instead of two (the textarea grows with its content). */
.composer textarea::placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.composer-bar {
margin-top: 4px;
gap: 4px;
}
.composer-bar select {
max-width: 150px;
padding: 2px 4px;
}
.composer-actions button {
padding: 4px 10px;
}
.todos {
margin: 4px 0;
padding: 6px 10px;
}
.page {
padding: 12px;
}
.wiz-title {
font-size: 19px;
margin: 0 0 8px;
}
.wiz-sub {
display: none;
}
.wiz-panel {
padding: 12px;
}
.wiz-step {
margin-bottom: 16px;
}
.wiz-step h3 {
font-size: 16px;
margin-bottom: 8px;
}
.wiz-cards,
.wiz-cards.five {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.wiz-card {
padding: 8px 10px;
gap: 8px;
min-height: 44px;
font-size: 14px;
}
.wiz-card.active {
padding: 7px 9px;
}
.wiz-card-text {
padding-right: 14px;
}
.wiz-card-desc {
display: none;
}
.wiz-icon {
width: 20px;
height: 20px;
}
.wiz-num {
width: 24px;
height: 24px;
font-size: 12px;
}
}
+15 -3
View File
@@ -2,7 +2,12 @@
export interface ImageData {
media_type: string;
data: string;
/** Base64 bytes; absent when the server stores the image separately (blob). */
data?: string;
/** SHA-256 of a stored image: GET /api/blobs/<blob>. */
blob?: string;
/** Size in bytes of a stored image. */
size?: number;
}
export type Part =
@@ -19,7 +24,7 @@ export type Part =
is_error: boolean;
meta?: any;
}
| { type: "summary"; text: string };
| { type: "summary"; text: string; auto?: boolean; tokens?: number };
export interface Usage {
input_tokens: number;
@@ -67,6 +72,10 @@ export interface Session {
pinned?: boolean;
archived?: boolean;
folder?: string | null;
/** Instructions for this chat (added to the system prompt). */
instructions?: string;
/** Approvals and questions of this chat are not sent to Telegram. */
telegram_quiet?: boolean;
}
export interface QuestionOption {
@@ -118,6 +127,7 @@ export type ServerEvent =
| { type: "waiting"; session_id: string; reason: string | null }
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
| { type: "resync" };
@@ -150,9 +160,11 @@ export interface AgentInfo {
description: string;
mode: "primary" | "subagent";
read_only: boolean;
/** "coding" or "chat" (plain conversation prompt). */
style?: "coding" | "chat";
model?: string | null;
tools?: string[] | null;
source: "builtin" | "config" | "file";
source: "builtin" | "config" | "file" | "personal";
max_parallel?: number | null;
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
/** Source file of Markdown agents. */
+92 -1
View File
@@ -1,5 +1,96 @@
/** "What's new" entries per version (shown once after an update). */
/**
* "What's new" entries per version (shown once after an update). Add new entries
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00012": {
en: ["Automations: set the approval mode right in the list – “Allow everything” runs unattended without asking", "⋯ → Send questions to Telegram: per chat, off for new chats"],
de: ["Automatisierungen: Freigabemodus direkt in der Liste wählen – „Alles erlauben“ läuft nachts ohne Rückfrage", "⋯ → Fragen an Telegram senden: pro Chat, bei neuen Chats aus"],
},
"202611-00011": {
en: ["The task list of the model can be folded (click on \"Tasks\")", "Better layout on square and short screens (e.g. Clicks Communicator, landscape)"],
de: ["Die Aufgabenliste des Modells lässt sich zuklappen (Klick auf „Aufgaben“)", "Besseres Layout auf quadratischen und niedrigen Displays (z. B. Clicks Communicator, Querformat)"],
},
"202611-00010": {
en: ["Chats with many images (screenshots, generated images) open much faster – images load when they scroll into view"],
de: ["Chats mit vielen Bildern (Screenshots, generierte Bilder) öffnen viel schneller – Bilder laden erst beim Hinscrollen"],
},
"202611-00008": {
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],
},
"202611-00007": {
en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"],
de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"],
},
"202611-00006": {
en: ["Docker CLI in the image – with the host's Docker socket mounted, admins' agents and terminals can use Docker"],
de: ["Docker-CLI im Image – mit eingehängtem Docker-Socket können Admin-Agents und -Terminals Docker nutzen"],
},
"202611-00005": {
en: ["Agents commit with the git name and e-mail from your settings – also in repositories with their own identity"],
de: ["Agents committen mit Git-Name und -E-Mail aus deinen Einstellungen – auch in Repos mit eigener Identität"],
},
"202611-00004": {
en: [
"Chats side by side: “Open beside” in a chat's menu in the sidebar",
"Settings → My modes: your own modes",
"Budget warnings as push notifications, budgets in the status overlay",
"File manager tabs are the same on every device",
],
de: [
"Chats nebeneinander: „Daneben öffnen“ im Menü eines Chats in der Seitenleiste",
"Einstellungen → Meine Modi: eigene Modi",
"Budget-Warnungen als Push, Budgets im Status-Overlay",
"Dateimanager-Tabs sind auf jedem Gerät gleich",
],
},
"202611-00003": {
en: [
"Terminals and file managers as tabs in a chat – terminals keep running, also for another device",
"New session → “Just chat” for plain conversation",
"Own modes: Settings → Agents & commands → New mode",
"The chat shows when the conversation is compacted (automatically or on request)",
"File manager: “Open chat here” in a folder's menu",
"Terminal client downloadable from the server again",
],
de: [
"Terminals und Dateimanager als Tabs im Chat – Terminals laufen weiter, auch für ein anderes Gerät",
"Neue Sitzung → „Einfach chatten“ für reine Unterhaltung",
"Eigene Modi: Einstellungen → Agents & Befehle → Neuer Modus",
"Der Chat zeigt an, wenn die Unterhaltung komprimiert wird (automatisch oder auf Wunsch)",
"Dateimanager: „Chat hier öffnen“ im Ordner-Menü",
"Terminal-Client wieder direkt vom Server herunterladbar",
],
},
"202611-00002": {
en: [
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",
"Settings → Config file: move keys from environment variables into the configuration",
"opencode: real costs per request; Go plan usage (5 hours, week, month) in budgets and status",
],
de: [
"opencode: „Preise holen“ übernimmt Preise inklusive Long-Context- und Spitzenzeit-Preisen",
"Einstellungen → Config-Datei: Keys aus Umgebungsvariablen in die Konfiguration übernehmen",
"opencode: echte Kosten pro Anfrage; Go-Plan-Verbrauch (5 Stunden, Woche, Monat) in Budget und Status",
],
},
"202611-00001": {
en: [
"Status LEDs in the sidebar for server, models and MCP – click them for details",
"Images: cheap previews first, then the final image with the best model (Nano Banana Pro refines the chosen preview)",
"Settings → Audit: restore a backup (stored or uploaded)",
"Provider form: test the connection before saving; saving checks the status right away",
"opencode Go works again (session header)",
],
de: [
"Status-LEDs in der Seitenleiste für Server, Modelle und MCP – ein Klick zeigt die Details",
"Bilder: erst günstige Vorschauen, dann das Endbild mit dem besten Modell (Nano Banana Pro verfeinert die gewählte Vorschau)",
"Einstellungen → Audit: Backup wiederherstellen (gespeichert oder hochgeladen)",
"Provider-Formular: Verbindung vor dem Speichern testen; Speichern prüft den Status sofort",
"opencode Go funktioniert wieder (Session-Header)",
],
},
"0.1.0": {
en: [
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",