diff --git a/CHANGELOG.md b/CHANGELOG.md
index f031bb39..3e86895e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.1.4] - 2026-06-06
+
+### Fixed
+
+- ๐ **Chat streaming no longer drops final content.** Fixed a race where the `done` socket event arrived before the DB commit, causing a transient blank message. The streamed content now stays visible until the reload confirms it.
+- ๐ **Unflushed text no longer lost at end of chat responses.** The final text buffer is now properly flushed and emitted before marking a message as done, across all exit paths (normal completion, cancellation, and errors).
+- ๐ **Intermediate chat state persisted during tool loops.** Content and output items are now saved to the database between tool call iterations, so progress survives crashes or disconnects.
+- ๐งน **In-memory task state cleaned up on completion.** The `_task_state` dict entry is now removed when a chat task finishes (done, cancelled, errored, or max iterations), preventing unbounded memory growth.
+- ๐ฑ **Sidebar stays closed on mobile.** The sidebar default breakpoint was raised to 1024px and an auto-close listener now collapses it whenever the viewport shrinks below 768px.
+- ๐ **Stale chat loads discarded.** Rapid chat switches no longer apply data from a slow earlier load, fixing a race condition that could show the wrong conversation.
+
+### Changed
+
+- โก **All blocking filesystem I/O offloaded to threads.** File reads, writes, directory walks, search, archive creation, uploads, renames, and deletions in the workspace and tool routers now run via `asyncio.to_thread()`, preventing event-loop stalls under heavy file operations.
+- โก **Port scanner made fully async.** Platform-specific port scanning (Darwin `lsof`, Linux `/proc`, Windows `netstat`) and PID-to-process lookups now use `asyncio.create_subprocess_exec` or `asyncio.to_thread` instead of blocking `subprocess.run`.
+- โก **Welcome endpoint system info collected off the event loop.** The `/welcome` handler now gathers hostname, memory, disk, CPU, network, and process data in a background thread.
+
## [0.1.3] - 2026-06-06
### Fixed
diff --git a/README.md b/README.md
index 8f3e5bf3..e74a7c7c 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,12 @@ Then open the URL printed in the logs, usually `http://localhost:8000/?token=...
The `:dev` image is also available and tracks the `main` branch.
+## Security model
+
+cptr is designed as **your computer, served to you**. Once authenticated, a user has full access to the host filesystem and shell, equivalent to an SSH session. There is no path sandboxing and no per-user isolation.
+
+This is safe when you are the only user and you control the network. It is not safe if untrusted users share the instance, it is exposed to the public internet, or a reverse proxy forwards spoofable auth headers. Treat a shared cptr like an open SSH port.
+
## License
Open Use License. Source available. All rights reserved. See [LICENSE](LICENSE). [Enterprise licenses available](mailto:sales@openwebui.com).
diff --git a/cptr/frontend/package-lock.json b/cptr/frontend/package-lock.json
index de9103cd..88694385 100644
--- a/cptr/frontend/package-lock.json
+++ b/cptr/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frontend",
- "version": "0.1.0",
+ "version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "0.1.0",
+ "version": "0.1.4",
"dependencies": {
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
diff --git a/cptr/frontend/package.json b/cptr/frontend/package.json
index 06140be4..deb494cb 100644
--- a/cptr/frontend/package.json
+++ b/cptr/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.1.3",
+ "version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite dev",
diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
index 86bcec63..715ec021 100644
--- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
+++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
@@ -196,6 +196,20 @@
type DisplayItem = ToolGroup | MessageItem | ArtifactItem;
+ const outputText = $derived.by((): string => {
+ return (output || [])
+ .filter((i: any) => i.type === 'message')
+ .flatMap((i: any) => i.content || [])
+ .map((c: any) => c.text || '')
+ .join('');
+ });
+
+ const unrenderedContent = $derived.by((): string => {
+ if (!content) return '';
+ if (!outputText) return content;
+ return content.startsWith(outputText) ? content.slice(outputText.length) : '';
+ });
+
const displayItems = $derived.by((): DisplayItem[] => {
if (!output?.length) return [];
@@ -317,6 +331,9 @@
class="inline-block w-[2px] h-3.5 bg-gray-400 dark:bg-gray-500 ml-0.5 animate-pulse align-text-bottom"
>
{:else}
+ {#if done && displayItems.length === 0 && content}
+
+ {/if}
{#each displayItems as displayItem, groupIdx}
{#if displayItem.type === 'message_item'}
{/if}
{/each}
- {#if !done}
- {@const flushedText = (output || [])
- .filter((i: any) => i.type === 'message')
- .flatMap((i: any) => i.content || [])
- .map((c: any) => c.text)
- .join('')}
- {@const pendingText = content.slice(flushedText.length)}
- {#if pendingText}
-
+ {#if done && unrenderedContent && displayItems.length > 0}
+
+ {:else if !done}
+ {#if unrenderedContent}
+
{/if}
0
- ? allMessages[allMessages.length - 1].id
+ : displayMessages.length > 0
+ ? displayMessages[displayMessages.length - 1].id
: null;
if (!effectiveId) return [];
@@ -203,8 +208,11 @@
// โโ Load chat from DB โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ let loadGeneration = 0;
+
async function loadChat(id: string) {
chatId = id;
+ const gen = ++loadGeneration;
// Only show loading spinner on initial load (no messages yet).
// On reloads (e.g. after cancel/done), keep the DOM intact to preserve scroll position.
const isInitialLoad = allMessages.length === 0;
@@ -215,10 +223,12 @@
try {
const data = await getChat(id);
+ // Discard stale response if a newer loadChat was called while we waited
+ if (gen !== loadGeneration) return;
allMessages = data.messages;
currentMessageId = data.chat.current_message_id;
} finally {
- if (isInitialLoad) loading = false;
+ if (isInitialLoad && gen === loadGeneration) loading = false;
}
// On reloads, restore scroll position after the DOM re-renders.
@@ -347,6 +357,12 @@
cancelledMessageId = null;
return;
}
+
+ // Mark done optimistically, but keep the streamed content visible until
+ // the DB reload returns. This avoids a transient blank message if the
+ // final `done` socket event beats the commit/read path.
+ msg.done = true;
+ allMessages = [...allMessages];
loadChat(data.chat_id);
}
}
diff --git a/cptr/frontend/src/lib/stores.ts b/cptr/frontend/src/lib/stores.ts
index 1bae2c9f..7715e10e 100644
--- a/cptr/frontend/src/lib/stores.ts
+++ b/cptr/frontend/src/lib/stores.ts
@@ -119,8 +119,17 @@ export const workspaceList = writable<{ path: string; name: string }[]>([]);
/** Global user preferences. */
export const sidebarOpen = writable(
- typeof window !== 'undefined' ? window.innerWidth >= 768 : false
+ typeof window !== 'undefined' ? window.innerWidth >= 1024 : false
);
+
+// Auto-close sidebar when window is resized below mobile breakpoint
+if (typeof window !== 'undefined') {
+ window.addEventListener('resize', () => {
+ if (window.innerWidth < 768) {
+ sidebarOpen.set(false);
+ }
+ });
+}
export const sidebarWidth = writable(220);
export const theme = writable('dark');
export const toolApprovalMode = writable('auto');
diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py
index 15f17aa7..a8640a61 100644
--- a/cptr/routers/chat.py
+++ b/cptr/routers/chat.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from pathlib import Path
from typing import List, Optional
@@ -77,14 +78,18 @@ async def list_chats(
if not chats_dir.exists():
return {"chats": [], "total": 0, "has_more": False}
- # Collect chat IDs and their relative folder paths
- chat_entries: list[dict] = []
- for json_file in chats_dir.rglob("*.json"):
- chat_id = json_file.stem
- rel_folder = str(json_file.parent.relative_to(chats_dir))
- if rel_folder == ".":
- rel_folder = ""
- chat_entries.append({"id": chat_id, "folder": rel_folder})
+ # Scan filesystem for chat JSON files in a thread
+ def _scan_chat_files() -> list[dict]:
+ entries = []
+ for json_file in chats_dir.rglob("*.json"):
+ chat_id = json_file.stem
+ rel_folder = str(json_file.parent.relative_to(chats_dir))
+ if rel_folder == ".":
+ rel_folder = ""
+ entries.append({"id": chat_id, "folder": rel_folder})
+ return entries
+
+ chat_entries = await asyncio.to_thread(_scan_chat_files)
if not chat_entries:
return {"chats": [], "total": 0, "has_more": False}
@@ -296,7 +301,7 @@ async def delete_chat(chat_id: str, request: Request):
workspace = chat.meta.get("workspace", "") if chat.meta else ""
if workspace:
marker = Path(workspace) / ".cptr" / "chats" / f"{chat_id}.json"
- marker.unlink(missing_ok=True)
+ await asyncio.to_thread(marker.unlink, True) # missing_ok=True
await Chat.delete(chat_id)
return {"ok": True}
@@ -344,10 +349,10 @@ async def send_message(body: SendMessageRequest, request: Request):
)
# Ensure .cptr/chats/ dir exists (export will write the full JSON)
chats_dir = Path(body.workspace) / ".cptr" / "chats"
- chats_dir.mkdir(parents=True, exist_ok=True)
+ await asyncio.to_thread(lambda: chats_dir.mkdir(parents=True, exist_ok=True))
# Auto-add .cptr to .gitignore if this is a git repo
- _ensure_gitignore(body.workspace)
+ await asyncio.to_thread(_ensure_gitignore, body.workspace)
# Check if the chat has an in-progress assistant message.
# If so, queue this message instead of starting a new task.
diff --git a/cptr/routers/events.py b/cptr/routers/events.py
index 2ce01d08..7be3ae50 100644
--- a/cptr/routers/events.py
+++ b/cptr/routers/events.py
@@ -10,7 +10,6 @@
import logging
import os
import platform
-import subprocess
import sys
import threading
from pathlib import Path
@@ -242,44 +241,39 @@ async def _fs_watcher_loop(ws: WebSocket, initial_path: str, path_holder: dict)
_SYSTEM_PORTS = {22, 53, 80, 443, 631, 5353}
-def _get_ppid(pid: int) -> int:
+async def _get_ppid(pid: int) -> int:
"""Get parent PID. Cross-platform."""
try:
if sys.platform == "win32":
- r = subprocess.run(
- [
- "wmic",
- "process",
- "where",
- f"ProcessId={pid}",
- "get",
- "ParentProcessId",
- "/value",
- ],
- capture_output=True,
- text=True,
- timeout=3,
+ proc = await asyncio.create_subprocess_exec(
+ "wmic", "process", "where", f"ProcessId={pid}",
+ "get", "ParentProcessId", "/value",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
)
- for line in r.stdout.splitlines():
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
+ for line in stdout.decode(errors="replace").splitlines():
if line.startswith("ParentProcessId="):
return int(line.split("=", 1)[1])
return 0
elif sys.platform == "linux":
- with open(f"/proc/{pid}/stat") as f:
- return int(f.read().split()[3])
+ def _read_ppid():
+ with open(f"/proc/{pid}/stat") as f:
+ return int(f.read().split()[3])
+ return await asyncio.to_thread(_read_ppid)
else:
- r = subprocess.run(
- ["ps", "-o", "ppid=", "-p", str(pid)],
- capture_output=True,
- text=True,
- timeout=2,
+ proc = await asyncio.create_subprocess_exec(
+ "ps", "-o", "ppid=", "-p", str(pid),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
)
- return int(r.stdout.strip())
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=2)
+ return int(stdout.decode(errors="replace").strip())
except Exception:
return 0
-def _find_session_for_pid(pid: int) -> Optional[str]:
+async def _find_session_for_pid(pid: int) -> Optional[str]:
"""Walk up the process tree to find which terminal session spawned this PID."""
current = pid
visited: set[int] = set()
@@ -296,58 +290,59 @@ def _find_session_for_pid(pid: int) -> Optional[str]:
return session.session_id
except Exception:
pass
- current = _get_ppid(current)
+ current = await _get_ppid(current)
return None
-def _get_process_name(pid: int) -> str:
+async def _get_process_name(pid: int) -> str:
"""Get process name from PID."""
try:
if sys.platform == "win32":
- r = subprocess.run(
- ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
- capture_output=True,
- text=True,
- timeout=3,
+ proc = await asyncio.create_subprocess_exec(
+ "tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
)
- # Output: "name.exe","1234",...
- line = r.stdout.strip()
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
+ line = stdout.decode(errors="replace").strip()
if line and line.startswith('"'):
return line.split('"')[1]
return "unknown"
elif sys.platform == "linux":
- with open(f"/proc/{pid}/comm") as f:
- return f.read().strip()
+ def _read_comm():
+ with open(f"/proc/{pid}/comm") as f:
+ return f.read().strip()
+ return await asyncio.to_thread(_read_comm)
else:
- r = subprocess.run(
- ["ps", "-o", "comm=", "-p", str(pid)],
- capture_output=True,
- text=True,
- timeout=2,
+ proc = await asyncio.create_subprocess_exec(
+ "ps", "-o", "comm=", "-p", str(pid),
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
)
- name = r.stdout.strip()
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=2)
+ name = stdout.decode(errors="replace").strip()
# macOS returns full path, extract basename
return os.path.basename(name) if name else "unknown"
except Exception:
return "unknown"
-def _scan_ports_darwin() -> list[dict]:
+async def _scan_ports_darwin() -> list[dict]:
"""Scan listening ports on macOS using lsof."""
try:
- r = subprocess.run(
- ["lsof", "-iTCP", "-sTCP:LISTEN", "-nP", "-F", "pcn"],
- capture_output=True,
- text=True,
- timeout=5,
+ proc = await asyncio.create_subprocess_exec(
+ "lsof", "-iTCP", "-sTCP:LISTEN", "-nP", "-F", "pcn",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
)
- if r.returncode != 0:
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
+ if proc.returncode != 0:
return []
ports = []
current_pid = 0
current_process = ""
- for line in r.stdout.splitlines():
+ for line in stdout.decode(errors="replace").splitlines():
if line.startswith("p"):
current_pid = int(line[1:])
elif line.startswith("c"):
@@ -374,59 +369,65 @@ def _scan_ports_darwin() -> list[dict]:
return []
-def _scan_ports_linux() -> list[dict]:
+async def _scan_ports_linux() -> list[dict]:
"""Scan listening ports on Linux using /proc/net/tcp."""
ports = []
try:
- with open("/proc/net/tcp") as f:
- for line in f.readlines()[1:]: # skip header
- parts = line.split()
- if parts[3] == "0A": # LISTEN state
- local = parts[1]
- port = int(local.split(":")[1], 16)
- inode = int(parts[9])
- # Find PID for this inode
- pid = _inode_to_pid(inode)
- process = _get_process_name(pid) if pid else "unknown"
- ports.append({"port": port, "pid": pid or 0, "process": process})
+ def _read_proc_net():
+ with open("/proc/net/tcp") as f:
+ return f.readlines()[1:] # skip header
+
+ lines = await asyncio.to_thread(_read_proc_net)
+ for line in lines:
+ parts = line.split()
+ if parts[3] == "0A": # LISTEN state
+ local = parts[1]
+ port = int(local.split(":")[1], 16)
+ inode = int(parts[9])
+ # Find PID for this inode
+ pid = await _inode_to_pid(inode)
+ process = await _get_process_name(pid) if pid else "unknown"
+ ports.append({"port": port, "pid": pid or 0, "process": process})
except Exception as e:
logger.warning(f"Port scan failed: {e}")
return ports
-def _inode_to_pid(inode: int) -> Optional[int]:
+async def _inode_to_pid(inode: int) -> Optional[int]:
"""Map a socket inode to a PID on Linux."""
- try:
- for entry in os.listdir("/proc"):
- if not entry.isdigit():
- continue
- try:
- fd_dir = f"/proc/{entry}/fd"
- for fd in os.listdir(fd_dir):
- try:
- link = os.readlink(f"{fd_dir}/{fd}")
- if f"socket:[{inode}]" in link:
- return int(entry)
- except (OSError, ValueError):
- continue
- except (OSError, PermissionError):
- continue
- except Exception:
- pass
- return None
+ def _scan():
+ try:
+ for entry in os.listdir("/proc"):
+ if not entry.isdigit():
+ continue
+ try:
+ fd_dir = f"/proc/{entry}/fd"
+ for fd in os.listdir(fd_dir):
+ try:
+ link = os.readlink(f"{fd_dir}/{fd}")
+ if f"socket:[{inode}]" in link:
+ return int(entry)
+ except (OSError, ValueError):
+ continue
+ except (OSError, PermissionError):
+ continue
+ except Exception:
+ pass
+ return None
+ return await asyncio.to_thread(_scan)
-def _scan_ports_windows() -> list[dict]:
+async def _scan_ports_windows() -> list[dict]:
"""Scan listening ports on Windows using netstat."""
try:
- r = subprocess.run(
- ["netstat", "-ano", "-p", "TCP"],
- capture_output=True,
- text=True,
- timeout=5,
+ proc = await asyncio.create_subprocess_exec(
+ "netstat", "-ano", "-p", "TCP",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
)
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
ports = []
- for line in r.stdout.splitlines():
+ for line in stdout.decode(errors="replace").splitlines():
parts = line.split()
if len(parts) >= 5 and "LISTENING" in parts:
local = parts[1]
@@ -439,7 +440,7 @@ def _scan_ports_windows() -> list[dict]:
{
"port": port,
"pid": pid,
- "process": _get_process_name(pid),
+ "process": await _get_process_name(pid),
}
)
except ValueError:
@@ -450,15 +451,15 @@ def _scan_ports_windows() -> list[dict]:
return []
-def _scan_ports() -> list[dict]:
+async def _scan_ports() -> list[dict]:
"""Scan listening ports. Cross-platform."""
system = platform.system()
if system == "Darwin":
- raw = _scan_ports_darwin()
+ raw = await _scan_ports_darwin()
elif system == "Linux":
- raw = _scan_ports_linux()
+ raw = await _scan_ports_linux()
elif system == "Windows":
- raw = _scan_ports_windows()
+ raw = await _scan_ports_windows()
else:
return []
@@ -484,7 +485,7 @@ def _scan_ports() -> list[dict]:
# Session attribution: only include ports spawned by our terminals
if entry["pid"]:
- session_id = _find_session_for_pid(entry["pid"])
+ session_id = await _find_session_for_pid(entry["pid"])
if session_id:
entry["session_id"] = session_id
filtered.append(entry)
@@ -502,7 +503,7 @@ async def _port_scanner_loop(ws: WebSocket) -> None:
await asyncio.sleep(3)
try:
- current_ports = {p["port"]: p for p in _scan_ports()}
+ current_ports = {p["port"]: p for p in await _scan_ports()}
except Exception as e:
logger.warning(f"Port scan error: {e}")
continue
diff --git a/cptr/routers/state.py b/cptr/routers/state.py
index b02fe5d9..6aeb5951 100644
--- a/cptr/routers/state.py
+++ b/cptr/routers/state.py
@@ -115,9 +115,30 @@ async def delete_workspace(request: Request, path: str = Query(...)):
@router.get("/welcome")
async def get_welcome(request: Request):
"""Return data for the welcome/landing page."""
+ import asyncio
+
+ system_info = await asyncio.to_thread(_collect_system_info)
+
+ # Recent workspaces from DB (most recently used first)
+ user_id = await _get_user_id(request)
+ recent: list[dict] = []
+ if user_id:
+ workspaces = await Workspace.get_by_user(user_id)
+ # Sort by updated_at descending (most recent first)
+ workspaces.sort(key=lambda ws: ws.updated_at or 0, reverse=True)
+ recent = [{"name": ws.name, "path": ws.path} for ws in workspaces[:10]]
+
+ system_info["recent"] = recent
+ return system_info
+
+
+def _collect_system_info() -> dict:
+ """Gather all system info synchronously. Called via asyncio.to_thread()."""
import platform
import socket
import shutil
+ import subprocess
+ import tempfile
import time
from importlib.metadata import version as pkg_version
@@ -138,8 +159,6 @@ async def get_welcome(request: Request):
# Memory (cross-platform)
try:
if platform.system() == "Darwin":
- import subprocess
-
result = subprocess.run(
["sysctl", "-n", "hw.memsize"],
capture_output=True,
@@ -207,8 +226,6 @@ class MEMORYSTATUSEX(ctypes.Structure):
# Uptime
try:
if platform.system() == "Darwin":
- import subprocess
-
result = subprocess.run(
["sysctl", "-n", "kern.boottime"],
capture_output=True,
@@ -238,8 +255,6 @@ class MEMORYSTATUSEX(ctypes.Structure):
# CPU usage
try:
- import subprocess
-
if platform.system() == "Linux":
with open("/proc/stat") as f:
line = f.readline()
@@ -269,8 +284,6 @@ class MEMORYSTATUSEX(ctypes.Structure):
# Network interfaces
try:
- import subprocess
-
interfaces = []
if platform.system() == "Darwin":
result = subprocess.run(
@@ -321,8 +334,6 @@ class MEMORYSTATUSEX(ctypes.Structure):
# Top processes (by CPU)
processes = []
try:
- import subprocess
-
if platform.system() == "Darwin":
result = subprocess.run(
["ps", "-Arco", "pid,pcpu,pmem,comm"],
@@ -401,8 +412,6 @@ class MEMORYSTATUSEX(ctypes.Structure):
pass
# Suggested directories
- import tempfile
-
home = str(Path.home())
candidates = [
home,
@@ -433,15 +442,6 @@ class MEMORYSTATUSEX(ctypes.Structure):
suggestions.append({"name": p.name or c, "path": c})
seen.add(c)
- # Recent workspaces from DB (most recently used first)
- user_id = await _get_user_id(request)
- recent: list[dict] = []
- if user_id:
- workspaces = await Workspace.get_by_user(user_id)
- # Sort by updated_at descending (most recent first)
- workspaces.sort(key=lambda ws: ws.updated_at or 0, reverse=True)
- recent = [{"name": ws.name, "path": ws.path} for ws in workspaces[:10]]
-
return {
"hostname": hostname,
"platform": platform.system(),
@@ -449,5 +449,4 @@ class MEMORYSTATUSEX(ctypes.Structure):
"system": system,
"processes": processes,
"suggestions": suggestions,
- "recent": recent,
}
diff --git a/cptr/routers/workspace.py b/cptr/routers/workspace.py
index 3d14cb67..932894f9 100644
--- a/cptr/routers/workspace.py
+++ b/cptr/routers/workspace.py
@@ -1,7 +1,13 @@
-"""Endpoints for browsing directories and managing files within the workspace."""
+"""Endpoints for browsing directories and managing files within the workspace.
+
+Security: these endpoints accept arbitrary absolute paths with no sandboxing.
+Authenticated users get full filesystem access. This is intentional for the
+single-user model. Not safe for shared or public instances. See README.md.
+"""
from __future__ import annotations
+import asyncio
import os
from datetime import datetime, timezone
from pathlib import Path
@@ -36,9 +42,8 @@ async def list_directory(path: str = Query(..., description="Absolute path to li
if not target.is_dir():
raise HTTPException(status_code=400, detail=f"Not a directory: {path}")
- entries: list[FileEntry] = []
-
- try:
+ def _scan() -> list[FileEntry]:
+ entries: list[FileEntry] = []
for item in target.iterdir():
try:
st = item.stat()
@@ -60,15 +65,17 @@ async def list_directory(path: str = Query(..., description="Absolute path to li
)
)
except (PermissionError, OSError):
- # Skip items we can't stat
entries.append(FileEntry(name=item.name, type="file"))
+ # Sort: directories first, then files, alphabetical within each group
+ type_order = {"directory": 0, "symlink": 1, "file": 2}
+ entries.sort(key=lambda e: (type_order.get(e.type, 2), e.name.lower()))
+ return entries
+
+ try:
+ entries = await asyncio.to_thread(_scan)
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {path}")
- # Sort: directories first, then files, alphabetical within each group
- type_order = {"directory": 0, "symlink": 1, "file": 2}
- entries.sort(key=lambda e: (type_order.get(e.type, 2), e.name.lower()))
-
return DirectoryListing(path=str(target), entries=entries)
@@ -236,32 +243,35 @@ async def read_file(path: str = Query(..., description="Absolute path to file"))
if not target.is_file():
raise HTTPException(status_code=400, detail=f"Not a file: {path}")
- size = target.stat().st_size
+ def _read() -> FileContent:
+ size = target.stat().st_size
- if size > MAX_FILE_SIZE:
- raise HTTPException(
- status_code=413,
- detail=f"File too large ({size} bytes). Max is {MAX_FILE_SIZE} bytes.",
- )
+ if size > MAX_FILE_SIZE:
+ raise HTTPException(
+ status_code=413,
+ detail=f"File too large ({size} bytes). Max is {MAX_FILE_SIZE} bytes.",
+ )
- is_text = _is_text_file(target)
+ is_text = _is_text_file(target)
- if is_text:
- try:
- content = target.read_text(encoding="utf-8", errors="replace")
- except (OSError, PermissionError) as e:
- raise HTTPException(status_code=403, detail=str(e))
- else:
- content = None
+ if is_text:
+ try:
+ content = target.read_text(encoding="utf-8", errors="replace")
+ except (OSError, PermissionError) as e:
+ raise HTTPException(status_code=403, detail=str(e))
+ else:
+ content = None
+
+ return FileContent(
+ path=str(target),
+ name=target.name,
+ size=size,
+ binary=not is_text,
+ content=content,
+ language=_detect_language(target.name) if is_text else None,
+ )
- return FileContent(
- path=str(target),
- name=target.name,
- size=size,
- binary=not is_text,
- content=content,
- language=_detect_language(target.name) if is_text else None,
- )
+ return await asyncio.to_thread(_read)
# โโ File writing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -281,14 +291,16 @@ async def write_file(req: WriteFileRequest):
if target.exists() and not target.is_file():
raise HTTPException(status_code=400, detail=f"Not a file: {req.path}")
- try:
+ def _write() -> dict:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(req.content, encoding="utf-8")
+ return {"status": "saved", "path": str(target), "size": target.stat().st_size}
+
+ try:
+ return await asyncio.to_thread(_write)
except (OSError, PermissionError) as e:
raise HTTPException(status_code=403, detail=str(e))
- return {"status": "saved", "path": str(target), "size": target.stat().st_size}
-
# โโ File search โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -339,64 +351,64 @@ async def search_files(
if not root.exists() or not root.is_dir():
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
- query_lower = query.strip().lower().replace("\\", "/")
- # Collect all matches first, then rank
- matches: list[tuple[int, int, SearchResult]] = [] # (score, path_len, result)
- max_collect = limit * 10 # collect more than needed for ranking
-
- def walk(directory: Path, depth: int = 0):
- if depth > 8 or len(matches) >= max_collect:
- return
- try:
- for item in sorted(directory.iterdir(), key=lambda p: p.name.lower()):
- if item.name in SEARCH_IGNORE_DIRS or item.name.startswith("."):
- continue
- if len(matches) >= max_collect:
- return
-
- name_lower = item.name.lower()
- if query_lower and query_lower in name_lower:
- # Score: 0 = exact, 1 = starts-with, 2 = contains
- if name_lower == query_lower:
- score = 0
- elif name_lower.startswith(query_lower):
- score = 1
- else:
- score = 2
- matches.append(
- (
- score,
- len(item.name),
- SearchResult(
- path=str(item),
- name=item.name,
- type="directory" if item.is_dir() else "file",
- ),
+ def _walk_and_rank() -> list[SearchResult]:
+ query_lower = query.strip().lower().replace("\\", "/")
+ matches: list[tuple[int, int, SearchResult]] = []
+ max_collect = limit * 10
+
+ def walk(directory: Path, depth: int = 0):
+ if depth > 8 or len(matches) >= max_collect:
+ return
+ try:
+ for item in sorted(directory.iterdir(), key=lambda p: p.name.lower()):
+ if item.name in SEARCH_IGNORE_DIRS or item.name.startswith("."):
+ continue
+ if len(matches) >= max_collect:
+ return
+
+ name_lower = item.name.lower()
+ if query_lower and query_lower in name_lower:
+ if name_lower == query_lower:
+ score = 0
+ elif name_lower.startswith(query_lower):
+ score = 1
+ else:
+ score = 2
+ matches.append(
+ (
+ score,
+ len(item.name),
+ SearchResult(
+ path=str(item),
+ name=item.name,
+ type="directory" if item.is_dir() else "file",
+ ),
+ )
)
- )
- elif not query_lower:
- # Empty query: return top-level files
- matches.append(
- (
- 2,
- len(item.name),
- SearchResult(
- path=str(item),
- name=item.name,
- type="directory" if item.is_dir() else "file",
- ),
+ elif not query_lower:
+ matches.append(
+ (
+ 2,
+ len(item.name),
+ SearchResult(
+ path=str(item),
+ name=item.name,
+ type="directory" if item.is_dir() else "file",
+ ),
+ )
)
- )
- if item.is_dir():
- walk(item, depth + 1)
- except (PermissionError, OSError):
- pass
+ if item.is_dir():
+ walk(item, depth + 1)
+ except (PermissionError, OSError):
+ pass
+
+ walk(root)
+ matches.sort(key=lambda m: (m[0], m[1]))
+ return [m[2] for m in matches[:limit]]
- walk(root)
- # Sort by score (lower = better), then by name length (shorter = better)
- matches.sort(key=lambda m: (m[0], m[1]))
- return SearchResponse(results=[m[2] for m in matches[:limit]])
+ results = await asyncio.to_thread(_walk_and_rank)
+ return SearchResponse(results=results)
# โโ File management โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -415,12 +427,15 @@ async def create_item(req: CreateRequest):
if target.exists():
raise HTTPException(status_code=409, detail=f"Already exists: {req.path}")
- try:
+ def _create():
if req.type == "directory":
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.touch()
+
+ try:
+ await asyncio.to_thread(_create)
except (OSError, PermissionError) as e:
raise HTTPException(status_code=403, detail=str(e))
@@ -449,7 +464,7 @@ async def move_item(req: MoveRequest):
raise HTTPException(status_code=409, detail=f"Destination exists: {dst}")
try:
- src.rename(dst)
+ await asyncio.to_thread(src.rename, dst)
except (OSError, PermissionError) as e:
raise HTTPException(status_code=403, detail=str(e))
@@ -470,11 +485,14 @@ async def delete_item(req: DeleteRequest):
if not target.exists():
raise HTTPException(status_code=404, detail=f"Not found: {req.path}")
- try:
+ def _delete():
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
+
+ try:
+ await asyncio.to_thread(_delete)
except (OSError, PermissionError) as e:
raise HTTPException(status_code=403, detail=str(e))
@@ -498,7 +516,7 @@ async def upload_file(
target = target_dir / file.filename
try:
content = await file.read()
- target.write_bytes(content)
+ await asyncio.to_thread(target.write_bytes, content)
except (OSError, PermissionError) as e:
raise HTTPException(status_code=403, detail=str(e))
@@ -574,23 +592,25 @@ async def archive_files(req: ArchiveRequest):
if not req.paths:
raise HTTPException(status_code=400, detail="No paths provided")
- buf = io.BytesIO()
-
- with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
- for raw_path in req.paths:
- target = Path(raw_path).resolve()
- if not target.exists():
- continue
-
- if target.is_file():
- zf.write(target, target.name)
- elif target.is_dir():
- for child in target.rglob("*"):
- if child.is_file():
- arcname = str(child.relative_to(target.parent))
- zf.write(child, arcname)
+ def _build_archive() -> io.BytesIO:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ for raw_path in req.paths:
+ target = Path(raw_path).resolve()
+ if not target.exists():
+ continue
- buf.seek(0)
+ if target.is_file():
+ zf.write(target, target.name)
+ elif target.is_dir():
+ for child in target.rglob("*"):
+ if child.is_file():
+ arcname = str(child.relative_to(target.parent))
+ zf.write(child, arcname)
+ buf.seek(0)
+ return buf
+
+ buf = await asyncio.to_thread(_build_archive)
# Derive a sensible filename
if len(req.paths) == 1:
diff --git a/cptr/utils/chat_export.py b/cptr/utils/chat_export.py
index 025561b6..7fb00e10 100644
--- a/cptr/utils/chat_export.py
+++ b/cptr/utils/chat_export.py
@@ -6,6 +6,7 @@
from __future__ import annotations
+import asyncio
import json
import logging
from pathlib import Path
@@ -65,11 +66,13 @@ async def export_chat_to_file(chat_id: str) -> None:
},
}
- chats_dir = Path(workspace) / ".cptr" / "chats"
- chats_dir.mkdir(parents=True, exist_ok=True)
- target = chats_dir / f"{chat_id}.json"
+ def _write():
+ chats_dir = Path(workspace) / ".cptr" / "chats"
+ chats_dir.mkdir(parents=True, exist_ok=True)
+ target = chats_dir / f"{chat_id}.json"
+ target.write_text(json.dumps(chat_data, indent=2, ensure_ascii=False))
try:
- target.write_text(json.dumps(chat_data, indent=2, ensure_ascii=False))
+ await asyncio.to_thread(_write)
except Exception:
logger.exception(f"Failed to export chat {chat_id}")
diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py
index 2a7cc647..13e0a060 100644
--- a/cptr/utils/chat_task.py
+++ b/cptr/utils/chat_task.py
@@ -547,6 +547,7 @@ def _flush_text() -> dict | None:
}
output_items.append(item)
text_buffer = ""
+ _sync_state()
return item
def _sync_state():
@@ -660,6 +661,11 @@ def _sync_state():
await emit(output=artifact_item)
_sync_state()
+ # Persist intermediate state so content survives crashes/errors
+ await ChatMessage.update(
+ message_id, content=content, output=output_items
+ )
+
# Append to messages for next iteration
_append_tool_to_messages(messages, event, result, provider)
restart = True
@@ -678,6 +684,7 @@ def _sync_state():
if flushed_item:
await emit(output=flushed_item)
await emit(output=item)
+ _task_state.pop(message_id, None)
await emit(done=True)
return
@@ -698,6 +705,7 @@ def _sync_state():
usage=usage,
done=True,
)
+ _task_state.pop(message_id, None)
await emit(done=True)
return
@@ -706,7 +714,9 @@ def _sync_state():
pass
if not restart:
- _flush_text()
+ flushed_item = _flush_text()
+ if flushed_item:
+ await emit(output=flushed_item)
logger.info(
"[task %s] save (end): content=%d chars, output=%d items, types=%s",
message_id[:8],
@@ -720,6 +730,7 @@ def _sync_state():
output=output_items,
done=True,
)
+ _task_state.pop(message_id, None)
await emit(done=True)
return
@@ -731,14 +742,17 @@ def _sync_state():
done=True,
meta={"error": "max iterations reached"},
)
+ _task_state.pop(message_id, None)
await emit(done=True)
except asyncio.CancelledError:
_flush_text()
await ChatMessage.update(message_id, content=content, output=output_items, done=True)
+ _task_state.pop(message_id, None)
await emit(done=True)
except Exception as e:
logger.exception(f"Chat task error for message {message_id}")
+ _flush_text()
await ChatMessage.update(
message_id,
content=content,
@@ -746,6 +760,7 @@ def _sync_state():
done=True,
meta={"error": str(e)},
)
+ _task_state.pop(message_id, None)
await emit(done=True)
finally:
_tasks.pop(message_id, None)
diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py
index c95d1a23..b65f5782 100644
--- a/cptr/utils/tools.py
+++ b/cptr/utils/tools.py
@@ -8,12 +8,14 @@
from __future__ import annotations
import asyncio
+import fnmatch
import inspect
import json
import os
+import re
import uuid
from pathlib import Path
-from typing import get_type_hints
+from typing import Callable, Optional, Pattern, get_type_hints
from cptr.env import CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS
@@ -84,28 +86,31 @@ async def read_file(
if not full.is_file():
return f"Error: file not found: {path}"
- size = full.stat().st_size
- if size > 500_000:
- return f"Error: file too large ({size} bytes, max 500KB)"
+ def _read():
+ size = full.stat().st_size
+ if size > 500_000:
+ return f"Error: file too large ({size} bytes, max 500KB)"
- lines = full.read_text(errors="replace").splitlines()
- total = len(lines)
+ lines = full.read_text(errors="replace").splitlines()
+ total = len(lines)
- if start_line > 0 or end_line > 0:
- s = max(1, start_line) - 1 # Convert to 0-indexed
- e = min(total, end_line) if end_line > 0 else total
- selected = lines[s:e]
- numbered = [f"{i + s + 1}: {line}" for i, line in enumerate(selected)]
- header = f"File: {path} | Lines {s + 1}-{e} of {total}\n"
- return header + "\n".join(numbered)
- else:
- # Cap at 800 lines, show line numbers
- capped = lines[:800]
- numbered = [f"{i + 1}: {line}" for i, line in enumerate(capped)]
- header = f"File: {path} | Total lines: {total}"
- if total > 800:
- header += " (showing first 800)"
- return header + "\n" + "\n".join(numbered)
+ if start_line > 0 or end_line > 0:
+ s = max(1, start_line) - 1 # Convert to 0-indexed
+ e = min(total, end_line) if end_line > 0 else total
+ selected = lines[s:e]
+ numbered = [f"{i + s + 1}: {line}" for i, line in enumerate(selected)]
+ header = f"File: {path} | Lines {s + 1}-{e} of {total}\n"
+ return header + "\n".join(numbered)
+ else:
+ # Cap at 800 lines, show line numbers
+ capped = lines[:800]
+ numbered = [f"{i + 1}: {line}" for i, line in enumerate(capped)]
+ header = f"File: {path} | Total lines: {total}"
+ if total > 800:
+ header += " (showing first 800)"
+ return header + "\n" + "\n".join(numbered)
+
+ return await asyncio.to_thread(_read)
async def list_directory(
@@ -122,49 +127,52 @@ async def list_directory(
if not full.is_dir():
return f"Error: not a directory: {path}"
- ignore = {
- ".git",
- "node_modules",
- "__pycache__",
- ".venv",
- "venv",
- ".next",
- "build",
- "dist",
- ".cptr",
- ".svelte-kit",
- }
- entries = []
-
- if recursive:
- for root, dirs, files in os.walk(full):
- dirs[:] = sorted(d for d in dirs if d not in ignore)
- rel = Path(root).relative_to(full)
- for f in sorted(files):
- fpath = Path(root) / f
- try:
- sz = fpath.stat().st_size
- except OSError:
- sz = 0
- entries.append(f"{rel / f} ({_human_size(sz)})")
- else:
- for item in sorted(full.iterdir()):
- if item.name in ignore:
- continue
- if item.is_dir():
- try:
- count = sum(1 for _ in item.rglob("*") if _.is_file())
- except (PermissionError, OSError):
- count = 0
- entries.append(f"{item.name}/ ({count} files)")
- else:
- try:
- sz = item.stat().st_size
- except OSError:
- sz = 0
- entries.append(f"{item.name} ({_human_size(sz)})")
-
- res = "\n".join(entries) if entries else "(empty directory)"
+ def _list():
+ ignore = {
+ ".git",
+ "node_modules",
+ "__pycache__",
+ ".venv",
+ "venv",
+ ".next",
+ "build",
+ "dist",
+ ".cptr",
+ ".svelte-kit",
+ }
+ entries = []
+
+ if recursive:
+ for root, dirs, files in os.walk(full):
+ dirs[:] = sorted(d for d in dirs if d not in ignore)
+ rel = Path(root).relative_to(full)
+ for f in sorted(files):
+ fpath = Path(root) / f
+ try:
+ sz = fpath.stat().st_size
+ except OSError:
+ sz = 0
+ entries.append(f"{rel / f} ({_human_size(sz)})")
+ else:
+ for item in sorted(full.iterdir()):
+ if item.name in ignore:
+ continue
+ if item.is_dir():
+ try:
+ count = sum(1 for _ in item.rglob("*") if _.is_file())
+ except (PermissionError, OSError):
+ count = 0
+ entries.append(f"{item.name}/ ({count} files)")
+ else:
+ try:
+ sz = item.stat().st_size
+ except OSError:
+ sz = 0
+ entries.append(f"{item.name} ({_human_size(sz)})")
+
+ return "\n".join(entries) if entries else "(empty directory)"
+
+ res = await asyncio.to_thread(_list)
return _truncate_output(res, max_chars=CHAT_TOOL_MAX_CHARS)
@@ -244,28 +252,46 @@ async def _search_rg(
async def _search_python(query: str, full: Path, case_insensitive: bool) -> str:
"""Fallback search using pure Python (when ripgrep not installed)."""
- results = []
- ignore = {".git", "node_modules", "__pycache__", ".venv", "venv"}
- q = query.lower() if case_insensitive else query
-
- for root, dirs, files in os.walk(full):
- dirs[:] = [d for d in dirs if d not in ignore]
- for fname in files:
- fpath = Path(root) / fname
- try:
- text = fpath.read_text(errors="replace")
- except (OSError, PermissionError):
- continue
- for i, line in enumerate(text.splitlines(), 1):
- target = line.lower() if case_insensitive else line
- if q in target:
- rel = fpath.relative_to(full)
- results.append(f"{rel}:{i}: {line.strip()}")
- if len(results) >= 50:
- results.append("... (truncated at 50 matches)")
- return "\n".join(results)
-
- return "\n".join(results) if results else "No matches found."
+
+ def _read_text_for_search(fpath: Path) -> str | None:
+ """Read a file for searching, skipping binary-looking files.
+
+ Match ripgrep's default binary handling closely enough for the fallback:
+ files containing NUL bytes are treated as binary and are not decoded or
+ returned as replacement-character text.
+ """
+ try:
+ data = fpath.read_bytes()
+ except (OSError, PermissionError):
+ return None
+ if b"\0" in data:
+ return None
+ return data.decode(errors="replace")
+
+ def _walk_and_search():
+ results = []
+ ignore = {".git", "node_modules", "__pycache__", ".venv", "venv"}
+ q = query.lower() if case_insensitive else query
+
+ for root, dirs, files in os.walk(full):
+ dirs[:] = [d for d in dirs if d not in ignore]
+ for fname in files:
+ fpath = Path(root) / fname
+ text = _read_text_for_search(fpath)
+ if text is None:
+ continue
+ for i, line in enumerate(text.splitlines(), 1):
+ target = line.lower() if case_insensitive else line
+ if q in target:
+ rel = fpath.relative_to(full)
+ results.append(f"{rel}:{i}: {line.strip()}")
+ if len(results) >= 50:
+ results.append("... (truncated at 50 matches)")
+ return "\n".join(results)
+
+ return "\n".join(results) if results else "No matches found."
+
+ return await asyncio.to_thread(_walk_and_search)
async def create_file(
@@ -288,8 +314,12 @@ async def create_file(
full = _resolve_path(path, workspace)
if full.is_file() and not overwrite:
return f"Error: file already exists: {path}. Use overwrite=true or edit_file to modify."
- full.parent.mkdir(parents=True, exist_ok=True)
- full.write_text(content)
+
+ def _write():
+ full.parent.mkdir(parents=True, exist_ok=True)
+ full.write_text(content)
+
+ await asyncio.to_thread(_write)
return f"Created {path} ({len(content)} bytes, {len(content.splitlines())} lines)"
@@ -299,8 +329,12 @@ async def write_file(path: str, content: str, *, workspace: str) -> str:
:param content: File contents to write.
"""
full = _resolve_path(path, workspace)
- full.parent.mkdir(parents=True, exist_ok=True)
- full.write_text(content)
+
+ def _write():
+ full.parent.mkdir(parents=True, exist_ok=True)
+ full.write_text(content)
+
+ await asyncio.to_thread(_write)
return f"Wrote {len(content)} bytes to {path}"
@@ -324,39 +358,46 @@ async def edit_file(
if not full.is_file():
return f"Error: file not found: {path}"
- content = full.read_text(errors="replace")
+ def _edit():
+ content = full.read_text(errors="replace")
+
+ if start_line > 0 or end_line > 0:
+ lines = content.splitlines(keepends=True)
+ total = len(lines)
+ s = max(1, start_line) - 1
+ e = min(total, end_line) if end_line > 0 else total
+ region = "".join(lines[s:e])
+
+ if target not in region:
+ return f"Error: target text not found in lines {s + 1}-{e} of {path}"
+
+ count = region.count(target)
+ if count > 1:
+ return (
+ f"Error: target text found {count} times in lines {s + 1}-{e}. "
+ f"Narrow the line range or use a more specific target."
+ )
+
+ new_region = region.replace(target, replacement, 1)
+ new_content = "".join(lines[:s]) + new_region + "".join(lines[e:])
+ else:
+ count = content.count(target)
+ if count == 0:
+ return f"Error: target text not found in {path}"
+ if count > 1:
+ return (
+ f"Error: target text found {count} times in {path}. "
+ f"Use start_line/end_line to disambiguate."
+ )
+ new_content = content.replace(target, replacement, 1)
+
+ full.write_text(new_content)
+ return None # success sentinel
+
+ result = await asyncio.to_thread(_edit)
+ if result is not None:
+ return result
- if start_line > 0 or end_line > 0:
- lines = content.splitlines(keepends=True)
- total = len(lines)
- s = max(1, start_line) - 1
- e = min(total, end_line) if end_line > 0 else total
- region = "".join(lines[s:e])
-
- if target not in region:
- return f"Error: target text not found in lines {s + 1}-{e} of {path}"
-
- count = region.count(target)
- if count > 1:
- return (
- f"Error: target text found {count} times in lines {s + 1}-{e}. "
- f"Narrow the line range or use a more specific target."
- )
-
- new_region = region.replace(target, replacement, 1)
- new_content = "".join(lines[:s]) + new_region + "".join(lines[e:])
- else:
- count = content.count(target)
- if count == 0:
- return f"Error: target text not found in {path}"
- if count > 1:
- return (
- f"Error: target text found {count} times in {path}. "
- f"Use start_line/end_line to disambiguate."
- )
- new_content = content.replace(target, replacement, 1)
-
- full.write_text(new_content)
target_lines = len(target.splitlines())
replacement_lines = len(replacement.splitlines())
return (
@@ -387,31 +428,34 @@ async def multi_edit_file(
if not isinstance(edit_list, list) or not edit_list:
return "Error: edits must be a non-empty JSON array"
- content = full.read_text(errors="replace")
- applied = 0
+ def _apply():
+ content = full.read_text(errors="replace")
+ applied = 0
- for i, edit in enumerate(edit_list):
- target = edit.get("target", "")
- replacement = edit.get("replacement", "")
+ for i, edit in enumerate(edit_list):
+ target = edit.get("target", "")
+ replacement = edit.get("replacement", "")
- if not target:
- return f"Error: edit {i + 1} missing 'target'"
+ if not target:
+ return f"Error: edit {i + 1} missing 'target'"
- if target not in content:
- return f"Error: target not found for edit {i + 1}: {target[:100]}..."
+ if target not in content:
+ return f"Error: target not found for edit {i + 1}: {target[:100]}..."
+
+ count = content.count(target)
+ if count > 1:
+ return (
+ f"Error: edit {i + 1} target found {count} times. "
+ f"Each target must be unique in the file."
+ )
- count = content.count(target)
- if count > 1:
- return (
- f"Error: edit {i + 1} target found {count} times. "
- f"Each target must be unique in the file."
- )
+ content = content.replace(target, replacement, 1)
+ applied += 1
- content = content.replace(target, replacement, 1)
- applied += 1
+ full.write_text(content)
+ return f"Applied {applied} edits to {path}"
- full.write_text(content)
- return f"Applied {applied} edits to {path}"
+ return await asyncio.to_thread(_apply)
async def run_command(
diff --git a/pyproject.toml b/pyproject.toml
index d39d137b..954a1d47 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "cptr"
-version = "0.1.3"
+version = "0.1.4"
description = "Your computer, from anywhere. Code, manage, and control your machine from the web."
license = {file = "LICENSE"}
readme = "README.md"