From f975e3a24369491d01a9d2b4b180295fb599eaa6 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Wed, 19 Nov 2025 19:40:06 -0800 Subject: [PATCH 01/12] big boy --- WARP.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 WARP.md 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 From 613895c33ba9ed68b189c2c44acbe3f48d7790d9 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Wed, 19 Nov 2025 19:41:05 -0800 Subject: [PATCH 02/12] large man --- apps/cli/package.json | 5 +- apps/cli/src/cli.tsx | 113 ++- apps/cli/src/commands/agent.tsx | 261 ++++++- apps/cli/src/commands/dashboard.tsx | 690 ++++++++++++++++++ apps/cli/src/commands/index.ts | 4 + apps/cli/src/commands/init.tsx | 397 ++++++++++ apps/cli/src/commands/workspace.tsx | 64 +- apps/cli/src/lib/launch/config.ts | 58 ++ apps/cli/src/lib/launch/run.ts | 65 ++ .../__tests__/change-orchestrator.test.ts | 14 +- .../environment-orchestrator.test.ts | 4 +- .../__tests__/process-orchestrator.test.ts | 32 +- .../__tests__/workspace-orchestrator.test.ts | 22 +- .../lib/orchestrators/process-orchestrator.ts | 90 ++- .../orchestrators/workspace-orchestrator.ts | 143 +++- .../storage/__tests__/lowdb-adapter.test.ts | 4 +- apps/cli/src/lib/storage/adapter.ts | 12 +- apps/cli/src/lib/storage/lowdb-adapter.ts | 32 +- apps/cli/src/lib/storage/types.ts | 14 +- apps/cli/src/types/process.ts | 18 +- apps/cli/src/types/workspace.ts | 21 +- .../Sidebar/ModeCarousel/ModeNavigation.tsx | 2 +- .../TabView/Sidebar/ModeCarousel/index.tsx | 6 +- apps/old-desktop/src/main/index.ts | 2 +- .../src/main/lib/desktop-stores.ts | 6 +- .../src/main/lib/migration/migrator.ts | 10 +- .../old-desktop/src/main/lib/storage/index.ts | 2 +- .../src/main/lib/storage/lowdb-adapter.ts | 2 +- .../main/lib/storage/orchestrators/index.ts | 4 +- .../src/main/lib/types/cli-types.ts | 34 +- .../src/main/lib/ui-store/index.ts | 2 +- .../src/main/lib/ui-store/store.ts | 10 +- .../src/main/lib/ui-store/types.ts | 2 +- .../lib/workspace-composition/composer.ts | 4 +- .../main/lib/workspace-composition/index.ts | 2 +- .../main/lib/workspace-composition/types.ts | 2 +- .../src/main/lib/workspace-ipcs.ts | 2 +- .../src/renderer/contexts/AppProviders.tsx | 6 +- .../src/renderer/contexts/TabContext.tsx | 2 +- .../src/renderer/contexts/TaskContext.tsx | 4 +- .../contexts/WorktreeOperationsContext.tsx | 2 +- .../src/renderer/contexts/index.ts | 10 +- .../src/renderer/screens/main/MainScreen.tsx | 20 +- .../Layout/AddTaskModal/CreatingView.tsx | 2 +- .../Layout/AddTaskModal/TaskList.tsx | 2 +- .../components/Layout/AddTaskModal/index.ts | 2 +- .../components/Layout/AddTaskModal/types.ts | 2 +- .../Layout/AddTaskModal/useTaskForm.ts | 2 +- .../components/Layout/AddTaskModal/utils.ts | 2 +- .../components/MainContent/TabContent.tsx | 2 +- .../main/components/MainContent/TabGroup.tsx | 4 +- .../MainContentArea/MainContentArea.tsx | 8 +- .../main/components/PlanView/PlanView.tsx | 4 +- .../main/components/Sidebar/Sidebar.tsx | 6 +- .../Sidebar/components/ModeCarousel/index.ts | 2 +- .../components/WorktreeItem/WorktreeItem.tsx | 2 +- .../WorktreeItem/WorktreeItemArborist.tsx | 15 +- .../renderer/screens/main/hooks/useTasks.ts | 4 +- .../src/renderer/screens/main/utils.ts | 2 +- .../src/shared/ipc-channels/index.ts | 2 +- .../old-desktop/src/shared/ipc-channels/ui.ts | 2 +- .../components/HeroSection/HeroSection.tsx | 2 +- .../LitBackground/LitBackground.tsx | 6 +- apps/website/src/app/page.tsx | 6 +- bun.lock | 7 +- packages/ui/src/components/context-menu.tsx | 2 +- packages/ui/src/components/dialog.tsx | 2 +- packages/ui/src/components/dropdown-menu.tsx | 2 +- packages/ui/src/components/resizable.tsx | 2 +- packages/ui/src/components/select.tsx | 2 +- packages/ui/src/components/separator.tsx | 2 +- 71 files changed, 2092 insertions(+), 205 deletions(-) create mode 100644 apps/cli/src/commands/dashboard.tsx create mode 100644 apps/cli/src/commands/init.tsx create mode 100644 apps/cli/src/lib/launch/config.ts create mode 100644 apps/cli/src/lib/launch/run.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index e04e0bfd180..1f60a950956 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -2,7 +2,9 @@ "name": "@superset/cli", "version": "0.0.0", "license": "MIT", - "bin": "dist/cli.js", + "bin": { + "superset": "dist/cli.js" + }, "type": "module", "engines": { "node": ">=16" @@ -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/src/cli.tsx b/apps/cli/src/cli.tsx index f9bd7c2e02e..b114c50d02c 100644 --- a/apps/cli/src/cli.tsx +++ b/apps/cli/src/cli.tsx @@ -7,19 +7,23 @@ import { AgentDelete, AgentGet, AgentList, + AgentStart, AgentStop, AgentStopAll, ChangeCreate, ChangeDelete, ChangeList, + Dashboard, EnvCreate, EnvDelete, EnvGet, EnvList, + Init, WorkspaceCreate, WorkspaceDelete, WorkspaceGet, WorkspaceList, + WorkspaceUse, } from "./commands/index"; import { AgentType, ProcessType } from "./types/process"; import { WorkspaceType } from "./types/workspace"; @@ -33,8 +37,26 @@ 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(); + }); + // 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 +91,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 +152,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 +188,16 @@ 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("create") .description("Create a new agent/process") @@ -208,9 +252,17 @@ agent agent .command("stop-all") - .description("Stop all agents/processes") - .action(() => { - render( process.exit(0)} />); + .description( + "Stop all agents/processes (uses current workspace if --workspace not provided)", + ) + .option("--workspace ", "Workspace ID to stop agents in") + .action((options: { workspace?: string }) => { + render( + process.exit(0)} + />, + ); }); agent @@ -222,7 +274,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 +314,49 @@ 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 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..1495e9313e3 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,7 +338,7 @@ export function AgentStopAll({ onComplete }: AgentStopAllProps) { }; stopAll(); - }, [onComplete]); + }, [workspaceId, onComplete]); if (loading) { return Stopping all agents/processes...; @@ -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,196 @@ 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([]); + + 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[] = []; + for (const agentType of agents) { + // Get default launch command for this agent type + const launchCommand = getDefaultLaunchCommand(agentType); + + // Create the process + const process = await processOrchestrator.create( + ProcessType.AGENT, + ws, + agentType, + ); + + // Transition to RUNNING status and set launch command + await processOrchestrator.update(process.id, { + status: ProcessStatus.RUNNING, + launchCommand, + }); + + created.push(process); + } + + // Update workspace lastUsedAt and set as current if not already + await workspaceOrchestrator.use(ws.id); + + setStartedAgents(created); + setStep(StartStep.COMPLETE); + + // Auto-exit after showing success + setTimeout(() => { + exit(); + }, 2000); + } 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) { + return ( + + + āœ“ Started {startedAgents.length} agent(s) successfully! + + + Workspace: {workspace?.name || workspace?.id} + Agents: {selectedAgents.join(", ")} + + + + Run superset dashboard to view agent status + + + + ); + } + + return null; +} diff --git a/apps/cli/src/commands/dashboard.tsx b/apps/cli/src/commands/dashboard.tsx new file mode 100644 index 00000000000..846107aaa8a --- /dev/null +++ b/apps/cli/src/commands/dashboard.tsx @@ -0,0 +1,690 @@ +import { Box, Text, useApp, useInput } 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 }: DashboardProps) { + const [data, setData] = React.useState(null); + const [error, setError] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [launching, setLaunching] = React.useState(false); + 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 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; + + const currentList = + selectionMode === "workspace" ? data.workspaces : filteredAgents; + const currentIndex = + selectionMode === "workspace" + ? selectedWorkspaceIndex + : selectedAgentIndex; + + // 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] && + !launching + ) { + const selectedAgent = filteredAgents[selectedAgentIndex]; + + // Only launch agents, not terminals + if (selectedAgent.type !== ProcessType.AGENT) { + return; + } + + // Exit the Ink app before launching + // The launchAgent function will be called after exit + setLaunching(true); + + // Small delay to let the UI update, then exit and launch + const agentToLaunch = selectedAgent as Agent; + setTimeout(() => { + exit(); + // Give the terminal time to reset after Ink exits + setTimeout(() => { + launchAgent(agentToLaunch) + .then((result) => { + if (!result.success) { + console.error( + `\nāŒ Failed to launch ${agentToLaunch.agentType} agent\n`, + ); + console.error(`Error: ${result.error}\n`); + if (result.exitCode !== undefined) { + console.error(`Exit code: ${result.exitCode}\n`); + } + process.exit(1); + } + // Agent launched successfully and exited normally + process.exit(0); + }) + .catch((error) => { + console.error( + `\nāŒ Failed to launch ${agentToLaunch.agentType} agent\n`, + ); + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); + }); + }, 200); + }, 100); + } + } 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 (launching) { + return Launching agent...; + } + + 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()} + + + + + {/* Summary Stats */} + + + + + ⚔ + + Workspaces + + {workspaces.length} + + + │ + + + ā— + + Running + + {runningAgents.length} + + + │ + + + ā— + + Idle + + {idleAgents.length} + + + │ + + + ā— + + Stopped + + {stoppedAgents.length} + + + {errorAgents.length > 0 && ( + <> + │ + + + āœ– + + Error + + {errorAgents.length} + + + + )} + + + + {/* Current Workspace Indicator */} + {currentWorkspaceId && ( + + + + ā–¶ + + Current: + + {workspaces.find((w) => w.id === currentWorkspaceId)?.name || + currentWorkspaceId.slice(0, 8)} + + + + )} + + {/* Selection Mode & Filter */} + + + + {selectionMode === "workspace" ? "šŸ“ Workspaces" : "šŸ¤– Agents"} + + (TAB) + + + Filter: + + {filterByCurrent + ? "Current workspace" + : selectedWorkspace + ? `${selectedWorkspace.name || selectedWorkspace.id.slice(0, 8)}` + : "All"} + + (f) + + + + {/* Workspaces Section */} + + + + šŸ“ WORKSPACES + + ({workspaces.length}) + + {workspaces.length === 0 ? ( + + + No workspaces found. 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; + + return ( + + + {isSelected ? "ā–¶" : " "} + + + {wsRunning.length > 0 ? ( + + ā— + + ) : ( + + ā—‹ + + )} + + + {ws.name || ws.id.slice(0, 8)} + + + ({ws.type}) + + + {wsRunning.length}/{wsAgents.length} agents + + {isCurrent && !isSelected && ( + + {" "} + CURRENT{" "} + + )} + + ); + })} + + )} + + + {/* Agents Section */} + + + + šŸ¤– AGENTS + + ({filteredAgents.length}) + + {filteredAgents.length === 0 ? ( + + No agents in this workspace + + ) : ( + + {filteredAgents.map((agent, index) => { + const isSelected = + selectionMode === "agent" && index === selectedAgentIndex; + return ( + + + {isSelected ? "ā–¶" : " "} + + {getStatusBadge(agent)} + + {agent.type === ProcessType.AGENT && + "agentType" in agent && + String(agent.agentType)} + + + ({agent.id.slice(0, 8)}) + + + {new Date(agent.createdAt).toLocaleTimeString()} + + + ); + })} + + )} + + + {/* Keyboard Shortcuts */} + + + + āŒØļø KEYBOARD SHORTCUTS + + + + + + + {" "} + ↑↓{" "} + {" "} + + {" "} + 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..41d6175d11d 100644 --- a/apps/cli/src/commands/index.ts +++ b/apps/cli/src/commands/index.ts @@ -3,14 +3,18 @@ export { 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 { 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..2d10dfbb36f --- /dev/null +++ b/apps/cli/src/commands/init.tsx @@ -0,0 +1,397 @@ +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); + + // Validate that the default environment exists + const environments = await db.read(); + const defaultEnv = environments.environments["default"]; + + if (!defaultEnv) { + setState((s) => ({ + ...s, + error: + 'Default environment not found. Run "superset env create" first.', + step: InitStep.SELECT_TYPE, + })); + return; + } + + const workspace = await orchestrator.create( + "default", + 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/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/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..a59fc0d1028 --- /dev/null +++ b/apps/cli/src/lib/launch/run.ts @@ -0,0 +1,65 @@ +import { spawn } from "node:child_process"; +import type { Agent } from "../../types/process"; +import { getLaunchCommand } from "./config"; + +export interface LaunchResult { + success: boolean; + exitCode?: number; + error?: string; +} + +/** + * Launch an agent by spawning its configured command + * Returns a promise that resolves when the process exits + */ +export async function launchAgent(agent: Agent): 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`, + }; + } + + console.log(`\nLaunching ${agent.agentType} agent...\n`); + + return new Promise((resolve) => { + try { + // Spawn the process with inherited stdio so user can interact + // Use shell to properly handle the command and its arguments + const child = spawn(command, [], { + stdio: "inherit", + shell: true, + detached: false, + }); + + child.on("error", (error) => { + resolve({ + success: false, + error: `Failed to launch ${command}: ${error.message}`, + }); + }); + + child.on("exit", (code) => { + if (code === 0 || code === null) { + resolve({ + success: true, + exitCode: code || 0, + }); + } else { + resolve({ + success: false, + exitCode: code, + error: `Process exited with code ${code}`, + }); + } + }); + } catch (error) { + 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..903cb483e75 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", + "/tmp/test" }, ); const change = await orchestrator.create({ @@ -56,7 +56,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/tmp/test" }, ); const changes = await orchestrator.list(workspace.id); @@ -97,7 +97,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/tmp/test" }, ); const c1 = await orchestrator.create({ @@ -124,7 +124,7 @@ describe("ChangeOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/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", + "/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", + "/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", + "/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..30aab10976f 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", + "/tmp/test" }, ); await orchestrator.delete(env.id); @@ -122,7 +122,7 @@ describe("EnvironmentOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/tmp/test" }, ); const process = await processOrchestrator.create( ProcessType.TERMINAL, 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..35e902fdcc0 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", + "/tmp/test" }, ); const process = await orchestrator.create( @@ -57,7 +57,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/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", + "/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", + "/tmp/test" }, ); const p1 = await orchestrator.create(ProcessType.TERMINAL, workspace); @@ -151,7 +151,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/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", + "/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", + "/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", + "/tmp/test" }, ); const process = (await orchestrator.create( ProcessType.AGENT, @@ -233,7 +233,7 @@ 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); }); }); @@ -244,7 +244,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/tmp/test" }, ); const p1 = await orchestrator.create(ProcessType.TERMINAL, workspace); @@ -261,7 +261,7 @@ describe("ProcessOrchestrator", () => { expect(retrieved1.endedAt).toBeInstanceOf(Date); expect(retrieved2.endedAt).toBeInstanceOf(Date); - expect(retrieved2.status).toBe("stopped"); + expect(retrieved2.status).toBe(ProcessStatus.STOPPED); }); test("does not update already stopped processes", async () => { @@ -269,7 +269,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/tmp/test" }, ); const process = await orchestrator.create( @@ -294,7 +294,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/tmp/test" }, ); const process = await orchestrator.create( ProcessType.TERMINAL, @@ -310,7 +310,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + "/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..21b81bdbabe 100644 --- a/apps/cli/src/lib/orchestrators/__tests__/workspace-orchestrator.test.ts +++ b/apps/cli/src/lib/orchestrators/__tests__/workspace-orchestrator.test.ts @@ -35,7 +35,7 @@ describe("WorkspaceOrchestrator", () => { const workspace = await orchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); expect(workspace.id).toBeDefined(); @@ -60,7 +60,7 @@ describe("WorkspaceOrchestrator", () => { const workspace = await orchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const retrieved = await orchestrator.get(workspace.id); @@ -85,12 +85,12 @@ describe("WorkspaceOrchestrator", () => { const ws1 = await orchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test1", + { path: "/tmp/test1" }, ); const ws2 = await orchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test2", + { path: "/tmp/test2" }, ); const workspaces = await orchestrator.list(); @@ -106,9 +106,11 @@ describe("WorkspaceOrchestrator", () => { const ws1 = await orchestrator.create( env1.id, WorkspaceType.LOCAL, - "/tmp/test1", + { path: "/tmp/test1" }, ); - await orchestrator.create(env2.id, WorkspaceType.LOCAL, "/tmp/test2"); + await orchestrator.create(env2.id, WorkspaceType.LOCAL, { + path: "/tmp/test2", + }); const workspaces = await orchestrator.list(env1.id); expect(workspaces).toHaveLength(1); @@ -122,7 +124,7 @@ describe("WorkspaceOrchestrator", () => { const workspace = await orchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); await orchestrator.update(workspace.id, { type: WorkspaceType.CLOUD }); @@ -138,7 +140,7 @@ describe("WorkspaceOrchestrator", () => { const workspace = await orchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); await orchestrator.delete(workspace.id); @@ -150,7 +152,7 @@ describe("WorkspaceOrchestrator", () => { const workspace = await orchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const change = await changeOrchestrator.create({ workspaceId: workspace.id, @@ -169,7 +171,7 @@ describe("WorkspaceOrchestrator", () => { const workspace = await orchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test", + { path: "/tmp/test" }, ); const change = await changeOrchestrator.create({ workspaceId: workspace.id, diff --git a/apps/cli/src/lib/orchestrators/process-orchestrator.ts b/apps/cli/src/lib/orchestrators/process-orchestrator.ts index ee7f186e3da..41a92304ce5 100644 --- a/apps/cli/src/lib/orchestrators/process-orchestrator.ts +++ b/apps/cli/src/lib/orchestrators/process-orchestrator.ts @@ -4,10 +4,12 @@ import { 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"; /** @@ -22,12 +24,31 @@ export class ProcessOrchestrator implements IProcessOrchestrator { if (!process) { throw new Error(`Process with id ${id} not found`); } - return process; + + const backfilled = this.backfillDefaults(process); + + // Persist backfilled defaults if they were added or updated + if (this.needsPersist(process)) { + 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); + + // Persist backfilled defaults if they were added or updated + if (this.needsPersist(process)) { + await this.storage.set("processes", id, backfilled); + } + + processList.push(backfilled); + } if (workspaceId) { return processList.filter((p) => p.workspaceId === workspaceId); @@ -36,6 +57,56 @@ 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 || + (process.agentType === "claude" && + (process.launchCommand === "claude-code" || + process.launchCommand === "claude-code shell")) || + (process.agentType === "codex" && process.launchCommand === "code"))) + ); + } + + 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 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; + } + } + + return backfilled; + } + async create( type: ProcessType, workspace: Workspace, @@ -47,6 +118,7 @@ export class ProcessOrchestrator implements IProcessOrchestrator { type, workspaceId: workspace.id, title: type === ProcessType.AGENT ? "Agent" : "Terminal", + status: ProcessStatus.IDLE, createdAt: now, updatedAt: now, }; @@ -56,7 +128,6 @@ export class ProcessOrchestrator implements IProcessOrchestrator { ? ({ ...baseProcess, agentType, - status: "idle", } as Agent) : type === ProcessType.TERMINAL ? (baseProcess as Terminal) @@ -95,18 +166,20 @@ 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 { + 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) { const updated = { ...process, endedAt: now, @@ -115,12 +188,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/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..e8b7c640f3a 100644 --- a/apps/cli/src/types/process.ts +++ b/apps/cli/src/types/process.ts @@ -5,30 +5,42 @@ 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"; } export interface ProcessOrchestrator { @@ -42,7 +54,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/apps/desktop/src/renderer/screens/main/components/TabView/Sidebar/ModeCarousel/ModeNavigation.tsx b/apps/desktop/src/renderer/screens/main/components/TabView/Sidebar/ModeCarousel/ModeNavigation.tsx index e1b31261ae7..83c734e3770 100644 --- a/apps/desktop/src/renderer/screens/main/components/TabView/Sidebar/ModeCarousel/ModeNavigation.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TabView/Sidebar/ModeCarousel/ModeNavigation.tsx @@ -1,7 +1,7 @@ import type { MotionValue } from "framer-motion"; +import { AnimatedBackground } from "./AnimatedBackground"; import { modeIcons, modeLabels } from "./constants"; import type { SidebarMode } from "./types"; -import { AnimatedBackground } from "./AnimatedBackground"; interface ModeNavigationProps { modes: SidebarMode[]; diff --git a/apps/desktop/src/renderer/screens/main/components/TabView/Sidebar/ModeCarousel/index.tsx b/apps/desktop/src/renderer/screens/main/components/TabView/Sidebar/ModeCarousel/index.tsx index 3eb29b4fe4d..a40de159b53 100644 --- a/apps/desktop/src/renderer/screens/main/components/TabView/Sidebar/ModeCarousel/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TabView/Sidebar/ModeCarousel/index.tsx @@ -1,10 +1,10 @@ import { useCallback, useRef, useState } from "react"; -import { ModeContent } from "./ModeContent"; -import { ModeHeader } from "./ModeHeader"; -import { ModeNavigation } from "./ModeNavigation"; import { useModeDetection } from "./hooks/useModeDetection"; import { useScrollProgress } from "./hooks/useScrollProgress"; import { useScrollSnap } from "./hooks/useScrollSnap"; +import { ModeContent } from "./ModeContent"; +import { ModeHeader } from "./ModeHeader"; +import { ModeNavigation } from "./ModeNavigation"; import type { ModeCarouselProps } from "./types"; export function ModeCarousel({ diff --git a/apps/old-desktop/src/main/index.ts b/apps/old-desktop/src/main/index.ts index a9af8a02959..bde490c3e6d 100644 --- a/apps/old-desktop/src/main/index.ts +++ b/apps/old-desktop/src/main/index.ts @@ -1,6 +1,6 @@ // Load .env from monorepo root before any other imports import { existsSync } from "node:fs"; -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { config } from "dotenv"; // Find .env file by searching upward from __dirname diff --git a/apps/old-desktop/src/main/lib/desktop-stores.ts b/apps/old-desktop/src/main/lib/desktop-stores.ts index 77dc94e6620..faebc9e7d00 100644 --- a/apps/old-desktop/src/main/lib/desktop-stores.ts +++ b/apps/old-desktop/src/main/lib/desktop-stores.ts @@ -1,15 +1,15 @@ +import { LegacyMigrator } from "./migration/migrator"; import { ensureDesktopStorageDirs } from "./storage/config"; import { DesktopLowdbAdapter } from "./storage/lowdb-adapter"; import { + DesktopChangeOrchestrator, DesktopEnvironmentOrchestrator, - DesktopWorkspaceOrchestrator, DesktopProcessOrchestrator, - DesktopChangeOrchestrator, + DesktopWorkspaceOrchestrator, } from "./storage/orchestrators"; import { DomainVersion } from "./storage/version"; import { UiStore } from "./ui-store/store"; import { WorkspaceComposer } from "./workspace-composition/composer"; -import { LegacyMigrator } from "./migration/migrator"; /** * Desktop stores singleton diff --git a/apps/old-desktop/src/main/lib/migration/migrator.ts b/apps/old-desktop/src/main/lib/migration/migrator.ts index b921f69b19d..5dfea8ee44d 100644 --- a/apps/old-desktop/src/main/lib/migration/migrator.ts +++ b/apps/old-desktop/src/main/lib/migration/migrator.ts @@ -1,14 +1,14 @@ -import { existsSync, readFileSync, writeFileSync, copyFileSync } from "node:fs"; -import { join } from "node:path"; -import os from "node:os"; import { randomUUID } from "node:crypto"; +import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; import type { WorkspaceConfig } from "shared/types"; -import { DomainVersion } from "../storage/version"; -import { WorkspaceType } from "../types/cli-types"; import type { DesktopEnvironmentOrchestrator, DesktopWorkspaceOrchestrator, } from "../storage/orchestrators"; +import { DomainVersion } from "../storage/version"; +import { WorkspaceType } from "../types/cli-types"; import type { UiStore } from "../ui-store/store"; /** diff --git a/apps/old-desktop/src/main/lib/storage/index.ts b/apps/old-desktop/src/main/lib/storage/index.ts index 91adb8e6c92..caa074263a5 100644 --- a/apps/old-desktop/src/main/lib/storage/index.ts +++ b/apps/old-desktop/src/main/lib/storage/index.ts @@ -1,5 +1,5 @@ export * from "./adapter"; export * from "./config"; export * from "./lowdb-adapter"; -export * from "./types"; export * from "./orchestrators"; +export * from "./types"; diff --git a/apps/old-desktop/src/main/lib/storage/lowdb-adapter.ts b/apps/old-desktop/src/main/lib/storage/lowdb-adapter.ts index d37cad3238d..a0504d447b3 100644 --- a/apps/old-desktop/src/main/lib/storage/lowdb-adapter.ts +++ b/apps/old-desktop/src/main/lib/storage/lowdb-adapter.ts @@ -3,7 +3,7 @@ import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import { JSONFilePreset } from "lowdb/node"; import type { DesktopStorageAdapter } from "./adapter"; -import { getDomainCollectionPath, getDesktopDbDir } from "./config"; +import { getDesktopDbDir, getDomainCollectionPath } from "./config"; import { createEmptyDesktopDatabase, type DesktopDatabase, diff --git a/apps/old-desktop/src/main/lib/storage/orchestrators/index.ts b/apps/old-desktop/src/main/lib/storage/orchestrators/index.ts index fc8d3039f60..3ebfedd176d 100644 --- a/apps/old-desktop/src/main/lib/storage/orchestrators/index.ts +++ b/apps/old-desktop/src/main/lib/storage/orchestrators/index.ts @@ -1,4 +1,4 @@ +export * from "./change-orchestrator"; export * from "./environment-orchestrator"; -export * from "./workspace-orchestrator"; export * from "./process-orchestrator"; -export * from "./change-orchestrator"; +export * from "./workspace-orchestrator"; diff --git a/apps/old-desktop/src/main/lib/types/cli-types.ts b/apps/old-desktop/src/main/lib/types/cli-types.ts index 3a722f15efe..a8a6978f1aa 100644 --- a/apps/old-desktop/src/main/lib/types/cli-types.ts +++ b/apps/old-desktop/src/main/lib/types/cli-types.ts @@ -2,33 +2,31 @@ * Re-export CLI types for Desktop app * Uses workspace import for proper monorepo resolution */ + +export type { + AgentSummary, + Change, + ChangeOrchestrator, + FileDiff, +} from "@superset/cli/types/change"; export type { Environment, EnvironmentOrchestrator, } from "@superset/cli/types/environment"; export type { - Workspace, - LocalWorkspace, - WorkspaceType, - WorkspaceOrchestrator, -} from "@superset/cli/types/workspace"; - -export type { - Process, - ProcessType, - Terminal, Agent, AgentType, + Process, ProcessOrchestrator, + ProcessType, + Terminal, } from "@superset/cli/types/process"; - +export { AgentType, ProcessType } from "@superset/cli/types/process"; export type { - Change, - FileDiff, - AgentSummary, - ChangeOrchestrator, -} from "@superset/cli/types/change"; - + LocalWorkspace, + Workspace, + WorkspaceOrchestrator, + WorkspaceType, +} from "@superset/cli/types/workspace"; export { WorkspaceType } from "@superset/cli/types/workspace"; -export { ProcessType, AgentType } from "@superset/cli/types/process"; diff --git a/apps/old-desktop/src/main/lib/ui-store/index.ts b/apps/old-desktop/src/main/lib/ui-store/index.ts index 3855748c736..d9d332b8527 100644 --- a/apps/old-desktop/src/main/lib/ui-store/index.ts +++ b/apps/old-desktop/src/main/lib/ui-store/index.ts @@ -1,2 +1,2 @@ -export * from "./types"; export * from "./store"; +export * from "./types"; diff --git a/apps/old-desktop/src/main/lib/ui-store/store.ts b/apps/old-desktop/src/main/lib/ui-store/store.ts index 4b203c2ed06..f3b5910bf6f 100644 --- a/apps/old-desktop/src/main/lib/ui-store/store.ts +++ b/apps/old-desktop/src/main/lib/ui-store/store.ts @@ -1,12 +1,12 @@ import { + closeSync, existsSync, - readFileSync, - writeFileSync, - mkdirSync, - renameSync, fsyncSync, + mkdirSync, openSync, - closeSync, + readFileSync, + renameSync, + writeFileSync, } from "node:fs"; import { join } from "node:path"; import { getDesktopUiDir, getUiVersionPath } from "../storage/config"; diff --git a/apps/old-desktop/src/main/lib/ui-store/types.ts b/apps/old-desktop/src/main/lib/ui-store/types.ts index 80d64d44db3..6096f748d0b 100644 --- a/apps/old-desktop/src/main/lib/ui-store/types.ts +++ b/apps/old-desktop/src/main/lib/ui-store/types.ts @@ -1,4 +1,4 @@ -import type { Tab, MosaicNode } from "shared/types"; +import type { MosaicNode, Tab } from "shared/types"; /** * UI state types for Desktop app diff --git a/apps/old-desktop/src/main/lib/workspace-composition/composer.ts b/apps/old-desktop/src/main/lib/workspace-composition/composer.ts index a13e7a8cc90..ae72275bff4 100644 --- a/apps/old-desktop/src/main/lib/workspace-composition/composer.ts +++ b/apps/old-desktop/src/main/lib/workspace-composition/composer.ts @@ -1,14 +1,14 @@ import { randomUUID } from "node:crypto"; import type { LocalWorkspace } from "../types/cli-types"; -import worktreeManager from "../worktree-manager"; import type { UiStore } from "../ui-store/store"; +import type { WorktreeUiMetadata } from "../ui-store/types"; +import worktreeManager from "../worktree-manager"; import type { ComposedWorkspaceState, ComposedWorktree, RescanResult, ScannedWorktree, } from "./types"; -import type { WorktreeUiMetadata } from "../ui-store/types"; /** * Workspace state composer diff --git a/apps/old-desktop/src/main/lib/workspace-composition/index.ts b/apps/old-desktop/src/main/lib/workspace-composition/index.ts index 5e7cd189916..183ad90ddd6 100644 --- a/apps/old-desktop/src/main/lib/workspace-composition/index.ts +++ b/apps/old-desktop/src/main/lib/workspace-composition/index.ts @@ -1,2 +1,2 @@ -export * from "./types"; export * from "./composer"; +export * from "./types"; diff --git a/apps/old-desktop/src/main/lib/workspace-composition/types.ts b/apps/old-desktop/src/main/lib/workspace-composition/types.ts index 5d8728a6b00..bde2c40c248 100644 --- a/apps/old-desktop/src/main/lib/workspace-composition/types.ts +++ b/apps/old-desktop/src/main/lib/workspace-composition/types.ts @@ -1,6 +1,6 @@ import type { LocalWorkspace } from "../types/cli-types"; -import type { WorktreeInfo } from "../worktree-manager"; import type { WorktreeUiMetadata } from "../ui-store/types"; +import type { WorktreeInfo } from "../worktree-manager"; /** * Scanned worktree with Git information diff --git a/apps/old-desktop/src/main/lib/workspace-ipcs.ts b/apps/old-desktop/src/main/lib/workspace-ipcs.ts index e00cc226bef..8022251c9a9 100644 --- a/apps/old-desktop/src/main/lib/workspace-ipcs.ts +++ b/apps/old-desktop/src/main/lib/workspace-ipcs.ts @@ -10,9 +10,9 @@ import type { } from "shared/types"; import configManager from "./config-manager"; +import windowManager from "./window-manager"; import workspaceManager from "./workspace-manager"; import worktreeManager from "./worktree-manager"; -import windowManager from "./window-manager"; export function registerWorkspaceIPCs() { // Open repository dialog diff --git a/apps/old-desktop/src/renderer/contexts/AppProviders.tsx b/apps/old-desktop/src/renderer/contexts/AppProviders.tsx index 120a74b2e50..e347d689441 100644 --- a/apps/old-desktop/src/renderer/contexts/AppProviders.tsx +++ b/apps/old-desktop/src/renderer/contexts/AppProviders.tsx @@ -1,11 +1,11 @@ import type React from "react"; import { useState } from "react"; import { - WorkspaceProvider, - TabProvider, SidebarProvider, - WorktreeOperationsProvider, + TabProvider, TaskProvider, + WorkspaceProvider, + WorktreeOperationsProvider, } from "./index"; interface AppProvidersProps { diff --git a/apps/old-desktop/src/renderer/contexts/TabContext.tsx b/apps/old-desktop/src/renderer/contexts/TabContext.tsx index 8559998b514..943f422ebd4 100644 --- a/apps/old-desktop/src/renderer/contexts/TabContext.tsx +++ b/apps/old-desktop/src/renderer/contexts/TabContext.tsx @@ -1,5 +1,5 @@ -import { createContext, useContext } from "react"; import type React from "react"; +import { createContext, useContext } from "react"; import type { Tab, Worktree } from "shared/types"; import { useTabs } from "../screens/main/hooks"; import { useWorkspaceContext } from "./WorkspaceContext"; diff --git a/apps/old-desktop/src/renderer/contexts/TaskContext.tsx b/apps/old-desktop/src/renderer/contexts/TaskContext.tsx index faf90dc0dc1..38c7cb91b00 100644 --- a/apps/old-desktop/src/renderer/contexts/TaskContext.tsx +++ b/apps/old-desktop/src/renderer/contexts/TaskContext.tsx @@ -1,10 +1,10 @@ import type React from "react"; import { createContext, useContext } from "react"; import type { TaskStatus } from "../screens/main/components/Layout/StatusIndicator"; -import type { PendingWorktree, UITask } from "../screens/main/types"; import { useTasks } from "../screens/main/hooks"; -import { useWorkspaceContext } from "./WorkspaceContext"; +import type { PendingWorktree, UITask } from "../screens/main/types"; import { useTabContext } from "./TabContext"; +import { useWorkspaceContext } from "./WorkspaceContext"; import { useWorktreeOperationsContext } from "./WorktreeOperationsContext"; interface TaskContextValue { diff --git a/apps/old-desktop/src/renderer/contexts/WorktreeOperationsContext.tsx b/apps/old-desktop/src/renderer/contexts/WorktreeOperationsContext.tsx index a41f814de5f..778273fd63d 100644 --- a/apps/old-desktop/src/renderer/contexts/WorktreeOperationsContext.tsx +++ b/apps/old-desktop/src/renderer/contexts/WorktreeOperationsContext.tsx @@ -2,8 +2,8 @@ import type React from "react"; import { createContext, useContext } from "react"; import type { Worktree } from "shared/types"; import { useWorktrees } from "../screens/main/hooks"; -import { useWorkspaceContext } from "./WorkspaceContext"; import { useTabContext } from "./TabContext"; +import { useWorkspaceContext } from "./WorkspaceContext"; interface WorktreeOperationsContextValue { handleWorktreeCreated: () => Promise; diff --git a/apps/old-desktop/src/renderer/contexts/index.ts b/apps/old-desktop/src/renderer/contexts/index.ts index c70a05fd1e8..4ffe34ebbb6 100644 --- a/apps/old-desktop/src/renderer/contexts/index.ts +++ b/apps/old-desktop/src/renderer/contexts/index.ts @@ -1,10 +1,10 @@ export { AppProviders } from "./AppProviders"; -export { WorkspaceProvider, useWorkspaceContext } from "./WorkspaceContext"; -export { TabProvider, useTabContext } from "./TabContext"; export { SidebarProvider, useSidebarContext } from "./SidebarContext"; +export { TabProvider, useTabContext } from "./TabContext"; +export { TaskProvider, useTaskContext } from "./TaskContext"; +export { useWorkspaceContext, WorkspaceProvider } from "./WorkspaceContext"; +export { useWorktree, WorktreeProvider } from "./WorktreeContext"; export { - WorktreeOperationsProvider, useWorktreeOperationsContext, + WorktreeOperationsProvider, } from "./WorktreeOperationsContext"; -export { TaskProvider, useTaskContext } from "./TaskContext"; -export { WorktreeProvider, useWorktree } from "./WorktreeContext"; diff --git a/apps/old-desktop/src/renderer/screens/main/MainScreen.tsx b/apps/old-desktop/src/renderer/screens/main/MainScreen.tsx index b5fae125c36..1aab351f221 100644 --- a/apps/old-desktop/src/renderer/screens/main/MainScreen.tsx +++ b/apps/old-desktop/src/renderer/screens/main/MainScreen.tsx @@ -1,6 +1,15 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { DndProvider } from "react-dnd"; +import { + useSidebarContext, + useTabContext, + useTaskContext, + useWorkspaceContext, + useWorktreeOperationsContext, +} from "../../contexts"; import { dragDropManager } from "../../lib/dnd"; +import { createShortcutHandler } from "../../lib/keyboard-shortcuts"; +import { createTabShortcuts } from "../../lib/shortcuts"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; import { AddTaskModal } from "./components/Layout/AddTaskModal"; @@ -8,17 +17,8 @@ import { TaskTabs } from "./components/Layout/TaskTabs"; import { MainContentArea } from "./components/MainContentArea"; import { SidebarOverlay } from "./components/SidebarOverlay"; import { WorkspaceSelectionModal } from "./components/WorkspaceSelectionModal"; -import { - useWorkspaceContext, - useTabContext, - useSidebarContext, - useWorktreeOperationsContext, - useTaskContext, -} from "../../contexts"; import type { AppMode } from "./types"; import { enrichWorktreesWithTasks } from "./utils"; -import { createShortcutHandler } from "../../lib/keyboard-shortcuts"; -import { createTabShortcuts } from "../../lib/shortcuts"; export function MainScreen() { const [mode, setMode] = useState("edit"); diff --git a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/CreatingView.tsx b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/CreatingView.tsx index ddc0a1f4675..4f6e995b77a 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/CreatingView.tsx +++ b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/CreatingView.tsx @@ -1,7 +1,7 @@ import { Button } from "@superset/ui/button"; +import { ScrollArea } from "@superset/ui/scroll-area"; import { Loader2 } from "lucide-react"; import type React from "react"; -import { ScrollArea } from "@superset/ui/scroll-area"; import { TerminalOutput } from "../../Sidebar/components/CreateWorktreeModal/TerminalOutput"; interface CreatingViewProps { diff --git a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/TaskList.tsx b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/TaskList.tsx index 54bd4a84ec1..28a34f2e1b4 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/TaskList.tsx +++ b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/TaskList.tsx @@ -8,9 +8,9 @@ import { import { ScrollArea } from "@superset/ui/scroll-area"; import { Loader2, Search, X } from "lucide-react"; import type React from "react"; -import type { Task } from "./types"; import { TaskListItem } from "../TaskListItem"; import { TaskPreview } from "../TaskPreview"; +import type { Task } from "./types"; interface TaskListProps { searchQuery: string; diff --git a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/index.ts b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/index.ts index 1a452bb0b36..bd3bfbbd38d 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/index.ts +++ b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/index.ts @@ -1,5 +1,5 @@ export { AddTaskModal } from "../AddTaskModal"; -export type { AddTaskModalProps, Task, APITask } from "./types"; export { CreatingView } from "./CreatingView"; export { TaskForm } from "./TaskForm"; export { TaskList } from "./TaskList"; +export type { AddTaskModalProps, APITask, Task } from "./types"; diff --git a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/types.ts b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/types.ts index a208fab27d7..8a4caace32b 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/types.ts +++ b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/types.ts @@ -1,5 +1,5 @@ -import type { TaskStatus } from "../StatusIndicator"; import type { Worktree } from "shared/types"; +import type { TaskStatus } from "../StatusIndicator"; export interface Task { id: string; diff --git a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/useTaskForm.ts b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/useTaskForm.ts index 9b304cebceb..65859aefd40 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/useTaskForm.ts +++ b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/useTaskForm.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import type { TaskStatus } from "../StatusIndicator"; import type { Worktree } from "shared/types"; +import type { TaskStatus } from "../StatusIndicator"; import { generateBranchNameWithCollisionAvoidance } from "./utils"; export function useTaskForm( diff --git a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/utils.ts b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/utils.ts index 7fc32efef3d..7a64ddf6812 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/utils.ts +++ b/apps/old-desktop/src/renderer/screens/main/components/Layout/AddTaskModal/utils.ts @@ -1,6 +1,6 @@ import type { Worktree } from "shared/types"; -import type { APITask, Task } from "./types"; import type { TaskStatus } from "../StatusIndicator"; +import type { APITask, Task } from "./types"; export function formatRelativeTime(date: Date): string { const now = new Date(); diff --git a/apps/old-desktop/src/renderer/screens/main/components/MainContent/TabContent.tsx b/apps/old-desktop/src/renderer/screens/main/components/MainContent/TabContent.tsx index 67e94fee28d..4a3c10c0a7e 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/MainContent/TabContent.tsx +++ b/apps/old-desktop/src/renderer/screens/main/components/MainContent/TabContent.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import type { Tab } from "shared/types"; -import { useWorkspaceContext, useTabContext } from "../../../../contexts"; +import { useTabContext, useWorkspaceContext } from "../../../../contexts"; import { PortTab } from "../TabContent/components/PortTab"; import { PreviewTab } from "../TabContent/components/PreviewTab"; import TabGroup from "./TabGroup"; diff --git a/apps/old-desktop/src/renderer/screens/main/components/MainContent/TabGroup.tsx b/apps/old-desktop/src/renderer/screens/main/components/MainContent/TabGroup.tsx index d94a7242711..0ad8901342b 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/MainContent/TabGroup.tsx +++ b/apps/old-desktop/src/renderer/screens/main/components/MainContent/TabGroup.tsx @@ -6,10 +6,10 @@ import { MosaicWindow, } from "react-mosaic-component"; import "react-mosaic-component/react-mosaic-component.css"; +import { dragDropManager } from "renderer/lib/dnd"; import type { Tab } from "shared/types"; -import { useWorkspaceContext, useTabContext } from "../../../../contexts"; +import { useTabContext, useWorkspaceContext } from "../../../../contexts"; import TabContent from "./TabContent"; -import { dragDropManager } from "renderer/lib/dnd"; interface ScreenLayoutProps { groupTab: Tab; // A tab with type: "group" diff --git a/apps/old-desktop/src/renderer/screens/main/components/MainContentArea/MainContentArea.tsx b/apps/old-desktop/src/renderer/screens/main/components/MainContentArea/MainContentArea.tsx index dda4e0dcb1a..469cf2bb02f 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/MainContentArea/MainContentArea.tsx +++ b/apps/old-desktop/src/renderer/screens/main/components/MainContentArea/MainContentArea.tsx @@ -4,14 +4,14 @@ import { ResizablePanelGroup, } from "@superset/ui/resizable"; import { useState } from "react"; -import { useDiffData } from "../../hooks"; -import type { AppMode } from "../../types"; import { - useWorkspaceContext, - useTabContext, useSidebarContext, + useTabContext, + useWorkspaceContext, useWorktreeOperationsContext, } from "../../../../contexts"; +import { useDiffData } from "../../hooks"; +import type { AppMode } from "../../types"; import { DiffContentArea } from "../DiffView"; import TabContent from "../MainContent/TabContent"; import TabGroup from "../MainContent/TabGroup"; diff --git a/apps/old-desktop/src/renderer/screens/main/components/PlanView/PlanView.tsx b/apps/old-desktop/src/renderer/screens/main/components/PlanView/PlanView.tsx index 1924695ff40..cf65fedcee6 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/PlanView/PlanView.tsx +++ b/apps/old-desktop/src/renderer/screens/main/components/PlanView/PlanView.tsx @@ -2,8 +2,8 @@ import type { RouterOutputs } from "@superset/api"; import { Plus } from "lucide-react"; import type React from "react"; import { useMemo, useState } from "react"; -import { useWorkspaceContext, useTabContext } from "../../../../contexts"; import { mockTasks, mockUsers } from "../../../../../lib/mock-data"; +import { useTabContext, useWorkspaceContext } from "../../../../contexts"; import { CreateTaskModal } from "./CreateTaskModal"; import { KanbanColumn } from "./KanbanColumn"; import { TaskPage } from "./TaskPage"; @@ -11,7 +11,7 @@ import { TaskPage } from "./TaskPage"; type Task = RouterOutputs["task"]["all"][number]; type User = RouterOutputs["user"]["all"][number]; -interface PlanViewProps {} +type PlanViewProps = {}; export const PlanView: React.FC = () => { const { currentWorkspace } = useWorkspaceContext(); diff --git a/apps/old-desktop/src/renderer/screens/main/components/Sidebar/Sidebar.tsx b/apps/old-desktop/src/renderer/screens/main/components/Sidebar/Sidebar.tsx index 42781caee6c..a5180b77864 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Sidebar/Sidebar.tsx +++ b/apps/old-desktop/src/renderer/screens/main/components/Sidebar/Sidebar.tsx @@ -2,13 +2,13 @@ import { type MotionValue, useMotionValue } from "framer-motion"; import { File, FileEdit, FilePlus, FileX } from "lucide-react"; import { useEffect, useState } from "react"; import type { Tab } from "shared/types"; -import { useDiffData } from "../../hooks"; import { - useWorkspaceContext, + useSidebarContext, useTabContext, + useWorkspaceContext, useWorktreeOperationsContext, - useSidebarContext, } from "../../../../contexts"; +import { useDiffData } from "../../hooks"; import { FileTree } from "../DiffView"; import type { FileDiff } from "../DiffView/types"; import { CreateWorktreeModal, WorktreeList } from "./components"; diff --git a/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/index.ts b/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/index.ts index de0e8d8c3dc..7167b4075d3 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/index.ts +++ b/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/index.ts @@ -1,2 +1,2 @@ export { ModeCarousel } from "./ModeCarousel"; -export type { SidebarMode, ModeCarouselProps } from "./types"; +export type { ModeCarouselProps, SidebarMode } from "./types"; diff --git a/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx b/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx index 06d4de75c62..5c4c6f0be45 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx +++ b/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItem.tsx @@ -18,11 +18,11 @@ import { DialogHeader, DialogTitle, } from "renderer/components/ui/dialog"; +import { dragDropManager } from "renderer/lib/dnd"; import type { Tab, Worktree } from "shared/types"; import { WorktreePortsList } from "../WorktreePortsList"; import { GitStatusDialog } from "./components/GitStatusDialog"; import { TabItem } from "./components/TabItem"; -import { dragDropManager } from "renderer/lib/dnd"; interface ProxyStatus { canonical: number; diff --git a/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItemArborist.tsx b/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItemArborist.tsx index c7c99702df3..e8ed60cbddc 100644 --- a/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItemArborist.tsx +++ b/apps/old-desktop/src/renderer/screens/main/components/Sidebar/components/WorktreeList/components/WorktreeItem/WorktreeItemArborist.tsx @@ -1,6 +1,3 @@ -import { Tree } from "react-arborist"; -import type { NodeApi } from "react-arborist"; -import type { TreeApi } from "react-arborist"; import { Button } from "@superset/ui/button"; import { ContextMenu, @@ -10,11 +7,8 @@ import { } from "@superset/ui/context-menu"; import { ChevronRight, Edit2, FolderOpen } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; -import type { Tab, Worktree } from "shared/types"; -import { WorktreePortsList } from "../WorktreePortsList"; -import { GitStatusDialog } from "./components/GitStatusDialog"; -import { TabItem } from "./components/TabItem"; -import { dragDropManager } from "renderer/lib/dnd"; +import type { NodeApi, TreeApi } from "react-arborist"; +import { Tree } from "react-arborist"; import { Dialog, DialogContent, @@ -23,6 +17,11 @@ import { DialogHeader, DialogTitle, } from "renderer/components/ui/dialog"; +import { dragDropManager } from "renderer/lib/dnd"; +import type { Tab, Worktree } from "shared/types"; +import { WorktreePortsList } from "../WorktreePortsList"; +import { GitStatusDialog } from "./components/GitStatusDialog"; +import { TabItem } from "./components/TabItem"; interface WorktreeItemProps { worktree: Worktree; diff --git a/apps/old-desktop/src/renderer/screens/main/hooks/useTasks.ts b/apps/old-desktop/src/renderer/screens/main/hooks/useTasks.ts index 4ba5386907a..223d9ca34d4 100644 --- a/apps/old-desktop/src/renderer/screens/main/hooks/useTasks.ts +++ b/apps/old-desktop/src/renderer/screens/main/hooks/useTasks.ts @@ -1,8 +1,8 @@ import { useEffect, useRef, useState } from "react"; import type { Worktree } from "shared/types"; -import type { TaskStatus } from "../components/Layout/StatusIndicator"; -import type { UITask, PendingWorktree } from "../types"; import { transformWorktreeToTask } from "../components/Layout/AddTaskModal/utils"; +import type { TaskStatus } from "../components/Layout/StatusIndicator"; +import type { PendingWorktree, UITask } from "../types"; interface UseTasksProps { currentWorkspace: { diff --git a/apps/old-desktop/src/renderer/screens/main/utils.ts b/apps/old-desktop/src/renderer/screens/main/utils.ts index c15ed98f5f4..9153d74eacf 100644 --- a/apps/old-desktop/src/renderer/screens/main/utils.ts +++ b/apps/old-desktop/src/renderer/screens/main/utils.ts @@ -1,8 +1,8 @@ import type { Tab, Worktree } from "shared/types"; +import { formatRelativeTime } from "./components/Layout/AddTaskModal/utils"; import type { TaskStatus } from "./components/Layout/StatusIndicator"; import type { WorktreeWithTask } from "./components/Layout/TaskTabs"; import type { PendingWorktree } from "./types"; -import { formatRelativeTime } from "./components/Layout/AddTaskModal/utils"; // Helper function to find a tab recursively (for finding sub-tabs inside groups) export function findTabRecursive( diff --git a/apps/old-desktop/src/shared/ipc-channels/index.ts b/apps/old-desktop/src/shared/ipc-channels/index.ts index 02d1ae0acd6..7eb575807cf 100644 --- a/apps/old-desktop/src/shared/ipc-channels/index.ts +++ b/apps/old-desktop/src/shared/ipc-channels/index.ts @@ -12,8 +12,8 @@ import type { TabChannels } from "./tab"; import type { TerminalChannels } from "./terminal"; import type { UiChannels } from "./ui"; import type { WindowChannels } from "./window"; -import type { WorktreeChannels } from "./worktree"; import type { WorkspaceChannels } from "./workspace"; +import type { WorktreeChannels } from "./worktree"; // Re-export shared types export type { diff --git a/apps/old-desktop/src/shared/ipc-channels/ui.ts b/apps/old-desktop/src/shared/ipc-channels/ui.ts index d25823a11e5..e580c7390f2 100644 --- a/apps/old-desktop/src/shared/ipc-channels/ui.ts +++ b/apps/old-desktop/src/shared/ipc-channels/ui.ts @@ -2,8 +2,8 @@ * UI-related IPC channels for Desktop app */ +import type { MosaicNode, Tab } from "../types"; import type { IpcResponse, NoRequest } from "./types"; -import type { Tab, MosaicNode } from "../types"; export interface UiChannels { // Workspace UI state diff --git a/apps/website/src/app/components/HeroSection/HeroSection.tsx b/apps/website/src/app/components/HeroSection/HeroSection.tsx index 154aaa61055..1e0ef36f5c0 100644 --- a/apps/website/src/app/components/HeroSection/HeroSection.tsx +++ b/apps/website/src/app/components/HeroSection/HeroSection.tsx @@ -1,6 +1,6 @@ import { FadeUp } from "./components/FadeUp"; -import { HeroParallax } from "./components/HeroParallax"; import { HeroCanvas } from "./components/HeroCanvas"; +import { HeroParallax } from "./components/HeroParallax"; export function HeroSection() { return ( diff --git a/apps/website/src/app/components/HeroSection/components/HeroCanvas/components/LitBackground/LitBackground.tsx b/apps/website/src/app/components/HeroSection/components/HeroCanvas/components/LitBackground/LitBackground.tsx index 56ae09a210f..8c64aafc683 100644 --- a/apps/website/src/app/components/HeroSection/components/HeroCanvas/components/LitBackground/LitBackground.tsx +++ b/apps/website/src/app/components/HeroSection/components/HeroCanvas/components/LitBackground/LitBackground.tsx @@ -6,15 +6,15 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Mesh, PointLight } from "three"; import * as THREE from "three"; import { useHeroVisibility } from "../../../HeroParallax"; -import { waveVertexShader } from "../../shaders/vertex"; -import { waveFragmentShader } from "../../shaders/fragment"; import { + GEOMETRY_CONFIG, LIGHT_CONFIG, MATERIAL_CONFIG, TEXT_CONFIG, - GEOMETRY_CONFIG, } from "../../config"; import { calculateGlareProperties } from "../../helpers"; +import { waveFragmentShader } from "../../shaders/fragment"; +import { waveVertexShader } from "../../shaders/vertex"; export function LitBackground() { const meshRef = useRef(null); diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index a113662ae77..5020aa292a3 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -1,13 +1,13 @@ "use client"; import { useState } from "react"; +import { ClientLogosSection } from "./components/ClientLogosSection"; +import { FeaturesSection } from "./components/FeaturesSection"; import { Footer } from "./components/Footer"; import { Header } from "./components/Header"; -import { WaitlistModal } from "./components/WaitlistModal"; import { HeroSection } from "./components/HeroSection"; -import { ClientLogosSection } from "./components/ClientLogosSection"; -import { FeaturesSection } from "./components/FeaturesSection"; import { TestimonialsSection } from "./components/TestimonialsSection"; +import { WaitlistModal } from "./components/WaitlistModal"; export default function Home() { const [isWaitlistOpen, setIsWaitlistOpen] = useState(false); 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=="], diff --git a/packages/ui/src/components/context-menu.tsx b/packages/ui/src/components/context-menu.tsx index 53a55fb9c57..ae05cb6cdac 100644 --- a/packages/ui/src/components/context-menu.tsx +++ b/packages/ui/src/components/context-menu.tsx @@ -1,6 +1,6 @@ import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; -import { HiMiniCheck, HiMiniChevronRight, HiMiniStop } from "react-icons/hi2"; import type * as React from "react"; +import { HiMiniCheck, HiMiniChevronRight, HiMiniStop } from "react-icons/hi2"; import { cn } from "../lib/utils"; diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 0680ed61b4f..be40094d25e 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,6 +1,6 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { HiMiniXMark } from "react-icons/hi2"; import type * as React from "react"; +import { HiMiniXMark } from "react-icons/hi2"; import { cn } from "../lib/utils"; diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx index 7efd658e3fb..c3e497c113d 100644 --- a/packages/ui/src/components/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -1,6 +1,6 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; -import { HiMiniCheck, HiMiniChevronRight, HiMiniStop } from "react-icons/hi2"; import type * as React from "react"; +import { HiMiniCheck, HiMiniChevronRight, HiMiniStop } from "react-icons/hi2"; import { cn } from "../lib/utils"; diff --git a/packages/ui/src/components/resizable.tsx b/packages/ui/src/components/resizable.tsx index a4225e4cad4..881354405e4 100644 --- a/packages/ui/src/components/resizable.tsx +++ b/packages/ui/src/components/resizable.tsx @@ -1,5 +1,5 @@ -import { HiMiniEllipsisVertical } from "react-icons/hi2"; import type * as React from "react"; +import { HiMiniEllipsisVertical } from "react-icons/hi2"; import * as ResizablePrimitive from "react-resizable-panels"; import { cn } from "../lib/utils"; diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 3498c2b21d8..5a1f82a10e9 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,12 +1,12 @@ "use client"; import * as SelectPrimitive from "@radix-ui/react-select"; +import type * as React from "react"; import { HiMiniCheck, HiMiniChevronDown, HiMiniChevronUp, } from "react-icons/hi2"; -import type * as React from "react"; import { cn } from "../lib/utils"; diff --git a/packages/ui/src/components/separator.tsx b/packages/ui/src/components/separator.tsx index d57e7e2272f..579541f0a1e 100644 --- a/packages/ui/src/components/separator.tsx +++ b/packages/ui/src/components/separator.tsx @@ -1,5 +1,5 @@ -import * as React from "react"; import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; import { cn } from "../lib/utils"; function Separator({ From c6ba47dfef78e29ba48484df3fdfb1f14f494e41 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Wed, 19 Nov 2025 19:54:07 -0800 Subject: [PATCH 03/12] dashboard style --- apps/cli/src/commands/dashboard.tsx | 425 ++++++------------ .../__tests__/change-orchestrator.test.ts | 18 +- .../environment-orchestrator.test.ts | 8 +- .../__tests__/process-orchestrator.test.ts | 28 +- .../__tests__/workspace-orchestrator.test.ts | 72 ++- 5 files changed, 192 insertions(+), 359 deletions(-) diff --git a/apps/cli/src/commands/dashboard.tsx b/apps/cli/src/commands/dashboard.tsx index 846107aaa8a..8b09803c9ec 100644 --- a/apps/cli/src/commands/dashboard.tsx +++ b/apps/cli/src/commands/dashboard.tsx @@ -1,4 +1,4 @@ -import { Box, Text, useApp, useInput } from "ink"; +import { Box, Text, useApp, useInput, useStdout } from "ink"; import React from "react"; import { getDb } from "../lib/db"; import { launchAgent } from "../lib/launch/run"; @@ -25,7 +25,7 @@ interface DashboardProps { type SelectionMode = "workspace" | "agent"; -export function Dashboard({ onComplete }: DashboardProps) { +export function Dashboard({ onComplete: _onComplete }: DashboardProps) { const [data, setData] = React.useState(null); const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(true); @@ -36,6 +36,19 @@ export function Dashboard({ onComplete }: DashboardProps) { 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 { @@ -98,13 +111,6 @@ export function Dashboard({ onComplete }: DashboardProps) { ? agents.filter((a) => a.workspaceId === displayWorkspaceId) : agents; - const currentList = - selectionMode === "workspace" ? data.workspaces : filteredAgents; - const currentIndex = - selectionMode === "workspace" - ? selectedWorkspaceIndex - : selectedAgentIndex; - // Switch selection mode if (key.tab) { setSelectionMode((prev) => @@ -262,121 +268,76 @@ export function Dashboard({ onComplete }: DashboardProps) { // Status badge helper const getStatusBadge = (agent: Process) => { if (agent.endedAt) { - return ( - - {" "} - STOPPED{" "} - - ); + return [stopped]; } switch (agent.status) { case ProcessStatus.RUNNING: - return ( - - {" "} - RUNNING{" "} - - ); + return [running]; case ProcessStatus.IDLE: - return ( - - {" "} - IDLE{" "} - - ); + return [idle]; case ProcessStatus.ERROR: - return ( - - {" "} - ERROR{" "} - - ); + return [error]; default: - return ( - - {" "} - UNKNOWN{" "} - - ); + return [unknown]; } }; return ( {/* Header */} - + - - ✨ SUPERSET DASHBOARD - - - {lastRefresh.toLocaleTimeString()} - + SUPERSET DASHBOARD + {lastRefresh.toLocaleTimeString()} + + + {getSeparator(terminalWidth)} {/* Summary Stats */} - - + + - - ⚔ - - Workspaces - + Workspaces: + {workspaces.length} - │ + Ā· - - ā— - - Running + Running: {runningAgents.length} - │ + Ā· - - ā— - - Idle + Idle: {idleAgents.length} - │ - - - ā— - - Stopped - - {stoppedAgents.length} - - - {errorAgents.length > 0 && ( + {terminalWidth >= 60 && ( <> - │ + Ā· - - āœ– + Stopped: + + {stoppedAgents.length} - Error + + + )} + {errorAgents.length > 0 && terminalWidth >= 80 && ( + <> + Ā· + + Error: {errorAgents.length} @@ -386,79 +347,60 @@ export function Dashboard({ onComplete }: DashboardProps) { - {/* Current Workspace Indicator */} - {currentWorkspaceId && ( - - - - ā–¶ - - Current: + {/* Current Workspace & Controls */} + + {currentWorkspaceId && ( + + Active: - {workspaces.find((w) => w.id === currentWorkspaceId)?.name || - currentWorkspaceId.slice(0, 8)} + {truncate( + workspaces.find((w) => w.id === currentWorkspaceId)?.name || + currentWorkspaceId.slice(0, 8), + Math.max(15, Math.floor(terminalWidth / 4)), + )} - - )} - - {/* Selection Mode & Filter */} - - + )} + + Mode: - {selectionMode === "workspace" ? "šŸ“ Workspaces" : "šŸ¤– Agents"} + {selectionMode === "workspace" ? "Workspaces" : "Agents"} - (TAB) + (tab) - - Filter: - + + Filter: + {filterByCurrent - ? "Current workspace" + ? "Current" : selectedWorkspace - ? `${selectedWorkspace.name || selectedWorkspace.id.slice(0, 8)}` + ? truncate( + selectedWorkspace.name || selectedWorkspace.id.slice(0, 8), + Math.max(10, Math.floor(terminalWidth / 6)), + ) : "All"} - (f) + (f) + + {getSeparator(terminalWidth)} + + {/* Workspaces Section */} - + - - šŸ“ WORKSPACES - + WORKSPACES ({workspaces.length}) {workspaces.length === 0 ? ( - - - No workspaces found. Run:{" "} - superset init - + + No workspaces. Run: + superset init ) : ( - + {workspaces.map((ws, index) => { const wsAgents = agents.filter((a) => a.workspaceId === ws.id); const wsRunning = wsAgents.filter( @@ -469,58 +411,38 @@ export function Dashboard({ onComplete }: DashboardProps) { 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 ? "ā–¶" : " "} - - - {wsRunning.length > 0 ? ( - - ā— - - ) : ( - - ā—‹ - - )} + + + {isSelected ? "ā–ø" : " "} - {ws.name || ws.id.slice(0, 8)} + {truncate(wsName, maxNameLength)} - - ({ws.type}) - - - {wsRunning.length}/{wsAgents.length} agents + ({ws.type}) + + {wsRunning.length > 0 && ( + {wsRunning.length} running + )} + {wsRunning.length === 0 && wsAgents.length > 0 && ( + {wsAgents.length} idle + )} + {wsAgents.length === 0 && no agents} - {isCurrent && !isSelected && ( - - {" "} - CURRENT{" "} + {isCurrent && ( + + [active] )} @@ -531,59 +453,36 @@ export function Dashboard({ onComplete }: DashboardProps) { {/* Agents Section */} - + - - šŸ¤– AGENTS - + AGENTS ({filteredAgents.length}) {filteredAgents.length === 0 ? ( - + No agents in this workspace ) : ( - + {filteredAgents.map((agent, index) => { const isSelected = selectionMode === "agent" && index === selectedAgentIndex; return ( - - - {isSelected ? "ā–¶" : " "} + + + {isSelected ? "ā–ø" : " "} {getStatusBadge(agent)} {agent.type === ProcessType.AGENT && "agentType" in agent && String(agent.agentType)} - - ({agent.id.slice(0, 8)}) - - + ({agent.id.slice(0, 8)}) + {new Date(agent.createdAt).toLocaleTimeString()} @@ -593,94 +492,46 @@ export function Dashboard({ onComplete }: DashboardProps) { )} + + {getSeparator(terminalWidth)} + + {/* Keyboard Shortcuts */} - + - - āŒØļø KEYBOARD SHORTCUTS + + CONTROLS - + - - {" "} - ↑↓{" "} - {" "} - - {" "} - j{" "} - {" "} - - {" "} - k{" "} - {" "} - Navigate - - - - {" "} - TAB{" "} - {" "} - Switch mode + ↑↓ j k Navigate - - {" "} - āŽ{" "} - {" "} - Launch agent + tab Switch mode - - {" "} - [{" "} - {" "} - - {" "} - ]{" "} - {" "} - Cycle workspace + āŽ Launch agent - - {" "} - f{" "} - {" "} - Toggle filter + [ ] Cycle workspace - - {" "} - r{" "} - {" "} - Refresh + f Toggle filter - - {" "} - o{" "} - {" "} - Print cd path + r Refresh + + + + + o Print cd path - - {" "} - q{" "} - {" "} - - {" "} - ESC{" "} - {" "} - Exit + q esc Exit 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 903cb483e75..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 30aab10976f..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 35e902fdcc0..0d1b1f09109 100644 --- a/apps/cli/src/lib/orchestrators/__tests__/process-orchestrator.test.ts +++ b/apps/cli/src/lib/orchestrators/__tests__/process-orchestrator.test.ts @@ -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( @@ -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, @@ -244,7 +244,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); @@ -269,7 +269,7 @@ describe("ProcessOrchestrator", () => { const workspace = await workspaceOrchestrator.create( env.id, WorkspaceType.LOCAL, - "/tmp/test" }, + { path: "/tmp/test" }, ); const process = await orchestrator.create( @@ -294,7 +294,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 +310,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 21b81bdbabe..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, - { path: "/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, - { path: "/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, - { path: "/tmp/test1" }, - ); - const ws2 = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - { path: "/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,11 +95,9 @@ describe("WorkspaceOrchestrator", () => { const env1 = await environmentOrchestrator.create(); const env2 = await environmentOrchestrator.create(); - const ws1 = await orchestrator.create( - env1.id, - WorkspaceType.LOCAL, - { path: "/tmp/test1" }, - ); + const ws1 = await orchestrator.create(env1.id, WorkspaceType.LOCAL, { + path: "/tmp/test1", + }); await orchestrator.create(env2.id, WorkspaceType.LOCAL, { path: "/tmp/test2", }); @@ -121,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, - { path: "/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); @@ -137,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, - { path: "/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(); @@ -149,11 +135,9 @@ describe("WorkspaceOrchestrator", () => { test("cascade deletes changes", async () => { const env = await environmentOrchestrator.create(); - const workspace = await orchestrator.create( - env.id, - WorkspaceType.LOCAL, - { path: "/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", @@ -168,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, - { path: "/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", From a6277f141faaf3f75b9184c6d3a751a72661c18e Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Wed, 19 Nov 2025 21:20:33 -0800 Subject: [PATCH 04/12] tmux and actual agent shit works kinda --- apps/cli/package.json | 2 +- apps/cli/readme.md | 52 +- apps/cli/src/cli.tsx | 21 + apps/cli/src/commands/agent.tsx | 103 +++- apps/cli/src/commands/dashboard.tsx | 22 +- apps/cli/src/commands/index.ts | 2 + apps/cli/src/commands/panels.tsx | 493 ++++++++++++++++++ apps/cli/src/lib/launch/run.ts | 148 +++++- .../lib/orchestrators/process-orchestrator.ts | 118 ++++- apps/cli/src/types/process.ts | 1 + 10 files changed, 927 insertions(+), 35 deletions(-) create mode 100644 apps/cli/src/commands/panels.tsx diff --git a/apps/cli/package.json b/apps/cli/package.json index 1f60a950956..c2952e3d6c3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -10,7 +10,7 @@ "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", diff --git a/apps/cli/readme.md b/apps/cli/readme.md index 68d0943c6a7..a861ecb4c65 100644 --- a/apps/cli/readme.md +++ b/apps/cli/readme.md @@ -1,13 +1,45 @@ -# 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`). +- 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 `~/.config/superset/cli.json` (default), seeded with a `default` environment and `state.currentWorkspaceId`. +- Dates are serialized ISO strings; orchestrators backfill defaults and persist missing fields (status, launchCommand, sessionName, timestamps). + +## 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 b114c50d02c..fb650c375b2 100644 --- a/apps/cli/src/cli.tsx +++ b/apps/cli/src/cli.tsx @@ -3,6 +3,7 @@ import { Command } from "commander"; import { render } from "ink"; import React from "react"; import { + AgentAttach, AgentCreate, AgentDelete, AgentGet, @@ -19,6 +20,7 @@ import { EnvGet, EnvList, Init, + Panels, WorkspaceCreate, WorkspaceDelete, WorkspaceGet, @@ -53,6 +55,14 @@ program render(); }); +// Panels command +program + .command("panels") + .description("Show three-panel IDE-style interface") + .action(() => { + render(); + }); + // Environment commands const env = program .command("env") @@ -198,6 +208,14 @@ agent 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") @@ -351,6 +369,9 @@ program.action(async () => { 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", diff --git a/apps/cli/src/commands/agent.tsx b/apps/cli/src/commands/agent.tsx index 1495e9313e3..75405ead36d 100644 --- a/apps/cli/src/commands/agent.tsx +++ b/apps/cli/src/commands/agent.tsx @@ -497,7 +497,22 @@ export function AgentStart({ workspaceId, onComplete }: AgentStartProps) { launchCommand, }); - created.push(process); + // 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, + }); + console.error( + `Failed to create session for ${agentType}: ${result.error}`, + ); + } else { + created.push(process); + } } // Update workspace lastUsedAt and set as current if not already @@ -607,3 +622,89 @@ export function AgentStart({ workspaceId, onComplete }: AgentStartProps) { 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 attachToAgent + const { launchAgent } = await import("../lib/launch/run"); + + // Exit the Ink app before attaching + exit(); + + // Small delay to let Ink clean up + setTimeout(async () => { + const result = await launchAgent(agent, { attach: true }); + if (!result.success) { + console.error(`\nāŒ Failed to attach to agent\n`); + console.error(`Error: ${result.error}\n`); + globalThis.process.exit(1); + } + globalThis.process.exit(0); + }, 100); + } 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 index 8b09803c9ec..b3ab82e952f 100644 --- a/apps/cli/src/commands/dashboard.tsx +++ b/apps/cli/src/commands/dashboard.tsx @@ -167,11 +167,11 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { exit(); // Give the terminal time to reset after Ink exits setTimeout(() => { - launchAgent(agentToLaunch) + launchAgent(agentToLaunch, { attach: true }) .then((result) => { if (!result.success) { console.error( - `\nāŒ Failed to launch ${agentToLaunch.agentType} agent\n`, + `\nāŒ Failed to attach to ${agentToLaunch.agentType} agent\n`, ); console.error(`Error: ${result.error}\n`); if (result.exitCode !== undefined) { @@ -179,12 +179,12 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { } process.exit(1); } - // Agent launched successfully and exited normally + // Detached successfully process.exit(0); }) .catch((error) => { console.error( - `\nāŒ Failed to launch ${agentToLaunch.agentType} agent\n`, + `\nāŒ Failed to attach to ${agentToLaunch.agentType} agent\n`, ); console.error( `Error: ${error instanceof Error ? error.message : String(error)}\n`, @@ -228,7 +228,7 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { } if (launching) { - return Launching agent...; + return Attaching to agent...; } if (error) { @@ -467,6 +467,12 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { {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 ( @@ -481,7 +487,11 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { "agentType" in agent && String(agent.agentType)} - ({agent.id.slice(0, 8)}) + {sessionName && ( + + [{truncate(sessionName, 16)}] + + )} {new Date(agent.createdAt).toLocaleTimeString()} diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts index 41d6175d11d..a62e9261c43 100644 --- a/apps/cli/src/commands/index.ts +++ b/apps/cli/src/commands/index.ts @@ -1,4 +1,5 @@ export { + AgentAttach, AgentCreate, AgentDelete, AgentGet, @@ -11,6 +12,7 @@ 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, diff --git a/apps/cli/src/commands/panels.tsx b/apps/cli/src/commands/panels.tsx new file mode 100644 index 00000000000..152e0a81d57 --- /dev/null +++ b/apps/cli/src/commands/panels.tsx @@ -0,0 +1,493 @@ +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"; + +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 [launching, setLaunching] = React.useState(false); + 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 && !launching) { + if (selectedAgent.type !== ProcessType.AGENT) { + return; + } + + setLaunching(true); + const agentToAttach = selectedAgent as Agent; + setTimeout(() => { + exit(); + setTimeout(() => { + launchAgent(agentToAttach, { attach: true }) + .then((result) => { + if (!result.success) { + 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); + }) + .catch((error) => { + console.error( + `\nāŒ Failed to attach to ${agentToAttach.agentType} agent\n`, + ); + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); + }); + }, 200); + }, 100); + } + } + + if (input === "r") { + loadData(); + } + }); + + if (loading) { + return Loading panels...; + } + + if (launching) { + return Attaching to agent...; + } + + 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]; + + const panelHeight = terminalHeight - 4; + const leftPanelWidth = Math.floor(terminalWidth * 0.25); + const middlePanelWidth = Math.floor(terminalWidth * 0.3); + const rightPanelWidth = terminalWidth - leftPanelWidth - middlePanelWidth - 6; + + const getStatusIcon = (status: ProcessStatus, endedAt?: Date) => { + if (endedAt) return "ā—‹"; + switch (status) { + case ProcessStatus.RUNNING: + return "ā—"; + case ProcessStatus.IDLE: + return "ā—‹"; + default: + return "ā—‹"; + } + }; + + const getStatusColor = ( + status: ProcessStatus, + endedAt?: Date, + ): "green" | "yellow" | "gray" | undefined => { + if (endedAt) return "gray"; + switch (status) { + case ProcessStatus.RUNNING: + return "green"; + case ProcessStatus.IDLE: + return "yellow"; + default: + return "gray"; + } + }; + + return ( + + {/* Header */} + + + + SUPERSET + + + {selectedWorkspace && + `${selectedWorkspace.name || selectedWorkspace.id.slice(0, 8)}`} + + + + + {/* Three Panel Layout */} + + {/* Left Panel - Workspaces */} + + + + WORKSPACES + + + + {workspaces.map((ws, index) => { + const isSelected = index === selectedWorkspaceIndex; + const isCurrent = ws.id === currentWorkspaceId; + const wsAgents = agents.filter((a) => a.workspaceId === ws.id); + const wsRunning = wsAgents.filter( + (a) => a.status === ProcessStatus.RUNNING, + ); + + return ( + + + {isSelected ? "ā–ø" : " "} {wsRunning.length > 0 ? "ā—" : "ā—‹"}{" "} + {ws.name || ws.id.slice(0, 8)} + + + ); + })} + + [W] New + + + + + {/* Middle Panel - Agents */} + + + + 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 sessionName = + agent.type === ProcessType.AGENT && + "sessionName" in agent && + agent.sessionName + ? String(agent.sessionName) + : null; + const timeAgo = Math.floor( + (Date.now() - new Date(agent.createdAt).getTime()) / 60000, + ); + + return ( + + + {isSelected ? "ā–ø" : " "} + + + {getStatusIcon(agent.status, agent.endedAt)} + + + {sessionName || `${agentType}-${timeAgo}m`} + + + ); + }) + )} + + [N] New Agent + + + [Enter] Open + + + + + {/* Right Panel - Details */} + + + + DETAILS + + + + {selectedAgent ? ( + <> + {/* Agent Details */} + + + {selectedAgent.type === ProcessType.AGENT && + "agentType" in selectedAgent + ? String(selectedAgent.agentType) + : "Unknown"} + + + Started:{" "} + {Math.floor( + (Date.now() - + new Date(selectedAgent.createdAt).getTime()) / + 60000, + )} + m ago + + + Status:{" "} + + {selectedAgent.endedAt ? "stopped" : selectedAgent.status} + + + ID: {selectedAgent.id.slice(0, 8)} + + + {/* Task Info */} + {selectedAgent.title && ( + + Task: + {selectedAgent.title} + + )} + + {/* Workspace Info */} + + Workspace: + + {workspaces.find((w) => w.id === selectedAgent.workspaceId) + ?.name || selectedAgent.workspaceId.slice(0, 8)} + + + + {/* Launch Command */} + {selectedAgent.launchCommand && ( + + Command: + {selectedAgent.launchCommand} + + )} + + {/* Timestamps */} + + Timeline: + + Created:{" "} + {new Date(selectedAgent.createdAt).toLocaleTimeString()} + + + Updated:{" "} + {new Date(selectedAgent.updatedAt).toLocaleTimeString()} + + {selectedAgent.endedAt && ( + + Ended:{" "} + {new Date(selectedAgent.endedAt).toLocaleTimeString()} + + )} + + + ) : ( + Select an agent to view details + )} + + + + + {/* Footer */} + + + + [1] Workspaces + + • + + [2] Agents + + • + + [3] Details + + • + + [Enter] Open Agent + + • + + [q] Exit + + + + + ); +} diff --git a/apps/cli/src/lib/launch/run.ts b/apps/cli/src/lib/launch/run.ts index a59fc0d1028..7886adfb62e 100644 --- a/apps/cli/src/lib/launch/run.ts +++ b/apps/cli/src/lib/launch/run.ts @@ -1,7 +1,10 @@ -import { spawn } from "node:child_process"; +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; @@ -9,10 +12,39 @@ export interface LaunchResult { } /** - * Launch an agent by spawning its configured command - * Returns a promise that resolves when the process exits + * 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 + */ +async function tmuxSessionExists(sessionName: string): Promise { + try { + await execAsync(`tmux has-session -t "${sessionName}" 2>/dev/null`); + return true; + } catch { + return false; + } +} + +/** + * 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 */ -export async function launchAgent(agent: Agent): Promise { +export async function launchAgent( + agent: Agent, + options: { attach?: boolean } = { attach: true }, +): Promise { const command = getLaunchCommand(agent); if (!command) { @@ -22,26 +54,120 @@ export async function launchAgent(agent: Agent): Promise { }; } - console.log(`\nLaunching ${agent.agentType} agent...\n`); + // 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", + }; + } + + const sessionName = agent.sessionName || `agent-${agent.id.slice(0, 6)}`; + + // Check if session already exists + const exists = await tmuxSessionExists(sessionName); + + if (exists) { + // Session exists - attach if requested + if (options.attach) { + console.log( + `\nSession "${sessionName}" exists. Attaching...\n`, + ); + return attachToAgent(agent); + } + // Session exists but not attaching - just return success + return { + success: true, + exitCode: 0, + }; + } + + // Session doesn't exist - create it in detached mode + try { + await execAsync(`tmux new-session -d -s "${sessionName}" "${command}"`); + + if (options.attach) { + // Created successfully, now attach + console.log( + `\nSession "${sessionName}" created. Attaching...\n`, + ); + return attachToAgent(agent); + } + + // Created but not attaching - just return success + console.log( + `\nāœ“ Agent session created: ${sessionName}\n`, + ); + return { + success: true, + exitCode: 0, + }; + } catch (error) { + return { + success: false, + error: + error instanceof Error + ? `Failed to create tmux session: ${error.message}` + : "Unknown error creating tmux session", + }; + } +} + +/** + * Attach to an existing agent's tmux session + * Inherits stdio so user can interact, returns when user detaches + */ +export async function attachToAgent(agent: Agent): Promise { + const sessionName = agent.sessionName || `agent-${agent.id.slice(0, 6)}`; + + // Check if session exists + const exists = await tmuxSessionExists(sessionName); + if (!exists) { + return { + success: false, + error: `Session "${sessionName}" not found. The agent may have stopped or never started.`, + }; + } + + 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 the process with inherited stdio so user can interact - // Use shell to properly handle the command and its arguments - const child = spawn(command, [], { + // Set tmux status bar message before attaching + try { + execSync( + `tmux set-option -t "${sessionName}" status-left-length 100 2>/dev/null`, + ); + execSync( + `tmux set-option -t "${sessionName}" status-style "bg=yellow,fg=black" 2>/dev/null`, + ); + execSync( + `tmux set-option -t "${sessionName}" status-left "#[bg=yellow,fg=black,bold] Ctrl-b d to detach and keep agent running #[default]" 2>/dev/null`, + ); + } catch { + // Ignore errors setting status bar + } + + // Spawn tmux attach with inherited stdio + const child = spawn("tmux", ["attach", "-t", sessionName], { stdio: "inherit", - shell: true, detached: false, }); child.on("error", (error) => { resolve({ success: false, - error: `Failed to launch ${command}: ${error.message}`, + error: `Failed to attach to session: ${error.message}`, }); }); child.on("exit", (code) => { + // Exit code 0 means user detached successfully if (code === 0 || code === null) { resolve({ success: true, @@ -51,7 +177,7 @@ export async function launchAgent(agent: Agent): Promise { resolve({ success: false, exitCode: code, - error: `Process exited with code ${code}`, + error: `Attach process exited with code ${code}`, }); } }); diff --git a/apps/cli/src/lib/orchestrators/process-orchestrator.ts b/apps/cli/src/lib/orchestrators/process-orchestrator.ts index 41a92304ce5..7f0c6691e5f 100644 --- a/apps/cli/src/lib/orchestrators/process-orchestrator.ts +++ b/apps/cli/src/lib/orchestrators/process-orchestrator.ts @@ -1,4 +1,6 @@ +import { exec, execSync } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { promisify } from "node:util"; import { type Agent, type AgentType, @@ -12,6 +14,35 @@ 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 + */ +async function tmuxSessionExists(sessionName: string): Promise { + if (!isTmuxInstalled()) { + return false; + } + try { + await execAsync(`tmux has-session -t "${sessionName}" 2>/dev/null`); + return true; + } catch { + return false; + } +} + /** * Process orchestrator implementation using storage adapter * Handles CRUD operations for processes (agents and terminals) @@ -27,8 +58,14 @@ export class ProcessOrchestrator implements IProcessOrchestrator { const backfilled = this.backfillDefaults(process); - // Persist backfilled defaults if they were added or updated - if (this.needsPersist(process)) { + // Sync agent status with tmux session reality + let needsSync = false; + if (backfilled.type === ProcessType.AGENT && "sessionName" in backfilled) { + needsSync = await 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); } @@ -42,8 +79,14 @@ export class ProcessOrchestrator implements IProcessOrchestrator { for (const [id, process] of Object.entries(processes)) { const backfilled = this.backfillDefaults(process); - // Persist backfilled defaults if they were added or updated - if (this.needsPersist(process)) { + // Sync agent status with tmux session reality + let needsSync = false; + if (backfilled.type === ProcessType.AGENT && "sessionName" in backfilled) { + needsSync = await 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); } @@ -65,6 +108,8 @@ export class ProcessOrchestrator implements IProcessOrchestrator { (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")) || @@ -72,6 +117,35 @@ export class ProcessOrchestrator implements IProcessOrchestrator { ); } + /** + * Sync agent status with tmux session reality + * Returns true if status was changed and needs persisting + */ + private async syncAgentStatus(agent: Agent): Promise { + if (!agent.sessionName) { + return false; + } + + const sessionExists = await tmuxSessionExists(agent.sessionName); + + // If session doesn't exist but agent is marked as RUNNING, mark it STOPPED + if (!sessionExists && agent.status === ProcessStatus.RUNNING && !agent.endedAt) { + agent.status = ProcessStatus.STOPPED; + agent.endedAt = new Date(); + agent.updatedAt = new Date(); + return true; + } + + // If session exists but agent is marked STOPPED without endedAt, mark as RUNNING + if (sessionExists && agent.status === ProcessStatus.STOPPED && !agent.endedAt) { + agent.status = ProcessStatus.RUNNING; + agent.updatedAt = new Date(); + return true; + } + + return false; + } + private backfillDefaults(process: Process): Process { const now = new Date(); // Determine status based on endedAt @@ -86,7 +160,7 @@ export class ProcessOrchestrator implements IProcessOrchestrator { updatedAt: process.updatedAt || now, }; - // Backfill or update launchCommand for agents + // 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); @@ -102,6 +176,11 @@ export class ProcessOrchestrator implements IProcessOrchestrator { if (isOutdated) { agent.launchCommand = currentDefault; } + + // Backfill sessionName if missing + if (!agent.sessionName) { + agent.sessionName = `agent-${agent.id.slice(0, 6)}`; + } } return backfilled; @@ -113,8 +192,9 @@ export class ProcessOrchestrator implements IProcessOrchestrator { 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", @@ -128,6 +208,7 @@ export class ProcessOrchestrator implements IProcessOrchestrator { ? ({ ...baseProcess, agentType, + sessionName: `agent-${id.slice(0, 6)}`, } as Agent) : type === ProcessType.TERMINAL ? (baseProcess as Terminal) @@ -158,6 +239,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(), @@ -180,6 +274,18 @@ export class ProcessOrchestrator implements IProcessOrchestrator { for (const [id, process] of Object.entries(processes)) { // 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, diff --git a/apps/cli/src/types/process.ts b/apps/cli/src/types/process.ts index e8b7c640f3a..386664ea38f 100644 --- a/apps/cli/src/types/process.ts +++ b/apps/cli/src/types/process.ts @@ -41,6 +41,7 @@ export enum AgentType { export interface Agent extends Process { type: ProcessType.AGENT; agentType: AgentType; + sessionName?: string; } export interface ProcessOrchestrator { From fd0fbf2cbfde2762b6d13d66597345f3e59eb031 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Wed, 19 Nov 2025 22:49:46 -0800 Subject: [PATCH 05/12] Refactor dashboard and panels to improve agent launching experience - Added LaunchOverlay component to display loading state while attaching to agents in the dashboard. - Removed unnecessary launching state from panels, simplifying the agent attachment logic. - Updated launchAgent and attachToAgent functions to handle synchronous checks for tmux sessions, improving performance and reliability. - Adjusted database initialization to use a default path from configuration instead of hardcoded values. --- apps/cli/src/commands/dashboard.tsx | 66 +++++++------- apps/cli/src/commands/panels.tsx | 51 +++++------ apps/cli/src/components/LaunchOverlay.tsx | 70 +++++++++++++++ apps/cli/src/lib/db.ts | 15 +--- apps/cli/src/lib/launch/run.ts | 85 +++++++++++++------ .../lib/orchestrators/process-orchestrator.ts | 24 ++++-- 6 files changed, 202 insertions(+), 109 deletions(-) create mode 100644 apps/cli/src/components/LaunchOverlay.tsx diff --git a/apps/cli/src/commands/dashboard.tsx b/apps/cli/src/commands/dashboard.tsx index b3ab82e952f..ee4df18346f 100644 --- a/apps/cli/src/commands/dashboard.tsx +++ b/apps/cli/src/commands/dashboard.tsx @@ -1,5 +1,6 @@ import { Box, Text, useApp, useInput, useStdout } from "ink"; import React from "react"; +import { LaunchOverlay } from "../components/LaunchOverlay"; import { getDb } from "../lib/db"; import { launchAgent } from "../lib/launch/run"; import { ProcessOrchestrator } from "../lib/orchestrators/process-orchestrator"; @@ -30,6 +31,9 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(true); const [launching, setLaunching] = React.useState(false); + const [launchingAgent, setLaunchingAgent] = React.useState( + null, + ); const [selectedWorkspaceIndex, setSelectedWorkspaceIndex] = React.useState(0); const [selectedAgentIndex, setSelectedAgentIndex] = React.useState(0); const [selectionMode, setSelectionMode] = @@ -157,41 +161,33 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { return; } - // Exit the Ink app before launching - // The launchAgent function will be called after exit - setLaunching(true); - - // Small delay to let the UI update, then exit and launch + // Exit Ink immediately and launch agent const agentToLaunch = selectedAgent as Agent; + exit(); setTimeout(() => { - exit(); - // Give the terminal time to reset after Ink exits - setTimeout(() => { - launchAgent(agentToLaunch, { attach: true }) - .then((result) => { - if (!result.success) { - 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); - } - // Detached successfully - process.exit(0); - }) - .catch((error) => { + launchAgent(agentToLaunch, { attach: true }) + .then((result) => { + if (!result.success) { console.error( `\nāŒ Failed to attach to ${agentToLaunch.agentType} agent\n`, ); - console.error( - `Error: ${error instanceof Error ? error.message : String(error)}\n`, - ); + console.error(`Error: ${result.error}\n`); + if (result.exitCode !== undefined) { + console.error(`Exit code: ${result.exitCode}\n`); + } process.exit(1); - }); - }, 200); + } + process.exit(0); + }) + .catch((error) => { + console.error( + `\nāŒ Failed to attach to ${agentToLaunch.agentType} agent\n`, + ); + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); + }); }, 100); } } else if (input === "r") { @@ -227,8 +223,16 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { return Loading dashboard...; } - if (launching) { - return Attaching to agent...; + if (launching && launchingAgent) { + return ( + + ); } if (error) { diff --git a/apps/cli/src/commands/panels.tsx b/apps/cli/src/commands/panels.tsx index 152e0a81d57..17257a61f44 100644 --- a/apps/cli/src/commands/panels.tsx +++ b/apps/cli/src/commands/panels.tsx @@ -29,7 +29,6 @@ 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 [launching, setLaunching] = React.useState(false); const [selectedWorkspaceIndex, setSelectedWorkspaceIndex] = React.useState(0); const [selectedAgentIndex, setSelectedAgentIndex] = React.useState(0); const [activePanel, setActivePanel] = React.useState("agents"); @@ -131,40 +130,38 @@ export function Panels({ onComplete: _onComplete }: PanelsProps) { // Attach to agent if (key.return && activePanel === "agents") { const selectedAgent = filteredAgents[selectedAgentIndex]; - if (selectedAgent && !launching) { + if (selectedAgent) { if (selectedAgent.type !== ProcessType.AGENT) { return; } - setLaunching(true); + // Exit Ink immediately and launch agent const agentToAttach = selectedAgent as Agent; + exit(); setTimeout(() => { - exit(); - setTimeout(() => { - launchAgent(agentToAttach, { attach: true }) - .then((result) => { - if (!result.success) { - 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); - }) - .catch((error) => { + launchAgent(agentToAttach, { attach: true }) + .then((result) => { + if (!result.success) { console.error( `\nāŒ Failed to attach to ${agentToAttach.agentType} agent\n`, ); - console.error( - `Error: ${error instanceof Error ? error.message : String(error)}\n`, - ); + console.error(`Error: ${result.error}\n`); + if (result.exitCode !== undefined) { + console.error(`Exit code: ${result.exitCode}\n`); + } process.exit(1); - }); - }, 200); + } + process.exit(0); + }) + .catch((error) => { + console.error( + `\nāŒ Failed to attach to ${agentToAttach.agentType} agent\n`, + ); + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); + }); }, 100); } } @@ -178,10 +175,6 @@ export function Panels({ onComplete: _onComplete }: PanelsProps) { return Loading panels...; } - if (launching) { - return Attaching to agent...; - } - if (error) { return Error: {error}; } diff --git a/apps/cli/src/components/LaunchOverlay.tsx b/apps/cli/src/components/LaunchOverlay.tsx new file mode 100644 index 00000000000..c09dfa9fc6a --- /dev/null +++ b/apps/cli/src/components/LaunchOverlay.tsx @@ -0,0 +1,70 @@ +import { Box, Text } from "ink"; +import React from "react"; + +interface LaunchOverlayProps { + agentType: string; + sessionName: string; +} + +/** + * Animated loading overlay shown while launching/attaching to agent session + * Displays a breathing animation with gradient colors + */ +export function LaunchOverlay({ agentType, sessionName }: LaunchOverlayProps) { + const [frame, setFrame] = React.useState(0); + + // Braille spinner frames for smooth animation + const spinnerFrames = ["ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā "]; + + // Breathing dots animation + const breathingFrames = [ + "ā—", + "ā—ā—‹", + "ā—ā—‹ā—‹", + "ā—‹ā—ā—‹", + "ā—‹ā—‹ā—", + "ā—‹ā—", + "ā—", + "", + ]; + + React.useEffect(() => { + const interval = setInterval(() => { + setFrame((prev) => (prev + 1) % spinnerFrames.length); + }, 80); + + return () => clearInterval(interval); + }, [spinnerFrames.length]); + + const spinner = spinnerFrames[frame]; + const breathing = breathingFrames[frame % breathingFrames.length]; + + return ( + + + + {spinner} Launching {agentType} + + + + + {breathing} + + + + Session: {sessionName} + + + + + Please wait... + + + + ); +} 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/run.ts b/apps/cli/src/lib/launch/run.ts index 7886adfb62e..d1afcafa29e 100644 --- a/apps/cli/src/lib/launch/run.ts +++ b/apps/cli/src/lib/launch/run.ts @@ -24,11 +24,13 @@ function isTmuxInstalled(): boolean { } /** - * Check if a tmux session already exists + * Check if a tmux session already exists (synchronous to avoid hangs) */ -async function tmuxSessionExists(sessionName: string): Promise { +function tmuxSessionExists(sessionName: string): boolean { try { - await execAsync(`tmux has-session -t "${sessionName}" 2>/dev/null`); + execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { + stdio: "ignore", + }); return true; } catch { return false; @@ -40,10 +42,11 @@ async function tmuxSessionExists(sessionName: string): Promise { * - 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 } = { attach: true }, + options: { attach?: boolean; silent?: boolean } = { attach: true }, ): Promise { const command = getLaunchCommand(agent); @@ -66,15 +69,15 @@ export async function launchAgent( const sessionName = agent.sessionName || `agent-${agent.id.slice(0, 6)}`; // Check if session already exists - const exists = await tmuxSessionExists(sessionName); + const exists = tmuxSessionExists(sessionName); if (exists) { // Session exists - attach if requested if (options.attach) { - console.log( - `\nSession "${sessionName}" exists. Attaching...\n`, - ); - return attachToAgent(agent); + if (!options.silent) { + console.log(`\nSession "${sessionName}" exists. Attaching...\n`); + } + return attachToAgent(agent, options.silent); } // Session exists but not attaching - just return success return { @@ -87,18 +90,29 @@ export async function launchAgent( try { await execAsync(`tmux new-session -d -s "${sessionName}" "${command}"`); + // Wait a moment and verify the session is still alive + await new Promise((resolve) => setTimeout(resolve, 200)); + const stillExists = tmuxSessionExists(sessionName); + + if (!stillExists) { + return { + success: false, + error: `Session "${sessionName}" was created but exited immediately.\nThe launch command may be invalid or missing: ${command}\n\nPlease verify:\n 1. The command is installed and on PATH\n 2. The command doesn't exit immediately\n 3. Check 'which ${command.split(" ")[0]}' to verify the binary exists`, + }; + } + if (options.attach) { - // Created successfully, now attach - console.log( - `\nSession "${sessionName}" created. Attaching...\n`, - ); - return attachToAgent(agent); + // Created successfully and still alive, now attach + if (!options.silent) { + console.log(`\nSession "${sessionName}" created. Attaching...\n`); + } + return attachToAgent(agent, options.silent); } // Created but not attaching - just return success - console.log( - `\nāœ“ Agent session created: ${sessionName}\n`, - ); + if (!options.silent) { + console.log(`\nāœ“ Agent session created: ${sessionName}\n`); + } return { success: true, exitCode: 0, @@ -117,24 +131,39 @@ export async function launchAgent( /** * 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 */ -export async function attachToAgent(agent: Agent): Promise { +export async function attachToAgent( + agent: Agent, + silent = false, +): Promise { const sessionName = agent.sessionName || `agent-${agent.id.slice(0, 6)}`; // Check if session exists - const exists = await tmuxSessionExists(sessionName); + const exists = tmuxSessionExists(sessionName); if (!exists) { - return { - success: false, - error: `Session "${sessionName}" not found. The agent may have stopped or never started.`, - }; + // Session missing - try to recreate it by calling launchAgent + if (!silent) { + console.log( + `\nSession "${sessionName}" not found. Creating new session...\n`, + ); + } + return launchAgent(agent, { attach: true, 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`); + 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 { diff --git a/apps/cli/src/lib/orchestrators/process-orchestrator.ts b/apps/cli/src/lib/orchestrators/process-orchestrator.ts index 7f0c6691e5f..9d572742fe0 100644 --- a/apps/cli/src/lib/orchestrators/process-orchestrator.ts +++ b/apps/cli/src/lib/orchestrators/process-orchestrator.ts @@ -29,14 +29,14 @@ function isTmuxInstalled(): boolean { } /** - * Check if a tmux session exists + * Check if a tmux session exists (synchronous to avoid promise hangs) */ -async function tmuxSessionExists(sessionName: string): Promise { +function tmuxSessionExists(sessionName: string): boolean { if (!isTmuxInstalled()) { return false; } try { - await execAsync(`tmux has-session -t "${sessionName}" 2>/dev/null`); + execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: "ignore" }); return true; } catch { return false; @@ -61,7 +61,7 @@ export class ProcessOrchestrator implements IProcessOrchestrator { // Sync agent status with tmux session reality let needsSync = false; if (backfilled.type === ProcessType.AGENT && "sessionName" in backfilled) { - needsSync = await this.syncAgentStatus(backfilled as Agent); + needsSync = this.syncAgentStatus(backfilled as Agent); } // Persist backfilled defaults or status sync if needed @@ -82,7 +82,7 @@ export class ProcessOrchestrator implements IProcessOrchestrator { // Sync agent status with tmux session reality let needsSync = false; if (backfilled.type === ProcessType.AGENT && "sessionName" in backfilled) { - needsSync = await this.syncAgentStatus(backfilled as Agent); + needsSync = this.syncAgentStatus(backfilled as Agent); } // Persist backfilled defaults or status sync if needed @@ -121,12 +121,12 @@ export class ProcessOrchestrator implements IProcessOrchestrator { * Sync agent status with tmux session reality * Returns true if status was changed and needs persisting */ - private async syncAgentStatus(agent: Agent): Promise { + private syncAgentStatus(agent: Agent): boolean { if (!agent.sessionName) { return false; } - const sessionExists = await tmuxSessionExists(agent.sessionName); + const sessionExists = tmuxSessionExists(agent.sessionName); // If session doesn't exist but agent is marked as RUNNING, mark it STOPPED if (!sessionExists && agent.status === ProcessStatus.RUNNING && !agent.endedAt) { @@ -136,9 +136,15 @@ export class ProcessOrchestrator implements IProcessOrchestrator { return true; } - // If session exists but agent is marked STOPPED without endedAt, mark as RUNNING - if (sessionExists && agent.status === ProcessStatus.STOPPED && !agent.endedAt) { + // If session exists and agent is not STOPPED, upgrade to RUNNING + // This handles IDLE → RUNNING and clears endedAt for restarted agents + if ( + sessionExists && + (agent.status === ProcessStatus.IDLE || + (agent.status === ProcessStatus.STOPPED && agent.endedAt)) + ) { agent.status = ProcessStatus.RUNNING; + agent.endedAt = undefined; // Clear endedAt when restarting agent.updatedAt = new Date(); return true; } From 9e20bb614281e857be865cc087e48d240d7a9a64 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Wed, 19 Nov 2025 23:22:31 -0800 Subject: [PATCH 06/12] Enhance agent attachment error handling and status updates --- apps/cli/src/commands/agent.tsx | 18 +++++ apps/cli/src/commands/dashboard.tsx | 72 ++++++++----------- apps/cli/src/commands/panels.tsx | 49 +++++++------ apps/cli/src/lib/launch/run.ts | 13 +++- .../lib/orchestrators/process-orchestrator.ts | 17 ++--- 5 files changed, 91 insertions(+), 78 deletions(-) diff --git a/apps/cli/src/commands/agent.tsx b/apps/cli/src/commands/agent.tsx index 75405ead36d..d9d0a70f149 100644 --- a/apps/cli/src/commands/agent.tsx +++ b/apps/cli/src/commands/agent.tsx @@ -683,6 +683,24 @@ export function AgentAttach({ id, onComplete: _onComplete }: AgentAttachProps) { setTimeout(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) { + // 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 agent\n`); console.error(`Error: ${result.error}\n`); globalThis.process.exit(1); diff --git a/apps/cli/src/commands/dashboard.tsx b/apps/cli/src/commands/dashboard.tsx index ee4df18346f..926b2809fd4 100644 --- a/apps/cli/src/commands/dashboard.tsx +++ b/apps/cli/src/commands/dashboard.tsx @@ -1,6 +1,5 @@ import { Box, Text, useApp, useInput, useStdout } from "ink"; import React from "react"; -import { LaunchOverlay } from "../components/LaunchOverlay"; import { getDb } from "../lib/db"; import { launchAgent } from "../lib/launch/run"; import { ProcessOrchestrator } from "../lib/orchestrators/process-orchestrator"; @@ -30,10 +29,6 @@ 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 [launching, setLaunching] = React.useState(false); - const [launchingAgent, setLaunchingAgent] = React.useState( - null, - ); const [selectedWorkspaceIndex, setSelectedWorkspaceIndex] = React.useState(0); const [selectedAgentIndex, setSelectedAgentIndex] = React.useState(0); const [selectionMode, setSelectionMode] = @@ -149,11 +144,7 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { // Actions else if (key.return) { // Launch selected agent - if ( - selectionMode === "agent" && - filteredAgents[selectedAgentIndex] && - !launching - ) { + if (selectionMode === "agent" && filteredAgents[selectedAgentIndex]) { const selectedAgent = filteredAgents[selectedAgentIndex]; // Only launch agents, not terminals @@ -164,30 +155,35 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { // Exit Ink immediately and launch agent const agentToLaunch = selectedAgent as Agent; exit(); - setTimeout(() => { - launchAgent(agentToLaunch, { attach: true }) - .then((result) => { - if (!result.success) { - 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); - }) - .catch((error) => { - console.error( - `\nāŒ Failed to attach to ${agentToLaunch.agentType} agent\n`, - ); + setTimeout(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( - `Error: ${error instanceof Error ? error.message : String(error)}\n`, + `\nWarning: Failed to update agent status: ${dbError instanceof Error ? dbError.message : String(dbError)}\n`, ); - process.exit(1); - }); + } + + 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); }, 100); } } else if (input === "r") { @@ -223,18 +219,6 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { return Loading dashboard...; } - if (launching && launchingAgent) { - return ( - - ); - } - if (error) { return Error: {error}; } diff --git a/apps/cli/src/commands/panels.tsx b/apps/cli/src/commands/panels.tsx index 17257a61f44..eff9a85be7c 100644 --- a/apps/cli/src/commands/panels.tsx +++ b/apps/cli/src/commands/panels.tsx @@ -138,30 +138,35 @@ export function Panels({ onComplete: _onComplete }: PanelsProps) { // Exit Ink immediately and launch agent const agentToAttach = selectedAgent as Agent; exit(); - setTimeout(() => { - launchAgent(agentToAttach, { attach: true }) - .then((result) => { - if (!result.success) { - 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); - }) - .catch((error) => { - console.error( - `\nāŒ Failed to attach to ${agentToAttach.agentType} agent\n`, - ); + setTimeout(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) { + // Log DB error but don't fail the process console.error( - `Error: ${error instanceof Error ? error.message : String(error)}\n`, + `\nWarning: Failed to update agent status: ${dbError instanceof Error ? dbError.message : String(dbError)}\n`, ); - process.exit(1); - }); + } + + 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); }, 100); } } diff --git a/apps/cli/src/lib/launch/run.ts b/apps/cli/src/lib/launch/run.ts index d1afcafa29e..14d7afcfb02 100644 --- a/apps/cli/src/lib/launch/run.ts +++ b/apps/cli/src/lib/launch/run.ts @@ -90,8 +90,8 @@ export async function launchAgent( try { await execAsync(`tmux new-session -d -s "${sessionName}" "${command}"`); - // Wait a moment and verify the session is still alive - await new Promise((resolve) => setTimeout(resolve, 200)); + // Wait longer and verify the session is still alive (increased from 200ms to 500ms) + await new Promise((resolve) => setTimeout(resolve, 500)); const stillExists = tmuxSessionExists(sessionName); if (!stillExists) { @@ -102,6 +102,15 @@ export async function launchAgent( } if (options.attach) { + // Double-check session still exists before attaching + const existsBeforeAttach = tmuxSessionExists(sessionName); + if (!existsBeforeAttach) { + 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`); diff --git a/apps/cli/src/lib/orchestrators/process-orchestrator.ts b/apps/cli/src/lib/orchestrators/process-orchestrator.ts index 9d572742fe0..04f69f1ee80 100644 --- a/apps/cli/src/lib/orchestrators/process-orchestrator.ts +++ b/apps/cli/src/lib/orchestrators/process-orchestrator.ts @@ -128,23 +128,20 @@ export class ProcessOrchestrator implements IProcessOrchestrator { const sessionExists = tmuxSessionExists(agent.sessionName); - // If session doesn't exist but agent is marked as RUNNING, mark it STOPPED - if (!sessionExists && agent.status === ProcessStatus.RUNNING && !agent.endedAt) { + // 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 STOPPED, upgrade to RUNNING - // This handles IDLE → RUNNING and clears endedAt for restarted agents - if ( - sessionExists && - (agent.status === ProcessStatus.IDLE || - (agent.status === ProcessStatus.STOPPED && agent.endedAt)) - ) { + // 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 restarting + agent.endedAt = undefined; // Clear endedAt when session is alive agent.updatedAt = new Date(); return true; } From 64fbac11d3d73eb1bb0ec83b0bb58f68488aeb5f Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Thu, 20 Nov 2025 00:10:57 -0800 Subject: [PATCH 07/12] made panels more responsive --- apps/cli/src/commands/agent.tsx | 16 +- apps/cli/src/commands/dashboard.tsx | 6 +- apps/cli/src/commands/panels.tsx | 442 +++++++++------------- apps/cli/src/components/LaunchOverlay.tsx | 70 ---- apps/cli/src/lib/launch/run.ts | 15 - 5 files changed, 200 insertions(+), 349 deletions(-) diff --git a/apps/cli/src/commands/agent.tsx b/apps/cli/src/commands/agent.tsx index d9d0a70f149..47e6b47155c 100644 --- a/apps/cli/src/commands/agent.tsx +++ b/apps/cli/src/commands/agent.tsx @@ -673,15 +673,14 @@ export function AgentAttach({ id, onComplete: _onComplete }: AgentAttachProps) { const agent = process as import("../types/process").Agent; - // Import and call attachToAgent + // Import and call launchAgent const { launchAgent } = await import("../lib/launch/run"); - // Exit the Ink app before attaching + // Exit Ink to stop useInput before tmux takes over stdin exit(); - - // Small delay to let Ink clean up - setTimeout(async () => { + setImmediate(async () => { const result = await launchAgent(agent, { attach: true }); + if (!result.success) { // Update agent status to STOPPED on failure try { @@ -695,7 +694,6 @@ export function AgentAttach({ id, onComplete: _onComplete }: AgentAttachProps) { 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`, ); @@ -703,10 +701,14 @@ export function AgentAttach({ id, onComplete: _onComplete }: AgentAttachProps) { 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); - }, 100); + }); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); setLoading(false); diff --git a/apps/cli/src/commands/dashboard.tsx b/apps/cli/src/commands/dashboard.tsx index 926b2809fd4..18f1133464a 100644 --- a/apps/cli/src/commands/dashboard.tsx +++ b/apps/cli/src/commands/dashboard.tsx @@ -152,10 +152,10 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { return; } - // Exit Ink immediately and launch agent const agentToLaunch = selectedAgent as Agent; + // Exit Ink to stop useInput before tmux takes over stdin exit(); - setTimeout(async () => { + setImmediate(async () => { const result = await launchAgent(agentToLaunch, { attach: true }); if (!result.success) { @@ -184,7 +184,7 @@ export function Dashboard({ onComplete: _onComplete }: DashboardProps) { process.exit(1); } process.exit(0); - }, 100); + }); } } else if (input === "r") { loadDashboard(); diff --git a/apps/cli/src/commands/panels.tsx b/apps/cli/src/commands/panels.tsx index eff9a85be7c..2643d105e5e 100644 --- a/apps/cli/src/commands/panels.tsx +++ b/apps/cli/src/commands/panels.tsx @@ -25,6 +25,9 @@ interface PanelsProps { 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); @@ -135,10 +138,10 @@ export function Panels({ onComplete: _onComplete }: PanelsProps) { return; } - // Exit Ink immediately and launch agent const agentToAttach = selectedAgent as Agent; + // Exit Ink to stop useInput before tmux takes over stdin exit(); - setTimeout(async () => { + setImmediate(async () => { const result = await launchAgent(agentToAttach, { attach: true }); if (!result.success) { @@ -151,7 +154,6 @@ export function Panels({ onComplete: _onComplete }: PanelsProps) { 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`, ); @@ -167,7 +169,7 @@ export function Panels({ onComplete: _onComplete }: PanelsProps) { process.exit(1); } process.exit(0); - }, 100); + }); } } @@ -197,293 +199,225 @@ export function Panels({ onComplete: _onComplete }: PanelsProps) { : agents; const selectedAgent = filteredAgents[selectedAgentIndex]; - const panelHeight = terminalHeight - 4; - const leftPanelWidth = Math.floor(terminalWidth * 0.25); - const middlePanelWidth = Math.floor(terminalWidth * 0.3); - const rightPanelWidth = terminalWidth - leftPanelWidth - middlePanelWidth - 6; - - const getStatusIcon = (status: ProcessStatus, endedAt?: Date) => { - if (endedAt) return "ā—‹"; - switch (status) { - case ProcessStatus.RUNNING: - return "ā—"; - case ProcessStatus.IDLE: - return "ā—‹"; - default: - return "ā—‹"; - } - }; - - const getStatusColor = ( - status: ProcessStatus, - endedAt?: Date, - ): "green" | "yellow" | "gray" | undefined => { - if (endedAt) return "gray"; - switch (status) { - case ProcessStatus.RUNNING: - return "green"; - case ProcessStatus.IDLE: - return "yellow"; - default: - return "gray"; - } - }; - return ( - + {/* Header */} - - - - SUPERSET - - - {selectedWorkspace && - `${selectedWorkspace.name || selectedWorkspace.id.slice(0, 8)}`} - - + + SUPERSET PANELS + {data.lastRefresh.toLocaleTimeString()} - {/* Three Panel Layout */} - - {/* Left Panel - Workspaces */} + {/* Panels layout */} + + {/* Workspaces panel */} - - - WORKSPACES - + + Workspaces ({workspaces.length}) - - {workspaces.map((ws, index) => { - const isSelected = index === selectedWorkspaceIndex; - const isCurrent = ws.id === currentWorkspaceId; - const wsAgents = agents.filter((a) => a.workspaceId === ws.id); - const wsRunning = wsAgents.filter( - (a) => a.status === ProcessStatus.RUNNING, - ); + {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 ? "ā–ø" : " "} {wsRunning.length > 0 ? "ā—" : "ā—‹"}{" "} - {ws.name || ws.id.slice(0, 8)} - - - ); - })} - - [W] New + return ( + + + + {isSelected ? "ā–ø" : " "} + + {statusEmoji} + + {ws.name || ws.id.slice(0, 8)} + + + + {" "} + {ws.type} + {isCurrent && " (active)"} + + + ); + })} - + )} - {/* Middle Panel - Agents */} + {/* Agents panel */} - - - AGENTS ({filteredAgents.length}) - + + Agents ({filteredAgents.length}) - - {filteredAgents.length === 0 ? ( - No agents - ) : ( - filteredAgents.map((agent, index) => { + {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 sessionName = - agent.type === ProcessType.AGENT && - "sessionName" in agent && - agent.sessionName - ? String(agent.sessionName) - : null; - const timeAgo = Math.floor( - (Date.now() - new Date(agent.createdAt).getTime()) / 60000, - ); + 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 ? "ā–ø" : " "} - - - {getStatusIcon(agent.status, agent.endedAt)} - - - {sessionName || `${agentType}-${timeAgo}m`} + + + + {isSelected ? "ā–ø" : " "} + + {statusEmoji} + + {agentType} + + + + {" "} + {new Date(agent.createdAt).toLocaleTimeString()} ); - }) - )} - - [N] New Agent + })} - - [Enter] Open - - + )} - {/* Right Panel - Details */} - - - - DETAILS - - - + {/* Details panel - hidden on small terminals */} + {terminalWidth >= SMALL_TERMINAL_THRESHOLD && ( + + + Details + {selectedAgent ? ( - <> - {/* Agent Details */} - - - {selectedAgent.type === ProcessType.AGENT && - "agentType" in selectedAgent - ? String(selectedAgent.agentType) - : "Unknown"} - - - Started:{" "} - {Math.floor( - (Date.now() - - new Date(selectedAgent.createdAt).getTime()) / - 60000, - )} - m ago - - - Status:{" "} - - {selectedAgent.endedAt ? "stopped" : selectedAgent.status} + + {selectedAgent.type === ProcessType.AGENT && + "agentType" in selectedAgent ? ( + <> + + Agent:{" "} + {String(selectedAgent.agentType)} - - ID: {selectedAgent.id.slice(0, 8)} - - - {/* Task Info */} - {selectedAgent.title && ( - - Task: - {selectedAgent.title} - - )} - - {/* Workspace Info */} - - Workspace: - - {workspaces.find((w) => w.id === selectedAgent.workspaceId) - ?.name || selectedAgent.workspaceId.slice(0, 8)} - - - - {/* Launch Command */} - {selectedAgent.launchCommand && ( - - Command: - {selectedAgent.launchCommand} - + 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 )} - - {/* Timestamps */} - - Timeline: - - Created:{" "} - {new Date(selectedAgent.createdAt).toLocaleTimeString()} - - - Updated:{" "} - {new Date(selectedAgent.updatedAt).toLocaleTimeString()} - - {selectedAgent.endedAt && ( - - Ended:{" "} - {new Date(selectedAgent.endedAt).toLocaleTimeString()} - - )} - - + ) : ( - Select an agent to view details + Select an agent to see details )} - + )} - {/* Footer */} - - - - [1] Workspaces - - • - - [2] Agents - - • - - [3] Details - - • - - [Enter] Open Agent - - • - - [q] Exit - + {/* 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/components/LaunchOverlay.tsx b/apps/cli/src/components/LaunchOverlay.tsx index c09dfa9fc6a..e69de29bb2d 100644 --- a/apps/cli/src/components/LaunchOverlay.tsx +++ b/apps/cli/src/components/LaunchOverlay.tsx @@ -1,70 +0,0 @@ -import { Box, Text } from "ink"; -import React from "react"; - -interface LaunchOverlayProps { - agentType: string; - sessionName: string; -} - -/** - * Animated loading overlay shown while launching/attaching to agent session - * Displays a breathing animation with gradient colors - */ -export function LaunchOverlay({ agentType, sessionName }: LaunchOverlayProps) { - const [frame, setFrame] = React.useState(0); - - // Braille spinner frames for smooth animation - const spinnerFrames = ["ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā "]; - - // Breathing dots animation - const breathingFrames = [ - "ā—", - "ā—ā—‹", - "ā—ā—‹ā—‹", - "ā—‹ā—ā—‹", - "ā—‹ā—‹ā—", - "ā—‹ā—", - "ā—", - "", - ]; - - React.useEffect(() => { - const interval = setInterval(() => { - setFrame((prev) => (prev + 1) % spinnerFrames.length); - }, 80); - - return () => clearInterval(interval); - }, [spinnerFrames.length]); - - const spinner = spinnerFrames[frame]; - const breathing = breathingFrames[frame % breathingFrames.length]; - - return ( - - - - {spinner} Launching {agentType} - - - - - {breathing} - - - - Session: {sessionName} - - - - - Please wait... - - - - ); -} diff --git a/apps/cli/src/lib/launch/run.ts b/apps/cli/src/lib/launch/run.ts index 14d7afcfb02..413b1ac967c 100644 --- a/apps/cli/src/lib/launch/run.ts +++ b/apps/cli/src/lib/launch/run.ts @@ -176,21 +176,6 @@ export async function attachToAgent( return new Promise((resolve) => { try { - // Set tmux status bar message before attaching - try { - execSync( - `tmux set-option -t "${sessionName}" status-left-length 100 2>/dev/null`, - ); - execSync( - `tmux set-option -t "${sessionName}" status-style "bg=yellow,fg=black" 2>/dev/null`, - ); - execSync( - `tmux set-option -t "${sessionName}" status-left "#[bg=yellow,fg=black,bold] Ctrl-b d to detach and keep agent running #[default]" 2>/dev/null`, - ); - } catch { - // Ignore errors setting status bar - } - // Spawn tmux attach with inherited stdio const child = spawn("tmux", ["attach", "-t", sessionName], { stdio: "inherit", From d75f92e4fdc2e851ae093071013d80964f022946 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Thu, 20 Nov 2025 00:26:01 -0800 Subject: [PATCH 08/12] updated agent start --- apps/cli/src/commands/agent.tsx | 81 +++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/commands/agent.tsx b/apps/cli/src/commands/agent.tsx index 47e6b47155c..a08169e38f1 100644 --- a/apps/cli/src/commands/agent.tsx +++ b/apps/cli/src/commands/agent.tsx @@ -434,6 +434,9 @@ export function AgentStart({ workspaceId, onComplete }: AgentStartProps) { 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 () => { @@ -480,20 +483,21 @@ export function AgentStart({ workspaceId, onComplete }: AgentStartProps) { 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 + // Create the process in IDLE state const process = await processOrchestrator.create( ProcessType.AGENT, ws, agentType, ); - // Transition to RUNNING status and set launch command + // Set launch command but keep in IDLE state await processOrchestrator.update(process.id, { - status: ProcessStatus.RUNNING, launchCommand, }); @@ -503,14 +507,21 @@ export function AgentStart({ workspaceId, onComplete }: AgentStartProps) { const result = await launchAgent(agent, { attach: false }); if (!result.success) { - // If session creation fails, mark agent as error + // 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", }); - console.error( - `Failed to create session for ${agentType}: ${result.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); } } @@ -519,12 +530,27 @@ export function AgentStart({ workspaceId, onComplete }: AgentStartProps) { await workspaceOrchestrator.use(ws.id); setStartedAgents(created); - setStep(StartStep.COMPLETE); + setFailures(failures); - // Auto-exit after showing success - setTimeout(() => { - exit(); - }, 2000); + // 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"); } @@ -602,20 +628,45 @@ export function AgentStart({ workspaceId, onComplete }: AgentStartProps) { } if (step === StartStep.COMPLETE) { + const totalAttempted = selectedAgents.length; + const successCount = startedAgents.length; + const failureCount = failures.length; + return ( - - āœ“ Started {startedAgents.length} agent(s) successfully! + 0 ? "yellow" : "green"}> + {failureCount > 0 ? "⚠" : "āœ“"} Started {successCount}/{totalAttempted}{" "} + agent(s) successfully + {failureCount > 0 ? ` (${failureCount} failed)` : "!"} Workspace: {workspace?.name || workspace?.id} - Agents: {selectedAgents.join(", ")} + {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 + + )} ); } From 8eec9bf36467649f638673457ce72aeb620eb3e2 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Thu, 20 Nov 2025 02:00:07 -0800 Subject: [PATCH 09/12] fix bad bug --- apps/cli/readme.md | 5 +- apps/cli/src/cli.tsx | 2 +- apps/cli/src/commands/agent.tsx | 2 +- apps/cli/src/commands/init.tsx | 30 ++- apps/cli/src/commands/panels.tsx | 7 +- apps/cli/src/components/LaunchOverlay.tsx | 0 apps/cli/src/lib/launch/run.ts | 250 ++++++++++++++---- .../__tests__/process-orchestrator.test.ts | 32 ++- .../lib/orchestrators/process-orchestrator.ts | 5 + apps/cli/src/lib/persistence_docs/EXAMPLE.md | 9 +- apps/cli/src/lib/persistence_docs/SUMMARY.md | 2 +- 11 files changed, 248 insertions(+), 96 deletions(-) delete mode 100644 apps/cli/src/components/LaunchOverlay.tsx diff --git a/apps/cli/readme.md b/apps/cli/readme.md index a861ecb4c65..3fe1c289007 100644 --- a/apps/cli/readme.md +++ b/apps/cli/readme.md @@ -33,11 +33,14 @@ CLI for managing environments, workspaces (git worktrees/cloud branches), agents - 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 `~/.config/superset/cli.json` (default), seeded with a `default` environment and `state.currentWorkspaceId`. +- 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). ## Tips diff --git a/apps/cli/src/cli.tsx b/apps/cli/src/cli.tsx index fb650c375b2..1b34816c063 100644 --- a/apps/cli/src/cli.tsx +++ b/apps/cli/src/cli.tsx @@ -271,7 +271,7 @@ agent agent .command("stop-all") .description( - "Stop all agents/processes (uses current workspace if --workspace not provided)", + "Stop all agents in workspace (kills tmux sessions, does not affect terminals)", ) .option("--workspace ", "Workspace ID to stop agents in") .action((options: { workspace?: string }) => { diff --git a/apps/cli/src/commands/agent.tsx b/apps/cli/src/commands/agent.tsx index a08169e38f1..33e1bb3e5ca 100644 --- a/apps/cli/src/commands/agent.tsx +++ b/apps/cli/src/commands/agent.tsx @@ -341,7 +341,7 @@ export function AgentStopAll({ workspaceId, onComplete }: AgentStopAllProps) { }, [workspaceId, onComplete]); if (loading) { - return Stopping all agents/processes...; + return Stopping all agents...; } if (error) { diff --git a/apps/cli/src/commands/init.tsx b/apps/cli/src/commands/init.tsx index 2d10dfbb36f..e6982dfa769 100644 --- a/apps/cli/src/commands/init.tsx +++ b/apps/cli/src/commands/init.tsx @@ -166,22 +166,26 @@ export function Init({ onComplete }: InitProps) { const db = getDb(); const orchestrator = new WorkspaceOrchestrator(db); - // Validate that the default environment exists - const environments = await db.read(); - const defaultEnv = environments.environments["default"]; - - if (!defaultEnv) { - setState((s) => ({ - ...s, - error: - 'Default environment not found. Run "superset env create" first.', - step: InitStep.SELECT_TYPE, - })); - return; + // 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( - "default", + envId, state.workspaceType!, { path: state.path || undefined, diff --git a/apps/cli/src/commands/panels.tsx b/apps/cli/src/commands/panels.tsx index 2643d105e5e..1b53c9ebe62 100644 --- a/apps/cli/src/commands/panels.tsx +++ b/apps/cli/src/commands/panels.tsx @@ -4,12 +4,7 @@ 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 Agent, type Process, ProcessStatus, ProcessType } from "../types/process"; import type { Workspace } from "../types/workspace"; interface PanelsData { diff --git a/apps/cli/src/components/LaunchOverlay.tsx b/apps/cli/src/components/LaunchOverlay.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/cli/src/lib/launch/run.ts b/apps/cli/src/lib/launch/run.ts index 413b1ac967c..a6687ae3438 100644 --- a/apps/cli/src/lib/launch/run.ts +++ b/apps/cli/src/lib/launch/run.ts @@ -37,6 +37,169 @@ function tmuxSessionExists(sessionName: string): boolean { } } +/** + * 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 }, + 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); + + // 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 @@ -66,6 +229,15 @@ export async function launchAgent( }; } + // Preflight check: verify the launch command binary exists on PATH + const binary = command.split(" ")[0]; + if (!binary || !commandExists(binary)) { + return { + success: false, + error: `Command not found: ${binary || "empty"}\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 @@ -86,55 +258,8 @@ export async function launchAgent( }; } - // Session doesn't exist - create it in detached mode - try { - await execAsync(`tmux new-session -d -s "${sessionName}" "${command}"`); - - // Wait longer and verify the session is still alive (increased from 200ms to 500ms) - await new Promise((resolve) => setTimeout(resolve, 500)); - const stillExists = tmuxSessionExists(sessionName); - - if (!stillExists) { - return { - success: false, - error: `Session "${sessionName}" was created but exited immediately.\nThe launch command may be invalid or missing: ${command}\n\nPlease verify:\n 1. The command is installed and on PATH\n 2. The command doesn't exit immediately\n 3. Check 'which ${command.split(" ")[0]}' to verify the binary exists`, - }; - } - - if (options.attach) { - // Double-check session still exists before attaching - const existsBeforeAttach = tmuxSessionExists(sessionName); - if (!existsBeforeAttach) { - 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`); - } - return attachToAgent(agent, options.silent); - } - - // 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) { - return { - success: false, - error: - error instanceof Error - ? `Failed to create tmux session: ${error.message}` - : "Unknown error creating tmux session", - }; - } + // Session doesn't exist - create it in detached mode with retry + return createSessionWithRetry(agent, sessionName, command, options); } /** @@ -183,28 +308,41 @@ export async function attachToAgent( }); 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", (code) => { + child.on("exit", async (code) => { // Exit code 0 means user detached successfully if (code === 0 || code === null) { resolve({ success: true, exitCode: code || 0, }); - } else { - resolve({ - success: false, - exitCode: code, - error: `Attach process exited with code ${code}`, - }); + return; + } + + // Non-zero exit code indicates failure - kill the session + killTmuxSession(sessionName); + + // One-time recovery: recreate the session and attach + // This handles cases where the pane died between session creation and attach + // The preflight and live-pane checks in launchAgent prevent infinite retries + if (!silent) { + console.log( + `\nSession pane died. Attempting to recreate session...\n`, + ); } + const retry = await launchAgent(agent, { attach: true, silent }); + 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__/process-orchestrator.test.ts b/apps/cli/src/lib/orchestrators/__tests__/process-orchestrator.test.ts index 0d1b1f09109..d67b6f2ef5c 100644 --- a/apps/cli/src/lib/orchestrators/__tests__/process-orchestrator.test.ts +++ b/apps/cli/src/lib/orchestrators/__tests__/process-orchestrator.test.ts @@ -239,7 +239,7 @@ describe("ProcessOrchestrator", () => { }); 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, @@ -247,8 +247,8 @@ describe("ProcessOrchestrator", () => { { 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,15 +256,18 @@ 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(ProcessStatus.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, @@ -272,18 +275,19 @@ describe("ProcessOrchestrator", () => { { 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()); }); }); diff --git a/apps/cli/src/lib/orchestrators/process-orchestrator.ts b/apps/cli/src/lib/orchestrators/process-orchestrator.ts index 04f69f1ee80..6cf15e275ac 100644 --- a/apps/cli/src/lib/orchestrators/process-orchestrator.ts +++ b/apps/cli/src/lib/orchestrators/process-orchestrator.ts @@ -269,6 +269,11 @@ export class ProcessOrchestrator implements IProcessOrchestrator { await this.storage.set("processes", id, updated); } + /** + * 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(); 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 From f928f7564a9d157575abdb9869728a3a43ad9b84 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Thu, 20 Nov 2025 02:11:41 -0800 Subject: [PATCH 10/12] more sturdy --- apps/cli/src/lib/launch/run.ts | 44 ++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/lib/launch/run.ts b/apps/cli/src/lib/launch/run.ts index a6687ae3438..daaed0bc7e7 100644 --- a/apps/cli/src/lib/launch/run.ts +++ b/apps/cli/src/lib/launch/run.ts @@ -90,7 +90,7 @@ async function createSessionWithRetry( agent: Agent, sessionName: string, command: string, - options: { attach?: boolean; silent?: boolean }, + options: { attach?: boolean; silent?: boolean; retryCount?: number }, attempt = 1, ): Promise { const maxAttempts = 2; @@ -151,7 +151,7 @@ async function createSessionWithRetry( if (!options.silent) { console.log(`\nSession "${sessionName}" created. Attaching...\n`); } - const result = await attachToAgent(agent, options.silent); + const result = await attachToAgent(agent, options.silent, options.retryCount || 0); // If attach failed, kill the session if (!result.success) { @@ -209,7 +209,7 @@ async function createSessionWithRetry( */ export async function launchAgent( agent: Agent, - options: { attach?: boolean; silent?: boolean } = { attach: true }, + options: { attach?: boolean; silent?: boolean; retryCount?: number } = { attach: true }, ): Promise { const command = getLaunchCommand(agent); @@ -230,11 +230,15 @@ export async function launchAgent( } // Preflight check: verify the launch command binary exists on PATH - const binary = command.split(" ")[0]; - if (!binary || !commandExists(binary)) { + // Skip check for shell wrappers (bash -c, env FOO=bar, etc.) to avoid false negatives + const parts = command.split(" "); + const firstPart = parts[0]; + const isShellWrapper = ["bash", "sh", "zsh", "env"].includes(firstPart || ""); + + if (!isShellWrapper && firstPart && !commandExists(firstPart)) { return { success: false, - error: `Command not found: ${binary || "empty"}\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`, + error: `Command not found: ${firstPart}\n\nThe agent launch command is not available on your PATH.\nTo fix this:\n 1. Install the command: which ${firstPart}\n 2. Or set a custom command: export SUPERSET_AGENT_LAUNCH_${agent.agentType.toUpperCase()}=your-command`, }; } @@ -249,7 +253,7 @@ export async function launchAgent( if (!options.silent) { console.log(`\nSession "${sessionName}" exists. Attaching...\n`); } - return attachToAgent(agent, options.silent); + return attachToAgent(agent, options.silent, options.retryCount || 0); } // Session exists but not attaching - just return success return { @@ -266,23 +270,31 @@ export async function launchAgent( * 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 + // 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 }); + return launchAgent(agent, { attach: true, silent, retryCount: retryCount + 1 }); } if (!silent) { @@ -329,15 +341,23 @@ export async function attachToAgent( // Non-zero exit code indicates failure - kill the session killTmuxSession(sessionName); - // One-time recovery: recreate the session and attach + // One-time recovery: recreate the session and attach (if not already retried) // This handles cases where the pane died between session creation and attach - // The preflight and live-pane checks in launchAgent prevent infinite retries + 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 }); + const retry = await launchAgent(agent, { attach: true, silent, retryCount: retryCount + 1 }); resolve(retry); }); } catch (error) { From 16cdcbf3a9ba85db63a055ff81a33f200e0cbc4f Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Thu, 20 Nov 2025 02:20:55 -0800 Subject: [PATCH 11/12] make preflight checks better --- apps/cli/src/lib/launch/run.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/lib/launch/run.ts b/apps/cli/src/lib/launch/run.ts index daaed0bc7e7..99a16acbcd6 100644 --- a/apps/cli/src/lib/launch/run.ts +++ b/apps/cli/src/lib/launch/run.ts @@ -230,16 +230,22 @@ export async function launchAgent( } // Preflight check: verify the launch command binary exists on PATH - // Skip check for shell wrappers (bash -c, env FOO=bar, etc.) to avoid false negatives - const parts = command.split(" "); - const firstPart = parts[0]; - const isShellWrapper = ["bash", "sh", "zsh", "env"].includes(firstPart || ""); - - if (!isShellWrapper && firstPart && !commandExists(firstPart)) { - return { - success: false, - error: `Command not found: ${firstPart}\n\nThe agent launch command is not available on your PATH.\nTo fix this:\n 1. Install the command: which ${firstPart}\n 2. Or set a custom command: export SUPERSET_AGENT_LAUNCH_${agent.agentType.toUpperCase()}=your-command`, - }; + // 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)}`; From 0b79f09fb832ae2742fb82fb1c200dadcb7bcfe9 Mon Sep 17 00:00:00 2001 From: AviPeltz Date: Thu, 20 Nov 2025 02:36:41 -0800 Subject: [PATCH 12/12] readme security ish --- apps/cli/readme.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/cli/readme.md b/apps/cli/readme.md index 3fe1c289007..94085668156 100644 --- a/apps/cli/readme.md +++ b/apps/cli/readme.md @@ -43,6 +43,24 @@ CLI for managing environments, workspaces (git worktrees/cloud branches), agents - 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.).