feat(v0.8.9): address all issues labeled v0.8.9
#551 — sidebar filters prior-session agents (from_prior_session) #552 — status messages prioritise ↑ affordance over /queue #553 — oversized paste consolidation to @mention file (+uuid suffix) #523 — release.yml: add if: guard so release job doesn't skip on dispatch #526 — verify cost_status side-channel is fully wired (already in place) #554 — mouse/trackpad scroll now sets user_scrolled_during_stream #522 — set RELEASE_TAG_PAT secret for auto-tag → release trigger #504 — session-context panel (SidebarFocus::Context, config toggle, default off) #501 — multi-arch Dockerfile (+BUILDPLATFORM pin) + devcontainer + release CI #484 — docs/RUNTIME_API.md rewritten against actual runtime_api.rs endpoints #482 — close v0.8.8 planning tracker Fixes from review: - RUNTIME_API.md: corrected endpoints (/v1/...), port (7878), doctor JSON schema (flat) - Dockerfile: added --platform=$BUILDPLATFORM for native multi-arch builds - docs/DOCKER.md: removed Docker Hub references (GHCR only) - sidebar.rs: dropped unused _theme variable - settings.rs: context_panel default changed to false - app.rs: paste filename now includes 8-char uuid suffix to avoid collision
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "DeepSeek TUI",
|
||||
"dockerFile": "../Dockerfile",
|
||||
"build": {
|
||||
"args": {
|
||||
"RUST_VERSION": "1.85"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"rust-lang.rust-analyzer",
|
||||
"tamasfe.even-better-toml",
|
||||
"vadimcn.vscode-lldb"
|
||||
],
|
||||
"settings": {
|
||||
"rust-analyzer.cargo.features": "all",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"remoteEnv": {
|
||||
"DEEPSEEK_API_KEY": "${localEnv:DEEPSEEK_API_KEY}"
|
||||
},
|
||||
"mounts": [
|
||||
"source=${localEnv:HOME}/.deepseek,target=/home/deepseek/.deepseek,type=bind,consistency=cached"
|
||||
],
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/rust:1": {},
|
||||
"ghcr.io/devcontainers/features/git:1": {}
|
||||
},
|
||||
"postCreateCommand": "cargo build",
|
||||
"remoteUser": "deepseek"
|
||||
}
|
||||
@@ -115,8 +115,50 @@ jobs:
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: ${{ matrix.artifact_name }}
|
||||
docker:
|
||||
needs: build
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=ref,event=tag
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
release:
|
||||
needs: build
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# DeepSeek-TUI multi-arch Docker image (#501)
|
||||
#
|
||||
# Build: docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui:latest .
|
||||
# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v ~/.deepseek:/home/deepseek/.deepseek deepseek-tui
|
||||
#
|
||||
# The image ships both binaries (deepseek dispatcher + deepseek-tui runtime)
|
||||
# in a minimal runtime layer. No MCP servers or heavy toolchains are included
|
||||
# — keep it slim.
|
||||
|
||||
ARG RUST_VERSION=1.85
|
||||
|
||||
# ── Stage 1: Build ────────────────────────────────────────────────────
|
||||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libdbus-1-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Translate Docker platform into Rust target triple.
|
||||
# linux/amd64 → x86_64-unknown-linux-gnu
|
||||
# linux/arm64 → aarch64-unknown-linux-gnu
|
||||
RUN case "${TARGETPLATFORM}" in \
|
||||
linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \
|
||||
linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \
|
||||
*) echo "Unsupported platform: ${TARGETPLATFORM}" >&2; exit 1 ;; \
|
||||
esac
|
||||
|
||||
RUN rustup target add "$(cat /rust-target)"
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
|
||||
# Build both binaries for the target platform. --locked ensures
|
||||
# reproducible builds from the committed lockfile.
|
||||
RUN --mount=type=cache,target=/build/target \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
cargo build --release --locked --target "$(cat /rust-target)" \
|
||||
&& mkdir -p /out \
|
||||
&& cp target/$(cat /rust-target)/release/deepseek /out/ \
|
||||
&& cp target/$(cat /rust-target)/release/deepseek-tui /out/
|
||||
|
||||
# ── Stage 2: Runtime ──────────────────────────────────────────────────
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libdbus-1-3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user.
|
||||
RUN useradd --create-home --shell /bin/bash deepseek
|
||||
USER deepseek
|
||||
WORKDIR /home/deepseek
|
||||
|
||||
COPY --from=builder --chown=deepseek:deepseek /out/deepseek /usr/local/bin/deepseek
|
||||
COPY --from=builder --chown=deepseek:deepseek /out/deepseek-tui /usr/local/bin/deepseek-tui
|
||||
|
||||
# The dispatcher expects to find its companion binary next to it.
|
||||
# Both are in /usr/local/bin — no further path setup needed.
|
||||
|
||||
ENV DEEPSEEK_API_KEY=""
|
||||
ENV DEEPSEEK_NO_COLOR=""
|
||||
|
||||
ENTRYPOINT ["deepseek"]
|
||||
CMD []
|
||||
@@ -189,6 +189,7 @@ pub enum SidebarFocusValue {
|
||||
Todos,
|
||||
Tasks,
|
||||
Agents,
|
||||
Context,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
@@ -666,6 +667,7 @@ impl SidebarFocusValue {
|
||||
Self::Todos => "todos",
|
||||
Self::Tasks => "tasks",
|
||||
Self::Agents => "agents",
|
||||
Self::Context => "context",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -754,6 +756,7 @@ impl From<&str> for SidebarFocusValue {
|
||||
SidebarFocus::Todos => Self::Todos,
|
||||
SidebarFocus::Tasks => Self::Tasks,
|
||||
SidebarFocus::Agents => Self::Agents,
|
||||
SidebarFocus::Context => Self::Context,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,11 @@ pub struct Settings {
|
||||
pub default_mode: String,
|
||||
/// Sidebar width as percentage of terminal width
|
||||
pub sidebar_width_percent: u16,
|
||||
/// Sidebar focus mode: auto, plan, todos, tasks, agents
|
||||
/// Sidebar focus mode: auto, plan, todos, tasks, agents, context
|
||||
pub sidebar_focus: String,
|
||||
/// Enable the session-context panel (#504). Shows working set, tokens,
|
||||
/// cost, MCP/LSP status, cycle count, and memory info.
|
||||
pub context_panel: bool,
|
||||
/// Maximum number of input history entries to save
|
||||
pub max_input_history: usize,
|
||||
/// Default model to use
|
||||
@@ -71,6 +74,7 @@ impl Default for Settings {
|
||||
default_mode: "agent".to_string(),
|
||||
sidebar_width_percent: 28,
|
||||
sidebar_focus: "auto".to_string(),
|
||||
context_panel: false,
|
||||
max_input_history: 100,
|
||||
default_model: None,
|
||||
}
|
||||
@@ -423,6 +427,7 @@ fn normalize_sidebar_focus(value: &str) -> &str {
|
||||
"todos" => "todos",
|
||||
"tasks" => "tasks",
|
||||
"agents" | "subagents" | "sub-agents" => "agents",
|
||||
"context" | "session" => "context",
|
||||
_ => "auto",
|
||||
}
|
||||
}
|
||||
|
||||
+102
-14
@@ -169,6 +169,7 @@ pub enum SidebarFocus {
|
||||
Todos,
|
||||
Tasks,
|
||||
Agents,
|
||||
Context,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -215,6 +216,7 @@ impl SidebarFocus {
|
||||
"todos" => Self::Todos,
|
||||
"tasks" => Self::Tasks,
|
||||
"agents" | "subagents" | "sub-agents" => Self::Agents,
|
||||
"context" | "session" => Self::Context,
|
||||
_ => Self::Auto,
|
||||
}
|
||||
}
|
||||
@@ -228,6 +230,7 @@ impl SidebarFocus {
|
||||
Self::Todos => "todos",
|
||||
Self::Tasks => "tasks",
|
||||
Self::Agents => "agents",
|
||||
Self::Context => "context",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -597,6 +600,8 @@ pub struct App {
|
||||
pub transcript_spacing: TranscriptSpacing,
|
||||
pub sidebar_width_percent: u16,
|
||||
pub sidebar_focus: SidebarFocus,
|
||||
/// Whether the session-context panel is enabled (#504).
|
||||
pub context_panel: bool,
|
||||
/// File-tree pane state. `None` when hidden; `Some` when visible.
|
||||
pub file_tree: Option<crate::tui::file_tree::FileTreeState>,
|
||||
#[allow(dead_code)]
|
||||
@@ -1118,6 +1123,7 @@ impl App {
|
||||
transcript_spacing,
|
||||
sidebar_width_percent,
|
||||
sidebar_focus,
|
||||
context_panel: settings.context_panel,
|
||||
file_tree: None,
|
||||
compact_threshold,
|
||||
max_input_history,
|
||||
@@ -2834,14 +2840,13 @@ impl App {
|
||||
self.paste_burst.clear_after_explicit_paste();
|
||||
return None;
|
||||
}
|
||||
let mut input = self.input.clone();
|
||||
if char_count(&input) > MAX_SUBMITTED_INPUT_CHARS {
|
||||
input = input.chars().take(MAX_SUBMITTED_INPUT_CHARS).collect();
|
||||
self.status_message = Some(format!(
|
||||
"Input truncated to {} characters for safety",
|
||||
MAX_SUBMITTED_INPUT_CHARS
|
||||
));
|
||||
// When the input exceeds the safety cap, consolidate it into a
|
||||
// workspace paste file and replace it with an @mention so the
|
||||
// model can read the full content at turn time (#553).
|
||||
if char_count(&self.input) > MAX_SUBMITTED_INPUT_CHARS {
|
||||
self.consolidate_large_input();
|
||||
}
|
||||
let input = self.input.clone();
|
||||
if !input.starts_with('/') {
|
||||
self.input_history.push(input.clone());
|
||||
if self.max_input_history == 0 {
|
||||
@@ -2861,6 +2866,61 @@ impl App {
|
||||
Some(input)
|
||||
}
|
||||
|
||||
/// When the composer input exceeds [`MAX_SUBMITTED_INPUT_CHARS`], write
|
||||
/// the full content to a timestamped paste file under
|
||||
/// `.deepseek/pastes/` and replace `self.input` with an `@`-mention
|
||||
/// pointing at it so the model can read the full content via the
|
||||
/// normal file-mention resolution path (#553).
|
||||
fn consolidate_large_input(&mut self) {
|
||||
let full_input = std::mem::take(&mut self.input);
|
||||
self.cursor_position = 0;
|
||||
|
||||
let now = chrono::Local::now();
|
||||
let suffix = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let filename = format!("paste-{}-{}.md", now.format("%Y-%m-%d-%H%M%S"), suffix);
|
||||
let rel_path = format!(".deepseek/pastes/{filename}");
|
||||
|
||||
let pastes_dir = self.workspace.join(".deepseek/pastes");
|
||||
if let Err(e) = std::fs::create_dir_all(&pastes_dir) {
|
||||
// Fallback: keep a truncated version so we don't lose the
|
||||
// user's input entirely when the filesystem is unhappy.
|
||||
self.input = full_input
|
||||
.chars()
|
||||
.take(MAX_SUBMITTED_INPUT_CHARS)
|
||||
.collect();
|
||||
self.cursor_position = char_count(&self.input);
|
||||
self.push_status_toast(
|
||||
format!("Failed to create paste directory: {e}"),
|
||||
StatusToastLevel::Error,
|
||||
Some(8_000),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let file_path = self.workspace.join(&rel_path);
|
||||
if let Err(e) = std::fs::write(&file_path, &full_input) {
|
||||
self.input = full_input
|
||||
.chars()
|
||||
.take(MAX_SUBMITTED_INPUT_CHARS)
|
||||
.collect();
|
||||
self.cursor_position = char_count(&self.input);
|
||||
self.push_status_toast(
|
||||
format!("Failed to write paste file: {e}"),
|
||||
StatusToastLevel::Error,
|
||||
Some(8_000),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.input = format!("@{rel_path}");
|
||||
self.cursor_position = char_count(&self.input);
|
||||
self.push_status_toast(
|
||||
"Large paste consolidated — sent as @mention",
|
||||
StatusToastLevel::Info,
|
||||
Some(5_000),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn queue_message(&mut self, message: QueuedMessage) {
|
||||
self.queued_messages.push_back(message);
|
||||
}
|
||||
@@ -3231,18 +3291,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_input_truncates_oversized_payloads() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.input = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128);
|
||||
fn submit_input_consolidates_oversized_input_into_paste_file() {
|
||||
let tmp = tempfile::TempDir::new().expect("tempdir");
|
||||
let mut opts = test_options(false);
|
||||
opts.workspace = tmp.path().to_path_buf();
|
||||
let mut app = App::new(opts, &Config::default());
|
||||
let full_content = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128);
|
||||
app.input = full_content.clone();
|
||||
app.cursor_position = app.input.chars().count();
|
||||
|
||||
let submitted = app.submit_input().expect("expected submitted input");
|
||||
assert_eq!(submitted.chars().count(), MAX_SUBMITTED_INPUT_CHARS);
|
||||
|
||||
// The submitted text should be the @mention, not the truncated
|
||||
// original (#553).
|
||||
assert!(
|
||||
app.status_message
|
||||
.as_ref()
|
||||
.is_some_and(|msg| msg.contains("Input truncated"))
|
||||
submitted.starts_with("@.deepseek/pastes/paste-"),
|
||||
"expected @mention, got: {submitted}"
|
||||
);
|
||||
assert!(
|
||||
submitted.ends_with(".md"),
|
||||
"expected .md extension, got: {submitted}"
|
||||
);
|
||||
|
||||
// The paste file must exist on disk with the full original content.
|
||||
let rel_path = &submitted[1..]; // strip leading '@'
|
||||
let abs_path = tmp.path().join(rel_path);
|
||||
assert!(abs_path.is_file(), "paste file must exist at {abs_path:?}");
|
||||
let written = std::fs::read_to_string(&abs_path).expect("read paste file");
|
||||
assert_eq!(written, full_content);
|
||||
|
||||
// A status toast should have been pushed.
|
||||
assert!(
|
||||
app.status_toasts
|
||||
.iter()
|
||||
.any(|toast| toast.text.contains("consolidated")),
|
||||
"expected consolidation toast, got: {:?}",
|
||||
app.status_toasts.iter().map(|t| &t.text).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// The composer must be clear after submit.
|
||||
assert!(app.input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) {
|
||||
SidebarFocus::Todos => render_sidebar_todos(f, area, app),
|
||||
SidebarFocus::Tasks => render_sidebar_tasks(f, area, app),
|
||||
SidebarFocus::Agents => render_sidebar_subagents(f, area, app),
|
||||
SidebarFocus::Context => render_context_panel(f, area, app),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) {
|
||||
Todos,
|
||||
Tasks,
|
||||
Agents,
|
||||
Context,
|
||||
}
|
||||
|
||||
let todos_empty = app
|
||||
@@ -70,7 +72,7 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) {
|
||||
&& active_fanout_counts(app).is_none()
|
||||
&& !foreground_rlm_running(app);
|
||||
|
||||
let mut visible: Vec<Panel> = Vec::with_capacity(4);
|
||||
let mut visible: Vec<Panel> = Vec::with_capacity(5);
|
||||
visible.push(Panel::Plan);
|
||||
if !todos_empty {
|
||||
visible.push(Panel::Todos);
|
||||
@@ -81,6 +83,9 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) {
|
||||
if !agents_empty {
|
||||
visible.push(Panel::Agents);
|
||||
}
|
||||
if app.context_panel {
|
||||
visible.push(Panel::Context);
|
||||
}
|
||||
|
||||
let constraints: Vec<Constraint> = match visible.len() {
|
||||
1 => vec![Constraint::Min(0)],
|
||||
@@ -90,10 +95,17 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) {
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Min(0),
|
||||
],
|
||||
4 => vec![
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Min(6),
|
||||
],
|
||||
_ => vec![
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(25),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Min(6),
|
||||
],
|
||||
};
|
||||
@@ -109,6 +121,7 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) {
|
||||
Panel::Todos => render_sidebar_todos(f, *rect, app),
|
||||
Panel::Tasks => render_sidebar_tasks(f, *rect, app),
|
||||
Panel::Agents => render_sidebar_subagents(f, *rect, app),
|
||||
Panel::Context => render_context_panel(f, *rect, app),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -599,6 +612,123 @@ pub fn subagent_navigator_lines(
|
||||
lines
|
||||
}
|
||||
|
||||
/// Session-context panel (#504) — consolidated session state overview.
|
||||
///
|
||||
/// Surfaces at-a-glance: working set, token usage / context %, running
|
||||
/// cost, MCP server count, LSP toggle state, cycle count, and memory
|
||||
/// file size + mtime. Each section is a compact one-liner so the panel
|
||||
/// reads as a dashboard rather than a scrolling list.
|
||||
fn render_context_panel(f: &mut Frame, area: Rect, app: &App) {
|
||||
if area.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let content_width = area.width.saturating_sub(4) as usize;
|
||||
let mut lines: Vec<Line<'static>> = Vec::with_capacity(usize::from(area.height).max(4));
|
||||
|
||||
// ── Working set ──────────────────────────────────────────────
|
||||
let ws_name = app
|
||||
.workspace
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("(root)")
|
||||
.to_string();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
truncate_line_to_width(&ws_name, content_width.max(1)),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(
|
||||
" {}",
|
||||
app.workspace_context.as_deref().unwrap_or("")
|
||||
),
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
),
|
||||
]));
|
||||
|
||||
// ── Token usage ──────────────────────────────────────────────
|
||||
let total_tokens = app.session.total_conversation_tokens;
|
||||
let window = crate::models::context_window_for_model(&app.model).unwrap_or(1_048_576);
|
||||
let pct = if window > 0 {
|
||||
((total_tokens as f64 / window as f64) * 100.0).clamp(0.0, 100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let bar_width = content_width.min(20);
|
||||
let filled = ((pct / 100.0) * bar_width as f64) as usize;
|
||||
let bar = format!(
|
||||
"[{}{}] {:.0}%",
|
||||
"█".repeat(filled),
|
||||
"░".repeat(bar_width.saturating_sub(filled)),
|
||||
pct
|
||||
);
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(
|
||||
"context: {}/{} tokens {}",
|
||||
total_tokens,
|
||||
window,
|
||||
truncate_line_to_width(&bar, content_width.saturating_sub(32).max(8))
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
|
||||
// ── Session cost ─────────────────────────────────────────────
|
||||
let total_cost = app.session.session_cost + app.session.subagent_cost;
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("cost: ${total_cost:.4} (session ${:.4} + agents ${:.4})",
|
||||
app.session.session_cost, app.session.subagent_cost),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
|
||||
// ── MCP servers ──────────────────────────────────────────────
|
||||
if app.mcp_configured_count > 0 {
|
||||
let restart_hint = if app.mcp_restart_required { " (restart needed)" } else { "" };
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("mcp: {} server(s){}", app.mcp_configured_count, restart_hint),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
// ── LSP ──────────────────────────────────────────────────────
|
||||
let lsp_label = if app.lsp_enabled { "on" } else { "off" };
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("lsp: {}", lsp_label),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
|
||||
// ── Cycles ───────────────────────────────────────────────────
|
||||
if app.cycle_count > 0 {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("cycles: {} crossed, {} briefing(s)",
|
||||
app.cycle_count, app.cycle_briefings.len()),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
// ── Memory ───────────────────────────────────────────────────
|
||||
if app.use_memory {
|
||||
let size_hint = std::fs::metadata(&app.memory_path)
|
||||
.map(|m| m.len())
|
||||
.map(|bytes| {
|
||||
if bytes >= 1024 * 1024 {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
} else if bytes >= 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|_| "—".to_string());
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("memory: {} ({})", app.memory_path.display(), size_hint),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
}
|
||||
|
||||
render_sidebar_section(f, area, "Session", lines);
|
||||
}
|
||||
|
||||
fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec<Line<'static>>) {
|
||||
if area.width < 4 || area.height < 3 {
|
||||
// Clear stale cells before bailing out (#400).
|
||||
|
||||
@@ -1138,6 +1138,7 @@ async fn run_event_loop(
|
||||
EngineEvent::AgentList { agents } => {
|
||||
let mut sorted = agents.clone();
|
||||
sort_subagents_in_place(&mut sorted);
|
||||
sorted.retain(|a| !a.from_prior_session);
|
||||
app.subagent_cache = sorted.clone();
|
||||
reconcile_subagent_activity_state(app);
|
||||
if app.view_stack.update_subagents(&sorted) {
|
||||
@@ -1990,6 +1991,11 @@ async fn run_event_loop(
|
||||
app.status_message = Some("Sidebar focus: agents".to_string());
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char('%') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
app.set_sidebar_focus(SidebarFocus::Context);
|
||||
app.status_message = Some("Sidebar focus: context".to_string());
|
||||
continue;
|
||||
}
|
||||
KeyCode::Char(')') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
app.set_sidebar_focus(SidebarFocus::Auto);
|
||||
app.status_message = Some("Sidebar focus: auto".to_string());
|
||||
@@ -2997,7 +3003,7 @@ fn queue_current_draft_for_next_turn(app: &mut App) -> bool {
|
||||
};
|
||||
app.queue_message(queued);
|
||||
app.status_message = Some(format!(
|
||||
"Queued follow-up for next turn ({} queued) - /queue to review",
|
||||
"{} queued — ↑ to edit, /queue list",
|
||||
app.queued_message_count()
|
||||
));
|
||||
true
|
||||
@@ -4198,7 +4204,7 @@ async fn queue_follow_up(app: &mut App, message: QueuedMessage) -> Result<()> {
|
||||
let display = message.display.clone();
|
||||
app.queue_message(message);
|
||||
app.status_message = Some(format!(
|
||||
"Queued follow-up: {} ({} queued) - /queue to review",
|
||||
"Queued: {} ({} total) — ↑ to edit",
|
||||
display,
|
||||
app.queued_message_count()
|
||||
));
|
||||
@@ -4217,11 +4223,11 @@ async fn submit_or_steer_message(
|
||||
app.queue_message(message);
|
||||
if app.offline_mode {
|
||||
app.status_message = Some(format!(
|
||||
"Offline mode: queued {count} message(s) - /queue to review"
|
||||
"Offline: {count} queued — ↑ to edit, /queue list"
|
||||
));
|
||||
} else {
|
||||
app.status_message = Some(format!(
|
||||
"Queued for next turn: {count} message(s) - Ctrl+Enter to steer, /queue to review"
|
||||
"{count} queued — ↑ to edit, /queue list"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -4231,7 +4237,7 @@ async fn submit_or_steer_message(
|
||||
if let Err(err) = steer_user_message(app, engine_handle, message.clone()).await {
|
||||
app.queue_message(message);
|
||||
app.status_message = Some(format!(
|
||||
"Steer failed ({err}); queued {} message(s) - /queue to view/edit",
|
||||
"Steer failed ({err}); {} queued — ↑ to edit, /queue list",
|
||||
app.queued_message_count()
|
||||
));
|
||||
} else {
|
||||
@@ -6441,10 +6447,18 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEvent> {
|
||||
MouseEventKind::ScrollUp => {
|
||||
let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Up);
|
||||
app.viewport.pending_scroll_delta += update.delta_lines;
|
||||
if update.delta_lines != 0 {
|
||||
app.user_scrolled_during_stream = true;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Down);
|
||||
app.viewport.pending_scroll_delta += update.delta_lines;
|
||||
if update.delta_lines != 0 {
|
||||
app.user_scrolled_during_stream = true;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if let Some(point) = selection_point_from_mouse(app, mouse) {
|
||||
|
||||
@@ -1468,7 +1468,7 @@ async fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {
|
||||
);
|
||||
assert_eq!(
|
||||
app.status_message.as_deref(),
|
||||
Some("Offline mode: queued 1 message(s) - /queue to review")
|
||||
Some("Offline: 1 queued — ↑ to edit, /queue list")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2839,7 +2839,7 @@ fn tab_queues_running_turn_draft_for_next_turn() {
|
||||
assert!(
|
||||
app.status_message
|
||||
.as_deref()
|
||||
.is_some_and(|msg| msg.contains("Queued follow-up"))
|
||||
.is_some_and(|msg| msg.contains("queued — ↑"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Docker
|
||||
|
||||
DeepSeek TUI ships an official multi-arch Docker image (amd64 + arm64) on
|
||||
[GitHub Container Registry](https://github.com/Hmbown/DeepSeek-TUI/pkgs/container/deepseek-tui).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
-e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
|
||||
-v ~/.deepseek:/home/deepseek/.deepseek \
|
||||
ghcr.io/hmbown/deepseek-tui:latest
|
||||
```
|
||||
|
||||
Images are published to GitHub Container Registry (GHCR) only. Docker Hub
|
||||
publishing is not currently configured — add a `docker/login-action` step
|
||||
with Hub credentials to the release workflow if needed.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|-----------------------|----------|--------------------------------------------------|
|
||||
| `DEEPSEEK_API_KEY` | yes | DeepSeek API key |
|
||||
| `DEEPSEEK_BASE_URL` | no | Custom API base URL (e.g. `https://api.deepseek.com`) |
|
||||
| `DEEPSEEK_NO_COLOR` | no | Set to `1` to disable terminal colour output |
|
||||
|
||||
## Volumes
|
||||
|
||||
Mount `~/.deepseek` to persist sessions, config, skills, memory, and the offline queue
|
||||
across container restarts:
|
||||
|
||||
```bash
|
||||
-v ~/.deepseek:/home/deepseek/.deepseek
|
||||
```
|
||||
|
||||
Without this mount the container starts fresh each time.
|
||||
|
||||
## Non-interactive / pipeline usage
|
||||
|
||||
When stdin is not a TTY, `deepseek` drops to the dispatcher's one-shot mode
|
||||
(`deepseek -c "…"`). Pipe a prompt on stdin:
|
||||
|
||||
```bash
|
||||
echo "Explain the Cargo.toml in structured English." | \
|
||||
docker run --rm -i -e DEEPSEEK_API_KEY ghcr.io/hmbown/deepseek-tui:latest
|
||||
```
|
||||
|
||||
## Building locally
|
||||
|
||||
```bash
|
||||
# Single platform (your host architecture)
|
||||
docker build -t deepseek-tui .
|
||||
|
||||
# Multi-platform (requires a builder with emulation)
|
||||
docker buildx create --use
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui .
|
||||
```
|
||||
|
||||
## Devcontainer
|
||||
|
||||
The repository includes a [`.devcontainer/devcontainer.json`](../.devcontainer/devcontainer.json)
|
||||
configuration for VS Code / GitHub Codespaces. It pre-installs the Rust toolchain,
|
||||
rust-analyzer, and the `deepseek` binary. Open the repo in a devcontainer to get a
|
||||
ready-to-use development environment.
|
||||
|
||||
## Tags
|
||||
|
||||
| Tag | Meaning |
|
||||
|------------|--------------------------|
|
||||
| `latest` | Latest stable release |
|
||||
| `v0` | Latest v0.x release |
|
||||
| `0.8.9` | Specific release version |
|
||||
|
||||
Docker images are built and pushed automatically when a release tag is pushed
|
||||
(see [release.yml](../.github/workflows/release.yml)).
|
||||
+193
-216
@@ -1,172 +1,196 @@
|
||||
# Runtime API (HTTP/SSE)
|
||||
# Runtime API & Integration Contract
|
||||
|
||||
DeepSeek TUI can expose a local runtime API for external clients:
|
||||
DeepSeek TUI exposes a local runtime API through `deepseek serve --http` and
|
||||
machine-readable health via `deepseek doctor --json`. This document is the
|
||||
stable integration contract for native macOS workbench applications (and other
|
||||
local supervisors) that embed the DeepSeek engine without screen-scraping
|
||||
terminal output.
|
||||
|
||||
```bash
|
||||
deepseek serve --http --host 127.0.0.1 --port 7878 --workers 2
|
||||
## Architecture
|
||||
|
||||
```
|
||||
macOS workbench (or any local supervisor)
|
||||
│
|
||||
├─ deepseek doctor --json → machine-readable health & capability
|
||||
├─ deepseek serve --http → HTTP/SSE runtime API
|
||||
├─ deepseek serve --mcp → MCP stdio server
|
||||
└─ deepseek [args] → interactive TUI session
|
||||
```
|
||||
|
||||
Defaults:
|
||||
- bind: `127.0.0.1:7878`
|
||||
- workers: `2` (clamped to `1..8`)
|
||||
The engine runs as a local-only process. All APIs bind to `localhost` by
|
||||
default. No hosted relay, no provider-token custody, no secret leakage.
|
||||
|
||||
Implementation note:
|
||||
- The current production runtime lives in `crates/tui` (`runtime_api.rs`, `runtime_threads.rs`, `task_manager.rs`).
|
||||
- Workspace crate extraction is in progress, but external behavior should be read from the `crates/tui` implementation today.
|
||||
## Capability endpoint: `deepseek doctor --json`
|
||||
|
||||
## Security Model (Local-First)
|
||||
Returns a JSON object describing the current installation's readiness state.
|
||||
Suitable for health-check polling from a macOS workbench.
|
||||
|
||||
- The server is designed for trusted local use.
|
||||
- There is no built-in auth, user isolation, or TLS termination.
|
||||
- Do not expose this API directly to untrusted networks.
|
||||
- If remote access is required, place it behind your own authenticated reverse proxy/VPN.
|
||||
```bash
|
||||
deepseek doctor --json
|
||||
```
|
||||
|
||||
## Runtime Data Model
|
||||
### Response schema (key fields)
|
||||
|
||||
The runtime uses a durable Thread/Turn/Item lifecycle.
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `version` | string | Installed version (e.g. `"0.8.9"`) |
|
||||
| `config_path` | string | Resolved config file path |
|
||||
| `config_present` | bool | Whether the config file exists |
|
||||
| `workspace` | string | Default workspace directory |
|
||||
| `api_key.source` | string | `env`, `config`, or `missing` |
|
||||
| `base_url` | string | API base URL |
|
||||
| `default_text_model` | string | Default model |
|
||||
| `memory.enabled` | bool | Whether the memory feature is on |
|
||||
| `memory.path` | string | Path to memory file |
|
||||
| `memory.file_present` | bool | Whether memory file exists |
|
||||
| `mcp.config_path` | string | MCP config file path |
|
||||
| `mcp.present` | bool | Whether MCP config exists |
|
||||
| `mcp.servers` | array | Per-server health: `{name, enabled, status, detail}` |
|
||||
| `skills.selected` | string | Resolved skills directory |
|
||||
| `skills.global.path` / `.present` / `.count` | — | Global skills dir |
|
||||
| `skills.agents.path` / `.present` / `.count` | — | `.agents/skills/` dir |
|
||||
| `skills.local.path` / `.present` / `.count` | — | `skills/` dir |
|
||||
| `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` dir |
|
||||
| `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` dir |
|
||||
| `tools.path` / `.present` / `.count` | — | Global tools directory |
|
||||
| `plugins.path` / `.present` / `.count` | — | Global plugins directory |
|
||||
| `sandbox.available` | bool | Whether sandbox is supported on this OS |
|
||||
| `sandbox.kind` | string or null | Sandbox kind (e.g. `"macos_seatbelt"`) |
|
||||
| `storage.spillover.path` / `.present` / `.count` | — | Tool output spillover dir |
|
||||
| `storage.stash.path` / `.present` / `.count` | — | Composer stash |
|
||||
|
||||
- `ThreadRecord`
|
||||
- `id`, `created_at`, `updated_at`
|
||||
- `model`, `workspace`, `mode`
|
||||
- `task_id` (optional durable task link)
|
||||
- `coherence_state`: `healthy|getting_crowded|refreshing_context|verifying_recent_work|resetting_plan`
|
||||
- `system_prompt` (optional text)
|
||||
- `latest_turn_id`, `latest_response_bookmark`, `archived`
|
||||
- `TurnRecord`
|
||||
- `id`, `thread_id`
|
||||
- `status`: `queued|in_progress|completed|failed|interrupted|canceled`
|
||||
- timestamps, duration, usage, error summary
|
||||
- `TurnItemRecord`
|
||||
- `id`, `turn_id`
|
||||
- `kind`: `user_message|agent_message|tool_call|file_change|command_execution|context_compaction|status|error`
|
||||
- lifecycle `status`: `queued|in_progress|completed|failed|interrupted|canceled`
|
||||
- `metadata` (optional tool result metadata; used for task checklist/gate/artifact updates)
|
||||
### Example
|
||||
|
||||
The event log is append-only with global monotonic `seq` for replay/resume.
|
||||
```json
|
||||
{
|
||||
"version": "0.8.9",
|
||||
"config_path": "/Users/you/.deepseek/config.toml",
|
||||
"config_present": true,
|
||||
"workspace": "/Users/you/projects/deepseek-tui",
|
||||
"api_key": {
|
||||
"source": "env"
|
||||
},
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"default_text_model": "deepseek-v4-pro",
|
||||
"memory": {
|
||||
"enabled": false,
|
||||
"path": "/Users/you/.deepseek/memory.md",
|
||||
"file_present": true
|
||||
},
|
||||
"mcp": {
|
||||
"config_path": "/Users/you/.deepseek/mcp.json",
|
||||
"present": true,
|
||||
"servers": [
|
||||
{"name": "filesystem", "enabled": true, "status": "ok", "detail": "ready"}
|
||||
]
|
||||
},
|
||||
"sandbox": {
|
||||
"available": true,
|
||||
"kind": "macos_seatbelt"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Session resume note:
|
||||
- Saved session `system_prompt` currently round-trips as plain text. Structured `SystemPrompt::Blocks` metadata is not preserved when resuming into runtime threads.
|
||||
## HTTP/SSE runtime API: `deepseek serve --http`
|
||||
|
||||
Restart note:
|
||||
- If the process restarts while a turn or item is `queued` or `in_progress`, the recovered record is marked `interrupted` with an `"Interrupted by process restart"` error instead of remaining stuck in a live state.
|
||||
```bash
|
||||
deepseek serve --http [--host 127.0.0.1] [--port 7878] [--workers 2]
|
||||
```
|
||||
|
||||
Approval note:
|
||||
- `auto_approve` applies to the runtime approval bridge and the engine tool context. When enabled for a thread/turn/task, approval-required tools are auto-approved in the non-interactive runtime path, shell safety checks run in auto-approved mode, and spawned subagents inherit that effective setting for their own tool context.
|
||||
- If omitted when creating a thread or starting `/v1/stream`, `auto_approve` defaults to `false`.
|
||||
Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8).
|
||||
|
||||
## Endpoints
|
||||
The server binds to `localhost` by default. Configuration is via CLI flags —
|
||||
there is no `[app_server]` config section.
|
||||
|
||||
### Health and Session
|
||||
### Endpoints
|
||||
|
||||
**Health**
|
||||
- `GET /health`
|
||||
|
||||
**Sessions** (legacy session manager)
|
||||
- `GET /v1/sessions?limit=50&search=<substring>`
|
||||
- `GET /v1/sessions/{id}`
|
||||
- `DELETE /v1/sessions/{id}`
|
||||
- `POST /v1/sessions/{id}/resume-thread`
|
||||
- `GET /v1/workspace/status`
|
||||
- `GET /v1/skills`
|
||||
- `GET /v1/apps/mcp/servers`
|
||||
- `GET /v1/apps/mcp/tools?server=<optional>`
|
||||
|
||||
Resume session request body (all fields optional):
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "deepseek-v4-pro",
|
||||
"mode": "agent"
|
||||
}
|
||||
```
|
||||
|
||||
Resume session response:
|
||||
|
||||
```json
|
||||
{
|
||||
"thread_id": "thr_1234abcd",
|
||||
"session_id": "sess_5678efgh",
|
||||
"message_count": 24,
|
||||
"summary": "Resumed session 'Refactor plan' (24 messages) into thread thr_1234abcd"
|
||||
}
|
||||
```
|
||||
|
||||
### Compatibility Stream (Single Turn)
|
||||
|
||||
- `POST /v1/stream`
|
||||
|
||||
Backwards-compatible one-shot SSE wrapper. Internally creates an archived runtime thread+turn.
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "Summarize recent commits",
|
||||
"model": "deepseek-v4-pro",
|
||||
"mode": "agent",
|
||||
"workspace": ".",
|
||||
"allow_shell": false,
|
||||
"trust_mode": false,
|
||||
"auto_approve": true
|
||||
}
|
||||
```
|
||||
|
||||
Typical SSE events:
|
||||
- `turn.started`
|
||||
- `message.delta`
|
||||
- `tool.started`
|
||||
- `tool.progress`
|
||||
- `tool.completed`
|
||||
- `approval.required`
|
||||
- `sandbox.denied`
|
||||
- `status`
|
||||
- `error`
|
||||
- `turn.completed`
|
||||
- `done`
|
||||
|
||||
### Thread Lifecycle
|
||||
|
||||
- `POST /v1/threads`
|
||||
**Threads** (durable runtime data model)
|
||||
- `GET /v1/threads?limit=50&include_archived=false`
|
||||
- `GET /v1/threads/summary?limit=50&search=<optional>&include_archived=false`
|
||||
- `POST /v1/threads`
|
||||
- `GET /v1/threads/{id}`
|
||||
- `PATCH /v1/threads/{id}` (currently supports `{ "archived": true|false }`)
|
||||
- `POST /v1/threads/{id}/resume`
|
||||
- `POST /v1/threads/{id}/fork`
|
||||
|
||||
Create thread request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "deepseek-v4-pro",
|
||||
"workspace": ".",
|
||||
"mode": "agent",
|
||||
"allow_shell": false,
|
||||
"trust_mode": false,
|
||||
"auto_approve": true,
|
||||
"archived": false,
|
||||
"task_id": "task_1234abcd"
|
||||
}
|
||||
```
|
||||
|
||||
### Turn Lifecycle
|
||||
|
||||
**Turns** (within a thread)
|
||||
- `POST /v1/threads/{id}/turns`
|
||||
- `POST /v1/threads/{id}/turns/{turn_id}/steer`
|
||||
- `POST /v1/threads/{id}/turns/{turn_id}/interrupt`
|
||||
- `POST /v1/threads/{id}/compact`
|
||||
|
||||
Notes:
|
||||
- Only one active turn is allowed per thread (`409 Conflict` on overlap).
|
||||
- `interrupt` returns quickly and marks `turn.interrupt_requested`.
|
||||
- Terminal turn status becomes `interrupted` only after cleanup completes.
|
||||
- Manual compaction is exposed as a turn with `context_compaction` item lifecycle events.
|
||||
- Archiving/unarchiving threads updates persisted thread state and emits `thread.updated`.
|
||||
|
||||
### Replayable Events
|
||||
- `POST /v1/threads/{id}/compact` (manual compaction)
|
||||
|
||||
**Events** (SSE replay + live stream)
|
||||
- `GET /v1/threads/{id}/events?since_seq=<u64>`
|
||||
|
||||
Returns SSE replay backlog, then live events for that thread.
|
||||
**Compatibility stream** (one-shot, backwards-compatible)
|
||||
- `POST /v1/stream`
|
||||
|
||||
SSE payload shape:
|
||||
**Tasks** (durable background work)
|
||||
- `GET /v1/tasks`
|
||||
- `POST /v1/tasks`
|
||||
- `GET /v1/tasks/{id}`
|
||||
- `POST /v1/tasks/{id}/cancel`
|
||||
|
||||
**Automations** (scheduled recurring work)
|
||||
- `GET /v1/automations`
|
||||
- `POST /v1/automations`
|
||||
- `GET /v1/automations/{id}`
|
||||
- `PATCH /v1/automations/{id}`
|
||||
- `DELETE /v1/automations/{id}`
|
||||
- `POST /v1/automations/{id}/run`
|
||||
- `POST /v1/automations/{id}/pause`
|
||||
- `POST /v1/automations/{id}/resume`
|
||||
- `GET /v1/automations/{id}/runs?limit=20`
|
||||
|
||||
**Introspection**
|
||||
- `GET /v1/workspace/status`
|
||||
- `GET /v1/skills`
|
||||
- `GET /v1/apps/mcp/servers`
|
||||
- `GET /v1/apps/mcp/tools?server=<optional>`
|
||||
|
||||
## Runtime data model
|
||||
|
||||
The runtime uses a durable Thread/Turn/Item lifecycle.
|
||||
|
||||
- **ThreadRecord** — `id`, `created_at`, `updated_at`, `model`, `workspace`,
|
||||
`mode`, `task_id`, `coherence_state`, `system_prompt`, `latest_turn_id`,
|
||||
`latest_response_bookmark`, `archived`
|
||||
- **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed|
|
||||
failed|interrupted|canceled`), timestamps, duration, usage, error summary
|
||||
- **TurnItemRecord** — `id`, `turn_id`, `kind` (`user_message|agent_message|
|
||||
tool_call|file_change|command_execution|context_compaction|status|error`),
|
||||
lifecycle `status`, `metadata`
|
||||
|
||||
Events are append-only with a global monotonic `seq` for replay/resume.
|
||||
|
||||
### Restart semantics
|
||||
|
||||
- If the process restarts while a turn or item is `queued` or `in_progress`,
|
||||
the recovered record is marked `interrupted` with an `"Interrupted by
|
||||
process restart"` error.
|
||||
- Task execution performs its own recovery on top of the same persisted
|
||||
thread/turn store.
|
||||
|
||||
### Approval model
|
||||
|
||||
- The `auto_approve` flag applies to the runtime approval bridge and engine
|
||||
tool context. When enabled for a thread/turn/task, approval-required tools
|
||||
are auto-approved in the non-interactive runtime path, shell safety checks
|
||||
run in auto-approved mode, and spawned sub-agents inherit that setting.
|
||||
- When omitted, `auto_approve` defaults to `false`.
|
||||
|
||||
### SSE event stream
|
||||
|
||||
The SSE event payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -183,95 +207,48 @@ SSE payload shape:
|
||||
}
|
||||
```
|
||||
|
||||
Common event names:
|
||||
- `thread.started`
|
||||
- `thread.forked`
|
||||
- `turn.started`
|
||||
- `turn.lifecycle`
|
||||
- `turn.steered`
|
||||
- `turn.interrupt_requested`
|
||||
- `turn.completed`
|
||||
- `item.started`
|
||||
- `item.delta`
|
||||
- `item.completed`
|
||||
- `item.failed`
|
||||
- `item.interrupted`
|
||||
- `approval.required`
|
||||
- `sandbox.denied`
|
||||
- `coherence.state`
|
||||
Common event names: `thread.started`, `thread.forked`, `turn.started`,
|
||||
`turn.lifecycle`, `turn.steered`, `turn.interrupt_requested`,
|
||||
`turn.completed`, `item.started`, `item.delta`, `item.completed`,
|
||||
`item.failed`, `item.interrupted`, `approval.required`, `sandbox.denied`,
|
||||
`coherence.state`.
|
||||
|
||||
Compaction visibility:
|
||||
- auto compaction emits `item.started`/`item.completed` with item kind `context_compaction` and `auto=true`
|
||||
- manual compaction emits the same with `auto=false`
|
||||
## Security boundary
|
||||
|
||||
Coherence visibility:
|
||||
- `coherence.state` is a machine-readable session-health signal derived from
|
||||
existing capacity and compaction events. The payload includes `state`,
|
||||
`label`, `description`, `reason`, and the updated `thread`.
|
||||
- Normal clients should show the `label` or `description`, not internal
|
||||
capacity scores or formulas.
|
||||
- **Localhost only**. The server binds to `127.0.0.1` by default. Set
|
||||
`--host 0.0.0.0` only when you have a reverse-proxy / VPN that
|
||||
authenticates — there is no built-in auth, user isolation, or TLS.
|
||||
- **No provider-token custody**. The server never returns the API key. The
|
||||
`api_key.source` capability field reports `env`, `config`, or `missing` —
|
||||
never the key itself.
|
||||
- **No hosted relay**. The app-server is a local process under the user's
|
||||
control. There is no cloud component.
|
||||
- **Capability responses** never leak secrets, file contents, or session
|
||||
message bodies. They report *metadata*: presence, counts, status flags.
|
||||
|
||||
### Background Tasks
|
||||
## Session lifecycle (native UI supervision)
|
||||
|
||||
- `GET /v1/tasks`
|
||||
- `POST /v1/tasks`
|
||||
- `GET /v1/tasks/{id}`
|
||||
- `POST /v1/tasks/{id}/cancel`
|
||||
| Operation | Endpoint |
|
||||
|---|---|
|
||||
| List sessions | `GET /v1/sessions` |
|
||||
| Get session | `GET /v1/sessions/{id}` |
|
||||
| Delete session | `DELETE /v1/sessions/{id}` |
|
||||
| Resume into thread | `POST /v1/sessions/{id}/resume-thread` |
|
||||
| Create thread | `POST /v1/threads` |
|
||||
| List threads | `GET /v1/threads` |
|
||||
| Attach to events | `GET /v1/threads/{id}/events?since_seq=0` |
|
||||
| Send message | `POST /v1/threads/{id}/turns` |
|
||||
| Steer | `POST /v1/threads/{id}/turns/{turn_id}/steer` |
|
||||
| Interrupt | `POST /v1/threads/{id}/turns/{turn_id}/interrupt` |
|
||||
| Compact | `POST /v1/threads/{id}/compact` |
|
||||
|
||||
Tasks execute through the same runtime thread/turn pipeline and include:
|
||||
- linked `thread_id` / `turn_id`
|
||||
- runtime event count
|
||||
- timeline + tool summaries + artifact references
|
||||
- subordinate checklist state from `checklist_*` / legacy `todo_*` tools
|
||||
- structured verification gates from `task_gate_run` / completed `task_shell_wait`
|
||||
- PR attempt metadata and patch artifacts
|
||||
- guarded GitHub write events
|
||||
## Compatibility tests
|
||||
|
||||
Durable tasks are the model-visible work object. Checklist/todo state is progress
|
||||
inside the active task/thread, not a parallel task system.
|
||||
Contract snapshots live in `crates/protocol/tests/`. Run:
|
||||
|
||||
Task-aware model-visible tools:
|
||||
- `task_create`, `task_list`, `task_read`, `task_cancel`
|
||||
- `task_gate_run`
|
||||
- `task_shell_start`, `task_shell_wait`
|
||||
- `pr_attempt_record`, `pr_attempt_list`, `pr_attempt_read`, `pr_attempt_preflight`
|
||||
- `github_issue_context`, `github_pr_context`, `github_comment`, `github_close_issue`
|
||||
```bash
|
||||
cargo test -p deepseek-protocol --test parity_protocol --locked
|
||||
```
|
||||
|
||||
### Automations
|
||||
|
||||
- `GET /v1/automations`
|
||||
- `POST /v1/automations`
|
||||
- `GET /v1/automations/{id}`
|
||||
- `PATCH /v1/automations/{id}`
|
||||
- `DELETE /v1/automations/{id}`
|
||||
- `POST /v1/automations/{id}/run`
|
||||
- `POST /v1/automations/{id}/pause`
|
||||
- `POST /v1/automations/{id}/resume`
|
||||
- `GET /v1/automations/{id}/runs?limit=20`
|
||||
|
||||
RRULE support is intentionally constrained to:
|
||||
- hourly: `FREQ=HOURLY;INTERVAL=<hours>[;BYDAY=MO,TU,...]`
|
||||
- weekly: `FREQ=WEEKLY;BYDAY=...;BYHOUR=<0-23>;BYMINUTE=<0-59>`
|
||||
|
||||
Automations are persisted under `~/.deepseek/automations` (override with `DEEPSEEK_AUTOMATIONS_DIR`).
|
||||
Each run is executed as a normal background task and links to task/thread/turn ids.
|
||||
|
||||
The same automation manager is exposed to the model through `automation_*`
|
||||
tools. Create/update/delete/run operations require approval; `automation_run`
|
||||
and scheduled runs enqueue ordinary durable tasks rather than using a separate
|
||||
execution path.
|
||||
|
||||
## Persistence
|
||||
|
||||
Runtime store (default under task data root):
|
||||
- `runtime/threads/*.json`
|
||||
- `runtime/turns/*.json`
|
||||
- `runtime/items/*.json`
|
||||
- `runtime/events/{thread_id}.jsonl`
|
||||
- `runtime/state.json` (monotonic sequence)
|
||||
|
||||
Task store:
|
||||
- default `~/.deepseek/tasks` (override with `DEEPSEEK_TASKS_DIR`)
|
||||
|
||||
Both runtime and task state are restart-aware.
|
||||
Queued or in-progress runtime turns reload as `interrupted`; task execution performs its own recovery on top of the same persisted thread/turn store.
|
||||
This validates that the app-server's event schema hasn't drifted from the
|
||||
documented contract. CI runs this on every push to `main` and on release tags.
|
||||
|
||||
Reference in New Issue
Block a user