Merge pull request #147 from omdsh-dev/fix/node-pty-load-failure-repair
fix: node-pty 加载失败降级为终端修复提示,不再拖垮 server(#140)
This commit is contained in:
+9
-4
@@ -12,8 +12,9 @@
|
||||
* Both registries share the same shell resolver and spawn-helper fix.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import * as nodePty from 'node-pty'
|
||||
import type { IPty } from 'node-pty'
|
||||
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
|
||||
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
|
||||
@@ -106,7 +107,7 @@ export interface AgentTerminalHandle {
|
||||
/** The working directory the process was spawned with. */
|
||||
cwd: string
|
||||
/** The live pty process. */
|
||||
pty: nodePty.IPty
|
||||
pty: IPty
|
||||
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
||||
transcript: string
|
||||
/** Whether the top-level process exited (transcript stays replayable). */
|
||||
@@ -189,7 +190,11 @@ export class AgentPtyRegistry {
|
||||
private readonly sessions = new Map<string, AgentTerminalHandle>()
|
||||
private readonly changeListeners = new Set<() => void>()
|
||||
|
||||
constructor(private readonly shell: string) {
|
||||
constructor(
|
||||
private readonly shell: string,
|
||||
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
|
||||
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
|
||||
) {
|
||||
ensureSpawnHelper()
|
||||
}
|
||||
|
||||
@@ -211,7 +216,7 @@ export class AgentPtyRegistry {
|
||||
): string {
|
||||
const uuid = randomUUID()
|
||||
const dims = clampDims(cols, rows)
|
||||
const pty = nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
name: 'xterm-256color',
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
|
||||
@@ -22,10 +22,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal, type ITheme } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import { openWhenSized } from './open-when-sized.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
|
||||
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
|
||||
import { resolveTerminalFont } from './terminal-font.ts'
|
||||
@@ -34,6 +35,17 @@ import css from './sidebar.module.css'
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* The WS close-code-1011 reason the host sends when node-pty is unavailable
|
||||
* (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so
|
||||
* the two sides keep the literal in lockstep). The view then fetches the
|
||||
* full repair details from /sidebar/api/terminal.deps.
|
||||
*/
|
||||
const PTY_DEPS_MISSING = 'pty-deps-missing'
|
||||
|
||||
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
|
||||
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
|
||||
|
||||
/**
|
||||
* Curated ANSI palettes for the terminal. The surface colors (background,
|
||||
* foreground, cursor, selection) ride the theme tokens so the terminal
|
||||
@@ -84,6 +96,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
|
||||
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
||||
const connectRef = useRef<(() => void) | null>(null)
|
||||
|
||||
@@ -157,6 +170,25 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
setConnected(false)
|
||||
// node-pty dependency missing/broken (issue #140): the host closed
|
||||
// with the short marker. Fetch the full repair details over HTTP —
|
||||
// a WS close reason is capped at 123 bytes, too small for the
|
||||
// pasteable command. A failed fetch falls back to the plain banner.
|
||||
if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) {
|
||||
void api.terminalDeps().then((status) => {
|
||||
if (status.ok) {
|
||||
// The host recovered between the close and the fetch — the
|
||||
// plain banner with a retry is the honest state.
|
||||
setFatal(t('terminalDepsFailed'))
|
||||
return
|
||||
}
|
||||
setFatal(null)
|
||||
setDepsFatal(status)
|
||||
}).catch(() => {
|
||||
setFatal(t('terminalDepsFailed'))
|
||||
})
|
||||
return
|
||||
}
|
||||
// A server-side refusal carries a close code + reason; retrying it
|
||||
// forever would only spin the banner, so surface it with a retry.
|
||||
if (event.code === 1011 && event.reason !== '') {
|
||||
@@ -263,6 +295,9 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
|
||||
return (
|
||||
<div className={css.terminalWrap}>
|
||||
{depsFatal !== null && (
|
||||
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
|
||||
)}
|
||||
{fatal !== null && (
|
||||
<div className={css.terminalBanner}>
|
||||
{t('terminalError')}: {fatal}
|
||||
@@ -276,8 +311,48 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
|
||||
{fatal === null && depsFatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
|
||||
<div ref={hostRef} className={css.terminal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The node-pty dependency failure banner (issue #140): explains that the
|
||||
* terminal's native dependency failed to load and shows the PASTEABLE repair
|
||||
* command (bash / cmd / PowerShell) with a copy button — the user pastes it
|
||||
* into a terminal where their DSH profile lives and runs it, then retries.
|
||||
* Extracted as a standalone component for direct testing.
|
||||
*/
|
||||
export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) {
|
||||
const { deps, onRetry } = props
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async (): Promise<void> => {
|
||||
const written = await writeClipboard(deps.command)
|
||||
if (written) {
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className={css.terminalDepsBanner}>
|
||||
<div className={css.terminalDepsTitle}>{t('terminalDepsFailed')}</div>
|
||||
<div className={css.terminalDepsHint}>
|
||||
{t('terminalDepsHint')}
|
||||
{deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''}
|
||||
</div>
|
||||
<div className={css.terminalDepsCommandRow}>
|
||||
<pre className={css.terminalRepairCommand}>{deps.command}</pre>
|
||||
<button type="button" className={css.terminalRetry} onClick={() => { void copy() }} aria-label={t('copy')}>
|
||||
{copied ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</div>
|
||||
{deps.note !== undefined && <div className={css.terminalDepsNote}>{deps.note}</div>}
|
||||
<div className={css.terminalDepsActions}>
|
||||
<button type="button" className={css.terminalRetry} onClick={onRetry}>
|
||||
{t('terminalRetry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,21 @@ export interface JobOutputResult {
|
||||
read: boolean
|
||||
}
|
||||
|
||||
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
|
||||
export type TerminalDepsStatus =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false
|
||||
/** The require-time error message (module missing, native binding broken…). */
|
||||
cause: string
|
||||
/** The pasteable repair command (terminal/cmd). */
|
||||
command: string
|
||||
/** The detected profile name (null when undetected → the command defaults to web). */
|
||||
profile: string | null
|
||||
/** Optional supplementary hint (fallback command only). */
|
||||
note?: string
|
||||
}
|
||||
|
||||
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
@@ -164,6 +179,11 @@ export const api = {
|
||||
/** Release an agent terminal by uuid (tab closed while WS was down). */
|
||||
agentPtyClose: (uuid: string) =>
|
||||
call<{ ok: true }>('agent-pty.close', { uuid }),
|
||||
/** Terminal dependency status (issue #140): after a WS close 1011 with
|
||||
* reason `pty-deps-missing` the view fetches the full repair details here
|
||||
* (the close reason itself is capped at 123 bytes). */
|
||||
terminalDeps: () =>
|
||||
call<TerminalDepsStatus>('terminal.deps', {}),
|
||||
/**
|
||||
* The output the model has read so far for one background job (replayed
|
||||
* from the owner session's event log — never the model's job_output
|
||||
|
||||
@@ -29,6 +29,9 @@ export const zh = {
|
||||
terminalError: '终端连接失败',
|
||||
terminalConnectFailed: '终端多次连接失败',
|
||||
terminalRetry: '重试',
|
||||
terminalDepsFailed: '终端依赖 node-pty 加载失败',
|
||||
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
|
||||
terminalDepsProfile: '(检测到 profile:{profile})',
|
||||
preview: '预览',
|
||||
edit: '编辑',
|
||||
refresh: '刷新',
|
||||
@@ -253,6 +256,9 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
terminalError: 'Terminal connection failed',
|
||||
terminalConnectFailed: 'Terminal failed to connect repeatedly',
|
||||
terminalRetry: 'Retry',
|
||||
terminalDepsFailed: 'Terminal dependency node-pty failed to load',
|
||||
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
|
||||
terminalDepsProfile: ' (detected profile: {profile})',
|
||||
preview: 'Preview',
|
||||
edit: 'Edit',
|
||||
refresh: 'Refresh',
|
||||
|
||||
@@ -420,7 +420,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.12.2'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.12.3'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
|
||||
@@ -1305,6 +1305,65 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The node-pty dependency failure banner (issue #140): a taller warn block
|
||||
carrying the pasteable repair command (bash / cmd / PowerShell). */
|
||||
.terminalDepsBanner {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.terminalDepsTitle {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.terminalDepsHint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.terminalDepsCommandRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The command the user pastes into a terminal / cmd. Selectable, wrapping,
|
||||
and scrollable when long (Windows paths can get very long). */
|
||||
.terminalRepairCommand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 4px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.terminalDepsNote {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.terminalDepsActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The PER-TAB error strip: fills one tab's content area (unlike
|
||||
.boundaryError, the full-height fixed rail for shell-level crashes), so a
|
||||
crashing viewer/editor never hides the toggle cluster or the other tabs. */
|
||||
|
||||
+59
-16
@@ -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.
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* node-pty dependency loading for the host half (issue #140, plugin side).
|
||||
*
|
||||
* The terminal surfaces (UI tabs + model-facing terminal_* tools) need
|
||||
* node-pty, but the package must NEVER be imported statically at module
|
||||
* top level: a missing or broken install (pnpm 11's strict-dep-builds
|
||||
* skipping node-pty's install script, a pruned store entry, a failed
|
||||
* prebuilt-binary download…) would then fail the plugin module load and —
|
||||
* because a loader entry apply failure aborts the boot — take the whole
|
||||
* `dsh web` server down with it.
|
||||
*
|
||||
* Instead the host half loads node-pty lazily (synchronously, via
|
||||
* createRequire — the same resolution `ensureSpawnHelper` already uses in
|
||||
* production). When the load fails the plugin stays mounted in a degraded
|
||||
* state: the terminal tab shows a friendly error carrying a pasteable
|
||||
* repair command (see scripts/install.sh / install.ps1 `--repair`), and the
|
||||
* agent terminal tools are simply not registered.
|
||||
*
|
||||
* Version contract: the plugin must stay in sync with DSH core —
|
||||
* `@deepseek-ai/dsh-subprocess-local` declares `"node-pty": "^1.1.0"` in
|
||||
* its `dependencies`. Both sides then resolve the SAME pnpm store entry
|
||||
* (same range, same integrity → one native binding, no drift). Do NOT
|
||||
* switch to a fork (e.g. @lydell/node-pty) or a different range without
|
||||
* re-checking the core declaration.
|
||||
*/
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type * as nodePtyNs from 'node-pty'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** The node-pty module surface the registries consume (spawn/kill/resize/…). */
|
||||
export type NodePtyModule = typeof nodePtyNs
|
||||
|
||||
/**
|
||||
* The node-pty version range this plugin ships. MUST stay identical to the
|
||||
* range DSH core declares (`@deepseek-ai/dsh-subprocess-local`): the same
|
||||
* range keeps pnpm resolving both to one physical package.
|
||||
*/
|
||||
export const DSH_NODE_PTY_RANGE = '^1.1.0'
|
||||
|
||||
/**
|
||||
* The WebSocket close-code-1011 reason the host sends when node-pty is
|
||||
* unavailable. The client recognizes this exact marker and fetches the full
|
||||
* repair details from `/sidebar/api/terminal.deps` (a WS close reason is
|
||||
* capped at 123 bytes, so the command itself cannot ride the close frame).
|
||||
*/
|
||||
export const PTY_DEPS_MISSING = 'pty-deps-missing'
|
||||
|
||||
/** A require-compatible loader, injectable for tests. */
|
||||
export type NodePtyRequire = (id: string) => unknown
|
||||
|
||||
const defaultRequire: NodePtyRequire = createRequire(import.meta.url)
|
||||
|
||||
type LoadResult = { ok: true; module: NodePtyModule } | { ok: false; cause: unknown }
|
||||
|
||||
let cached: LoadResult | undefined
|
||||
|
||||
/**
|
||||
* Load node-pty once (synchronously) and cache the outcome. Returns null
|
||||
* when the package or its native binding cannot be loaded; the cause stays
|
||||
* queryable through {@link nodePtyLoadCause}. Never throws.
|
||||
*/
|
||||
export function loadNodePty(requireImpl: NodePtyRequire = defaultRequire): NodePtyModule | null {
|
||||
if (cached === undefined) {
|
||||
try {
|
||||
cached = { ok: true, module: requireImpl('node-pty') as NodePtyModule }
|
||||
} catch (cause) {
|
||||
cached = { ok: false, cause }
|
||||
}
|
||||
}
|
||||
return cached.ok ? cached.module : null
|
||||
}
|
||||
|
||||
/** The recorded load failure (undefined when the load succeeded or never ran). */
|
||||
export function nodePtyLoadCause(): unknown {
|
||||
return cached !== undefined && !cached.ok ? cached.cause : undefined
|
||||
}
|
||||
|
||||
/** Forget the cached outcome (tests only — a real reload is otherwise one-shot). */
|
||||
export function resetNodePtyCache(): void {
|
||||
cached = undefined
|
||||
}
|
||||
|
||||
/** Load node-pty or throw the canonical degraded-mode error (class-constructor default). */
|
||||
export function loadRequiredNodePty(): NodePtyModule {
|
||||
const module = loadNodePty()
|
||||
if (module === null) {
|
||||
const cause = describeCause(nodePtyLoadCause())
|
||||
throw new SidebarError(
|
||||
'pty-deps-missing',
|
||||
`node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${cause} — run the repair command shown in the terminal tab`,
|
||||
503,
|
||||
)
|
||||
}
|
||||
return module
|
||||
}
|
||||
|
||||
/** Resolve a directory to its physical location (symlinked/link: installs). */
|
||||
function realDir(file: string): string {
|
||||
try {
|
||||
return dirname(realpathSync(file))
|
||||
} catch {
|
||||
return dirname(file)
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk up from `dir` looking for a DSH profile root (package.json + pnpm-workspace.yaml). */
|
||||
function walkUp(dir: string, isRoot: (dir: string) => boolean): string | null {
|
||||
let current = dir
|
||||
for (let depth = 0; depth < 16; depth += 1) {
|
||||
if (isRoot(current)) return current
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whether `dir` looks like a DSH profile root (the plugin lives under its node_modules). */
|
||||
function isProfileRoot(dir: string): boolean {
|
||||
return existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'pnpm-workspace.yaml'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the DSH profile directory this plugin is installed into: the
|
||||
* nearest ancestor of the plugin module that carries both `package.json`
|
||||
* and `pnpm-workspace.yaml` (the profile root; the plugin resolves from the
|
||||
* profile's node_modules). Falls back to `$DSH_HOME/profiles/web` (the
|
||||
* standard web profile), then null.
|
||||
*/
|
||||
export function findProfileDir(fromFile: string = fileURLToPath(import.meta.url)): string | null {
|
||||
const detected = walkUp(realDir(fromFile), isProfileRoot)
|
||||
if (detected !== null) return detected
|
||||
const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.trim() !== ''
|
||||
? process.env.DSH_HOME
|
||||
: join(homedir(), '.dsh')
|
||||
const web = join(home, 'profiles', 'web')
|
||||
return isProfileRoot(web) ? realpathSync(web) : null
|
||||
}
|
||||
|
||||
/** Whether `dir`'s package.json declares this plugin's name. */
|
||||
function isPluginRoot(dir: string): boolean {
|
||||
const file = join(dir, 'package.json')
|
||||
if (!existsSync(file)) return false
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { name?: unknown }
|
||||
return parsed.name === 'dsh-better-sidebar'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugin package root (walk-up from the module; works for lib/ and src/ layouts). */
|
||||
export function findPluginRoot(fromFile: string = fileURLToPath(import.meta.url)): string | null {
|
||||
return walkUp(realDir(fromFile), isPluginRoot)
|
||||
}
|
||||
|
||||
/** Options for {@link buildRepairCommand}. */
|
||||
export interface RepairCommandOptions {
|
||||
/** The plugin package root (where scripts/install.sh / install.ps1 live). */
|
||||
pluginRoot: string | null
|
||||
/** The detected profile directory (null → the standard `web` profile). */
|
||||
profileDir: string | null
|
||||
/** Platform override for tests; defaults to the live process. */
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
/**
|
||||
* The pasteable repair command for a broken node-pty install: rerun the
|
||||
* plugin's own installer in `--repair` mode (idempotent: it re-writes the
|
||||
* profile's `allowBuilds: node-pty: true` and re-installs/rebuilds the
|
||||
* dependency). Falls back to DSH's plugin command when the scripts are not
|
||||
* shipped (exotic layouts).
|
||||
*/
|
||||
export function buildRepairCommand(options: RepairCommandOptions): { command: string; note?: string } {
|
||||
const { pluginRoot, profileDir } = options
|
||||
const platform = options.platform ?? process.platform
|
||||
const profileName = profileDir !== null ? basename(profileDir) : null
|
||||
const profileArg = profileName !== null
|
||||
? (platform === 'win32' ? ` -Profile "${profileName}"` : ` --profile "${profileName}"`)
|
||||
: ''
|
||||
if (pluginRoot !== null) {
|
||||
if (platform === 'win32') {
|
||||
const script = join(pluginRoot, 'scripts', 'install.ps1')
|
||||
if (existsSync(script)) {
|
||||
return { command: `powershell -ExecutionPolicy Bypass -File "${script}" -Repair${profileArg}` }
|
||||
}
|
||||
} else {
|
||||
const script = join(pluginRoot, 'scripts', 'install.sh')
|
||||
if (existsSync(script)) {
|
||||
return { command: `bash "${script}" --repair${profileArg}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
const name = profileName ?? 'web'
|
||||
return {
|
||||
command: `dsh plugin --profile "${name}" install`,
|
||||
note: 'If pnpm 11 blocked node-pty\'s build script, ensure `allowBuilds: node-pty: true` in the profile\'s pnpm-workspace.yaml (the plugin\'s scripts/install.sh / install.ps1 --repair does this automatically).',
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line human description of the recorded load cause. */
|
||||
function describeCause(cause: unknown): string {
|
||||
if (cause instanceof Error) return cause.message
|
||||
return String(cause)
|
||||
}
|
||||
|
||||
/** Structured status served by the `/sidebar/api/terminal.deps` endpoint. */
|
||||
export type NodePtyDepsStatus =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false
|
||||
/** The require-time error message (module missing, native binding broken…). */
|
||||
cause: string
|
||||
/** The pasteable repair command (terminal/cmd). */
|
||||
command: string
|
||||
/** The detected profile name (null when undetected → the command defaults to web). */
|
||||
profile: string | null
|
||||
/** Optional supplementary hint (fallback command only). */
|
||||
note?: string
|
||||
}
|
||||
|
||||
/** Current node-pty dependency status (loaded vs degraded + repair info). */
|
||||
export function depsStatus(options: { fromFile?: string } = {}): NodePtyDepsStatus {
|
||||
const module = loadNodePty()
|
||||
if (module !== null) return { ok: true }
|
||||
const pluginRoot = findPluginRoot(options.fromFile)
|
||||
const profileDir = findProfileDir(options.fromFile)
|
||||
const { command, note } = buildRepairCommand({ pluginRoot, profileDir })
|
||||
return {
|
||||
ok: false,
|
||||
cause: describeCause(nodePtyLoadCause()),
|
||||
command,
|
||||
profile: profileDir !== null ? basename(profileDir) : null,
|
||||
...(note !== undefined ? { note } : {}),
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -10,7 +10,8 @@ import { chmodSync, existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { userInfo } from 'node:os'
|
||||
import * as nodePty from 'node-pty'
|
||||
import type { IPty } from 'node-pty'
|
||||
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Per-terminal transcript bound (bytes kept for replay). */
|
||||
@@ -52,7 +53,7 @@ export interface SidebarPty {
|
||||
* the page-load hydrate race can attach the real cwd after the first
|
||||
* connect, and a shell in the wrong directory must not linger). */
|
||||
cwd: string
|
||||
pty: nodePty.IPty
|
||||
pty: IPty
|
||||
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
||||
transcript: string
|
||||
/** Whether the top-level process exited (transcript stays replayable). */
|
||||
@@ -71,6 +72,8 @@ export class PtyManager {
|
||||
constructor(
|
||||
private readonly shell: string,
|
||||
private readonly maxPerSession: number,
|
||||
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
|
||||
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
|
||||
) {}
|
||||
|
||||
/** All live terminal keys of one session. */
|
||||
@@ -119,7 +122,7 @@ export class PtyManager {
|
||||
sessionId,
|
||||
tabId,
|
||||
cwd,
|
||||
pty: nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
pty: this.nodePty.spawn(this.shell, shellSpawnArgs(), {
|
||||
name: 'xterm-256color',
|
||||
cols: Math.max(2, Math.floor(cols)),
|
||||
rows: Math.max(2, Math.floor(rows)),
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'pty-deps-missing'
|
||||
| 'job-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
|
||||
Reference in New Issue
Block a user