- tasks→jobs:ctx.jobs / job_output / job_id / session/jobs / jobsBySession / Job* 词汇 - httpServer→webServer(inject + augmentation + register/registerUpgrade) - autoOpenTasks→autoOpenJobs 偏好键 - 右上角开关簇回到角落,注入 CSS 挤开 header 的「Session log」胶囊
200 lines
7.3 KiB
TypeScript
200 lines
7.3 KiB
TypeScript
/**
|
|
* PTY session table for the sidebar terminals. One node-pty process per
|
|
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
|
|
* (page refresh, tab switch) and reconnect to the same process by key.
|
|
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
|
|
* connection replays history before live data. Sessions die only when the
|
|
* tab is closed or the plugin tears down.
|
|
*/
|
|
import { chmodSync, existsSync } from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import { createRequire } from 'node:module'
|
|
import * as nodePty from 'node-pty'
|
|
import { SidebarError } from './wire.ts'
|
|
|
|
/** Per-terminal transcript bound (bytes kept for replay). */
|
|
const TRANSCRIPT_LIMIT = 1 << 20
|
|
|
|
/**
|
|
* Restore the executable bit pnpm strips from node-pty's prebuilt
|
|
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
|
|
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
|
|
* @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
|
|
* plugin activation so link-installed deployments get the fix too.
|
|
*/
|
|
export function ensureSpawnHelper(): void {
|
|
if (process.platform === 'win32') return
|
|
try {
|
|
const require = createRequire(import.meta.url)
|
|
const entry = require.resolve('node-pty')
|
|
const packageRoot = dirname(dirname(entry))
|
|
const candidates = [
|
|
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
|
|
join(packageRoot, 'build', 'Release', 'spawn-helper'),
|
|
]
|
|
for (const helper of candidates) {
|
|
if (existsSync(helper)) chmodSync(helper, 0o755)
|
|
}
|
|
} catch {
|
|
// Resolution or chmod failure: the terminal surfaces its own spawn error.
|
|
}
|
|
}
|
|
|
|
/** One live terminal. */
|
|
export interface SidebarPty {
|
|
/** `${sessionId}:${tabId}` registry key. */
|
|
key: string
|
|
sessionId: string
|
|
tabId: string
|
|
/** The working directory the process was SPAWNED with (a reconnect that
|
|
* resolves a different authoritative cwd respawns instead of reusing —
|
|
* 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
|
|
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
|
|
transcript: string
|
|
/** Whether the top-level process exited (transcript stays replayable). */
|
|
exited: boolean
|
|
exitCode?: number | null
|
|
}
|
|
|
|
/**
|
|
* The terminal registry. `maxPerSession` bounds concurrent processes per
|
|
* conversation (the client caps tabs at the same number).
|
|
*/
|
|
export class PtyManager {
|
|
private readonly sessions = new Map<string, SidebarPty>()
|
|
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
|
|
|
|
constructor(
|
|
private readonly shell: string,
|
|
private readonly maxPerSession: number,
|
|
) {}
|
|
|
|
/** All live terminal keys of one session. */
|
|
keysOf(sessionId: string): string[] {
|
|
const keys: string[] = []
|
|
for (const handle of this.sessions.values()) {
|
|
if (handle.sessionId === sessionId) keys.push(handle.key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
/**
|
|
* Open (or reuse) the terminal for a session/tab key. A handle whose
|
|
* process already exited is replaced with a fresh spawn (reconnecting a
|
|
* dead terminal must yield a live shell, not an input sink), and so is a
|
|
* live handle whose spawn cwd differs from the now-authoritative one (the
|
|
* first connect of a page load can arrive before the session hydrates, so
|
|
* it fell back to the process cwd — reconnecting with the real cwd must
|
|
* restart the shell in the right directory). Reopening also cancels any
|
|
* pending scheduled close (a reconnect within the grace window keeps the
|
|
* process alive).
|
|
* @param sessionId - conversation id.
|
|
* @param tabId - client tab id.
|
|
* @param cwd - initial working directory (the session's cwd).
|
|
* @param cols - initial terminal width.
|
|
* @param rows - initial terminal height.
|
|
* @returns the live handle.
|
|
* @throws {SidebarError} pty-error when the per-session cap is reached.
|
|
*/
|
|
open(sessionId: string, tabId: string, cwd: string, cols: number, rows: number): SidebarPty {
|
|
const key = `${sessionId}:${tabId}`
|
|
this.cancelClose(key)
|
|
const existing = this.sessions.get(key)
|
|
if (existing !== undefined && !existing.exited && existing.cwd === cwd) return existing
|
|
if (existing !== undefined) this.close(key)
|
|
// Zombie cleanup: a session's exited handles (shell closed, tab dropped
|
|
// on an old host without the close frame) must not eat the quota.
|
|
for (const [candidate, handle] of [...this.sessions]) {
|
|
if (handle.sessionId === sessionId && handle.exited) this.close(candidate)
|
|
}
|
|
if (this.keysOf(sessionId).length >= this.maxPerSession) {
|
|
throw new SidebarError('pty-error', `terminal limit reached (${this.maxPerSession}) for this session`, 400)
|
|
}
|
|
const handle: SidebarPty = {
|
|
key,
|
|
sessionId,
|
|
tabId,
|
|
cwd,
|
|
pty: nodePty.spawn(this.shell, [], {
|
|
name: 'xterm-256color',
|
|
cols: Math.max(2, Math.floor(cols)),
|
|
rows: Math.max(2, Math.floor(rows)),
|
|
cwd,
|
|
env: { ...process.env },
|
|
}),
|
|
transcript: '',
|
|
exited: false,
|
|
}
|
|
handle.pty.onData((data) => {
|
|
handle.transcript += data
|
|
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
|
|
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
|
|
}
|
|
})
|
|
handle.pty.onExit(({ exitCode }) => {
|
|
handle.exited = true
|
|
handle.exitCode = exitCode
|
|
})
|
|
this.sessions.set(key, handle)
|
|
return handle
|
|
}
|
|
|
|
/**
|
|
* Schedule the terminal's destruction after `delayMs`. A tab close sends
|
|
* delay 0 (release the quota immediately); a bare socket drop (refresh,
|
|
* crash) uses the grace period so a quick reconnect keeps the process.
|
|
* `open()` cancels any pending close.
|
|
*/
|
|
scheduleClose(key: string, delayMs: number): void {
|
|
const handle = this.sessions.get(key)
|
|
if (handle === undefined) return
|
|
this.cancelClose(key)
|
|
const timer = setTimeout(() => { this.close(key) }, delayMs)
|
|
this.pendingCloses.set(key, timer)
|
|
}
|
|
|
|
/** Cancel a pending scheduled close (the terminal is being reopened). */
|
|
cancelClose(key: string): void {
|
|
const timer = this.pendingCloses.get(key)
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer)
|
|
this.pendingCloses.delete(key)
|
|
}
|
|
}
|
|
|
|
/** Resolve a live handle by key, or undefined. */
|
|
get(key: string): SidebarPty | undefined {
|
|
return this.sessions.get(key)
|
|
}
|
|
|
|
/** Close a terminal and drop its state (the owning tab was closed). */
|
|
close(key: string): void {
|
|
this.cancelClose(key)
|
|
const handle = this.sessions.get(key)
|
|
if (handle === undefined) return
|
|
this.sessions.delete(key)
|
|
try {
|
|
handle.pty.kill()
|
|
} catch {
|
|
// Already exited or gone; nothing left to kill.
|
|
}
|
|
}
|
|
|
|
/** Close every terminal (plugin teardown). */
|
|
disposeAll(): void {
|
|
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
|
|
this.pendingCloses.clear()
|
|
for (const key of [...this.sessions.keys()]) this.close(key)
|
|
}
|
|
}
|
|
|
|
/** The interactive shell for this platform (empty SHELL falls back). */
|
|
export function defaultShell(): string {
|
|
if (process.platform === 'win32') return 'powershell.exe'
|
|
const shell = process.env.SHELL
|
|
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
|
|
}
|