Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
583 changes: 552 additions & 31 deletions cli/app.jsx

Large diffs are not rendered by default.

59 changes: 57 additions & 2 deletions tsunami/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

# Active WebSocket connections
connections: list[WebSocket] = []
active_runs: dict[int, dict[str, Any]] = {}

# Server state
_config: TsunamiConfig | None = None
Expand Down Expand Up @@ -155,6 +156,7 @@ async def websocket_endpoint(ws: WebSocket):
await ws.accept()
connections.append(ws)
log.info(f"WebSocket connected. {len(connections)} active.")
ws_id = id(ws)

try:
while True:
Expand All @@ -167,12 +169,38 @@ async def websocket_endpoint(ws: WebSocket):
if task.startswith("/"):
await handle_command(ws, task)
else:
await run_agent_with_streaming(ws, task)
current = active_runs.get(ws_id)
if current and not current["task"].done():
await ws.send_text(json.dumps({
"type": "error",
"message": "A run is already in progress. Stop it before starting another.",
}))
continue
run_task = asyncio.create_task(run_agent_with_streaming(ws, task))
active_runs[ws_id] = {"task": run_task, "agent": None}

elif msg.get("type") == "abort":
current = active_runs.get(ws_id)
if current and current.get("agent") is not None:
current["agent"].abort_signal.abort("user_stop")
await ws.send_text(json.dumps({
"type": "status",
"message": "Stopping run...",
}))
else:
await ws.send_text(json.dumps({
"type": "status",
"message": "No run is currently active.",
}))

elif msg.get("type") == "ping":
await ws.send_text(json.dumps({"type": "pong"}))

except WebSocketDisconnect:
current = active_runs.get(ws_id)
if current and current.get("agent") is not None:
current["agent"].abort_signal.abort("websocket_disconnect")
active_runs.pop(ws_id, None)
connections.remove(ws)
log.info(f"WebSocket disconnected. {len(connections)} active.")

Expand Down Expand Up @@ -222,6 +250,26 @@ async def handle_command(ws: WebSocket, command: str):
"iterations": 0,
}))

elif parts[1] in {"delete", "del"} and len(parts) == 3:
name = parts[2]
proj_dir = workspace / "deliverables" / name
if not proj_dir.exists():
await ws.send_text(json.dumps({
"type": "error",
"message": f"Project '{name}' not found. Use /project to list.",
}))
return

import shutil
shutil.rmtree(proj_dir)
if _active_project == name:
_active_project = None
await ws.send_text(json.dumps({
"type": "complete",
"result": f"Deleted project: {name}",
"iterations": 0,
}))

else:
# Switch to project
name = parts[1]
Expand Down Expand Up @@ -267,7 +315,8 @@ async def handle_command(ws: WebSocket, command: str):
" /project list projects\n"
" /project <name> switch to project (loads tsunami.md)\n"
" /project new <name> create new project\n"
" /serve [port] serve active project on localhost\n"
" /project del <name> delete a project\n"
" /serve [port] serve active project on localhost using the given port\n"
" /help this message\n"
" exit quit\n"
"\nAnything else goes to the agent."
Expand All @@ -286,6 +335,10 @@ async def run_agent_with_streaming(ws: WebSocket, task: str):
"""Run the agent loop, streaming each iteration to the WebSocket."""
cfg = get_config()
agent = Agent(cfg)
ws_id = id(ws)
current = active_runs.get(ws_id)
if current is not None:
current["agent"] = agent

# Inject active project context
if _active_project:
Expand Down Expand Up @@ -353,6 +406,8 @@ async def streaming_step(_watcher_depth=0):
"message": str(e),
"iteration": agent.state.iteration,
}))
finally:
active_runs.pop(ws_id, None)


def start_server(host: str = "0.0.0.0", port: int = 3000):
Expand Down
25 changes: 25 additions & 0 deletions tsunami/tools/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@
from .base import BaseTool, ToolResult


def _normalize_workspace_like_path(path: str, workspace_dir: str) -> str:
"""Rewrite common host/Docker workspace path variants to the configured workspace."""
normalized = path.replace("\\", "/")
workspace = Path(workspace_dir).resolve()
repo_root = workspace.parent

variants = {
"/app/workspace": str(workspace),
"/workspace/tsunami/workspace": str(workspace),
"/workspace/tsunami/deliverables": str(workspace / "deliverables"),
"/workspace/deliverables": str(workspace / "deliverables"),
"/workspace": str(workspace),
str(repo_root / "deliverables"): str(workspace / "deliverables"),
}

for src, dst in sorted(variants.items(), key=lambda item: len(item[0]), reverse=True):
if normalized == src:
return dst
if normalized.startswith(src + "/"):
return dst + normalized[len(src):]

return normalized


def _is_safe_write(p: Path, workspace_dir: str) -> str | None:
"""Check if a write path is safe. Returns error message or None if OK."""
resolved = str(p.resolve())
Expand Down Expand Up @@ -62,6 +86,7 @@ def _resolve_path(path: str, workspace_dir: str) -> Path:
- deliverables/x/file.tsx
- /absolute/path/to/file.tsx
"""
path = _normalize_workspace_like_path(path, workspace_dir)
p = Path(path)

# Already absolute — use as-is
Expand Down
10 changes: 2 additions & 8 deletions tsunami/tools/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from pathlib import Path

from .base import BaseTool, ToolResult
from .filesystem import _resolve_path

log = logging.getLogger("tsunami.generate")

Expand Down Expand Up @@ -45,14 +46,7 @@ def parameters_schema(self) -> dict:

async def execute(self, prompt: str, save_path: str, width: int = 1024,
height: int = 1024, style: str = "photo", **kw) -> ToolResult:
# Resolve path — always within workspace, strip leading /workspace
clean = save_path.lstrip("/")
# Strip "workspace/" prefix if the model sends absolute-looking paths
for prefix in ["workspace/", "app/workspace/"]:
if clean.startswith(prefix):
clean = clean[len(prefix):]
break
p = (Path(self.config.workspace_dir) / clean).resolve()
p = _resolve_path(save_path, self.config.workspace_dir)
p.parent.mkdir(parents=True, exist_ok=True)

# SD-Turbo in-process first, placeholder fallback
Expand Down
11 changes: 9 additions & 2 deletions tsunami/tools/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pathlib import Path

from .base import BaseTool, ToolResult
from .filesystem import _resolve_path


class MatchGlob(BaseTool):
Expand All @@ -31,9 +32,11 @@ def parameters_schema(self) -> dict:

async def execute(self, pattern: str, directory: str = ".", limit: int = 50, **kw) -> ToolResult:
try:
root = Path(directory).expanduser().resolve()
root = _resolve_path(directory, self.config.workspace_dir)
if not root.exists():
return ToolResult(f"Directory not found: {directory}", is_error=True)
if not root.is_dir():
return ToolResult(f"Not a directory: {directory}", is_error=True)

matches = sorted(root.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
results = [str(m.relative_to(root)) for m in matches[:limit]]
Expand Down Expand Up @@ -74,7 +77,11 @@ def parameters_schema(self) -> dict:
async def execute(self, pattern: str, directory: str = ".", file_pattern: str = "",
limit: int = 30, **kw) -> ToolResult:
try:
root = Path(directory).expanduser().resolve()
root = _resolve_path(directory, self.config.workspace_dir)
if not root.exists():
return ToolResult(f"Directory not found: {directory}", is_error=True)
if not root.is_dir():
return ToolResult(f"Not a directory: {directory}", is_error=True)

if shutil.which("grep"):
cmd = ["grep", "-rn", "--include", file_pattern or "*", "-E", pattern, str(root)]
Expand Down
2 changes: 1 addition & 1 deletion tsunami/tools/project_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ async def execute(self, name: str, dependencies: list = None, **kw) -> ToolResul
f"Extra deps: {dep_list}\n"
f"Dev server: {url or 'run npx vite --port 9876'}\n\n"
f"src/App.tsx is a stub — replace it with your app.\n"
f"After all files: shell_exec 'cd {project_dir} && npx vite build'"
f"After all files: shell_exec command='npx vite build' workdir='deliverables/{name}'"
f"{readme_content}"
)

Expand Down
14 changes: 8 additions & 6 deletions tsunami/tools/python_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import sys
import traceback
from contextlib import redirect_stdout, redirect_stderr
from pathlib import Path

from .base import BaseTool, ToolResult

Expand Down Expand Up @@ -59,7 +60,6 @@ async def execute(self, code: str = "", **kwargs) -> ToolResult:
# Inject useful defaults into namespace (persistent across calls)
if "os" not in _namespace:
import os, json, csv, re, math, datetime, collections
from pathlib import Path

_namespace["os"] = os
_namespace["json"] = json
Expand All @@ -71,14 +71,16 @@ async def execute(self, code: str = "", **kwargs) -> ToolResult:
_namespace["Path"] = Path
_namespace["__builtins__"] = __builtins__

# Set working directory to project root
ark_dir = str(Path(__file__).parent.parent.parent)
try:
import os

workspace_dir = str(Path(self.config.workspace_dir).resolve())
ark_dir = str(Path(workspace_dir).parent.resolve())
os.chdir(ark_dir)
_namespace["ARK_DIR"] = ark_dir
_namespace["WORKSPACE"] = os.path.join(ark_dir, "workspace")
_namespace["DELIVERABLES"] = os.path.join(ark_dir, "workspace", "deliverables")
_namespace["WORKSPACE"] = workspace_dir
_namespace["DELIVERABLES"] = str(Path(workspace_dir) / "deliverables")

try:
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
# Use exec for statements, eval for expressions
try:
Expand Down
19 changes: 7 additions & 12 deletions tsunami/tools/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def _check_destructive(command: str) -> str | None:
import signal

from .base import BaseTool, ToolResult
from .filesystem import _normalize_workspace_like_path, _resolve_path

log = logging.getLogger("tsunami.shell")

Expand Down Expand Up @@ -94,6 +95,8 @@ def parameters_schema(self) -> dict:
}

async def execute(self, command: str, timeout: int = 3600, workdir: str = "", **kw) -> ToolResult:
command = _normalize_workspace_like_path(command, self.config.workspace_dir)

# Destructive command detection
import re
warning = _check_destructive(command)
Expand All @@ -112,20 +115,12 @@ async def execute(self, command: str, timeout: int = 3600, workdir: str = "", **
log.warning(f"Bash security warnings for '{command[:80]}': {sec_warnings}")

try:
# Resolve workdir — default to the ark directory
import os
# Resolve workdir through the shared workspace-aware path normalizer
cwd = None
if workdir:
expanded = os.path.expanduser(workdir)
if os.path.isdir(expanded):
cwd = expanded
else:
# Try relative to ark dir
ark_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
candidate = os.path.join(ark_dir, workdir)
if os.path.isdir(candidate):
cwd = candidate
# else: let it use default cwd
resolved_workdir = _resolve_path(workdir, self.config.workspace_dir)
if resolved_workdir.is_dir():
cwd = str(resolved_workdir)

proc = await asyncio.create_subprocess_shell(
command,
Expand Down
14 changes: 6 additions & 8 deletions tsunami/tools/swell_analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pathlib import Path

from .base import BaseTool, ToolResult
from .filesystem import _resolve_path

log = logging.getLogger("tsunami.swell_analyze")

Expand Down Expand Up @@ -46,15 +47,12 @@ async def execute(self, directory: str = "", question: str = "",
if not directory or not question:
return ToolResult("directory and question required", is_error=True)

# Resolve directory
root = Path(directory).expanduser().resolve()
if not root.exists():
stripped = directory.lstrip("/")
root = Path(self.config.workspace_dir).parent / stripped
if not root.exists():
root = Path(self.config.workspace_dir).parent / directory.replace("/workspace/", "workspace/")
# Resolve directory through the shared workspace-aware resolver
root = _resolve_path(directory, self.config.workspace_dir)
if not root.exists():
return ToolResult(f"Directory not found: {directory}", is_error=True)
if not root.is_dir():
return ToolResult(f"Not a directory: {directory}", is_error=True)

files = sorted(root.glob(pattern))
if not files:
Expand Down Expand Up @@ -93,7 +91,7 @@ async def execute(self, directory: str = "", question: str = "",
# Save results to disk
if not output_path:
output_path = str(root / "_swarm_results.txt")
out = Path(output_path)
out = _resolve_path(output_path, self.config.workspace_dir)
out.parent.mkdir(parents=True, exist_ok=True)
with open(out, "w") as f:
for r in results:
Expand Down