Merge pull request #63023 from NousResearch/bb/salvage-61584-terminal

fix(desktop-terminal): fix idle prompt accumulation, double prompt, and cwd on relaunch
This commit is contained in:
brooklyn! 2026-07-12 01:51:37 -05:00 committed by GitHub
commit 0dd6ced0cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 393 additions and 23 deletions

View file

@ -1,6 +1,6 @@
type Method = string
import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'
import { execFileSync, spawn } from 'node:child_process'
import { execFile, execFileSync, spawn } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import http from 'node:http'
@ -8353,6 +8353,51 @@ function terminalChannel(id, suffix) {
return `hermes:terminal:${id}:${suffix}`
}
// Best-effort read of a live PTY child's current working directory so a
// reopened tab can restart the shell where the user last `cd`'d, instead of the
// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on
// POSIX; Windows has no cheap per-process cwd query without a native module, so
// it returns null and the caller falls back to the launch cwd.
function readProcessCwd(pid) {
return new Promise(resolve => {
if (!Number.isInteger(pid) || pid <= 0) {
resolve(null)
return
}
if (process.platform === 'linux') {
fs.promises
.readlink(`/proc/${pid}/cwd`)
.then(target => resolve(target || null))
.catch(() => resolve(null))
return
}
if (process.platform === 'darwin') {
// lsof ships with macOS; -Fn emits the cwd fd's path on an `n<path>` line.
execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => {
if (err) {
resolve(null)
return
}
const line = String(stdout || '')
.split('\n')
.find(entry => entry.startsWith('n'))
resolve(line ? line.slice(1) : null)
})
return
}
resolve(null)
})
}
function disposeTerminalSession(id) {
const sessionInfo = terminalSessions.get(id)
@ -8591,6 +8636,16 @@ ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
return true
})
ipcMain.handle('hermes:terminal:cwd', async (_event, id) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return null
}
return readProcessCwd(sessionInfo.pty.pid)
})
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
ipcMain.handle('hermes:updates:check', async () =>

View file

@ -136,6 +136,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
}
},
terminal: {
cwd: id => ipcRenderer.invoke('hermes:terminal:cwd', id),
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
start: options => ipcRenderer.invoke('hermes:terminal:start', options),

View file

@ -19,12 +19,20 @@ interface TerminalInstanceProps {
cwd: string
active: boolean
onAddSelectionToChat: (text: string, label?: string) => void
restoreCwd?: string
reviveBuffer?: string
}
/** One persistent xterm+PTY. Every open tab stays mounted (so its shell and
* scrollback survive tab switches); only the active one is shown. */
export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, reviveBuffer }: TerminalInstanceProps) {
export function TerminalInstance({
id,
active,
cwd,
onAddSelectionToChat,
restoreCwd,
reviveBuffer
}: TerminalInstanceProps) {
const { t } = useI18n()
const { addSelectionToChat, hostRef, selection, selectionStyle, status } = useTerminalSession({
@ -32,6 +40,7 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, revive
cwd,
active,
onAddSelectionToChat,
restoreCwd,
reviveBuffer,
onShell: shell => reportTerminalShell(id, shell)
})

View file

@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import { cleanReviveSnapshot, isIdlePromptOnly, parseOscCwd } from './use-terminal-session'
// A default-PowerShell idle prompt: no blank-line separator before it.
const PS_PROMPT = 'PS C:\\Users\\Aleksandr>'
describe('isIdlePromptOnly', () => {
it('is true for an empty or whitespace-only buffer', () => {
expect(isIdlePromptOnly('')).toBe(true)
expect(isIdlePromptOnly('\r\n \r\n')).toBe(true)
})
it('is true for a lone prompt line', () => {
expect(isIdlePromptOnly(PS_PROMPT)).toBe(true)
})
it('is true for a buffer that is only repeated identical prompts (accumulation)', () => {
expect(isIdlePromptOnly([PS_PROMPT, PS_PROMPT, PS_PROMPT].join('\r\n'))).toBe(true)
})
it('ignores blank gaps between repeated prompts (the "gapped" variant)', () => {
expect(isIdlePromptOnly([PS_PROMPT, '', '', PS_PROMPT].join('\r\n'))).toBe(true)
})
it('is false when the buffer holds a real command and output', () => {
expect(isIdlePromptOnly([PS_PROMPT, 'cd project', 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false)
})
it('is false when two different prompts are present (cwd actually changed)', () => {
expect(isIdlePromptOnly([PS_PROMPT, 'PS C:\\Users\\Aleksandr\\project>'].join('\r\n'))).toBe(false)
})
})
describe('cleanReviveSnapshot', () => {
it('drops a spaced trailing prompt block after a blank separator (starship)', () => {
const snapshot = ['echo hi', 'hi', '', PS_PROMPT].join('\r\n')
expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi')
})
it('drops a multi-line prompt block after a blank separator (powerline)', () => {
const snapshot = ['work', '', '┌─ user@host ~/project', '└─$'].join('\r\n')
expect(cleanReviveSnapshot(snapshot)).toBe('work')
})
it('drops a single-line trailing prompt with no preceding blank line (PowerShell)', () => {
// Default PowerShell prints no blank line before its prompt; the fresh shell
// reprints it on boot, so the redundant idle prompt must be trimmed here.
const snapshot = ['echo hi', 'hi', PS_PROMPT].join('\r\n')
expect(cleanReviveSnapshot(snapshot)).toBe('echo hi\r\nhi')
})
it('keeps command output and drops only the trailing prompt on a long history', () => {
const history = ['cmd1', 'out1', 'cmd2', 'out2']
const snapshot = [...history, PS_PROMPT].join('\r\n')
expect(cleanReviveSnapshot(snapshot)).toBe(history.join('\r\n'))
})
it('reduces a lone prompt to an empty buffer', () => {
expect(cleanReviveSnapshot(PS_PROMPT)).toBe('')
expect(cleanReviveSnapshot([PS_PROMPT, '', ''].join('\r\n'))).toBe('')
})
it('returns empty for a blank-only buffer without throwing', () => {
expect(cleanReviveSnapshot('')).toBe('')
expect(cleanReviveSnapshot('\r\n \r\n')).toBe('')
})
})
describe('parseOscCwd', () => {
it('parses an OSC 7 file URI and percent-decodes it', () => {
expect(parseOscCwd(7, 'file://host/Users/al/my%20project')).toBe('/Users/al/my project')
})
it('strips the leading slash from a Windows OSC 7 file URI', () => {
expect(parseOscCwd(7, 'file:///C:/Users/Aleksandr/project')).toBe('C:/Users/Aleksandr/project')
})
it('ignores non-file OSC 7 payloads', () => {
expect(parseOscCwd(7, 'https://example.com')).toBeNull()
expect(parseOscCwd(7, '')).toBeNull()
})
it('parses an OSC 9;9 cwd payload and unquotes it', () => {
expect(parseOscCwd(9, '9;"C:\\Users\\Aleksandr"')).toBe('C:\\Users\\Aleksandr')
expect(parseOscCwd(9, '9;/home/al/src')).toBe('/home/al/src')
})
it('ignores OSC 9 sub-commands other than 9;<path> (e.g. progress)', () => {
expect(parseOscCwd(9, '4;3')).toBeNull()
expect(parseOscCwd(9, 'some notification')).toBeNull()
})
})

View file

@ -87,4 +87,37 @@ describe('terminal store persistence', () => {
closeAllTerminals()
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
})
it('restores and persists the last observed cwd so a reopened tab lands where the user cd-d', async () => {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
activeTerminalId: 'term-one',
terminals: [{ auto: false, cwd: '/repo', id: 'term-one', restoreCwd: '/repo/packages/api', title: 'zsh' }]
})
)
const { $terminals, updateTerminalRestoreCwd } = await loadTerminalStore()
expect($terminals.get()[0]?.restoreCwd).toBe('/repo/packages/api')
updateTerminalRestoreCwd('term-one', '/repo/packages/web')
expect($terminals.get()[0]?.restoreCwd).toBe('/repo/packages/web')
expect(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}').terminals[0].restoreCwd).toBe(
'/repo/packages/web'
)
})
it('never attaches a restore cwd to an agent tab and ignores empty values', async () => {
const { $terminals, createTerminal, ensureAgentTerminal, updateTerminalRestoreCwd } = await loadTerminalStore()
const userId = createTerminal('/repo')
const agentId = ensureAgentTerminal('proc-1', 'background task')!
updateTerminalRestoreCwd(agentId, '/somewhere')
updateTerminalRestoreCwd(userId, ' ')
expect($terminals.get().find(term => term.id === agentId)?.restoreCwd).toBeUndefined()
expect($terminals.get().find(term => term.id === userId)?.restoreCwd).toBeUndefined()
})
})

View file

@ -19,6 +19,10 @@ export interface TerminalEntry {
* (the project root if opened in one, else the backend's default). Switching
* sessions never moves or recreates a terminal. */
cwd: string
/** Last observed working directory of the live shell (tracked via the PTY
* cwd probe / OSC 7). Used to reopen the tab where the user last `cd`'d
* rather than the original launch dir. User tabs only. */
restoreCwd?: string
/** Serialized xterm scrollback from the last session, replayed on relaunch so
* the tab reopens with its recent history (VS Code parity). Processes are NOT
* revived a fresh shell starts beneath the restored buffer. Captured live
@ -34,6 +38,7 @@ interface PersistedTerminalEntry {
auto: boolean
cwd: string
id: string
restoreCwd?: string
reviveBuffer?: string
title: string
}
@ -59,6 +64,7 @@ function sanitizePersistedTerminal(value: unknown): PersistedTerminalEntry | nul
const id = typeof record.id === 'string' ? record.id.trim() : ''
const title = typeof record.title === 'string' ? record.title.trim() : ''
const cwd = typeof record.cwd === 'string' ? record.cwd : ''
const restoreCwd = typeof record.restoreCwd === 'string' && record.restoreCwd ? record.restoreCwd : undefined
const reviveBuffer = typeof record.reviveBuffer === 'string' ? record.reviveBuffer : undefined
if (!id) {
@ -69,6 +75,7 @@ function sanitizePersistedTerminal(value: unknown): PersistedTerminalEntry | nul
auto: typeof record.auto === 'boolean' ? record.auto : true,
cwd,
id,
...(restoreCwd ? { restoreCwd } : {}),
...(reviveBuffer ? { reviveBuffer } : {}),
title: title || 'Terminal'
}
@ -116,6 +123,7 @@ function persistTerminals(list: readonly TerminalEntry[], activeTerminalId: null
auto: term.auto,
cwd: term.cwd,
id: term.id,
...(term.restoreCwd ? { restoreCwd: term.restoreCwd } : {}),
...(term.reviveBuffer ? { reviveBuffer: term.reviveBuffer } : {}),
title: term.title
}))
@ -309,6 +317,27 @@ export function updateTerminalReviveBuffer(id: string, reviveBuffer: string): vo
)
}
/** Record the shell's latest working directory for a tab so the next launch can
* restart the PTY there instead of the original launch dir. User tabs only;
* no-ops when the value is empty or unchanged to avoid redundant persistence. */
export function updateTerminalRestoreCwd(id: string, restoreCwd: string): void {
const next = restoreCwd.trim()
if (!next) {
return
}
$terminals.set(
$terminals.get().map(term => {
if (term.id !== id || term.kind !== 'user' || term.restoreCwd === next) {
return term
}
return { ...term, restoreCwd: next }
})
)
}
export function renameTerminal(id: string, title: string): void {
const trimmed = title.trim()

View file

@ -21,7 +21,7 @@ import {
terminalSelectionLabel,
terminalTheme
} from './selection'
import { closeTerminal, updateTerminalReviveBuffer } from './terminals'
import { closeTerminal, updateTerminalRestoreCwd, updateTerminalReviveBuffer } from './terminals'
// How many scrollback lines to serialize for relaunch restore. Mirrors VS Code's
// terminal.integrated.persistentSessionScrollback default; the store caps the
@ -33,6 +33,11 @@ const PERSISTENT_SESSION_SCROLLBACK = 200
// renderer tears down), then at most once per window while output streams.
const SNAPSHOT_THROTTLE_MS = 750
// Minimum gap between main-side PTY cwd probes. The probe spawns lsof on macOS,
// so keep it well throttled — cwd only changes on a `cd`, which the reporter
// already reads off the next output snapshot anyway.
const CWD_PROBE_THROTTLE_MS = 2000
// True once the page/app is tearing down (Cmd+Q, Alt+F4, window close, reload).
// App quit kills the PTYs from the main process, which fires onExit in the
// renderer — but React skips effect cleanups on teardown, so the per-instance
@ -168,38 +173,55 @@ function stripInitialPromptGap(data: string) {
return prefix
}
// A row's content with ANSI escapes and all whitespace stripped — '' for a
// spacer / prompt-gap / zsh `%` marker row.
const visibleText = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '')
// Trim the shell's trailing idle prompt from a serialized snapshot before it's
// persisted. Without it, the saved buffer ends in the old prompt, so the next
// launch replays it directly above the fresh shell's prompt ("double bar"). The
// prompt is the short block after the last blank line (starship's add_newline
// gap); only a short tail is dropped, so real command output is never trimmed and
// configs without that blank line simply keep the historical prompt (no loss).
function cleanReviveSnapshot(serialized: string): string {
const visible = (line: string) => stripEscapeSequences(line).replace(/[\s%]/g, '')
// launch replays it directly above the fresh shell's prompt ("double bar").
//
// An interactive shell always reprints its prompt after a command finishes, so
// the tail of an idle buffer is the prompt, never real history. Two prompt
// shapes exist:
// - Spaced/multi-line (starship add_newline, powerline): a blank line sits
// just above the prompt, so the short block after the last blank is dropped.
// - Single-line (default PowerShell `PS C:\..>`, bash `user@host:~$`): no blank
// separator, so the final line itself is the prompt and is dropped.
// The fresh shell reprints the current prompt on boot either way, so only the
// redundant idle prompt is removed — command output is preserved.
export function cleanReviveSnapshot(serialized: string): string {
const lines = serialized.split(/\r?\n/)
while (lines.length && visible(lines[lines.length - 1]) === '') {
while (lines.length && !visibleText(lines[lines.length - 1])) {
lines.pop()
}
let lastBlank = -1
for (let i = lines.length - 1; i >= 0; i -= 1) {
if (visible(lines[i]) === '') {
lastBlank = i
break
}
if (lines.length === 0) {
return ''
}
// A prompt is a short block; a long tail after the blank is real output, leave it.
if (lastBlank >= 0 && lines.length - 1 - lastBlank <= 3) {
lines.length = lastBlank
}
const lastBlank = lines.findLastIndex(line => !visibleText(line))
const spacedPrompt = lastBlank >= 0 && lines.length - 1 - lastBlank <= 3
// Spaced prompt (starship/powerline): drop the block after the blank
// separator. Otherwise the last line is the single-line prompt itself.
lines.length = spacedPrompt ? lastBlank : lines.length - 1
return lines.join('\r\n')
}
// True when a revive buffer holds no real scrollback: empty, or only repeats of
// one line (the idle prompt). This is the idle-accumulation signature (#61572) —
// each relaunch replayed the saved prompt(s) and the fresh shell printed one more
// below, growing the tab by a line per cycle. Real sessions vary (prompt +
// command + output), so genuine short histories are never mistaken for idle.
export function isIdlePromptOnly(serialized: string): boolean {
const lines = serialized.split(/\r?\n/).map(visibleText).filter(Boolean)
return lines.length === 0 || lines.every(line => line === lines[0])
}
interface UseTerminalSessionOptions {
/** Renderer-side terminal id (the tab handle), used to key the agent reader. */
id: string
@ -207,12 +229,52 @@ interface UseTerminalSessionOptions {
/** Only the active tab is visible, owns the agent reader, and runs injections. */
active: boolean
onAddSelectionToChat: (text: string, label?: string) => void
/** Last observed shell cwd from the previous session; the fresh PTY starts
* here (falling back to `cwd`) so a prior `cd` survives a relaunch. */
restoreCwd?: string
/** Serialized scrollback from the previous session, replayed once on mount. */
reviveBuffer?: string
/** Reports the resolved shell name once the PTY is live (for the tab label). */
onShell?: (shell: string) => void
}
// Parse a working directory out of a cwd-reporting OSC payload. Covers OSC 7
// (`file://host/path`, emitted by many bash/zsh integrations) and OSC 9;9
// (`9;<path>`, ConEmu/Windows-Terminal style some PowerShell profiles emit).
// Returns null for anything unrecognized so callers can ignore it.
export function parseOscCwd(code: 7 | 9, payload: string): string | null {
if (code === 9) {
// OSC 9;9;<path> — the leading "9;" selects the cwd sub-command.
if (!payload.startsWith('9;')) {
return null
}
const raw = payload.slice(2).trim().replace(/^"|"$/g, '')
return raw || null
}
// OSC 7 — a file URI. Strip the scheme + authority and percent-decode.
const match = /^file:\/\/[^/]*(\/.*)$/.exec(payload.trim())
if (!match) {
return null
}
let raw = match[1]
try {
raw = decodeURIComponent(raw)
} catch {
// Keep the undecoded path if it isn't valid percent-encoding.
}
// Windows file URIs carry a leading slash before the drive (`/C:/Users`).
const windows = /^\/[A-Za-z]:[\\/]/.exec(raw)
return (windows ? raw.slice(1) : raw) || null
}
// Bind the palette to the live skin surface so the terminal blends with the app
// (and the contrast clamp has a real background to work against).
function withSurface(theme: ReturnType<typeof terminalTheme>) {
@ -314,6 +376,7 @@ export function useTerminalSession({
cwd,
active,
onAddSelectionToChat,
restoreCwd,
reviveBuffer,
onShell
}: UseTerminalSessionOptions) {
@ -336,6 +399,15 @@ export function useTerminalSession({
// Snapshot the revive buffer once: live snapshots feed updateTerminalReviveBuffer
// and would otherwise re-arm replay on every store-driven re-render.
const initialReviveBufferRef = useRef(reviveBuffer)
// The cwd to boot the fresh PTY in — the last dir the prior session observed
// (survives a `cd`), captured once so store-driven re-renders don't move it.
const initialRestoreCwdRef = useRef(restoreCwd)
// Latest cwd seen this session; de-dupes redundant store writes.
const lastObservedCwdRef = useRef<string | null>(null)
// Whether the user ever fed input into this session (keystrokes, paste,
// drag-and-drop paths, or an injected command). Gates idle-buffer handling in
// persistSnapshot so an untouched tab never re-saves an accumulating snapshot.
const hasSessionActivityRef = useRef(false)
const shellNameRef = useRef('shell')
const selectionLabelRef = useRef('')
const selectionRef = useRef('')
@ -473,6 +545,50 @@ export function useTerminalSession({
term.write('\r\n')
}
// Track the shell's working directory so a reopened tab restarts where the
// user last `cd`'d. Two independent signals feed it: cwd-reporting OSC
// sequences (immediate, for shells configured to emit them) and a periodic
// PTY cwd probe on the main side (shell-agnostic on POSIX). The store
// updater de-dupes, so both feeding it is harmless.
const recordCwd = (next: string | null | undefined) => {
const value = (next ?? '').trim()
if (!value || value === lastObservedCwdRef.current) {
return
}
lastObservedCwdRef.current = value
updateTerminalRestoreCwd(id, value)
}
const cwdOscHandlers = ([7, 9] as const).map(code =>
term.parser.registerOscHandler(code, payload => {
recordCwd(parseOscCwd(code, payload))
return false // let the sequence propagate; we only observe it
})
)
cleanup.push(() => cwdOscHandlers.forEach(handler => handler.dispose()))
let cwdProbeAt = 0
const probeCwd = () => {
const sessionId = sessionIdRef.current
if (!sessionId || !terminalApi.cwd || Date.now() - cwdProbeAt < CWD_PROBE_THROTTLE_MS) {
return
}
cwdProbeAt = Date.now()
void terminalApi
.cwd(sessionId)
.then(recordCwd)
.catch(() => {
// Best-effort: no cwd probe on this platform (e.g. Windows).
})
}
// Capture the buffer on a leading-edge throttle and persist synchronously via
// the store. No unload hook: by the time the user quits, a recent snapshot is
// already on disk (the prior beforeunload-based attempt lost the last output).
@ -486,12 +602,31 @@ export function useTerminalSession({
lastSnapshotAt = Date.now()
// No user input this session: never re-serialize. The live buffer now holds
// the replayed history plus a fresh boot prompt, and re-saving that is
// exactly what grew idle tabs by one prompt line per relaunch (#61572).
// If the buffer we loaded carried no real scrollback (empty, or only a
// repeated prompt), clear it so the next launch shows a single fresh prompt
// and any pre-existing accumulation heals. Otherwise leave the prior
// snapshot untouched so real history from an earlier active session
// survives an idle reopen instead of being overwritten.
if (!hasSessionActivityRef.current) {
if (isIdlePromptOnly(initialReviveBufferRef.current ?? '')) {
updateTerminalReviveBuffer(id, '')
}
return
}
try {
const snapshot = serialize.serialize({ scrollback: PERSISTENT_SESSION_SCROLLBACK })
updateTerminalReviveBuffer(id, cleanReviveSnapshot(snapshot))
} catch {
// Best-effort restore: never let serialization break a live terminal.
}
// A user command may have `cd`'d; refresh the persisted cwd (throttled).
probeCwd()
}
const scheduleSnapshot = () => {
@ -544,6 +679,7 @@ export function useTerminalSession({
return
}
hasSessionActivityRef.current = true
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
term.focus()
triggerHaptic('selection')
@ -640,6 +776,7 @@ export function useTerminalSession({
})
const dataDisposable = term.onData(data => {
hasSessionActivityRef.current = true
const id = sessionIdRef.current
if (id) {
@ -661,7 +798,10 @@ export function useTerminalSession({
const startSession = () =>
void terminalApi
.start({ cols: term.cols, cwd, rows: term.rows })
// Prefer the prior session's last cwd so a reopened tab lands where the
// user last `cd`'d; the main side falls back to the launch cwd (then
// home) if that dir no longer exists.
.start({ cols: term.cols, cwd: initialRestoreCwdRef.current || cwd, rows: term.rows })
.then(session => {
if (disposed) {
void terminalApi.dispose(session.id)
@ -846,6 +986,7 @@ export function useTerminalSession({
return
}
hasSessionActivityRef.current = true
void window.hermesDesktop?.terminal?.write(sessionId, `${command}\r`)
$terminalInjection.set(null)
termRef.current?.focus()

View file

@ -56,6 +56,7 @@ export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspacePro
id={term.id}
key={term.id}
onAddSelectionToChat={onAddSelectionToChat}
restoreCwd={term.restoreCwd}
reviveBuffer={term.reviveBuffer}
/>
)

View file

@ -163,6 +163,10 @@ declare global {
scanRepos: (roots: string[], options?: { maxDepth?: number }) => Promise<{ root: string; label: string }[]>
}
terminal: {
/** Best-effort current working directory of the live PTY child (POSIX
* only; null on Windows or when unavailable). Used to reopen a tab
* where the user last `cd`'d. */
cwd: (id: string) => Promise<string | null>
dispose: (id: string) => Promise<boolean>
onData: (id: string, callback: (payload: string) => void) => () => void
onExit: (id: string, callback: (payload: HermesTerminalExit) => void) => () => void