diff --git a/README.md b/README.md index 688c19a0..49ea7a47 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ dreb is a hackable, open-source terminal coding agent and agent runtime for people who want to own their AI development workflow. It gives you a practical coding assistant today — tools, sessions, memory, model switching, subagents, and a polished TUI — while keeping the core flexible enough to reshape with skills, extensions, packages, custom providers, and alternate frontends. -Use dreb if you want a coding agent that can run against direct APIs, coding subscriptions, proxies, cloud providers, local models, or your own provider code; if you want workflows such as issue-to-merge automation and multi-agent review to be inspectable and replaceable; or if you want an agent runtime you can embed in a CLI, an RPC process, an SDK integration, or a Telegram bot. +Use dreb if you want a coding agent that can run against direct APIs, coding subscriptions, proxies, cloud providers, local models, or your own provider code; if you want workflows such as issue-to-merge automation and multi-agent review to be inspectable and replaceable; or if you want an agent runtime you can embed in a CLI, a browser dashboard, an RPC process, an SDK integration, or a Telegram bot. ## Why choose dreb? @@ -16,7 +16,7 @@ Use dreb if you want a coding agent that can run against direct APIs, coding sub - **Codebase and web understanding.** dreb includes file, grep/find/ls, bash, web search/fetch, task tracking, skill invocation, and semantic `search`. Semantic search uses AST-aware chunks, embeddings, POEM ranking, memory indexing, and also ships as [`@dreb/semantic-search`](packages/semantic-search/) with an MCP server for other harnesses. The semantic search package requires Node.js 22+. - **Detailed usage tracking and performance logging.** dreb records per-session token usage, cost, context-window utilization, and rolling tokens-per-second performance in a local JSONL log (`~/.dreb/agent/performance.jsonl`). This data stays on your machine and can be queried via the TUI footer, Telegram `/stats`, or RPC for personal analytics and model comparison. - **Safety and reliability primitives.** Recent dreb-specific hardening includes secret output scrubbing, sensitive-file guards, destructive-command guards, resource diagnostics surfaced in-session, warning propagation, rate-limited web search across parallel subagents, and JSON/RPC protocol hardening. Dropped provider streams are retried (discarding the partial), and responses truncated at the model's output-token limit are retried with a larger token budget — failing loudly rather than returning a silently empty or truncated result. -- **Multiple interfaces.** Run dreb as an interactive TUI, print/headless CLI, JSON event stream, RPC process, embedded [SDK](packages/coding-agent/docs/sdk.md), or [Telegram bot](packages/telegram/). +- **Multiple interfaces.** Run dreb as an interactive TUI, first-party [web dashboard](packages/coding-agent/docs/dashboard.md), print/headless CLI, JSON event stream, RPC process, embedded [SDK](packages/coding-agent/docs/sdk.md), or [Telegram bot](packages/telegram/). ## Quick Start @@ -108,6 +108,7 @@ Project context files (`AGENTS.md`/`CLAUDE.md`) are loaded at startup by walking The same agent runtime powers multiple surfaces: - **Interactive TUI** — the default terminal coding workspace. +- **Dashboard** — `@dreb/dashboard` serves a first-party browser UI for projects, sessions, chat, files, subagents, and settings with localhost/Tailscale access controls. - **Print/headless CLI** — `dreb -p` for one-shot prompts, including piped stdin. - **JSON mode** — event stream for scripts and automation. - **RPC mode** — strict [JSONL stdin/stdout protocol](packages/coding-agent/docs/rpc.md) for non-Node clients and custom UIs. @@ -142,6 +143,7 @@ See [FORK.md](FORK.md) for details. | [`@dreb/agent-core`](packages/agent/) | General-purpose agent runtime: tool loop, state, streaming, hooks, steering/follow-up queue semantics | | [`@dreb/tui`](packages/tui/) | Terminal UI library with differential rendering, markdown/syntax rendering, editor/input components, overlays, keybindings | | [`@dreb/semantic-search`](packages/semantic-search/) | Semantic codebase search engine with AST chunking, embeddings, POEM ranking, library API, and MCP server | +| [`@dreb/dashboard`](packages/dashboard/) | First-party browser dashboard for dreb over the native RPC protocol, with localhost/Tailscale access controls | | [`@dreb/telegram`](packages/telegram/) | Telegram bot frontend for dreb over the native RPC protocol | ## License diff --git a/biome.json b/biome.json index 147d081a..35bba79a 100644 --- a/biome.json +++ b/biome.json @@ -28,8 +28,6 @@ "packages/*/src/**/*.ts", "packages/*/test/**/*.ts", "packages/coding-agent/examples/**/*.ts", - "packages/web-ui/src/**/*.ts", - "packages/web-ui/example/**/*.ts", "!**/node_modules/**/*", "!**/test-sessions.ts", "!**/models.generated.ts", diff --git a/package-lock.json b/package-lock.json index 8eef032b..7bb9b6d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1004,6 +1004,10 @@ "resolved": "packages/coding-agent", "link": true }, + "node_modules/@dreb/dashboard": { + "resolved": "packages/dashboard", + "link": true + }, "node_modules/@dreb/semantic-search": { "resolved": "packages/semantic-search", "link": true @@ -9125,6 +9129,40 @@ "dev": true, "license": "MIT" }, + "packages/dashboard": { + "name": "@dreb/dashboard", + "version": "2.34.4", + "license": "MIT", + "dependencies": { + "@dreb/agent-core": "*", + "@dreb/coding-agent": "*" + }, + "bin": { + "dreb-dashboard": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "shx": "^0.4.0", + "typescript": "^5.9.2", + "vitest": "^4.1.8" + }, + "engines": { + "node": "22.x" + } + }, + "packages/dashboard/node_modules/@types/node": { + "version": "24.12.0", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "packages/dashboard/node_modules/undici-types": { + "version": "7.16.0", + "dev": true, + "license": "MIT" + }, "packages/semantic-search": { "name": "@dreb/semantic-search", "version": "2.34.4", diff --git a/package.json b/package.json index 4affdcb0..6fa842dc 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "scripts": { "clean": "npm run clean --workspaces", "sync-version": "bash scripts/sync-version.sh", - "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../semantic-search && npm run build && cd ../coding-agent && npm run build && cd ../telegram && npm run build", - "dev": "concurrently --names \"ai,agent,coding-agent,tui\" --prefix-colors \"cyan,yellow,red,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/tui && npm run dev\"", + "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../semantic-search && npm run build && cd ../coding-agent && npm run build && cd ../telegram && npm run build && cd ../dashboard && npm run build", + "dev": "concurrently --names \"ai,agent,coding-agent,tui,dashboard\" --prefix-colors \"cyan,yellow,red,magenta,green\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/tui && npm run dev\" \"cd packages/dashboard && npm run dev\"", "check": "npm run verify-git-hooks && npm run verify-engines && biome check --write --error-on-warnings . && tsgo --noEmit", "verify-git-hooks": "node scripts/verify-git-hooks.js", "verify-workspace-links": "node scripts/verify-workspace-links.js", diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 211ab13b..87bfb718 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -81,7 +81,7 @@ Or use a custom provider (corporate proxy, Bedrock, etc.) — see [Custom provid Then just talk to dreb. All 11 built-in tools are enabled by default: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, and `wait`. Use `--tools` to restrict to a subset (e.g., `--tools read,grep,find,ls` for read-only). Three additional tools — `search`, `skill`, and `tasks_update` — are always active regardless of `--tools`. `suggest_next` is active by default but excluded when `--tools` is specified. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [packages](#packages). -**Also available:** [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). +**Also available:** [`@dreb/dashboard`](docs/dashboard.md) — run dreb in a browser with project/session browsing, chat, files, subagents, settings, and localhost/Tailscale access controls (`npm install -g @dreb/dashboard`); [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). **Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md) @@ -549,6 +549,19 @@ await session.prompt("What files are in the current directory?"); See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/). +### Dashboard + +For a browser interface, use the first-party dashboard package: + +```bash +npm install -g @dreb/dashboard +dreb-dashboard +``` + +The dashboard serves a local web UI for project/file browsing, session history, chat, live subagents, and runtime settings. Localhost access works without pairing; non-localhost access is opt-in and should go through Tailscale allowlisting plus PIN pairing. + +See [docs/dashboard.md](docs/dashboard.md) for launch and security details. + ### RPC Mode For non-Node.js integrations, use RPC mode over stdin/stdout: diff --git a/packages/coding-agent/docs/dashboard.md b/packages/coding-agent/docs/dashboard.md new file mode 100644 index 00000000..2ccc4ebc --- /dev/null +++ b/packages/coding-agent/docs/dashboard.md @@ -0,0 +1,89 @@ +# Dashboard + +`@dreb/dashboard` is dreb's first-party browser dashboard. It provides a local web UI for browsing projects and files, opening sessions, chatting with dreb agents, watching live events/subagents, and adjusting runtime settings. + +The dashboard is a separate workspace package and talks to `@dreb/coding-agent` through the native RPC client. Each active project/session gets its own runtime entry so the dashboard does not silently switch a running agent across unrelated project contexts. + +## Install and launch + +From a source checkout: + +```bash +npm install +npm run build +npm link -w packages/dashboard +dreb-dashboard +``` + +By default the server binds to `127.0.0.1:3762`: + +```bash +dreb-dashboard --host 127.0.0.1 --port 3762 +``` + +Open the printed local URL in a browser on the host machine. + +## Localhost mode + +Same-machine loopback access is allowed without pairing friction. This is the default and safest mode: + +```bash +dreb-dashboard +``` + +Do not bind to `0.0.0.0` unless remote mode is intentionally enabled and configured. + +## Tailscale remote mode + +Remote access is opt-in. Use Tailscale identities/devices and PIN pairing: + +```bash +dreb-dashboard \ + --host 0.0.0.0 \ + --remote true \ + --allowed-identity drew@example.com \ + --allowed-device drews-phone +``` + +When remote mode starts, the server prints a short-lived pairing PIN. Open the dashboard from the allowed Tailnet device and enter the PIN. The paired browser receives an HTTP-only pairing cookie and future API calls are authorized until the pairing expires. + +Remote requests fail closed when: + +- remote mode is disabled +- the request is not from a verified Tailscale peer +- no Tailscale identity or device allowlist is configured +- the peer does not match the allowlist +- the browser has not completed PIN pairing + +The production resolver uses `tailscale status --json` and matches the socket remote address against Tailnet peer addresses. The dashboard does not trust caller-supplied identity headers. + +## Features + +- Browse configured roots (`cwd` and home by default) +- Browse directories with path traversal and symlink escape checks +- Upload files into the selected folder with size limits +- Download files from allowed roots with size limits +- List all sessions and sessions for the selected project +- Open a new runtime or resume a project session +- Send prompts, steering messages, follow-ups, and aborts +- Load current runtime state and historical messages +- Stream live agent events with SSE +- Show tasks, suggest-next commands, event log entries, and background subagent lifecycle events +- Change model, thinking level, steering mode, and follow-up mode through RPC-backed settings controls + +## Security notes + +The dashboard can control agents and move files on the host. Treat it as a powerful local control plane: + +- Keep the default localhost binding unless remote access is needed. +- Use Tailscale for non-localhost access, including devices on the same LAN. +- Configure specific allowed identities/devices; empty allowlists deny remote clients. +- Pair each remote browser/device with the short-lived PIN shown on the host. +- Avoid exposing the dashboard to a public network or unauthenticated reverse proxy. +- File APIs are intentionally separate from model tools; human upload/download is authorized at the dashboard boundary. + +## MVP limitations + +- The embedded terminal pane is not part of the MVP. +- Full custom TUI component rendering is not reused directly; the dashboard renders the core RPC event categories and supported extension UI events. +- Remote TLS/origin hardening depends on the deployment path. Prefer Tailscale-local access rather than public exposure. diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index ebc432d4..e44ab579 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -25,6 +25,10 @@ "types": "./dist/modes/rpc/index.d.ts", "import": "./dist/modes/rpc/index.js" }, + "./session-manager": { + "types": "./dist/core/session-manager.d.ts", + "import": "./dist/core/session-manager.js" + }, "./buddy": { "types": "./dist/core/buddy/index.d.ts", "import": "./dist/core/buddy/index.js" diff --git a/packages/dashboard/CHANGELOG.md b/packages/dashboard/CHANGELOG.md new file mode 100644 index 00000000..99b4d34d --- /dev/null +++ b/packages/dashboard/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 2.34.4 + +- Initial dashboard backend scaffold. diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md new file mode 100644 index 00000000..38272380 --- /dev/null +++ b/packages/dashboard/README.md @@ -0,0 +1,42 @@ +# @dreb/dashboard + +First-party web dashboard for dreb. It serves a minimalist responsive browser UI for project/file browsing, session history, chat controls, live events/subagents, and runtime settings. + +## Launch + +```bash +npm run build +npm link -w packages/dashboard +dreb-dashboard +``` + +Default: `http://127.0.0.1:3762`. + +Options: + +```bash +dreb-dashboard [--host 127.0.0.1] [--port 3762] [--cwd /project] [--agentDir ~/.dreb/agent] +``` + +Remote access is opt-in and should go through Tailscale: + +```bash +dreb-dashboard \ + --host 0.0.0.0 \ + --remote true \ + --allowed-identity drew@example.com \ + --allowed-device drews-phone +``` + +The server prints a short-lived PIN for remote browser pairing. Localhost access does not require pairing. + +## Security model + +- Local loopback clients are allowed by default. +- Non-loopback clients are rejected unless remote mode is enabled. +- Remote mode requires verified Tailscale peer identity/device allowlisting. +- Empty remote allowlists deny access. +- Remote browsers must complete PIN pairing before API control is granted. +- Pairings are stored in `dashboard-pairings.json` under the dreb agent directory. + +See [`packages/coding-agent/docs/dashboard.md`](../coding-agent/docs/dashboard.md) for full usage and security notes. diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json new file mode 100644 index 00000000..be1d293b --- /dev/null +++ b/packages/dashboard/package.json @@ -0,0 +1,52 @@ +{ + "name": "@dreb/dashboard", + "version": "2.34.4", + "description": "First-party web dashboard for dreb coding agent", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "bin": { + "dreb-dashboard": "./dist/index.js" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "clean": "shx rm -rf dist", + "build": "tsgo -p tsconfig.build.json && tsgo -p tsconfig.client.json && shx chmod +x dist/index.js && npm run copy-assets", + "copy-assets": "shx mkdir -p dist/static && shx cp src/static/* dist/static/", + "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", + "test": "vitest --run", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "@dreb/agent-core": "*", + "@dreb/coding-agent": "*" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "shx": "^0.4.0", + "typescript": "^5.9.2", + "vitest": "^4.1.8" + }, + "engines": { + "node": "22.x" + }, + "repository": { + "type": "git", + "url": "https://github.com/aebrer/dreb.git", + "directory": "packages/dashboard" + } +} diff --git a/packages/dashboard/src/auth.ts b/packages/dashboard/src/auth.ts new file mode 100644 index 00000000..4467f8f9 --- /dev/null +++ b/packages/dashboard/src/auth.ts @@ -0,0 +1,292 @@ +import { execFile } from "node:child_process"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import type { IncomingMessage } from "node:http"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface TailscaleIdentity { + id: string; + loginName?: string; + user?: string; + deviceName?: string; + dnsName?: string; + addresses: string[]; +} + +export interface TailscaleIdentityResolver { + resolve(remoteAddress: string): Promise; +} + +export interface DashboardAuthOptions { + remoteEnabled?: boolean; + agentDir?: string; + pinTtlMs?: number; + pairingTtlMs?: number; + allowedIdentities?: string[]; + allowedDevices?: string[]; + resolver?: TailscaleIdentityResolver; +} + +interface StoredPairing { + tokenHash: string; + identityId: string; + deviceId?: string; + createdAt: string; + expiresAt: string; +} + +interface PairingFile { + version: 1; + pairings: StoredPairing[]; +} + +export interface AuthResult { + allowed: boolean; + status: number; + reason?: string; + loopback: boolean; + identity?: TailscaleIdentity; +} + +interface PinChallenge { + hash: string; + expiresAt: number; +} + +export class DashboardAuth { + private readonly remoteEnabled: boolean; + private readonly agentDir: string; + private readonly pinTtlMs: number; + private readonly pairingTtlMs: number; + private readonly allowedIdentities: Set; + private readonly allowedDevices: Set; + private readonly resolver: TailscaleIdentityResolver; + private currentPin: PinChallenge | null = null; + + constructor(options: DashboardAuthOptions = {}) { + this.remoteEnabled = options.remoteEnabled ?? false; + this.agentDir = options.agentDir ?? join(homedir(), ".dreb", "agent"); + this.pinTtlMs = options.pinTtlMs ?? 5 * 60 * 1000; + this.pairingTtlMs = options.pairingTtlMs ?? 30 * 24 * 60 * 60 * 1000; + this.allowedIdentities = new Set(options.allowedIdentities ?? []); + this.allowedDevices = new Set(options.allowedDevices ?? []); + this.resolver = options.resolver ?? new TailscaleStatusResolver(); + } + + get pairingsPath(): string { + return join(this.agentDir, "dashboard-pairings.json"); + } + + generatePin(): { pin: string; expiresAt: string } { + const pin = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0"); + const expiresAt = Date.now() + this.pinTtlMs; + this.currentPin = { hash: hashSecret(pin), expiresAt }; + return { pin, expiresAt: new Date(expiresAt).toISOString() }; + } + + async authenticate(req: IncomingMessage): Promise { + const remoteAddress = getRemoteAddress(req); + if (isLoopbackAddress(remoteAddress)) { + return { allowed: true, status: 200, loopback: true }; + } + + if (!this.remoteEnabled) { + return { allowed: false, status: 403, reason: "Remote dashboard access is disabled", loopback: false }; + } + + const identity = await this.resolver.resolve(remoteAddress); + if (!identity || !this.isAllowedIdentity(identity)) { + return { + allowed: false, + status: 403, + reason: "Remote client is not an allowed Tailscale identity", + loopback: false, + }; + } + + const token = getBearerToken(req) ?? getCookie(req, "dreb_dashboard_pairing"); + if (!token || !(await this.isPaired(identity, token))) { + return { allowed: false, status: 401, reason: "Remote client is not paired", loopback: false, identity }; + } + + return { allowed: true, status: 200, loopback: false, identity }; + } + + async pair( + req: IncomingMessage, + pin: string, + ): Promise<{ token: string; identity: TailscaleIdentity; expiresAt: string }> { + const remoteAddress = getRemoteAddress(req); + if (isLoopbackAddress(remoteAddress)) { + throw Object.assign(new Error("Loopback clients do not require pairing"), { status: 400 }); + } + if (!this.remoteEnabled) { + throw Object.assign(new Error("Remote dashboard access is disabled"), { status: 403 }); + } + const identity = await this.resolver.resolve(remoteAddress); + if (!identity || !this.isAllowedIdentity(identity)) { + throw Object.assign(new Error("Remote client is not an allowed Tailscale identity"), { status: 403 }); + } + if (!this.verifyPin(pin)) { + throw Object.assign(new Error("Invalid or expired pairing PIN"), { status: 401 }); + } + + const token = randomBytes(32).toString("base64url"); + const now = Date.now(); + const expiresAt = new Date(now + this.pairingTtlMs).toISOString(); + const file = await this.readPairings(); + file.pairings = file.pairings.filter((pairing) => new Date(pairing.expiresAt).getTime() > now); + file.pairings.push({ + tokenHash: hashSecret(token), + identityId: identity.id, + deviceId: identity.deviceName ?? identity.dnsName, + createdAt: new Date(now).toISOString(), + expiresAt, + }); + await this.writePairings(file); + this.currentPin = null; + return { token, identity, expiresAt }; + } + + private verifyPin(pin: string): boolean { + if (!this.currentPin || Date.now() > this.currentPin.expiresAt) return false; + return safeEqual(hashSecret(pin), this.currentPin.hash); + } + + private isAllowedIdentity(identity: TailscaleIdentity): boolean { + if (this.allowedIdentities.size === 0 && this.allowedDevices.size === 0) return false; + + const identityCandidates = [identity.id, identity.loginName, identity.user].filter(Boolean) as string[]; + const deviceCandidates = [identity.deviceName, identity.dnsName].filter(Boolean) as string[]; + const identityAllowed = + this.allowedIdentities.size === 0 || identityCandidates.some((value) => this.allowedIdentities.has(value)); + const deviceAllowed = + this.allowedDevices.size === 0 || deviceCandidates.some((value) => this.allowedDevices.has(value)); + return identityAllowed && deviceAllowed; + } + + private async isPaired(identity: TailscaleIdentity, token: string): Promise { + const tokenHash = hashSecret(token); + const now = Date.now(); + const file = await this.readPairings(); + let changed = false; + const live = file.pairings.filter((pairing) => { + const keep = new Date(pairing.expiresAt).getTime() > now; + if (!keep) changed = true; + return keep; + }); + if (changed) { + await this.writePairings({ version: 1, pairings: live }); + } + return live.some((pairing) => pairing.identityId === identity.id && safeEqual(pairing.tokenHash, tokenHash)); + } + + private async readPairings(): Promise { + try { + const parsed = JSON.parse(await readFile(this.pairingsPath, "utf8")) as PairingFile; + if (parsed.version === 1 && Array.isArray(parsed.pairings)) return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + return { version: 1, pairings: [] }; + } + + private async writePairings(file: PairingFile): Promise { + await mkdir(this.agentDir, { recursive: true }); + await writeFile(this.pairingsPath, `${JSON.stringify(file, null, "\t")}\n`, { mode: 0o600 }); + } +} + +export class TailscaleStatusResolver implements TailscaleIdentityResolver { + async resolve(remoteAddress: string): Promise { + const address = normalizeAddress(remoteAddress); + if (!address) return null; + try { + const { stdout } = await execFileAsync("tailscale", ["status", "--json"], { + timeout: 3000, + maxBuffer: 1024 * 1024, + }); + const status = JSON.parse(stdout) as TailscaleStatus; + const identities: TailscaleIdentity[] = []; + if (status.Self) identities.push(identityFromNode(status.Self, "self")); + for (const [id, peer] of Object.entries(status.Peer ?? {})) { + identities.push(identityFromNode(peer, id)); + } + return identities.find((identity) => identity.addresses.map(normalizeAddress).includes(address)) ?? null; + } catch { + return null; + } + } +} + +interface TailscaleNode { + ID?: string; + PublicKey?: string; + HostName?: string; + DNSName?: string; + TailscaleIPs?: string[]; + User?: string; + LoginName?: string; +} + +interface TailscaleStatus { + Self?: TailscaleNode; + Peer?: Record; +} + +function identityFromNode(node: TailscaleNode, fallbackId: string): TailscaleIdentity { + return { + id: node.ID ?? node.PublicKey ?? fallbackId, + loginName: node.LoginName, + user: node.User, + deviceName: node.HostName, + dnsName: node.DNSName, + addresses: node.TailscaleIPs ?? [], + }; +} + +function getRemoteAddress(req: IncomingMessage): string { + return req.socket.remoteAddress ?? ""; +} + +export function isLoopbackAddress(address: string): boolean { + const normalized = normalizeAddress(address); + return normalized === "127.0.0.1" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1"; +} + +function normalizeAddress(address: string): string { + let value = address.trim().toLowerCase(); + if (value.startsWith("::ffff:")) value = value.slice("::ffff:".length); + return value; +} + +function getBearerToken(req: IncomingMessage): string | null { + const header = req.headers.authorization; + if (!header) return null; + const match = /^bearer\s+(.+)$/i.exec(header); + return match?.[1] ?? null; +} + +function getCookie(req: IncomingMessage, name: string): string | null { + const cookie = req.headers.cookie; + if (!cookie) return null; + for (const part of cookie.split(";")) { + const [key, ...rest] = part.trim().split("="); + if (key === name) return decodeURIComponent(rest.join("=")); + } + return null; +} + +function hashSecret(secret: string): string { + return createHash("sha256").update(secret).digest("base64url"); +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} diff --git a/packages/dashboard/src/client/app.ts b/packages/dashboard/src/client/app.ts new file mode 100644 index 00000000..2a97c75c --- /dev/null +++ b/packages/dashboard/src/client/app.ts @@ -0,0 +1,613 @@ +import { + applyDashboardEvent, + clearSuggestion, + createInitialDashboardState, + type DashboardClientState, + type DashboardMessage, + type DashboardRuntimeState, + hydrateMessages, + hydrateRuntimeState, + type JsonRecord, +} from "./state.js"; + +interface FileRoot { + id: string; + label: string; + path: string; +} + +interface DirectoryEntry { + name: string; + path: string; + type: "directory" | "file" | "symlink" | "other"; + size: number; + modified: string; +} + +interface BrowseResult { + root: FileRoot; + path: string; + entries: DirectoryEntry[]; +} + +interface SessionInfo { + path: string; + id: string; + cwd: string; + name?: string; + created: string; + modified: string; + messageCount: number; + firstMessage?: string; +} + +interface RuntimeContext { + id: string; + cwd: string; + sessionPath?: string; +} + +interface ModelInfo { + provider?: string; + id?: string; + name?: string; +} + +const authPanel = byId("auth-panel"); +const appShell = byId("app-shell"); +const statusLine = byId("status-line"); +const runtimeBadge = byId("runtime-badge"); +const rootSelect = byId("root-select"); +const pathInput = byId("path-input"); +const cwdInput = byId("cwd-input"); +const entriesList = byId("entries-list"); +const sessionSelect = byId("session-select"); +const allSessionsList = byId("all-sessions-list"); +const projectSessionsList = byId("project-sessions-list"); +const transcript = byId("transcript"); +const eventLog = byId("event-log"); +const taskList = byId("task-list"); +const suggestions = byId("suggestions"); +const subagentList = byId("subagent-list"); +const messageInput = byId("message-input"); +const commandSelect = byId("command-select"); +const modelSelect = byId("model-select"); +const thinkingSelect = byId("thinking-select"); +const steeringModeSelect = byId("steering-mode-select"); +const followUpModeSelect = byId("follow-up-mode-select"); + +let roots: FileRoot[] = []; +let currentBrowse: BrowseResult | undefined; +let allSessions: SessionInfo[] = []; +let projectSessions: SessionInfo[] = []; +let runtime: RuntimeContext | undefined; +let eventSource: EventSource | undefined; +let dashboardState: DashboardClientState = createInitialDashboardState(); + +void initialize(); + +async function initialize(): Promise { + bindUi(); + showStatus("Checking authentication…"); + try { + await fetchJson("/api/auth/status"); + showAuthenticated(); + await loadWorkspace(); + } catch (error) { + showAuth(errorMessage(error)); + } +} + +function bindUi(): void { + byId("pair-form").addEventListener("submit", (event) => { + event.preventDefault(); + void pair(); + }); + byId("browse-form").addEventListener("submit", (event) => { + event.preventDefault(); + void browseSelected(pathInput.value || "."); + }); + byId("up-button").addEventListener("click", () => void browseSelected(parentPath(currentBrowse?.path ?? "."))); + byId("use-project-button").addEventListener("click", () => void useCurrentFolderAsProject()); + rootSelect.addEventListener("change", () => void selectRoot(rootSelect.value)); + byId("upload-form").addEventListener("submit", (event) => { + event.preventDefault(); + void uploadFile(); + }); + byId("refresh-sessions-button").addEventListener("click", () => void loadSessions()); + byId("new-runtime-button").addEventListener("click", () => void openRuntime(false)); + byId("open-session-button").addEventListener("click", () => void openRuntime(true)); + byId("refresh-runtime-button").addEventListener("click", () => void refreshRuntime()); + byId("message-form").addEventListener("submit", (event) => { + event.preventDefault(); + void sendMessage(); + }); + byId("abort-button").addEventListener("click", () => void abortRuntime()); + byId("apply-model-button").addEventListener("click", () => void applyModel()); + byId("apply-settings-button").addEventListener("click", () => void applyModes()); + + for (const button of document.querySelectorAll("[data-tab]")) { + button.addEventListener("click", () => activateTab(button.dataset.tab ?? "chat")); + } +} + +async function pair(): Promise { + const pin = byId("pin-input").value.trim(); + try { + await fetchJson("/api/auth/pair", { method: "POST", body: JSON.stringify({ pin }) }); + showAuthenticated(); + await loadWorkspace(); + } catch (error) { + showAuth(errorMessage(error)); + } +} + +function showAuth(message: string): void { + authPanel.hidden = false; + appShell.hidden = true; + showStatus(message || "Pair this browser with the local dashboard PIN."); +} + +function showAuthenticated(): void { + authPanel.hidden = true; + appShell.hidden = false; + showStatus("Connected"); +} + +async function loadWorkspace(): Promise { + await loadRoots(); + await loadSessions(); +} + +async function loadRoots(): Promise { + const response = await fetchJson<{ roots: FileRoot[] }>("/api/roots"); + roots = response.roots; + rootSelect.replaceChildren(...roots.map((root) => option(root.id, `${root.label} — ${root.path}`))); + if (roots[0]) await selectRoot(roots[0].id); +} + +async function selectRoot(rootId: string): Promise { + const root = roots.find((candidate) => candidate.id === rootId); + if (!root) return; + rootSelect.value = root.id; + cwdInput.value = root.path; + await browseSelected("."); + await loadSessions(); +} + +async function browseSelected(path: string): Promise { + if (!rootSelect.value) return; + const params = new URLSearchParams({ root: rootSelect.value, path }); + currentBrowse = await fetchJson(`/api/files/browse?${params}`); + pathInput.value = currentBrowse.path; + renderFiles(); +} + +function renderFiles(): void { + entriesList.replaceChildren(); + if (!currentBrowse) return; + for (const entry of currentBrowse.entries) { + const row = document.createElement("li"); + row.className = `file-row is-${entry.type}`; + const open = document.createElement("button"); + open.type = "button"; + open.className = "link-button file-name"; + open.textContent = `${entry.type === "directory" ? "▸" : "•"} ${entry.name}`; + open.addEventListener("click", () => { + if (entry.type === "directory") void browseSelected(entry.path); + }); + row.append(open, meta(`${entry.type} · ${formatBytes(entry.size)} · ${formatDate(entry.modified)}`)); + if (entry.type === "file") { + const download = document.createElement("a"); + download.className = "small-button"; + download.href = `/api/files/download?${new URLSearchParams({ root: rootSelect.value, path: entry.path })}`; + download.textContent = "download"; + row.append(download); + } + entriesList.append(row); + } +} + +async function useCurrentFolderAsProject(): Promise { + if (!currentBrowse) return; + cwdInput.value = projectPath(currentBrowse.root.path, currentBrowse.path); + await loadSessions(); + activateTab("runtime"); + showStatus(`Selected project ${cwdInput.value}`); +} + +async function uploadFile(): Promise { + const fileInput = byId("upload-input"); + const file = fileInput.files?.[0]; + if (!file || !currentBrowse) return; + const params = new URLSearchParams({ root: rootSelect.value, path: currentBrowse.path, name: file.name }); + await fetchJson(`/api/files/upload?${params}`, { method: "POST", body: await file.arrayBuffer() }); + fileInput.value = ""; + await browseSelected(currentBrowse.path); + showStatus(`Uploaded ${file.name}`); +} + +async function loadSessions(): Promise { + const all = fetchJson<{ sessions: SessionInfo[] }>("/api/sessions"); + const cwd = cwdInput.value || roots.find((root) => root.id === rootSelect.value)?.path || "."; + const project = fetchJson<{ sessions: SessionInfo[] }>(`/api/sessions/project?${new URLSearchParams({ cwd })}`); + const [allResult, projectResult] = await Promise.all([all, project]); + allSessions = allResult.sessions; + projectSessions = projectResult.sessions; + renderSessions(); +} + +function renderSessions(): void { + sessionSelect.replaceChildren(option("", "New session")); + for (const session of projectSessions) { + sessionSelect.append(option(session.path, sessionLabel(session))); + } + allSessionsList.replaceChildren(...allSessions.slice(0, 30).map((session) => sessionItem(session))); + projectSessionsList.replaceChildren(...projectSessions.map((session) => sessionItem(session))); +} + +function sessionItem(session: SessionInfo): HTMLElement { + const item = document.createElement("li"); + item.className = "session-item"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "link-button"; + button.textContent = sessionLabel(session); + button.addEventListener("click", () => { + cwdInput.value = session.cwd; + void openRuntimeFor(session.cwd, session.path); + }); + item.append(button, meta(`${session.messageCount} messages · ${formatDate(session.modified)}`)); + if (session.firstMessage) item.append(meta(session.firstMessage)); + return item; +} + +async function openRuntime(useSelectedSession: boolean): Promise { + const cwd = cwdInput.value.trim(); + if (!cwd) throw new Error("Choose a project path before opening a runtime"); + const sessionPath = useSelectedSession ? sessionSelect.value || undefined : undefined; + await openRuntimeFor(cwd, sessionPath); +} + +async function openRuntimeFor(cwd: string, sessionPath: string | undefined): Promise { + const body = { cwd, sessionPath }; + const response = await fetchJson<{ id: string; state: DashboardRuntimeState }>("/api/runtime", { + method: "POST", + body: JSON.stringify(body), + }); + runtime = { id: response.id, cwd, sessionPath }; + dashboardState = hydrateRuntimeState(createInitialDashboardState(), response.state); + renderState(); + connectEvents(); + await Promise.all([loadRuntimeMessages(), loadModels()]); + showStatus("Runtime ready"); +} + +async function refreshRuntime(): Promise { + if (!runtime) return; + const params = runtimeParams(); + const [state, messages] = await Promise.all([ + fetchJson<{ state: DashboardRuntimeState }>(`/api/runtime/${encodeURIComponent(runtime.id)}/state?${params}`), + fetchJson<{ messages: DashboardMessage[] }>(`/api/runtime/${encodeURIComponent(runtime.id)}/messages?${params}`), + ]); + dashboardState = hydrateMessages(hydrateRuntimeState(dashboardState, state.state), messages.messages); + renderState(); +} + +async function loadRuntimeMessages(): Promise { + if (!runtime) return; + const response = await fetchJson<{ messages: DashboardMessage[] }>( + `/api/runtime/${encodeURIComponent(runtime.id)}/messages?${runtimeParams()}`, + ); + dashboardState = hydrateMessages(dashboardState, response.messages); + renderState(); +} + +async function loadModels(): Promise { + if (!runtime) return; + const response = await fetchJson<{ models: ModelInfo[] }>( + `/api/runtime/${encodeURIComponent(runtime.id)}/models?${runtimeParams()}`, + ); + modelSelect.replaceChildren(option("", "Select model")); + for (const model of response.models) { + if (!model.provider || !model.id) continue; + modelSelect.append(option(JSON.stringify([model.provider, model.id]), `${model.provider}/${model.id}`)); + } + const active = dashboardState.runtime?.model; + if (active?.provider && active.id) modelSelect.value = JSON.stringify([active.provider, active.id]); +} + +function connectEvents(): void { + eventSource?.close(); + if (!runtime) return; + eventSource = new EventSource(`/api/runtime/${encodeURIComponent(runtime.id)}/events?${runtimeParams()}`, { + withCredentials: true, + }); + eventSource.addEventListener("ready", () => showStatus("Live events connected")); + eventSource.addEventListener("agent", (message) => { + const event = JSON.parse((message as MessageEvent).data) as JsonRecord; + dashboardState = applyDashboardEvent(dashboardState, event); + renderState(); + }); + eventSource.onerror = () => showStatus("Live events disconnected; refresh or reopen the runtime to reconnect."); +} + +async function sendMessage(): Promise { + if (!runtime) throw new Error("Open a runtime before sending a message"); + const message = messageInput.value.trim(); + if (!message) return; + const command = commandSelect.value; + await fetchJson(`/api/runtime/${encodeURIComponent(runtime.id)}/${command}`, { + method: "POST", + body: JSON.stringify({ ...runtimeBody(), message }), + }); + messageInput.value = ""; + showStatus(`${command.replace("_", " ")} sent`); +} + +async function abortRuntime(): Promise { + if (!runtime) return; + await fetchJson(`/api/runtime/${encodeURIComponent(runtime.id)}/abort`, { + method: "POST", + body: JSON.stringify(runtimeBody()), + }); + showStatus("Abort requested"); +} + +async function applyModel(): Promise { + if (!runtime || !modelSelect.value) return; + const [provider, modelId] = JSON.parse(modelSelect.value) as [string, string]; + await fetchJson(`/api/runtime/${encodeURIComponent(runtime.id)}/model`, { + method: "POST", + body: JSON.stringify({ ...runtimeBody(), provider, modelId }), + }); + await refreshRuntime(); + showStatus(`Model set to ${provider}/${modelId}`); +} + +async function applyModes(): Promise { + if (!runtime) return; + await fetchJson(`/api/runtime/${encodeURIComponent(runtime.id)}/thinking`, { + method: "POST", + body: JSON.stringify({ ...runtimeBody(), level: thinkingSelect.value }), + }); + await fetchJson(`/api/runtime/${encodeURIComponent(runtime.id)}/modes`, { + method: "POST", + body: JSON.stringify({ + ...runtimeBody(), + steeringMode: steeringModeSelect.value, + followUpMode: followUpModeSelect.value, + }), + }); + await refreshRuntime(); + showStatus("Runtime settings updated"); +} + +function renderState(): void { + runtimeBadge.textContent = runtime + ? `${shortId(runtime.id)} · ${dashboardState.runtime?.model?.provider ?? "model"}/${dashboardState.runtime?.model?.id ?? "unset"}` + : "no runtime"; + if (dashboardState.runtime?.thinkingLevel) thinkingSelect.value = dashboardState.runtime.thinkingLevel; + if (dashboardState.runtime?.steeringMode) steeringModeSelect.value = dashboardState.runtime.steeringMode; + if (dashboardState.runtime?.followUpMode) followUpModeSelect.value = dashboardState.runtime.followUpMode; + renderTranscript(); + renderTasks(); + renderSuggestions(); + renderSubagents(); + renderEvents(); +} + +function renderTranscript(): void { + const messages = dashboardState.streamMessage + ? [...dashboardState.messages, dashboardState.streamMessage] + : dashboardState.messages; + transcript.replaceChildren(...messages.map(messageElement)); + if (messages.length === 0) transcript.append(empty("No messages loaded.")); + transcript.scrollTop = transcript.scrollHeight; +} + +function messageElement(message: DashboardMessage): HTMLElement { + const item = document.createElement("article"); + item.className = `message role-${typeof message.role === "string" ? message.role : "custom"}`; + const heading = document.createElement("header"); + heading.textContent = `${message.role ?? "message"}${message.timestamp ? ` · ${formatDate(message.timestamp)}` : ""}`; + const body = document.createElement("pre"); + body.textContent = messageText(message); + item.append(heading, body); + return item; +} + +function renderTasks(): void { + taskList.replaceChildren(); + if (dashboardState.tasks.length === 0) { + taskList.append(empty("No task list.")); + return; + } + for (const task of dashboardState.tasks) { + const item = document.createElement("li"); + item.className = `task status-${task.status ?? "pending"}`; + item.textContent = `${task.status ?? "pending"} — ${task.title ?? task.id ?? "task"}`; + taskList.append(item); + } +} + +function renderSuggestions(): void { + suggestions.replaceChildren(); + for (const command of dashboardState.suggestions) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "chip"; + button.textContent = command; + button.addEventListener("click", () => { + messageInput.value = command; + dashboardState = clearSuggestion(dashboardState, command); + renderSuggestions(); + }); + suggestions.append(button); + } +} + +function renderSubagents(): void { + subagentList.replaceChildren(); + if (dashboardState.parentPause) { + const pause = document.createElement("li"); + pause.className = "subagent pause"; + pause.textContent = `Parent paused: ${dashboardState.parentPause.runningAgentCount} running · turn ${dashboardState.parentPause.turnsUsed}/${dashboardState.parentPause.turnLimit}`; + subagentList.append(pause); + } + for (const subagent of dashboardState.subagents) { + const item = document.createElement("li"); + item.className = `subagent status-${subagent.status}`; + item.textContent = `${subagent.status} · ${subagent.agentType} · ${subagent.taskSummary}`; + subagentList.append(item); + } + if (subagentList.childElementCount === 0) subagentList.append(empty("No background agents.")); +} + +function renderEvents(): void { + eventLog.replaceChildren( + ...dashboardState.events + .slice(-50) + .reverse() + .map((entry) => { + const item = document.createElement("li"); + item.className = `event category-${entry.category}`; + item.textContent = `${formatDate(entry.timestamp)} · ${entry.category} · ${entry.type}`; + return item; + }), + ); + if (dashboardState.events.length === 0) eventLog.append(empty("No live events yet.")); +} + +function runtimeParams(): URLSearchParams { + return new URLSearchParams(runtimeBody()); +} + +function runtimeBody(): { cwd: string; sessionPath?: string } { + if (!runtime) throw new Error("Runtime is not open"); + return runtime.sessionPath ? { cwd: runtime.cwd, sessionPath: runtime.sessionPath } : { cwd: runtime.cwd }; +} + +function activateTab(name: string): void { + for (const button of document.querySelectorAll("[data-tab]")) { + button.classList.toggle("active", button.dataset.tab === name); + } + for (const panel of document.querySelectorAll("[data-panel]")) { + panel.hidden = panel.dataset.panel !== name; + } +} + +async function fetchJson(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + if (init.body && !(init.body instanceof ArrayBuffer) && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + const response = await fetch(url, { ...init, headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(parseError(text) || `${response.status} ${response.statusText}`); + } + return (await response.json()) as T; +} + +function parseError(text: string): string | undefined { + try { + const parsed = JSON.parse(text) as { error?: string }; + return parsed.error; + } catch { + return text.trim() || undefined; + } +} + +function messageText(message: DashboardMessage): string { + return contentText(message.content) || JSON.stringify(message, null, 2); +} + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((block) => { + if (!isRecord(block)) return ""; + if (block.type === "text" && typeof block.text === "string") return block.text; + if (block.type === "thinking" && typeof block.thinking === "string") return `[thinking]\n${block.thinking}`; + if (block.type === "toolCall" && typeof block.name === "string") { + return `[tool call] ${block.name}\n${JSON.stringify(block.arguments ?? {}, null, 2)}`; + } + if (block.type === "image") return "[image]"; + return JSON.stringify(block); + }) + .filter(Boolean) + .join("\n\n"); +} + +function parentPath(path: string): string { + if (path === "." || path === "") return "."; + const parts = path.split("/").filter(Boolean); + parts.pop(); + return parts.length ? parts.join("/") : "."; +} + +function projectPath(rootPath: string, relativePath: string): string { + if (relativePath === "." || relativePath === "") return rootPath; + const separator = rootPath.includes("\\") ? "\\" : "/"; + return `${rootPath.replace(/[\\/]+$/, "")}${separator}${relativePath}`; +} + +function sessionLabel(session: SessionInfo): string { + return session.name || session.firstMessage || session.id || session.path; +} + +function formatBytes(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} + +function formatDate(value: string | number): string { + const date = new Date(value); + return Number.isNaN(date.valueOf()) ? String(value) : date.toLocaleString(); +} + +function shortId(id: string): string { + return id.length > 14 ? `${id.slice(0, 14)}…` : id; +} + +function option(value: string, label: string): HTMLOptionElement { + const element = document.createElement("option"); + element.value = value; + element.textContent = label; + return element; +} + +function meta(text: string): HTMLElement { + const element = document.createElement("span"); + element.className = "muted"; + element.textContent = text; + return element; +} + +function empty(text: string): HTMLElement { + const element = document.createElement("li"); + element.className = "empty"; + element.textContent = text; + return element; +} + +function showStatus(message: string): void { + statusLine.textContent = message; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function byId(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing element #${id}`); + return element as T; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/dashboard/src/client/state.ts b/packages/dashboard/src/client/state.ts new file mode 100644 index 00000000..29ce7949 --- /dev/null +++ b/packages/dashboard/src/client/state.ts @@ -0,0 +1,295 @@ +export type EventCategory = "lifecycle" | "message" | "stream" | "tool" | "task" | "suggestion" | "subagent" | "system"; + +export type JsonRecord = Record; + +export interface DashboardMessage extends JsonRecord { + role?: string; + content?: unknown; + timestamp?: number; +} + +export interface DashboardTask extends JsonRecord { + id?: string; + title?: string; + status?: string; +} + +export interface DashboardSubagent { + id: string; + agentType: string; + taskSummary: string; + status: "running" | "succeeded" | "failed"; + startedAt: number; + endedAt?: number; + lastEvent?: JsonRecord; +} + +export interface DashboardRuntimeState extends JsonRecord { + model?: { provider?: string; id?: string }; + thinkingLevel?: string; + isStreaming?: boolean; + isCompacting?: boolean; + steeringMode?: "all" | "one-at-a-time"; + followUpMode?: "all" | "one-at-a-time"; + sessionFile?: string; + sessionId?: string; + sessionName?: string; + messageCount?: number; + pendingMessageCount?: number; + modelFallbackMessage?: string; +} + +export interface EventLogEntry { + id: number; + category: EventCategory; + type: string; + timestamp: number; + event: JsonRecord; +} + +export interface ParentPauseInfo { + runningAgentCount: number; + turnsUsed: number; + turnLimit: number; + updatedAt: number; +} + +export interface DashboardClientState { + runtime?: DashboardRuntimeState; + messages: DashboardMessage[]; + streamMessage?: DashboardMessage; + tasks: DashboardTask[]; + suggestions: string[]; + subagents: DashboardSubagent[]; + parentPause?: ParentPauseInfo; + events: EventLogEntry[]; + isStreaming: boolean; + lastEventId: number; +} + +export function createInitialDashboardState(): DashboardClientState { + return { + messages: [], + tasks: [], + suggestions: [], + subagents: [], + events: [], + isStreaming: false, + lastEventId: 0, + }; +} + +export function hydrateRuntimeState( + state: DashboardClientState, + runtime: DashboardRuntimeState | undefined, +): DashboardClientState { + return { + ...state, + runtime, + isStreaming: Boolean(runtime?.isStreaming), + }; +} + +export function hydrateMessages(state: DashboardClientState, messages: DashboardMessage[]): DashboardClientState { + return { + ...state, + messages: [...messages], + streamMessage: undefined, + }; +} + +export function categorizeEvent(event: JsonRecord): EventCategory { + switch (event.type) { + case "agent_start": + case "agent_end": + case "turn_start": + case "turn_end": + case "auto_compaction_start": + case "auto_compaction_end": + case "auto_retry_start": + case "auto_retry_end": + case "stream_retry": + case "length_retry": + return "lifecycle"; + case "message_start": + case "message_end": + return "message"; + case "message_update": + return "stream"; + case "tool_execution_start": + case "tool_execution_update": + case "tool_execution_end": + return "tool"; + case "tasks_update": + return "task"; + case "suggest_next": + return "suggestion"; + case "background_agent_start": + case "background_agent_end": + case "parent_paused_for_background_agents": + return "subagent"; + default: + return "system"; + } +} + +export function applyDashboardEvent( + state: DashboardClientState, + event: JsonRecord, + now = Date.now(), +): DashboardClientState { + const type = typeof event.type === "string" ? event.type : "unknown"; + const next: DashboardClientState = appendEvent(state, event, type, now); + + switch (type) { + case "agent_start": + return { ...next, isStreaming: true, runtime: { ...next.runtime, isStreaming: true } }; + case "agent_end": { + const messages = Array.isArray(event.messages) ? asMessages(event.messages) : next.messages; + return { + ...next, + messages, + streamMessage: undefined, + isStreaming: false, + runtime: { ...next.runtime, isStreaming: false }, + }; + } + case "message_start": + return upsertEventMessage(next, event, false); + case "message_update": { + const message = asMessage(event.message); + if (!message) return next; + return { ...next, streamMessage: message, isStreaming: true, runtime: { ...next.runtime, isStreaming: true } }; + } + case "message_end": + return { ...upsertEventMessage(next, event, true), streamMessage: undefined }; + case "turn_end": + return upsertEventMessage(next, event, true); + case "tasks_update": + return { ...next, tasks: Array.isArray(event.tasks) ? asTasks(event.tasks) : [] }; + case "suggest_next": { + const command = typeof event.command === "string" ? event.command : undefined; + if (!command || next.suggestions.includes(command)) return next; + return { ...next, suggestions: [...next.suggestions, command].slice(-8) }; + } + case "background_agent_start": + return startSubagent(next, event, now); + case "background_agent_end": + return endSubagent(next, event, now); + case "parent_paused_for_background_agents": + return { + ...next, + parentPause: { + runningAgentCount: numberField(event.runningAgentCount), + turnsUsed: numberField(event.turnsUsed), + turnLimit: numberField(event.turnLimit), + updatedAt: now, + }, + }; + default: + return next; + } +} + +export function clearSuggestion(state: DashboardClientState, command: string): DashboardClientState { + return { ...state, suggestions: state.suggestions.filter((candidate) => candidate !== command) }; +} + +function appendEvent(state: DashboardClientState, event: JsonRecord, type: string, now: number): DashboardClientState { + const id = state.lastEventId + 1; + const entry: EventLogEntry = { + id, + category: categorizeEvent(event), + type, + timestamp: now, + event, + }; + return { + ...state, + lastEventId: id, + events: [...state.events, entry].slice(-120), + }; +} + +function upsertEventMessage( + state: DashboardClientState, + event: JsonRecord, + preferReplace: boolean, +): DashboardClientState { + const message = asMessage(event.message); + if (!message) return state; + return { ...state, messages: upsertMessage(state.messages, message, preferReplace) }; +} + +function upsertMessage( + messages: DashboardMessage[], + message: DashboardMessage, + preferReplace: boolean, +): DashboardMessage[] { + const key = messageKey(message); + const existingIndex = key ? messages.findIndex((candidate) => messageKey(candidate) === key) : -1; + if (existingIndex >= 0) { + const next = [...messages]; + next[existingIndex] = preferReplace ? message : { ...next[existingIndex], ...message }; + return next; + } + return [...messages, message]; +} + +function messageKey(message: DashboardMessage): string | undefined { + if (typeof message.timestamp === "number" && typeof message.role === "string") + return `${message.role}:${message.timestamp}`; + const toolCallId = message.toolCallId; + if (typeof toolCallId === "string") return `tool:${toolCallId}`; + return undefined; +} + +function startSubagent(state: DashboardClientState, event: JsonRecord, now: number): DashboardClientState { + const id = typeof event.agentId === "string" ? event.agentId : "unknown"; + const subagent: DashboardSubagent = { + id, + agentType: typeof event.agentType === "string" ? event.agentType : "agent", + taskSummary: typeof event.taskSummary === "string" ? event.taskSummary : "Background agent", + status: "running", + startedAt: now, + lastEvent: event, + }; + return { ...state, subagents: [...state.subagents.filter((candidate) => candidate.id !== id), subagent] }; +} + +function endSubagent(state: DashboardClientState, event: JsonRecord, now: number): DashboardClientState { + const id = typeof event.agentId === "string" ? event.agentId : "unknown"; + const status = event.success === false ? "failed" : "succeeded"; + const existing = state.subagents.find((candidate) => candidate.id === id); + const subagent: DashboardSubagent = { + id, + agentType: typeof event.agentType === "string" ? event.agentType : (existing?.agentType ?? "agent"), + taskSummary: existing?.taskSummary ?? "Background agent", + status, + startedAt: existing?.startedAt ?? now, + endedAt: now, + lastEvent: event, + }; + return { ...state, subagents: [...state.subagents.filter((candidate) => candidate.id !== id), subagent] }; +} + +function asMessage(value: unknown): DashboardMessage | undefined { + if (!isRecord(value)) return undefined; + return value as DashboardMessage; +} + +function asMessages(values: unknown[]): DashboardMessage[] { + return values.filter(isRecord) as DashboardMessage[]; +} + +function asTasks(values: unknown[]): DashboardTask[] { + return values.filter(isRecord) as DashboardTask[]; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function numberField(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} diff --git a/packages/dashboard/src/files.ts b/packages/dashboard/src/files.ts new file mode 100644 index 00000000..f457f947 --- /dev/null +++ b/packages/dashboard/src/files.ts @@ -0,0 +1,154 @@ +import { createReadStream } from "node:fs"; +import { mkdir, readdir, realpath, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; +import type { Readable } from "node:stream"; + +export interface FileRoot { + id: string; + label: string; + path: string; +} + +export interface FileApiOptions { + cwd?: string; + homeDir?: string; + maxUploadBytes?: number; + maxDownloadBytes?: number; +} + +export interface DirectoryEntry { + name: string; + path: string; + type: "directory" | "file" | "symlink" | "other"; + size: number; + modified: string; +} + +export interface BrowseResult { + root: FileRoot; + path: string; + entries: DirectoryEntry[]; +} + +export class FileApi { + private readonly cwd: string; + private readonly homeDir: string; + private readonly maxUploadBytes: number; + private readonly maxDownloadBytes: number; + + constructor(options: FileApiOptions = {}) { + this.cwd = resolve(options.cwd ?? process.cwd()); + this.homeDir = resolve(options.homeDir ?? homedir()); + this.maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024; + this.maxDownloadBytes = options.maxDownloadBytes ?? 50 * 1024 * 1024; + } + + listRoots(): FileRoot[] { + const roots: FileRoot[] = [{ id: "cwd", label: "Current project", path: this.cwd }]; + if (this.homeDir !== this.cwd) roots.push({ id: "home", label: "Home", path: this.homeDir }); + return roots; + } + + async browse(rootId: string, requestedPath = "."): Promise { + const root = this.getRoot(rootId); + const target = await this.resolveExisting(root, requestedPath); + const info = await stat(target.absolutePath); + if (!info.isDirectory()) throw httpError(400, "Path is not a directory"); + const entries = await readdir(target.absolutePath, { withFileTypes: true }); + const result: DirectoryEntry[] = []; + for (const entry of entries) { + const fullPath = join(target.absolutePath, entry.name); + const entryStat = await stat(fullPath).catch(() => null); + result.push({ + name: entry.name, + path: join(target.relativePath, entry.name), + type: entry.isDirectory() + ? "directory" + : entry.isFile() + ? "file" + : entry.isSymbolicLink() + ? "symlink" + : "other", + size: entryStat?.size ?? 0, + modified: (entryStat?.mtime ?? new Date(0)).toISOString(), + }); + } + result.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1)); + return { root, path: target.relativePath, entries: result }; + } + + async upload( + rootId: string, + folderPath: string, + filename: string, + body: Buffer, + ): Promise<{ path: string; size: number }> { + if (body.byteLength > this.maxUploadBytes) throw httpError(413, "Upload exceeds size limit"); + if (!filename || basename(filename) !== filename) + throw httpError(400, "Filename must not contain path separators"); + const root = this.getRoot(rootId); + const folder = await this.resolveExisting(root, folderPath); + const folderStat = await stat(folder.absolutePath); + if (!folderStat.isDirectory()) throw httpError(400, "Upload target is not a directory"); + const targetPath = resolve(folder.absolutePath, filename); + assertWithin(folder.rootRealPath, targetPath); + await mkdir(folder.absolutePath, { recursive: true }); + await writeFile(targetPath, body, { flag: "w" }); + const realTarget = await realpath(targetPath); + assertWithin(folder.rootRealPath, realTarget); + return { path: toRelative(folder.rootRealPath, realTarget), size: body.byteLength }; + } + + async download( + rootId: string, + requestedPath: string, + ): Promise<{ stream: Readable; size: number; filename: string; mime: string }> { + const root = this.getRoot(rootId); + const target = await this.resolveExisting(root, requestedPath); + const info = await stat(target.absolutePath); + if (!info.isFile()) throw httpError(400, "Path is not a file"); + if (info.size > this.maxDownloadBytes) throw httpError(413, "Download exceeds size limit"); + return { + stream: createReadStream(target.absolutePath), + size: info.size, + filename: basename(target.absolutePath), + mime: "application/octet-stream", + }; + } + + private getRoot(rootId: string): FileRoot { + const root = this.listRoots().find((candidate) => candidate.id === rootId); + if (!root) throw httpError(404, "Unknown file root"); + return root; + } + + private async resolveExisting(root: FileRoot, requestedPath: string): Promise { + const rootRealPath = await realpath(root.path); + const lexicalPath = resolve(rootRealPath, requestedPath || "."); + assertWithin(rootRealPath, lexicalPath); + const absolutePath = await realpath(lexicalPath); + assertWithin(rootRealPath, absolutePath); + return { rootRealPath, absolutePath, relativePath: toRelative(rootRealPath, absolutePath) }; + } +} + +interface ResolvedPath { + rootRealPath: string; + absolutePath: string; + relativePath: string; +} + +function assertWithin(root: string, target: string): void { + const rel = relative(root, target); + if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) return; + throw httpError(403, "Path escapes the selected root"); +} + +function toRelative(root: string, target: string): string { + return relative(root, target) || "."; +} + +export function httpError(status: number, message: string): Error & { status: number } { + return Object.assign(new Error(message), { status }); +} diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts new file mode 100644 index 00000000..3c14d561 --- /dev/null +++ b/packages/dashboard/src/index.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import { DashboardAuth } from "./auth.js"; +import { FileApi } from "./files.js"; +import { createDashboardServer } from "./server.js"; + +export { DashboardAuth, isLoopbackAddress, TailscaleStatusResolver } from "./auth.js"; +export { FileApi } from "./files.js"; +export { DashboardRuntime, DashboardRuntimePool, runtimeId } from "./runtime.js"; +export { createDashboardServer } from "./server.js"; +export { SessionApi } from "./sessions.js"; + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = parseArgs(process.argv.slice(2)); + const host = args.host ?? "127.0.0.1"; + const port = Number(args.port ?? 3762); + const remoteEnabled = args.remote === "true" || args.remote === "1" || args.remote === "yes"; + const auth = new DashboardAuth({ + remoteEnabled, + agentDir: args.agentDir, + allowedIdentities: parseCsv(args.allowedIdentity ?? args.allowedIdentities), + allowedDevices: parseCsv(args.allowedDevice ?? args.allowedDevices), + }); + const pin = remoteEnabled ? auth.generatePin() : null; + const server = createDashboardServer({ + auth, + files: new FileApi({ cwd: args.cwd ?? process.cwd() }), + }); + server.listen(port, host, () => { + const address = server.address(); + const bound = typeof address === "object" && address ? `${address.address}:${address.port}` : `${host}:${port}`; + console.log(`dreb dashboard listening on http://${bound}`); + if (pin) console.log(`remote pairing PIN: ${pin.pin} (expires ${pin.expiresAt})`); + }); +} + +function parseArgs(args: string[]): Record { + const parsed: Record = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg.startsWith("--")) continue; + const [rawKey, inlineValue] = arg.slice(2).split("=", 2); + parsed[rawKey] = inlineValue ?? args[++i] ?? "true"; + } + return parsed; +} + +function parseCsv(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} diff --git a/packages/dashboard/src/runtime.ts b/packages/dashboard/src/runtime.ts new file mode 100644 index 00000000..9042f135 --- /dev/null +++ b/packages/dashboard/src/runtime.ts @@ -0,0 +1,214 @@ +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AgentMessage, ThinkingLevel } from "@dreb/agent-core"; +import { RpcClient, type RpcSessionState } from "@dreb/coding-agent/rpc"; +import { SessionApi } from "./sessions.js"; + +export type DashboardAgentEvent = { type: string; [key: string]: unknown }; +export type RuntimeEventListener = (event: DashboardAgentEvent) => void; + +export interface RpcClientLike { + start(): Promise; + stop(): Promise; + onEvent(listener: RuntimeEventListener): () => void; + prompt(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise; + steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise; + followUp(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise; + abort(): Promise; + getState(): Promise; + getMessages(): Promise; + setModel(provider: string, modelId: string): Promise; + setThinkingLevel(level: ThinkingLevel): Promise; + setSteeringMode(mode: "all" | "one-at-a-time"): Promise; + setFollowUpMode(mode: "all" | "one-at-a-time"): Promise; + switchSession(sessionPath: string): Promise<{ cancelled: boolean }>; + getAvailableModels?(): Promise; +} + +export interface RuntimeRequest { + id?: string; + cwd: string; + sessionPath?: string; + provider?: string; + model?: string; +} + +export interface RuntimeFactoryOptions { + cwd: string; + provider?: string; + model?: string; +} + +export type RuntimeFactory = (options: RuntimeFactoryOptions) => RpcClientLike; + +export interface RuntimePoolOptions { + factory?: RuntimeFactory; + sessionApi?: SessionApi; + validateSessionProject?: boolean; +} + +export class DashboardRuntime { + private client: RpcClientLike | null = null; + private started = false; + private readonly listeners = new Set(); + + constructor( + readonly id: string, + readonly cwd: string, + readonly sessionPath: string | undefined, + private readonly factory: RuntimeFactory, + private readonly provider?: string, + private readonly model?: string, + ) {} + + async start(): Promise { + if (this.started) return; + this.client = this.factory({ cwd: this.cwd, provider: this.provider, model: this.model }); + this.client.onEvent((event) => this.fanout(event)); + await this.client.start(); + if (this.sessionPath) { + const result = await this.client.switchSession(this.sessionPath); + if (result.cancelled) throw new Error("Session switch was cancelled"); + } + this.started = true; + } + + async stop(): Promise { + if (!this.client) return; + await this.client.stop(); + this.client = null; + this.started = false; + } + + onEvent(listener: RuntimeEventListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async prompt(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise { + await this.requireClient().prompt(message, images); + } + + async steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise { + await this.requireClient().steer(message, images); + } + + async followUp(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise { + await this.requireClient().followUp(message, images); + } + + async abort(): Promise { + await this.requireClient().abort(); + } + + async getState(): Promise { + return this.requireClient().getState(); + } + + async getMessages(): Promise { + return this.requireClient().getMessages(); + } + + async setModel(provider: string, modelId: string): Promise { + return this.requireClient().setModel(provider, modelId); + } + + async setThinkingLevel(level: ThinkingLevel): Promise { + await this.requireClient().setThinkingLevel(level); + } + + async setSteeringMode(mode: "all" | "one-at-a-time"): Promise { + await this.requireClient().setSteeringMode(mode); + } + + async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise { + await this.requireClient().setFollowUpMode(mode); + } + + async getAvailableModels(): Promise { + return (await this.requireClient().getAvailableModels?.()) ?? []; + } + + private requireClient(): RpcClientLike { + if (!this.client || !this.started) throw new Error("Runtime is not started"); + return this.client; + } + + private fanout(event: DashboardAgentEvent): void { + for (const listener of this.listeners) listener(event); + } +} + +export class DashboardRuntimePool { + private readonly runtimes = new Map(); + private readonly factory: RuntimeFactory; + private readonly sessionApi: SessionApi; + private readonly validateSessionProject: boolean; + + constructor(options: RuntimePoolOptions = {}) { + this.factory = options.factory ?? defaultRuntimeFactory; + this.sessionApi = options.sessionApi ?? new SessionApi(); + this.validateSessionProject = options.validateSessionProject ?? true; + } + + async getOrCreate(request: RuntimeRequest): Promise { + const cwd = resolve(request.cwd); + const id = request.id ?? runtimeId(cwd, request.sessionPath); + const existing = this.runtimes.get(id); + if (existing) { + if (existing.cwd !== cwd) throw new Error("Runtime cannot switch projects; create a separate runtime"); + if ((existing.sessionPath ?? "") !== (request.sessionPath ?? "")) { + throw new Error("Runtime cannot switch sessions; create a separate runtime"); + } + return existing; + } + + if (request.sessionPath && this.validateSessionProject) { + const sessions = await this.sessionApi.listProject(cwd); + if (!sessions.some((session) => session.path === request.sessionPath)) { + throw new Error("Session does not belong to the requested project"); + } + } + + const runtime = new DashboardRuntime(id, cwd, request.sessionPath, this.factory, request.provider, request.model); + this.runtimes.set(id, runtime); + try { + await runtime.start(); + return runtime; + } catch (error) { + this.runtimes.delete(id); + throw error; + } + } + + async stop(id: string): Promise { + const runtime = this.runtimes.get(id); + if (!runtime) return; + this.runtimes.delete(id); + await runtime.stop(); + } + + async stopAll(): Promise { + await Promise.all([...this.runtimes.keys()].map((id) => this.stop(id))); + } +} + +export function runtimeId(cwd: string, sessionPath?: string): string { + return Buffer.from(JSON.stringify({ cwd: resolve(cwd), sessionPath: sessionPath ?? null })).toString("base64url"); +} + +export function defaultRuntimeFactory(options: RuntimeFactoryOptions): RpcClientLike { + return new RpcClient({ + cliPath: resolveDrebCliPath(), + cwd: options.cwd, + provider: options.provider, + model: options.model, + args: ["--ui", "dashboard"], + }) as unknown as RpcClientLike; +} + +function resolveDrebCliPath(): string { + const resolved = import.meta.resolve("@dreb/coding-agent"); + const distDir = dirname(fileURLToPath(resolved)); + return join(distDir, "cli.js"); +} diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts new file mode 100644 index 00000000..2150d2ae --- /dev/null +++ b/packages/dashboard/src/server.ts @@ -0,0 +1,303 @@ +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { DashboardAuth } from "./auth.js"; +import { FileApi } from "./files.js"; +import { DashboardRuntimePool } from "./runtime.js"; +import { SessionApi } from "./sessions.js"; + +export interface DashboardServerOptions { + auth?: DashboardAuth; + files?: FileApi; + sessions?: SessionApi; + runtimes?: DashboardRuntimePool; + staticDir?: string; + maxJsonBytes?: number; +} + +const DEFAULT_STATIC_DIR = fileURLToPath(new URL("./static", import.meta.url)); + +export function createDashboardServer(options: DashboardServerOptions = {}): Server { + const app = new DashboardServer(options); + return createServer((req, res) => app.handle(req, res)); +} + +class DashboardServer { + private readonly auth = this.options.auth ?? new DashboardAuth(); + private readonly files = this.options.files ?? new FileApi(); + private readonly sessions = this.options.sessions ?? new SessionApi(); + private readonly runtimes = this.options.runtimes ?? new DashboardRuntimePool({ sessionApi: this.sessions }); + private readonly staticDir = this.options.staticDir ?? DEFAULT_STATIC_DIR; + private readonly maxJsonBytes = this.options.maxJsonBytes ?? 1024 * 1024; + + constructor(private readonly options: DashboardServerOptions) {} + + async handle(req: IncomingMessage, res: ServerResponse): Promise { + try { + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname === "/api/health") { + return json(res, 200, { ok: true }); + } + if (url.pathname === "/api/auth/pair" && req.method === "POST") { + return this.handlePair(req, res); + } + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/events")) { + const auth = await this.auth.authenticate(req); + if (!auth.allowed) return json(res, auth.status, { error: auth.reason ?? "Unauthorized" }); + return this.handleApi(req, res, url); + } + return this.serveStatic(req, res, url); + } catch (error) { + const status = statusFromError(error); + return json(res, status, { error: error instanceof Error ? error.message : String(error) }); + } + } + + private async handlePair(req: IncomingMessage, res: ServerResponse): Promise { + const body = await readJson<{ pin?: string }>(req, this.maxJsonBytes); + const result = await this.auth.pair(req, body.pin ?? ""); + res.setHeader( + "Set-Cookie", + `dreb_dashboard_pairing=${encodeURIComponent(result.token)}; HttpOnly; SameSite=Strict; Path=/`, + ); + return json(res, 200, result); + } + + private async handleApi(req: IncomingMessage, res: ServerResponse, url: URL): Promise { + if (url.pathname === "/api/auth/status" && req.method === "GET") return json(res, 200, { authenticated: true }); + if (url.pathname === "/api/roots" && req.method === "GET") + return json(res, 200, { roots: this.files.listRoots() }); + if (url.pathname === "/api/files/browse" && req.method === "GET") { + return json( + res, + 200, + await this.files.browse(requiredQuery(url, "root"), url.searchParams.get("path") ?? "."), + ); + } + if (url.pathname === "/api/files/upload" && req.method === "POST") { + const body = await readBody(req, Number(url.searchParams.get("maxBytes") ?? 10 * 1024 * 1024)); + return json( + res, + 200, + await this.files.upload( + requiredQuery(url, "root"), + url.searchParams.get("path") ?? ".", + requiredQuery(url, "name"), + body, + ), + ); + } + if (url.pathname === "/api/files/download" && req.method === "GET") { + const download = await this.files.download(requiredQuery(url, "root"), requiredQuery(url, "path")); + res.writeHead(200, { + "content-type": download.mime, + "content-length": String(download.size), + "content-disposition": `attachment; filename="${download.filename.replaceAll('"', "")}"`, + }); + download.stream.pipe(res); + return; + } + if (url.pathname === "/api/sessions" && req.method === "GET") + return json(res, 200, { sessions: await this.sessions.listAll() }); + if (url.pathname === "/api/sessions/project" && req.method === "GET") { + return json(res, 200, { sessions: await this.sessions.listProject(requiredQuery(url, "cwd")) }); + } + if (url.pathname === "/api/runtime" && req.method === "POST") { + const body = await readJson(req, this.maxJsonBytes); + const runtime = await this.runtimes.getOrCreate({ + id: body.id, + cwd: requiredBodyString(body, "cwd"), + sessionPath: body.sessionPath, + provider: body.provider, + model: body.model, + }); + return json(res, 200, { id: runtime.id, state: await runtime.getState() }); + } + const match = + /^\/api\/runtime\/([^/]+)\/(prompt|steer|follow_up|abort|state|messages|model|thinking|modes|models|events)$/.exec( + url.pathname, + ); + if (match) return this.handleRuntimeRoute(req, res, url, decodeURIComponent(match[1]), match[2]); + return json(res, 404, { error: "Not found" }); + } + + private async handleRuntimeRoute( + req: IncomingMessage, + res: ServerResponse, + url: URL, + id: string, + action: string, + ): Promise { + if (action === "events" && req.method === "GET") { + const runtime = await this.runtimes.getOrCreate({ + id, + cwd: requiredQuery(url, "cwd"), + sessionPath: url.searchParams.get("sessionPath") ?? undefined, + }); + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.write(`event: ready\ndata: ${JSON.stringify({ id: runtime.id })}\n\n`); + const unsubscribe = runtime.onEvent((event) => { + res.write(`event: agent\ndata: ${JSON.stringify(event)}\n\n`); + }); + req.on("close", unsubscribe); + return; + } + + const body = + req.method === "GET" ? queryRuntimeBody(url) : await readJson(req, this.maxJsonBytes); + const runtime = await this.runtimes.getOrCreate({ + id, + cwd: requiredBodyString(body, "cwd"), + sessionPath: body.sessionPath, + provider: body.provider, + model: body.model, + }); + switch (action) { + case "prompt": + await runtime.prompt(requiredBodyString(body, "message"), body.images); + return json(res, 200, { ok: true }); + case "steer": + await runtime.steer(requiredBodyString(body, "message"), body.images); + return json(res, 200, { ok: true }); + case "follow_up": + await runtime.followUp(requiredBodyString(body, "message"), body.images); + return json(res, 200, { ok: true }); + case "abort": + await runtime.abort(); + return json(res, 200, { ok: true }); + case "state": + return json(res, 200, { state: await runtime.getState() }); + case "messages": + return json(res, 200, { messages: await runtime.getMessages() }); + case "model": + return json(res, 200, { + model: await runtime.setModel(requiredBodyString(body, "provider"), requiredBodyString(body, "modelId")), + }); + case "thinking": + await runtime.setThinkingLevel(requiredBodyString(body, "level") as never); + return json(res, 200, { ok: true }); + case "modes": + if (body.steeringMode) await runtime.setSteeringMode(body.steeringMode); + if (body.followUpMode) await runtime.setFollowUpMode(body.followUpMode); + return json(res, 200, { ok: true }); + case "models": + return json(res, 200, { models: await runtime.getAvailableModels() }); + default: + return json(res, 404, { error: "Not found" }); + } + } + + private async serveStatic(req: IncomingMessage, res: ServerResponse, url: URL): Promise { + if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "Method not allowed" }); + const relativePath = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1)); + if (relativePath.includes("..")) return json(res, 403, { error: "Forbidden" }); + const filePath = join(this.staticDir, relativePath); + let info = await stat(filePath).catch(() => null); + const resolvedPath = info?.isDirectory() ? join(filePath, "index.html") : filePath; + info = await stat(resolvedPath).catch(() => null); + if (!info?.isFile()) return json(res, 404, { error: "Not found" }); + res.writeHead(200, { "content-type": contentType(resolvedPath), "content-length": String(info.size) }); + if (req.method === "HEAD") { + res.end(); + return; + } + createReadStream(resolvedPath).pipe(res); + } +} + +interface RuntimeBody { + id?: string; + cwd?: string; + sessionPath?: string; + provider?: string; + model?: string; +} + +interface RuntimeActionBody extends RuntimeBody { + message?: string; + images?: Array<{ type: "image"; data: string; mimeType: string }>; + provider?: string; + modelId?: string; + level?: string; + steeringMode?: "all" | "one-at-a-time"; + followUpMode?: "all" | "one-at-a-time"; +} + +async function readJson(req: IncomingMessage, maxBytes: number): Promise { + const body = await readBody(req, maxBytes); + if (body.byteLength === 0) return {} as T; + return JSON.parse(body.toString("utf8")) as T; +} + +function readBody(req: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.byteLength; + if (size > maxBytes) { + reject(Object.assign(new Error("Request body exceeds size limit"), { status: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function json(res: ServerResponse, status: number, body: unknown): void { + const encoded = Buffer.from(`${JSON.stringify(body)}\n`); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": String(encoded.byteLength), + }); + res.end(encoded); +} + +function requiredQuery(url: URL, key: string): string { + const value = url.searchParams.get(key); + if (!value) throw Object.assign(new Error(`Missing query parameter: ${key}`), { status: 400 }); + return value; +} + +function requiredBodyString(body: object, key: string): string { + const value = (body as Record)[key]; + if (typeof value !== "string" || value.length === 0) + throw Object.assign(new Error(`Missing body field: ${key}`), { status: 400 }); + return value; +} + +function queryRuntimeBody(url: URL): RuntimeActionBody { + return { + cwd: url.searchParams.get("cwd") ?? undefined, + sessionPath: url.searchParams.get("sessionPath") ?? undefined, + }; +} + +function statusFromError(error: unknown): number { + const status = (error as { status?: unknown }).status; + return typeof status === "number" ? status : 500; +} + +function contentType(path: string): string { + switch (extname(path)) { + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".json": + return "application/json; charset=utf-8"; + default: + return "application/octet-stream"; + } +} diff --git a/packages/dashboard/src/sessions.ts b/packages/dashboard/src/sessions.ts new file mode 100644 index 00000000..f5c2d34f --- /dev/null +++ b/packages/dashboard/src/sessions.ts @@ -0,0 +1,41 @@ +import { type SessionInfo, SessionManager } from "@dreb/coding-agent/session-manager"; + +export type DashboardSessionInfo = Omit & { + created: string; + modified: string; +}; + +export interface SessionLister { + listAll(): Promise; + listProject(cwd: string): Promise; +} + +export class CodingAgentSessionLister implements SessionLister { + async listAll(): Promise { + return SessionManager.listAll(); + } + + async listProject(cwd: string): Promise { + return SessionManager.list(cwd); + } +} + +export class SessionApi { + constructor(private readonly lister: SessionLister = new CodingAgentSessionLister()) {} + + async listAll(): Promise { + return (await this.lister.listAll()).map(serializeSession); + } + + async listProject(cwd: string): Promise { + return (await this.lister.listProject(cwd)).map(serializeSession); + } +} + +function serializeSession(session: SessionInfo): DashboardSessionInfo { + return { + ...session, + created: session.created.toISOString(), + modified: session.modified.toISOString(), + }; +} diff --git a/packages/dashboard/src/static/app.css b/packages/dashboard/src/static/app.css new file mode 100644 index 00000000..a3902735 --- /dev/null +++ b/packages/dashboard/src/static/app.css @@ -0,0 +1,434 @@ +:root { + color-scheme: light dark; + --bg: #f6f7f8; + --panel: #ffffff; + --panel-subtle: #f0f2f4; + --text: #16181b; + --muted: #68707a; + --border: #d9dee3; + --accent: #245b7d; + --accent-strong: #16415c; + --danger: #8a2f2b; + --shadow: 0 12px 30px rgb(30 40 50 / 10%); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #111417; + --panel: #181c20; + --panel-subtle: #20262b; + --text: #eceff1; + --muted: #9aa4ad; + --border: #303941; + --accent: #6aa7c8; + --accent-strong: #9ccbe2; + --danger: #d0716b; + --shadow: 0 12px 30px rgb(0 0 0 / 25%); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +.small-button { + border: 1px solid var(--accent); + border-radius: 0.6rem; + background: var(--accent); + color: white; + cursor: pointer; + padding: 0.55rem 0.85rem; + text-decoration: none; +} + +button:hover, +.small-button:hover { + background: var(--accent-strong); +} + +button.secondary, +.small-button, +.tabs button { + background: transparent; + color: var(--accent); +} + +button.danger { + border-color: var(--danger); + background: transparent; + color: var(--danger); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid var(--border); + border-radius: 0.55rem; + background: var(--panel); + color: var(--text); + padding: 0.55rem 0.7rem; +} + +textarea { + resize: vertical; +} + +label { + display: grid; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 600; +} + +label span, +.muted { + color: var(--muted); + font-size: 0.82rem; + font-weight: 500; +} + +main { + width: min(1480px, 100%); + margin: 0 auto; + padding: 1rem; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 92%, transparent); + padding: 0.9rem 1.2rem; + position: sticky; + top: 0; + z-index: 2; + backdrop-filter: blur(12px); +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 0; + font-size: clamp(1.4rem, 3vw, 2rem); +} + +h2 { + font-size: 1.05rem; +} + +h3 { + font-size: 0.95rem; +} + +.eyebrow { + margin-bottom: 0.1rem; + color: var(--muted); + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.status-stack { + display: grid; + justify-items: end; + gap: 0.25rem; + text-align: right; +} + +.badge, +.chip { + display: inline-flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-subtle); + color: var(--text); + padding: 0.25rem 0.65rem; + font-size: 0.8rem; +} + +.card { + border: 1px solid var(--border); + border-radius: 1rem; + background: var(--panel); + box-shadow: var(--shadow); + padding: 1rem; +} + +.auth-card { + max-width: 36rem; + margin: 8vh auto; +} + +.dashboard { + display: grid; + gap: 1rem; +} + +.tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.4rem; +} + +.tabs button.active { + background: var(--accent); + color: white; +} + +.panel-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(20rem, 26rem); + gap: 1rem; + align-items: start; +} + +.panel, +.side-stack { + min-width: 0; +} + +.panel-heading, +.button-row, +.composer-actions, +.inline-form { + display: flex; + align-items: end; + gap: 0.65rem; +} + +.panel-heading { + align-items: center; + justify-content: space-between; +} + +.inline-form label { + flex: 1; +} + +.side-stack { + display: grid; + gap: 1rem; +} + +.side-card { + box-shadow: none; +} + +.transcript { + display: grid; + align-content: start; + gap: 0.8rem; + min-height: 45vh; + max-height: 62vh; + overflow: auto; + border: 1px solid var(--border); + border-radius: 0.8rem; + background: var(--panel-subtle); + padding: 0.75rem; +} + +.message { + border: 1px solid var(--border); + border-radius: 0.85rem; + background: var(--panel); + padding: 0.75rem; +} + +.message header { + margin-bottom: 0.4rem; + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + +.message pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9rem; + line-height: 1.45; +} + +.role-user { + border-left: 4px solid var(--accent); +} + +.role-assistant { + border-left: 4px solid var(--muted); +} + +.role-toolResult { + border-left: 4px solid var(--border); +} + +.composer { + display: grid; + grid-template-columns: 10rem minmax(0, 1fr) auto; + gap: 0.65rem; + align-items: end; + margin-top: 0.75rem; +} + +.list { + display: grid; + gap: 0.45rem; + margin: 0.75rem 0 0; + padding: 0; + list-style: none; +} + +.list.compact { + font-size: 0.9rem; +} + +.file-row, +.session-item, +.task, +.subagent, +.event, +.empty { + display: grid; + gap: 0.2rem; + border: 1px solid var(--border); + border-radius: 0.7rem; + background: var(--panel-subtle); + padding: 0.55rem; +} + +.file-row { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.file-row .muted { + grid-column: 1 / -1; +} + +.link-button { + border: 0; + background: transparent; + color: var(--accent); + padding: 0; + text-align: left; +} + +.file-name { + font-weight: 700; +} + +.two-column, +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.settings-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 1rem 0; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.chip { + background: transparent; + color: var(--accent); +} + +.status-completed, +.status-succeeded { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); +} + +.status-failed { + border-color: var(--danger); +} + +.event-list { + max-height: 18rem; + overflow: auto; +} + +hr { + border: 0; + border-top: 1px solid var(--border); + margin: 1rem 0; +} + +[hidden] { + display: none !important; +} + +@media (max-width: 900px) { + main { + padding: 0.75rem; + } + + .topbar { + align-items: flex-start; + position: static; + } + + .panel-grid { + grid-template-columns: 1fr; + } + + .side-stack { + grid-row: 2; + } + + .composer, + .two-column, + .settings-grid, + .file-row { + grid-template-columns: 1fr; + } + + .inline-form, + .panel-heading, + .button-row, + .composer-actions { + align-items: stretch; + flex-direction: column; + } + + .transcript { + max-height: 52vh; + } +} diff --git a/packages/dashboard/src/static/index.html b/packages/dashboard/src/static/index.html new file mode 100644 index 00000000..d6b3d7bd --- /dev/null +++ b/packages/dashboard/src/static/index.html @@ -0,0 +1,183 @@ + + + + + + dreb dashboard + + + + +
+
+

dreb

+

Dashboard

+
+
+ no runtime + Starting… +
+
+ +
+
+

Pair this browser

+

Remote clients need the dashboard PIN. Local loopback clients are trusted automatically.

+
+ + +
+
+ + +
+ + diff --git a/packages/dashboard/test/auth.test.ts b/packages/dashboard/test/auth.test.ts new file mode 100644 index 00000000..ecd5bf36 --- /dev/null +++ b/packages/dashboard/test/auth.test.ts @@ -0,0 +1,74 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { DashboardAuth, type TailscaleIdentityResolver } from "../src/auth.js"; + +const identity = { + id: "user@example.com", + loginName: "user@example.com", + deviceName: "phone", + dnsName: "phone.tailnet.ts.net.", + addresses: ["100.64.0.2"], +}; + +function req(remoteAddress: string, headers: Record = {}) { + return { socket: { remoteAddress }, headers } as any; +} + +describe("DashboardAuth", () => { + it("allows loopback clients without pairing", async () => { + const auth = new DashboardAuth(); + await expect(auth.authenticate(req("127.0.0.1"))).resolves.toMatchObject({ allowed: true, loopback: true }); + await expect(auth.authenticate(req("::ffff:127.0.0.1"))).resolves.toMatchObject({ + allowed: true, + loopback: true, + }); + }); + + it("fails closed for remote clients when remote access is disabled", async () => { + const resolver: TailscaleIdentityResolver = { resolve: async () => identity }; + const auth = new DashboardAuth({ resolver }); + await expect(auth.authenticate(req("100.64.0.2"))).resolves.toMatchObject({ allowed: false, status: 403 }); + }); + + it("requires an allowed Tailscale identity and short-lived PIN pairing for remote clients", async () => { + const agentDir = await mkdtemp(join(tmpdir(), "dreb-dashboard-auth-")); + const resolver: TailscaleIdentityResolver = { resolve: async () => identity }; + const auth = new DashboardAuth({ + remoteEnabled: true, + agentDir, + resolver, + allowedIdentities: ["user@example.com"], + allowedDevices: ["phone"], + }); + + await expect(auth.authenticate(req("100.64.0.2"))).resolves.toMatchObject({ allowed: false, status: 401 }); + const pin = auth.generatePin(); + await expect(auth.pair(req("100.64.0.2"), "000000")).rejects.toThrow("Invalid or expired pairing PIN"); + const pairing = await auth.pair(req("100.64.0.2"), pin.pin); + await expect( + auth.authenticate(req("100.64.0.2", { authorization: `Bearer ${pairing.token}` })), + ).resolves.toMatchObject({ allowed: true, loopback: false }); + + const stored = JSON.parse(await readFile(join(agentDir, "dashboard-pairings.json"), "utf8")); + expect(stored.pairings).toHaveLength(1); + expect(stored.pairings[0].identityId).toBe("user@example.com"); + expect(stored.pairings[0].tokenHash).not.toBe(pairing.token); + }); + + it("denies remote clients when no Tailscale identity or device allowlist is configured", async () => { + const resolver: TailscaleIdentityResolver = { resolve: async () => identity }; + const auth = new DashboardAuth({ remoteEnabled: true, resolver }); + await expect(auth.authenticate(req("100.64.0.2"))).resolves.toMatchObject({ allowed: false, status: 403 }); + }); + + it("denies remote clients when the Tailscale resolver has no matching identity", async () => { + const auth = new DashboardAuth({ + remoteEnabled: true, + resolver: { resolve: async () => null }, + allowedIdentities: ["user@example.com"], + }); + await expect(auth.authenticate(req("203.0.113.1"))).resolves.toMatchObject({ allowed: false, status: 403 }); + }); +}); diff --git a/packages/dashboard/test/client-state.test.ts b/packages/dashboard/test/client-state.test.ts new file mode 100644 index 00000000..83d4c881 --- /dev/null +++ b/packages/dashboard/test/client-state.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + applyDashboardEvent, + categorizeEvent, + clearSuggestion, + createInitialDashboardState, + hydrateMessages, + hydrateRuntimeState, +} from "../src/client/state.js"; + +describe("dashboard client state", () => { + it("categorizes streaming, message, lifecycle, and tool events", () => { + expect(categorizeEvent({ type: "message_update" })).toBe("stream"); + expect(categorizeEvent({ type: "message_end" })).toBe("message"); + expect(categorizeEvent({ type: "agent_start" })).toBe("lifecycle"); + expect(categorizeEvent({ type: "tool_execution_update" })).toBe("tool"); + expect(categorizeEvent({ type: "extension_error" })).toBe("system"); + }); + + it("hydrates runtime state and replaces loaded messages", () => { + let state = createInitialDashboardState(); + state = hydrateRuntimeState(state, { sessionId: "s1", thinkingLevel: "medium", isStreaming: true }); + state = hydrateMessages(state, [{ role: "user", content: "hello", timestamp: 1 }]); + + expect(state.runtime?.sessionId).toBe("s1"); + expect(state.isStreaming).toBe(true); + expect(state.messages).toEqual([{ role: "user", content: "hello", timestamp: 1 }]); + }); + + it("tracks streaming message updates until completion", () => { + let state = createInitialDashboardState(); + state = applyDashboardEvent(state, { type: "agent_start" }, 10); + state = applyDashboardEvent( + state, + { + type: "message_update", + message: { role: "assistant", content: [{ type: "text", text: "hel" }], timestamp: 20 }, + assistantMessageEvent: { type: "text_delta", delta: "hel" }, + }, + 20, + ); + + expect(state.isStreaming).toBe(true); + expect(state.streamMessage?.content).toEqual([{ type: "text", text: "hel" }]); + expect(state.events.map((event) => event.category)).toEqual(["lifecycle", "stream"]); + + state = applyDashboardEvent( + state, + { + type: "message_end", + message: { role: "assistant", content: [{ type: "text", text: "hello" }], timestamp: 20 }, + }, + 30, + ); + state = applyDashboardEvent(state, { type: "agent_end", messages: state.messages }, 40); + + expect(state.streamMessage).toBeUndefined(); + expect(state.isStreaming).toBe(false); + expect(state.messages).toEqual([ + { role: "assistant", content: [{ type: "text", text: "hello" }], timestamp: 20 }, + ]); + }); + + it("updates task lists from tasks_update events", () => { + const state = applyDashboardEvent( + createInitialDashboardState(), + { + type: "tasks_update", + tasks: [ + { id: "1", title: "Read files", status: "completed" }, + { id: "2", title: "Implement UI", status: "in_progress" }, + ], + }, + 100, + ); + + expect(state.tasks).toHaveLength(2); + expect(state.tasks[1]).toMatchObject({ title: "Implement UI", status: "in_progress" }); + expect(state.events[0]?.category).toBe("task"); + }); + + it("stores and clears suggest-next commands without duplicates", () => { + let state = createInitialDashboardState(); + state = applyDashboardEvent(state, { type: "suggest_next", command: "/test" }, 100); + state = applyDashboardEvent(state, { type: "suggest_next", command: "/test" }, 101); + state = applyDashboardEvent(state, { type: "suggest_next", command: "/build" }, 102); + + expect(state.suggestions).toEqual(["/test", "/build"]); + expect(state.events.map((event) => event.category)).toEqual(["suggestion", "suggestion", "suggestion"]); + + state = clearSuggestion(state, "/test"); + expect(state.suggestions).toEqual(["/build"]); + }); + + it("tracks subagent lifecycle and parent pause events", () => { + let state = createInitialDashboardState(); + state = applyDashboardEvent( + state, + { type: "background_agent_start", agentId: "a1", agentType: "reviewer", taskSummary: "Check tests" }, + 100, + ); + state = applyDashboardEvent( + state, + { type: "parent_paused_for_background_agents", runningAgentCount: 1, turnsUsed: 3, turnLimit: 8 }, + 120, + ); + state = applyDashboardEvent( + state, + { type: "background_agent_end", agentId: "a1", agentType: "reviewer", success: true }, + 150, + ); + + expect(state.parentPause).toMatchObject({ runningAgentCount: 1, turnsUsed: 3, turnLimit: 8 }); + expect(state.subagents).toEqual([ + expect.objectContaining({ + id: "a1", + agentType: "reviewer", + taskSummary: "Check tests", + status: "succeeded", + startedAt: 100, + endedAt: 150, + }), + ]); + expect(state.events.every((event) => event.category === "subagent")).toBe(true); + }); +}); diff --git a/packages/dashboard/test/files.test.ts b/packages/dashboard/test/files.test.ts new file mode 100644 index 00000000..eaec083f --- /dev/null +++ b/packages/dashboard/test/files.test.ts @@ -0,0 +1,49 @@ +import { mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { FileApi } from "../src/files.js"; + +async function tempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), prefix)); +} + +describe("FileApi", () => { + it("lists roots and browses directories", async () => { + const root = await tempDir("dreb-dashboard-files-"); + await writeFile(join(root, "hello.txt"), "hello"); + const api = new FileApi({ cwd: root, homeDir: root }); + + expect(api.listRoots()).toEqual([{ id: "cwd", label: "Current project", path: root }]); + const result = await api.browse("cwd", "."); + expect(result.entries.map((entry) => entry.name)).toContain("hello.txt"); + }); + + it("uploads and downloads bytes inside the selected root", async () => { + const root = await tempDir("dreb-dashboard-files-"); + const api = new FileApi({ cwd: root, homeDir: root, maxUploadBytes: 100, maxDownloadBytes: 100 }); + + await expect(api.upload("cwd", ".", "note.bin", Buffer.from([0, 1, 2]))).resolves.toEqual({ + path: "note.bin", + size: 3, + }); + expect(await readFile(join(root, "note.bin"))).toEqual(Buffer.from([0, 1, 2])); + + const download = await api.download("cwd", "note.bin"); + expect(download.size).toBe(3); + expect(download.filename).toBe("note.bin"); + }); + + it("rejects traversal, unsafe filenames, symlink escapes, and oversized bodies", async () => { + const root = await tempDir("dreb-dashboard-files-"); + const outside = await tempDir("dreb-dashboard-outside-"); + await writeFile(join(outside, "secret.txt"), "secret"); + await symlink(outside, join(root, "escape")); + const api = new FileApi({ cwd: root, homeDir: root, maxUploadBytes: 3 }); + + await expect(api.browse("cwd", "../")).rejects.toMatchObject({ status: 403 }); + await expect(api.download("cwd", "escape/secret.txt")).rejects.toMatchObject({ status: 403 }); + await expect(api.upload("cwd", ".", "../evil.txt", Buffer.from("x"))).rejects.toMatchObject({ status: 400 }); + await expect(api.upload("cwd", ".", "big.txt", Buffer.from("toolong"))).rejects.toMatchObject({ status: 413 }); + }); +}); diff --git a/packages/dashboard/test/runtime.test.ts b/packages/dashboard/test/runtime.test.ts new file mode 100644 index 00000000..b3441eed --- /dev/null +++ b/packages/dashboard/test/runtime.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { DashboardRuntimePool, type RpcClientLike, type RuntimeEventListener } from "../src/runtime.js"; + +class FakeRpcClient implements RpcClientLike { + started = false; + stopped = false; + calls: Array<[string, unknown[]]> = []; + private listeners: RuntimeEventListener[] = []; + + async start(): Promise { + this.started = true; + } + + async stop(): Promise { + this.stopped = true; + } + + onEvent(listener: RuntimeEventListener): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((candidate) => candidate !== listener); + }; + } + + emit(event: { type: string; [key: string]: unknown }): void { + for (const listener of this.listeners) listener(event); + } + + async prompt(...args: Parameters): Promise { + this.calls.push(["prompt", args]); + } + + async steer(...args: Parameters): Promise { + this.calls.push(["steer", args]); + } + + async followUp(...args: Parameters): Promise { + this.calls.push(["followUp", args]); + } + + async abort(): Promise { + this.calls.push(["abort", []]); + } + + async getState(): Promise { + return { sessionId: "s1", thinkingLevel: "medium", isStreaming: false }; + } + + async getMessages(): Promise { + return [{ role: "user", content: "hello" }]; + } + + async setModel(...args: Parameters): Promise { + this.calls.push(["setModel", args]); + return { provider: args[0], id: args[1] }; + } + + async setThinkingLevel(...args: Parameters): Promise { + this.calls.push(["setThinkingLevel", args]); + } + + async setSteeringMode(...args: Parameters): Promise { + this.calls.push(["setSteeringMode", args]); + } + + async setFollowUpMode(...args: Parameters): Promise { + this.calls.push(["setFollowUpMode", args]); + } + + async switchSession(...args: Parameters): Promise<{ cancelled: boolean }> { + this.calls.push(["switchSession", args]); + return { cancelled: false }; + } +} + +describe("DashboardRuntimePool", () => { + it("starts injected RPC clients and forwards runtime commands", async () => { + const fake = new FakeRpcClient(); + const pool = new DashboardRuntimePool({ factory: () => fake, validateSessionProject: false }); + const runtime = await pool.getOrCreate({ id: "r1", cwd: process.cwd(), sessionPath: "/tmp/session.jsonl" }); + + expect(fake.started).toBe(true); + expect(fake.calls[0]).toEqual(["switchSession", ["/tmp/session.jsonl"]]); + await runtime.prompt("hello"); + await runtime.steer("wait"); + await runtime.followUp("next"); + await runtime.abort(); + await runtime.setModel("openai", "gpt-test"); + await runtime.setThinkingLevel("medium" as never); + await runtime.setSteeringMode("all"); + await runtime.setFollowUpMode("one-at-a-time"); + + expect(fake.calls.map(([name]) => name)).toEqual([ + "switchSession", + "prompt", + "steer", + "followUp", + "abort", + "setModel", + "setThinkingLevel", + "setSteeringMode", + "setFollowUpMode", + ]); + }); + + it("fans out RPC events to runtime listeners", async () => { + const fake = new FakeRpcClient(); + const pool = new DashboardRuntimePool({ factory: () => fake, validateSessionProject: false }); + const runtime = await pool.getOrCreate({ id: "r1", cwd: process.cwd() }); + const events: unknown[] = []; + runtime.onEvent((event) => events.push(event)); + + fake.emit({ type: "agent_chunk", text: "hi" }); + expect(events).toEqual([{ type: "agent_chunk", text: "hi" }]); + }); + + it("refuses to switch project or session inside an existing runtime", async () => { + const pool = new DashboardRuntimePool({ factory: () => new FakeRpcClient(), validateSessionProject: false }); + await pool.getOrCreate({ id: "fixed", cwd: "/tmp/project-a", sessionPath: "/tmp/a.jsonl" }); + + await expect( + pool.getOrCreate({ id: "fixed", cwd: "/tmp/project-b", sessionPath: "/tmp/a.jsonl" }), + ).rejects.toThrow("Runtime cannot switch projects"); + await expect( + pool.getOrCreate({ id: "fixed", cwd: "/tmp/project-a", sessionPath: "/tmp/b.jsonl" }), + ).rejects.toThrow("Runtime cannot switch sessions"); + }); +}); diff --git a/packages/dashboard/test/server.test.ts b/packages/dashboard/test/server.test.ts new file mode 100644 index 00000000..a25f9d61 --- /dev/null +++ b/packages/dashboard/test/server.test.ts @@ -0,0 +1,138 @@ +import { mkdtemp } from "node:fs/promises"; +import type { Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { FileApi } from "../src/files.js"; +import { DashboardRuntimePool, type RpcClientLike, type RuntimeEventListener } from "../src/runtime.js"; +import { createDashboardServer } from "../src/server.js"; +import { SessionApi, type SessionLister } from "../src/sessions.js"; + +class FakeRpcClient implements RpcClientLike { + private listeners: RuntimeEventListener[] = []; + prompts: string[] = []; + async start(): Promise {} + async stop(): Promise {} + onEvent(listener: RuntimeEventListener): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((candidate) => candidate !== listener); + }; + } + emit(event: { type: string; [key: string]: unknown }): void { + for (const listener of this.listeners) listener(event); + } + async prompt(message: string): Promise { + this.prompts.push(message); + } + async steer(): Promise {} + async followUp(): Promise {} + async abort(): Promise {} + async getState(): Promise { + return { sessionId: "s1", thinkingLevel: "medium", isStreaming: false }; + } + async getMessages(): Promise { + return []; + } + async setModel(): Promise { + return null; + } + async setThinkingLevel(): Promise {} + async setSteeringMode(): Promise {} + async setFollowUpMode(): Promise {} + async switchSession(): Promise<{ cancelled: boolean }> { + return { cancelled: false }; + } +} + +const servers: Server[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + ), + ); +}); + +describe("dashboard server", () => { + it("serves health, static assets, sessions, files, and runtime JSON routes", async () => { + const root = await mkdtemp(join(tmpdir(), "dreb-dashboard-server-")); + const fake = new FakeRpcClient(); + const sessionLister: SessionLister = { + listAll: async () => [], + listProject: async () => [], + }; + const server = await listen( + createDashboardServer({ + files: new FileApi({ cwd: root, homeDir: root }), + sessions: new SessionApi(sessionLister), + runtimes: new DashboardRuntimePool({ factory: () => fake, validateSessionProject: false }), + }), + ); + + await expectJson(`${server.url}/api/health`, { ok: true }); + const index = await fetch(`${server.url}/`); + expect(index.status).toBe(200); + expect(await index.text()).toContain("dreb dashboard"); + await expectJson(`${server.url}/api/roots`, { roots: [{ id: "cwd", label: "Current project", path: root }] }); + await expectJson(`${server.url}/api/sessions`, { sessions: [] }); + await expectJson( + `${server.url}/api/runtime/r1/prompt`, + { ok: true }, + { method: "POST", body: JSON.stringify({ cwd: root, message: "hi" }) }, + ); + expect(fake.prompts).toEqual(["hi"]); + }); + + it("fans runtime events out over SSE", async () => { + const root = await mkdtemp(join(tmpdir(), "dreb-dashboard-server-")); + const fake = new FakeRpcClient(); + const server = await listen( + createDashboardServer({ + runtimes: new DashboardRuntimePool({ factory: () => fake, validateSessionProject: false }), + }), + ); + const abort = new AbortController(); + const response = await fetch(`${server.url}/api/runtime/r1/events?cwd=${encodeURIComponent(root)}`, { + signal: abort.signal, + }); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + const reader = response.body!.getReader(); + const ready = await readChunk(reader); + expect(ready).toContain("event: ready"); + + fake.emit({ type: "agent_chunk", text: "hello" }); + const event = await readChunk(reader); + expect(event).toContain("event: agent"); + expect(event).toContain('"type":"agent_chunk"'); + abort.abort(); + }); +}); + +async function listen(server: Server): Promise<{ url: string }> { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Server did not bind to TCP"); + return { url: `http://127.0.0.1:${address.port}` }; +} + +async function expectJson(url: string, expected: unknown, init?: RequestInit): Promise { + const response = await fetch(url, { headers: { "content-type": "application/json" }, ...init }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual(expected); +} + +async function readChunk(reader: ReadableStreamDefaultReader): Promise { + const result = await Promise.race([ + reader.read(), + new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out waiting for SSE chunk")), 2000)), + ]); + if (result.done) throw new Error("SSE stream ended"); + return new TextDecoder().decode(result.value); +} diff --git a/packages/dashboard/test/sessions.test.ts b/packages/dashboard/test/sessions.test.ts new file mode 100644 index 00000000..c6bebf7e --- /dev/null +++ b/packages/dashboard/test/sessions.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { SessionApi, type SessionLister } from "../src/sessions.js"; + +const session = { + path: "/tmp/session.jsonl", + id: "s1", + cwd: "/tmp/project", + created: new Date("2026-01-01T00:00:00.000Z"), + modified: new Date("2026-01-02T00:00:00.000Z"), + messageCount: 1, + firstMessage: "hello", + allMessagesText: "hello", +}; + +describe("SessionApi", () => { + it("lists all and project sessions through the injected lister", async () => { + const calls: string[] = []; + const lister: SessionLister = { + listAll: async () => { + calls.push("all"); + return [session]; + }, + listProject: async (cwd) => { + calls.push(cwd); + return [session]; + }, + }; + const api = new SessionApi(lister); + + await expect(api.listAll()).resolves.toEqual([ + { ...session, created: "2026-01-01T00:00:00.000Z", modified: "2026-01-02T00:00:00.000Z" }, + ]); + await expect(api.listProject("/tmp/project")).resolves.toHaveLength(1); + expect(calls).toEqual(["all", "/tmp/project"]); + }); +}); diff --git a/packages/dashboard/tsconfig.build.json b/packages/dashboard/tsconfig.build.json new file mode 100644 index 00000000..6d83af04 --- /dev/null +++ b/packages/dashboard/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/client/**", "**/*.d.ts"] +} diff --git a/packages/dashboard/tsconfig.client.json b/packages/dashboard/tsconfig.client.json new file mode 100644 index 00000000..6b842edf --- /dev/null +++ b/packages/dashboard/tsconfig.client.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/static/client", + "rootDir": "./src/client", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "declaration": false, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src/client/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts"] +} diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts new file mode 100644 index 00000000..3925f062 --- /dev/null +++ b/packages/dashboard/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + testTimeout: 10000, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 6e370f9e..c5c2b9f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,9 +12,12 @@ "@dreb/agent-core/*": ["./packages/agent/src/*"], "@dreb/telegram": ["./packages/telegram/src/index.ts"], "@dreb/telegram/*": ["./packages/telegram/src/*"], + "@dreb/dashboard": ["./packages/dashboard/src/index.ts"], + "@dreb/dashboard/*": ["./packages/dashboard/src/*"], "@dreb/coding-agent": ["./packages/coding-agent/src/index.ts"], "@dreb/coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"], "@dreb/coding-agent/rpc": ["./packages/coding-agent/src/modes/rpc/index.ts"], + "@dreb/coding-agent/session-manager": ["./packages/coding-agent/src/core/session-manager.ts"], "@dreb/coding-agent/*": ["./packages/coding-agent/src/*"], "@sinclair/typebox": ["./node_modules/@sinclair/typebox"], "@dreb/tui": ["./packages/tui/src/index.ts"], @@ -24,5 +27,5 @@ } }, "include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/coding-agent/examples/**/*"], - "exclude": ["packages/web-ui/**/*", "**/dist/**", "packages/coding-agent/examples/extensions/sandbox/**"] + "exclude": ["**/dist/**", "packages/dashboard/src/client/app.ts", "packages/coding-agent/examples/extensions/sandbox/**"] } diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 5f42d762..53ed2962 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -16,6 +16,7 @@ export default defineConfig({ "packages/ai", "packages/agent", "packages/coding-agent", + "packages/dashboard", "packages/semantic-search", "packages/telegram", ],