Files
apes-sidebar/src/index.ts
T
Menghuan1918 069c754877 feat: Git 面板真正的 diff 显示 + VSCode 式历史与右键高级操作,v0.3.0
- 真正的统一 diff 显示:hunk 头 + 双行号 + 红绿着色,长行换行无横向滚动,untracked 全新增视图
- 点击变更/历史自动打开独立 diff tab(首次在下半栏新增分栏,同变更去重聚焦,临时页签刷新不恢复)
- 历史行:分支徽章 + 作者/相对时间,懒加载分页(20 条/页 + 加载更多)
- 右键菜单:文件行(打开/暂存/放弃更改/复制路径)、历史行(复制哈希/信息/还原/捡取),破坏性操作确认弹窗
- 修复暂存/未暂存分类错误(' M' 被误标为已暂存导致 diff 恒空)
- 修复 git 相对路径解析(会话在仓库子目录时 diff/discard/show/read 可用)
- host 新增 git.commit-diff / git.discard / git.revert / git.cherry-pick 路由与 log 分页
2026-08-10 22:29:24 +08:00

731 lines
32 KiB
TypeScript

/**
* 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 { isTrustedApiRequest } 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 }
/** 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<string, string> = {
'.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',
}
/** 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<string> {
if (isAbsolute(raw)) return requireAbsolute(raw)
const root = await git.repoRoot(cwd).catch(() => cwd)
return requireAbsolute(join(root, raw))
}
/** Text read of a file with the size cap; binary detection via NUL probe. */
async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
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)
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size }
} finally {
await handle.close()
}
}
/** One API method dispatch table entry. */
type ApiMethod = (payload: unknown) => Promise<unknown> | 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<string, unknown>, 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<string, ApiMethod> {
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 } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated }
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<string, unknown>, 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)
}
},
}
}
/**
* 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<string, string> = { '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')
// ── 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:<uuid>`,
// 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<void> {
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<void> {
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.
})
}