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:
NI0317
2026-07-21 19:29:56 +08:00
parent 85ac747208
commit a7bfade7eb
28 changed files with 568 additions and 401 deletions

View File

@@ -6,7 +6,7 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime.
Linux readiness combines a private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit.
## Model Experience

View File

@@ -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 ''
}
}
/**

View File

@@ -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 {

View File

@@ -3,7 +3,7 @@ import { normalizeTerminalText, TerminalSanitizer } from '@deepseek-ai/dsh-pty-l
describe('TerminalSanitizer', () => {
it('removes split CSI and owned OSC prompt markers', () => {
const sanitizer = new TerminalSanitizer()
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
@@ -11,7 +11,7 @@ describe('TerminalSanitizer', () => {
})
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
const sanitizer = new TerminalSanitizer()
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('a\x1b]0;title\x1b\\b\x1b7c\x07')).toEqual({ text: 'abc', prompt: false })
expect(sanitizer.push('tail\x1b')).toEqual({ text: 'tail', prompt: false })
expect(sanitizer.flush()).toBe('')
@@ -24,4 +24,39 @@ describe('TerminalSanitizer', () => {
it('normalizes CRLF and standalone carriage returns', () => {
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
})
it('bounds and discards unterminated control sequences through their terminators', () => {
const oscBel = new TerminalSanitizer(8)
expect(oscBel.push(`\x1b]0;${'x'.repeat(16)}`)).toEqual({ text: '', prompt: false })
expect(oscBel.push('more\x07tail')).toEqual({ text: 'tail', prompt: false })
const oscSt = new TerminalSanitizer(8)
oscSt.push(`\x1b]0;${'x'.repeat(16)}`)
expect(oscSt.push('more\x1b')).toEqual({ text: '', prompt: false })
expect(oscSt.push('\\tail')).toEqual({ text: 'tail', prompt: false })
const oscDirectSt = new TerminalSanitizer(8)
oscDirectSt.push(`\x1b]0;${'x'.repeat(16)}`)
expect(oscDirectSt.push('more\x1b\\tail')).toEqual({ text: 'tail', prompt: false })
const oscFalseSt = new TerminalSanitizer(8)
oscFalseSt.push(`\x1b]0;${'x'.repeat(16)}`)
oscFalseSt.push('\x1b')
expect(oscFalseSt.push('more')).toEqual({ text: '', prompt: false })
expect(oscFalseSt.push('\x07tail')).toEqual({ text: 'tail', prompt: false })
const oscNonTerminatingEscape = new TerminalSanitizer(8)
oscNonTerminatingEscape.push(`\x1b]0;${'x'.repeat(16)}`)
expect(oscNonTerminatingEscape.push('more\x1bxmore\x07tail')).toEqual({ text: 'tail', prompt: false })
const csi = new TerminalSanitizer(8)
expect(csi.push(`\x1b[${'1'.repeat(16)}`)).toEqual({ text: '', prompt: false })
expect(csi.push('123')).toEqual({ text: '', prompt: false })
expect(csi.push('mtext')).toEqual({ text: 'text', prompt: false })
const flushed = new TerminalSanitizer(8)
flushed.push(`\x1b]0;${'x'.repeat(16)}`)
expect(flushed.flush()).toBe('')
expect(flushed.push('text')).toEqual({ text: 'text', prompt: false })
})
})

View File

@@ -124,9 +124,9 @@ describe('LocalPtySession readiness and output', () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.pgid = undefined
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
inspector.pgid = undefined
const inferred = session.startSend({ text: 'sleep', submit: false })
terminal.emitData('working')
@@ -238,6 +238,27 @@ describe('LocalPtySession readiness and output', () => {
await vi.advanceTimersByTimeAsync(100)
await timedOut
})
it('trusts prompt markers only while the startup shell owns the foreground group', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
const operation = session.startSend({ text: 'run', submit: true })
let settled = false
void operation.done.then(() => { settled = true })
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07spoofed')
await vi.advanceTimersByTimeAsync(10)
expect(settled).toBe(false)
inspector.pgid = 456
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
expect((await operation.done).waitReason).toBe('stdin_read')
})
})
describe('LocalPtySession bounds, signals, and teardown', () => {
@@ -278,7 +299,7 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
const session = new LocalPtySession(terminal.asPty(), inspector, config())
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
inspector.pgid = terminal.pid
await expect(session.signal('SIGKILL')).rejects.toThrow('use pty_kill')
await expect(session.signal('SIGKILL')).rejects.toThrow('use terminal_close')
inspector.pgid = undefined
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
})
@@ -297,6 +318,23 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
})
it('settles an active send as session_exit when closed mid-operation', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config({ disposeGraceMs: 50 }))
await initialize(session, terminal)
const operation = session.startSend({ text: 'run', submit: true })
// The shell returns to its prompt while the send is active; a running
// readiness poll would otherwise mis-settle this as stdin_read once close
// begins, so teardown must stop polling before its grace period.
terminal.emitData('\x1b]133;D;0\x07dsh> ')
terminal.throwKill = true
const closing = session.close('mid-send')
await vi.advanceTimersByTimeAsync(60)
expect((await operation.done).waitReason).toBe('session_exit')
await closing
})
it('waits for SIGKILL recipients to leave the process table after the shell exits', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()