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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/cli/src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface CliOptions {
hubInfo: boolean;
hubStop: boolean;
hubStatus: boolean;
headless: boolean;
}

export function parseArgs(argv: string[]): CliOptions {
Expand All @@ -36,6 +37,7 @@ export function parseArgs(argv: string[]): CliOptions {
hubInfo: false,
hubStop: false,
hubStatus: false,
headless: false,
};

const args = argv.slice(2);
Expand Down Expand Up @@ -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);
Expand Down
136 changes: 67 additions & 69 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ async function main(): Promise<void> {
}

// 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();
}

Expand All @@ -196,6 +196,8 @@ async function main(): Promise<void> {
process.exit(1);
}

const headless = options.headless;

// Load config
const config = loadConfig();

Expand Down Expand Up @@ -276,89 +278,85 @@ async function main(): Promise<void> {
}
}

// 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<void> {
// 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
Expand Down
41 changes: 40 additions & 1 deletion packages/hub/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<void> {
const hubDir = getHubDir();
mkdirSync(hubDir, { recursive: true });
Expand All @@ -56,6 +82,9 @@ async function main(): Promise<void> {
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)) {
Expand All @@ -72,9 +101,11 @@ async function main(): Promise<void> {
} 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({
Expand All @@ -93,6 +124,14 @@ async function main(): Promise<void> {
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
Expand Down
45 changes: 45 additions & 0 deletions packages/hub/src/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,51 @@
</div>
</main>

<!-- FAB: Create Session -->
<button id="fab-create" aria-label="New session">+</button>

<!-- Create Session Modal -->
<div id="create-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<span class="modal-title">New Session</span>
<button class="modal-close" id="modal-close">&times;</button>
</div>

<!-- View: Create Form -->
<div id="create-form-view">
<label class="form-label">Tool</label>
<input type="text" id="tool-input" class="form-input" placeholder="e.g. claude, aider, bash" autocomplete="off" autocapitalize="off" />
<div id="tool-chips" class="chip-row"></div>

<label class="form-label">Working Directory</label>
<div id="dir-selected" class="dir-display">~</div>
<div class="dir-actions">
<button class="dir-action-btn" id="btn-browse">Browse...</button>
</div>

<div id="create-error" class="create-error hidden"></div>
<button id="btn-create-session" class="btn-create" disabled>Create Session</button>
</div>

<!-- View: Directory Browser -->
<div id="browse-view" class="hidden">
<div class="browse-header">
<button class="browse-back" id="browse-back">&larr; Back</button>
<button class="browse-select" id="browse-select">Select this folder</button>
</div>
<div id="browse-breadcrumb" class="browse-breadcrumb"></div>
<div id="browse-list" class="browse-list"></div>
</div>

<!-- Loading spinner shown during session creation -->
<div id="create-spinner" class="create-spinner hidden">
<div class="spinner"></div>
<span>Creating session...</span>
</div>
</div>
</div>

<script type="module" src="./main.ts"></script>
</body>
</html>
Loading