refactor(pty): rename model-facing tools to terminal_* and harden teardown
Rename the six model-facing tools pty_* -> terminal_* and align every description, guidance section, ACP card title, and rendered result to terminal terminology. Package and service internals keep their technical PTY names (PtyService, "unknown PTY session", node-pty). Harden the local backend teardown: - a failed close is retryable: drop the memoized rejection so a later terminal_close re-runs against the live process table - service disposal clears the backend, reservation, and owner-cleanup registries even when a close fails - stop readiness polling before teardown so an in-flight send settles as session_exit instead of a mis-inferred wait reason - bound the sanitizer's pending buffer against unterminated escape runs Update the tool catalog, package READMEs, the bilingual Agent Note, and the acp/headless pty-tools snapshots to match.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
/** Streaming terminal-control sanitizer for the line-oriented first release. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
/** OSC marker emitted by the controlled bash before each prompt. */
|
||||
export const PROMPT_MARKER_PREFIX = '133;D;'
|
||||
|
||||
@@ -16,6 +18,10 @@ export interface SanitizedChunk {
|
||||
*/
|
||||
export class TerminalSanitizer {
|
||||
private pending = ''
|
||||
private discardMode: 'osc' | 'csi' | undefined
|
||||
private discardOscEscape = false
|
||||
|
||||
constructor(private readonly maxPendingBytes: number) {}
|
||||
|
||||
/**
|
||||
* Consume one decoded `node-pty` data chunk.
|
||||
@@ -23,7 +29,7 @@ export class TerminalSanitizer {
|
||||
* @returns Printable text and whether the private prompt marker completed.
|
||||
*/
|
||||
push(chunk: string): SanitizedChunk {
|
||||
this.pending += chunk
|
||||
this.pending += this.discardPrefix(chunk)
|
||||
let text = ''
|
||||
let prompt = false
|
||||
let index = 0
|
||||
@@ -75,6 +81,7 @@ export class TerminalSanitizer {
|
||||
index = escape + 2
|
||||
}
|
||||
this.pending = this.pending.slice(index)
|
||||
this.enforcePendingBound()
|
||||
return { text: normalizeTerminalText(text), prompt }
|
||||
}
|
||||
|
||||
@@ -85,8 +92,54 @@ export class TerminalSanitizer {
|
||||
flush(): string {
|
||||
const text = this.pending.startsWith('\x1b') ? '' : this.pending
|
||||
this.pending = ''
|
||||
this.discardMode = undefined
|
||||
this.discardOscEscape = false
|
||||
return normalizeTerminalText(text)
|
||||
}
|
||||
|
||||
private enforcePendingBound(): void {
|
||||
if (Buffer.byteLength(this.pending) <= this.maxPendingBytes) return
|
||||
this.discardMode = this.pending[1] === ']' ? 'osc' : 'csi'
|
||||
this.pending = ''
|
||||
}
|
||||
|
||||
private discardPrefix(chunk: string): string {
|
||||
if (this.discardMode === undefined) return chunk
|
||||
if (this.discardMode === 'csi') {
|
||||
for (let index = 0; index < chunk.length; index += 1) {
|
||||
const code = chunk.charCodeAt(index)
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
let index = 0
|
||||
if (this.discardOscEscape) {
|
||||
this.discardOscEscape = false
|
||||
if (chunk.startsWith('\\')) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(1)
|
||||
}
|
||||
}
|
||||
while (index < chunk.length) {
|
||||
if (chunk[index] === '\x07') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
if (chunk[index] === '\x1b') {
|
||||
if (chunk[index + 1] === '\\') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 2)
|
||||
}
|
||||
if (index + 1 === chunk.length) this.discardOscEscape = true
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,7 +138,7 @@ function signalName(number: number | undefined): NodeJS.Signals | null {
|
||||
export class LocalPtySession implements PtyBackendSession {
|
||||
motd = ''
|
||||
readonly pid: number
|
||||
private readonly sanitizer = new TerminalSanitizer()
|
||||
private readonly sanitizer: TerminalSanitizer
|
||||
private readonly scrollback: BoundedTextBuffer
|
||||
private readonly exitPromise: PromiseWithResolvers<void> = Promise.withResolvers<void>()
|
||||
private readonly dataDisposable: IDisposable
|
||||
@@ -148,6 +148,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private promptSeen = false
|
||||
private shellPgid: number | undefined
|
||||
private initializing = false
|
||||
private lastOutputAt = Date.now()
|
||||
private closePromise: Promise<void> | undefined
|
||||
@@ -158,6 +159,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private readonly config: ResolvedConfig,
|
||||
) {
|
||||
this.pid = terminal.pid
|
||||
this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
|
||||
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
|
||||
this.dataDisposable = terminal.onData((data) => { this.onData(data) })
|
||||
this.exitDisposable = terminal.onExit(({ exitCode, signal }) => {
|
||||
@@ -253,7 +255,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
|
||||
if (signal === 'SIGKILL' && pgid === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the PTY shell; use pty_kill')
|
||||
throw new Error('refusing to SIGKILL the PTY shell; use terminal_close')
|
||||
}
|
||||
this.inspector.signalGroup(pgid, signal)
|
||||
return { delivered: true, targetPgid: pgid }
|
||||
@@ -273,8 +275,12 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
const sanitized = this.sanitizer.push(data)
|
||||
this.appendOutput(sanitized.text)
|
||||
if (sanitized.prompt) {
|
||||
this.promptSeen = true
|
||||
this.lastOutputAt = Date.now()
|
||||
const foregroundPgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
|
||||
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
|
||||
this.promptSeen = true
|
||||
this.lastOutputAt = Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,9 +325,13 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
|
||||
}
|
||||
|
||||
private clearActive(): void {
|
||||
private stopPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
}
|
||||
|
||||
private clearActive(): void {
|
||||
this.stopPolling()
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
this.active = undefined
|
||||
@@ -329,6 +339,10 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
|
||||
private async closeOnce(reason: string): Promise<void> {
|
||||
this.dataDisposable.dispose()
|
||||
// Stop readiness polling but retain the active operation: teardown settles
|
||||
// it as session_exit below, so an in-flight send is never mis-settled as
|
||||
// stdin_read/inferred_idle/timeout during the grace period.
|
||||
this.stopPolling()
|
||||
const members = this.inspector.processTree(this.pid)
|
||||
for (const member of members) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user