Loading files
6aee22971This file is pinned to the selected release. Download the original to open it in your design tool.
import type { TerminalBackend } from './terminalBackend.js'
import { HerdrApiClient, type HerdrApiResult, type HerdrEndpoint } from './herdrApiClient.js'
import {
TERMINAL_ACTION_SUCCEEDED,
terminalActionNotStarted,
terminalActionPossiblyExecuted,
terminalActionRejected,
type HerdrRuntimeRef,
type RuntimeValidation,
type TerminalActionResult,
type TerminalCaptureOptions,
type TerminalCreateRequest,
type TerminalCreateResult,
type TerminalInventoryResult,
type TerminalLogicalKey,
type TerminalProcessExpectation,
type TerminalReadResult,
} from './terminalTypes.js'
import { engineProcessMatchScore, enrichProcessRows, processRows } from './tmux.js'
import { terminalRouteKey } from './terminalRuntime.js'
interface HerdrPaneInfo {
pane_id: string
terminal_id: string
workspace_id?: string
cwd?: string
label?: string
title?: string
terminal_title_stripped?: string
}
interface HerdrSessionSnapshotResult {
type: 'session_snapshot'
snapshot: {
version: string
protocol: number
panes: HerdrPaneInfo[]
}
}
interface HerdrPaneProcessInfoResult {
type: 'pane_process_info'
process_info: {
pane_id: string
shell_pid?: number
}
}
interface HerdrPaneReadResult {
type: 'pane_read'
read: { text: string }
}
interface HerdrPaneInfoResult {
type: 'pane_info'
pane: HerdrPaneInfo
}
interface HerdrWorkspaceCreatedResult {
type: 'workspace_created'
root_pane: HerdrPaneInfo
}
const HERDR_KEYS: Record<TerminalLogicalKey, string> = {
enter: 'Enter',
escape: 'Escape',
tab: 'Tab',
backtab: 'BackTab',
up: 'Up',
down: 'Down',
left: 'Left',
right: 'Right',
home: 'Home',
end: 'End',
backspace: 'Backspace',
delete: 'Delete',
pageup: 'PageUp',
pagedown: 'PageDown',
'ctrl-c': 'Ctrl+C',
'ctrl-d': 'Ctrl+D',
'ctrl-u': 'Ctrl+U',
'ctrl-w': 'Ctrl+W',
space: 'Space',
'0': '0',
'1': '1',
'2': '2',
'3': '3',
'4': '4',
'5': '5',
'6': '6',
'7': '7',
'8': '8',
'9': '9',
}
function actionResult(result: HerdrApiResult<unknown>): TerminalActionResult {
if (result.ok) return TERMINAL_ACTION_SUCCEEDED
if (result.dispatch === 'not_started') return terminalActionNotStarted(result.reason)
if (result.dispatch === 'rejected') return terminalActionRejected(result.reason)
return terminalActionPossiblyExecuted(result.reason)
}
function runtimeFor(endpoint: HerdrEndpoint, pane: HerdrPaneInfo): HerdrRuntimeRef {
return {
backend: 'herdr',
endpointId: endpoint.endpointId,
sessionName: endpoint.sessionName,
terminalId: pane.terminal_id,
paneId: pane.pane_id,
}
}
function paneTitle(pane: HerdrPaneInfo): string | null {
for (const candidate of [pane.label, pane.terminal_title_stripped, pane.title]) {
if (typeof candidate === 'string' && candidate.trim()) return candidate
}
return null
}
export class HerdrBackend implements TerminalBackend<HerdrRuntimeRef> {
readonly name = 'herdr' as const
readonly instanceId: string
readonly client: HerdrApiClient
constructor(
readonly endpoint: HerdrEndpoint,
client = new HerdrApiClient(endpoint),
) {
this.instanceId = `herdr:${endpoint.endpointId}`
this.client = client
}
async create(request: TerminalCreateRequest): Promise<TerminalCreateResult<HerdrRuntimeRef>> {
const result = await this.client.request<HerdrWorkspaceCreatedResult>('workspace.create', {
...(request.cwd ? { cwd: request.cwd } : {}),
focus: false,
...(request.label ? { label: request.label } : {}),
env: request.env ?? {},
}, { mutation: 'other' })
if (!result.ok) {
if (result.dispatch === 'not_started') return terminalActionNotStarted(result.reason)
if (result.dispatch === 'rejected') return terminalActionRejected(result.reason)
return terminalActionPossiblyExecuted(result.reason)
}
if (result.result.type !== 'workspace_created'
|| typeof result.result.root_pane?.pane_id !== 'string'
|| typeof result.result.root_pane?.terminal_id !== 'string') {
return terminalActionPossiblyExecuted('Herdr created a workspace without returning its root pane')
}
return {
state: 'succeeded',
dispatch: 'executed',
runtime: runtimeFor(this.endpoint, result.result.root_pane),
}
}
async kill(runtime: HerdrRuntimeRef): Promise<TerminalActionResult> {
if (!this.owns(runtime)) return terminalActionNotStarted('Herdr runtime belongs to another configured endpoint')
const info = await this.client.request<HerdrPaneInfoResult>('pane.get', { pane_id: runtime.paneId })
if (!info.ok
|| info.result.type !== 'pane_info'
|| info.result.pane?.terminal_id !== runtime.terminalId
|| typeof info.result.pane.workspace_id !== 'string') {
return terminalActionNotStarted('Herdr workspace could not be resolved from terminal runtime')
}
return actionResult(await this.client.request('workspace.close', {
workspace_id: info.result.pane.workspace_id,
}, { mutation: 'other' }))
}
async titles(): Promise<TerminalReadResult<Map<string, string>>> {
const snapshot = await this.client.request<HerdrSessionSnapshotResult>('session.snapshot', {})
if (!snapshot.ok) return { state: 'failed', reason: snapshot.reason }
if (snapshot.result.type !== 'session_snapshot' || !Array.isArray(snapshot.result.snapshot?.panes)) {
return { state: 'failed', reason: 'Herdr session snapshot has an incompatible shape' }
}
const titles = new Map<string, string>()
for (const pane of snapshot.result.snapshot.panes) {
if (!pane || typeof pane.pane_id !== 'string' || typeof pane.terminal_id !== 'string') continue
const title = paneTitle(pane)
if (title) titles.set(terminalRouteKey(runtimeFor(this.endpoint, pane)), title)
}
return { state: 'succeeded', value: titles }
}
private owns(runtime: HerdrRuntimeRef): boolean {
return runtime.endpointId === this.endpoint.endpointId && runtime.sessionName === this.endpoint.sessionName
}
async inventory(): Promise<TerminalInventoryResult> {
const ping = await this.client.ping()
if (!ping.ok) {
return ping.code === 'protocol_mismatch'
? { state: 'incompatible', reason: ping.reason }
: { state: 'unavailable', reason: ping.reason }
}
const snapshot = await this.client.request<HerdrSessionSnapshotResult>('session.snapshot', {})
if (!snapshot.ok) return { state: 'unavailable', reason: snapshot.reason }
if (snapshot.result.type !== 'session_snapshot' || !Array.isArray(snapshot.result.snapshot?.panes)) {
return { state: 'incompatible', reason: 'Herdr session snapshot has an incompatible shape' }
}
const roots = await Promise.all(snapshot.result.snapshot.panes.map(async (pane) => {
if (!pane || typeof pane.pane_id !== 'string' || typeof pane.terminal_id !== 'string') return null
const info = await this.client.request<HerdrPaneProcessInfoResult>('pane.process_info', { pane_id: pane.pane_id })
if (!info.ok
|| info.result.type !== 'pane_process_info'
|| !Number.isSafeInteger(info.result.process_info?.shell_pid)
|| info.result.process_info.shell_pid! <= 0) return null
return {
runtime: runtimeFor(this.endpoint, pane),
rootPid: info.result.process_info.shell_pid!,
cwd: typeof pane.cwd === 'string' ? pane.cwd : '',
}
}))
if (roots.some((root) => root === null)) {
return { state: 'unavailable', reason: 'Herdr pane process inventory failed' }
}
return { state: 'available', roots: roots.filter((root) => root !== null) }
}
async resolveRuntimeHint(paneId: string): Promise<TerminalReadResult<HerdrRuntimeRef>> {
const info = await this.client.request<HerdrPaneInfoResult>('pane.get', { pane_id: paneId })
if (!info.ok || info.result.type !== 'pane_info' || typeof info.result.pane?.terminal_id !== 'string') {
return { state: 'failed', reason: info.ok ? 'Herdr pane response is incompatible' : info.reason }
}
return { state: 'succeeded', value: runtimeFor(this.endpoint, info.result.pane) }
}
async validate(runtime: HerdrRuntimeRef, _expected: TerminalProcessExpectation): Promise<RuntimeValidation> {
if (!this.owns(runtime)) return { state: 'gone', reason: 'Herdr runtime belongs to another configured endpoint' }
const resolved = await this.resolveRuntimeHint(runtime.paneId)
if (resolved.state === 'failed') return { state: 'unknown', reason: resolved.reason }
if (resolved.value.terminalId !== runtime.terminalId) {
return { state: 'gone', reason: 'Herdr terminal identity changed under pane route' }
}
if (!_expected.processIdentity) return { state: 'alive' }
const [info, rows] = await Promise.all([
this.client.request<HerdrPaneProcessInfoResult>('pane.process_info', { pane_id: runtime.paneId }),
processRows(),
])
if (!info.ok || !rows || info.result.type !== 'pane_process_info'
|| !Number.isSafeInteger(info.result.process_info?.shell_pid)) {
return { state: 'unknown', reason: 'Herdr process identity could not be verified' }
}
const enrichedRows = await enrichProcessRows(rows, new Set([_expected.processIdentity.pid]))
const expected = enrichedRows.find((row) => row.pid === _expected.processIdentity!.pid)
if (!expected || expected.startMarker !== _expected.processIdentity.startMarker) {
return { state: 'gone', reason: 'Herdr engine process identity changed' }
}
if (engineProcessMatchScore(expected, _expected.engine) <= 0) {
return { state: 'gone', reason: 'Herdr process no longer matches the registered engine' }
}
const byPid = new Map(enrichedRows.map((row) => [row.pid, row]))
const rootPid = info.result.process_info.shell_pid!
let pid = expected.pid
const visited = new Set<number>()
while (pid > 0 && !visited.has(pid)) {
if (pid === rootPid) return { state: 'alive' }
visited.add(pid)
pid = byPid.get(pid)?.parentPid ?? 0
}
return { state: 'gone', reason: 'Herdr engine process is no longer under the terminal root' }
}
async capture(runtime: HerdrRuntimeRef, options: TerminalCaptureOptions = {}): Promise<TerminalReadResult<string>> {
if (!this.owns(runtime)) return { state: 'failed', reason: 'Herdr runtime belongs to another configured endpoint' }
const result = await this.client.request<HerdrPaneReadResult>('pane.read', {
pane_id: runtime.paneId,
source: options.mode ?? 'recent_unwrapped',
lines: Math.max(20, Math.min(300, Math.floor(options.historyLines ?? 100))),
format: options.ansi === false ? 'text' : 'ansi',
strip_ansi: options.ansi === false,
})
if (!result.ok) return { state: 'failed', reason: result.reason }
if (result.result.type !== 'pane_read' || typeof result.result.read?.text !== 'string') {
return { state: 'failed', reason: 'Herdr pane capture has an incompatible shape' }
}
return { state: 'succeeded', value: result.result.read.text }
}
async typeLiteral(runtime: HerdrRuntimeRef, text: string): Promise<TerminalActionResult> {
if (!this.owns(runtime)) return terminalActionNotStarted('Herdr runtime belongs to another configured endpoint')
return actionResult(await this.client.request('pane.send_text', {
pane_id: runtime.paneId,
text,
}, { mutation: 'single_enqueue' }))
}
async submitText(runtime: HerdrRuntimeRef, text: string): Promise<TerminalActionResult> {
if (!this.owns(runtime)) return terminalActionNotStarted('Herdr runtime belongs to another configured endpoint')
return actionResult(await this.client.request('pane.send_input', {
pane_id: runtime.paneId,
text: text.replace(/[\r\n]+$/, ''),
keys: ['Enter'],
}, { mutation: 'single_enqueue' }))
}
async sendKey(runtime: HerdrRuntimeRef, key: TerminalLogicalKey): Promise<TerminalActionResult> {
if (!this.owns(runtime)) return terminalActionNotStarted('Herdr runtime belongs to another configured endpoint')
return actionResult(await this.client.request('pane.send_keys', {
pane_id: runtime.paneId,
keys: [HERDR_KEYS[key]],
}, { mutation: 'single_key' }))
}
async setTitle(runtime: HerdrRuntimeRef, title: string): Promise<TerminalActionResult> {
if (!this.owns(runtime)) return terminalActionNotStarted('Herdr runtime belongs to another configured endpoint')
return actionResult(await this.client.request('pane.rename', {
pane_id: runtime.paneId,
label: title.slice(0, 200),
}, { mutation: 'other' }))
}
async notify(runtime: HerdrRuntimeRef, title: string, body: string): Promise<TerminalActionResult> {
if (!this.owns(runtime)) return terminalActionNotStarted('Herdr runtime belongs to another configured endpoint')
return actionResult(await this.client.request('notification.show', {
title: title.slice(0, 200),
body: body.slice(0, 1_000),
sound: 'none',
}, { mutation: 'other' }))
}
}