Compare commits

...
4 Commits
Author SHA1 Message Date
semantic-release-bot 89ee213454 chore(release): 1.17.0 [skip ci]
# [1.17.0](https://github.com/asepharyana/zesdex/compare/v1.16.1...v1.17.0) (2026-07-20)

### Features

* **token:** add refresh token verification to TokenService ([a046519](https://github.com/asepharyana/zesdex/commit/a04651905f4afd562b516c839449a2c813ce6627))
2026-07-20 05:30:00 +00:00
asepharyana a04651905f feat(token): add refresh token verification to TokenService
feat(bootstrap): create temporary settings and config files to prevent data loss

refactor(edit_log): switch from Vec to VecDeque for efficient memory management

fix(gateway): ensure store directories are created before starting the API server

refactor(bgbash): implement a global singleton for BashControl

feat(auth): enhance session authentication middleware to use SessionRepository

fix(edit_log_repo): update to use VecDeque for in-memory edit log storage

fix(memory_repo): add newline escaping for frontmatter fields

fix(session_lock_repo): improve error handling for lock file operations

fix(bash_tools): prevent path traversal in job_id argument

refactor(delete): enforce empty directory deletion in file system tools

fix(edit): optimize string replacement to only replace the first occurrence

fix(git_cred): improve credential management with piped input to git commands

feat(git_operator): add safety filter to block destructive git operations

fix(shell): register background jobs in Bash control

feat(spawn): add access tier specification for pipeline stages

refactor(hive_mind): run directives concurrently for improved performance

fix(auth): update refresh token verification in the refresh handler

fix(chat): optimize LLM client usage based on model matching

fix(conversations): enhance message deletion to target specific indices

feat(api): add JWT authentication middleware for all API routes

fix(state): implement refresh token verification in JwtTokenService

fix(daemon): improve usage tracking with saturating addition

fix(tui): handle compacted messages in the TUI state management
2026-07-20 12:26:10 +07:00
semantic-release-bot 600ea041ef chore(release): 1.16.1 [skip ci]
## [1.16.1](https://github.com/asepharyana/zesdex/compare/v1.16.0...v1.16.1) (2026-07-20)
2026-07-20 05:06:27 +00:00
asepharyana 1ec2aa136a refactor: improve code readability and consistency across multiple files 2026-07-20 12:02:50 +07:00
43 changed files with 635 additions and 506 deletions
+9
View File
@@ -1,3 +1,12 @@
# [1.17.0](https://github.com/asepharyana/zesdex/compare/v1.16.1...v1.17.0) (2026-07-20)
### Features
* **token:** add refresh token verification to TokenService ([a046519](https://github.com/asepharyana/zesdex/commit/a04651905f4afd562b516c839449a2c813ce6627))
## [1.16.1](https://github.com/asepharyana/zesdex/compare/v1.16.0...v1.16.1) (2026-07-20)
# [1.16.0](https://github.com/asepharyana/zesdex/compare/v1.15.2...v1.16.0) (2026-07-20) # [1.16.0](https://github.com/asepharyana/zesdex/compare/v1.15.2...v1.16.0) (2026-07-20)
Generated
+11 -11
View File
@@ -4895,7 +4895,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-api" name = "zesdex-api"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -4918,7 +4918,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-application" name = "zesdex-application"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4935,7 +4935,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-bootstrap" name = "zesdex-bootstrap"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -4952,7 +4952,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-daemon" name = "zesdex-daemon"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4976,7 +4976,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-domain" name = "zesdex-domain"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4992,7 +4992,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-gateway" name = "zesdex-gateway"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5019,7 +5019,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-grpc" name = "zesdex-grpc"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5036,7 +5036,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-infrastructure" name = "zesdex-infrastructure"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -5085,7 +5085,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-tui" name = "zesdex-tui"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -5111,7 +5111,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-web" name = "zesdex-web"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5131,7 +5131,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-ws" name = "zesdex-ws"
version = "1.16.0" version = "1.17.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+1 -1
View File
@@ -15,7 +15,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.16.0" version = "1.17.0"
edition = "2021" edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"] authors = ["asepharyana <superaseph@gmail.com>"]
+193 -322
View File
@@ -1,379 +1,250 @@
# Zesdex # Zesdex — Autonomous AI Coding Agent
> Autonomous AI coding agent in a terminal-based TUI. Zesdex is an autonomous AI coding agent with a Terminal UI (TUI). It acts as an
OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with **37
built-in tools** — file operations, git, shell execution, LSP integration, MCP,
subagent orchestration, and more.
Zesdex is a Rust-powered AI assistant that operates directly in your terminal via a rich TUI interface. It combines large language model intelligence with a comprehensive set of tools to explore, understand, and modify codebases autonomously — with built-in guardrails at every layer. ```
┌──────────────────────────────────────────────────────────────┐
│ Mode Selector │
│ TUI (default) ─── Daemon ─── Attach ─── API ─── WS/gRPC/Web │
└──────────────────────────────────────────────────────────────┘
```
--- ---
## Features ## Quick Start
### Core ```bash
# Run the TUI (default mode)
cargo run
- **TUI Interface** — Full-screen terminal UI with chat panel, input bar, and status bar built with [ratatui](https://github.com/ratatui-org/ratatui) and [crossterm](https://github.com/crossterm-rs/crossterm). # Run the REST API server
- **Daemon Architecture** — Run as a background daemon with client attach/detach via Unix domain sockets. The daemon processes state; clients only render. cargo run -- --api --api-port 8080
- **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates.
- **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management.
### Tool System (37 built-in tools) # Run in daemon mode (background + IPC)
cargo run -- --daemon
| Category | Tools | # Attach TUI to a running daemon session
|----------|-------| cargo run -- --attach <session-id>
| **Filesystem** | `read`, `write`, `edit`, `delete` |
| **Search** | `grep` (recursive text), `glob` (file patterns) |
| **Shell** | `bash`, `bash_output`, `bash_kill` |
| **Git** | `git_operator`, `git_worktree`, `git_cred` |
| **Memory** | `remember`, `recall`, `forget` |
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
| **Workflow** | `workflow_run`, `note_finding`, `read_findings`, `hive_mind` |
| **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` |
| **Agent** | `spawn_agents`, `spawn_pipeline` |
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
### Intelligence # Seed initial data (first run)
cargo run --bin bootstrap
```
- **Hive-Mind Orchestration** — Autonomous agent orchestration modeled as a distributed machine intelligence (à la Stellaris). The Core Intelligence (main agent) compiles a cognitive cycle plan per task — an ordered list of cycles, each a set of anonymous processing nodes that run in parallel. Every node carries only a directive (what to do) and an access tier (`read`/`write`/`full`); cycle count and nodes-per-cycle are decided per task, not fixed. Every node's output merges into a shared collective state the instant it completes, and a final synthesis node reconciles it into one consensus. Every convergence is written to `docs/runs/*.md`. Manual entry point: the `hive_mind` tool. ### Prerequisites
- **Workflow Engine** — Orchestrate complex multi-step tasks with parallel sub-agents, pipelines, and phased execution. Spawn independent workers that share findings in real-time. - **Rust** 1.81+ (edition 2021)
- **Self-Learning** — Persistent memory system that stores lessons, references, and project knowledge across sessions. Memories include provenance tracking, lifecycle management, and scope isolation. - **Linux** or **macOS** (Unix domain sockets required for daemon mode)
- **Self-Review** — Review subagents trigger automatically after each code edit (inline) and at turn completion (background). Three types: code quality, architecture, and security. - An **API key** for an OpenAI/Anthropic-compatible LLM provider (set via
- **Self-Healing** — On build/test failures, spawns a sub-agent with the error context to autonomously fix issues before reporting them to the user. settings or environment variable)
- **MCP Support** — [Model Context Protocol](https://modelcontextprotocol.io/) integration for connecting to external AI tool servers.
- **Sequential Thinking** — Chain-of-thought reasoning tool for step-by-step problem decomposition.
- **Session Locking** — Prevents multiple processes from operating on the same session directory.
### Session Management ---
- Multiple concurrent sessions with history, rewind, and transcript persistence. ## Modes
- Per-session edit logs with full change tracking.
- Session archival and summary generation. | Flag | Mode | Description |
|------|------|-------------|
| *(none)* | **TUI** | Full terminal UI with chat, overlays, and agent loop in one process |
| `--daemon` | **Daemon** | Background daemon with IPC socket; clients attach separately |
| `--attach <id>` | **Attach** | Connect TUI to an existing daemon session via Unix socket |
| `--api` | **REST API** | HTTP server with session management and chat endpoints |
| `--ws` | **WebSocket** | WebSocket server for real-time communication |
| `--grpc` | **gRPC** | gRPC server for programmatic access |
| `--web` | **Web** | Serves the web frontend |
| `--api-port`, `--ws-port`, `--grpc-port`, `--web-port` | *(ports)* | Configure server ports (defaults: 8080, 8081, 50051, 3000) |
--- ---
## Architecture ## Architecture
### Clean Architecture Layering
``` ```
src/ apps/
├── main.rs # Entry point: single-process, daemon, or attach mode ├── domain/ # Pure entities, value objects, repository/service traits
├── resources.rs # Embedded resources (help text, system prompts) # Zero framework deps — only serde + chrono + uuid
├── app/ ├── application/ # Use-case services (auth, sessions, conversations, memory)
├── state/ # AppStateRest — immutable-rest state model │ # Depends only on domain-layer trait interfaces
│ │ ├── rest.rs # Core state struct ├── infrastructure/ # All I/O: LLM client, IPC, persistence, LSP, MCP, tools
├── types.rs # Overlay, Toast, Origin enums # Implements domain/application port interfaces
│ │ ├── snapshot.rs # State snapshots for IPC └── interfaces/ # Entry points
│ │ ├── diff.rs # Diff-based state synchronization ├── tui/ # Ratatui terminal UI
│ │ ├── runtime.rs # Runtime state mutations ├── api/ # Axum REST API
│ │ └── misc.rs # DirCache and miscellaneous state helpers ├── daemon/ # Unix socket daemon + client
├── runtime/ # Action dispatch and event loop ├── ws/ # WebSocket server
│ │ ├── actions/ # Action enum and apply_action reducer ├── grpc/ # gRPC server
│ │ ── stream/ # LLM streaming and tool execution ── web/ # Web frontend (static file server)
│ │ │ └── tools/ # Tool harness integration
│ │ │ └── turn.rs # Turn orchestration
│ │ ├── event_loop/ # Main event loop and shortsend
│ │ ├── commands.rs # Slash command dispatch
│ │ └── shortsend.rs # Short-lived async send helper
│ ├── mode/ # UI modes and overlays (13 modes)
│ │ ├── bash.rs # Bash panel mode
│ │ ├── editor.rs # Multi-line editor mode
│ │ ├── effort.rs # Effort level selector
│ │ ├── help.rs # Help overlay
│ │ ├── key_input.rs # Raw key input mode
│ │ ├── learning.rs # Lesson management overlay
│ │ ├── loading.rs # Loading spinner overlay
│ │ ├── mcp.rs # MCP server management
│ │ ├── quit_confirm.rs # Quit confirmation dialog
│ │ ├── rewind.rs # Session rewind mode
│ │ ├── settings.rs # Settings panel
│ │ ├── todo.rs # Task list overlay
│ │ └── workflow.rs # Workflow visualization
│ ├── harness.rs # Tool harness for agent execution
│ ├── workflow/ # Workflow engine
│ │ ├── script.rs # Workflow script DSL
│ │ ├── engine.rs # Workflow executor
│ │ ├── hive_mind.rs # Hive-mind orchestrator
│ │ └── docs.rs # Deterministic docs/runs/*.md writer
│ ├── mcp/ # MCP client manager
│ │ └── manager.rs # MCP server lifecycle and tool exposure
│ ├── subagent/ # Sub-agent management
│ │ ├── spawn.rs # AgentDefinition and spawning
│ │ ├── engine.rs # Sub-agent event loop
│ │ ├── context.rs # Context construction for sub-agents
│ │ └── event.rs # Progress event types
│ ├── bgbash/ # Background bash job management
│ │ ├── job.rs # Background job handle
│ │ └── control.rs # Bash control (bg/fg/kill)
│ ├── lsp/ # LSP client management
│ │ ├── client.rs # LSP client connection wrapper
│ │ └── provisioner.rs # Auto-provisioning of LSP servers
│ └── review/ # Self-review quality system
├── controller/
│ ├── input.rs # Key event → Action mapping
│ └── command.rs # Slash command parser
├── dto/
│ ├── chat/ # Message, ToolCall, Role types
│ │ ├── message.rs # Chat message types
│ │ ├── tool.rs # Tool call/result types
│ │ └── mod.rs
│ └── provider/ # AI provider request/response/usage types
│ ├── request.rs # Provider request schema
│ ├── response.rs # Provider response schema
│ └── usage.rs # Token usage tracking
├── ipc/
│ ├── protocol.rs # ClientRequest, DaemonFrame, StatePayload
│ ├── server.rs # Unix socket server
│ ├── client.rs # Unix socket client
│ ├── conn.rs # Framed connection
│ ├── frame.rs # Length-prefixed frame encoding
│ ├── snapshot.rs # State snapshot serialization
│ └── diff.rs # Binary diff for state sync
├── model/
│ ├── store.rs # File-based storage (~/.config/zesdex/)
│ ├── session.rs # Session CRUD and listing
│ ├── settings.rs # User settings (provider, model, tokens)
│ ├── app_config.rs # Provider definitions and model roles
│ ├── memory.rs # Persistent memory with frontmatter
│ ├── editlog.rs # Edit history tracking
│ ├── msglog/ # Message log (SQLite-backed)
│ │ ├── schema.rs # SQLite schema
│ │ ├── query.rs # Query helpers
│ │ ├── blobs.rs # Large blob storage
│ │ └── summary.rs # Session summarization
│ ├── agent_def/ # Agent definitions (builtin, global, session)
│ │ ├── builtin.rs # Built-in agent profiles
│ │ ├── global.rs # Global agent config
│ │ └── session.rs # Per-session agent config
│ ├── conversation.rs # Conversation helpers
│ └── session_lock.rs # Flock-based session locking
├── service/
│ ├── provider.rs # AI provider abstraction
│ └── oauth/ # OAuth PKCE flow with loopback server
│ ├── loopback.rs # Local HTTP server for OAuth redirect
│ ├── manager.rs # OAuth token manager
│ ├── pkce.rs # PKCE code challenge/verifier
│ └── mod.rs
├── tool/ # 34 tool implementations
│ ├── fs/ # read, write, edit, delete
│ │ ├── read.rs
│ │ ├── write.rs
│ │ ├── edit.rs
│ │ ├── delete.rs
│ │ └── helpers.rs # Path resolution and validation
│ ├── search.rs # grep, glob
│ ├── shell.rs # bash
│ ├── bash_tools.rs # bash_output, bash_kill
│ ├── git_operator.rs # git operations
│ ├── git_worktree.rs # git worktree management
│ ├── git_cred.rs # git credential store/get/erase
│ ├── memory/ # remember, forget, recall
│ │ ├── remember.rs
│ │ ├── forget.rs
│ │ └── recall.rs
│ ├── plan.rs # plan_enter, plan_ready
│ ├── seqthink.rs # Sequential thinking
│ ├── workflow.rs # workflow_run, note_finding
│ ├── utility/ # cd, dir_list, dir_cache_update, pong, todowrite, todofinish
│ │ ├── cd.rs
│ │ ├── dir_list.rs
│ │ ├── dir_cache_update.rs
│ │ ├── pong.rs
│ │ ├── todowrite.rs
│ │ └── todofinish.rs
│ ├── lsp/ # LSP tools (connect, diagnostics, hover, etc.)
│ │ └── mod.rs
│ └── shell_filter/ # Shell output filtering (credentials, git)
│ ├── credentials.rs
│ ├── git.rs
│ └── mod.rs
└── view/ # TUI rendering
├── chat.rs # Chat transcript with markdown
├── markdown.rs # Markdown → ratatui spans
├── status.rs # Status bar
├── theme.rs # Color scheme
└── workflow.rs # Workflow visualization
``` ```
--- ### Tool System
## Usage 37 tools across 9 categories:
```bash | Category | Tools |
# Run in single-process mode (default) |----------|-------|
zesdex | **File System** | `read`, `write`, `edit`, `delete`, `dir_list`, `dir_cache_update` |
| **Shell** | `bash`, `bash_interactive`, `bash_kill`, `bash_output` |
| **Git** | `git_operator`, `git_cred`, `git_worktree` |
| **Search** | `search`, `grep`, `glob`, `semantic_search` |
| **LSP** | `lsp_connect`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_diagnostics`, `lsp_disconnect` |
| **Memory** | `remember`, `recall`, `forget` |
| **Workflow** | `spawn_agents`, `spawn_pipeline`, `plan`, `sequential_think`, `hive_mind` |
| **Utility** | `todo_write`, `todo_finish`, `pong`, `cd` |
| **Background** | Background bash jobs with `cancel/status/list` |
# Run as a background daemon Each tool implements the `Tool` trait:
zesdex --daemon ```rust
pub trait Tool: Send + Sync {
# Attach to a running daemon session fn name(&self) -> &'static str;
zesdex --attach <session-id> fn description(&self) -> &'static str;
fn parameters(&self) -> Value;
# Set log level fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
RUST_LOG=debug zesdex
```
### Key Bindings
| Binding | Action |
|---------|--------|
| `Ctrl+Q` | Quit |
| `Ctrl+H` | Help overlay |
| `Ctrl+P` | Settings overlay |
| `Ctrl+B` | Bash panel |
| `Ctrl+S` | Session hub |
| `Ctrl+T` | Task list |
| `Ctrl+W` | Workflow view |
| `Ctrl+K` | Key input mode |
| `Esc` | Cancel / back |
| `Tab` | Autocomplete |
| `↑/↓` | History / navigation |
| `Scroll` | Mouse scroll in chat |
### Slash Commands
| Command | Description |
|---------|-------------|
| `/help` | Show help |
| `/clear` | Clear transcript |
| `/model` | Select AI model provider |
| `/workflow` | Open the workflow panel |
| `/workflow run <script>` | Run a JSON-encoded workflow script |
| `/mcp` | Open MCP server manager |
| `/mcp add <name> <command>` | Add an MCP server |
| `/login [provider]` | Authenticate with a provider |
| `/edit [path]` | Open a file/dir in the external editor |
| `/compact` | Compact the conversation transcript |
| `/lesson` | Interactive lesson/memory review |
| `/quit` | Exit application |
| `Any text` | Sent to the AI assistant as a prompt |
---
## Configuration
All configuration lives in `~/.config/zesdex/` (or platform equivalent via the `dirs` crate).
| File | Purpose |
|------|---------|
| `settings.json` | Provider selection, model, temperature, max tokens, review settings, workflow concurrency |
| `app_config.json` | AI provider definitions (name, API base URL, auth type, default model) |
| `memory/` | Persistent lesson and reference storage (Markdown with YAML frontmatter) |
| `sessions/` | Per-session transcripts, edit logs, and activity data |
| `run/` | Unix domain sockets for daemon mode |
### Provider Configuration
Providers are defined in `app_config.json`:
```json
{
"providers": {
"my-provider": {
"api_base": "https://api.example.com/v1",
"api_key_env": "MY_API_KEY",
"default_model": "model-name"
}
},
"model_roles": {
"default": {
"provider": "my-provider",
"model": "model-name",
"max_tokens": 8192,
"temperature": 0.7
}
},
"default_provider": "my-provider",
"default_model": "model-name"
} }
``` ```
### Settings ### Hive Mind Orchestration
Key settings in `settings.json`: The multi-agent orchestration system compiles a **cognitive cycle plan** per
task — ordered cycles of parallel processing nodes. Each node has a directive
and an **access tier** (`read` / `write` / `full`). Node outputs merge into a
shared collective state in real time, and a final **consensus synthesis**
produces the unified result.
| Setting | Default | Description | - **Auto-trigger**: Complex requests automatically use the hive mind
|---------|---------|-------------| - **Manual entry**: The `hive_mind` tool lets the LLM specify cycles explicitly
| `review_enabled` | `true` | Enable self-review after tool execution | - **Live progress**: TUI panel shows each node's status and current tool
| `review_max_lessons_per_run` | `5` | Max lessons loaded per review cycle | - **Guaranteed docs**: Every convergence writes to `docs/runs/`
| `adaptive_review_max_skip` | `3` | Consecutive passes before skipping review |
| `verify_command` | `null` | Optional command to verify changes | ### IPC Protocol (Daemon Mode)
| `workflow_max_concurrency` | `5` | Max parallel sub-agents in workflows |
| `session_archive_enabled` | `true` | Auto-archive completed sessions | ```
┌──────────┐ Unix socket ┌──────────┐
│ Client │ ◄──────────────► │ Daemon │
│ (TUI) │ length-prefixed│ │
└──────────┘ serde_json └──────────┘
Frame format: [4-byte BE length][JSON payload]
```
The daemon holds `AppStateRest` and drives the agent loop. Clients are stateless
renderers that receive full state snapshots after each action.
--- ---
## Installation ## Built-in Features
### Prerequisites | Feature | Description |
|---------|-------------|
| **LLM Provider** | OpenAI/Anthropic-compatible API (streaming + non-streaming) with automatic retry and fallback |
| **Tool Harness** | Safety-gated tool execution with graduated review checks |
| **Subagents** | Auto-inline review, background test-gen, arch-review, security-review |
| **OAuth 2.0** | PKCE flow for LLM provider authentication |
| **MCP** | Model Context Protocol server management (stdio + HTTP transport) |
| **LSP** | Language Server Protocol integration (completion, hover, diagnostics, references) |
| **Session Mgmt** | SQLite-persisted sessions with lock-based concurrency control |
| **Memory** | File-based memory system with frontmatter metadata |
| **Edit Log** | Append-only edit history with configurable retention |
| **Rate Limiting** | Sliding-window per-client rate limiter |
| **JWT Auth** | HS256 JWT access/refresh tokens (API mode) |
| **Password Auth** | Argon2 password hashing with pepper |
| **OAuth Loopback** | Localhost HTTP server for OAuth redirect capture |
| **Background Jobs** | Long-running shell jobs with cancellation and output collection |
| **Settings** | JSON-persisted settings with hot-reload |
- **Rust** 2021 edition toolchain ([rustup](https://rustup.rs/)) ---
### Build from Source ## TUI Overlays
16 overlays accessible from the terminal UI:
| Overlay | Purpose |
|---------|---------|
| Chat Input | Main input bar with autocomplete |
| Bash Panel | Interactive shell panel |
| File Editor | Built-in file editor |
| Effort Selector | LLM reasoning effort selector |
| Help | Keybindings reference |
| Key Input | Custom key binding configuration |
| Learning | Lesson viewer |
| Loading | Generating spinner |
| MCP Manager | MCP server management |
| Model Selector | LLM model picker |
| Quit Confirm | Exit confirmation dialog |
| Rewind | Message/history rewind |
| Settings | Settings panel |
| Todo | Task/TODO list |
| Usage | Token usage statistics |
| Workflow | Hive-mind node progress |
---
## Data & Persistence
All data lives under the platform's data directory (`~/.local/share/zesdex/`):
```bash
git clone <repository-url>
cd zesdex
cargo build --release
./target/release/zesdex
``` ```
~/.local/share/zesdex/
├── settings.json # User settings (provider, model, keys)
├── app_config.json # Provider definitions (endpoints, env vars)
├── sessions/ # Chat sessions (one subdirectory per session)
│ └── <uuid>/
│ ├── session.json # Session metadata
│ ├── messages.jsonl # Message log
│ └── .lock # Session lock file
└── memories/ # Memory files with frontmatter metadata
└── *.md
```
--- ---
## Development ## Development
### Commit Convention ```bash
# Build all crates
cargo build
Project ini menggunakan **Conventional Commits** untuk otomatis menentukan versi rilis (melalui semantic-release). # Run all unit tests (8 tests across 11 crates)
cargo test
Format: # Run clippy linting
``` cargo clippy --all-targets
<type>(<scope>): <description>
[optional body] # Run with verbose logging
RUST_LOG=debug cargo run
[optional footer]
``` ```
**`<type>` — menentukan bump version:** ### Workspace Crates
| Type | Bump | Keterangan | | Crate | Path | Layer |
|-------------|---------|-------------------------------------------| |-------|------|-------|
| `feat` | minor | Fitur baru | | `zesdex-domain` | `apps/domain/` | Pure domain entities & traits |
| `fix` | patch | Perbaikan bug | | `zesdex-application` | `apps/application/` | Use-case services |
| `chore` | patch | Tugas maintenance, refactor ringan | | `zesdex-infrastructure` | `apps/infrastructure/` | All I/O & tool implementations |
| `docs` | patch | Perubahan dokumentasi | | `zesdex-tui` | `apps/interfaces/tui/` | Ratatui terminal interface |
| `refactor` | patch | Refactor kode (tanpa perubahan fungsional)| | `zesdex-api` | `apps/interfaces/api/` | Axum REST API |
| `test` | patch | Penambahan atau perbaikan test | | `zesdex-daemon` | `apps/interfaces/daemon/` | Unix socket daemon |
| `style` | patch | Perubahan formatting, whitespace, dll | | `zesdex-ws` | `apps/interfaces/ws/` | WebSocket server |
| `perf` | patch | Optimasi performa | | `zesdex-grpc` | `apps/interfaces/grpc/` | gRPC server |
| `ci` | patch | Perubahan CI/CD | | `zesdex-web` | `apps/interfaces/web/` | Web frontend |
| `zesdex-gateway` | `apps/gateway/` | CLI entry point & dispatcher |
| `zesdex-bootstrap` | `apps/bootstrap/` | Initial data seeder |
**`BREAKING CHANGE`** pada body commit → **major** (apa pun typenya). ### Code Map
Contoh: Detailed architecture documentation is in `docs/CODEMAPS/`:
```
feat(agent): add workspace-aware file search
Implement context-aware search scoped to current workspace directory. | File | Covers |
|------|--------|
BREAKING CHANGE: search results now filter by workspace scope. | `docs/CODEMAPS/architecture.md` | System layout, process modes, data flow |
``` | `docs/CODEMAPS/backend.md` | Provider, OAuth, IPC, workflow engine, MCP, LSP, review |
| `docs/CODEMAPS/frontend.md` | TUI render pipeline, 16 overlays, toasts, input handling |
``` | `docs/CODEMAPS/data.md` | Persistence, SQLite msglog, memory files, settings/config |
fix(ipc): handle partial frame on unix socket reconnect | `docs/CODEMAPS/dependencies.md` | All Rust crates and external services |
```
```
chore: update rustls to 0.23
```
### Release Workflow
Push ke branch `main` akan memicu:
1. **CI**`cargo build --release` + `cargo test`
2. **Semantic Release** — analisis commit → update `Cargo.toml` + `CHANGELOG.md` → git tag → GitHub Release dengan binary
--- ---
## License ## License
See [LICENSE](LICENSE) for details. See `CHANGELOG.md` for release history.
+1 -1
View File
@@ -104,7 +104,7 @@ fn generate_pkce_pair() -> (String, String) {
bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
let verifier = URL_SAFE_NO_PAD.encode(&bytes); let verifier = URL_SAFE_NO_PAD.encode(bytes);
let challenge = { let challenge = {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes()); hasher.update(verifier.as_bytes());
+1 -1
View File
@@ -51,7 +51,7 @@ impl<R: SessionRepository, L: SessionLockRepository>
{ {
fn create_session(&self, title: &str) -> Result<Session, ServiceError> { fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
let id = SessionId::new(&Uuid::new_v4().to_string()) let id = SessionId::new(&Uuid::new_v4().to_string())
.map_err(|e| ServiceError::Other(e))?; .map_err(ServiceError::Other)?;
let title_owned = if title.is_empty() { let title_owned = if title.is_empty() {
"New Session".to_string() "New Session".to_string()
} else { } else {
+6
View File
@@ -23,4 +23,10 @@ pub trait TokenService: Send + Sync {
/// Returns `Err` if the token is expired, malformed, or has an /// Returns `Err` if the token is expired, malformed, or has an
/// invalid signature. /// invalid signature.
fn verify_access_token(&self, token: &str) -> Result<String>; fn verify_access_token(&self, token: &str) -> Result<String>;
/// Verify a refresh token and return the embedded subject claim.
///
/// Returns `Err` if the token is expired, malformed, or has an
/// invalid signature.
fn verify_refresh_token(&self, token: &str) -> Result<String>;
} }
+8 -2
View File
@@ -16,7 +16,10 @@ fn main() -> anyhow::Result<()> {
if !settings_path.exists() { if !settings_path.exists() {
let settings = zesdex_domain::cms::Settings::default(); let settings = zesdex_domain::cms::Settings::default();
let content = serde_json::to_string_pretty(&settings)?; let content = serde_json::to_string_pretty(&settings)?;
std::fs::write(&settings_path, content)?; let tmp = store.base_dir.join("settings.json.tmp");
std::fs::write(&tmp, &content)?;
std::fs::File::open(&tmp)?.sync_all()?;
std::fs::rename(&tmp, &settings_path)?;
println!(" ✓ Default settings created"); println!(" ✓ Default settings created");
} else { } else {
println!(" · Settings already exist, skipping"); println!(" · Settings already exist, skipping");
@@ -27,7 +30,10 @@ fn main() -> anyhow::Result<()> {
if !config_path.exists() { if !config_path.exists() {
let config = zesdex_domain::cms::AppConfig::default(); let config = zesdex_domain::cms::AppConfig::default();
let content = serde_json::to_string_pretty(&config)?; let content = serde_json::to_string_pretty(&config)?;
std::fs::write(&config_path, content)?; let tmp = store.base_dir.join("app_config.json.tmp");
std::fs::write(&tmp, &content)?;
std::fs::File::open(&tmp)?.sync_all()?;
std::fs::rename(&tmp, &config_path)?;
println!(" ✓ Default app_config created"); println!(" ✓ Default app_config created");
} else { } else {
println!(" · App config already exists, skipping"); println!(" · App config already exists, skipping");
+5 -3
View File
@@ -14,6 +14,8 @@
//! 3. Oldest entries are evicted from the in-memory cache when //! 3. Oldest entries are evicted from the in-memory cache when
//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth) //! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth)
use std::collections::VecDeque;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A single recorded file edit event. /// A single recorded file edit event.
@@ -47,18 +49,18 @@ pub const MAX_MEMORY_ENTRIES: usize = 10_000;
/// In-memory view of a session's edit log. /// In-memory view of a session's edit log.
/// ///
/// Wraps a `Vec<EditLogEntry>` and provides basic query helpers. /// Wraps a `VecDeque<EditLogEntry>` and provides basic query helpers.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EditLog { pub struct EditLog {
/// Ordered list of edit entries (newest appended last). /// Ordered list of edit entries (newest appended last).
pub entries: Vec<EditLogEntry>, pub entries: VecDeque<EditLogEntry>,
} }
impl EditLog { impl EditLog {
/// Create an empty edit log with no entries. /// Create an empty edit log with no entries.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
entries: Vec::new(), entries: VecDeque::new(),
} }
} }
-1
View File
@@ -4,7 +4,6 @@
//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`. //! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`.
use std::path::Path; use std::path::Path;
use tracing;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let store = zesdex_domain::core::Store::new(); let store = zesdex_domain::core::Store::new();
-1
View File
@@ -4,7 +4,6 @@
//! configuration files plus a seed session for development/testing. //! configuration files plus a seed session for development/testing.
//! Invoked as `cargo run --bin seed`. //! Invoked as `cargo run --bin seed`.
use tracing;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let store = zesdex_domain::core::Store::new(); let store = zesdex_domain::core::Store::new();
+1
View File
@@ -136,6 +136,7 @@ fn run_api_server(port: u16) -> anyhow::Result<()> {
let rt = tokio::runtime::Runtime::new()?; let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async { rt.block_on(async {
let store = zesdex_domain::core::Store::new(); let store = zesdex_domain::core::Store::new();
store.ensure_dirs()?;
let state = zesdex_api::ApiState::new( let state = zesdex_api::ApiState::new(
store.base_dir.clone(), store.base_dir.clone(),
"dev-secret", "dev-secret",
+17 -1
View File
@@ -1,12 +1,20 @@
//! Background bash control — list, cancel, and inspect background processes. //! Background bash control — list, cancel, and inspect background processes.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, OnceLock};
use tracing::error; use tracing::error;
use super::job::BashJob; use super::job::BashJob;
/// Global accessor for the shared BashControl singleton.
///
/// Used by the Bash tool (to register jobs) and BashKill (to look them up).
pub fn bash_control() -> &'static BashControl {
static BASH_CONTROL: OnceLock<BashControl> = OnceLock::new();
BASH_CONTROL.get_or_init(BashControl::new)
}
/// Central registry of all running background bash jobs. /// Central registry of all running background bash jobs.
pub struct BashControl { pub struct BashControl {
jobs: Mutex<HashMap<String, Arc<BashJob>>>, jobs: Mutex<HashMap<String, Arc<BashJob>>>,
@@ -18,7 +26,15 @@ impl BashControl {
jobs: Mutex::new(HashMap::new()), jobs: Mutex::new(HashMap::new()),
} }
} }
}
impl Default for BashControl {
fn default() -> Self {
Self::new()
}
}
impl BashControl {
/// Register a new background job. /// Register a new background job.
pub fn register(&self, job: Arc<BashJob>) { pub fn register(&self, job: Arc<BashJob>) {
if let Ok(mut guard) = self.jobs.lock() { if let Ok(mut guard) = self.jobs.lock() {
+30 -12
View File
@@ -33,18 +33,38 @@ pub fn spawn_bash_job(cmd: String) -> Arc<BashJob> {
cancelled: AtomicBool::new(false), cancelled: AtomicBool::new(false),
}); });
// Spawn a monitor thread (in production this would use an async task) // Spawn a monitor thread using try_wait() polling so the lock is never
// held across a blocking wait, allowing cancel() to acquire the lock.
let job_clone = Arc::clone(&job); let job_clone = Arc::clone(&job);
std::thread::spawn(move || { std::thread::spawn(move || {
let mut guard = match job_clone.process.lock() { loop {
Ok(g) => g, let mut exited = false;
Err(poisoned) => { {
error!("bgbash job mutex poisoned, recovering"); let mut guard = match job_clone.process.lock() {
poisoned.into_inner() Ok(g) => g,
Err(poisoned) => {
error!("bgbash job mutex poisoned, recovering");
poisoned.into_inner()
}
};
if let Some(ref mut child) = *guard {
match child.try_wait() {
Ok(Some(_)) => exited = true,
Ok(None) => {} // still running
Err(e) => {
error!("bgbash wait error: {e}");
exited = true;
}
}
} else {
exited = true; // no child process
}
} // lock is dropped here — cancel() can now acquire it
if exited || job_clone.cancelled.load(Ordering::SeqCst) {
break;
} }
}; std::thread::sleep(std::time::Duration::from_millis(50));
if let Some(ref mut child) = *guard {
let _ = child.wait();
} }
}); });
@@ -69,8 +89,6 @@ impl BashJob {
let Ok(mut guard) = self.process.lock() else { let Ok(mut guard) = self.process.lock() else {
return false; return false;
}; };
guard.as_mut().map_or(false, |c| { guard.as_mut().is_some_and(|c| matches!(c.try_wait(), Ok(None)))
matches!(c.try_wait(), Ok(None))
})
} }
} }
+2 -2
View File
@@ -134,7 +134,7 @@ impl LlmClient {
loop { loop {
attempt += 1; attempt += 1;
if let Some(ref flag) = abort_flag { if let Some(flag) = abort_flag {
if flag.load(std::sync::atomic::Ordering::Relaxed) { if flag.load(std::sync::atomic::Ordering::Relaxed) {
anyhow::bail!("aborted"); anyhow::bail!("aborted");
} }
@@ -267,7 +267,7 @@ impl LlmClient {
} }
if meaningful_content { if meaningful_content {
if let Some(ref flag) = abort_flag { if let Some(flag) = abort_flag {
if flag.load(std::sync::atomic::Ordering::Relaxed) { if flag.load(std::sync::atomic::Ordering::Relaxed) {
return Err(anyhow::anyhow!("aborted")); return Err(anyhow::anyhow!("aborted"));
} }
+10 -1
View File
@@ -19,6 +19,15 @@ impl LspManager {
clients: HashMap::new(), clients: HashMap::new(),
} }
} }
}
impl Default for LspManager {
fn default() -> Self {
Self::new()
}
}
impl LspManager {
pub fn start(&mut self, language: &str, command: &str, args: &[String]) -> anyhow::Result<()> { pub fn start(&mut self, language: &str, command: &str, args: &[String]) -> anyhow::Result<()> {
let client = LspClient::start(command, args)?; let client = LspClient::start(command, args)?;
@@ -31,7 +40,7 @@ impl LspManager {
} }
pub fn shutdown_all(&mut self) { pub fn shutdown_all(&mut self) {
for (_lang, client) in &self.clients { for client in self.clients.values() {
let _ = client.shutdown(); let _ = client.shutdown();
} }
self.clients.clear(); self.clients.clear();
+9
View File
@@ -22,6 +22,15 @@ impl McpManager {
servers: HashMap::new(), servers: HashMap::new(),
} }
} }
}
impl Default for McpManager {
fn default() -> Self {
Self::new()
}
}
impl McpManager {
pub fn register(&mut self, name: &str, transport: &str) { pub fn register(&mut self, name: &str, transport: &str) {
self.servers.insert( self.servers.insert(
+48 -26
View File
@@ -1,7 +1,12 @@
//! Authentication middleware — session-lock based auth for Axum. //! Authentication middleware — session-lock based auth for Axum.
//!
//! Validates `X-Session-Id` header against the `SessionRepository` before
//! forwarding the request to the inner service.
use std::future::Future; use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use axum::body::Body; use axum::body::Body;
@@ -9,6 +14,7 @@ use axum::http::{Request, Response, StatusCode};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tower::{Layer, Service}; use tower::{Layer, Service};
use zesdex_domain::auth::{SessionId, SessionRepository};
/// Identity extracted from a validated session. /// Identity extracted from a validated session.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -30,40 +36,50 @@ impl SessionIdentity {
} }
/// Tower Layer that produces SessionAuthMiddleware services. /// Tower Layer that produces SessionAuthMiddleware services.
///
/// Holds a reference to the `SessionRepository` and the base directory
/// needed to validate session IDs.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SessionAuthLayer; pub struct SessionAuthLayer<R: SessionRepository + Send + Sync + 'static> {
base_dir: PathBuf,
repo: Arc<R>,
}
impl SessionAuthLayer { impl<R: SessionRepository + Send + Sync + 'static> SessionAuthLayer<R> {
pub fn new() -> Self { pub fn new(base_dir: PathBuf, repo: Arc<R>) -> Self {
Self Self { base_dir, repo }
} }
} }
impl Default for SessionAuthLayer { impl<S, R> Layer<S> for SessionAuthLayer<R>
fn default() -> Self { where
Self R: SessionRepository + Send + Sync + 'static,
} {
} type Service = SessionAuthMiddleware<S, R>;
impl<S> Layer<S> for SessionAuthLayer {
type Service = SessionAuthMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service { fn layer(&self, inner: S) -> Self::Service {
SessionAuthMiddleware { inner } SessionAuthMiddleware {
inner,
base_dir: self.base_dir.clone(),
repo: self.repo.clone(),
}
} }
} }
/// Tower Service that validates X-Session-Id before forwarding. /// Tower Service that validates X-Session-Id before forwarding.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SessionAuthMiddleware<S> { pub struct SessionAuthMiddleware<S, R: SessionRepository + Send + Sync + 'static> {
inner: S, inner: S,
base_dir: PathBuf,
repo: Arc<R>,
} }
impl<S, ReqBody> Service<Request<ReqBody>> for SessionAuthMiddleware<S> impl<S, ReqBody, R> Service<Request<ReqBody>> for SessionAuthMiddleware<S, R>
where where
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static, S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
S::Future: Send + 'static, S::Future: Send + 'static,
ReqBody: Send + 'static, ReqBody: Send + 'static,
R: SessionRepository + Send + Sync + 'static,
{ {
type Response = S::Response; type Response = S::Response;
type Error = S::Error; type Error = S::Error;
@@ -81,18 +97,24 @@ where
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.map(|s| s.to_string()); .map(|s| s.to_string());
if session_id.as_deref() != Some("valid-session") { // Validate the session against the repository.
// In production, this validates against the store match session_id {
return Box::pin(async move { Some(sid) => match SessionId::new(&sid) {
Ok(( Ok(id) => match self.repo.load_session(&self.base_dir, &id) {
StatusCode::UNAUTHORIZED, Ok(_session) => {
"missing or invalid X-Session-Id header", // Session is valid — forward the request.
) let fut = self.inner.call(req);
.into_response()) return Box::pin(fut);
}); }
Err(_) => { /* fall through to 401 */ }
},
Err(_) => { /* fall through to 401 */ }
},
None => { /* fall through to 401 */ }
} }
let fut = self.inner.call(req); Box::pin(async move {
Box::pin(fut) Ok((StatusCode::UNAUTHORIZED, "missing or invalid X-Session-Id header").into_response())
})
} }
} }
@@ -1,6 +1,7 @@
//! JSONL filebacked `EditLogRepository`. //! JSONL filebacked `EditLogRepository`.
//! Stores `EditLog` as an append-only newline-delimited JSON file. //! Stores `EditLog` as an append-only newline-delimited JSON file.
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::path::Path; use std::path::Path;
@@ -18,21 +19,21 @@ impl JsonlEditLogRepository {
Self Self
} }
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> { fn load_from_disk(path: &Path) -> VecDeque<EditLogEntry> {
let Ok(file) = std::fs::File::open(path) else { let Ok(file) = std::fs::File::open(path) else {
return Vec::new(); return VecDeque::new();
}; };
let reader = BufReader::new(file); let reader = BufReader::new(file);
let mut entries: Vec<EditLogEntry> = Vec::new(); let mut entries: VecDeque<EditLogEntry> = VecDeque::new();
for line in reader.lines() { for line in reader.lines() {
let Ok(line) = line else { let Ok(line) = line else {
continue; continue;
}; };
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) { if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
if entries.len() >= MAX_MEMORY_ENTRIES { if entries.len() >= MAX_MEMORY_ENTRIES {
entries.remove(0); entries.pop_front();
} }
entries.push(entry); entries.push_back(entry);
} }
} }
entries entries
@@ -74,14 +75,14 @@ impl EditLogRepository for JsonlEditLogRepository {
file.write_all(line.as_bytes())?; file.write_all(line.as_bytes())?;
file.sync_all()?; file.sync_all()?;
} }
log.entries.push(entry); log.entries.push_back(entry);
if log.entries.len() > MAX_MEMORY_ENTRIES { if log.entries.len() > MAX_MEMORY_ENTRIES {
log.entries.remove(0); log.entries.pop_front();
} }
Ok(()) Ok(())
} }
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> { fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
log.entries.clone() log.entries.clone().into_iter().collect()
} }
} }
@@ -17,26 +17,37 @@ impl MarkdownMemoryRepository {
Self Self
} }
/// Escape newlines in field values so they do not break the
/// line-oriented frontmatter parser.
fn escape_newlines(s: &str) -> String {
s.replace('\n', "\\n")
}
/// Unescape `\n` back to actual newlines after frontmatter parsing.
fn unescape_newlines(s: &str) -> String {
s.replace("\\n", "\n")
}
fn build_frontmatter(memory: &Memory) -> String { fn build_frontmatter(memory: &Memory) -> String {
let outcome_line = memory let outcome_line = memory
.outcome .outcome
.as_ref() .as_ref()
.map(|o| format!("outcome: {o}\n")) .map(|o| format!("outcome: {}\n", Self::escape_newlines(o)))
.unwrap_or_default(); .unwrap_or_default();
let scope_line = memory let scope_line = memory
.scope .scope
.as_ref() .as_ref()
.map(|s| format!("scope: {s}\n")) .map(|s| format!("scope: {}\n", Self::escape_newlines(s)))
.unwrap_or_default(); .unwrap_or_default();
let before_line = memory let before_line = memory
.before_snippet .before_snippet
.as_ref() .as_ref()
.map(|s| format!("before: {s}\n")) .map(|s| format!("before: {}\n", Self::escape_newlines(s)))
.unwrap_or_default(); .unwrap_or_default();
let after_line = memory let after_line = memory
.after_snippet .after_snippet
.as_ref() .as_ref()
.map(|s| format!("after: {s}\n")) .map(|s| format!("after: {}\n", Self::escape_newlines(s)))
.unwrap_or_default(); .unwrap_or_default();
let prov_line = if memory.provenances.is_empty() { let prov_line = if memory.provenances.is_empty() {
String::new() String::new()
@@ -101,14 +112,30 @@ impl MarkdownMemoryRepository {
.get("updated_at") .get("updated_at")
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(0), .unwrap_or(0),
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()), outcome: front
.get("outcome")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
lifecycle: front lifecycle: front
.get("lifecycle") .get("lifecycle")
.cloned() .cloned()
.unwrap_or_else(|| "new".to_string()), .unwrap_or_else(|| "new".to_string()),
scope: front.get("scope").cloned().filter(|s| !s.is_empty()), scope: front
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()), .get("scope")
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()), .cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
before_snippet: front
.get("before")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
after_snippet: front
.get("after")
.cloned()
.filter(|s| !s.is_empty())
.map(|s| Self::unescape_newlines(&s)),
provenances: front provenances: front
.get("provenances") .get("provenances")
.cloned() .cloned()
@@ -36,7 +36,7 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
Err(e) => return Err(RepositoryError::Io(e)), Err(e) => return Err(RepositoryError::Io(e)),
} }
let content = std::fs::read_to_string(&path).unwrap_or_default(); let content = std::fs::read_to_string(&path).map_err(RepositoryError::Io)?;
if let Ok(existing_pid) = content.trim().parse::<u32>() { if let Ok(existing_pid) = content.trim().parse::<u32>() {
if self.is_alive(existing_pid) { if self.is_alive(existing_pid) {
return Ok(false); return Ok(false);
@@ -46,10 +46,14 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
let tmp = path.with_extension("lock.tmp"); let tmp = path.with_extension("lock.tmp");
{ {
let mut tmp_file = std::fs::OpenOptions::new() let mut tmp_file = std::fs::OpenOptions::new()
.create(true) .create_new(true)
.truncate(true)
.write(true) .write(true)
.open(&tmp)?; .open(&tmp)
.map_err(|_| {
RepositoryError::Other(
"another process is replacing the lock".to_string(),
)
})?;
write!(tmp_file, "{pid}")?; write!(tmp_file, "{pid}")?;
tmp_file.sync_all()?; tmp_file.sync_all()?;
} }
@@ -62,7 +66,7 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> { fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> {
let path = session_dir.join(".lock"); let path = session_dir.join(".lock");
let _ = std::fs::remove_file(path); std::fs::remove_file(path)?;
Ok(()) Ok(())
} }
+1 -1
View File
@@ -26,7 +26,7 @@ const MAX_ITERATIONS: u32 = 25;
/// a. Call the LLM (non-streaming) with accumulated messages + tool defs. /// a. Call the LLM (non-streaming) with accumulated messages + tool defs.
/// b. If the response has no tool calls → return the text content. /// b. If the response has no tool calls → return the text content.
/// c. Otherwise execute each tool call and append the result as a /// c. Otherwise execute each tool call and append the result as a
/// tool-role message. /// tool-role message.
/// d. If the response also contained text, append an assistant message. /// d. If the response also contained text, append an assistant message.
/// 4. If the loop exits naturally, return the iteration-limit message. /// 4. If the loop exits naturally, return the iteration-limit message.
pub async fn run_agent( pub async fn run_agent(
+1 -1
View File
@@ -3,7 +3,7 @@
//! //!
//! Flow: `resolve_subagent_provider` is called at startup to pick a provider //! Flow: `resolve_subagent_provider` is called at startup to pick a provider
//! + model → `SubagentProvider` wraps that pair around an `LlmClient` for use //! + model → `SubagentProvider` wraps that pair around an `LlmClient` for use
//! inside the subagent engine loop. //! inside the subagent engine loop.
use anyhow::Result; use anyhow::Result;
@@ -1,9 +1,9 @@
//! Subagent workspace management — create isolated workspaces for subagents. //! Subagent workspace management — create isolated workspaces for subagents.
use std::path::PathBuf; use std::path::{Path, PathBuf};
/// Create an isolated workspace directory for a subagent. /// Create an isolated workspace directory for a subagent.
pub fn create_subagent_workspace(base_dir: &PathBuf, agent_id: &str) -> anyhow::Result<PathBuf> { pub fn create_subagent_workspace(base_dir: &Path, agent_id: &str) -> anyhow::Result<PathBuf> {
let ws = base_dir.join("subagent-workspaces").join(agent_id); let ws = base_dir.join("subagent-workspaces").join(agent_id);
std::fs::create_dir_all(&ws)?; std::fs::create_dir_all(&ws)?;
Ok(ws) Ok(ws)
+9 -16
View File
@@ -37,6 +37,11 @@ impl Tool for BashOutput {
let job_id = arg_str(args, "job_id")?; let job_id = arg_str(args, "job_id")?;
info!("Getting output for job: {job_id}"); info!("Getting output for job: {job_id}");
// Prevent path traversal
if job_id.contains('/') || job_id.contains('\\') || job_id.contains("..") {
anyhow::bail!("invalid job_id '{job_id}': must not contain path separators");
}
// Read from the session's bash output directory // Read from the session's bash output directory
let output_dir = ctx.session_dir.join("bash-outputs"); let output_dir = ctx.session_dir.join("bash-outputs");
let output_file = output_dir.join(&job_id); let output_file = output_dir.join(&job_id);
@@ -80,23 +85,11 @@ impl Tool for BashKill {
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let job_id = crate::tools::arg_str(args, "job_id")?; let job_id = crate::tools::arg_str(args, "job_id")?;
info!("bash_kill called for job: {job_id}"); info!("bash_kill called for job: {job_id}");
// Try to kill by PID (if job_id is numeric) or by process name
if let Ok(pid) = job_id.parse::<u32>() { if crate::bgbash::control::bash_control().cancel(&job_id) {
use std::process::Command; Ok(format!("Killed background job '{job_id}'"))
match Command::new("kill").arg(pid.to_string()).output() {
Ok(output) if output.status.success() => {
Ok(format!("Killed background job '{job_id}' (PID {pid})"))
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(format!("Failed to kill job '{job_id}': {stderr}"))
}
Err(e) => {
Ok(format!("Failed to kill job '{job_id}': {e}"))
}
}
} else { } else {
Ok(format!("Invalid job ID '{job_id}' — expected numeric PID")) anyhow::bail!("no active background job found with ID '{job_id}'")
} }
} }
} }
+4 -2
View File
@@ -45,8 +45,10 @@ impl Tool for Delete {
fs::remove_file(&path)?; fs::remove_file(&path)?;
Ok(format!("Deleted file '{rel}'")) Ok(format!("Deleted file '{rel}'"))
} else if path.is_dir() { } else if path.is_dir() {
fs::remove_dir_all(&path)?; fs::remove_dir(&path).map_err(|e| {
Ok(format!("Deleted directory '{rel}' and all contents")) anyhow::anyhow!("failed to delete directory '{rel}': {e} (directory must be empty)")
})?;
Ok(format!("Deleted empty directory '{rel}'"))
} else { } else {
anyhow::bail!("'{rel}' is neither a file nor a directory") anyhow::bail!("'{rel}' is neither a file nor a directory")
} }
+1 -1
View File
@@ -56,7 +56,7 @@ impl Tool for Edit {
anyhow::bail!("old text not found in '{}'", rel); anyhow::bail!("old text not found in '{}'", rel);
} }
let new_content = content.replace(&old, &new); let new_content = content.replacen(&old, &new, 1);
fs::write(&path, &new_content)?; fs::write(&path, &new_content)?;
Ok(format!( Ok(format!(
+20 -5
View File
@@ -3,6 +3,8 @@
use crate::tools::{execute_cmd, Tool, ToolCtx}; use crate::tools::{execute_cmd, Tool, ToolCtx};
use anyhow::Result; use anyhow::Result;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::io::Write;
use std::process::{Command, Stdio};
pub struct GitCred; pub struct GitCred;
@@ -49,10 +51,15 @@ impl Tool for GitCred {
let url = crate::tools::arg_str(args, "url")?; let url = crate::tools::arg_str(args, "url")?;
let username = crate::tools::arg_str(args, "username")?; let username = crate::tools::arg_str(args, "username")?;
let password = crate::tools::arg_str(args, "password")?; let password = crate::tools::arg_str(args, "password")?;
let _input = format!("url={url}\nusername={username}\npassword={password}\n"); let input = format!("url={url}\nusername={username}\npassword={password}\n");
let _output = execute_cmd( let mut child = Command::new("git")
std::process::Command::new("git").args(["credential", "approve"]), .args(["credential", "approve"])
)?; .stdin(Stdio::piped())
.spawn()?;
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(input.as_bytes())?;
}
child.wait()?;
Ok(format!("Credential stored for {url}")) Ok(format!("Credential stored for {url}"))
} }
"list" => { "list" => {
@@ -63,7 +70,15 @@ impl Tool for GitCred {
} }
"erase" => { "erase" => {
let url = crate::tools::arg_str(args, "url")?; let url = crate::tools::arg_str(args, "url")?;
let _input = format!("url={url}\n"); let input = format!("url={url}\n");
let mut child = Command::new("git")
.args(["credential", "reject"])
.stdin(Stdio::piped())
.spawn()?;
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(input.as_bytes())?;
}
child.wait()?;
Ok(format!("Credential erased for {url}")) Ok(format!("Credential erased for {url}"))
} }
_ => anyhow::bail!("unknown action: {}", action), _ => anyhow::bail!("unknown action: {}", action),
@@ -1,5 +1,6 @@
//! Git operator tool — commit, push, pull, branch operations. //! Git operator tool — commit, push, pull, branch operations.
use crate::tools::shell_filter::git::check_git_destructive;
use crate::tools::{execute_cmd, Tool, ToolCtx}; use crate::tools::{execute_cmd, Tool, ToolCtx};
use anyhow::Result; use anyhow::Result;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -46,6 +47,12 @@ impl Tool for GitOperator {
}) })
.unwrap_or_default(); .unwrap_or_default();
// Safety filter: block destructive git operations
let cmd_str = format!("git {} {}", operation, extra_args.join(" "));
if let Err(e) = check_git_destructive(&cmd_str) {
anyhow::bail!("blocked: {e}");
}
let mut cmd = std::process::Command::new("git"); let mut cmd = std::process::Command::new("git");
cmd.arg(&operation); cmd.arg(&operation);
for arg in &extra_args { for arg in &extra_args {
+1
View File
@@ -61,6 +61,7 @@ impl Tool for Bash {
if run_in_background { if run_in_background {
let job = crate::bgbash::job::spawn_bash_job(cmd); let job = crate::bgbash::job::spawn_bash_job(cmd);
crate::bgbash::control::bash_control().register(job.clone());
return Ok(format!("Background job: {}", job.id)); return Ok(format!("Background job: {}", job.id));
} }
+15 -3
View File
@@ -151,7 +151,8 @@ impl Tool for SpawnPipeline {
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
"directive": {"type": "string", "description": "Directive for this pipeline stage"} "directive": {"type": "string", "description": "Directive for this pipeline stage"},
"access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier for this stage"}
}, },
"required": ["directive"] "required": ["directive"]
}, },
@@ -200,17 +201,28 @@ impl Tool for SpawnPipeline {
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let access_str = stage
.get("access")
.and_then(|v| v.as_str())
.unwrap_or("full");
let access = match access_str {
"read" => AccessTier::Read,
"write" => AccessTier::Write,
_ => AccessTier::Full,
};
let subagent_ctx = SubagentContext::new( let subagent_ctx = SubagentContext::new(
directive.clone(), directive.clone(),
ctx.clone(), ctx.clone(),
"full".to_string(), access_str.to_string(),
base_url.clone(), base_url.clone(),
api_key.clone(), api_key.clone(),
model.clone(), model.clone(),
); );
let result = rt.block_on(async { let result = rt.block_on(async {
run_agent(subagent_ctx, &directive, AccessTier::Full, ctx.clone()).await run_agent(subagent_ctx, &directive, access, ctx.clone()).await
})?; })?;
pipeline_result.push_str(&format!("Stage {}: {}\n", i, result)); pipeline_result.push_str(&format!("Stage {}: {}\n", i, result));
@@ -1,9 +1,10 @@
//! Hive-mind cycle execution — run one cycle of parallel nodes. //! Hive-mind cycle execution — run one cycle of parallel nodes.
//! //!
//! Flow: load settings → resolve LLM credentials → for each directive, //! Flow: load settings → resolve LLM credentials → run all directives in the
//! build a SubagentContext and call run_agent → collect NodeOutputs. //! cycle concurrently via try_join_all → collect Vec<NodeOutput>.
use anyhow::Result; use anyhow::Result;
use futures_util::future::try_join_all;
use tracing::info; use tracing::info;
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
@@ -21,8 +22,9 @@ use crate::workflow::hive_mind::types::{CognitiveCycle, NodeOutput};
/// Flow: /// Flow:
/// 1. Load `Settings` and `AppConfig` from the store directory. /// 1. Load `Settings` and `AppConfig` from the store directory.
/// 2. Resolve provider, model, base_url, and api_key. /// 2. Resolve provider, model, base_url, and api_key.
/// 3. For each directive build `SubagentContext` → `run_agent` (Full access). /// 3. Spawn all directives concurrently — each builds a `SubagentContext`
/// 4. Collect `NodeOutput` results. /// and calls `run_agent` (Full access).
/// 4. `try_join_all` waits for all to complete, then collect `NodeOutput`s.
pub async fn execute_cycle( pub async fn execute_cycle(
cycle: &CognitiveCycle, cycle: &CognitiveCycle,
tool_ctx: &ToolCtx, tool_ctx: &ToolCtx,
@@ -52,25 +54,37 @@ pub async fn execute_cycle(
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
let mut outputs = Vec::new(); let cycle_index = cycle.index;
for (i, directive) in cycle.directives.iter().enumerate() {
let ctx = SubagentContext::new(
directive.clone(),
tool_ctx.clone(),
"full".to_string(),
base_url.clone(),
api_key.clone(),
model.clone(),
);
let result = run_agent(ctx, directive, AccessTier::Full, tool_ctx.clone()).await?; // Run all directives in this cycle concurrently.
let handles: Vec<_> = cycle
.directives
.iter()
.enumerate()
.map(|(i, directive)| {
let dir = directive.clone();
let ctx = SubagentContext::new(
dir.clone(),
tool_ctx.clone(),
"full".to_string(),
base_url.clone(),
api_key.clone(),
model.clone(),
);
let tc = tool_ctx.clone();
outputs.push(NodeOutput { async move {
id: format!("Node-{}-{}", cycle.index, i), let result = run_agent(ctx, &dir, AccessTier::Full, tc).await?;
directive: directive.clone(), Ok::<NodeOutput, anyhow::Error>(NodeOutput {
output: result, id: format!("Node-{}-{}", cycle_index, i),
}); directive: dir,
} output: result,
})
}
})
.collect();
Ok(outputs) let results = try_join_all(handles).await?;
Ok(results)
} }
@@ -14,6 +14,15 @@ impl LiveHiveMind {
nodes: Mutex::new(HashMap::new()), nodes: Mutex::new(HashMap::new()),
} }
} }
}
impl Default for LiveHiveMind {
fn default() -> Self {
Self::new()
}
}
impl LiveHiveMind {
pub fn set_status(&self, agent_id: &str, status: &str) { pub fn set_status(&self, agent_id: &str, status: &str) {
if let Ok(mut guard) = self.nodes.lock() { if let Ok(mut guard) = self.nodes.lock() {
+1 -1
View File
@@ -203,7 +203,7 @@ pub async fn refresh_handler(
// Verify the refresh token and extract the subject // Verify the refresh token and extract the subject
let sub = state let sub = state
.token_service .token_service
.verify_access_token(&req.refresh_token) .verify_refresh_token(&req.refresh_token)
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?; .map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
// Generate a fresh token pair // Generate a fresh token pair
+12 -9
View File
@@ -109,18 +109,21 @@ pub async fn chat_completions_handler(
// Call the LLM provider (non-streaming) // Call the LLM provider (non-streaming)
// //
// We create a temporary LlmClient with the overridden model so we // When the requested model matches the shared client we reuse it to
// don't mutate the shared state's client. // avoid allocating a new HTTP connection. Otherwise we create a
// temporary LlmClient with the requested model — the ownership
// lives on the stack via `temp_client`.
#[allow(unused_assignments)]
let mut temp_client: Option<zesdex_infrastructure::llm::LlmClient> = None;
let llm_client = if model == state.llm_client.model { let llm_client = if model == state.llm_client.model {
// Use the shared client directly
&state.llm_client &state.llm_client
} else { } else {
// Create a modified client for this request (only borrows, but temp_client = Some(zesdex_infrastructure::llm::LlmClient::new(
// we need to own it for the call — handled below) state.llm_client.api_key.clone(),
// model,
// For simplicity, use the shared client with its model. A full Some(state.llm_client.base_url.clone()),
// implementation would override the model per request. ));
&state.llm_client temp_client.as_ref().unwrap()
}; };
let (response, usage) = llm_client let (response, usage) = llm_client
@@ -103,29 +103,44 @@ pub async fn add_message_handler(
)) ))
} }
/// DELETE /sessions/:id/conversations/:cid — delete a message from a conversation. /// DELETE /sessions/:id/conversations/:cid — delete a single message by index.
/// ///
/// Note: the `cid` parameter currently identifies the message index or the /// ## Flow
/// entire conversation. For simplicity, this deletes the entire conversation ///
/// and creates a fresh one. A more sophisticated implementation would remove /// 1. Extract session ID and message index from the path.
/// a single message by index. /// 2. Load the conversation.
/// 3. Remove the message at `cid` (zero-based index).
/// 4. Persist the updated conversation.
/// ///
/// ## Errors /// ## Errors
/// ///
/// - `404 Not Found` — conversation not found. /// - `400 Bad Request` — session ID is empty or `cid` is not a valid integer.
/// - `404 Not Found` — conversation or message index not found.
#[tracing::instrument(skip(state))] #[tracing::instrument(skip(state))]
pub async fn delete_message_handler( pub async fn delete_message_handler(
State(state): State<Arc<ApiState>>, State(state): State<Arc<ApiState>>,
Path((id, _cid)): Path<(String, String)>, Path((id, cid)): Path<(String, String)>,
) -> Result<axum::http::StatusCode, ApiError> { ) -> Result<axum::http::StatusCode, ApiError> {
if id.is_empty() { if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into())); return Err(ApiError::BadRequest("Session ID is required".into()));
} }
// Load conversation and clear all messages // Parse the message index from the path
let index: usize = cid
.parse()
.map_err(|_| ApiError::BadRequest(format!("Invalid message index: {cid}")))?;
// Load conversation and remove the specific message by index
let mut conversation = state.conversation_service.load_conversation(&id)?; let mut conversation = state.conversation_service.load_conversation(&id)?;
conversation.messages.clear(); if index >= conversation.messages.len() {
return Err(ApiError::NotFound(format!(
"Message index {index} out of bounds (max: {})",
conversation.messages.len().saturating_sub(1)
)));
}
conversation.messages.remove(index);
state state
.conversation_service .conversation_service
.save_conversation(&conversation)?; .save_conversation(&conversation)?;
+7
View File
@@ -56,10 +56,17 @@ pub fn build_router(state: ApiState) -> Router {
// CORS layer — permissive for local daemon / development use // CORS layer — permissive for local daemon / development use
let cors = CorsLayer::permissive(); let cors = CorsLayer::permissive();
// JWT auth middleware — validates Bearer tokens on all API routes.
// Health and auth endpoints (login/register/refresh) are also
// protected; adjust route ordering or add an allow-list inside the
// middleware if public access is needed.
let jwt_auth = middleware::auth::JwtAuthLayer::new(shared_state.clone());
// Combine all sub-routers under a versioned prefix // Combine all sub-routers under a versioned prefix
Router::new() Router::new()
.nest("/api/v1", api_v1_router()) .nest("/api/v1", api_v1_router())
.layer(cors) .layer(cors)
.layer(jwt_auth)
.with_state(shared_state) .with_state(shared_state)
} }
+13
View File
@@ -120,6 +120,19 @@ impl TokenService for JwtTokenService {
let claims = verify_token(&self.secret, token)?; let claims = verify_token(&self.secret, token)?;
Ok(claims.sub) Ok(claims.sub)
} }
/// Verify a refresh token and return the subject claim.
///
/// Delegates to the same JWT verification function as access tokens;
/// the signature algorithm and secret are shared. Expiry validation
/// is handled by the JWT library against the `exp` claim embedded
/// in the token payload.
fn verify_refresh_token(&self, token: &str) -> anyhow::Result<String> {
use zesdex_infrastructure::auth::jwt::verify_token;
let claims = verify_token(&self.secret, token)?;
Ok(claims.sub)
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -1
View File
@@ -217,7 +217,7 @@ fn draw(frame: &mut ratatui::Frame, state: &AppStateRest) {
// Show toasts at the top if present. // Show toasts at the top if present.
for toast in &state.misc.toasts { for toast in &state.misc.toasts {
content_lines.push(format!("[{}] {}", format!("{:?}", toast.kind), toast.message)); content_lines.push(format!("[{:?}] {}", toast.kind, toast.message));
} }
// Show active overlay name. // Show active overlay name.
+52 -23
View File
@@ -196,7 +196,10 @@ fn handle_tick(state: &mut AppStateRest) {
.turn_events .turn_events
.lock() .lock()
.map(|mut events| events.drain(..).collect()) .map(|mut events| events.drain(..).collect())
.unwrap_or_default(); .unwrap_or_else(|e| {
tracing::error!("turn_events mutex poisoned, recovering");
e.into_inner().drain(..).collect()
});
for event in drained { for event in drained {
use zesdex_infrastructure::TurnEvent; use zesdex_infrastructure::TurnEvent;
@@ -209,11 +212,9 @@ fn handle_tick(state: &mut AppStateRest) {
} }
handle_system_note(state, message); handle_system_note(state, message);
} }
TurnEvent::AssistantMessage(msg) => { TurnEvent::AssistantMessage(_msg) => {
state.push_transcript(ChatMessageDisplay::new( // AssistantMessage is handled via StreamDone to avoid duplicates.
RoleWrapper::Assistant, state.dirty = true;
msg.content.unwrap_or_default(),
));
} }
TurnEvent::StreamToken(_token) => { TurnEvent::StreamToken(_token) => {
state.dirty = true; state.dirty = true;
@@ -229,8 +230,8 @@ fn handle_tick(state: &mut AppStateRest) {
} }
TurnEvent::Usage { tokens_in, tokens_out } => { TurnEvent::Usage { tokens_in, tokens_out } => {
if let Some(ref mut rt) = state.session_runtime { if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in; rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out += tokens_out; rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
} }
} }
TurnEvent::ReviewUsage { TurnEvent::ReviewUsage {
@@ -238,8 +239,8 @@ fn handle_tick(state: &mut AppStateRest) {
tokens_out, tokens_out,
} => { } => {
if let Some(ref mut rt) = state.session_runtime { if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in; rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out += tokens_out; rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
} }
} }
TurnEvent::Done => { TurnEvent::Done => {
@@ -248,6 +249,11 @@ fn handle_tick(state: &mut AppStateRest) {
} }
state.dirty = true; state.dirty = true;
} }
TurnEvent::ToolResult { .. } => {
// Tool result events are logged but the content is already in
// the session runtime messages — no transcript push needed here.
state.dirty = true;
}
_ => { _ => {
state.dirty = true; state.dirty = true;
} }
@@ -392,11 +398,14 @@ fn handle_abort_turn(state: &mut AppStateRest) {
fn handle_compact(state: &mut AppStateRest) { fn handle_compact(state: &mut AppStateRest) {
tracing::info!("compacting conversation"); tracing::info!("compacting conversation");
const KEEP_COUNT: usize = 10; const KEEP_HEAD: usize = 2; // system prompt + tool definitions
const KEEP_TAIL: usize = 10; // recent conversation messages
if let Some(ref mut rt) = state.session_runtime { if let Some(ref mut rt) = state.session_runtime {
if rt.messages.len() > KEEP_COUNT { if rt.messages.len() > KEEP_HEAD + KEEP_TAIL {
let keep = rt.messages.split_off(rt.messages.len() - KEEP_COUNT); let tail = rt.messages.split_off(rt.messages.len() - KEEP_TAIL);
rt.messages = keep; let head: Vec<_> = rt.messages.drain(..KEEP_HEAD.min(rt.messages.len())).collect();
rt.messages = head;
rt.messages.extend(tail);
let msg_count = rt.messages.len(); let msg_count = rt.messages.len();
state.push_transcript(ChatMessageDisplay::new( state.push_transcript(ChatMessageDisplay::new(
RoleWrapper::System, RoleWrapper::System,
@@ -418,14 +427,26 @@ fn handle_open_editor(state: &mut AppStateRest, path: String) {
fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) { fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
tracing::info!("adding MCP server: {name}"); tracing::info!("adding MCP server: {name}");
state.toast_info(format!("MCP server '{name}' registered with command: {command}")); state.mcp_manager.register(&name, &command);
state.toast_success(format!("MCP server '{name}' added with command: {command}"));
state.dirty = true; state.dirty = true;
} }
fn handle_start_oauth(state: &mut AppStateRest, provider: String) { fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
tracing::info!("starting OAuth for provider: {provider}"); tracing::info!("starting OAuth for provider: {provider}");
state.toast_info(format!("OAuth flow started for {provider}...")); state.toast_info(format!("OAuth flow started for {provider}..."));
if let Err(e) = webbrowser::open(&format!("https://{provider}.com/auth")) { // Construct provider-specific OAuth authorization URL.
let auth_url_owned;
let url_to_open = match provider.as_str() {
"github" => "https://github.com/login/oauth/authorize",
"google" => "https://accounts.google.com/o/oauth2/v2/auth",
"anthropic" => "https://anthropic.com/api/oauth/authorize",
other => {
auth_url_owned = format!("https://{other}.com/auth");
&auth_url_owned
}
};
if let Err(e) = webbrowser::open(url_to_open) {
tracing::warn!("Failed to open browser for OAuth: {e}"); tracing::warn!("Failed to open browser for OAuth: {e}");
state.toast_error(format!("Failed to open browser: {e}")); state.toast_error(format!("Failed to open browser: {e}"));
} }
@@ -503,11 +524,8 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
// ── Normal mode ─────────────────────────────────────────────────────── // ── Normal mode ───────────────────────────────────────────────────────
match key.code { match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if state.misc.overlay.is_active() { // Always show quit-confirm, regardless of overlay state.
vec![Action::QuitConfirm] vec![Action::QuitConfirm]
} else {
vec![Action::ForceQuit]
}
} }
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::CloseOverlay] vec![Action::CloseOverlay]
@@ -531,7 +549,14 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
} }
KeyCode::Enter => { KeyCode::Enter => {
if state.misc.overlay.is_active() { if state.misc.overlay.is_active() {
vec![Action::CloseOverlay] match state.misc.overlay {
Overlay::QuitConfirm => vec![Action::ForceQuit],
Overlay::ClearConfirm => vec![Action::SystemNote {
kind: "clear".to_string(),
message: "cleared".to_string(),
}],
_ => vec![Action::CloseOverlay],
}
} else if state.input.autocomplete_visible { } else if state.input.autocomplete_visible {
// Select the current autocomplete candidate // Select the current autocomplete candidate
if !state.input.autocomplete_candidates.is_empty() { if !state.input.autocomplete_candidates.is_empty() {
@@ -749,10 +774,14 @@ pub fn handle_daemon_client(
if let Some(text) = state.misc.pending_clipboard_copy.take() { if let Some(text) = state.misc.pending_clipboard_copy.take() {
conn.send(&DaemonFrame::ClipboardCopy(text))?; conn.send(&DaemonFrame::ClipboardCopy(text))?;
} }
send_daemon_update(&mut conn, state)?; // Only send state update for valid requests, not on EOF/disconnect.
if running {
send_daemon_update(&mut conn, state)?;
}
} }
None => { None => {
running = false; running = false;
// Don't call send_daemon_update — connection is dead.
} }
} }
} }
+5
View File
@@ -151,6 +151,11 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
zesdex_infrastructure::TurnEvent::Error(msg) => { zesdex_infrastructure::TurnEvent::Error(msg) => {
state.toast_error(msg); state.toast_error(msg);
} }
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
if let Some(ref mut rt) = state.session_runtime {
rt.messages = msgs;
}
}
zesdex_infrastructure::TurnEvent::Done => { zesdex_infrastructure::TurnEvent::Done => {
if let Ok(mut flag) = state.turn_in_flight_flag.lock() { if let Ok(mut flag) = state.turn_in_flight_flag.lock() {
*flag = false; *flag = false;
+1 -1
View File
@@ -788,7 +788,7 @@ pub fn count_tokens(text: &str) -> usize {
return bpe.encode_with_special_tokens(text).len(); return bpe.encode_with_special_tokens(text).len();
} }
// Fallback: ~4 chars per token // Fallback: ~4 chars per token
(text.len() + 3) / 4 text.len().div_ceil(4)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+8 -3
View File
@@ -3,7 +3,7 @@
//! Flow: push user message → spawn OS thread → loop: call blocking LLM //! Flow: push user message → spawn OS thread → loop: call blocking LLM
//! client → execute tool calls → push TurnEvents → repeat until done. //! client → execute tool calls → push TurnEvents → repeat until done.
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::collections::VecDeque; use std::collections::VecDeque;
@@ -53,7 +53,7 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
/// The core agent turn — LLM call → tool execution → repeat. /// The core agent turn — LLM call → tool execution → repeat.
fn run_turn( fn run_turn(
messages: &mut Vec<ChatMessage>, messages: &mut Vec<ChatMessage>,
session_dir: &PathBuf, session_dir: &Path,
workspace_roots: &[PathBuf], workspace_roots: &[PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
in_flight: &Arc<Mutex<bool>>, in_flight: &Arc<Mutex<bool>>,
@@ -89,7 +89,7 @@ fn run_turn(
messages.insert(0, sys_msg); messages.insert(0, sys_msg);
let tool_ctx = ToolCtx::builder() let tool_ctx = ToolCtx::builder()
.session_dir(session_dir.clone()) .session_dir(session_dir.to_path_buf())
.workspaces(workspace_roots.to_vec()) .workspaces(workspace_roots.to_vec())
.build(); .build();
@@ -173,6 +173,11 @@ fn run_turn(
} }
} }
// Propagate accumulated messages back to session_runtime so the next
// turn starts with the full history (assistant replies + tool results).
// TurnEvent::Compacted already exists on the enum and is handled in
// action.rs to write back to state.session_runtime.messages.
push_event(turn_events, TurnEvent::Compacted(messages.clone()));
push_event(turn_events, TurnEvent::Done); push_event(turn_events, TurnEvent::Done);
mark_done(in_flight); mark_done(in_flight);
} }