diff --git a/packages/cli/src/cli-options.ts b/packages/cli/src/cli-options.ts index 156bdb1..9761ea1 100644 --- a/packages/cli/src/cli-options.ts +++ b/packages/cli/src/cli-options.ts @@ -22,6 +22,7 @@ export interface CliOptions { hubInfo: boolean; hubStop: boolean; hubStatus: boolean; + headless: boolean; } export function parseArgs(argv: string[]): CliOptions { @@ -36,6 +37,7 @@ export function parseArgs(argv: string[]): CliOptions { hubInfo: false, hubStop: false, hubStatus: false, + headless: false, }; const args = argv.slice(2); @@ -89,6 +91,9 @@ export function parseArgs(argv: string[]): CliOptions { } else if (arg === "--hub-status") { options.hubStatus = true; i++; + } else if (arg === "--headless") { + options.headless = true; + i++; } else if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fc63e34..ce461c1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -186,7 +186,7 @@ async function main(): Promise { } // First-run: trigger wizard if no config exists and no override flags - if (!configExists() && !options.tailscale && !options.local && process.stdin.isTTY) { + if (!options.headless && !configExists() && !options.tailscale && !options.local && process.stdin.isTTY) { await runSetupWizard(); } @@ -196,6 +196,8 @@ async function main(): Promise { process.exit(1); } + const headless = options.headless; + // Load config const config = loadConfig(); @@ -276,89 +278,85 @@ async function main(): Promise { } } - // Display connection info - const dashboardUrl = hubConfig - ? `http://${ip}:${HUB_EXTERNAL_PORT}?token=${hubConfig.masterToken}` - : null; + // Display connection info (skip in headless mode) + let sleepGuard: ChildProcess | null = null; + + if (!headless) { + const dashboardUrl = hubConfig + ? `http://${ip}:${HUB_EXTERNAL_PORT}?token=${hubConfig.masterToken}` + : null; - if (dashboardUrl && !options.noQr) { - // Show QR pointing to dashboard - if (!isFirstSession) { + if (dashboardUrl && !options.noQr) { + if (!isFirstSession) { + console.log(`\n Session "${cmd}" registered with hub.`); + } + displayQR(dashboardUrl); + } else if (dashboardUrl) { console.log(`\n Session "${cmd}" registered with hub.`); + console.log(` Dashboard: ${dashboardUrl}`); + console.log(""); + } else if (!options.noQr) { + const directUrl = `http://${ip}:${port}?token=${token}`; + displayQR(directUrl); } - displayQR(dashboardUrl); - } else if (dashboardUrl) { - // QR disabled — text only - console.log(`\n Session "${cmd}" registered with hub.`); - console.log(` Dashboard: ${dashboardUrl}`); + + sleepGuard = preventSleep(); + + console.log(` Server listening on ${host}:${port}`); + console.log(` Running: ${options.command.join(" ")}`); + console.log(` PID: ${ptyManager.pid}`); + console.log(` Sleep prevention: ${sleepGuard ? "active" : "unavailable"}`); console.log(""); - } else if (!options.noQr) { - // No hub — show direct session URL - const directUrl = `http://${ip}:${port}?token=${token}`; - displayQR(directUrl); - } - // Prevent laptop from sleeping during session - const sleepGuard = preventSleep(); + // Pipe local terminal I/O to/from the PTY + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.setEncoding("utf-8"); - console.log(` Server listening on ${host}:${port}`); - console.log(` Running: ${options.command.join(" ")}`); - console.log(` PID: ${ptyManager.pid}`); - console.log(` Sleep prevention: ${sleepGuard ? "active" : "unavailable"}`); - console.log(""); + process.stdin.on("data", (data: string) => { + if (process.stdout.columns && process.stdout.rows) { + if (process.stdout.columns !== ptyManager.cols || process.stdout.rows !== ptyManager.rows) { + server.resizeFromLocal(process.stdout.columns, process.stdout.rows); + } + } + ptyManager.write(data); + }); - // Pipe local terminal I/O to/from the PTY - // This lets the user interact with the agent locally too - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - process.stdin.resume(); - process.stdin.setEncoding("utf-8"); - - process.stdin.on("data", (data: string) => { - // Auto-resize PTY to match local terminal when user types locally. - // Handles the case where a phone resized the PTY smaller, - // and the user returns to the laptop without resizing the window. - if (process.stdout.columns && process.stdout.rows) { - if (process.stdout.columns !== ptyManager.cols || process.stdout.rows !== ptyManager.rows) { + ptyManager.onData((data: string) => { + process.stdout.write(data); + }); + + // Handle terminal resize + function handleResize(): void { + if (process.stdout.columns && process.stdout.rows) { server.resizeFromLocal(process.stdout.columns, process.stdout.rows); } } - ptyManager.write(data); - }); - - ptyManager.onData((data: string) => { - process.stdout.write(data); - }); - // Handle terminal resize — resizes PTY and broadcasts to all web clients - function handleResize(): void { - if (process.stdout.columns && process.stdout.rows) { - server.resizeFromLocal(process.stdout.columns, process.stdout.rows); - } + process.stdout.on("resize", handleResize); + handleResize(); } - process.stdout.on("resize", handleResize); - handleResize(); - // Clean shutdown async function cleanup(): Promise { - // Restore terminal state that the child process may have modified. - // Agents like Codex enable kitty keyboard protocol, bracketed paste, - // mouse tracking etc. — if killed abruptly they don't get to reset these. - process.stdout.write( - "\x1b[>0u" + // Reset kitty keyboard protocol to default - "\x1b[?2004l" + // Disable bracketed paste mode - "\x1b[?1000l" + // Disable mouse click tracking - "\x1b[?1002l" + // Disable mouse button tracking - "\x1b[?1003l" + // Disable mouse all-motion tracking - "\x1b[?1006l" + // Disable SGR mouse encoding - "\x1b[?25h" + // Show cursor (in case it was hidden) - "\x1b[?1049l" // Exit alternate screen buffer (if active) - ); - - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); + if (!headless) { + // Restore terminal state that the child process may have modified. + process.stdout.write( + "\x1b[>0u" + // Reset kitty keyboard protocol to default + "\x1b[?2004l" + // Disable bracketed paste mode + "\x1b[?1000l" + // Disable mouse click tracking + "\x1b[?1002l" + // Disable mouse button tracking + "\x1b[?1003l" + // Disable mouse all-motion tracking + "\x1b[?1006l" + // Disable SGR mouse encoding + "\x1b[?25h" + // Show cursor (in case it was hidden) + "\x1b[?1049l" // Exit alternate screen buffer (if active) + ); + + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } } // Stop heartbeat diff --git a/packages/hub/src/daemon.ts b/packages/hub/src/daemon.ts index c4446f7..1ef54d3 100644 --- a/packages/hub/src/daemon.ts +++ b/packages/hub/src/daemon.ts @@ -2,9 +2,11 @@ import { writeFileSync, mkdirSync, unlinkSync, existsSync, readdirSync, statSync import { homedir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { spawn, type ChildProcess } from "node:child_process"; import { generateToken } from "./auth.js"; import { SessionRegistry } from "./registry.js"; import { SessionStore } from "./session-store.js"; +import { ToolHistory } from "./tool-history.js"; import { createInternalApi } from "./internal-api.js"; import { createDashboardServer } from "./server.js"; import { PreviewCollector } from "./preview-collector.js"; @@ -30,6 +32,30 @@ function getHubConfigPath(): string { return join(getHubDir(), "hub.json"); } +/** Validate a tool name: alphanumeric, hyphens, underscores, dots only. */ +function isValidToolName(tool: string): boolean { + return /^[a-zA-Z0-9._-]+$/.test(tool) && tool.length > 0 && tool.length <= 100; +} + +/** + * Spawn a new headless CLI session. + * The CLI process registers with the hub as usual. + */ +function spawnSession( + tool: string, + cwd: string, + cliEntryPath: string, +): ChildProcess { + const child = spawn(process.execPath, [cliEntryPath, "--headless", "--", tool], { + cwd, + stdio: "ignore", + detached: true, + env: { ...process.env }, + }); + child.unref(); + return child; +} + async function main(): Promise { const hubDir = getHubDir(); mkdirSync(hubDir, { recursive: true }); @@ -56,6 +82,9 @@ async function main(): Promise { const registry = new SessionRegistry({ store: sessionStore }); registry.startHealthChecks(); + // Tool history for autocomplete + const toolHistory = new ToolHistory(); + // Clean up old session logs (default: 30 days retention) const logsDir = join(hubDir, "logs"); if (existsSync(logsDir)) { @@ -72,9 +101,11 @@ async function main(): Promise { } catch {} } - // Resolve path to the built dashboard + // Resolve paths const __dirname = dirname(fileURLToPath(import.meta.url)); const dashboardPath = join(__dirname, "dashboard"); + // Hub lives at dist/hub/daemon.js, CLI entry is at dist/index.js + const cliEntryPath = join(__dirname, "..", "index.js"); // Start internal API (localhost only) const internalApi = createInternalApi({ @@ -93,6 +124,14 @@ async function main(): Promise { host: "0.0.0.0", port: HUB_EXTERNAL_PORT, previewCollector, + toolHistory, + onCreateSession: (tool: string, cwd: string) => { + if (!isValidToolName(tool)) { + throw new Error(`Invalid tool name: ${tool}`); + } + toolHistory.recordUsage(tool); + spawnSession(tool, cwd, cliEntryPath); + }, }); // Wait for both servers to actually bind to their ports diff --git a/packages/hub/src/dashboard/index.html b/packages/hub/src/dashboard/index.html index 2b22549..265c8fb 100644 --- a/packages/hub/src/dashboard/index.html +++ b/packages/hub/src/dashboard/index.html @@ -26,6 +26,51 @@ + + + + + + diff --git a/packages/hub/src/dashboard/main.ts b/packages/hub/src/dashboard/main.ts index 3731099..dbdaf42 100644 --- a/packages/hub/src/dashboard/main.ts +++ b/packages/hub/src/dashboard/main.ts @@ -137,6 +137,155 @@ function refreshUptimes(): void { } } +// --- Create Session UI --- + +const fabCreate = document.getElementById("fab-create")!; +const createModal = document.getElementById("create-modal")!; +const modalClose = document.getElementById("modal-close")!; +const toolInput = document.getElementById("tool-input") as HTMLInputElement; +const toolChips = document.getElementById("tool-chips")!; +const dirSelected = document.getElementById("dir-selected")!; +const btnBrowse = document.getElementById("btn-browse")!; +const btnCreateSession = document.getElementById("btn-create-session") as HTMLButtonElement; +const createError = document.getElementById("create-error")!; +const createFormView = document.getElementById("create-form-view")!; +const browseView = document.getElementById("browse-view")!; +const browseBreadcrumb = document.getElementById("browse-breadcrumb")!; +const browseList = document.getElementById("browse-list")!; +const browseBack = document.getElementById("browse-back")!; +const browseSelect = document.getElementById("browse-select")!; +const createSpinner = document.getElementById("create-spinner")!; + +let selectedCwd = "~"; +let currentBrowsePath = "~"; + +function makeEl(tag: string, className: string, text: string): HTMLElement { + const el = document.createElement(tag); + el.className = className; + el.textContent = text; + return el; +} + +function openCreateModal(): void { + createModal.classList.remove("hidden"); + createFormView.classList.remove("hidden"); + browseView.classList.add("hidden"); + createSpinner.classList.add("hidden"); + createError.classList.add("hidden"); + toolInput.value = ""; + selectedCwd = "~"; + dirSelected.textContent = "~"; + updateCreateButton(); + fetchToolHistory(); + toolInput.focus(); +} + +function closeCreateModal(): void { + createModal.classList.add("hidden"); +} + +function updateCreateButton(): void { + btnCreateSession.disabled = !toolInput.value.trim(); +} + +async function fetchToolHistory(): Promise { + try { + const res = await fetch(`/api/tool-history?token=${token}`); + const data = await res.json(); + toolChips.replaceChildren(); + for (const name of (data.tools as string[]).slice(0, 6)) { + const chip = makeEl("span", "chip", name); + chip.addEventListener("click", () => { + toolInput.value = name; + updateCreateButton(); + }); + toolChips.appendChild(chip); + } + } catch { /* ignore */ } +} + +async function browseDirectory(path: string): Promise { + currentBrowsePath = path; + browseList.replaceChildren(makeEl("div", "browse-empty", "Loading...")); + + try { + const res = await fetch(`/api/browse?path=${encodeURIComponent(path)}&token=${token}`); + const data = await res.json(); + + if (data.error) { + browseList.replaceChildren(makeEl("div", "browse-empty", data.error as string)); + return; + } + + const displayPath = (data.path as string) || path; + currentBrowsePath = displayPath; + buildBreadcrumb(displayPath); + + const entries = data.entries as string[]; + if (entries.length === 0) { + browseList.replaceChildren(makeEl("div", "browse-empty", "No subdirectories")); + return; + } + + browseList.replaceChildren(); + for (const name of entries) { + const item = document.createElement("div"); + item.className = "browse-item"; + item.appendChild(makeEl("span", "browse-item-name", name)); + item.appendChild(makeEl("span", "browse-item-arrow", "\u203A")); + item.addEventListener("click", () => browseDirectory(`${displayPath}/${name}`)); + browseList.appendChild(item); + } + } catch { + browseList.replaceChildren(makeEl("div", "browse-empty", "Failed to load")); + } +} + +function buildBreadcrumb(displayPath: string): void { + browseBreadcrumb.replaceChildren(); + const parts = displayPath.split("/").filter(Boolean); + for (let i = 0; i < parts.length; i++) { + if (i > 0) { + browseBreadcrumb.appendChild(makeEl("span", "breadcrumb-sep", " / ")); + } + const seg = makeEl("span", "breadcrumb-segment", parts[i]); + const targetPath = parts.slice(0, i + 1).join("/"); + seg.addEventListener("click", () => browseDirectory(targetPath)); + browseBreadcrumb.appendChild(seg); + } +} + +function doCreateSession(): void { + const tool = toolInput.value.trim(); + if (!tool) return; + createError.classList.add("hidden"); + createFormView.classList.add("hidden"); + createSpinner.classList.remove("hidden"); + sendMessage({ type: "create-session", tool, cwd: selectedCwd }); +} + +fabCreate.addEventListener("click", openCreateModal); +modalClose.addEventListener("click", closeCreateModal); +createModal.addEventListener("click", (e) => { + if (e.target === createModal) closeCreateModal(); +}); +toolInput.addEventListener("input", updateCreateButton); +btnCreateSession.addEventListener("click", doCreateSession); +btnBrowse.addEventListener("click", () => { + createFormView.classList.add("hidden"); + browseView.classList.remove("hidden"); + browseDirectory(selectedCwd); +}); +browseBack.addEventListener("click", () => { + browseView.classList.add("hidden"); + createFormView.classList.remove("hidden"); +}); +browseSelect.addEventListener("click", () => { + selectedCwd = currentBrowsePath; + dirSelected.textContent = selectedCwd; + browseView.classList.add("hidden"); + createFormView.classList.remove("hidden"); +}); // --- WebSocket Connection --- let ws: WebSocket | null = null; @@ -174,6 +323,10 @@ function connect(): void { } case "session-added": { addSession(msg.session as SessionData); + // Close create modal if it's open (session was created from dashboard) + if (!createModal.classList.contains("hidden")) { + closeCreateModal(); + } break; } case "session-removed": { @@ -206,6 +359,17 @@ function connect(): void { } break; } + case "session-creating": { + // Ack received — spinner is already showing + break; + } + case "session-create-error": { + createSpinner.classList.add("hidden"); + createFormView.classList.remove("hidden"); + createError.textContent = msg.error as string; + createError.classList.remove("hidden"); + break; + } case "operation-error": { console.warn(`Operation "${msg.operation}" failed for session ${msg.sessionId}: ${msg.error}`); break; diff --git a/packages/hub/src/dashboard/style.css b/packages/hub/src/dashboard/style.css index df86952..c854264 100644 --- a/packages/hub/src/dashboard/style.css +++ b/packages/hub/src/dashboard/style.css @@ -453,3 +453,352 @@ html, body { @keyframes spin { to { transform: rotate(360deg); } } + +/* --- FAB Button --- */ + +#fab-create { + position: fixed; + bottom: 24px; + right: 24px; + width: 56px; + height: 56px; + border-radius: 50%; + background: #e94560; + color: white; + font-size: 28px; + font-weight: 300; + border: none; + cursor: pointer; + box-shadow: 0 4px 16px rgba(233, 69, 96, 0.4); + z-index: 20; + -webkit-tap-highlight-color: transparent; + transition: transform 0.15s, box-shadow 0.15s; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +#fab-create:active { + transform: scale(0.92); +} + +/* --- Modal Overlay --- */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(10, 10, 20, 0.85); + z-index: 50; + display: flex; + align-items: flex-end; + justify-content: center; + animation: fadeIn 0.15s ease; +} + +.modal-overlay.hidden { + display: none; +} + +.modal-content { + background: #16213e; + border-radius: 16px 16px 0 0; + width: 100%; + max-width: 480px; + max-height: 85vh; + overflow-y: auto; + padding: 20px; + animation: slideUp 0.2s ease; +} + +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.modal-title { + font-size: 18px; + font-weight: 600; + color: #f0f0f0; +} + +.modal-close { + background: none; + border: none; + color: #707090; + font-size: 24px; + cursor: pointer; + padding: 4px 8px; + line-height: 1; +} + +/* --- Form Elements --- */ + +.form-label { + display: block; + font-size: 13px; + font-weight: 500; + color: #a0a0b0; + margin-bottom: 6px; + margin-top: 16px; +} + +.form-label:first-of-type { + margin-top: 0; +} + +.form-input { + width: 100%; + padding: 10px 12px; + background: #111122; + border: 1px solid #2a2a44; + border-radius: 8px; + color: #f0f0f0; + font-size: 15px; + font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; + outline: none; + transition: border-color 0.15s; +} + +.form-input:focus { + border-color: #e94560; +} + +.form-input.hidden { + display: none; +} + +/* --- Chips --- */ + +.chip-row { + display: flex; + gap: 6px; + margin-top: 8px; + flex-wrap: wrap; +} + +.chip { + padding: 5px 12px; + background: #1e1e36; + border: 1px solid #2a2a44; + border-radius: 16px; + font-size: 12px; + color: #c0c0d0; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s, border-color 0.15s; + white-space: nowrap; + font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; +} + +.chip:active, +.chip.selected { + background: rgba(233, 69, 96, 0.15); + border-color: #e94560; + color: #e94560; +} + +/* --- Directory Display --- */ + +.dir-display { + padding: 10px 12px; + background: #111122; + border: 1px solid #2a2a44; + border-radius: 8px; + font-size: 13px; + font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; + color: #c0c0d0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dir-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.dir-action-btn { + flex: 1; + padding: 8px 0; + background: #1e1e36; + border: 1px solid #2a2a44; + border-radius: 8px; + color: #a0a0b0; + font-size: 13px; + font-family: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s; +} + +.dir-action-btn:active { + background: #2a2a44; +} + +/* --- Create Button --- */ + +.btn-create { + width: 100%; + margin-top: 20px; + padding: 14px; + background: #e94560; + color: white; + border: none; + border-radius: 10px; + font-size: 16px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: opacity 0.15s; +} + +.btn-create:disabled { + opacity: 0.4; + cursor: default; +} + +.btn-create:not(:disabled):active { + opacity: 0.8; +} + +.create-error { + margin-top: 12px; + padding: 8px 12px; + background: rgba(233, 69, 96, 0.15); + border-radius: 6px; + font-size: 13px; + color: #e94560; +} + +.create-error.hidden { + display: none; +} + +/* --- Create Spinner --- */ + +.create-spinner { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 32px 0; + color: #a0a0b0; + font-size: 14px; +} + +.create-spinner.hidden { + display: none; +} + +/* --- Directory Browser --- */ + +.browse-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.browse-back { + background: none; + border: none; + color: #a0a0b0; + font-size: 14px; + font-family: inherit; + cursor: pointer; + padding: 4px 0; +} + +.browse-select { + padding: 6px 14px; + background: #e94560; + color: white; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; +} + +.browse-breadcrumb { + display: flex; + gap: 4px; + align-items: center; + margin-bottom: 12px; + font-size: 13px; + font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; + color: #808098; + flex-wrap: wrap; +} + +.breadcrumb-segment { + cursor: pointer; + color: #a0a0b0; + transition: color 0.15s; +} + +.breadcrumb-segment:hover, +.breadcrumb-segment:active { + color: #e94560; +} + +.breadcrumb-sep { + color: #505070; +} + +.browse-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 50vh; + overflow-y: auto; +} + +.browse-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: #111122; + border-radius: 6px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: background 0.1s; +} + +.browse-item:active { + background: #1e1e36; +} + +.browse-item-name { + font-size: 14px; + color: #d0d0e0; + font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; +} + +.browse-item-arrow { + color: #505070; + font-size: 14px; +} + +.browse-empty { + text-align: center; + padding: 24px; + color: #505070; + font-size: 13px; +} + +.hidden { + display: none !important; +} diff --git a/packages/hub/src/registry.ts b/packages/hub/src/registry.ts index 7468905..b6b4db8 100644 --- a/packages/hub/src/registry.ts +++ b/packages/hub/src/registry.ts @@ -183,6 +183,33 @@ export class SessionRegistry extends EventEmitter { } } + /** Get deduplicated recent working directories from current + persisted sessions. */ + getRecentDirectories(): string[] { + const dirMap = new Map(); // cwd → most recent timestamp + + // Current sessions + for (const s of this.sessions.values()) { + const existing = dirMap.get(s.cwd) ?? 0; + if (s.lastSeen > existing) { + dirMap.set(s.cwd, s.lastSeen); + } + } + + // Persisted sessions (includes ended ones) + if (this.store) { + for (const s of this.store.getAllSessions()) { + const existing = dirMap.get(s.cwd) ?? 0; + if (s.lastSeen > existing) { + dirMap.set(s.cwd, s.lastSeen); + } + } + } + + return Array.from(dirMap.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([cwd]) => cwd); + } + clear(): void { const ids = Array.from(this.sessions.keys()); this.sessions.clear(); diff --git a/packages/hub/src/server.ts b/packages/hub/src/server.ts index 55c12a3..e9a10d2 100644 --- a/packages/hub/src/server.ts +++ b/packages/hub/src/server.ts @@ -1,11 +1,13 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir, stat, realpath } from "node:fs/promises"; import { join, extname } from "node:path"; +import { homedir } from "node:os"; import { gzipSync } from "node:zlib"; import { WebSocketServer, type WebSocket } from "ws"; import { validateToken, RateLimiter } from "./auth.js"; import type { SessionRegistry } from "./registry.js"; import type { PreviewCollector } from "./preview-collector.js"; +import type { ToolHistory } from "./tool-history.js"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -27,6 +29,8 @@ export interface DashboardServerOptions { host: string; port: number; previewCollector?: PreviewCollector; + toolHistory?: ToolHistory; + onCreateSession?: (tool: string, cwd: string) => void; } /** @@ -35,7 +39,8 @@ export interface DashboardServerOptions { * All requests are authenticated with the master token. */ export function createDashboardServer(options: DashboardServerOptions) { - const { registry, masterToken, dashboardPath, host, port, previewCollector } = options; + const { registry, masterToken, dashboardPath, host, port, previewCollector, toolHistory, onCreateSession } = options; + const homeDir = homedir(); const clients = new Set(); const aliveMap = new WeakMap(); const gzipCache = new Map(); @@ -98,6 +103,60 @@ export function createDashboardServer(options: DashboardServerOptions) { return; } + // Tool history for autocomplete + if (pathname === "/api/tool-history") { + const tools = toolHistory ? toolHistory.getTools() : []; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ tools })); + return; + } + + // Recent working directories from past sessions + if (pathname === "/api/recent-dirs") { + const dirs = registry.getRecentDirectories(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ dirs })); + return; + } + + // Directory browser — list subdirectories at a path + if (pathname === "/api/browse") { + const rawPath = url.searchParams.get("path") || "~"; + const browsePath = rawPath.replace(/^~/, homeDir); + + try { + // Resolve to real path and verify it's under home directory + const resolved = await realpath(browsePath); + if (!resolved.startsWith(homeDir)) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Access denied" })); + return; + } + + const dirStat = await stat(resolved); + if (!dirStat.isDirectory()) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not a directory" })); + return; + } + + const entries = await readdir(resolved, { withFileTypes: true }); + const dirs = entries + .filter((e) => e.isDirectory() && !e.name.startsWith(".")) + .map((e) => e.name) + .sort(); + + // Return path relative to home for display + const displayPath = resolved.replace(homeDir, "~"); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ path: displayPath, resolvedPath: resolved, entries: dirs })); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Cannot read directory" })); + } + return; + } + await serveStaticFile(dashboardPath, pathname, req, res); }); @@ -235,6 +294,40 @@ export function createDashboardServer(options: DashboardServerOptions) { break; } + case "create-session": { + if (!onCreateSession) { + ws.send(JSON.stringify({ type: "session-create-error", error: "Session creation not available" })); + break; + } + + const tool = (msg.tool || "").trim(); + const rawCwd = (msg.cwd || "").trim(); + + if (!tool) { + ws.send(JSON.stringify({ type: "session-create-error", error: "Tool name is required" })); + break; + } + + // Resolve ~ to home directory + const cwd = rawCwd ? rawCwd.replace(/^~/, homeDir) : homeDir; + + try { + // Verify directory exists + const resolved = await realpath(cwd); + const dirStat = await stat(resolved); + if (!dirStat.isDirectory()) { + ws.send(JSON.stringify({ type: "session-create-error", error: "Not a directory" })); + break; + } + + ws.send(JSON.stringify({ type: "session-creating", tool, cwd: rawCwd || "~" })); + onCreateSession(tool, resolved); + } catch (err) { + ws.send(JSON.stringify({ type: "session-create-error", error: (err as Error).message })); + } + break; + } + case "get-metadata": { const session = registry.getById(msg.sessionId); if (!session) { diff --git a/packages/hub/src/session-store.ts b/packages/hub/src/session-store.ts index f324be5..102a05c 100644 --- a/packages/hub/src/session-store.ts +++ b/packages/hub/src/session-store.ts @@ -68,6 +68,11 @@ export class SessionStore { }, 500); } + /** Get all persisted sessions (including ended). */ + getAllSessions(): PersistedSession[] { + return this.sessions; + } + /** Flush any pending save immediately. */ flush(): void { if (this.saveTimer) { diff --git a/packages/hub/src/tool-history.ts b/packages/hub/src/tool-history.ts new file mode 100644 index 0000000..11c57eb --- /dev/null +++ b/packages/hub/src/tool-history.ts @@ -0,0 +1,75 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; + +interface ToolEntry { + name: string; + lastUsed: number; +} + +interface ToolHistoryFile { + tools: ToolEntry[]; +} + +const MAX_ENTRIES = 20; + +function getHistoryPath(): string { + const dir = process.env.ITWILLSYNC_CONFIG_DIR || join(homedir(), ".itwillsync"); + return join(dir, "tool-history.json"); +} + +export class ToolHistory { + private tools: ToolEntry[] = []; + + constructor() { + this.load(); + } + + /** Get tool names sorted by most recently used. */ + getTools(): string[] { + return this.tools + .sort((a, b) => b.lastUsed - a.lastUsed) + .map((t) => t.name); + } + + /** Record a tool usage (add or update lastUsed). */ + recordUsage(toolName: string): void { + const existing = this.tools.find((t) => t.name === toolName); + if (existing) { + existing.lastUsed = Date.now(); + } else { + this.tools.push({ name: toolName, lastUsed: Date.now() }); + } + + // Prune to max entries (remove least recently used) + if (this.tools.length > MAX_ENTRIES) { + this.tools.sort((a, b) => b.lastUsed - a.lastUsed); + this.tools = this.tools.slice(0, MAX_ENTRIES); + } + + this.save(); + } + + private load(): void { + const path = getHistoryPath(); + if (!existsSync(path)) return; + + try { + const raw = readFileSync(path, "utf-8"); + const data: ToolHistoryFile = JSON.parse(raw); + if (Array.isArray(data.tools)) { + this.tools = data.tools; + } + } catch { + // Ignore corrupt file + } + } + + private save(): void { + const path = getHistoryPath(); + mkdirSync(dirname(path), { recursive: true }); + + const data: ToolHistoryFile = { tools: this.tools }; + writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8"); + } +}