diff --git a/cli/app.jsx b/cli/app.jsx index 9c52c27..b16c26b 100644 --- a/cli/app.jsx +++ b/cli/app.jsx @@ -1,5 +1,5 @@ #!/usr/bin/env node -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { render, Box, Text, useInput, useApp, useStdout } from 'ink'; import TextInput from 'ink-text-input'; import Spinner from 'ink-spinner'; @@ -11,6 +11,7 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ARK_DIR = path.resolve(__dirname, '..'); +const CLI_HISTORY_PATH = path.join(ARK_DIR, 'workspace', '.history', 'cli_prompt_history.json'); // ── Banner: white froth → deep blue water ── const BANNER_LINES = [ @@ -29,6 +30,197 @@ const TOOL_LABELS = { plan_advance: 'Advance plan', }; +const COMMAND_SPECS = [ + { cmd: '/project', desc: 'list · open with /project · new · del' }, + { cmd: '/serve', desc: 'serve active project on localhost[:port]' }, + { cmd: '/stop', desc: 'stop the active run' }, + { cmd: '/attach', desc: 'attach a file or image' }, + { cmd: '/unattach', desc: 'remove an attached file' }, + { cmd: '/help', desc: 'show all commands' }, +]; + +function safeListProjects(deliverablesDir) { + try { + return fs.readdirSync(deliverablesDir).filter((d) => { + try { + return fs.statSync(path.join(deliverablesDir, d)).isDirectory() && !d.startsWith('.'); + } catch { + return false; + } + }).sort(); + } catch { + return []; + } +} + +function expandHome(inputPath) { + if (!inputPath) return inputPath; + if (inputPath === '~') return process.env.HOME || inputPath; + if (inputPath.startsWith('~/')) return path.join(process.env.HOME || '~', inputPath.slice(2)); + return inputPath; +} + +function longestCommonPrefix(values) { + if (!values.length) return ''; + let prefix = values[0]; + for (const value of values.slice(1)) { + while (!value.startsWith(prefix) && prefix) { + prefix = prefix.slice(0, -1); + } + if (!prefix) break; + } + return prefix; +} + +function buildPathSuggestions(rawInput) { + const typed = rawInput ?? ''; + const homeDir = process.env.HOME || ''; + const usingHome = typed === '~' || typed.startsWith('~/'); + const expanded = expandHome(typed); + const hasSlash = expanded.includes('/'); + const typedEndsWithSlash = typed.endsWith('/') || typed.endsWith(path.sep); + const endsWithSlash = typedEndsWithSlash || expanded.endsWith(path.sep) || expanded.endsWith('/'); + let baseDir; + let fragment; + + if (typed === '~' && homeDir) { + baseDir = homeDir; + fragment = ''; + } else { + baseDir = endsWithSlash + ? expanded || '.' + : (hasSlash ? path.dirname(expanded) : '.'); + fragment = endsWithSlash ? '' : (hasSlash ? path.basename(expanded) : expanded); + } + const resolvedBase = path.resolve(process.cwd(), baseDir === '.' ? '' : baseDir); + + let entries = []; + try { + entries = fs.readdirSync(resolvedBase, { withFileTypes: true }); + } catch { + return []; + } + + let prefix = ''; + if (usingHome) { + if (typed === '~') { + prefix = '~/'; + } else { + prefix = typed.slice(0, typed.lastIndexOf('/') + 1); + } + } else if (typed.includes('/')) { + prefix = typed.slice(0, typed.lastIndexOf('/') + 1); + } + + return entries + .filter((entry) => entry.name.toLowerCase().startsWith(fragment.toLowerCase())) + .sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; + return a.name.localeCompare(b.name); + }) + .slice(0, 12) + .map((entry) => { + const suffix = entry.isDirectory() ? '/' : ''; + let replacement = `${prefix}${entry.name}${suffix}`; + if (usingHome && typed === '~' && homeDir) { + replacement = `~/${entry.name}${suffix}`; + } + return { + value: replacement, + label: replacement || './', + desc: entry.isDirectory() ? 'folder' : 'file', + }; + }); +} + +function buildUnattachSuggestions(input, attachedFiles) { + const query = input.trim().toLowerCase(); + const suggestions = attachedFiles.map((filePath, index) => { + const display = path.basename(filePath); + return { + value: `/unattach ${display}`, + label: `/unattach ${display}`, + desc: `attached #${index + 1}`, + }; + }).filter((entry) => !query || entry.value.toLowerCase().startsWith(`/unattach ${query}`) || entry.label.toLowerCase().includes(query)); + + if ('all'.startsWith(query)) { + suggestions.unshift({ + value: '/unattach all', + label: '/unattach all', + desc: 'remove every attached file', + }); + } + + return suggestions; +} + +function buildSlashSuggestions(input, deliverablesDir, activeProject, attachedFiles) { + if (!input.startsWith('/')) return []; + + const projects = safeListProjects(deliverablesDir); + const hasTrailingSpace = /\s$/.test(input); + const trimmed = input.trim(); + const parts = trimmed ? trimmed.split(/\s+/) : []; + const cmd = parts[0] || ''; + + if (!trimmed.includes(' ')) { + return COMMAND_SPECS + .filter((spec) => spec.cmd.startsWith(trimmed || '/')) + .map((spec) => ({ + value: spec.cmd, + label: spec.cmd, + desc: spec.desc, + })); + } + + if (cmd === '/project') { + const arg = hasTrailingSpace ? '' : (parts[1] || ''); + if (parts.length <= 2 && (parts[1] !== 'new' || hasTrailingSpace)) { + const options = [ + { value: '/project list', label: '/project list', desc: 'list projects' }, + { value: '/project new ', label: '/project new ', desc: 'create a new project' }, + { value: '/project del ', label: '/project del ', desc: 'delete a project' }, + ...projects.map((name) => ({ + value: `/project ${name}`, + label: `/project ${name}${name === activeProject ? ' ← active' : ''}`, + desc: 'open project', + })), + ]; + return options.filter((option) => option.value.startsWith(`/project ${arg}`)); + } + return []; + } + + if (cmd === '/attach') { + const rawPath = input.slice('/attach'.length).trimStart(); + return buildPathSuggestions(rawPath).map((entry) => ({ + ...entry, + value: `/attach ${entry.value}`, + label: `/attach ${entry.label}`, + })); + } + + if (cmd === '/unattach') { + const rawTarget = input.slice('/unattach'.length).trimStart(); + return buildUnattachSuggestions(rawTarget, attachedFiles); + } + + if (cmd === '/serve') { + const ports = ['8080', '9876', '4173']; + const arg = hasTrailingSpace ? '' : (parts[1] || ''); + return ports + .filter((port) => port.startsWith(arg)) + .map((port) => ({ + value: `/serve ${port}`, + label: `/serve ${port}`, + desc: port === '8080' ? 'static preview' : 'common dev port', + })); + } + + return []; +} + function toolLabel(name, args) { const base = TOOL_LABELS[name] || name.replace(/_/g, ' '); const detail = args?.command || args?.path || args?.query || args?.pattern || args?.url || ''; @@ -49,6 +241,69 @@ function humanSize(bytes) { return `${bytes.toFixed(1)}TB`; } +function loadCliHistory() { + try { + if (!fs.existsSync(CLI_HISTORY_PATH)) return []; + const raw = JSON.parse(fs.readFileSync(CLI_HISTORY_PATH, 'utf-8')); + if (!Array.isArray(raw)) return []; + return raw.filter((entry) => typeof entry === 'string' && entry.trim().length > 0).slice(-200); + } catch { + return []; + } +} + +function saveCliHistory(entries) { + try { + fs.mkdirSync(path.dirname(CLI_HISTORY_PATH), { recursive: true }); + fs.writeFileSync(CLI_HISTORY_PATH, JSON.stringify(entries.slice(-200), null, 2)); + } catch {} +} + +function previewText(text, maxLen = 320) { + if (!text) return ''; + const normalized = String(text) + .replace(/\r\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + if (normalized.length <= maxLen) return normalized; + return `${normalized.slice(0, maxLen - 3)}...`; +} + +function traceLabelForTool(name, args) { + const base = TOOL_LABELS[name] || name.replace(/_/g, ' '); + const detail = args?.command || args?.path || args?.query || args?.pattern || args?.url || ''; + return detail ? `${base}(${String(detail).slice(0, 110)})` : base; +} + +function traceLineForEvent(evt, iteration) { + if (evt.tool && !evt.tool.startsWith('message_')) { + return { + kind: 'call', + iteration, + text: traceLabelForTool(evt.tool, evt.args || {}), + }; + } + + if (evt.role === 'tool_result') { + const content = previewText(evt.content || '', 420); + return { + kind: content.includes('ERROR:') ? 'error' : 'result', + iteration, + text: content, + }; + } + + if (evt.tool === 'message_info') { + return { + kind: 'info', + iteration, + text: previewText(evt.args?.text || '', 260), + }; + } + + return null; +} + // ── Main App ── function App({ serverUrl, singleTask }) { const { exit } = useApp(); @@ -57,13 +312,28 @@ function App({ serverUrl, singleTask }) { const [input, setInput] = useState(''); const [messages, setMessages] = useState([]); + const [traceEntries, setTraceEntries] = useState([]); const [running, setRunning] = useState(false); const [connected, setConnected] = useState(false); const [iteration, setIteration] = useState(0); const [startTime, setStartTime] = useState(null); const [currentAction, setCurrentAction] = useState(null); + const [showTrace, setShowTrace] = useState(false); const [attachedFiles, setAttachedFiles] = useState([]); + const [suggestionIndex, setSuggestionIndex] = useState(0); + const [inputVersion, setInputVersion] = useState(0); + const [history, setHistory] = useState(() => loadCliHistory()); + const [historyIndex, setHistoryIndex] = useState(null); + const [historyDraft, setHistoryDraft] = useState(''); const wsRef = useRef(null); + const inputRef = useRef(''); + const runningRef = useRef(false); + const deliverablesDir = path.resolve(ARK_DIR, 'workspace/deliverables'); + + useEffect(() => { + inputRef.current = input; + runningRef.current = running; + }, [input, running]); useEffect(() => { const ws = new WebSocket(serverUrl); @@ -79,11 +349,30 @@ function App({ serverUrl, singleTask }) { setIteration(0); setStartTime(Date.now()); setCurrentAction(null); + setTraceEntries([{ + kind: 'status', + iteration: 0, + text: `run started: ${previewText(msg.task || '', 180)}`, + }]); + } + + if (msg.type === 'status') { + setMessages(prev => [...prev, { type: 'result', text: msg.message }]); + setTraceEntries(prev => [...prev, { + kind: 'status', + iteration, + text: previewText(msg.message || '', 180), + }]); } if (msg.type === 'step') { setIteration(msg.iteration); + const nextTraceEntries = []; for (const evt of (msg.events || [])) { + const traceLine = traceLineForEvent(evt, msg.iteration); + if (traceLine) { + nextTraceEntries.push(traceLine); + } if (evt.tool && !evt.tool.startsWith('message_')) { const label = toolLabel(evt.tool, evt.args || {}); setCurrentAction(label); @@ -103,11 +392,19 @@ function App({ serverUrl, singleTask }) { } } } + if (nextTraceEntries.length > 0) { + setTraceEntries(prev => [...prev, ...nextTraceEntries].slice(-160)); + } } if (msg.type === 'complete') { setRunning(false); setCurrentAction(null); + setTraceEntries(prev => [...prev, { + kind: 'status', + iteration: msg.iterations ?? iteration, + text: `run completed after ${msg.iterations} ${msg.iterations === 1 ? 'iteration' : 'iterations'}`, + }].slice(-160)); if (msg.result) { setMessages(prev => [...prev, { type: 'result', text: msg.result, iters: msg.iterations }]); } @@ -118,6 +415,11 @@ function App({ serverUrl, singleTask }) { setRunning(false); setCurrentAction(null); setMessages(prev => [...prev, { type: 'error', text: msg.message }]); + setTraceEntries(prev => [...prev, { + kind: 'error', + iteration: msg.iteration ?? iteration, + text: previewText(msg.message || '', 220), + }].slice(-160)); } }); @@ -133,6 +435,51 @@ function App({ serverUrl, singleTask }) { // ── Slash commands — client-side, instant, no agent ── const [activeProject, setActiveProject] = useState(null); + const slashSuggestions = useMemo( + () => buildSlashSuggestions(input, deliverablesDir, activeProject, attachedFiles), + [input, deliverablesDir, activeProject, attachedFiles], + ); + const activeSuggestion = slashSuggestions[suggestionIndex] || null; + + const applyInputValue = useCallback((nextValue) => { + setInput(nextValue); + setInputVersion((v) => v + 1); + }, []); + + const pushHistory = useCallback((entry) => { + if (!entry) return; + setHistory((prev) => { + if (prev[prev.length - 1] === entry) return prev; + return [...prev, entry].slice(-200); + }); + setHistoryIndex(null); + setHistoryDraft(''); + }, []); + + useEffect(() => { + saveCliHistory(history); + }, [history]); + + useEffect(() => { + const onSigInt = () => { + if (!runningRef.current && inputRef.current.length > 0) { + setHistoryIndex(null); + setHistoryDraft(''); + applyInputValue(''); + return; + } + exit(); + }; + + process.on('SIGINT', onSigInt); + return () => { + process.off('SIGINT', onSigInt); + }; + }, [applyInputValue, exit]); + + useEffect(() => { + setSuggestionIndex(0); + }, [input]); function handleSlashCommand(text) { const parts = text.split(/\s+/); @@ -140,13 +487,22 @@ function App({ serverUrl, singleTask }) { if (cmd === '/help') { setMessages(prev => [...prev, { type: 'user', text }, { type: 'result', text: - 'Commands:\n /project list projects\n /project switch to project\n /project new create new project\n /serve [port] serve active project\n /help this message\n exit quit\n\nAnything else goes to the agent.' + 'Commands:\n /project list projects\n /project open a project\n /project new create a new project\n /project del delete a project\n /serve [port] serve active project on localhost using the given port\n /stop stop the active run\n /attach attach a file or image\n /unattach remove an attached file\n /help this message\n exit quit\n\nAnything else goes to the agent.' }]); return true; } + if (cmd === '/stop') { + if (running) { + wsRef.current?.send(JSON.stringify({ type: 'abort' })); + } else { + setMessages(prev => [...prev, { type: 'user', text }, { type: 'result', text: 'No run is currently active.' }]); + } + return true; + } + if (cmd === '/project') { - const delDir = path.resolve(ARK_DIR, 'workspace/deliverables'); + const delDir = deliverablesDir; // List projects: /project or /project list if (parts.length === 1 || parts[1] === 'list') { @@ -177,6 +533,21 @@ function App({ serverUrl, singleTask }) { return true; } + if ((parts[1] === 'delete' || parts[1] === 'del') && parts[2]) { + const name = parts[2]; + const projDir = path.join(delDir, name); + if (!fs.existsSync(projDir) || !fs.statSync(projDir).isDirectory()) { + setMessages(prev => [...prev, { type: 'user', text }, { type: 'error', text: `Project '${name}' not found` }]); + return true; + } + fs.rmSync(projDir, { recursive: true, force: true }); + if (activeProject === name) { + setActiveProject(null); + } + setMessages(prev => [...prev, { type: 'user', text }, { type: 'result', text: `Deleted project: ${name}` }]); + return true; + } + // Switch project const name = parts[1]; const projDir = path.join(delDir, name); @@ -208,9 +579,10 @@ function App({ serverUrl, singleTask }) { // If path provided, use it directly if (parts[1]) { const filePath = parts.slice(1).join(' '); - if (fs.existsSync(filePath)) { - setAttachedFiles(prev => [...prev, filePath]); - setMessages(prev => [...prev, { type: 'result', text: `Attached: ${path.basename(filePath)}` }]); + const resolvedPath = expandHome(filePath); + if (fs.existsSync(resolvedPath)) { + setAttachedFiles(prev => [...prev, resolvedPath]); + setMessages(prev => [...prev, { type: 'result', text: `Attached: ${path.basename(resolvedPath)}` }]); } else { setMessages(prev => [...prev, { type: 'error', text: `File not found: ${filePath}` }]); } @@ -234,6 +606,36 @@ function App({ serverUrl, singleTask }) { return true; } + if (cmd === '/unattach') { + const target = parts.slice(1).join(' ').trim(); + if (!target) { + setMessages(prev => [...prev, { type: 'user', text }, { type: 'result', text: attachedFiles.length ? `Attached files:\n${attachedFiles.map((filePath, index) => ` ${index + 1}. ${path.basename(filePath)}`).join('\n')}` : 'No files are currently attached.' }]); + return true; + } + + if (target.toLowerCase() === 'all') { + setAttachedFiles([]); + setMessages(prev => [...prev, { type: 'user', text }, { type: 'result', text: 'Removed all attached files.' }]); + return true; + } + + const normalizedTarget = target.toLowerCase(); + const matchIndex = attachedFiles.findIndex((filePath, index) => { + const basename = path.basename(filePath).toLowerCase(); + return basename === normalizedTarget || String(index + 1) === target || filePath.toLowerCase() === normalizedTarget; + }); + + if (matchIndex === -1) { + setMessages(prev => [...prev, { type: 'user', text }, { type: 'error', text: `Attached file not found: ${target}` }]); + return true; + } + + const removed = attachedFiles[matchIndex]; + setAttachedFiles(prev => prev.filter((_, index) => index !== matchIndex)); + setMessages(prev => [...prev, { type: 'user', text }, { type: 'result', text: `Unattached: ${path.basename(removed)}` }]); + return true; + } + // Unknown slash command setMessages(prev => [...prev, { type: 'user', text }, { type: 'error', text: `Unknown command: ${cmd}. Type /help` }]); return true; @@ -243,6 +645,14 @@ function App({ serverUrl, singleTask }) { const text = value.trim(); if (!text && attachedFiles.length === 0) return; + if (!running && !singleTask && activeSuggestion && value.startsWith('/')) { + const normalizedInput = value.trimEnd(); + if (activeSuggestion.value !== normalizedInput) { + applyInputValue(activeSuggestion.value); + return; + } + } + if (['exit', 'quit'].includes(text.toLowerCase())) { exit(); return; @@ -250,12 +660,18 @@ function App({ serverUrl, singleTask }) { // Slash commands — instant, no agent if (text.startsWith('/')) { + pushHistory(text); setInput(''); + setHistoryIndex(null); + setHistoryDraft(''); handleSlashCommand(text); return; } + pushHistory(text); setInput(''); + setHistoryIndex(null); + setHistoryDraft(''); // Build task with file context let task = text; @@ -280,18 +696,18 @@ function App({ serverUrl, singleTask }) { }]); setAttachedFiles([]); wsRef.current?.send(JSON.stringify({ type: 'run', task })); - }, [exit, attachedFiles]); + }, [exit, attachedFiles, activeSuggestion, running, singleTask, applyInputValue, pushHistory]); - // Key bindings — Ctrl+C does NOT exit, only cancels current action + // Key bindings useInput((ch, key) => { if (key.ctrl && ch === 'c') { - if (running) { - // Cancel current task (TODO: send cancel to backend) - setRunning(false); - setCurrentAction(null); - setMessages(prev => [...prev, { type: 'error', text: 'interrupted' }]); + if (!running && input.length > 0) { + setHistoryIndex(null); + setHistoryDraft(''); + applyInputValue(''); + } else { + exit(); } - // Don't exit — user can keep typing return; } @@ -301,11 +717,78 @@ function App({ serverUrl, singleTask }) { return; } - // /attach command + if (key.escape) { + if (input.length > 0) { + setHistoryIndex(null); + setHistoryDraft(''); + applyInputValue(''); + } else if (running) { + wsRef.current?.send(JSON.stringify({ type: 'abort' })); + } + return; + } + + if (running && !input.length && !key.ctrl && !key.meta && ch === 't') { + setShowTrace((prev) => !prev); + return; + } + + if (!running && !singleTask && slashSuggestions.length > 0) { + if (key.upArrow) { + setSuggestionIndex((prev) => (prev - 1 + slashSuggestions.length) % slashSuggestions.length); + return; + } + + if (key.downArrow) { + setSuggestionIndex((prev) => (prev + 1) % slashSuggestions.length); + return; + } + } + + if (!running && !singleTask && key.tab && slashSuggestions.length > 0) { + const suggestion = slashSuggestions[suggestionIndex] || slashSuggestions[0]; + const common = longestCommonPrefix(slashSuggestions.map((item) => item.value)); + const nextValue = slashSuggestions.length === 1 + ? suggestion.value + : (common.length > input.length ? common : suggestion.value); + applyInputValue(nextValue); + if (slashSuggestions.length > 1 && (common.length <= input.length || nextValue === suggestion.value)) { + setSuggestionIndex((prev) => (prev + 1) % slashSuggestions.length); + } + return; + } + + if (!running && !singleTask && slashSuggestions.length === 0 && history.length > 0) { + if (key.upArrow) { + setHistoryIndex((prev) => { + const nextIndex = prev == null ? history.length - 1 : Math.max(0, prev - 1); + if (prev == null) setHistoryDraft(input); + applyInputValue(history[nextIndex] || ''); + return nextIndex; + }); + return; + } + + if (key.downArrow && historyIndex != null) { + if (historyIndex >= history.length - 1) { + setHistoryIndex(null); + applyInputValue(historyDraft); + } else { + const nextIndex = historyIndex + 1; + setHistoryIndex(nextIndex); + applyInputValue(history[nextIndex] || ''); + } + return; + } + } }, { isActive: !singleTask }); // Handle /commands in input const handleChange = useCallback((value) => { + if (historyIndex != null) { + setHistoryIndex(null); + setHistoryDraft(''); + } // Check for /attach command if (value.endsWith(' ') && value.trim().startsWith('/attach ')) { const filePath = value.trim().slice(8).trim(); @@ -316,7 +799,7 @@ function App({ serverUrl, singleTask }) { } } setInput(value); - }, []); + }, [historyIndex]); return ( @@ -329,10 +812,14 @@ function App({ serverUrl, singleTask }) { Autonomous Execution Agent - {/* Messages */} - {messages.map((msg, i) => ( - - ))} + {/* Messages / Trace */} + {showTrace && running ? ( + + ) : ( + messages.map((msg, i) => ( + + )) + )} {/* Current activity */} {running && currentAction && ( @@ -352,6 +839,7 @@ function App({ serverUrl, singleTask }) { {running && startTime && ( ({timeAgo(startTime)} · iteration {iteration}) + · esc stops run · t for {showTrace ? 'normal view' : 'trace tail'} )} @@ -374,19 +862,19 @@ function App({ serverUrl, singleTask }) { )} {/* Command suggestions */} - {!running && !singleTask && input.startsWith('/') && input.length < 15 && ( + {!running && !singleTask && input.startsWith('/') && slashSuggestions.length > 0 && ( - {[ - { cmd: '/project', desc: 'list / switch / create projects' }, - { cmd: '/serve', desc: 'host active project on localhost' }, - { cmd: '/attach', desc: 'attach a file or image' }, - { cmd: '/help', desc: 'show all commands' }, - ].filter(c => c.cmd.startsWith(input)).map((c, i) => ( + {slashSuggestions.map((suggestion, i) => ( - {c.cmd} - {c.desc} + + {i === suggestionIndex ? '› ' : ' '}{suggestion.label} + + {suggestion.desc} ))} + + ↑/↓ to select · tab or enter to complete + )} @@ -401,6 +889,7 @@ function App({ serverUrl, singleTask }) { width={cols - 2} > {!input.startsWith('/') && ( - type / for commands · exit to quit + type / for commands · ↑/↓ recall history · esc clears or stops · ctrl-c exits )} @@ -488,6 +977,38 @@ function MessageView({ msg, cols }) { return null; } +function TraceTailView({ entries, cols }) { + const visible = entries.slice(-Math.max(14, Math.min(36, cols ? Math.floor(cols / 2) : 20))); + + return ( + + Trace Tail + {visible.length === 0 ? ( + waiting for agent activity... + ) : ( + visible.map((entry, index) => ( + + [{String(entry.iteration).padStart(3, ' ')}] + + + {entry.text} + + + )) + )} + + ); +} + // ── CLI entry ── const args = process.argv.slice(2); let task = null; @@ -508,4 +1029,4 @@ if (task && attachFiles.length > 0) { } const serverUrl = `ws://localhost:${wsPort}/ws`; -render(); +render(, { exitOnCtrlC: false }); diff --git a/tsunami/server.py b/tsunami/server.py index 21c8661..c0185c1 100644 --- a/tsunami/server.py +++ b/tsunami/server.py @@ -31,6 +31,7 @@ # Active WebSocket connections connections: list[WebSocket] = [] +active_runs: dict[int, dict[str, Any]] = {} # Server state _config: TsunamiConfig | None = None @@ -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: @@ -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.") @@ -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] @@ -267,7 +315,8 @@ async def handle_command(ws: WebSocket, command: str): " /project list projects\n" " /project switch to project (loads tsunami.md)\n" " /project new create new project\n" - " /serve [port] serve active project on localhost\n" + " /project del 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." @@ -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: @@ -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): diff --git a/tsunami/tools/filesystem.py b/tsunami/tools/filesystem.py index 8f01f3f..1ecfbc6 100644 --- a/tsunami/tools/filesystem.py +++ b/tsunami/tools/filesystem.py @@ -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()) @@ -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 diff --git a/tsunami/tools/generate.py b/tsunami/tools/generate.py index 7a49c1c..e0ac72e 100644 --- a/tsunami/tools/generate.py +++ b/tsunami/tools/generate.py @@ -15,6 +15,7 @@ from pathlib import Path from .base import BaseTool, ToolResult +from .filesystem import _resolve_path log = logging.getLogger("tsunami.generate") @@ -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 diff --git a/tsunami/tools/match.py b/tsunami/tools/match.py index b6ec53b..d6afa5c 100644 --- a/tsunami/tools/match.py +++ b/tsunami/tools/match.py @@ -11,6 +11,7 @@ from pathlib import Path from .base import BaseTool, ToolResult +from .filesystem import _resolve_path class MatchGlob(BaseTool): @@ -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]] @@ -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)] diff --git a/tsunami/tools/project_init.py b/tsunami/tools/project_init.py index 66056a2..74bfca4 100644 --- a/tsunami/tools/project_init.py +++ b/tsunami/tools/project_init.py @@ -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}" ) diff --git a/tsunami/tools/python_exec.py b/tsunami/tools/python_exec.py index 344d918..f0889f5 100644 --- a/tsunami/tools/python_exec.py +++ b/tsunami/tools/python_exec.py @@ -15,6 +15,7 @@ import sys import traceback from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path from .base import BaseTool, ToolResult @@ -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 @@ -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: diff --git a/tsunami/tools/shell.py b/tsunami/tools/shell.py index 251c206..9dd1c1e 100644 --- a/tsunami/tools/shell.py +++ b/tsunami/tools/shell.py @@ -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") @@ -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) @@ -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, diff --git a/tsunami/tools/swell_analyze.py b/tsunami/tools/swell_analyze.py index 7a07b83..9f9b655 100644 --- a/tsunami/tools/swell_analyze.py +++ b/tsunami/tools/swell_analyze.py @@ -13,6 +13,7 @@ from pathlib import Path from .base import BaseTool, ToolResult +from .filesystem import _resolve_path log = logging.getLogger("tsunami.swell_analyze") @@ -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: @@ -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: