/** * @fileoverview Tiny standalone HTTP server for the visualization frontend. * * We deliberately avoid Express/Fastify to keep the dependency tree of this * plugin at zero. Routes: * GET / → index of recent task sessions * GET /task/:taskId → detail page (mermaid flow + tables) * GET /api/sessions → JSON list of sessions * GET /api/sessions/:taskId → JSON detail of one session * GET /api/events → Server-Sent Events stream (real-time push) * GET /static/* → static frontend assets (CSS/JS) * * The server is started/stopped via `TaskShowServer.start()` / * `.stop()`. When the configured port is busy we probe `port + 1`, * `port + 2`, ... in ascending order until a free TCP port is found — * never falling back to an OS-assigned ephemeral port (`listen(0)`). * That keeps the bound port predictable for documentation, fire-and-forget * scripts and ad-hoc coordination between processes. */ import * as http from "node:http"; import type { TaskSession, TaskShowConfig } from "./types.js"; import type { ToolCallCollector } from "./collector.js"; import type { EventBus } from "./event-bus.js"; import { OperationsCache } from "./operations-cache.js"; import type { CachedEnvelope } from "./cli-tasks-adapter.js"; import { TasksTreeCache } from "./tasks-tree-cache.js"; import { TaskSessionStore } from "./task-session-store.js"; /** Public-facing server info (returned to whoever called `start`). */ export interface ServerInfo { host: string; port: number; url: string; server: http.Server; /** True if the original port was busy and we had to pick a free one. */ portRewritten: boolean; } /** Upper bound of the TCP user port range. Probing never wraps past this. */ export declare const MAX_TCP_PORT = 65535; /** Smallest legal TCP user port (avoid binding to well-known 0..1023). */ export declare const MIN_TCP_PORT = 1024; /** Minimal logger interface used by `listenWithProbing`. */ export type PortProbingLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void; }; /** Outcome of `listenWithProbing`. */ export interface PortProbingResult { /** The port that was actually bound. */ port: number; /** `true` iff we ended up on a port other than the requested start. */ portRewritten: boolean; } /** * Try `server.listen(port, host, …)` starting at `startPort`. If the OS * reports `EADDRINUSE`, increment the port and retry. Stops at 65535 — * never wraps, never falls back to an OS-assigned ephemeral port. * * Only `EADDRINUSE` is retried. Any other listen error (e.g. `EACCES`, * `ENOTSUP`) is surfaced as a normal rejection — the caller is * responsible for closing the server in that case (this helper does * not own the server's lifecycle). * * Listener hygiene: each attempt attaches a fresh one-shot `error` * listener and detaches it as soon as we move on (success, permanent * failure, or exhaustion). This guarantees no duplicate resolve / * reject calls and no listener leaks even when the helper is called * many times on the same server. */ export declare function listenWithProbing(args: { server: http.Server; host: string; startPort: number; logger?: PortProbingLogger; }): Promise; export declare class TaskShowServer { private server; private readonly cfg; private readonly collector; private readonly eventBus; private readonly publicRoot; private readonly logger; /** * v1.1.0+: the plugin version string included on every SSE event we * broadcast. Lets clients (and humans staring at curl output) tell * which build is serving the stream. Defaults to the literal * `"1.1.0"`; the plugin entry point overrides it with its own * `version` field. */ private readonly pluginVersion; /** Optional operations cache (v0.7.0+). Used by /api/tasks/:id/operations. */ private readonly operationsCache; /** Optional tasks tree cache (v0.8.0+). Used by /api/tasks/tree. */ private readonly tasksTreeCache; /** v0.9.0+: optional session-scoped task store. When set, GET / renders the * session forest; otherwise the legacy cross-host tree view is used. */ private readonly sessionStore; /** v2.5.0+: ChatProcessManager singleton (null when chat is disabled). */ private readonly chatManager; constructor(opts: { cfg: TaskShowConfig; collector: ToolCallCollector; /** EventBus for SSE broadcasts. Defaults to a new EventBus if omitted. */ eventBus?: EventBus; /** Absolute or relative path to the directory with public/index.html. */ publicDir?: string; logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void; }; metaUrl?: string; /** v0.7.0+: pre-built OperationsCache. If omitted, /api/tasks/:id/operations returns 503. */ operationsCache?: OperationsCache; /** v0.8.0+: pre-built TasksTreeCache. If omitted, /api/tasks/tree returns 503. */ tasksTreeCache?: TasksTreeCache; /** v0.9.0+: pre-built TaskSessionStore. When set, GET / uses session forest. */ sessionStore?: TaskSessionStore; /** v1.1.0+: plugin version stamped on every SSE event we broadcast. */ pluginVersion?: string; /** * v2.5.0+: chat feature config. When `chatOptions.enabled` is true * (default), the server mounts `/api/chat/*` routes and renders the * home chat panel. When false, the chat UI is hidden and the API * endpoints return 503 — useful for environments without a built * `roy-agent` CLI on disk. */ chatOptions?: { cliPath: string; maxConcurrent?: number; defaultTimeoutMs?: number; enabled?: boolean; /** * v2.5.7+: extra args appended to every spawned CLI invocation. * Used by tests to wire `fake-act.mjs --mode=multi` without * having to set up a real `roy-agent` CLI. */ extraArgs?: string[]; }; }); /** * Get the SSE event bus (for tests / health checks). */ getEventBus(): EventBus; /** * v2.3.0+: Expose the underlying `ToolCallCollector` so test * fixtures (and future host integrations) can seed sessions, * including sessions with `context.worktree` set, before * exercising the HTTP endpoints. Returns the same singleton * the server uses internally. */ getCollector(): ToolCallCollector; /** * Start the listener. The promise resolves once the server is bound — but * the caller can race against `getInfo()` immediately afterwards. * * Port handling: we attempt to bind on `cfg.port` first; if the OS reports * `EADDRINUSE` we probe `cfg.port + 1`, `cfg.port + 2`, ... in ascending * order until a free port is found. The probing never wraps past 65535 * and never falls back to `listen(0)`. `ServerInfo.portRewritten` is * `true` whenever the bound port differs from `cfg.port`. */ start(): Promise; /** * Read-only accessor used by the plugin entry point to compute the per-task * URL after the server has been bound. */ getInfo(): { host: string; port: number; url: string; } | null; /** * Stop the listener; safe to call multiple times. */ stop(): Promise; private handle; /** * SSE handler — sets the SSE headers, sends an initial snapshot, and * subscribes the response to the event bus. The bus will write frames * until the socket closes; the `res.on("close", ...)` listener removes * the subscriber. */ /** * Serve a small text file's content for the "View file" link next to * write_file / edit_file / etc. tool calls in the detail page. * * Security: * - Path must be non-empty. * - File must exist and be a regular file. * - Hard size cap of 256 KiB (configurable via `?max=N` query). * - Sandbox enforcement: file must be inside one of the allowed * directories (defaults to cwd + $HOME; configurable via * `ts.config.fileSandboxPaths`). */ /** * Allocate a fresh chat sessionId. The id is just a string — the * underlying SQLite store lives inside the `roy-agent act` subprocess * (we never persist anything ourselves; on the next `act -s ` call * the CLI resurrects the session from disk if it still exists). * * v2.5.7: Frontend clients (chat-panel.js) self-produce a * `ts_session_` id and POST it here for confirmation. We * accept it as long as it matches the `roy-agent act` validator * (`/^[a-zA-Z0-9_-]{1,128}$/`); malformed values are replaced * with a server-minted `ts_session_` so the frontend * never has to retry. */ private handleChatStart; /** * Server-side fallback: produce a fresh `ts_session_`-shaped * id. Mirrors the helper in `public/chat-panel.js` so the same * namespace is shared whether the id originates client-side or * server-side. */ private generateTaskShowSessionId; /** * SSE endpoint. Accepts `application/json` POST body and emits a series * of SSE frames: `event: chunk` for every ChatChunk, then * `event: done` once the subprocess exits (or `event: error` on * failure). */ private handleChatMessage; /** Abort an in-flight chat query for the given session. */ private handleChatAbort; /** * v2.5.0+ REQ-3: task-aware chat endpoint. * * Resolves the task by id (from the collector session OR the * operations cache), builds an additionInfo block from the task * context, wraps the user's message, and forwards it to the same * ChatProcessManager REQ-2 already plumbs. The sessionId is the * deterministic `task-${taskId}` so reloads land on the same * SQLite-backed conversation. * * Returns 404 when the task is unknown — we deliberately do NOT * answer off-topic questions for tasks that don't exist (which is * the same behaviour REQ-2 already shows for unknown chat * sessions, just gated by a task id instead of a random uuid). */ private handleTaskChatMessage; /** * Resolve the task context for a chat turn. Returns the * TaskChatContextBuilder input or null when the task is unknown. * Prefers the live collector session (rich `context.worktree` + * current status); falls back to the operations cache envelope * for tasks that have already been finalized. */ private resolveTaskChatContext; private handleFileContent; /** * v2.0.0: GET /api/task/:id/file-tree — returns the git-tracked * file list as `{ files: string[] }`. * * v2.3.0: now worktree-aware. When `taskSession` is provided AND * carries `context.worktree`, we run `git ls-files` inside that * directory (so the sidebar matches the files the agent is * editing). When the task id doesn't resolve to a known session * OR the session has no worktree, we fall back to `process.cwd()` * (the legacy project-wide behaviour). The task id is still * included in the URL so the route reads naturally: "give me * the file tree for the task's project". * * Caching: a 30-second in-memory cache keyed by cwd. Beyond that we * re-spawn `git ls-files -z`. The cache key is the resolved cwd so a * worktree-mode runner gets its own bucket. */ private handleFileTree; /** * v2.5.12 (Task #2951): best-effort fetch of the cached task for * the file-tree endpoint. The cache is consulted only when the * live collector session lacks `context.worktree` — a fresh hit * gives us `task.projectPath`, the same field the host CLI * populates when the task is created. A stale or missing entry * is harmless: the resolver falls through to `process.cwd()`. * * `timeoutMs` caps the wait (default: 200 ms). A slow / failing * CLI must not block the SSR render — callers fall back to the * cwd-based tree and (when the patch lands) the next request * benefits from the warmer cache. */ private loadCachedTaskForFileTree; private handleSSE; /** * Render the index page. v0.9.0+ selects the session-scoped forest when * a `TaskSessionStore` is wired; otherwise it falls back to the legacy * cross-host tree view. */ private sendIndex; /** * Task #2893: render a minimal HTML page explaining the session * forest couldn't be loaded because the host CLI failed. Returning * a 200 page (not 500) keeps the plugin live and the interactive * TUI responsive -- the user can still navigate to per-task URLs via * `/task/` and operator logs already contain the precise error. */ private renderIndexFallbackHtml; /** * Handle /api/tasks/:id/operations — fetch the parsed operations envelope * via the operations cache. Maps typed errors to HTTP status codes and * sanitizes the payload to the public schema (no raw CLI output). */ private handleOperations; /** * Handle /api/tasks/tree — fetch a parsed task tree from the host CLI * via the TasksTreeCache. Query parameters map directly to CLI flags: * - status (todo | active | completed | paused | cancelled) * - priority (low | medium | high) * - type (normal | cycle | longterm) * - rootId (positive integer) * - includeArchived (boolean) * - stale=0 forces a fresh fetch * * Errors are mapped to HTTP status codes (503 when the cache isn't * configured, 502 on CLI / parse failures, 500 on the unexpected). */ private handleTasksTree; /** * Send a 404 page that still includes nav + helpful hint. */ private sendNotFound; private serveStatic; private json; private text; private sendHtml; /** * v2.0.3: content-negotiated error response for /api/file-content. * * When the client sends `Accept: text/html` (typical for "open in * new tab" navigation from the View file link), we render a styled * HTML error page with status code + error icon + actionable * links. Otherwise we fall back to the original plain-text body so * the inline `fetch()` in tool-call-detail.js still gets a parseable * signal (it checks `res.ok`, not the body). */ private respondError; } /** * Build a synthetic TaskSession from an operations-cache envelope. * * Task #2928: Tasks created via `task_create` from a subagent have * no recorded tool calls — the collector never sees them. But the * host CLI's `tasks get --operations` envelope still carries the * task metadata + operations pipeline. We synthesize a minimal * TaskSession from that envelope so `/task/:id` returns a useful * detail page (lifecycle + title) instead of a 404. * * The synthetic session has zero tool calls (the operations pipeline * is rendered from the operations array client-side via the existing * `task-operations.js` widget). startedAt / endedAt are derived from * the first / last operation timestamps. */ export declare function operationsEnvelopeToTaskSession(envelope: CachedEnvelope): TaskSession; /** * Build the per-task HTML page. The page relies on mermaid.js loaded from a * CDN so we don't need to ship a vendored copy. * * v2.5.12 (Task #2951): accepts an optional `cachedTask` so the * file-tree sidebar can fall back to `task.projectPath` when the * live session lacks `context.worktree`. Without this fallback the * user sees files from the plugin host's `process.cwd()` (the wrong * repo) on tasks created via `task_create`. */ export declare function renderTaskPage(session: TaskSession, opts?: { cachedTask?: { projectPath?: string; } | null; }): string; export declare function renderIndexPage(sessions: TaskSession[]): string; //# sourceMappingURL=server.d.ts.map