The local PTY readiness poll held its inferred_idle fallback for exactly one pollIntervalMs after a prompt marker, so a bash foreground handoff that lands on the silence boundary only wins the exact stdin_read attribution when the kernel publishes it inside that single poll. On a slow or loaded host it does not, and the attribution flips. handoffGraceMs replaces the hardcoded one-poll window as a validated, deployment-owned config field defaulting to 500ms, rejected at load when it cannot contain one readiness poll. Real-shell tests that interrupt a send now assert the session is usable again rather than which readiness tier observed the handoff, because no fixed grace removes the race.
82 lines
3.2 KiB
TypeScript
82 lines
3.2 KiB
TypeScript
/** Validated configuration for the local PTY backend. */
|
|
|
|
import z from 'schemastery'
|
|
|
|
/** Public plugin configuration. */
|
|
export interface Config {
|
|
/** Backend registry type (default: `shell`). */
|
|
backendType?: string
|
|
/** Interactive shell executable (default: `/bin/bash`). */
|
|
shellPath?: string
|
|
/** Shell arguments (default: `--noprofile --norc -i`). */
|
|
shellArgs?: string[]
|
|
/** Terminal rows. */
|
|
rows?: number
|
|
/** Terminal columns. */
|
|
cols?: number
|
|
/** Maximum retained logical lines. */
|
|
scrollbackLines?: number
|
|
/** Maximum retained UTF-8 bytes. */
|
|
scrollbackMaxBytes?: number
|
|
/** Maximum bytes returned by one read or settled viewport. */
|
|
maxReadBytes?: number
|
|
/** Readiness polling interval. */
|
|
pollIntervalMs?: number
|
|
/** Delay before Linux exact syscall probes. */
|
|
exactProbeAfterMs?: number
|
|
/** Silence duration that yields `inferred_idle`. */
|
|
idleSilenceMs?: number
|
|
/**
|
|
* Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
|
|
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
|
|
*/
|
|
handoffGraceMs?: number
|
|
/** Absolute send wait bound. */
|
|
timeoutMs?: number
|
|
/** Grace before teardown escalates to `SIGKILL`. */
|
|
disposeGraceMs?: number
|
|
}
|
|
|
|
/** Configuration after Schemastery defaults. */
|
|
export type ResolvedConfig = Required<Config>
|
|
|
|
/** Schemastery config exposed by the plugin. */
|
|
export const Config: z<Config> = z.object({
|
|
backendType: z.string().default('shell'),
|
|
shellPath: z.string().default('/bin/bash'),
|
|
shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']),
|
|
rows: z.number().default(40),
|
|
cols: z.number().default(160),
|
|
scrollbackLines: z.number().default(10_000),
|
|
scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
|
|
maxReadBytes: z.number().default(256 * 1024),
|
|
pollIntervalMs: z.number().default(50),
|
|
exactProbeAfterMs: z.number().default(150),
|
|
idleSilenceMs: z.number().default(3_000),
|
|
handoffGraceMs: z.number().default(500),
|
|
timeoutMs: z.number().default(30_000),
|
|
disposeGraceMs: z.number().default(3_000),
|
|
})
|
|
|
|
/**
|
|
* Assert every numeric config field is a positive safe integer and bounds compose.
|
|
* @param config - Schemastery-resolved plugin configuration.
|
|
* @returns Narrows the input to the fully resolved configuration.
|
|
*/
|
|
export function validateConfig(config: Config): asserts config is ResolvedConfig {
|
|
const resolved = config as ResolvedConfig
|
|
if (resolved.backendType.length === 0) throw new Error('pty-local: backendType must be non-empty')
|
|
if (resolved.shellPath.length === 0) throw new Error('pty-local: shellPath must be non-empty')
|
|
for (const [name, value] of Object.entries(resolved)) {
|
|
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
|
|
throw new Error(`pty-local: ${name} must be a positive safe integer`)
|
|
}
|
|
}
|
|
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
|
|
throw new Error('pty-local: maxReadBytes must not exceed scrollbackMaxBytes')
|
|
}
|
|
if (resolved.handoffGraceMs < resolved.pollIntervalMs) {
|
|
throw new Error('pty-local: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
|
|
}
|
|
}
|