diff --git a/WARP.md b/WARP.md new file mode 120000 index 00000000000..681311eb9cf --- /dev/null +++ b/WARP.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/apps/cli/package.json b/apps/cli/package.json index e04e0bfd180..c2952e3d6c3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -2,13 +2,15 @@ "name": "@superset/cli", "version": "0.0.0", "license": "MIT", - "bin": "dist/cli.js", + "bin": { + "superset": "dist/cli.js" + }, "type": "module", "engines": { "node": ">=16" }, "scripts": { - "build": "bun build src/cli.tsx --outfile=dist/cli.js --target=node --packages=bundle", + "build": "bun build src/cli.tsx --outfile=dist/cli.js --target=node", "dev": "tsc --watch", "start": "bun src/server.ts", "typecheck": "tsc --noEmit", @@ -31,6 +33,7 @@ "ink": "^6.5.0", "ink-select-input": "^6.2.0", "ink-table": "^3.1.0", + "ink-text-input": "^6.0.0", "lowdb": "^7.0.1", "meow": "^11.0.0", "react": "^19.1.1" diff --git a/apps/cli/readme.md b/apps/cli/readme.md index 68d0943c6a7..94085668156 100644 --- a/apps/cli/readme.md +++ b/apps/cli/readme.md @@ -1,13 +1,66 @@ -# CLI +# Superset CLI -To run this, have 2 processes: +CLI for managing environments, workspaces (git worktrees/cloud branches), agents, and changes. Built with Ink + Commander, storage via lowdb JSON. -The first one is the build process: -``` -bun dev -``` +## Quick start (dev) +- Install deps: `bun install` +- Dev watch: `bun dev` (builds to `dist/`) +- Run CLI from source: `bunx ts-node src/cli.tsx --help` + Or from build: `bun run build && bun start …` +- Binary name: `superset` (points to `dist/cli.js`) -The 2nd one runs the actual dev "server" -``` -bun start -``` \ No newline at end of file +## Core concepts +- **Environment**: grouping for workspaces (default env seeded). +- **Workspace**: local or cloud. For local, include `--path`; for cloud, include `--branch`. Tracks `defaultAgents`, `lastUsedAt`, `current workspace` pointer. +- **Worktree**: modelled as a workspace in CLI (one repo path/branch per workspace; dedicated worktree creation helpers are TODO). +- **Agent**: tmux-backed interactive session (Claude/Codex/Cursor). Each agent has a `sessionName`; `agent start` launches tmux sessions, `agent attach`/dashboard Enter attaches; detach with `Ctrl-b d`. +- **Change**: change log per workspace; has file diffs. + +## Common commands +- `superset init` – Wizard to create workspace (local/cloud), set name/path/branch, choose default agents, set current workspace. +- `superset dashboard` – Ink dashboard; shows workspaces/agents, press Enter on an agent to attach to its tmux session; use `q/ESC` to exit. +- `superset workspace list|get|create|delete|use` + - Local: `workspace create local --path ` + - Cloud: `workspace create cloud --branch ` + - `workspace use ` sets current workspace. +- `superset env list|get|create|delete` +- `superset agent start [workspaceId]` – Uses current workspace if omitted; starts default agents if configured. +- `superset agent attach ` – Attach to tmux session. Detach with `Ctrl-b d`. +- `superset agent list|get|stop |stop-all [--workspace ]|delete ` – `stop-all` only stops agents and kills their tmux sessions. +- `superset change list |create ""|delete ` + +## tmux integration +- Requires `tmux` installed and on PATH. +- Sessions are named `agent-` unless overridden in storage. +- Launch commands resolve from agent `launchCommand`, env overrides `SUPERSET_AGENT_LAUNCH_`, or defaults (`claude`, `codex`, `cursor`). + - To customize: `export SUPERSET_AGENT_LAUNCH_CLAUDE="your-custom-command"` (similarly for `CODEX`, `CURSOR`) + - Ensure the command stays alive (doesn't exit immediately) to prevent tmux session failures. +- If a session exists, attach; otherwise create detached then attach. Detach with `Ctrl-b d` to return to the dashboard/CLI; agents continue running. +- `stop/stop-all` issue `tmux kill-session` and mark agent stopped. + +## Storage +- lowdb JSON at `~/.superset/cli/db.json` (default), seeded with a `default` environment and `state.currentWorkspaceId`. +- Can be overridden with `SUPERSET_CLI_DATA_DIR` environment variable. +- Dates are serialized ISO strings; orchestrators backfill defaults and persist missing fields (status, launchCommand, sessionName, timestamps). + +## Security & Configuration Notes + +### Session Names +- Session names are generated internally as `agent-` (6-char UUID prefix). +- Only alphanumeric, hyphen, and underscore characters are allowed in tmux session names. +- If custom session names are added in the future, they will be sanitized to meet tmux requirements. + +### Launch Commands +- Agent launch commands are executed exactly as provided in environment variables or config. +- **Security**: Only use trusted commands. The CLI does not sanitize or escape launch commands. +- **Best practice**: Use binaries on PATH (e.g., `claude`, `codex`) rather than complex shell expressions. +- **Complex shells**: For wrapped commands or environment setup, set `SUPERSET_AGENT_LAUNCH_` to point to a single, well-known entrypoint script: + ```bash + export SUPERSET_AGENT_LAUNCH_CLAUDE="/usr/local/bin/launch-claude.sh" + ``` + Then put your complex logic in that script. +- **Command validation**: Simple commands (1-2 words) are checked for existence on PATH. Complex commands (with quotes, env vars, or multiple arguments) skip preflight validation to avoid false negatives. + +## Tips +- Use `superset` (no args) for a welcome summary and quick commands. +- If attach fails, ensure tmux is installed and the agent has a valid `launchCommand` (set env `SUPERSET_AGENT_LAUNCH_CLAUDE=claude` etc.). diff --git a/apps/cli/src/cli.tsx b/apps/cli/src/cli.tsx index f9bd7c2e02e..1b34816c063 100644 --- a/apps/cli/src/cli.tsx +++ b/apps/cli/src/cli.tsx @@ -3,23 +3,29 @@ import { Command } from "commander"; import { render } from "ink"; import React from "react"; import { + AgentAttach, AgentCreate, AgentDelete, AgentGet, AgentList, + AgentStart, AgentStop, AgentStopAll, ChangeCreate, ChangeDelete, ChangeList, + Dashboard, EnvCreate, EnvDelete, EnvGet, EnvList, + Init, + Panels, WorkspaceCreate, WorkspaceDelete, WorkspaceGet, WorkspaceList, + WorkspaceUse, } from "./commands/index"; import { AgentType, ProcessType } from "./types/process"; import { WorkspaceType } from "./types/workspace"; @@ -33,8 +39,34 @@ program ) .version("0.1.0"); +// Init command +program + .command("init") + .description("Interactive workspace creation wizard") + .action(() => { + render(); + }); + +// Dashboard command +program + .command("dashboard") + .description("Show dashboard with all agents and workspaces") + .action(() => { + render(); + }); + +// Panels command +program + .command("panels") + .description("Show three-panel IDE-style interface") + .action(() => { + render(); + }); + // Environment commands -const env = program.command("env").description("Manage environments"); +const env = program + .command("env") + .description("Manage environments (list, get, create, delete)"); env .command("list") @@ -69,7 +101,9 @@ env }); // Workspace commands -const workspace = program.command("workspace").description("Manage workspaces"); +const workspace = program + .command("workspace") + .description("Manage workspaces (list, get, create, use, delete)"); workspace .command("list") @@ -128,10 +162,20 @@ workspace render( process.exit(0)} />); }); +workspace + .command("use") + .description("Set current workspace (updates lastUsedAt)") + .argument("", "Workspace ID") + .action((id: string) => { + render( process.exit(0)} />); + }); + // Agent/Process commands const agent = program .command("agent") - .description("Manage agents and processes"); + .description( + "Manage agents and processes (start, stop, stop-all, list, delete)", + ); agent .command("list") @@ -154,6 +198,24 @@ agent render( process.exit(0)} />); }); +agent + .command("start") + .description( + "Start agents (uses current workspace if no ID provided, or workspace's default agents)", + ) + .argument("[workspaceId]", "Workspace ID (optional, uses current workspace)") + .action((workspaceId?: string) => { + render(); + }); + +agent + .command("attach") + .description("Attach to an agent's tmux session") + .argument("", "Agent ID or session name (e.g., agent-abc123)") + .action((id: string) => { + render( process.exit(0)} />); + }); + agent .command("create") .description("Create a new agent/process") @@ -208,9 +270,17 @@ agent agent .command("stop-all") - .description("Stop all agents/processes") - .action(() => { - render( process.exit(0)} />); + .description( + "Stop all agents in workspace (kills tmux sessions, does not affect terminals)", + ) + .option("--workspace ", "Workspace ID to stop agents in") + .action((options: { workspace?: string }) => { + render( + process.exit(0)} + />, + ); }); agent @@ -222,7 +292,9 @@ agent }); // Change commands -const change = program.command("change").description("Manage changes"); +const change = program + .command("change") + .description("Manage changes (list, create, delete)"); change .command("list") @@ -260,4 +332,52 @@ change render( process.exit(0)} />); }); +// Default action when no command is provided +program.action(async () => { + console.log("\n👋 Welcome to Superset CLI!\n"); + + // Show current workspace if set + try { + const { getDb } = await import("./lib/db"); + const { WorkspaceOrchestrator } = await import( + "./lib/orchestrators/workspace-orchestrator" + ); + const db = getDb(); + const orchestrator = new WorkspaceOrchestrator(db); + const currentWorkspace = await orchestrator.getCurrent(); + + if (currentWorkspace) { + console.log( + `📁 Current workspace: ${currentWorkspace.name || currentWorkspace.id}`, + ); + if ("path" in currentWorkspace && currentWorkspace.path) { + console.log(` Path: ${currentWorkspace.path}`); + } + if ("branch" in currentWorkspace && currentWorkspace.branch) { + console.log(` Branch: ${currentWorkspace.branch}`); + } + console.log(""); + } else { + console.log( + "💡 No workspace selected. Run 'superset init' to get started!\n", + ); + } + } catch (err) { + // Silently ignore errors (e.g., no database yet) + } + + console.log("Get started with these commands:\n"); + console.log(" superset init Create workspace (wizard)"); + console.log(" superset dashboard Show dashboard overview"); + console.log( + " superset panels Show three-panel IDE interface", + ); + console.log(" superset workspace use Switch to a workspace"); + console.log( + " superset agent start Start agents in current workspace", + ); + console.log("\nFor more information, run: superset --help\n"); + process.exit(0); +}); + program.parse(); diff --git a/apps/cli/src/commands/agent.tsx b/apps/cli/src/commands/agent.tsx index d7a250802bf..33e1bb3e5ca 100644 --- a/apps/cli/src/commands/agent.tsx +++ b/apps/cli/src/commands/agent.tsx @@ -1,10 +1,17 @@ -import { Box, Text } from "ink"; +import { Box, Text, useApp } from "ink"; +import SelectInput from "ink-select-input"; import React from "react"; import Table from "../components/Table"; import { getDb } from "../lib/db"; +import { getDefaultLaunchCommand } from "../lib/launch/config"; import { ProcessOrchestrator } from "../lib/orchestrators/process-orchestrator"; import { WorkspaceOrchestrator } from "../lib/orchestrators/workspace-orchestrator"; -import type { AgentType, Process, ProcessType } from "../types/process"; +import { + AgentType, + type Process, + ProcessStatus, + ProcessType, +} from "../types/process"; // Display type with formatted date strings type FormattedProcess = Omit & { @@ -267,20 +274,60 @@ export function AgentStop({ id, onComplete }: AgentStopProps) { } interface AgentStopAllProps { + workspaceId?: string; onComplete?: () => void; } -export function AgentStopAll({ onComplete }: AgentStopAllProps) { +export function AgentStopAll({ workspaceId, onComplete }: AgentStopAllProps) { const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(true); const [success, setSuccess] = React.useState(false); + const [stoppedCount, setStoppedCount] = React.useState(0); React.useEffect(() => { const stopAll = async () => { try { const db = getDb(); - const orchestrator = new ProcessOrchestrator(db); - await orchestrator.stopAll(); + const processOrchestrator = new ProcessOrchestrator(db); + const workspaceOrchestrator = new WorkspaceOrchestrator(db); + + // Determine which workspace to use + let targetWorkspaceId = workspaceId; + if (!targetWorkspaceId) { + const currentWorkspace = await workspaceOrchestrator.getCurrent(); + if (!currentWorkspace) { + setError( + "No current workspace set. Specify --workspace or run 'superset workspace use '", + ); + setLoading(false); + return; + } + targetWorkspaceId = currentWorkspace.id; + } + + // Get all AGENT processes for the workspace (not terminals) + const processes = await processOrchestrator.list(targetWorkspaceId); + const runningAgents = processes.filter( + (p) => !p.endedAt && p.type === ProcessType.AGENT, + ); + + // Stop each agent + let count = 0; + for (const agent of runningAgents) { + await processOrchestrator.stop(agent.id); + count++; + } + + // Provide feedback if nothing was stopped + if (count === 0) { + setError( + `No running agents found in workspace ${targetWorkspaceId.slice(0, 8)}`, + ); + setLoading(false); + return; + } + + setStoppedCount(count); setSuccess(true); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); @@ -291,10 +338,10 @@ export function AgentStopAll({ onComplete }: AgentStopAllProps) { }; stopAll(); - }, [onComplete]); + }, [workspaceId, onComplete]); if (loading) { - return Stopping all agents/processes...; + return Stopping all agents...; } if (error) { @@ -303,7 +350,14 @@ export function AgentStopAll({ onComplete }: AgentStopAllProps) { if (success) { return ( - ✓ All agents/processes stopped successfully + + + ✓ Stopped {stoppedCount} agent(s)/process(es) successfully + + {workspaceId && ( + Workspace: {workspaceId.slice(0, 8)}... + )} + ); } @@ -360,3 +414,368 @@ export function AgentDelete({ id, onComplete }: AgentDeleteProps) { return null; } + +enum StartStep { + LOADING = "LOADING", + SELECT_AGENTS = "SELECT_AGENTS", + STARTING = "STARTING", + COMPLETE = "COMPLETE", +} + +interface AgentStartProps { + workspaceId?: string; + onComplete?: () => void; +} + +export function AgentStart({ workspaceId, onComplete }: AgentStartProps) { + const { exit } = useApp(); + const [step, setStep] = React.useState(StartStep.LOADING); + const [error, setError] = React.useState(null); + const [workspace, setWorkspace] = React.useState(null); + const [selectedAgents, setSelectedAgents] = React.useState([]); + const [startedAgents, setStartedAgents] = React.useState([]); + const [failures, setFailures] = React.useState< + Array<{ agentType: AgentType; error: string }> + >([]); + + React.useEffect(() => { + const loadWorkspace = async () => { + try { + const db = getDb(); + const workspaceOrchestrator = new WorkspaceOrchestrator(db); + + let ws; + if (workspaceId) { + ws = await workspaceOrchestrator.get(workspaceId); + } else { + ws = await workspaceOrchestrator.getCurrent(); + if (!ws) { + setError( + "No current workspace set. Run 'superset init' or 'superset workspace use ' first.", + ); + return; + } + } + + setWorkspace(ws); + + // If workspace has default agents, use them automatically + if (ws.defaultAgents && ws.defaultAgents.length > 0) { + setSelectedAgents(ws.defaultAgents as AgentType[]); + setStep(StartStep.STARTING); + startAgents(ws, ws.defaultAgents as AgentType[]); + } else { + // No defaults, prompt user to select + setStep(StartStep.SELECT_AGENTS); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } + }; + + loadWorkspace(); + }, [workspaceId]); + + const startAgents = async (ws: any, agents: AgentType[]) => { + try { + const db = getDb(); + const processOrchestrator = new ProcessOrchestrator(db); + const workspaceOrchestrator = new WorkspaceOrchestrator(db); + + const created: Process[] = []; + const failures: Array<{ agentType: AgentType; error: string }> = []; + + for (const agentType of agents) { + // Get default launch command for this agent type + const launchCommand = getDefaultLaunchCommand(agentType); + + // Create the process in IDLE state + const process = await processOrchestrator.create( + ProcessType.AGENT, + ws, + agentType, + ); + + // Set launch command but keep in IDLE state + await processOrchestrator.update(process.id, { + launchCommand, + }); + + // Actually create the tmux session in the background + const agent = process as import("../types/process").Agent; + const { launchAgent } = await import("../lib/launch/run"); + const result = await launchAgent(agent, { attach: false }); + + if (!result.success) { + // If session creation fails, mark agent as ERROR + await processOrchestrator.update(process.id, { + status: ProcessStatus.ERROR, + endedAt: new Date(), + }); + failures.push({ + agentType, + error: result.error || "Unknown error", + }); + } else { + // Only mark as RUNNING if session creation succeeded + await processOrchestrator.update(process.id, { + status: ProcessStatus.RUNNING, + endedAt: undefined, // Clear endedAt since session is alive + }); + created.push(process); + } + } + + // Update workspace lastUsedAt and set as current if not already + await workspaceOrchestrator.use(ws.id); + + setStartedAgents(created); + setFailures(failures); + + // Only advance to COMPLETE if at least one agent succeeded + if (created.length > 0) { + setStep(StartStep.COMPLETE); + + // Auto-exit after showing success (skip if there were partial failures) + if (failures.length === 0) { + setTimeout(() => { + exit(); + }, 2000); + } + } else { + // All agents failed - show error + const errorMsg = failures + .map((f) => `${f.agentType}: ${f.error}`) + .join("\n"); + setError( + `Failed to start all agents:\n${errorMsg}\n\nPlease check that tmux is installed and the agent commands are available.`, + ); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } + }; + + const handleAgentSelect = (item: { value: string }) => { + if (item.value === "done") { + if (selectedAgents.length === 0) { + setError("Please select at least one agent to start"); + return; + } + setStep(StartStep.STARTING); + startAgents(workspace, selectedAgents); + } else if (item.value === "cancel") { + exit(); + } else { + // Toggle agent selection + setSelectedAgents((current) => + current.includes(item.value as AgentType) + ? current.filter((a) => a !== item.value) + : [...current, item.value as AgentType], + ); + } + }; + + if (error) { + return Error: {error}; + } + + if (step === StartStep.LOADING) { + return Loading workspace...; + } + + if (step === StartStep.SELECT_AGENTS) { + const agentItems = [ + ...Object.values(AgentType).map((type) => ({ + label: `${selectedAgents.includes(type) ? "✓" : "○"} ${type}`, + value: type, + })), + { label: "→ Start selected agents", value: "done" }, + { label: "→ Cancel", value: "cancel" }, + ]; + + return ( + + + Start Agents + + + Workspace: {workspace?.name || workspace?.id} + + + + Select which agents to start (use arrow keys, Enter to toggle): + + + {selectedAgents.length > 0 && ( + + Selected: {selectedAgents.join(", ")} + + )} + + + + + ); + } + + if (step === StartStep.STARTING) { + return ( + + Starting {selectedAgents.length} agent(s)... + + ); + } + + if (step === StartStep.COMPLETE) { + const totalAttempted = selectedAgents.length; + const successCount = startedAgents.length; + const failureCount = failures.length; + + return ( + + 0 ? "yellow" : "green"}> + {failureCount > 0 ? "⚠" : "✓"} Started {successCount}/{totalAttempted}{" "} + agent(s) successfully + {failureCount > 0 ? ` (${failureCount} failed)` : "!"} + + + Workspace: {workspace?.name || workspace?.id} + {successCount > 0 && ( + + Success: {startedAgents.map((a) => (a as any).agentType).join(", ")} + + )} + + {failureCount > 0 && ( + + Failed agents: + {failures.map((f, i) => ( + + • {f.agentType}: {f.error} + + ))} + + )} + + + Run superset dashboard to view agent status + + + {failureCount > 0 && ( + + Press Ctrl+C to exit + + )} + + ); + } + + return null; +} + +interface AgentAttachProps { + id: string; + onComplete?: () => void; +} + +export function AgentAttach({ id, onComplete: _onComplete }: AgentAttachProps) { + const { exit } = useApp(); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + const attachToSession = async () => { + try { + const db = getDb(); + const orchestrator = new ProcessOrchestrator(db); + + // Try to get by ID first + let process; + try { + process = await orchestrator.get(id); + } catch { + // If not found by ID, try to find by sessionName + const allProcesses = await orchestrator.list(); + const foundBySession = allProcesses.find( + (p) => + p.type === ProcessType.AGENT && + "sessionName" in p && + (p as import("../types/process").Agent).sessionName === id, + ); + + if (!foundBySession) { + setError( + `Agent not found. Use the full agent ID or sessionName.\nRun 'superset agent list' to see available agents.`, + ); + setLoading(false); + return; + } + + process = foundBySession; + } + + // Ensure it's an agent + if (process.type !== ProcessType.AGENT) { + setError("Cannot attach: process is not an agent"); + setLoading(false); + return; + } + + const agent = process as import("../types/process").Agent; + + // Import and call launchAgent + const { launchAgent } = await import("../lib/launch/run"); + + // Exit Ink to stop useInput before tmux takes over stdin + exit(); + setImmediate(async () => { + const result = await launchAgent(agent, { attach: true }); + + if (!result.success) { + // Update agent status to STOPPED on failure + try { + const db = (await import("../lib/db")).getDb(); + const { ProcessOrchestrator } = await import( + "../lib/orchestrators/process-orchestrator" + ); + const orchestrator = new ProcessOrchestrator(db); + await orchestrator.update(agent.id, { + status: ProcessStatus.STOPPED, + endedAt: new Date(), + }); + } catch (dbError) { + console.error( + `\nWarning: Failed to update agent status: ${dbError instanceof Error ? dbError.message : String(dbError)}\n`, + ); + } + + console.error(`\n❌ Failed to attach to agent\n`); + console.error(`Error: ${result.error}\n`); + if (result.exitCode !== undefined) { + console.error(`Exit code: ${result.exitCode}\n`); + } + globalThis.process.exit(1); + } + + globalThis.process.exit(0); + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + setLoading(false); + } + }; + + attachToSession(); + }, [id, exit]); + + if (loading && !error) { + return Preparing to attach...; + } + + if (error) { + return Error: {error}; + } + + return null; +} diff --git a/apps/cli/src/commands/dashboard.tsx b/apps/cli/src/commands/dashboard.tsx new file mode 100644 index 00000000000..18f1133464a --- /dev/null +++ b/apps/cli/src/commands/dashboard.tsx @@ -0,0 +1,539 @@ +import { Box, Text, useApp, useInput, useStdout } from "ink"; +import React from "react"; +import { getDb } from "../lib/db"; +import { launchAgent } from "../lib/launch/run"; +import { ProcessOrchestrator } from "../lib/orchestrators/process-orchestrator"; +import { WorkspaceOrchestrator } from "../lib/orchestrators/workspace-orchestrator"; +import { + type Agent, + type Process, + ProcessStatus, + ProcessType, +} from "../types/process"; +import type { Workspace } from "../types/workspace"; + +interface DashboardData { + workspaces: Workspace[]; + processes: Process[]; + currentWorkspaceId?: string; + lastRefresh: Date; +} + +interface DashboardProps { + onComplete?: () => void; +} + +type SelectionMode = "workspace" | "agent"; + +export function Dashboard({ onComplete: _onComplete }: DashboardProps) { + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [selectedWorkspaceIndex, setSelectedWorkspaceIndex] = React.useState(0); + const [selectedAgentIndex, setSelectedAgentIndex] = React.useState(0); + const [selectionMode, setSelectionMode] = + React.useState("workspace"); + const [filterByCurrent, setFilterByCurrent] = React.useState(false); + const { exit } = useApp(); + const { stdout } = useStdout(); + + // Get terminal width, default to 80 if not available + const terminalWidth = stdout?.columns || 80; + + // Helper to create responsive separator + const getSeparator = (width: number) => "─".repeat(Math.max(width - 4, 20)); + + // Helper to truncate text if needed + const truncate = (text: string, maxLength: number) => { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 3)}...`; + }; + + const loadDashboard = React.useCallback(async () => { + try { + const db = getDb(); + const workspaceOrchestrator = new WorkspaceOrchestrator(db); + const processOrchestrator = new ProcessOrchestrator(db); + + // Fetch all data in parallel + const [workspaces, processes, currentWorkspace] = await Promise.all([ + workspaceOrchestrator.list(), + processOrchestrator.list(), + workspaceOrchestrator.getCurrent(), + ]); + + setData({ + workspaces, + processes, + currentWorkspaceId: currentWorkspace?.id, + lastRefresh: new Date(), + }); + + setLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + setLoading(false); + } + }, []); + + // Initial load + React.useEffect(() => { + loadDashboard(); + }, [loadDashboard]); + + // Auto-refresh every 3 seconds + React.useEffect(() => { + const interval = setInterval(() => { + loadDashboard(); + }, 3000); + + return () => clearInterval(interval); + }, [loadDashboard]); + + // Keyboard shortcuts + useInput((input, key) => { + if (!data) return; + + // Exit + if (key.escape || input === "q" || (key.ctrl && input === "c")) { + exit(); + return; + } + + // Get current list based on selection mode + const agents = data.processes.filter((p) => p.type === ProcessType.AGENT); + const selectedWorkspace = data.workspaces[selectedWorkspaceIndex]; + const displayWorkspaceId = filterByCurrent + ? data.currentWorkspaceId + : selectedWorkspace?.id; + const filteredAgents = displayWorkspaceId + ? agents.filter((a) => a.workspaceId === displayWorkspaceId) + : agents; + + // Switch selection mode + if (key.tab) { + setSelectionMode((prev) => + prev === "workspace" ? "agent" : "workspace", + ); + return; + } + + // Navigation + if (key.upArrow || input === "k") { + if (selectionMode === "workspace") { + setSelectedWorkspaceIndex((prev) => + prev > 0 ? prev - 1 : data.workspaces.length - 1, + ); + } else { + setSelectedAgentIndex((prev) => + prev > 0 ? prev - 1 : filteredAgents.length - 1, + ); + } + } else if (key.downArrow || input === "j") { + if (selectionMode === "workspace") { + setSelectedWorkspaceIndex((prev) => + prev < data.workspaces.length - 1 ? prev + 1 : 0, + ); + } else { + setSelectedAgentIndex((prev) => + prev < filteredAgents.length - 1 ? prev + 1 : 0, + ); + } + } + + // Actions + else if (key.return) { + // Launch selected agent + if (selectionMode === "agent" && filteredAgents[selectedAgentIndex]) { + const selectedAgent = filteredAgents[selectedAgentIndex]; + + // Only launch agents, not terminals + if (selectedAgent.type !== ProcessType.AGENT) { + return; + } + + const agentToLaunch = selectedAgent as Agent; + // Exit Ink to stop useInput before tmux takes over stdin + exit(); + setImmediate(async () => { + const result = await launchAgent(agentToLaunch, { attach: true }); + + if (!result.success) { + // Update agent status to STOPPED on failure + try { + const db = getDb(); + const orchestrator = new ProcessOrchestrator(db); + await orchestrator.update(agentToLaunch.id, { + status: ProcessStatus.STOPPED, + endedAt: new Date(), + }); + } catch (dbError) { + // Log DB error but don't fail the process + console.error( + `\nWarning: Failed to update agent status: ${dbError instanceof Error ? dbError.message : String(dbError)}\n`, + ); + } + + console.error( + `\n❌ Failed to attach to ${agentToLaunch.agentType} agent\n`, + ); + console.error(`Error: ${result.error}\n`); + if (result.exitCode !== undefined) { + console.error(`Exit code: ${result.exitCode}\n`); + } + process.exit(1); + } + process.exit(0); + }); + } + } else if (input === "r") { + loadDashboard(); + } else if (input === "f") { + setFilterByCurrent((prev) => !prev); + } else if (input === "[" || input === "]") { + // Cycle current workspace + const direction = input === "[" ? -1 : 1; + const currentIndex = data.workspaces.findIndex( + (w) => w.id === data.currentWorkspaceId, + ); + const newIndex = + (currentIndex + direction + data.workspaces.length) % + data.workspaces.length; + const newWorkspace = data.workspaces[newIndex]; + if (newWorkspace) { + const workspaceOrchestrator = new WorkspaceOrchestrator(getDb()); + workspaceOrchestrator.use(newWorkspace.id).then(() => { + loadDashboard(); + }); + } + } else if (input === "o") { + // Print cd hint for selected local workspace + const selectedWorkspace = data.workspaces[selectedWorkspaceIndex]; + if (selectedWorkspace && "path" in selectedWorkspace) { + console.log(`\n cd ${selectedWorkspace.path}\n`); + } + } + }); + + if (loading) { + return Loading dashboard...; + } + + if (error) { + return Error: {error}; + } + + if (!data) { + return Error: Failed to load dashboard; + } + + const { workspaces, processes, currentWorkspaceId, lastRefresh } = data; + + // Filter agents (exclude terminals) + const agents = processes.filter((p) => p.type === ProcessType.AGENT); + + // Filter by workspace if enabled + const selectedWorkspace = workspaces[selectedWorkspaceIndex]; + const displayWorkspaceId = filterByCurrent + ? currentWorkspaceId + : selectedWorkspace?.id; + const filteredAgents = displayWorkspaceId + ? agents.filter((a) => a.workspaceId === displayWorkspaceId) + : agents; + + // Categorize agents by status + const runningAgents = filteredAgents.filter( + (a) => a.status === ProcessStatus.RUNNING || !a.endedAt, + ); + const idleAgents = filteredAgents.filter( + (a) => a.status === ProcessStatus.IDLE && !a.endedAt, + ); + const stoppedAgents = filteredAgents.filter((a) => a.endedAt); + const errorAgents = filteredAgents.filter( + (a) => a.status === ProcessStatus.ERROR, + ); + + // Status badge helper + const getStatusBadge = (agent: Process) => { + if (agent.endedAt) { + return [stopped]; + } + switch (agent.status) { + case ProcessStatus.RUNNING: + return [running]; + case ProcessStatus.IDLE: + return [idle]; + case ProcessStatus.ERROR: + return [error]; + default: + return [unknown]; + } + }; + + return ( + + {/* Header */} + + + SUPERSET DASHBOARD + {lastRefresh.toLocaleTimeString()} + + + {getSeparator(terminalWidth)} + + + + {/* Summary Stats */} + + + + Workspaces: + + {workspaces.length} + + + · + + Running: + + {runningAgents.length} + + + · + + Idle: + + {idleAgents.length} + + + {terminalWidth >= 60 && ( + <> + · + + Stopped: + + {stoppedAgents.length} + + + + )} + {errorAgents.length > 0 && terminalWidth >= 80 && ( + <> + · + + Error: + + {errorAgents.length} + + + + )} + + + + {/* Current Workspace & Controls */} + + {currentWorkspaceId && ( + + Active: + + {truncate( + workspaces.find((w) => w.id === currentWorkspaceId)?.name || + currentWorkspaceId.slice(0, 8), + Math.max(15, Math.floor(terminalWidth / 4)), + )} + + + )} + + Mode: + + {selectionMode === "workspace" ? "Workspaces" : "Agents"} + + (tab) + + + Filter: + + {filterByCurrent + ? "Current" + : selectedWorkspace + ? truncate( + selectedWorkspace.name || selectedWorkspace.id.slice(0, 8), + Math.max(10, Math.floor(terminalWidth / 6)), + ) + : "All"} + + (f) + + + + + {getSeparator(terminalWidth)} + + + {/* Workspaces Section */} + + + WORKSPACES + ({workspaces.length}) + + {workspaces.length === 0 ? ( + + No workspaces. Run: + superset init + + ) : ( + + {workspaces.map((ws, index) => { + const wsAgents = agents.filter((a) => a.workspaceId === ws.id); + const wsRunning = wsAgents.filter( + (a) => a.status === ProcessStatus.RUNNING || !a.endedAt, + ); + const isSelected = + selectionMode === "workspace" && + index === selectedWorkspaceIndex; + const isCurrent = ws.id === currentWorkspaceId; + + const wsName = ws.name || ws.id.slice(0, 8); + const maxNameLength = Math.max( + 20, + Math.floor((terminalWidth - 40) / 2), + ); + + return ( + + + {isSelected ? "▸" : " "} + + + {truncate(wsName, maxNameLength)} + + ({ws.type}) + + {wsRunning.length > 0 && ( + {wsRunning.length} running + )} + {wsRunning.length === 0 && wsAgents.length > 0 && ( + {wsAgents.length} idle + )} + {wsAgents.length === 0 && no agents} + + {isCurrent && ( + + [active] + + )} + + ); + })} + + )} + + + {/* Agents Section */} + + + AGENTS + ({filteredAgents.length}) + + {filteredAgents.length === 0 ? ( + + No agents in this workspace + + ) : ( + + {filteredAgents.map((agent, index) => { + const isSelected = + selectionMode === "agent" && index === selectedAgentIndex; + const sessionName = + agent.type === ProcessType.AGENT && + "sessionName" in agent && + agent.sessionName + ? String(agent.sessionName) + : null; + return ( + + + {isSelected ? "▸" : " "} + + {getStatusBadge(agent)} + + {agent.type === ProcessType.AGENT && + "agentType" in agent && + String(agent.agentType)} + + {sessionName && ( + + [{truncate(sessionName, 16)}] + + )} + + {new Date(agent.createdAt).toLocaleTimeString()} + + + ); + })} + + )} + + + + {getSeparator(terminalWidth)} + + + {/* Keyboard Shortcuts */} + + + + CONTROLS + + + + + + ↑↓ j k Navigate + + + tab Switch mode + + + Launch agent + + + + + [ ] Cycle workspace + + + f Toggle filter + + + r Refresh + + + + + o Print cd path + + + q esc Exit + + + + + + ); +} diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts index 3e5378259fa..a62e9261c43 100644 --- a/apps/cli/src/commands/index.ts +++ b/apps/cli/src/commands/index.ts @@ -1,16 +1,22 @@ export { + AgentAttach, AgentCreate, AgentDelete, AgentGet, AgentList, + AgentStart, AgentStop, AgentStopAll, } from "./agent"; export { ChangeCreate, ChangeDelete, ChangeList } from "./change"; +export { Dashboard } from "./dashboard"; export { EnvCreate, EnvDelete, EnvGet, EnvList } from "./env"; +export { Init } from "./init"; +export { Panels } from "./panels"; export { WorkspaceCreate, WorkspaceDelete, WorkspaceGet, WorkspaceList, + WorkspaceUse, } from "./workspace"; diff --git a/apps/cli/src/commands/init.tsx b/apps/cli/src/commands/init.tsx new file mode 100644 index 00000000000..e6982dfa769 --- /dev/null +++ b/apps/cli/src/commands/init.tsx @@ -0,0 +1,401 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { Box, Text, useApp } from "ink"; +import SelectInput from "ink-select-input"; +import TextInput from "ink-text-input"; +import React from "react"; +import { getDb } from "../lib/db"; +import { WorkspaceOrchestrator } from "../lib/orchestrators/workspace-orchestrator"; +import { AgentType } from "../types/process"; +import { WorkspaceType } from "../types/workspace"; + +interface InitProps { + onComplete?: () => void; +} + +enum InitStep { + SELECT_TYPE = "SELECT_TYPE", + INPUT_PATH = "INPUT_PATH", + INPUT_BRANCH = "INPUT_BRANCH", + INPUT_NAME = "INPUT_NAME", + SELECT_AGENTS = "SELECT_AGENTS", + CREATING = "CREATING", + COMPLETE = "COMPLETE", +} + +interface InitState { + step: InitStep; + workspaceType?: WorkspaceType; + path: string; + branch: string; + name: string; + defaultAgents: AgentType[]; + error?: string; + workspaceId?: string; +} + +export function Init({ onComplete }: InitProps) { + const { exit } = useApp(); + const [state, setState] = React.useState({ + step: InitStep.SELECT_TYPE, + path: "", + branch: "", + name: "", + defaultAgents: [], + }); + + const handleTypeSelect = (item: { value: WorkspaceType }) => { + setState((s) => ({ + ...s, + workspaceType: item.value, + step: + item.value === WorkspaceType.LOCAL + ? InitStep.INPUT_PATH + : InitStep.INPUT_BRANCH, + })); + }; + + const handlePathSubmit = async () => { + const expandedPath = state.path.replace(/^~/, process.env.HOME || "~"); + const absolutePath = resolve(expandedPath); + + // Validate path exists + if (!existsSync(absolutePath)) { + setState((s) => ({ + ...s, + error: `Path does not exist: ${absolutePath}`, + })); + return; + } + + // Check for duplicate paths + try { + const db = getDb(); + const orchestrator = new WorkspaceOrchestrator(db); + const workspaces = await orchestrator.list(); + + const duplicate = workspaces.find( + (w) => "path" in w && w.path === absolutePath, + ); + + if (duplicate) { + setState((s) => ({ + ...s, + error: `A workspace already exists for this path: ${duplicate.name || duplicate.id}`, + })); + return; + } + } catch (err) { + // Ignore database errors during validation + } + + setState((s) => ({ + ...s, + path: absolutePath, + error: undefined, + step: InitStep.INPUT_NAME, + })); + }; + + const handleBranchSubmit = async () => { + if (!state.branch.trim()) { + setState((s) => ({ + ...s, + error: "Branch/ref cannot be empty", + })); + return; + } + + // Check for duplicate branches + try { + const db = getDb(); + const orchestrator = new WorkspaceOrchestrator(db); + const workspaces = await orchestrator.list(); + + const duplicate = workspaces.find( + (w) => "branch" in w && w.branch === state.branch.trim(), + ); + + if (duplicate) { + setState((s) => ({ + ...s, + error: `A workspace already exists for this branch: ${duplicate.name || duplicate.id}`, + })); + return; + } + } catch (err) { + // Ignore database errors during validation + } + + setState((s) => ({ + ...s, + error: undefined, + step: InitStep.INPUT_NAME, + })); + }; + + const handleNameSubmit = () => { + setState((s) => ({ + ...s, + error: undefined, + step: InitStep.SELECT_AGENTS, + })); + }; + + const handleAgentsSelect = (item: { value: string }) => { + if (item.value === "done") { + createWorkspace(); + } else if (item.value === "skip") { + setState((s) => ({ ...s, defaultAgents: [] })); + createWorkspace(); + } else { + // Toggle agent selection + setState((s) => ({ + ...s, + defaultAgents: s.defaultAgents.includes(item.value as AgentType) + ? s.defaultAgents.filter((a) => a !== item.value) + : [...s.defaultAgents, item.value as AgentType], + })); + } + }; + + const createWorkspace = async () => { + setState((s) => ({ ...s, step: InitStep.CREATING })); + + try { + const db = getDb(); + const orchestrator = new WorkspaceOrchestrator(db); + + // Ensure at least one environment exists, create if missing + const { EnvironmentOrchestrator } = await import( + "../lib/orchestrators/environment-orchestrator" + ); + const envOrchestrator = new EnvironmentOrchestrator(db); + const environments = await envOrchestrator.list(); + + let envId: string; + if (environments.length === 0) { + // No environments at all - create one + const newEnv = await envOrchestrator.create(); + envId = newEnv.id; + } else { + // Use the first available environment (prefer "default" if it exists) + const defaultEnv = environments.find((e) => e.id === "default"); + envId = defaultEnv ? defaultEnv.id : environments[0]!.id; + } + + const workspace = await orchestrator.create( + envId, + state.workspaceType!, + { + path: state.path || undefined, + branch: state.branch || undefined, + name: state.name || undefined, + defaultAgents: state.defaultAgents, + }, + ); + + setState((s) => ({ + ...s, + workspaceId: workspace.id, + step: InitStep.COMPLETE, + })); + + // Auto-exit after showing success message + setTimeout(() => { + exit(); + }, 2000); + } catch (err) { + setState((s) => ({ + ...s, + error: err instanceof Error ? err.message : "Unknown error", + step: InitStep.SELECT_TYPE, + })); + } + }; + + React.useEffect(() => { + if (state.step === InitStep.COMPLETE) { + onComplete?.(); + } + }, [state.step, onComplete]); + + if (state.step === InitStep.SELECT_TYPE) { + return ( + + + 🚀 Initialize Superset Workspace + + + Select workspace type: + + {state.error && ( + + Error: {state.error} + + )} + + + + + ); + } + + if (state.step === InitStep.INPUT_PATH) { + return ( + + + 🚀 Initialize Local Workspace + + + Enter path to your project (supports ~): + + {state.error && ( + + {state.error} + + )} + + > + setState((s) => ({ ...s, path: value }))} + onSubmit={handlePathSubmit} + /> + + + ); + } + + if (state.step === InitStep.INPUT_BRANCH) { + return ( + + + 🚀 Initialize Cloud Workspace + + + Enter git branch or ref: + + {state.error && ( + + {state.error} + + )} + + > + setState((s) => ({ ...s, branch: value }))} + onSubmit={handleBranchSubmit} + /> + + + ); + } + + if (state.step === InitStep.INPUT_NAME) { + return ( + + + 🚀 Name Your Workspace + + + Enter workspace name (optional, press Enter to skip): + + + > + setState((s) => ({ ...s, name: value }))} + onSubmit={handleNameSubmit} + /> + + + ); + } + + if (state.step === InitStep.SELECT_AGENTS) { + const agentItems = [ + ...Object.values(AgentType).map((type) => ({ + label: `${state.defaultAgents.includes(type) ? "✓" : "○"} ${type}`, + value: type, + })), + { label: "→ Done (save selection)", value: "done" }, + { label: "→ Skip (no default agents)", value: "skip" }, + ]; + + return ( + + + 🚀 Select Default Agents + + + + Choose which agents to start automatically (use arrow keys, Enter to + toggle): + + + {state.defaultAgents.length > 0 && ( + + + Selected: {state.defaultAgents.join(", ")} + + + )} + + + + + ); + } + + if (state.step === InitStep.CREATING) { + return ( + + Creating workspace... + + ); + } + + if (state.step === InitStep.COMPLETE) { + return ( + + ✓ Workspace created successfully! + + ID: {state.workspaceId} + Name: {state.name || "(unnamed)"} + Type: {state.workspaceType} + {state.path && Path: {state.path}} + {state.branch && Branch: {state.branch}} + {state.defaultAgents.length > 0 && ( + + Default agents: {state.defaultAgents.join(", ")} + + )} + + + Current workspace set! + + + + Run superset agent start to launch agents + + + Run superset dashboard to view status + + + + ); + } + + return null; +} diff --git a/apps/cli/src/commands/panels.tsx b/apps/cli/src/commands/panels.tsx new file mode 100644 index 00000000000..1b53c9ebe62 --- /dev/null +++ b/apps/cli/src/commands/panels.tsx @@ -0,0 +1,420 @@ +import { Box, Text, useApp, useInput, useStdout } from "ink"; +import React from "react"; +import { getDb } from "../lib/db"; +import { launchAgent } from "../lib/launch/run"; +import { ProcessOrchestrator } from "../lib/orchestrators/process-orchestrator"; +import { WorkspaceOrchestrator } from "../lib/orchestrators/workspace-orchestrator"; +import { type Agent, type Process, ProcessStatus, ProcessType } from "../types/process"; +import type { Workspace } from "../types/workspace"; + +interface PanelsData { + workspaces: Workspace[]; + processes: Process[]; + currentWorkspaceId?: string; + lastRefresh: Date; +} + +interface PanelsProps { + onComplete?: () => void; +} + +type ActivePanel = "workspaces" | "agents" | "details"; + +// Threshold for responsive layout - hide details panel and adjust widths below this +const SMALL_TERMINAL_THRESHOLD = 80; + +export function Panels({ onComplete: _onComplete }: PanelsProps) { + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [selectedWorkspaceIndex, setSelectedWorkspaceIndex] = React.useState(0); + const [selectedAgentIndex, setSelectedAgentIndex] = React.useState(0); + const [activePanel, setActivePanel] = React.useState("agents"); + const { exit } = useApp(); + const { stdout } = useStdout(); + + const terminalWidth = stdout?.columns || 120; + const terminalHeight = stdout?.rows || 30; + + const loadData = React.useCallback(async () => { + try { + const db = getDb(); + const workspaceOrchestrator = new WorkspaceOrchestrator(db); + const processOrchestrator = new ProcessOrchestrator(db); + + const [workspaces, processes, currentWorkspace] = await Promise.all([ + workspaceOrchestrator.list(), + processOrchestrator.list(), + workspaceOrchestrator.getCurrent(), + ]); + + setData({ + workspaces, + processes, + currentWorkspaceId: currentWorkspace?.id, + lastRefresh: new Date(), + }); + + setLoading(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + setLoading(false); + } + }, []); + + React.useEffect(() => { + loadData(); + }, [loadData]); + + React.useEffect(() => { + const interval = setInterval(() => { + loadData(); + }, 3000); + + return () => clearInterval(interval); + }, [loadData]); + + useInput((input, key) => { + if (!data) return; + + if (key.escape || input === "q" || (key.ctrl && input === "c")) { + exit(); + return; + } + + // Panel switching + if (input === "1") { + setActivePanel("workspaces"); + return; + } + if (input === "2") { + setActivePanel("agents"); + return; + } + if (input === "3") { + setActivePanel("details"); + return; + } + + const agents = data.processes.filter((p) => p.type === ProcessType.AGENT); + const selectedWorkspace = data.workspaces[selectedWorkspaceIndex]; + const filteredAgents = selectedWorkspace + ? agents.filter((a) => a.workspaceId === selectedWorkspace.id) + : agents; + + // Navigation + if (key.upArrow || input === "k") { + if (activePanel === "workspaces") { + setSelectedWorkspaceIndex((prev) => + prev > 0 ? prev - 1 : data.workspaces.length - 1, + ); + } else if (activePanel === "agents") { + setSelectedAgentIndex((prev) => + prev > 0 ? prev - 1 : filteredAgents.length - 1, + ); + } + } else if (key.downArrow || input === "j") { + if (activePanel === "workspaces") { + setSelectedWorkspaceIndex((prev) => + prev < data.workspaces.length - 1 ? prev + 1 : 0, + ); + } else if (activePanel === "agents") { + setSelectedAgentIndex((prev) => + prev < filteredAgents.length - 1 ? prev + 1 : 0, + ); + } + } + + // Attach to agent + if (key.return && activePanel === "agents") { + const selectedAgent = filteredAgents[selectedAgentIndex]; + if (selectedAgent) { + if (selectedAgent.type !== ProcessType.AGENT) { + return; + } + + const agentToAttach = selectedAgent as Agent; + // Exit Ink to stop useInput before tmux takes over stdin + exit(); + setImmediate(async () => { + const result = await launchAgent(agentToAttach, { attach: true }); + + if (!result.success) { + // Update agent status to STOPPED on failure + try { + const db = getDb(); + const orchestrator = new ProcessOrchestrator(db); + await orchestrator.update(agentToAttach.id, { + status: ProcessStatus.STOPPED, + endedAt: new Date(), + }); + } catch (dbError) { + console.error( + `\nWarning: Failed to update agent status: ${dbError instanceof Error ? dbError.message : String(dbError)}\n`, + ); + } + + console.error( + `\n❌ Failed to attach to ${agentToAttach.agentType} agent\n`, + ); + console.error(`Error: ${result.error}\n`); + if (result.exitCode !== undefined) { + console.error(`Exit code: ${result.exitCode}\n`); + } + process.exit(1); + } + process.exit(0); + }); + } + } + + if (input === "r") { + loadData(); + } + }); + + if (loading) { + return Loading panels...; + } + + if (error) { + return Error: {error}; + } + + if (!data) { + return Error: Failed to load data; + } + + const { workspaces, processes, currentWorkspaceId } = data; + + const agents = processes.filter((p) => p.type === ProcessType.AGENT); + const selectedWorkspace = workspaces[selectedWorkspaceIndex]; + const filteredAgents = selectedWorkspace + ? agents.filter((a) => a.workspaceId === selectedWorkspace.id) + : agents; + const selectedAgent = filteredAgents[selectedAgentIndex]; + + return ( + + {/* Header */} + + SUPERSET PANELS + {data.lastRefresh.toLocaleTimeString()} + + + {/* Panels layout */} + + {/* Workspaces panel */} + + + Workspaces ({workspaces.length}) + + {workspaces.length === 0 ? ( + No workspaces + ) : ( + + {workspaces.map((ws, index) => { + const wsAgents = agents.filter((a) => a.workspaceId === ws.id); + const wsRunning = wsAgents.filter( + (a) => a.status === ProcessStatus.RUNNING || !a.endedAt, + ); + const isSelected = index === selectedWorkspaceIndex; + const isCurrent = ws.id === currentWorkspaceId; + const statusEmoji = wsRunning.length > 0 ? "●" : "○"; + const statusColor = wsRunning.length > 0 ? "green" : "gray"; + + return ( + + + + {isSelected ? "▸" : " "} + + {statusEmoji} + + {ws.name || ws.id.slice(0, 8)} + + + + {" "} + {ws.type} + {isCurrent && " (active)"} + + + ); + })} + + )} + + + {/* Agents panel */} + + + Agents ({filteredAgents.length}) + + {filteredAgents.length === 0 ? ( + No agents + ) : ( + + {filteredAgents.map((agent, index) => { + const isSelected = index === selectedAgentIndex; + const agentType = + agent.type === ProcessType.AGENT && "agentType" in agent + ? String(agent.agentType) + : "unknown"; + const statusEmoji = + agent.status === ProcessStatus.RUNNING + ? "●" + : agent.status === ProcessStatus.IDLE + ? "○" + : agent.status === ProcessStatus.ERROR + ? "✗" + : "○"; + const statusColor = + agent.status === ProcessStatus.RUNNING + ? "green" + : agent.status === ProcessStatus.IDLE + ? "yellow" + : agent.status === ProcessStatus.ERROR + ? "red" + : "gray"; + + return ( + + + + {isSelected ? "▸" : " "} + + {statusEmoji} + + {agentType} + + + + {" "} + {new Date(agent.createdAt).toLocaleTimeString()} + + + ); + })} + + )} + + + {/* Details panel - hidden on small terminals */} + {terminalWidth >= SMALL_TERMINAL_THRESHOLD && ( + + + Details + + {selectedAgent ? ( + + {selectedAgent.type === ProcessType.AGENT && + "agentType" in selectedAgent ? ( + <> + + Agent:{" "} + {String(selectedAgent.agentType)} + + ID: {selectedAgent.id} + {"sessionName" in selectedAgent && + selectedAgent.sessionName && ( + + Session: {String(selectedAgent.sessionName)} + + )} + Status: {selectedAgent.status} + {selectedAgent.endedAt && ( + + Ended: {new Date(selectedAgent.endedAt).toLocaleString()} + + )} + + ) : ( + Not an agent + )} + + ) : ( + Select an agent to see details + )} + + )} + + + {/* Controls */} + + + [1] + Workspaces + + + [2] + Agents + + {terminalWidth >= SMALL_TERMINAL_THRESHOLD && ( + + [3] + Details + + )} + + ↑↓ + j k + + + [Enter] + Attach + + + [r] + Refresh + + + [q] + Exit + + + + ); +} diff --git a/apps/cli/src/commands/workspace.tsx b/apps/cli/src/commands/workspace.tsx index 7648183acf3..715c8e52834 100644 --- a/apps/cli/src/commands/workspace.tsx +++ b/apps/cli/src/commands/workspace.tsx @@ -137,7 +137,7 @@ export function WorkspaceCreate({ try { const db = getDb(); const orchestrator = new WorkspaceOrchestrator(db); - const ws = await orchestrator.create(environmentId, type, path); + const ws = await orchestrator.create(environmentId, type, { path }); setWorkspace(ws); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); @@ -162,6 +162,13 @@ export function WorkspaceCreate({ ✓ Workspace created successfully + + Current workspace set to{" "} + {workspace?.name || workspace?.id}. + + + Run superset agent start to launch agents. + ); } @@ -216,3 +223,58 @@ export function WorkspaceDelete({ id, onComplete }: WorkspaceDeleteProps) { return null; } + +interface WorkspaceUseProps { + id: string; + onComplete?: () => void; +} + +export function WorkspaceUse({ id, onComplete }: WorkspaceUseProps) { + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [workspace, setWorkspace] = React.useState(null); + + React.useEffect(() => { + const useWorkspace = async () => { + try { + const db = getDb(); + const orchestrator = new WorkspaceOrchestrator(db); + + // Get the workspace to verify it exists + const ws = await orchestrator.get(id); + + // Update lastUsedAt and set as current + await orchestrator.use(id); + + setWorkspace(ws); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + onComplete?.(); + } + }; + + useWorkspace(); + }, [id, onComplete]); + + if (loading) { + return Setting current workspace...; + } + + if (error) { + return Error: {error}; + } + + return ( + + + ✓ Current workspace set to: {workspace?.name || id} + + + Run superset agent start to launch agents in this + workspace. + + + ); +} diff --git a/apps/cli/src/lib/db.ts b/apps/cli/src/lib/db.ts index 517d0e69060..cdf3da21c01 100644 --- a/apps/cli/src/lib/db.ts +++ b/apps/cli/src/lib/db.ts @@ -1,27 +1,18 @@ -import { existsSync, mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; import { LowdbAdapter } from "./storage/lowdb-adapter"; let adapter: LowdbAdapter | null = null; /** * Get or create the database adapter instance. - * Database is stored at ~/.superset/db.json + * Database path is determined by storage config (defaults to ~/.superset/cli/db.json) */ export function getDb(): LowdbAdapter { if (adapter) { return adapter; } - // Create ~/.superset directory if it doesn't exist - const supersetDir = join(homedir(), ".superset"); - if (!existsSync(supersetDir)) { - mkdirSync(supersetDir, { recursive: true }); - } - - const dbPath = join(supersetDir, "db.json"); - adapter = new LowdbAdapter(dbPath); + // LowdbAdapter will use getDbPath() from config + adapter = new LowdbAdapter(); return adapter; } diff --git a/apps/cli/src/lib/launch/config.ts b/apps/cli/src/lib/launch/config.ts new file mode 100644 index 00000000000..d0051c85c69 --- /dev/null +++ b/apps/cli/src/lib/launch/config.ts @@ -0,0 +1,58 @@ +import type { Agent, AgentType } from "../../types/process"; + +/** + * Default launch commands for each agent type + */ +const DEFAULT_LAUNCH_COMMANDS: Record = { + claude: "claude", + codex: "codex", + cursor: "cursor", +}; + +/** + * Get launch command for an agent, considering: + * 1. Agent's stored launchCommand (highest priority) + * 2. Environment variable override (SUPERSET_AGENT_LAUNCH_) + * 3. User config file (~/.superset-cli.json) + * 4. Default for agent type + */ +export function getLaunchCommand(agent: Agent): string | null { + // 1. Use agent's stored launch command if available + if (agent.launchCommand) { + return agent.launchCommand; + } + + const agentType = agent.agentType; + + // 2. Check environment variable override + const envKey = `SUPERSET_AGENT_LAUNCH_${agentType.toUpperCase()}`; + const envOverride = process.env[envKey]; + if (envOverride) { + return envOverride; + } + + // 3. Check user config file (TODO: implement config file reading) + // const userConfig = loadUserConfig(); + // if (userConfig?.launchers?.[agentType]) { + // return userConfig.launchers[agentType]; + // } + + // 4. Use default + return DEFAULT_LAUNCH_COMMANDS[agentType] || null; +} + +/** + * Get the default launch command for an agent type + * Used when creating new agents + */ +export function getDefaultLaunchCommand(agentType: AgentType): string { + // Check environment variable override + const envKey = `SUPERSET_AGENT_LAUNCH_${agentType.toUpperCase()}`; + const envOverride = process.env[envKey]; + if (envOverride) { + return envOverride; + } + + // Return default + return DEFAULT_LAUNCH_COMMANDS[agentType]; +} diff --git a/apps/cli/src/lib/launch/run.ts b/apps/cli/src/lib/launch/run.ts new file mode 100644 index 00000000000..99a16acbcd6 --- /dev/null +++ b/apps/cli/src/lib/launch/run.ts @@ -0,0 +1,378 @@ +import { exec, execSync, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import type { Agent } from "../../types/process"; +import { getLaunchCommand } from "./config"; + +const execAsync = promisify(exec); + +export interface LaunchResult { + success: boolean; + exitCode?: number; + error?: string; +} + +/** + * Check if tmux is installed on the system + */ +function isTmuxInstalled(): boolean { + try { + execSync("which tmux", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * Check if a tmux session already exists (synchronous to avoid hangs) + */ +function tmuxSessionExists(sessionName: string): boolean { + try { + execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +/** + * Kill a tmux session if it exists + */ +function killTmuxSession(sessionName: string): void { + try { + execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { + stdio: "ignore", + }); + } catch { + // Ignore errors - session might not exist + } +} + +/** + * Check if a tmux session has at least one live pane (not dead) + * Dead panes indicate the command exited + */ +function tmuxSessionHasLivePane(sessionName: string): boolean { + try { + const output = execSync( + `tmux list-panes -t "${sessionName}" -F "#{pane_dead}" 2>/dev/null`, + { encoding: "utf-8" }, + ); + // Check if any pane has pane_dead=0 (alive) + return output + .trim() + .split("\n") + .some((value) => value === "0"); + } catch { + return false; + } +} + +/** + * Check if a command/binary exists on PATH + */ +function commandExists(binary: string): boolean { + try { + execSync(`command -v ${binary}`, { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * Create a tmux session with retry logic + * Attempts to create session, waits for readiness, and retries once if it fails + */ +async function createSessionWithRetry( + agent: Agent, + sessionName: string, + command: string, + options: { attach?: boolean; silent?: boolean; retryCount?: number }, + attempt = 1, +): Promise { + const maxAttempts = 2; + + try { + // Create the session with 10s timeout + await execAsync(`tmux new-session -d -s "${sessionName}" "${command}"`, { + timeout: 10000, + }); + + // Increased wait time for better readiness check (from 500ms to 1000ms) + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Verify session still exists and has a live pane (not dead) + const stillExists = tmuxSessionExists(sessionName); + const hasLivePane = stillExists ? tmuxSessionHasLivePane(sessionName) : false; + + if (!stillExists || !hasLivePane) { + // Session died or has no live pane - kill any remnants + killTmuxSession(sessionName); + + // Retry once if this was the first attempt + if (attempt < maxAttempts) { + if (!options.silent) { + console.log( + `\nSession creation failed (attempt ${attempt}/${maxAttempts}). Retrying...\n`, + ); + } + // Wait before retry + await new Promise((resolve) => setTimeout(resolve, 500)); + return createSessionWithRetry( + agent, + sessionName, + command, + options, + attempt + 1, + ); + } + + return { + success: false, + error: `Session "${sessionName}" exited immediately after ${maxAttempts} attempts.\nThe launch command may be invalid or exiting immediately: ${command}\n\nPlease verify:\n 1. Run '${command.split(" ")[0]}' directly to test if it stays alive\n 2. Check 'which ${command.split(" ")[0]}' to verify the binary exists\n 3. Ensure the command doesn't require interactive input`, + }; + } + + if (options.attach) { + // Final check before attach + const existsBeforeAttach = tmuxSessionExists(sessionName); + if (!existsBeforeAttach) { + killTmuxSession(sessionName); + return { + success: false, + error: `Session "${sessionName}" died before attach. The command may exit immediately: ${command}`, + }; + } + + // Created successfully and still alive, now attach + if (!options.silent) { + console.log(`\nSession "${sessionName}" created. Attaching...\n`); + } + const result = await attachToAgent(agent, options.silent, options.retryCount || 0); + + // If attach failed, kill the session + if (!result.success) { + killTmuxSession(sessionName); + } + + return result; + } + + // Created but not attaching - just return success + if (!options.silent) { + console.log(`\n✓ Agent session created: ${sessionName}\n`); + } + return { + success: true, + exitCode: 0, + }; + } catch (error) { + // Kill any partial session on error + killTmuxSession(sessionName); + + // Retry once if this was the first attempt + if (attempt < maxAttempts) { + if (!options.silent) { + console.log( + `\nSession creation error (attempt ${attempt}/${maxAttempts}). Retrying...\n`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 500)); + return createSessionWithRetry( + agent, + sessionName, + command, + options, + attempt + 1, + ); + } + + return { + success: false, + error: + error instanceof Error + ? `Failed to create tmux session after ${maxAttempts} attempts: ${error.message}` + : "Unknown error creating tmux session", + }; + } +} + +/** + * Launch an agent in a tmux session (create-or-attach behavior) + * - If session exists: attach to it + * - If session doesn't exist: create it in detached mode and return immediately + * - Optional attach parameter: if true, attach after creating; if false, just create and return + * - Optional silent parameter: if true, suppress console output (for use with Ink overlays) + */ +export async function launchAgent( + agent: Agent, + options: { attach?: boolean; silent?: boolean; retryCount?: number } = { attach: true }, +): Promise { + const command = getLaunchCommand(agent); + + if (!command) { + return { + success: false, + error: `No launch command configured for agent type "${agent.agentType}". Set SUPERSET_AGENT_LAUNCH_${agent.agentType.toUpperCase()}= or add launchers in ~/.superset-cli.json`, + }; + } + + // Check if tmux is installed + if (!isTmuxInstalled()) { + return { + success: false, + error: + "tmux is not installed. Please install tmux to launch agents:\n macOS: brew install tmux\n Ubuntu/Debian: sudo apt install tmux\n Fedora: sudo dnf install tmux", + }; + } + + // Preflight check: verify the launch command binary exists on PATH + // Skip for complex commands (wrappers, env vars, quotes) to avoid false negatives + // The live pane check after creation will catch actual failures + const isComplexCommand = + command.includes("=") || // env var assignments (FOO=bar cmd) + command.includes("'") || // single quotes (bash -c 'cmd') + command.includes('"') || // double quotes (bash -c "cmd") + command.split(" ").length > 2; // multiple args (likely a wrapper) + + if (!isComplexCommand) { + const binary = command.split(" ")[0]; + if (binary && !commandExists(binary)) { + return { + success: false, + error: `Command not found: ${binary}\n\nThe agent launch command is not available on your PATH.\nTo fix this:\n 1. Install the command: which ${binary}\n 2. Or set a custom command: export SUPERSET_AGENT_LAUNCH_${agent.agentType.toUpperCase()}=your-command`, + }; + } + } + + const sessionName = agent.sessionName || `agent-${agent.id.slice(0, 6)}`; + + // Check if session already exists + const exists = tmuxSessionExists(sessionName); + + if (exists) { + // Session exists - attach if requested + if (options.attach) { + if (!options.silent) { + console.log(`\nSession "${sessionName}" exists. Attaching...\n`); + } + return attachToAgent(agent, options.silent, options.retryCount || 0); + } + // Session exists but not attaching - just return success + return { + success: true, + exitCode: 0, + }; + } + + // Session doesn't exist - create it in detached mode with retry + return createSessionWithRetry(agent, sessionName, command, options); +} + +/** + * Attach to an existing agent's tmux session + * Inherits stdio so user can interact, returns when user detaches + * If session doesn't exist, attempts to create it first + * @param retryCount Internal counter to prevent infinite recursion (max 1 retry) + */ +export async function attachToAgent( + agent: Agent, + silent = false, + retryCount = 0, +): Promise { + const sessionName = agent.sessionName || `agent-${agent.id.slice(0, 6)}`; + + // Check if session exists + const exists = tmuxSessionExists(sessionName); + if (!exists) { + // Session missing - try to recreate it by calling launchAgent (if not already retried) + if (retryCount >= 1) { + return { + success: false, + error: `Session "${sessionName}" not found and recreate attempt already failed.`, + }; + } + if (!silent) { + console.log( + `\nSession "${sessionName}" not found. Creating new session...\n`, + ); + } + return launchAgent(agent, { attach: true, silent, retryCount: retryCount + 1 }); + } + + if (!silent) { + console.log( + `\n╔════════════════════════════════════════════════════════════════╗`, + ); + console.log(`║ Attaching to session: ${sessionName.padEnd(38)} ║`); + console.log(`║ ║`); + console.log( + `║ Press Ctrl-b then d to detach and keep agent running ║`, + ); + console.log( + `╚════════════════════════════════════════════════════════════════╝\n`, + ); + } + + return new Promise((resolve) => { + try { + // Spawn tmux attach with inherited stdio + const child = spawn("tmux", ["attach", "-t", sessionName], { + stdio: "inherit", + detached: false, + }); + + child.on("error", (error) => { + // Kill session on attach error + killTmuxSession(sessionName); + resolve({ + success: false, + error: `Failed to attach to session: ${error.message}`, + }); + }); + + child.on("exit", async (code) => { + // Exit code 0 means user detached successfully + if (code === 0 || code === null) { + resolve({ + success: true, + exitCode: code || 0, + }); + return; + } + + // Non-zero exit code indicates failure - kill the session + killTmuxSession(sessionName); + + // One-time recovery: recreate the session and attach (if not already retried) + // This handles cases where the pane died between session creation and attach + if (retryCount >= 1) { + resolve({ + success: false, + exitCode: code, + error: `Attach process exited with code ${code} after retry. The launch command may be invalid or exiting immediately.`, + }); + return; + } + + if (!silent) { + console.log( + `\nSession pane died. Attempting to recreate session...\n`, + ); + } + const retry = await launchAgent(agent, { attach: true, silent, retryCount: retryCount + 1 }); + resolve(retry); + }); + } catch (error) { + // Kill session on catch error + killTmuxSession(sessionName); + resolve({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }); +} diff --git a/apps/cli/src/lib/orchestrators/__tests__/change-orchestrator.test.ts b/apps/cli/src/lib/orchestrators/__tests__/change-orchestrator.test.ts index f0944a0af1b..ab3ca226f40 100644 --- a/apps/cli/src/lib/orchestrators/__tests__/change-orchestrator.test.ts +++ b/apps/cli/src/lib/orchestrators/__tests__/change-orchestrator.test.ts @@ -34,7 +34,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const change = await orchestrator.create({ @@ -56,7 +56,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const changes = await orchestrator.list(workspace.id); @@ -68,12 +68,12 @@ describe("ChangeOrchestrator", () => { const ws1 = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test1", + { path: "/tmp/test1" }, ); const ws2 = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test2", + { path: "/tmp/test2" }, ); const c1 = await orchestrator.create({ @@ -97,7 +97,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const c1 = await orchestrator.create({ @@ -124,7 +124,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const change = await orchestrator.create({ workspaceId: workspace.id, @@ -151,7 +151,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const change = await orchestrator.create({ workspaceId: workspace.id, @@ -170,7 +170,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const change = await orchestrator.create({ workspaceId: workspace.id, @@ -209,7 +209,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const c1 = await orchestrator.create({ workspaceId: workspace.id, diff --git a/apps/cli/src/lib/orchestrators/__tests__/environment-orchestrator.test.ts b/apps/cli/src/lib/orchestrators/__tests__/environment-orchestrator.test.ts index cd760ec1767..0d1304dec76 100644 --- a/apps/cli/src/lib/orchestrators/__tests__/environment-orchestrator.test.ts +++ b/apps/cli/src/lib/orchestrators/__tests__/environment-orchestrator.test.ts @@ -109,7 +109,7 @@ describe("EnvironmentOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); await orchestrator.delete(env.id); @@ -122,7 +122,7 @@ describe("EnvironmentOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = await processOrchestrator.create( ProcessType.TERMINAL, @@ -139,12 +139,12 @@ describe("EnvironmentOrchestrator", () => { const ws1 = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test1", + { path: "/tmp/test1" }, ); const ws2 = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test2", + { path: "/tmp/test2" }, ); await processOrchestrator.create(ProcessType.TERMINAL, ws1); diff --git a/apps/cli/src/lib/orchestrators/__tests__/process-orchestrator.test.ts b/apps/cli/src/lib/orchestrators/__tests__/process-orchestrator.test.ts index 956c8a4bf8e..d67b6f2ef5c 100644 --- a/apps/cli/src/lib/orchestrators/__tests__/process-orchestrator.test.ts +++ b/apps/cli/src/lib/orchestrators/__tests__/process-orchestrator.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Agent } from "../../../types/process"; -import { AgentType, ProcessType } from "../../../types/process"; +import { AgentType, ProcessStatus, ProcessType } from "../../../types/process"; import { WorkspaceType } from "../../../types/workspace"; import { LowdbAdapter } from "../../storage/lowdb-adapter"; import { EnvironmentOrchestrator } from "../environment-orchestrator"; @@ -36,7 +36,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = await orchestrator.create( @@ -57,7 +57,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = (await orchestrator.create( @@ -68,7 +68,7 @@ describe("ProcessOrchestrator", () => { expect(process.type).toBe(ProcessType.AGENT); expect(process.agentType).toBe(AgentType.CLAUDE); - expect(process.status).toBe("idle"); + expect(process.status).toBe(ProcessStatus.IDLE); }); }); @@ -78,7 +78,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = await orchestrator.create( ProcessType.TERMINAL, @@ -107,7 +107,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const p1 = await orchestrator.create(ProcessType.TERMINAL, workspace); @@ -128,12 +128,12 @@ describe("ProcessOrchestrator", () => { const ws1 = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test1", + { path: "/tmp/test1" }, ); const ws2 = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test2", + { path: "/tmp/test2" }, ); const p1 = await orchestrator.create(ProcessType.TERMINAL, ws1); @@ -151,7 +151,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = await orchestrator.create( ProcessType.TERMINAL, @@ -172,7 +172,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = await orchestrator.create( ProcessType.TERMINAL, @@ -204,7 +204,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = await orchestrator.create( ProcessType.TERMINAL, @@ -222,7 +222,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = (await orchestrator.create( ProcessType.AGENT, @@ -233,22 +233,22 @@ describe("ProcessOrchestrator", () => { await orchestrator.stop(process.id); const retrieved = (await orchestrator.get(process.id)) as Agent; - expect(retrieved.status).toBe("stopped"); + expect(retrieved.status).toBe(ProcessStatus.STOPPED); expect(retrieved.endedAt).toBeInstanceOf(Date); }); }); describe("stopAll", () => { - test("stops all running processes", async () => { + test("stops all running agents (but not terminals)", async () => { const env = await environmentOrchestrator.create(); const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); - const p1 = await orchestrator.create(ProcessType.TERMINAL, workspace); - const p2 = await orchestrator.create( + const terminal = await orchestrator.create(ProcessType.TERMINAL, workspace); + const agent = await orchestrator.create( ProcessType.AGENT, workspace, AgentType.CLAUDE, @@ -256,34 +256,38 @@ describe("ProcessOrchestrator", () => { await orchestrator.stopAll(); - const retrieved1 = await orchestrator.get(p1.id); - const retrieved2 = (await orchestrator.get(p2.id)) as Agent; + const retrievedTerminal = await orchestrator.get(terminal.id); + const retrievedAgent = (await orchestrator.get(agent.id)) as Agent; - expect(retrieved1.endedAt).toBeInstanceOf(Date); - expect(retrieved2.endedAt).toBeInstanceOf(Date); - expect(retrieved2.status).toBe("stopped"); + // Terminal should NOT be stopped (stopAll only stops agents) + expect(retrievedTerminal.endedAt).toBeUndefined(); + + // Agent should be stopped + expect(retrievedAgent.endedAt).toBeInstanceOf(Date); + expect(retrievedAgent.status).toBe(ProcessStatus.STOPPED); }); - test("does not update already stopped processes", async () => { + test("does not update already stopped agents", async () => { const env = await environmentOrchestrator.create(); const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); - const process = await orchestrator.create( - ProcessType.TERMINAL, + const agent = await orchestrator.create( + ProcessType.AGENT, workspace, + AgentType.CLAUDE, ); - await orchestrator.stop(process.id); + await orchestrator.stop(agent.id); - const firstStopped = await orchestrator.get(process.id); + const firstStopped = await orchestrator.get(agent.id); const firstEndedAt = firstStopped.endedAt!; await orchestrator.stopAll(); - const secondStopped = await orchestrator.get(process.id); + const secondStopped = await orchestrator.get(agent.id); expect(secondStopped.endedAt!.getTime()).toBe(firstEndedAt.getTime()); }); }); @@ -294,7 +298,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = await orchestrator.create( ProcessType.TERMINAL, @@ -310,7 +314,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const process = await orchestrator.create( ProcessType.AGENT, diff --git a/apps/cli/src/lib/orchestrators/__tests__/workspace-orchestrator.test.ts b/apps/cli/src/lib/orchestrators/__tests__/workspace-orchestrator.test.ts index 55670b865ad..29df148befb 100644 --- a/apps/cli/src/lib/orchestrators/__tests__/workspace-orchestrator.test.ts +++ b/apps/cli/src/lib/orchestrators/__tests__/workspace-orchestrator.test.ts @@ -32,11 +32,9 @@ describe("WorkspaceOrchestrator", () => { describe("create", () => { test("creates local workspace with path", async () => { const env = await environmentOrchestrator.create(); - const workspace = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - "/tmp/test", - ); + const workspace = await orchestrator.create(env.id, WorkspaceType.LOCAL, { + path: "/tmp/test", + }); expect(workspace.id).toBeDefined(); expect(workspace.type).toBe(WorkspaceType.LOCAL); @@ -57,11 +55,9 @@ describe("WorkspaceOrchestrator", () => { describe("get", () => { test("retrieves existing workspace", async () => { const env = await environmentOrchestrator.create(); - const workspace = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - "/tmp/test", - ); + const workspace = await orchestrator.create(env.id, WorkspaceType.LOCAL, { + path: "/tmp/test", + }); const retrieved = await orchestrator.get(workspace.id); expect(retrieved).toEqual(workspace); @@ -82,16 +78,12 @@ describe("WorkspaceOrchestrator", () => { test("returns all workspaces", async () => { const env = await environmentOrchestrator.create(); - const ws1 = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - "/tmp/test1", - ); - const ws2 = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - "/tmp/test2", - ); + const ws1 = await orchestrator.create(env.id, WorkspaceType.LOCAL, { + path: "/tmp/test1", + }); + const ws2 = await orchestrator.create(env.id, WorkspaceType.LOCAL, { + path: "/tmp/test2", + }); const workspaces = await orchestrator.list(); expect(workspaces).toHaveLength(2); @@ -103,12 +95,12 @@ describe("WorkspaceOrchestrator", () => { const env1 = await environmentOrchestrator.create(); const env2 = await environmentOrchestrator.create(); - const ws1 = await orchestrator.create( - env1.id, - WorkspaceType.LOCAL, - "/tmp/test1", - ); - await orchestrator.create(env2.id, WorkspaceType.LOCAL, "/tmp/test2"); + const ws1 = await orchestrator.create(env1.id, WorkspaceType.LOCAL, { + path: "/tmp/test1", + }); + await orchestrator.create(env2.id, WorkspaceType.LOCAL, { + path: "/tmp/test2", + }); const workspaces = await orchestrator.list(env1.id); expect(workspaces).toHaveLength(1); @@ -119,11 +111,9 @@ describe("WorkspaceOrchestrator", () => { describe("update", () => { test("updates workspace properties", async () => { const env = await environmentOrchestrator.create(); - const workspace = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - "/tmp/test", - ); + const workspace = await orchestrator.create(env.id, WorkspaceType.LOCAL, { + path: "/tmp/test", + }); await orchestrator.update(workspace.id, { type: WorkspaceType.CLOUD }); const retrieved = await orchestrator.get(workspace.id); @@ -135,11 +125,9 @@ describe("WorkspaceOrchestrator", () => { describe("delete", () => { test("deletes workspace", async () => { const env = await environmentOrchestrator.create(); - const workspace = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - "/tmp/test", - ); + const workspace = await orchestrator.create(env.id, WorkspaceType.LOCAL, { + path: "/tmp/test", + }); await orchestrator.delete(workspace.id); expect(orchestrator.get(workspace.id)).rejects.toThrow(); @@ -147,11 +135,9 @@ describe("WorkspaceOrchestrator", () => { test("cascade deletes changes", async () => { const env = await environmentOrchestrator.create(); - const workspace = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - "/tmp/test", - ); + const workspace = await orchestrator.create(env.id, WorkspaceType.LOCAL, { + path: "/tmp/test", + }); const change = await changeOrchestrator.create({ workspaceId: workspace.id, summary: "Test change", @@ -166,11 +152,9 @@ describe("WorkspaceOrchestrator", () => { test("cascade deletes file diffs through changes", async () => { const env = await environmentOrchestrator.create(); - const workspace = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - "/tmp/test", - ); + const workspace = await orchestrator.create(env.id, WorkspaceType.LOCAL, { + path: "/tmp/test", + }); const change = await changeOrchestrator.create({ workspaceId: workspace.id, summary: "Test change", diff --git a/apps/cli/src/lib/orchestrators/process-orchestrator.ts b/apps/cli/src/lib/orchestrators/process-orchestrator.ts index ee7f186e3da..6cf15e275ac 100644 --- a/apps/cli/src/lib/orchestrators/process-orchestrator.ts +++ b/apps/cli/src/lib/orchestrators/process-orchestrator.ts @@ -1,15 +1,48 @@ +import { exec, execSync } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { promisify } from "node:util"; import { type Agent, type AgentType, type ProcessOrchestrator as IProcessOrchestrator, type Process, + ProcessStatus, ProcessType, type Terminal, } from "../../types/process"; import type { Workspace } from "../../types/workspace"; +import { getDefaultLaunchCommand } from "../launch/config"; import type { StorageAdapter } from "../storage/adapter"; +const execAsync = promisify(exec); + +/** + * Check if tmux is installed + */ +function isTmuxInstalled(): boolean { + try { + execSync("which tmux", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * Check if a tmux session exists (synchronous to avoid promise hangs) + */ +function tmuxSessionExists(sessionName: string): boolean { + if (!isTmuxInstalled()) { + return false; + } + try { + execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + /** * Process orchestrator implementation using storage adapter * Handles CRUD operations for processes (agents and terminals) @@ -22,12 +55,43 @@ export class ProcessOrchestrator implements IProcessOrchestrator { if (!process) { throw new Error(`Process with id ${id} not found`); } - return process; + + const backfilled = this.backfillDefaults(process); + + // Sync agent status with tmux session reality + let needsSync = false; + if (backfilled.type === ProcessType.AGENT && "sessionName" in backfilled) { + needsSync = this.syncAgentStatus(backfilled as Agent); + } + + // Persist backfilled defaults or status sync if needed + if (this.needsPersist(process) || needsSync) { + await this.storage.set("processes", id, backfilled); + } + + return backfilled; } async list(workspaceId?: string): Promise { const processes = await this.storage.getCollection("processes"); - const processList = Object.values(processes); + const processList: Process[] = []; + + for (const [id, process] of Object.entries(processes)) { + const backfilled = this.backfillDefaults(process); + + // Sync agent status with tmux session reality + let needsSync = false; + if (backfilled.type === ProcessType.AGENT && "sessionName" in backfilled) { + needsSync = this.syncAgentStatus(backfilled as Agent); + } + + // Persist backfilled defaults or status sync if needed + if (this.needsPersist(process) || needsSync) { + await this.storage.set("processes", id, backfilled); + } + + processList.push(backfilled); + } if (workspaceId) { return processList.filter((p) => p.workspaceId === workspaceId); @@ -36,17 +100,108 @@ export class ProcessOrchestrator implements IProcessOrchestrator { return processList; } + private needsPersist(process: Process): boolean { + return ( + !process.status || + !process.createdAt || + !process.updatedAt || + (process.type === ProcessType.AGENT && + "agentType" in process && + (!process.launchCommand || + !("sessionName" in process) || + !(process as Agent).sessionName || + (process.agentType === "claude" && + (process.launchCommand === "claude-code" || + process.launchCommand === "claude-code shell")) || + (process.agentType === "codex" && process.launchCommand === "code"))) + ); + } + + /** + * Sync agent status with tmux session reality + * Returns true if status was changed and needs persisting + */ + private syncAgentStatus(agent: Agent): boolean { + if (!agent.sessionName) { + return false; + } + + const sessionExists = tmuxSessionExists(agent.sessionName); + + // If session doesn't exist but agent is not already STOPPED, mark it STOPPED + if (!sessionExists && agent.status !== ProcessStatus.STOPPED) { + agent.status = ProcessStatus.STOPPED; + agent.endedAt = new Date(); + agent.updatedAt = new Date(); + return true; + } + + // If session exists and agent is not RUNNING, upgrade to RUNNING + // This handles IDLE → RUNNING and revival of STOPPED agents + // Clear endedAt when reviving to indicate the agent is active again + if (sessionExists && agent.status !== ProcessStatus.RUNNING) { + agent.status = ProcessStatus.RUNNING; + agent.endedAt = undefined; // Clear endedAt when session is alive + agent.updatedAt = new Date(); + return true; + } + + return false; + } + + private backfillDefaults(process: Process): Process { + const now = new Date(); + // Determine status based on endedAt + const defaultStatus = process.endedAt + ? ProcessStatus.STOPPED + : ProcessStatus.IDLE; + + const backfilled = { + ...process, + status: process.status || defaultStatus, + createdAt: process.createdAt || now, + updatedAt: process.updatedAt || now, + }; + + // Backfill or update launchCommand and sessionName for agents + if (process.type === ProcessType.AGENT && "agentType" in process) { + const agent = backfilled as Agent; + const currentDefault = getDefaultLaunchCommand(agent.agentType); + + // Update if missing or outdated + const isOutdated = + !agent.launchCommand || + (agent.agentType === "claude" && + (agent.launchCommand === "claude-code" || + agent.launchCommand === "claude-code shell")) || + (agent.agentType === "codex" && agent.launchCommand === "code"); + + if (isOutdated) { + agent.launchCommand = currentDefault; + } + + // Backfill sessionName if missing + if (!agent.sessionName) { + agent.sessionName = `agent-${agent.id.slice(0, 6)}`; + } + } + + return backfilled; + } + async create( type: ProcessType, workspace: Workspace, agentType?: AgentType, ): Promise { const now = new Date(); + const id = randomUUID(); const baseProcess = { - id: randomUUID(), + id, type, workspaceId: workspace.id, title: type === ProcessType.AGENT ? "Agent" : "Terminal", + status: ProcessStatus.IDLE, createdAt: now, updatedAt: now, }; @@ -56,7 +211,7 @@ export class ProcessOrchestrator implements IProcessOrchestrator { ? ({ ...baseProcess, agentType, - status: "idle", + sessionName: `agent-${id.slice(0, 6)}`, } as Agent) : type === ProcessType.TERMINAL ? (baseProcess as Terminal) @@ -87,6 +242,19 @@ export class ProcessOrchestrator implements IProcessOrchestrator { async stop(id: string): Promise { const existing = await this.get(id); + + // Kill tmux session if it's an agent with a sessionName + if (existing.type === ProcessType.AGENT && "sessionName" in existing) { + const agent = existing as Agent; + if (agent.sessionName) { + try { + await execAsync(`tmux kill-session -t "${agent.sessionName}" 2>/dev/null`); + } catch { + // Ignore errors (session might already be dead) + } + } + } + const updated = { ...existing, endedAt: new Date(), @@ -95,18 +263,37 @@ export class ProcessOrchestrator implements IProcessOrchestrator { // Update status for agents if ("status" in updated) { - (updated as Agent).status = "stopped"; + (updated as Agent).status = ProcessStatus.STOPPED; } await this.storage.set("processes", id, updated); } - async stopAll(): Promise { + /** + * Stop all running agents (does not affect terminals) + * Kills their tmux sessions and marks them as STOPPED + * @returns The number of agents stopped + */ + async stopAll(): Promise { const processes = await this.storage.getCollection("processes"); const now = new Date(); + let stoppedCount = 0; for (const [id, process] of Object.entries(processes)) { - if (!process.endedAt) { + // Only stop agents, not terminals + if (process.type === ProcessType.AGENT && !process.endedAt) { + // Kill tmux session if agent has sessionName + if ("sessionName" in process) { + const agent = process as Agent; + if (agent.sessionName) { + try { + await execAsync(`tmux kill-session -t "${agent.sessionName}" 2>/dev/null`); + } catch { + // Ignore errors (session might already be dead) + } + } + } + const updated = { ...process, endedAt: now, @@ -115,12 +302,15 @@ export class ProcessOrchestrator implements IProcessOrchestrator { // Update status for agents if ("status" in updated) { - (updated as Agent).status = "stopped"; + (updated as Agent).status = ProcessStatus.STOPPED; } await this.storage.set("processes", id, updated); + stoppedCount++; } } + + return stoppedCount; } async delete(id: string): Promise { diff --git a/apps/cli/src/lib/orchestrators/workspace-orchestrator.ts b/apps/cli/src/lib/orchestrators/workspace-orchestrator.ts index 53285d99e66..dd384d0e0e8 100644 --- a/apps/cli/src/lib/orchestrators/workspace-orchestrator.ts +++ b/apps/cli/src/lib/orchestrators/workspace-orchestrator.ts @@ -3,8 +3,8 @@ import type { WorkspaceOrchestrator as IWorkspaceOrchestrator, LocalWorkspace, Workspace, - WorkspaceType, } from "../../types/workspace"; +import { WorkspaceType } from "../../types/workspace"; import type { StorageAdapter } from "../storage/adapter"; /** @@ -19,12 +19,27 @@ export class WorkspaceOrchestrator implements IWorkspaceOrchestrator { if (!workspace) { throw new Error(`Workspace with id ${id} not found`); } - return workspace; + + const backfilled = this.backfillDefaults(workspace); + + // Persist backfilled defaults if they were added + const needsPersist = + !workspace.createdAt || + !workspace.updatedAt || + workspace.defaultAgents === undefined; + + if (needsPersist) { + await this.storage.set("workspaces", id, backfilled); + } + + return backfilled; } async list(environmentId?: string): Promise { const workspaces = await this.storage.getCollection("workspaces"); - const workspaceList = Object.values(workspaces); + const workspaceList = Object.values(workspaces).map((w) => + this.backfillDefaults(w), + ); if (environmentId) { return workspaceList.filter((w) => w.environmentId === environmentId); @@ -33,29 +48,123 @@ export class WorkspaceOrchestrator implements IWorkspaceOrchestrator { return workspaceList; } + async getCurrent(): Promise { + const db = await this.storage.read(); + + // Defensive: handle missing state object (old DB files) + if (!db.state) { + db.state = {}; + await this.storage.write(db); + return null; + } + + const currentId = db.state.currentWorkspaceId; + + if (!currentId) { + return null; + } + + try { + return await this.get(currentId); + } catch { + // Current workspace was deleted, clear the pointer + const newDb = await this.storage.read(); + if (!newDb.state) { + newDb.state = {}; + } + newDb.state.currentWorkspaceId = undefined; + await this.storage.write(newDb); + return null; + } + } + + private backfillDefaults(workspace: Workspace): Workspace { + const now = new Date(); + return { + ...workspace, + createdAt: workspace.createdAt || now, + updatedAt: workspace.updatedAt || now, + lastUsedAt: workspace.lastUsedAt, + name: workspace.name, + description: workspace.description, + defaultAgents: workspace.defaultAgents || [], + }; + } + async create( environmentId: string, type: WorkspaceType, - path?: string, + options?: { + path?: string; + branch?: string; + name?: string; + description?: string; + defaultAgents?: string[]; + }, ): Promise { - const workspace: Workspace | LocalWorkspace = - type === "local" && path - ? ({ - id: randomUUID(), - type, - environmentId, - path, - } as LocalWorkspace) - : { - id: randomUUID(), - type, - environmentId, - }; + // Validate required fields based on type + if (type === WorkspaceType.LOCAL && !options?.path) { + throw new Error("Local workspace requires a path"); + } + if (type === WorkspaceType.CLOUD && !options?.branch) { + throw new Error("Cloud workspace requires a branch/ref"); + } + + const now = new Date(); + const baseWorkspace = { + id: randomUUID(), + type, + environmentId, + name: options?.name, + description: options?.description, + createdAt: now, + updatedAt: now, + defaultAgents: options?.defaultAgents || [], + }; + + let workspace: Workspace; + if (type === WorkspaceType.LOCAL) { + workspace = { + ...baseWorkspace, + type: WorkspaceType.LOCAL, + path: options!.path!, + } as LocalWorkspace; + } else if (type === WorkspaceType.CLOUD) { + workspace = { + ...baseWorkspace, + type: WorkspaceType.CLOUD, + branch: options!.branch!, + } as any; // CloudWorkspace + } else { + throw new Error(`Unknown workspace type: ${type}`); + } await this.storage.set("workspaces", workspace.id, workspace); + + // Auto-select the newly created workspace + await this.use(workspace.id); + return workspace; } + async use(id: string): Promise { + // Verify workspace exists + await this.get(id); + + // Update lastUsedAt + const workspace = await this.storage.get("workspaces", id); + if (workspace) { + workspace.lastUsedAt = new Date(); + workspace.updatedAt = new Date(); + await this.storage.set("workspaces", id, workspace); + } + + // Set as current workspace + const db = await this.storage.read(); + db.state.currentWorkspaceId = id; + await this.storage.write(db); + } + async update(id: string, updates: Partial): Promise { const existing = await this.get(id); diff --git a/apps/cli/src/lib/persistence_docs/EXAMPLE.md b/apps/cli/src/lib/persistence_docs/EXAMPLE.md index 6659d26027e..33181f5f196 100644 --- a/apps/cli/src/lib/persistence_docs/EXAMPLE.md +++ b/apps/cli/src/lib/persistence_docs/EXAMPLE.md @@ -114,11 +114,14 @@ await processOrch.update(claudeAgent.id, { // Stop a specific process await processOrch.stop(terminal.id); -// Stop all processes +// Stop all agents (not terminals) await processOrch.stopAll(); -// Check if stopped -const stopped = await processOrch.get(terminal.id); +// Note: terminal won't be stopped by stopAll (only agents are stopped) +// To check if an agent was stopped: +const agent = await processOrch.create(ProcessType.AGENT, workspace, AgentType.CLAUDE); +await processOrch.stopAll(); +const stopped = await processOrch.get(agent.id); console.log("Ended at:", stopped.endedAt); // Date object ``` diff --git a/apps/cli/src/lib/persistence_docs/SUMMARY.md b/apps/cli/src/lib/persistence_docs/SUMMARY.md index 3a2a0a52636..2e5a8ded9a5 100644 --- a/apps/cli/src/lib/persistence_docs/SUMMARY.md +++ b/apps/cli/src/lib/persistence_docs/SUMMARY.md @@ -200,7 +200,7 @@ const changesInWorkspace = await changes.list(workspace.id); await environments.update(env.id, { /* updates */ }); await processes.update(process.id, { title: "New Title" }); await processes.stop(process.id); // Sets endedAt -await processes.stopAll(); // Stops all running processes +await processes.stopAll(); // Stops all running agents (not terminals) ``` ### Deleting Data diff --git a/apps/cli/src/lib/storage/__tests__/lowdb-adapter.test.ts b/apps/cli/src/lib/storage/__tests__/lowdb-adapter.test.ts index 5febc307525..a6fa652c4d5 100644 --- a/apps/cli/src/lib/storage/__tests__/lowdb-adapter.test.ts +++ b/apps/cli/src/lib/storage/__tests__/lowdb-adapter.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Change, Environment, Process } from "../../../types/index"; -import { ProcessType } from "../../../types/process"; +import { ProcessStatus, ProcessType } from "../../../types/process"; import { LowdbAdapter } from "../lowdb-adapter"; describe("LowdbAdapter", () => { @@ -124,6 +124,7 @@ describe("LowdbAdapter", () => { id: "proc-1", type: ProcessType.TERMINAL, workspaceId: "ws-1", + status: ProcessStatus.RUNNING, title: "Test Process", createdAt: now, updatedAt: now, @@ -143,6 +144,7 @@ describe("LowdbAdapter", () => { id: "proc-1", type: ProcessType.TERMINAL, workspaceId: "ws-1", + status: ProcessStatus.RUNNING, title: "Test Process", createdAt: now, updatedAt: now, diff --git a/apps/cli/src/lib/storage/adapter.ts b/apps/cli/src/lib/storage/adapter.ts index a3d841240b5..859ce8ca076 100644 --- a/apps/cli/src/lib/storage/adapter.ts +++ b/apps/cli/src/lib/storage/adapter.ts @@ -1,5 +1,13 @@ import type { Database } from "./types"; +// Helper type to extract value type from Record collections +type CollectionValue = Database[K] extends Record< + string, + infer V +> + ? V + : never; + /** * Generic storage adapter interface * Abstracts persistence layer to allow easy migration from Lowdb to other solutions @@ -34,7 +42,7 @@ export interface StorageAdapter { get( collection: K, id: string, - ): Promise; + ): Promise | undefined>; /** * Set a single entity in a collection @@ -42,7 +50,7 @@ export interface StorageAdapter { set( collection: K, id: string, - value: Database[K][string], + value: CollectionValue, ): Promise; /** diff --git a/apps/cli/src/lib/storage/lowdb-adapter.ts b/apps/cli/src/lib/storage/lowdb-adapter.ts index 8abd9ca3ea4..c92cda91d9e 100644 --- a/apps/cli/src/lib/storage/lowdb-adapter.ts +++ b/apps/cli/src/lib/storage/lowdb-adapter.ts @@ -10,6 +10,14 @@ import { type SerializedDatabase, } from "./types"; +// Helper type to extract value type from Record collections +type CollectionValue = Database[K] extends Record< + string, + infer V +> + ? V + : never; + /** * Lowdb implementation of StorageAdapter * Handles JSON file persistence with date serialization/deserialization @@ -36,10 +44,18 @@ export class LowdbAdapter implements StorageAdapter { await mkdir(parentDir, { recursive: true, mode: 0o700 }); } + // Check if database file exists before creating + const dbExists = existsSync(this.dbPath); + this.db = await JSONFilePreset( this.dbPath, createEmptyDatabase(), ); + + // If database was just created, write the default data to disk + if (!dbExists) { + await this.db.write(); + } } /** @@ -133,21 +149,23 @@ export class LowdbAdapter implements StorageAdapter { async get( collection: K, id: string, - ): Promise { + ): Promise | undefined> { await this.init(); await this.db!.read(); - const item = this.db!.data[collection][id]; + const coll = this.db!.data[collection] as Record; + const item = coll[id]; return item ? this.deserializeDates(item) : undefined; } async set( collection: K, id: string, - value: Database[K][string], + value: CollectionValue, ): Promise { await this.init(); await this.db!.read(); - this.db!.data[collection][id] = this.serializeDates(value); + const coll = this.db!.data[collection] as Record; + coll[id] = this.serializeDates(value); await this.db!.write(); } @@ -157,7 +175,8 @@ export class LowdbAdapter implements StorageAdapter { ): Promise { await this.init(); await this.db!.read(); - delete this.db!.data[collection][id]; + const coll = this.db!.data[collection] as Record; + delete coll[id]; await this.db!.write(); } @@ -167,7 +186,8 @@ export class LowdbAdapter implements StorageAdapter { ): Promise { await this.init(); await this.db!.read(); - return id in this.db!.data[collection]; + const coll = this.db!.data[collection] as Record; + return id in coll; } async clear(): Promise { diff --git a/apps/cli/src/lib/storage/types.ts b/apps/cli/src/lib/storage/types.ts index 124d3765d82..06ea4e045f3 100644 --- a/apps/cli/src/lib/storage/types.ts +++ b/apps/cli/src/lib/storage/types.ts @@ -19,6 +19,9 @@ export interface Database { changes: Record; fileDiffs: Record; agentSummaries: Record; + state: { + currentWorkspaceId?: string; + }; } /** @@ -32,6 +35,9 @@ export interface SerializedDatabase { changes: Record; fileDiffs: Record; agentSummaries: Record; + state: { + currentWorkspaceId?: string; + }; } // Serialized type helpers - convert Date fields to string @@ -53,12 +59,18 @@ export type SerializedAgentSummary = Serialized; /** * Empty database structure for initialization + * Includes a default environment to get started */ export const createEmptyDatabase = (): SerializedDatabase => ({ - environments: {}, + environments: { + default: { + id: "default", + }, + }, workspaces: {}, processes: {}, changes: {}, fileDiffs: {}, agentSummaries: {}, + state: {}, }); diff --git a/apps/cli/src/types/process.ts b/apps/cli/src/types/process.ts index 12a6cb2bb0e..386664ea38f 100644 --- a/apps/cli/src/types/process.ts +++ b/apps/cli/src/types/process.ts @@ -5,30 +5,43 @@ export enum ProcessType { TERMINAL = "terminal", } +export enum ProcessStatus { + RUNNING = "running", + IDLE = "idle", + STOPPED = "stopped", + ERROR = "error", +} + export interface Process { id: string; type: ProcessType; workspaceId: string; + status: ProcessStatus; // Metadata title: string; createdAt: Date; updatedAt: Date; endedAt?: Date; + pid?: number; + lastHeartbeat?: Date; + launchCommand?: string; } export interface Terminal extends Process { - // Placeholder + type: ProcessType.TERMINAL; } export enum AgentType { CODEX = "codex", CLAUDE = "claude", + CURSOR = "cursor", } export interface Agent extends Process { + type: ProcessType.AGENT; agentType: AgentType; - status: "idle" | "running" | "stopped" | "error"; + sessionName?: string; } export interface ProcessOrchestrator { @@ -42,7 +55,7 @@ export interface ProcessOrchestrator { ) => Promise; update: (id: string, process: Partial) => void; stop: (id: string) => void; - stopAll: () => void; + stopAll: () => Promise; // Danger delete: (id: string) => void; diff --git a/apps/cli/src/types/workspace.ts b/apps/cli/src/types/workspace.ts index e7cdbc7ed53..9644d375082 100644 --- a/apps/cli/src/types/workspace.ts +++ b/apps/cli/src/types/workspace.ts @@ -7,6 +7,12 @@ export interface Workspace { id: string; type: WorkspaceType; environmentId: string; + name?: string; + description?: string; + createdAt: Date; + updatedAt: Date; + lastUsedAt?: Date; + defaultAgents?: string[]; // AgentType array } export interface LocalWorkspace extends Workspace { @@ -14,17 +20,30 @@ export interface LocalWorkspace extends Workspace { path: string; } +export interface CloudWorkspace extends Workspace { + type: WorkspaceType.CLOUD; + branch: string; // git branch/ref +} + export interface WorkspaceOrchestrator { get: (id: string) => Promise; list: (environmentId?: string) => Promise; + getCurrent: () => Promise; // Note: For cloud, will need more optional params create: ( environmentId: string, type: WorkspaceType, - path?: string, + options?: { + path?: string; + branch?: string; + name?: string; + description?: string; + defaultAgents?: string[]; + }, ) => Promise; update: (id: string, workspace: Partial) => void; + use: (id: string) => Promise; // Danger delete: (id: string) => void; diff --git a/bun.lock b/bun.lock index e60ff3d47d9..5f7127cbf4b 100644 --- a/bun.lock +++ b/bun.lock @@ -42,12 +42,15 @@ "apps/cli": { "name": "@superset/cli", "version": "0.0.0", - "bin": "dist/cli.js", + "bin": { + "superset": "dist/cli.js", + }, "dependencies": { "commander": "^14.0.2", "ink": "^6.5.0", "ink-select-input": "^6.2.0", "ink-table": "^3.1.0", + "ink-text-input": "^6.0.0", "lowdb": "^7.0.1", "meow": "^11.0.0", "react": "^19.1.1", @@ -2274,6 +2277,8 @@ "ink-testing-library": ["ink-testing-library@3.0.0", "", { "peerDependencies": { "@types/react": ">=18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-ItyyoOmcm6yftb7c5mZI2HU22BWzue8PBbO3DStmY8B9xaqfKr7QJONiWOXcwVsOk/6HuVQ0v7N5xhPaR3jycA=="], + "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], + "inline-style-parser": ["inline-style-parser@0.2.6", "", {}, "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],