Merge pull request #147 from omdsh-dev/fix/node-pty-load-failure-repair

fix: node-pty 加载失败降级为终端修复提示,不再拖垮 server(#140)
This commit is contained in:
Atlas Zeng
2026-08-17 00:06:20 +08:00
committed by GitHub
parent a0918496f3
commit 8d9ab1cd2f
19 changed files with 956 additions and 134 deletions
+59 -16
View File
@@ -37,6 +37,12 @@ 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 {
DSH_NODE_PTY_RANGE,
depsStatus,
loadNodePty,
PTY_DEPS_MISSING,
} from './pty-deps.ts'
import { registerTools } from './tools.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
@@ -179,8 +185,8 @@ export interface SidebarSettingsFace {
/** 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,
ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry | null,
resolved: ResolvedSidebarConfig,
getSettings: () => SidebarSettingsFace | undefined,
): Record<string, ApiMethod> {
@@ -313,7 +319,9 @@ function buildApi(
'pty.close': (payload) => {
const sessionId = requireString(payload, 'sessionId')
const tab = requireString(payload, 'tab')
ptyManager.close(`${sessionId}:${tab}`)
// Degraded mode (node-pty unavailable): no live pty can exist, so a
// no-op ok is the honest answer — never an error the client must show.
ptyManager?.close(`${sessionId}:${tab}`)
return { ok: true }
},
// Release an agent terminal by uuid. The WS close frame already does
@@ -322,9 +330,14 @@ function buildApi(
// tab never leaves a zombie pty behind. Idempotent.
'agent-pty.close': (payload) => {
const uuid = requireString(payload, 'uuid')
agentPtyRegistry.close(uuid)
agentPtyRegistry?.close(uuid)
return { ok: true }
},
// Terminal dependency status (issue #140): after a WS close 1011 with
// reason `pty-deps-missing` the client fetches the full repair details
// here — the close reason itself is capped at 123 bytes, too small for
// the pasteable command.
'terminal.deps': () => depsStatus(),
// Background jobs: read one job's output (a REPLAY of what the model
// has read so far, from the owner session's event log — the model's
// job_output cursor is never touched, so the human pane can never steal
@@ -436,13 +449,25 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// gateway fence derives its list from. Read per request from the live
// service value; a replaced list takes effect without a plugin restart.
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
const ptyManager = new PtyManager(terminalShell, resolved.terminalsPerSession)
// node-pty is loaded lazily, never at module top level (issue #140): a
// missing or broken install must degrade THIS plugin — terminal tab shows
// a repair command, agent terminal tools stay unregistered — instead of
// failing the plugin load and taking the whole `dsh web` server down.
const nodePty = loadNodePty()
if (nodePty === null) {
const status = depsStatus()
const detail = status.ok
? 'unknown cause'
: `${status.cause}. Repair: ${status.command}`
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
}
const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, nodePty) : null
// 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(terminalShell)
const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, nodePty) : null
// ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page
@@ -461,6 +486,9 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
if (scope.get().agentTerminalTools) {
if (toolsDisposers === null) {
// Degraded mode (node-pty unavailable): never register the terminal
// tools — every one of them would fail at spawn time.
if (agentPtyRegistry === null) return
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
}
} else if (toolsDisposers !== null) {
@@ -469,7 +497,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// 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()
agentPtyRegistry?.disposeAll()
}
}
ctx.inject(['settings'], (sctx) => {
@@ -700,8 +728,8 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
ctx.effect(() => () => {
toolsDisposers?.()
ptyManager.disposeAll()
agentPtyRegistry.disposeAll()
ptyManager?.disposeAll()
agentPtyRegistry?.disposeAll()
wss.close()
agentListWss.close()
}, 'dsh-better-sidebar: teardown')
@@ -709,7 +737,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
/** Push the live agent-terminal list for one session to a connected sidebar view. */
async function attachAgentList(
registry: AgentPtyRegistry,
registry: AgentPtyRegistry | null,
ws: WebSocket,
req: SidebarHttpRequest,
): Promise<void> {
@@ -722,13 +750,15 @@ async function attachAgentList(
}
const send = (): void => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(registry.list(sessionId)))
// Degraded mode (node-pty unavailable): no agent terminal can exist,
// so the honest push is the empty list.
ws.send(JSON.stringify(registry?.list(sessionId) ?? []))
}
}
send()
const unsubscribe = registry.subscribe(send)
ws.on('close', () => { unsubscribe() })
ws.on('error', () => { unsubscribe() })
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))
}
@@ -748,8 +778,8 @@ async function attachAgentList(
*/
async function attachTerminal(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry | null,
ws: WebSocket,
req: SidebarHttpRequest,
resolved: ResolvedSidebarConfig,
@@ -758,6 +788,12 @@ async function attachTerminal(
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const uuid = url.searchParams.get('uuid')
if (uuid !== null) {
// Degraded mode (node-pty unavailable): no agent terminal can exist,
// so the lookup behaves exactly like a missing uuid.
if (agentPtyRegistry === null) {
ws.close(1011, `agent terminal "${uuid}" not found`)
return
}
const handle = agentPtyRegistry.get(uuid)
if (handle === undefined) {
ws.close(1011, `agent terminal "${uuid}" not found`)
@@ -772,6 +808,13 @@ async function attachTerminal(
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
return
}
if (ptyManager === null) {
// Degraded mode (issue #140): node-pty unavailable. The close reason
// is a SHORT marker — a WS close reason is capped at 123 bytes, so the
// client fetches the full repair command from /sidebar/api/terminal.deps.
ws.close(1011, PTY_DEPS_MISSING)
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.