/** * dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file * read/write, git), the /sidebar/file media route (images), and the terminal * WebSocket upgrade. Every route passes the same browser-trust fence as the * /api gateway — Host-header loopback or the connection row's `trustedHosts` * (the `dsh web` launcher derives LAN IP literals per boot) — with the * trustedHosts read live from the connection loader row so the fence never * drifts from the deployment's. * * All operations are conversation-scoped: requests carry a sessionId, the * session's authoritative cwd comes from the session store, and terminal * processes are keyed by session. */ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import { basename, dirname, extname, isAbsolute, join } from 'node:path' import type { IncomingMessage } from 'node:http' import { WebSocket, WebSocketServer } from 'ws' import type { Context } from './context-types.ts' import { Config, PrefsSchema, resolveSidebarConfig, SIDEBAR_PREFS_NS, type ResolvedSidebarConfig, type SidebarConfig, type SidebarPrefs, } from './config.ts' import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' import { decodeHtmlUrl } from './html-route.ts' import { extractFrameAncestors } from './browser-probe.ts' import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts' import * as git from './git.ts' import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts' import { registerTools } from './tools.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' export { Config } export type { SidebarConfig, ResolvedSidebarConfig } // Re-export the Context augmentation (declare module 'cordis') so consumers // `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`. // Also re-export the service descriptor types so consumers can type their // registerTab / registerFileViewer arguments without reaching into /client. export type { Context } from './context-types.ts' export type { BetterSidebarService, TabDescriptor, TabComponentProps, FileViewerDescriptor, FileViewerProps, FileFetchStrategy, } from './client/service.ts' /** Plugin identity for cordis.yml rows. */ export const name = 'dsh-better-sidebar' /** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */ export const inject = ['httpServer', 'sessions', 'loader', 'tools'] /** Content types for the media route, by extension. */ const MEDIA_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.avif': 'image/avif', '.pdf': 'application/pdf', '.html': 'text/html', '.htm': 'text/html', } /** Content type served by /sidebar/file (binary-safe fallback for unknowns). */ export function mediaTypeForPath(path: string): string { return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream' } /** The connection row's resolved trustedHosts (live read; the /api fence's own list). */ function trustedHostsOf(ctx: Context): string[] { for (const entry of ctx.loader.entries()) { if (entry.options.name === 'connection') { const config = entry.options.config as { trustedHosts?: string[] } | undefined return config?.trustedHosts ?? [] } } return [] } /** * Resolve a session's authoritative working directory. The attached session * header wins; while the session is still hydrating from persistence (the * web client attaches the current conversation a moment after page load, so * the very first sidebar requests can arrive detached) the caller's own * list-summary cwd is used; the process cwd is the last resort (blank * sessions have no cwd anywhere yet). Never throws for a missing cwd, so * explorer/git/terminal work from first paint instead of surfacing * "session ... has no working directory". */ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string { const session = ctx.sessions.get(sessionId) const headerCwd = session?.header.cwd if (headerCwd !== undefined && headerCwd !== '') return headerCwd if (clientCwd !== undefined && clientCwd !== '') { try { return requireAbsolute(clientCwd) } catch { throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`) } } return process.cwd() } /** * Resolve a path that a git command reported — `git status`/`git diff` * print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the * session cwd (a session inside a subdirectory of a repository). Absolute * paths pass through; relative ones join the repo root (falling back to the * cwd when the root cannot be resolved, e.g. a bare directory). */ async function resolveGitPath(cwd: string, raw: string): Promise { if (isAbsolute(raw)) return requireAbsolute(raw) const root = await git.repoRoot(cwd).catch(() => cwd) return requireAbsolute(join(root, raw)) } /** How many leading bytes a binary read returns for client-side detect sniffing. */ const READ_HEAD_LIMIT = 4096 /** Text read of a file with the size cap; binary detection via NUL probe. * Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64) * so the client can re-match viewers by content (`detect`). */ async function readText(path: string, readLimit: number): Promise<{ content: string truncated: boolean binary: boolean size: number head?: string }> { const info = await stat(path).catch((error: unknown) => { throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) }) if (info.isDirectory()) { throw new SidebarError('fs-error', `"${path}" is a directory`, 400) } const size = info.size const truncated = size > readLimit const handle = await open(path, 'r').catch((error: unknown) => { throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) }) try { const buffer = Buffer.alloc(Math.min(size, readLimit)) const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) const slice = buffer.subarray(0, bytesRead) const binary = slice.includes(0) const head = binary ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64') : undefined return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head } } finally { await handle.close() } } /** One API method dispatch table entry. */ type ApiMethod = (payload: unknown) => Promise | unknown /** * The live face of the side card settings namespace, bound to the settings * service when it is mounted. The DSH settings RPC domain only serves * allowlisted namespaces (api-proxy exposedNamespaces), so the client reads * and writes THIS namespace through the plugin's own fenced /sidebar routes, * which call the seam in-process — no configuration-client gate involved. */ export interface SidebarSettingsFace { /** The current resolved value + revision (undefined while the settings service is absent). */ get(): { value?: unknown; revision?: number } /** Merge a patch (revision-guarded) and return the fresh resolved view. */ update(patch: Record, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }> } /** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */ function buildApi( ctx: Context, ptyManager: PtyManager, agentPtyRegistry: AgentPtyRegistry, resolved: ResolvedSidebarConfig, getSettings: () => SidebarSettingsFace | undefined, ): Record { const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => { const sessionId = requireString(payload, 'sessionId') const record = payload as { cwd?: unknown } | null const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) } } return { 'session.cwd': (payload) => { const { sessionId, cwd } = cwdOf(payload) return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null } }, 'fs.tree': async (payload) => { const { cwd } = cwdOf(payload) const record = payload as { path?: unknown } const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path')) return listDirectory(target, resolved.listLimit) }, 'fs.read': async (payload) => { const { cwd } = cwdOf(payload) // Relative paths are git-derived (status/diff report repo-root-relative // names; the untracked diff view reads the file through this route). const path = await resolveGitPath(cwd, requireString(payload, 'path')) const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit) if (binary) return { kind: 'binary', size, truncated, head } return { kind: 'text', content, truncated } }, 'fs.write': async (payload) => { const { cwd } = cwdOf(payload) const path = requireAbsolute(requireString(payload, 'path')) const content = requireString(payload, 'content') const tmp = `${path}.dsh-sidebar-tmp-${process.pid}` try { await mkdir(dirname(path), { recursive: true }) await writeFile(tmp, content, 'utf8') await rename(tmp, path) } catch (error) { await rm(tmp, { force: true }).catch(() => {}) throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400) } return { ok: true } }, 'git.status': async (payload) => { const { cwd } = cwdOf(payload) return git.status(cwd) }, 'git.diff': async (payload) => { const { cwd } = cwdOf(payload) const record = payload as { path?: unknown; staged?: unknown } const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path')) return { diff: await git.diff(cwd, path, record.staged === true) } }, 'git.stage': async (payload) => { const { cwd } = cwdOf(payload) const record = payload as { path?: unknown } const path = record.path === undefined ? undefined : requireString(payload, 'path') await git.stage(cwd, path) return { ok: true } }, 'git.unstage': async (payload) => { const { cwd } = cwdOf(payload) const record = payload as { path?: unknown } const path = record.path === undefined ? undefined : requireString(payload, 'path') await git.unstage(cwd, path) return { ok: true } }, 'git.commit': async (payload) => { const { cwd } = cwdOf(payload) const message = requireString(payload, 'message') await git.commit(cwd, message) return { ok: true } }, 'git.branch': async (payload) => { const { cwd } = cwdOf(payload) return git.branches(cwd) }, 'git.checkout': async (payload) => { const { cwd } = cwdOf(payload) await git.checkout(cwd, requireString(payload, 'branch')) return { ok: true } }, 'git.log': async (payload) => { const { cwd } = cwdOf(payload) const record = payload as { count?: unknown; skip?: unknown } const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0 ? record.count : undefined const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0 ? record.skip : undefined return git.log(cwd, count, skip) }, 'git.commit-diff': async (payload) => { const { cwd } = cwdOf(payload) return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) } }, 'git.discard': async (payload) => { const { cwd } = cwdOf(payload) await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path'))) return { ok: true } }, 'git.revert': async (payload) => { const { cwd } = cwdOf(payload) await git.revert(cwd, requireString(payload, 'hash')) return { ok: true } }, 'git.cherry-pick': async (payload) => { const { cwd } = cwdOf(payload) await git.cherryPick(cwd, requireString(payload, 'hash')) return { ok: true } }, 'git.show': async (payload) => { const { cwd } = cwdOf(payload) const path = await resolveGitPath(cwd, requireString(payload, 'path')) const rev = requireString(payload, 'rev') return { content: await git.show(cwd, rev, path) } }, // Release a terminal immediately. The WebSocket close frame already does // this while the socket is open; this route covers the tab-close that // happens while the socket is down (reconnect loop), so a closed tab can // never hold the per-session quota until the reconnect grace expires. 'pty.close': (payload) => { const sessionId = requireString(payload, 'sessionId') const tab = requireString(payload, 'tab') ptyManager.close(`${sessionId}:${tab}`) return { ok: true } }, // Release an agent terminal by uuid. The WS close frame already does // this while the socket is open; this route covers the tab-close that // happens while the socket is down (reconnect loop) so a closed agent // tab never leaves a zombie pty behind. Idempotent. 'agent-pty.close': (payload) => { const uuid = requireString(payload, 'uuid') agentPtyRegistry.close(uuid) return { ok: true } }, // The side card preferences. The settings service is optional in the // composition; while absent the routes report undefined and the client // keeps the schema defaults. Writes are revision-guarded: a stale editor // is refused with settings-conflict so a concurrent change is never // silently overwritten (mirror of the settings seam's own guard). 'settings.get': () => { const settings = getSettings() return settings?.get() ?? { value: undefined, revision: undefined } }, 'settings.update': async (payload) => { const settings = getSettings() if (settings === undefined) { throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503) } const record = payload as { patch?: unknown; expectedRevision?: unknown } | null const patch = record?.patch if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) { throw new SidebarError('bad-request', 'patch must be a plain object') } const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined try { return await settings.update(patch as Record, expectedRevision) } catch (error) { if (error instanceof SettingsConflictError) { throw new SidebarError('settings-conflict', error.message, 409) } throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400) } }, // Probe a URL's RESPONSE HEADERS so the sidebar browser can explain an // iframe refusal: X-Frame-Options / CSP frame-ancestors are exactly the // signals the browser enforces when it refuses to embed a site. The // probe is display-only (headers back to the caller), restricted to // http(s) non-loopback URLs with a hard timeout, and gated by the same // trust fence as every other route — a cross-site page cannot reach it. 'browser.probe': async (payload) => { const raw = requireString(payload, 'url') let parsed: URL try { parsed = new URL(raw) } catch { throw new SidebarError('bad-request', 'invalid url', 400) } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new SidebarError('bad-request', 'only http/https urls can be probed', 400) } // Mirror the browser tab's address-bar policy: loopback stays unreachable // from the sidebar, so probing it would leak nothing the tab could use. if (isLoopbackHostname(parsed.hostname)) { throw new SidebarError('bad-request', 'local addresses are not probed', 400) } const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 8000) try { let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal }) // Some servers answer HEAD with 405/501; retry once as GET (the // body is discarded — only the headers matter). if (response.status === 405 || response.status === 501) { response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal }) } const csp = response.headers.get('content-security-policy') const frameAncestors = extractFrameAncestors(csp) const xFrameOptions = response.headers.get('x-frame-options') return { reachable: true, url: response.url, status: response.status, ...(xFrameOptions !== null ? { xFrameOptions } : {}), ...(frameAncestors !== undefined ? { frameAncestors } : {}), } } catch { // DNS / TLS / connection / timeout: nothing to judge — the client // keeps the plain iframe. return { reachable: false } } finally { clearTimeout(timer) } }, } } /** * Plugin body: mount the fenced routes and the pty lifecycle. * @param ctx - host plugin context (httpServer, sessions, loader). * @param config - deployment-provided limits; the Loader validates against * {@link Config} and fills defaults, direct callers get them from * {@link resolveSidebarConfig}. */ export function apply(ctx: Context, config?: SidebarConfig): void { // pnpm strips the executable bit from node-pty's prebuilt spawn-helper; // restore it before any terminal can spawn (idempotent). ensureSpawnHelper() const resolved = resolveSidebarConfig(config) const trustedHosts = trustedHostsOf(ctx) const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts) const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession) // The agent-owned terminal registry: parallel to the UI-tab ptyManager, // keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`, // uncapped, and torn down with the plugin. The model creates terminals here // through the terminal_create tool; the sidebar view attaches through the // same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=... const agentPtyRegistry = new AgentPtyRegistry(defaultShell()) // ── User-facing "Side card" preferences ────────────────────────────────── // Register the namespace with the settings provider so the Settings page // (client half) can render and persist the new-conversation defaults. The // DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to // configuration clients, so the client reaches this namespace through the // plugin's own fenced routes below ('settings.get'/'settings.update'), // which call the seam in-process. Deployments without a settings service // simply never fill the face and the client falls back to the defaults. let settingsFace: SidebarSettingsFace | undefined // The model-facing terminal tools are gated on the side-card setting // `agentTerminalTools` (default off): nothing is injected until the user // turns the feature on, and turning it off mid-session unregisters the // tools and releases the agent terminals they created. let toolsDisposers: (() => void) | null = null const syncToolsGate = (scope: { get(): SidebarPrefs }): void => { if (scope.get().agentTerminalTools) { if (toolsDisposers === null) { toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId)) } } else if (toolsDisposers !== null) { toolsDisposers() toolsDisposers = null // The feature is off: release every agent terminal the model created // while it was on (they are only reachable through the tools). The // registry change fires the push, so the sidebar reconciles them away. agentPtyRegistry.disposeAll() } } ctx.inject(['settings'], (sctx) => { const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS) // The structural settings mirror types `schema` as unknown, so the // generic is not inferred here; the real service resolves it from the // schemastery schema (PrefsSchema) — narrow the owner scope explicitly. const scope = sctx.settings.register(ns, PrefsSchema) as { get(): SidebarPrefs watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void } const viewOf = (): { value?: unknown; revision?: number } => { const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns) return descriptor === undefined ? { value: undefined, revision: undefined } : { value: descriptor.value, revision: descriptor.revision } } settingsFace = { get: viewOf, update: async (patch, expectedRevision) => { await sctx.settings.update(ns, patch, expectedRevision) return viewOf() }, } // Register (or unregister) the terminal tools from the current setting, // and keep them in sync with every settings commit. syncToolsGate(scope) scope.watch(() => { syncToolsGate(scope) }) }) // ── JSON API ──────────────────────────────────────────────────────────── const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace) ctx.effect(() => ctx.httpServer.register({ kind: 'prefix', path: '/sidebar/api', handler: async (req, res) => { if (!fence(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } }) return } if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } }) return } const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined if (method === undefined || method.includes('/')) { writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404)) return } try { const payload = await readJsonBody(req) const handler = api[method] if (handler === undefined) { throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404) } writeOk(res, await handler(payload)) } catch (error) { writeError(res, error) } }, }), 'dsh-better-sidebar: /sidebar/api routes') // ── Media route (images for the editor) ───────────────────────────────── ctx.effect(() => ctx.httpServer.register({ kind: 'prefix', path: '/sidebar/file', handler: async (req, res) => { if (!fence(req)) { res.writeHead(403) res.end('forbidden') return } if (req.method !== 'GET') { res.writeHead(405) res.end() return } try { const url = new URL(req.url ?? '/', 'http://dsh.internal') const sessionId = url.searchParams.get('sessionId') const raw = url.searchParams.get('path') if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required') const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) const path = requireAbsolute(raw) if (!isWithin(cwd, path)) { // Only files under the session cwd are served as media (the editor // opens images from the explorer; produced files go through read). // isWithin (not a raw startsWith) so case-mismatched Windows paths // and mixed separators cannot be misclassified. throw new SidebarError('fs-error', 'media path outside the session working directory', 403) } const info = await stat(path) if (!info.isFile() || info.size > resolved.mediaLimit) { throw new SidebarError('fs-error', 'not a file or too large', 400) } const type = mediaTypeForPath(path) const body = await readFile(path) // Raw bytes either way (binary-safe); ?download=1 switches the // disposition so the browser saves the file instead of showing it. const headers: Record = { 'content-type': type, 'cache-control': 'no-cache' } if (url.searchParams.get('download') === '1') { headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}` } res.writeHead(200, headers) res.end(body) } catch (error) { writeError(res, error) } }, }), 'dsh-better-sidebar: /sidebar/file media route') // ── HTML preview route (sandboxed HTML + its relative assets) ─────────── // Serves files under the session cwd for the built-in HTML previewer. The // URL is path-encoded (see html-route.ts) so the previewed page's relative // assets (./style.css, img/x.png) resolve back into this route with the // session scope intact — a query-encoded URL would drop the scope when the // browser resolves relatives. Every response carries the CSP `sandbox` // directive: inside the editor's iframe the sandbox ATTRIBUTE is the // boundary, this header is defense-in-depth so even a top-level load of // the URL (e.g. a popup opened by a previewed page) stays in an opaque // origin with no same-origin access to the GUI. ctx.effect(() => ctx.httpServer.register({ kind: 'prefix', path: '/sidebar/html', handler: async (req, res) => { if (!fence(req)) { res.writeHead(403) res.end('forbidden') return } if (req.method !== 'GET') { res.writeHead(405) res.end() return } try { const url = new URL(req.url ?? '/', 'http://dsh.internal') const decoded = decodeHtmlUrl(url.pathname) if (!decoded.ok) { writeError(res, new SidebarError('bad-request', decoded.message, decoded.status)) return } const { sessionId, path } = decoded.ref // The session's authoritative cwd (client cwd cannot ride in the URL // — the path encoding has no query; a detached first request falls // back to the process cwd and is normally refused by isWithin, same // semantics as the media route's fallback). const cwd = sessionCwdOf(ctx, sessionId) const absolute = requireAbsolute(path) if (!isWithin(cwd, absolute)) { throw new SidebarError('fs-error', 'html path outside the session working directory', 403) } const info = await stat(absolute) if (!info.isFile() || info.size > resolved.mediaLimit) { throw new SidebarError('fs-error', 'not a file or too large', 400) } const type = mediaTypeForPath(absolute) const body = await readFile(absolute) res.writeHead(200, { 'content-type': type, 'cache-control': 'no-cache', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', // The sandbox directive (no allow-same-origin → opaque origin) is // the previewer's security boundary even for top-level loads; // object-src 'none' blocks plugin embeds. 'content-security-policy': "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'", }) res.end(body) } catch (error) { writeError(res, error) } }, }), 'dsh-better-sidebar: /sidebar/html preview route') // ── Terminal WebSocket ────────────────────────────────────────────────── // One upgrade endpoint serves both UI-tab terminals (?tab=...) and // agent-owned terminals (?uuid=...). The two paths attach to different // registries but share the wire protocol: input frames are raw text, // resize frames are JSON `{type:'resize',cols,rows}`, and a close frame // `{type:'close'}` releases the underlying pty (immediate for agent // terminals, scheduled-0 for UI tabs which keep the same reconnect grace // contract the host has always had). const wss = new WebSocketServer({ noServer: true }) ctx.effect(() => ctx.httpServer.registerUpgrade({ path: '/sidebar/ws/terminal', handler: (req, socket, head) => { if (!fence(req)) { socket.destroy() return } wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved) }) }, }), 'dsh-better-sidebar: terminal WebSocket') // ── Agent terminals push WebSocket ────────────────────────────────────── // Pushes the live list of agent terminals for one session to the sidebar // view: the client mirrors the list into tabs (id `agent:`, // title from the agent's `terminal_create` call). The host fires on every // create / close / exit; the client reconciles by adding tabs for new // uuids and dropping tabs whose uuids disappeared (the user closing a tab // sends `{type:'close'}` on the terminal WS, which kills the pty, which // fires a change here, which converges the view). const agentListWss = new WebSocketServer({ noServer: true }) ctx.effect(() => ctx.httpServer.registerUpgrade({ path: '/sidebar/ws/agent-terminals', handler: (req, socket, head) => { if (!fence(req)) { socket.destroy() return } agentListWss.handleUpgrade(req, socket, head, (ws) => { void attachAgentList(agentPtyRegistry, ws, req) }) }, }), 'dsh-better-sidebar: agent-terminals push WebSocket') ctx.effect(() => () => { toolsDisposers?.() ptyManager.disposeAll() agentPtyRegistry.disposeAll() wss.close() agentListWss.close() }, 'dsh-better-sidebar: teardown') } /** Push the live agent-terminal list for one session to a connected sidebar view. */ async function attachAgentList( registry: AgentPtyRegistry, ws: WebSocket, req: IncomingMessage, ): Promise { try { const url = new URL(req.url ?? '/', 'http://dsh.internal') const sessionId = url.searchParams.get('sessionId') if (sessionId === null) { ws.close(1008, 'sessionId is required') return } const send = (): void => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(registry.list(sessionId))) } } send() const unsubscribe = registry.subscribe(send) ws.on('close', () => { unsubscribe() }) ws.on('error', () => { unsubscribe() }) } catch (error) { ws.close(1011, error instanceof Error ? error.message : String(error)) } } /** * Wire one terminal socket to its pty: replay transcript, pump both ways. * Two attach modes share the wire protocol: * - `?uuid=...` attaches to an agent-owned terminal (created by the * `terminal_create` tool). The close frame kills the pty immediately * (the agent's terminal closes when the user closes the sidebar tab); a * bare socket drop (refresh, tab switch) leaves the pty alive for the * reconnect grace, exactly like UI-tab terminals. * - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user * created it from the + menu). The close frame schedules a 0-ms close * (the host's reconnect grace keeps the shell alive across a refresh). */ async function attachTerminal( ctx: Context, ptyManager: PtyManager, agentPtyRegistry: AgentPtyRegistry, ws: WebSocket, req: IncomingMessage, resolved: ResolvedSidebarConfig, ): Promise { try { const url = new URL(req.url ?? '/', 'http://dsh.internal') const uuid = url.searchParams.get('uuid') if (uuid !== null) { const handle = agentPtyRegistry.get(uuid) if (handle === undefined) { ws.close(1011, `agent terminal "${uuid}" not found`) return } pumpAgentTerminal(agentPtyRegistry, handle, ws) return } const sessionId = url.searchParams.get('sessionId') const tabId = url.searchParams.get('tab') if (sessionId === null || tabId === null) { ws.close(1008, 'either ?uuid or ?sessionId+?tab are required') return } const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24) // Replay the transcript, then follow live output. if (handle.transcript !== '') ws.send(handle.transcript) const onData = (data: string): void => { if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) { ws.send(data) } } const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => { onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`) } const dataSub = handle.pty.onData(onData) const exitSub = handle.pty.onExit(onExit) ws.on('message', (data) => { const text = data.toString('utf8') // Control frames are JSON with a known shape; anything else (including // JSON that is not a recognized control) is terminal input, verbatim. let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null try { const parsed: unknown = JSON.parse(text) if (parsed !== null && typeof parsed === 'object') { control = parsed as { type?: unknown; cols?: unknown; rows?: unknown } } } catch { // Not JSON: terminal input. } if (control !== null && control.type === 'close') { // The owning tab was closed: release the quota immediately. ptyManager.scheduleClose(handle.key, 0) return } if (handle.exited) return if ( control !== null && control.type === 'resize' && typeof control.cols === 'number' && typeof control.rows === 'number' ) { const dims = clampDims(control.cols, control.rows) handle.pty.resize(dims.cols, dims.rows) } else { handle.pty.write(text) } }) ws.on('close', () => { dataSub.dispose() exitSub.dispose() // A bare socket drop (refresh, tab switch) leaves the process alive // for a grace period so a quick reconnect keeps it; the reconnect's // open() cancels the pending close. ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs) }) } catch (error) { ws.close(1011, error instanceof Error ? error.message : String(error)) } } /** * Pump one agent terminal's pty to a connected view. The close frame kills * the pty immediately (the agent's terminal closes when the user closes the * sidebar tab); a bare socket drop leaves the pty alive — the agent owns * the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or * plugin teardown kills it. */ function pumpAgentTerminal( registry: AgentPtyRegistry, handle: AgentTerminalHandle, ws: WebSocket, ): void { if (handle.transcript !== '') ws.send(handle.transcript) const onData = (data: string): void => { if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) { ws.send(data) } } const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => { onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`) } const dataSub = handle.pty.onData(onData) const exitSub = handle.pty.onExit(onExit) ws.on('message', (data) => { if (handle.exited) return const text = data.toString('utf8') let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null try { const parsed: unknown = JSON.parse(text) if (parsed !== null && typeof parsed === 'object') { control = parsed as { type?: unknown; cols?: unknown; rows?: unknown } } } catch { // Not JSON: terminal input. } if (control !== null && control.type === 'close') { // The user closed the sidebar tab: kill the pty immediately. The // agent's next terminal_list / terminal_send will see it gone. registry.close(handle.uuid) return } if ( control !== null && control.type === 'resize' && typeof control.cols === 'number' && typeof control.rows === 'number' ) { const dims = clampDims(control.cols, control.rows) handle.pty.resize(dims.cols, dims.rows) } else if (control === null) { // Raw text input (a JSON-looking string the pty would have received // verbatim is reachable in theory but is exotic for an agent terminal; // preserve the UI-tab semantics and forward as input). handle.pty.write(text) } // An unrecognized JSON control frame is dropped (the UI-tab path also // treats non-resize JSON controls as input, but for an agent terminal // there is no realistic input that is also valid JSON). }) ws.on('close', () => { dataSub.dispose() exitSub.dispose() // A bare socket drop (refresh, tab switch) leaves the agent's pty alive. // The agent owns the lifetime: only `terminal_close`, a `{type:'close'}` // frame, or plugin teardown kills it. A reconnecting view reattaches the // same shell and gets the full transcript replayed. }) }