🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

zigpty

Package Overview
Dependencies
Maintainers
1
Versions
15
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

zigpty - npm Package Compare versions

Comparing version
0.1.6
to
0.2.0
+233
dist/_chunks/types.d.mts
interface INativeChildStats {
pid: number;
name: string;
rssBytes: number;
cpuUser: number;
cpuSys: number;
}
interface INativeStats {
pid: number;
cwd: string | null;
rssBytes: number;
cpuUser: number;
cpuSys: number;
count: number;
children: INativeChildStats[];
}
interface INativeWindows {
spawn(file: string, args: string[], env: string[], cwd: string, cols: number, rows: number, onData: (data: Buffer) => void, onExit: (info: {
exitCode: number;
signal: number;
}) => void): {
pid: number;
handle: object;
};
write(handle: object, data: string): void;
resize(handle: object, cols: number, rows: number): void;
kill(handle: object): void;
close(handle: object): void;
stats(handle: object): INativeStats | undefined;
}
/** True when native Zig PTY bindings loaded successfully. */
declare const hasNative: boolean;
interface TerminalOptions {
/** Number of columns. Default: 80 */
cols?: number;
/** Number of rows. Default: 24 */
rows?: number;
/** Terminal type name (sets TERM env var). Default: "xterm-256color" */
name?: string;
/** Callback when data is received from the terminal. */
data?: (terminal: Terminal, data: Uint8Array) => void;
/** Callback when PTY stream closes (EOF or error). exitCode is PTY lifecycle status (0=EOF, 1=error). */
exit?: (terminal: Terminal, exitCode: number, signal: string | null) => void;
/** Callback when the terminal is ready for more data. */
drain?: (terminal: Terminal) => void;
}
/**
* Standalone terminal (PTY).
*
* Can be created standalone via `new Terminal()` or passed to `spawn()` via the
* `terminal` option for callback-based data handling.
*
* Supports `Disposable` (`using`) — close is synchronous.
*/
declare class Terminal implements Disposable {
stdin: number;
stdout: number;
private _closed;
private _cols;
private _rows;
private _name;
private _onData?;
private _onExit?;
private _onDrain?;
private _textDecoder;
/** @internal Listeners for waitFor support. */
_dataListeners: Array<(data: string) => void>;
private _readable?;
private _wq?;
private _winHandle?;
private _winNative?;
private _winReady;
private _winDeferred;
private _standalone;
constructor(options?: TerminalOptions);
get closed(): boolean;
write(data: string | Uint8Array): number;
resize(cols: number, rows: number): void;
ref(): void;
unref(): void;
close(): void;
[Symbol.dispose](): void;
/** @internal Attach to a fork's master fd (called from UnixTerminal). */
_attachUnixFd(fd: number): void;
/** @internal Attach a Windows ConPTY handle (called from WindowsTerminal). */
_attachWindows(winNative: INativeWindows, handle: object): void;
/** @internal Mark Windows terminal as ready and flush deferred calls. */
_markReady(): void;
/** @internal Emit data from native. */
_emitData(data: Uint8Array): void;
private _destroyReader;
private _setupUnixReader;
private _writeUnix;
private _writeWindows;
}
interface IEvent<T> {
(listener: (data: T) => void): IDisposable;
}
interface IDisposable {
dispose(): void;
}
/**
* A sink that consumes PTY output. Pass to {@link IPty.attach} to wire it
* onto a PTY's data stream.
*
* Anything with a `feed(data)` method conforms — including `OSCInspector`,
* a terminal recorder, a logger, etc. Optional lifecycle hooks let the
* consumer react to attach/detach (which also fires when the PTY exits).
*/
interface IPtyConsumer {
/** Receive a chunk of PTY output. */
feed(data: string | Buffer): void;
/** Optional: called once when attached, before the first `feed`. */
onAttach?(pty: IPty): void;
/** Optional: called once when detached (explicit dispose or PTY exit). */
onDetach?(pty: IPty): void;
}
interface IPtyChildStats {
/** Process ID. */
pid: number;
/** Short executable / command name (truncated to ~15 chars on Unix, up to 31 on Windows). */
name: string;
/** Resident set size (physical memory) in bytes. */
rssBytes: number;
/** Accumulated user-mode CPU time in microseconds. */
cpuUser: number;
/** Accumulated system-mode CPU time in microseconds. */
cpuSys: number;
}
interface IPtyStats {
/** Leader PID — the spawned process (e.g. the shell). */
pid: number;
/** Leader's current working directory. `null` when unavailable (always on Windows, or when the process has exited). */
cwd: string | null;
/** Total resident set size (physical memory) in bytes, aggregated across leader + descendants. */
rssBytes: number;
/** Total accumulated user-mode CPU time in microseconds, aggregated across leader + descendants. */
cpuUser: number;
/** Total accumulated system-mode CPU time in microseconds, aggregated across leader + descendants. */
cpuSys: number;
/** Total number of processes aggregated (leader + descendants). Always `>= 1`. */
count: number;
/**
* Non-leader transitive descendants (BFS by ppid) aggregated into the totals.
* Catches background jobs, subshells, pipelines, and grandchildren of the leader.
* Double-fork daemons that reparent to init/launchd are not tracked.
*/
children: IPtyChildStats[];
}
interface IPty extends AsyncDisposable {
/** Process ID of the spawned process. */
pid: number;
/** Number of columns. */
cols: number;
/** Number of rows. */
rows: number;
/** Name of the current foreground process. */
readonly process: string;
/** Whether to intercept flow control characters. */
handleFlowControl: boolean;
/** Promise that resolves with the exit code when the process exits. */
readonly exited: Promise<number>;
/** The exit code, or null if still running. */
readonly exitCode: number | null;
/** Fires when data is received from the PTY. */
onData: IEvent<string | Buffer>;
/** Fires when the process exits. */
onExit: IEvent<{
exitCode: number;
signal: number;
}>;
/** Write data to the PTY. */
write(data: string): void;
/** Resize the PTY. */
resize(cols: number, rows: number, pixelSize?: {
width: number;
height: number;
}): void;
/** Clear the PTY buffer (no-op on Unix). */
clear(): void;
/** Kill the process. */
kill(signal?: string): void;
/** Pause reading from the PTY. */
pause(): void;
/** Resume reading from the PTY. */
resume(): void;
/** Close the PTY, closing file descriptors and cleaning up resources. */
close(): void;
/** Wait until the output contains the given string. Resolves with all output collected so far. */
waitFor(pattern: string, options?: {
timeout?: number;
}): Promise<string>;
/** Snapshot OS-level stats (cwd, memory, CPU time) aggregated across the leader process and every transitive descendant. Returns null when unavailable. */
stats(): IPtyStats | null;
/**
* Attach a consumer to the PTY's output stream. The consumer's `feed`
* receives every data chunk. Returns an `IDisposable` to detach early;
* the consumer is also auto-detached when the PTY exits.
*/
attach(consumer: IPtyConsumer): IDisposable;
}
interface IPtyOpenOptions {
cols?: number;
rows?: number;
encoding?: BufferEncoding | null;
}
interface IOpenResult {
master: number;
slave: number;
pty: string;
}
interface IPtyOptions {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: Record<string, string>;
encoding?: BufferEncoding | null;
uid?: number;
gid?: number;
handleFlowControl?: boolean;
flowControlPause?: string;
flowControlResume?: string;
/** Terminal options or an existing Terminal instance. When provided, data flows through terminal callbacks. */
terminal?: TerminalOptions | Terminal;
/** Called when the process exits (alternative to onExit event). */
onExit?: (exitCode: number, signal: number) => void;
/** Force pipe-based PTY fallback even when native bindings are available. */
pipe?: boolean;
/** Treat the command as an interactive shell (auto-enables `-i`, raw mode, stderr merge). Auto-detected for known shells (bash, zsh, sh, fish, etc.) when unset. */
shell?: boolean;
}
export { IPtyConsumer as a, IPtyStats as c, hasNative as d, IPty as i, Terminal as l, IEvent as n, IPtyOpenOptions as o, IOpenResult as r, IPtyOptions as s, IDisposable as t, TerminalOptions as u };
import { a as IPtyConsumer, i as IPty } from "../_chunks/types.mjs";
import { Buffer } from "node:buffer";
/** Detector state — either nothing meaningful is flowing, or output is in flight. */
type IdleState = "idle" | "active";
/** Emitted on every state transition. */
interface IdleEvent {
/** New state after the transition. */
type: IdleState;
/** Significant content bytes (ANSI/control bytes excluded) accumulated for the output burst. */
bytes: number;
/** How long the previous state lasted, in ms. */
durationMs: number;
}
/** Listener for idle-detector transitions. */
type IdleListener = (event: IdleEvent) => void;
interface IdleDetectorOptions {
/**
* Quiet period (ms) with no significant bytes before transitioning
* `active` → `idle`. This is the main "attention" signal — when output
* stops, the agent is likely done or waiting for input. Default `750`.
*/
quietMs?: number;
/**
* Minimum significant bytes in a single burst (gaps shorter than
* `quietMs`) before transitioning `idle` → `active`. Tiny status-bar
* updates and cursor-blink redraws fall below this. Default `512`.
*/
activeThreshold?: number;
/**
* Grace period (ms) after attach during which significant bytes are
* silently absorbed without firing `active`. Filters out the initial
* shell-prompt / banner flood. Default `1500`.
*/
graceMs?: number;
}
/**
* Implicit terminal-attention detector.
*
* Watches a PTY's byte stream and emits an `idle` event when output stops
* after a burst of meaningful activity — typically meaning an interactive
* AI agent or REPL is done streaming and waiting for input.
*
* Designed to suppress common false positives:
* - **Startup flood**: bytes arriving within `graceMs` of attach are
* silently absorbed (shell init, banner, first prompt).
* - **Tiny UI updates**: status bars and cursor-blink redraws fall below
* `activeThreshold` (significant bytes per burst) and never enter active.
* - **ANSI/CSI/OSC sequences**: only printable content counts toward the
* threshold, so heavy color/escape output doesn't masquerade as text.
*
* @example
* ```ts
* const det = new IdleDetector((e) => {
* if (e.type === "idle") console.log("agent likely waiting for input");
* });
* pty.attach(det);
* ```
*/
declare class IdleDetector implements IPtyConsumer {
private _state;
private _bytesPending;
private _stateStart;
private _attachAt;
private _lastSigTime;
private _idleTimer;
private _escState;
private _listeners;
private readonly _quietMs;
private readonly _activeThreshold;
private readonly _graceMs;
constructor(listener?: IdleListener, options?: IdleDetectorOptions);
/** Subscribe to idle/active transitions. Returns a disposer. */
on(listener: IdleListener): () => void;
/** Current state (`idle` initially). */
get state(): IdleState;
/** Reset the grace window — called automatically by `pty.attach()`. */
onAttach(_pty: IPty): void;
onDetach(_pty: IPty): void;
/** Feed bytes into the detector. Accepts string (utf-8), Buffer, or Uint8Array. */
feed(data: string | Buffer | Uint8Array): void;
/** Drop all listeners and reset internal state. */
dispose(): void;
private _scheduleIdle;
private _countSignificant;
private _emit;
}
export { IdleDetector, type IdleDetectorOptions, type IdleEvent, type IdleListener, type IdleState };
import { Buffer } from "node:buffer";
const Ground = 0;
const Esc = 1;
const Csi = 2;
const Osc = 3;
const OscSt = 4;
var IdleDetector = class {
_state = "idle";
_bytesPending = 0;
_stateStart;
_attachAt;
_lastSigTime;
_idleTimer = null;
_escState = Ground;
_listeners = [];
_quietMs;
_activeThreshold;
_graceMs;
constructor(listener, options = {}) {
this._quietMs = options.quietMs ?? 750;
this._activeThreshold = options.activeThreshold ?? 512;
this._graceMs = options.graceMs ?? 1500;
const now = Date.now();
this._stateStart = now;
this._attachAt = now;
this._lastSigTime = now;
if (listener) this._listeners.push(listener);
}
on(listener) {
this._listeners.push(listener);
return () => {
const idx = this._listeners.indexOf(listener);
if (idx >= 0) this._listeners.splice(idx, 1);
};
}
get state() {
return this._state;
}
onAttach(_pty) {
const now = Date.now();
this._attachAt = now;
this._stateStart = now;
this._lastSigTime = now;
}
onDetach(_pty) {
this.dispose();
}
feed(data) {
const buf = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.isBuffer(data) ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
const sig = this._countSignificant(buf);
if (sig === 0) return;
const now = Date.now();
if (this._state === "idle") {
if (now - this._attachAt < this._graceMs) {
this._lastSigTime = now;
return;
}
if (now - this._lastSigTime > this._quietMs) this._bytesPending = 0;
this._lastSigTime = now;
this._bytesPending += sig;
if (this._bytesPending >= this._activeThreshold) {
const prevBytes = this._bytesPending;
const prevDuration = now - this._stateStart;
this._state = "active";
this._stateStart = now;
this._emit({
type: "active",
bytes: prevBytes,
durationMs: prevDuration
});
this._scheduleIdle();
}
} else {
this._lastSigTime = now;
this._bytesPending += sig;
this._scheduleIdle();
}
}
dispose() {
if (this._idleTimer) {
clearTimeout(this._idleTimer);
this._idleTimer = null;
}
this._listeners.length = 0;
this._state = "idle";
this._bytesPending = 0;
this._escState = Ground;
}
_scheduleIdle() {
if (this._idleTimer) clearTimeout(this._idleTimer);
const timer = setTimeout(() => {
this._idleTimer = null;
const now = Date.now();
const prevBytes = this._bytesPending;
const prevDuration = now - this._stateStart;
this._state = "idle";
this._bytesPending = 0;
this._stateStart = now;
this._emit({
type: "idle",
bytes: prevBytes,
durationMs: prevDuration
});
}, this._quietMs);
if (typeof timer.unref === "function") timer.unref();
this._idleTimer = timer;
}
_countSignificant(buf) {
let count = 0;
for (let i = 0; i < buf.length; i++) {
const b = buf[i];
switch (this._escState) {
case Ground:
if (b === 27) this._escState = Esc;
else if (b === 10 || b === 9) count++;
else if (b >= 32 && b !== 127) count++;
break;
case Esc:
if (b === 91) this._escState = Csi;
else if (b === 93) this._escState = Osc;
else this._escState = Ground;
break;
case Csi:
if (b >= 64 && b <= 126) this._escState = Ground;
break;
case Osc:
if (b === 7) this._escState = Ground;
else if (b === 27) this._escState = OscSt;
break;
case OscSt:
if (b === 92) this._escState = Ground;
else if (b === 27) this._escState = Esc;
else this._escState = Ground;
break;
}
}
return count;
}
_emit(event) {
for (const l of this._listeners) try {
l(event);
} catch {}
}
};
export { IdleDetector };
import { a as IPtyConsumer } from "../_chunks/types.mjs";
import { Buffer } from "node:buffer";
/** Raw OSC event emitted by the parser. */
interface OSCEvent {
/** Numeric OSC code (e.g. 0, 7, 133, 633, 9, 99, 1337). `-1` if absent. */
code: number;
/** Raw payload after the leading `code;` (or the whole body if no `;`). */
payload: string;
}
/** Decoded shapes for well-known OSC codes. */
type DecodedOSC = {
kind: "title";
code: 0 | 1 | 2;
title: string;
} | {
kind: "cwd"; /** Where this CWD was reported from. */
source: "osc7" | "conemu" | "iterm"; /** Decoded filesystem path (percent-decoded for OSC 7). */
path: string; /** Raw URI — only present for OSC 7. */
uri?: string; /** URI scheme (`file`, `kitty-shell-cwd`, …) — only present for OSC 7. */
scheme?: string; /** Host from the OSC 7 URI authority — `undefined` when empty. */
host?: string; /** True when `host` is empty or `localhost` (OSC 7). */
local?: boolean;
} | {
kind: "shellIntegration";
vendor: "vt" | "vscode"; /** Sub-command letter or word (e.g. `A`, `B`, `C`, `D`, `EnvSingleStart`). */
command: string; /** Remainder after the command, joined by `;`. Empty when no data. */
data: string; /** Parsed exit code for `D`. */
exitCode?: number; /** Parsed `err=` value for OSC 133 `D` (empty string = success). */
err?: string; /** Parsed `key=value` extras (kitty `A`/`C`; vscode `P`). */
params?: Record<string, string>; /** Parsed key for vscode `P;<Key>=<Value>` / `EnvSingleEntry`. */
key?: string; /** Parsed value for vscode `P;<Key>=<Value>` / `EnvSingleEntry`. */
value?: string; /** Parsed command line for vscode `E`. */
commandLine?: string; /** Parsed nonce for vscode `E` / `EnvSingle*`. */
nonce?: string; /** Index for vscode `EnvSingleStart`. */
index?: number;
} | {
kind: "notification";
vendor: "iterm" | "conemu" | "kitty" | "rxvt";
title?: string;
body?: string; /** kitty: notification identifier ties chunks together. */
id?: string; /** kitty: 0=low, 1=normal, 2=critical. */
urgency?: 0 | 1 | 2; /** kitty: `d=0` — more chunks pending. */
partial?: boolean; /** kitty: non-payload phase (`close`, `alive`, `icon`, `buttons`, `?`). */
phase?: string;
raw: string;
} | {
kind: "progress"; /** 0=remove, 1=normal, 2=error, 3=indeterminate, 4=paused. */
state: number; /** 0-100. Omitted for states 0/3 and optional for 2/4. */
value?: number;
} | {
kind: "attention";
vendor: "iterm";
action: "request" | "cancel";
effect?: "fireworks" | "once";
value: string;
raw: string;
} | {
kind: "hyperlink"; /** `open` = active hyperlink begins; `close` = empty-URI terminator. */
action: "open" | "close";
uri: string;
id?: string;
params: Record<string, string>;
} | {
kind: "clipboard"; /** Raw `Pc` field (may be empty for default `s0`, may be multi-char). */
selection: string; /** `Pc` split into individual selection chars (`cs` → `['c','s']`). */
selections: string[]; /** Base64-encoded data (when setting). */
data?: string; /** True for `?` query. */
query?: boolean; /** True when `Pd` is neither base64 nor `?` (xterm-spec: clear clipboard). */
clear?: boolean;
} | {
kind: "mark";
vendor: "iterm" | "conemu";
raw: string;
} | {
kind: "userVar";
vendor: "iterm";
name: string; /** Base64-decoded value. */
value: string;
raw: string;
} | {
kind: "remoteHost";
vendor: "iterm";
user?: string;
host: string;
raw: string;
} | {
kind: "shellIntegrationVersion";
vendor: "iterm";
version: string;
raw: string;
} | {
kind: "unknown";
code: number;
payload: string;
};
/** Listener for raw OSC events. */
type OSCListener = (event: OSCEvent) => void;
/**
* Terminal state derived from OSC sequences seen so far.
*
* Populated by {@link OSCInspector} as it parses incoming bytes. Only
* sequences that represent durable, observable state are folded in here —
* action-like sequences (clipboard writes, notifications, marks, attention
* requests) are still emitted to listeners but don't update state.
*/
interface OSCState {
/** Window title — last value from OSC 0 or OSC 2. */
title?: string;
/** Icon / tab name — last value from OSC 0 or OSC 1. */
iconName?: string;
/** Current working directory — from OSC 7, OSC 1337 `CurrentDir`, or OSC 9;9. */
cwd?: {
path: string;
source: "osc7" | "conemu" | "iterm"; /** Host from the OSC 7 URI authority (only when present and non-empty). */
host?: string;
};
/** Active hyperlink between OSC 8 open and OSC 8 close. */
hyperlink?: {
uri: string;
id?: string;
params: Record<string, string>;
};
/** Latest taskbar progress (OSC 9;4). Cleared when state 0 is reported. */
progress?: {
state: number;
value?: number;
};
/** Remote host (OSC 1337 `RemoteHost`). */
remoteHost?: {
user?: string;
host: string;
};
/** iTerm shell-integration version (OSC 1337 `ShellIntegrationVersion`). */
shellIntegrationVersion?: string;
/** User-defined variables set via OSC 1337 `SetUserVar`. */
userVars?: Record<string, string>;
}
/** Listener for state changes. Called after each OSC sequence that mutated state. */
type OSCStateListener = (state: Readonly<OSCState>) => void;
/** Signature for an OSC decoder. `payload` is split out for ergonomics. */
type OSCDecoderFn<T> = (payload: string, event: OSCEvent) => T;
/** A map of OSC code → decoder. Used by both built-ins and custom decoders. */
type OSCDecoderMap<T> = Record<number, OSCDecoderFn<T>>;
/** Union of return types from a custom decoder map — used to type the result of {@link createOSCDecoder}. */
type CustomDecodedOSC<Map> = Map[keyof Map] extends ((...args: never) => infer R) ? R : never;
/**
* Pure-TS OSC (Operating System Command) inspector.
*
* Feed any byte stream — typically a PTY's data stream — and receive a
* callback per recognized OSC sequence. The parser is a byte-fed state
* machine, so sequences split across feed calls are stitched back together.
*
* @example
* ```ts
* const inspector = new OSCInspector((event) => {
* console.log(`OSC ${event.code}: ${event.payload}`);
* });
* pty.attach(inspector);
* ```
*/
declare class OSCInspector implements IPtyConsumer {
private _state;
private _buf;
private _len;
private _overflow;
private _listeners;
private _stateListeners;
/**
* Terminal state derived from the sequences seen so far. Mutated in place
* before listeners fire, so handlers can read fresh values. Treat as
* read-only — direct mutation will not notify state listeners.
*/
readonly state: OSCState;
constructor(listener?: OSCListener);
/** Subscribe to OSC events. Returns a disposer. */
on(listener: OSCListener): () => void;
/**
* Subscribe to state changes. The listener is invoked after each OSC
* sequence that mutated {@link state}. Returns a disposer.
*/
onStateChange(listener: OSCStateListener): () => void;
/** Feed bytes into the parser. Accepts string (utf-8), Buffer, or Uint8Array. */
feed(data: string | Buffer | Uint8Array): void;
/** Drop all listeners and reset parser + derived state. */
dispose(): void;
private _feedByte;
private _finish;
private _applyToState;
}
/**
* Built-in OSC decoders keyed by code. Exposed so callers can inspect,
* reuse, or layer their own decoders on top via {@link createOSCDecoder}.
*
* Mutating this object is supported but considered a global side-effect —
* prefer passing custom decoders to {@link createOSCDecoder} instead.
*/
declare const builtinOSCDecoders: OSCDecoderMap<DecodedOSC>;
/**
* Build a decoder function that runs custom decoders first, then falls back
* to the built-ins, then to `{ kind: "unknown", code, payload }`.
*
* Custom decoders may register handlers for any OSC code — including unknown
* ones — and may override built-in codes. The returned type is the union of
* {@link DecodedOSC} and every custom decoder's return type, so the result
* is fully typed in user code.
*
* @example
* ```ts
* const decode = createOSCDecoder({
* 50: (p) => ({ kind: "screen-mode", mode: p } as const),
* 1234: (p, e) => ({ kind: "x", code: e.code, raw: p } as const),
* });
*
* const d = decode(event);
* // d is DecodedOSC | { kind: "screen-mode"; ... } | { kind: "x"; ... }
* ```
*/
declare function createOSCDecoder(): (event: OSCEvent) => DecodedOSC;
declare function createOSCDecoder<Map extends Record<number, OSCDecoderFn<unknown>>>(custom: Map): (event: OSCEvent) => DecodedOSC | CustomDecodedOSC<Map>;
/**
* Decode a raw OSC event into a typed shape for well-known codes.
*
* Returns `{ kind: "unknown", code, payload }` for unrecognized codes — the
* raw event is always preserved so callers can implement custom decoders
* (see {@link createOSCDecoder} for extending the decoder with new codes).
*/
declare const decodeOSC: (event: OSCEvent) => DecodedOSC;
export { type CustomDecodedOSC, type DecodedOSC, type OSCDecoderFn, type OSCDecoderMap, type OSCEvent, OSCInspector, type OSCListener, type OSCState, type OSCStateListener, builtinOSCDecoders, createOSCDecoder, decodeOSC };
import { Buffer } from "node:buffer";
function stripControls(s) {
return s.replace(/[\x00-\x1f\x7f]/g, "");
}
function safeBase64Decode(s) {
try {
return Buffer.from(s, "base64").toString("utf8");
} catch {
return s;
}
}
function unquote(s) {
if (s.length >= 2 && s[0] === "\"" && s[s.length - 1] === "\"") return s.slice(1, -1);
return s;
}
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
function vscodeUnescape(s) {
let out = "";
let i = 0;
while (i < s.length) {
if (s.charCodeAt(i) === 92 && i + 1 < s.length) {
const n = s.charCodeAt(i + 1);
if (n === 92) {
out += "\\";
i += 2;
continue;
}
if (n === 120 && i + 3 < s.length) {
const hex = s.slice(i + 2, i + 4);
if (/^[0-9a-fA-F]{2}$/.test(hex)) {
out += String.fromCharCode(Number.parseInt(hex, 16));
i += 4;
continue;
}
}
}
out += s[i];
i++;
}
return out;
}
const titleDecoder = (payload, event) => ({
kind: "title",
code: event.code,
title: stripControls(payload)
});
const cwdDecoder = (payload) => {
const m = /^([a-zA-Z][a-zA-Z0-9+.\-]*):\/\/([^/]*)(\/.*)?$/.exec(payload);
if (m) {
const scheme = m[1];
const host = m[2] || void 0;
const rawPath = m[3] ?? "";
let path;
try {
path = decodeURIComponent(rawPath);
} catch {
path = rawPath;
}
return {
kind: "cwd",
source: "osc7",
uri: payload,
scheme,
host,
path,
local: !host || host === "localhost"
};
}
return {
kind: "cwd",
source: "osc7",
uri: payload,
path: payload
};
};
const shellIntegrationDecoder = (vendor) => (payload) => {
const semi = payload.indexOf(";");
const command = semi >= 0 ? payload.slice(0, semi) : payload;
const data = semi >= 0 ? payload.slice(semi + 1) : "";
const out = {
kind: "shellIntegration",
vendor,
command,
data
};
if (vendor === "vt") {
if (command === "D" && data) {
const parts = data.split(";");
const head = parts[0];
if (/^\d+$/.test(head)) out.exitCode = Number(head);
for (const p of parts) if (p.startsWith("err=")) out.err = p.slice(4);
}
if ((command === "A" || command === "C") && data) {
const params = {};
for (const p of data.split(";")) {
const eq = p.indexOf("=");
if (eq >= 0) params[p.slice(0, eq)] = p.slice(eq + 1);
}
if (Object.keys(params).length > 0) out.params = params;
}
return out;
}
if (command === "D" && data) {
if (/^\d+$/.test(data)) out.exitCode = Number(data);
} else if (command === "P") {
const eq = data.indexOf("=");
if (eq >= 0) {
out.key = data.slice(0, eq);
out.value = vscodeUnescape(data.slice(eq + 1));
}
} else if (command === "E") {
const parts = data.split(";");
out.commandLine = vscodeUnescape(parts[0] ?? "");
if (parts.length > 1) out.nonce = parts[parts.length - 1];
} else if (command === "EnvSingleStart") {
const parts = data.split(";");
if (parts[0] && /^\d+$/.test(parts[0])) out.index = Number(parts[0]);
if (parts[1]) out.nonce = parts[1];
} else if (command === "EnvSingleEntry") {
const parts = data.split(";");
if (parts[0]) out.key = parts[0];
if (parts[1] !== void 0) out.value = vscodeUnescape(parts[1]);
if (parts[2]) out.nonce = parts[2];
} else if (command === "EnvSingleEnd") out.nonce = data;
return out;
};
const CONEMU_SUBCMDS = new Set([
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12"
]);
const osc9Decoder = (payload, event) => {
const semi = payload.indexOf(";");
const head = semi >= 0 ? payload.slice(0, semi) : payload;
const rest = semi >= 0 ? payload.slice(semi + 1) : "";
if (head === "4") {
const parts = rest.split(";");
const state = Number(parts[0] ?? 0);
const valueRaw = parts[1];
const out = {
kind: "progress",
state
};
if (valueRaw !== void 0 && valueRaw !== "") out.value = Number(valueRaw);
return out;
}
if (head === "9") return {
kind: "cwd",
source: "conemu",
path: unquote(rest)
};
if (head === "12") return {
kind: "mark",
vendor: "conemu",
raw: payload
};
if (CONEMU_SUBCMDS.has(head)) return {
kind: "unknown",
code: event.code,
payload
};
return {
kind: "notification",
vendor: "iterm",
body: payload,
raw: payload
};
};
const osc99Decoder = (payload) => {
const semi = payload.indexOf(";");
const meta = semi >= 0 ? payload.slice(0, semi) : payload;
const rawValue = semi >= 0 ? payload.slice(semi + 1) : "";
const fields = {};
for (const kv of meta.split(":")) {
if (!kv) continue;
const eq = kv.indexOf("=");
if (eq >= 0) fields[kv.slice(0, eq)] = kv.slice(eq + 1);
else fields[kv] = "";
}
const phase = fields.p ?? "title";
const value = fields.e === "1" ? safeBase64Decode(rawValue) : rawValue;
const out = {
kind: "notification",
vendor: "kitty",
raw: payload
};
if (fields.i) out.id = fields.i;
if (fields.u === "0" || fields.u === "1" || fields.u === "2") out.urgency = Number(fields.u);
if (fields.d === "0") out.partial = true;
if (phase === "title") out.title = value;
else if (phase === "body") out.body = value;
else out.phase = phase;
return out;
};
const osc1337Decoder = (payload, event) => {
if (payload === "RequestAttention" || payload.startsWith("RequestAttention=")) {
const value = payload.includes("=") ? payload.slice(payload.indexOf("=") + 1) : "yes";
const action = value === "no" ? "cancel" : "request";
const effect = value === "fireworks" || value === "once" ? value : void 0;
return {
kind: "attention",
vendor: "iterm",
action,
...effect ? { effect } : {},
value,
raw: payload
};
}
if (payload === "SetMark") return {
kind: "mark",
vendor: "iterm",
raw: payload
};
if (payload.startsWith("CurrentDir=")) return {
kind: "cwd",
source: "iterm",
path: payload.slice(11)
};
if (payload.startsWith("SetUserVar=")) {
const rest = payload.slice(11);
const eq = rest.indexOf("=");
if (eq >= 0) return {
kind: "userVar",
vendor: "iterm",
name: rest.slice(0, eq),
value: safeBase64Decode(rest.slice(eq + 1)),
raw: payload
};
}
if (payload.startsWith("RemoteHost=")) {
const v = payload.slice(11);
const at = v.indexOf("@");
return {
kind: "remoteHost",
vendor: "iterm",
...at >= 0 ? {
user: v.slice(0, at),
host: v.slice(at + 1)
} : { host: v },
raw: payload
};
}
if (payload.startsWith("ShellIntegrationVersion=")) return {
kind: "shellIntegrationVersion",
vendor: "iterm",
version: payload.slice(24),
raw: payload
};
if (payload.startsWith("Copy=")) {
const rest = payload.slice(5);
const colon = rest.indexOf(":");
const sel = colon >= 0 ? rest.slice(0, colon) : "";
const data = colon >= 0 ? rest.slice(colon + 1) : rest;
return {
kind: "clipboard",
selection: sel,
selections: sel ? [...sel] : [],
data
};
}
return {
kind: "unknown",
code: event.code,
payload
};
};
const osc777Decoder = (payload, event) => {
if (payload.startsWith("notify;")) {
const parts = payload.slice(7).split(";");
return {
kind: "notification",
vendor: "rxvt",
title: parts[0] ?? "",
body: parts.slice(1).join(";"),
raw: payload
};
}
return {
kind: "unknown",
code: event.code,
payload
};
};
const hyperlinkDecoder = (payload) => {
const semi = payload.indexOf(";");
const paramStr = semi >= 0 ? payload.slice(0, semi) : "";
const uri = semi >= 0 ? payload.slice(semi + 1) : "";
const params = {};
if (paramStr) for (const kv of paramStr.split(":")) {
if (!kv) continue;
const eq = kv.indexOf("=");
if (eq >= 0) params[kv.slice(0, eq)] = kv.slice(eq + 1);
else params[kv] = "";
}
const action = uri === "" ? "close" : "open";
return params.id !== void 0 ? {
kind: "hyperlink",
action,
uri,
id: params.id,
params
} : {
kind: "hyperlink",
action,
uri,
params
};
};
const clipboardDecoder = (payload) => {
const semi = payload.indexOf(";");
const selection = semi >= 0 ? payload.slice(0, semi) : payload;
const value = semi >= 0 ? payload.slice(semi + 1) : "";
const selections = selection ? [...selection] : [];
if (value === "?") return {
kind: "clipboard",
selection,
selections,
query: true
};
if (!BASE64_RE.test(value)) return {
kind: "clipboard",
selection,
selections,
clear: true
};
return {
kind: "clipboard",
selection,
selections,
data: value
};
};
const builtinOSCDecoders = {
0: titleDecoder,
1: titleDecoder,
2: titleDecoder,
7: cwdDecoder,
8: hyperlinkDecoder,
9: osc9Decoder,
52: clipboardDecoder,
99: osc99Decoder,
133: shellIntegrationDecoder("vt"),
633: shellIntegrationDecoder("vscode"),
777: osc777Decoder,
1337: osc1337Decoder
};
function createOSCDecoder(custom) {
if (!custom) return decodeBuiltin;
return (event) => {
const fn = custom[event.code];
if (fn) return fn(event.payload, event);
return decodeBuiltin(event);
};
}
function decodeBuiltin(event) {
const fn = builtinOSCDecoders[event.code];
if (fn) return fn(event.payload, event);
return {
kind: "unknown",
code: event.code,
payload: event.payload
};
}
const decodeOSC = decodeBuiltin;
const MAX_PAYLOAD = 4096;
const Ground = 0;
const Esc = 1;
const Osc = 2;
const OscSt = 3;
var OSCInspector = class {
_state = Ground;
_buf = Buffer.allocUnsafe(MAX_PAYLOAD);
_len = 0;
_overflow = false;
_listeners = [];
_stateListeners = [];
state = {};
constructor(listener) {
if (listener) this._listeners.push(listener);
}
on(listener) {
this._listeners.push(listener);
return () => {
const idx = this._listeners.indexOf(listener);
if (idx >= 0) this._listeners.splice(idx, 1);
};
}
onStateChange(listener) {
this._stateListeners.push(listener);
return () => {
const idx = this._stateListeners.indexOf(listener);
if (idx >= 0) this._stateListeners.splice(idx, 1);
};
}
feed(data) {
const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.isBuffer(data) ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
for (let i = 0; i < bytes.length; i++) this._feedByte(bytes[i]);
}
dispose() {
this._listeners.length = 0;
this._stateListeners.length = 0;
this._state = Ground;
this._len = 0;
this._overflow = false;
for (const k of Object.keys(this.state)) delete this.state[k];
}
_feedByte(b) {
if (b === 24 || b === 26) {
this._state = Ground;
this._len = 0;
this._overflow = false;
return;
}
switch (this._state) {
case Ground:
if (b === 27) this._state = Esc;
return;
case Esc:
if (b === 93) {
this._state = Osc;
this._len = 0;
this._overflow = false;
} else if (b !== 27) this._state = Ground;
return;
case Osc:
if (b === 7) this._finish();
else if (b === 27) this._state = OscSt;
else if (!this._overflow) if (this._len === MAX_PAYLOAD) this._overflow = true;
else this._buf[this._len++] = b;
return;
case OscSt:
if (b === 92) this._finish();
else if (b === 27) {
this._state = Esc;
this._len = 0;
this._overflow = false;
} else {
this._state = Ground;
this._len = 0;
this._overflow = false;
}
return;
}
}
_finish() {
if (!this._overflow && this._len > 0) {
const data = this._buf.toString("utf8", 0, this._len);
let code = -1;
let payload = data;
const semi = data.indexOf(";");
const codeStr = semi >= 0 ? data.slice(0, semi) : data;
if (codeStr.length > 0 && /^\d+$/.test(codeStr)) {
code = Number(codeStr);
payload = semi >= 0 ? data.slice(semi + 1) : "";
} else if (codeStr.length === 0) payload = semi >= 0 ? data.slice(semi + 1) : "";
const event = {
code,
payload
};
const mutated = this._applyToState(decodeOSC(event));
for (const l of this._listeners) try {
l(event);
} catch {}
if (mutated) for (const l of this._stateListeners) try {
l(this.state);
} catch {}
}
this._state = Ground;
this._len = 0;
this._overflow = false;
}
_applyToState(d) {
const s = this.state;
switch (d.kind) {
case "title":
if (d.code === 0) {
s.title = d.title;
s.iconName = d.title;
} else if (d.code === 1) s.iconName = d.title;
else s.title = d.title;
return true;
case "cwd":
s.cwd = d.host ? {
path: d.path,
source: d.source,
host: d.host
} : {
path: d.path,
source: d.source
};
return true;
case "hyperlink":
if (d.action === "close") {
if (s.hyperlink === void 0) return false;
s.hyperlink = void 0;
} else s.hyperlink = d.id ? {
uri: d.uri,
id: d.id,
params: d.params
} : {
uri: d.uri,
params: d.params
};
return true;
case "progress":
if (d.state === 0) {
if (s.progress === void 0) return false;
s.progress = void 0;
} else s.progress = d.value === void 0 ? { state: d.state } : {
state: d.state,
value: d.value
};
return true;
case "remoteHost":
s.remoteHost = d.user ? {
user: d.user,
host: d.host
} : { host: d.host };
return true;
case "shellIntegrationVersion":
s.shellIntegrationVersion = d.version;
return true;
case "userVar": {
const vars = s.userVars ?? (s.userVars = {});
vars[d.name] = d.value;
return true;
}
default: return false;
}
}
};
export { OSCInspector, builtinOSCDecoders, createOSCDecoder, decodeOSC };
+4
-211

@@ -1,211 +0,2 @@

interface INativeChildStats {
pid: number;
name: string;
rssBytes: number;
cpuUser: number;
cpuSys: number;
}
interface INativeStats {
pid: number;
cwd: string | null;
rssBytes: number;
cpuUser: number;
cpuSys: number;
count: number;
children: INativeChildStats[];
}
interface INativeWindows {
spawn(file: string, args: string[], env: string[], cwd: string, cols: number, rows: number, onData: (data: Buffer) => void, onExit: (info: {
exitCode: number;
signal: number;
}) => void): {
pid: number;
handle: object;
};
write(handle: object, data: string): void;
resize(handle: object, cols: number, rows: number): void;
kill(handle: object): void;
close(handle: object): void;
stats(handle: object): INativeStats | undefined;
}
/** True when native Zig PTY bindings loaded successfully. */
declare const hasNative: boolean;
interface TerminalOptions {
/** Number of columns. Default: 80 */
cols?: number;
/** Number of rows. Default: 24 */
rows?: number;
/** Terminal type name (sets TERM env var). Default: "xterm-256color" */
name?: string;
/** Callback when data is received from the terminal. */
data?: (terminal: Terminal, data: Uint8Array) => void;
/** Callback when PTY stream closes (EOF or error). exitCode is PTY lifecycle status (0=EOF, 1=error). */
exit?: (terminal: Terminal, exitCode: number, signal: string | null) => void;
/** Callback when the terminal is ready for more data. */
drain?: (terminal: Terminal) => void;
}
/**
* Standalone terminal (PTY).
*
* Can be created standalone via `new Terminal()` or passed to `spawn()` via the
* `terminal` option for callback-based data handling.
*
* Supports `AsyncDisposable` (`await using`).
*/
declare class Terminal implements AsyncDisposable {
stdin: number;
stdout: number;
private _closed;
private _cols;
private _rows;
private _name;
private _onData?;
private _onExit?;
private _onDrain?;
private _textDecoder;
/** @internal Listeners for waitFor support. */
_dataListeners: Array<(data: string) => void>;
private _readable?;
private _wq?;
private _winHandle?;
private _winNative?;
private _winReady;
private _winDeferred;
private _standalone;
constructor(options?: TerminalOptions);
get closed(): boolean;
write(data: string | Uint8Array): number;
resize(cols: number, rows: number): void;
ref(): void;
unref(): void;
close(): void;
[Symbol.asyncDispose](): Promise<void>;
/** @internal Attach to a fork's master fd (called from UnixTerminal). */
_attachUnixFd(fd: number): void;
/** @internal Attach a Windows ConPTY handle (called from WindowsTerminal). */
_attachWindows(winNative: INativeWindows, handle: object): void;
/** @internal Mark Windows terminal as ready and flush deferred calls. */
_markReady(): void;
/** @internal Emit data from native. */
_emitData(data: Uint8Array): void;
private _destroyReader;
private _setupUnixReader;
private _writeUnix;
private _writeWindows;
}
interface IEvent<T> {
(listener: (data: T) => void): IDisposable;
}
interface IDisposable {
dispose(): void;
}
interface IPtyChildStats {
/** Process ID. */
pid: number;
/** Short executable / command name (truncated to ~15 chars on Unix, up to 31 on Windows). */
name: string;
/** Resident set size (physical memory) in bytes. */
rssBytes: number;
/** Accumulated user-mode CPU time in microseconds. */
cpuUser: number;
/** Accumulated system-mode CPU time in microseconds. */
cpuSys: number;
}
interface IPtyStats {
/** Leader PID — the spawned process (e.g. the shell). */
pid: number;
/** Leader's current working directory. `null` when unavailable (always on Windows, or when the process has exited). */
cwd: string | null;
/** Total resident set size (physical memory) in bytes, aggregated across leader + descendants. */
rssBytes: number;
/** Total accumulated user-mode CPU time in microseconds, aggregated across leader + descendants. */
cpuUser: number;
/** Total accumulated system-mode CPU time in microseconds, aggregated across leader + descendants. */
cpuSys: number;
/** Total number of processes aggregated (leader + descendants). Always `>= 1`. */
count: number;
/**
* Non-leader transitive descendants (BFS by ppid) aggregated into the totals.
* Catches background jobs, subshells, pipelines, and grandchildren of the leader.
* Double-fork daemons that reparent to init/launchd are not tracked.
*/
children: IPtyChildStats[];
}
interface IPty {
/** Process ID of the spawned process. */
pid: number;
/** Number of columns. */
cols: number;
/** Number of rows. */
rows: number;
/** Name of the current foreground process. */
readonly process: string;
/** Whether to intercept flow control characters. */
handleFlowControl: boolean;
/** Promise that resolves with the exit code when the process exits. */
readonly exited: Promise<number>;
/** The exit code, or null if still running. */
readonly exitCode: number | null;
/** Fires when data is received from the PTY. */
onData: IEvent<string | Buffer>;
/** Fires when the process exits. */
onExit: IEvent<{
exitCode: number;
signal: number;
}>;
/** Write data to the PTY. */
write(data: string): void;
/** Resize the PTY. */
resize(cols: number, rows: number, pixelSize?: {
width: number;
height: number;
}): void;
/** Clear the PTY buffer (no-op on Unix). */
clear(): void;
/** Kill the process. */
kill(signal?: string): void;
/** Pause reading from the PTY. */
pause(): void;
/** Resume reading from the PTY. */
resume(): void;
/** Close the PTY, closing file descriptors and cleaning up resources. */
close(): void;
/** Wait until the output contains the given string. Resolves with all output collected so far. */
waitFor(pattern: string, options?: {
timeout?: number;
}): Promise<string>;
/** Snapshot OS-level stats (cwd, memory, CPU time) aggregated across the leader process and every transitive descendant. Returns null when unavailable. */
stats(): IPtyStats | null;
}
interface IPtyOpenOptions {
cols?: number;
rows?: number;
encoding?: BufferEncoding | null;
}
interface IOpenResult {
master: number;
slave: number;
pty: string;
}
interface IPtyOptions {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: Record<string, string>;
encoding?: BufferEncoding | null;
uid?: number;
gid?: number;
handleFlowControl?: boolean;
flowControlPause?: string;
flowControlResume?: string;
/** Terminal options or an existing Terminal instance. When provided, data flows through terminal callbacks. */
terminal?: TerminalOptions | Terminal;
/** Called when the process exits (alternative to onExit event). */
onExit?: (exitCode: number, signal: number) => void;
/** Force pipe-based PTY fallback even when native bindings are available. */
pipe?: boolean;
/** Treat the command as an interactive shell (auto-enables `-i`, raw mode, stderr merge). Auto-detected for known shells (bash, zsh, sh, fish, etc.) when unset. */
shell?: boolean;
}
import { a as IPtyConsumer, c as IPtyStats, d as hasNative, i as IPty, l as Terminal, n as IEvent, o as IPtyOpenOptions, r as IOpenResult, s as IPtyOptions, t as IDisposable, u as TerminalOptions } from "./_chunks/types.mjs";
declare abstract class BasePty implements IPty {

@@ -235,5 +26,7 @@ pid: number;

}>;
attach(consumer: IPtyConsumer): IDisposable;
waitFor(pattern: string, options?: {
timeout?: number;
}): Promise<string>;
[Symbol.asyncDispose](): Promise<void>;
protected _handleExit(info: {

@@ -290,2 +83,2 @@ exitCode: number;

declare function open(options?: IPtyOpenOptions): IOpenResult;
export { type IDisposable, type IEvent, type IOpenResult, type IPty, type IPtyOpenOptions, type IPtyOptions, PipePty, Terminal, type TerminalOptions, hasNative, open, spawn };
export { type IDisposable, type IEvent, type IOpenResult, type IPty, type IPtyConsumer, type IPtyOpenOptions, type IPtyOptions, PipePty, Terminal, type TerminalOptions, hasNative, open, spawn };

@@ -168,3 +168,3 @@ import { createRequire } from "node:module";

}
async [Symbol.asyncDispose]() {
[Symbol.dispose]() {
this.close();

@@ -278,2 +278,35 @@ }

}
attach(consumer) {
consumer.onAttach?.(this);
const feed = (data) => {
try {
consumer.feed(data);
} catch {}
};
let dataSub;
let terminalListener;
if (this._terminal) {
terminalListener = feed;
this._terminal._dataListeners.push(terminalListener);
} else dataSub = this.onData(feed);
let detached = false;
const detach = () => {
if (detached) return;
detached = true;
dataSub?.dispose();
if (terminalListener) {
const listeners = this._terminal?._dataListeners;
if (listeners) {
const idx = listeners.indexOf(terminalListener);
if (idx >= 0) listeners.splice(idx, 1);
}
}
exitSub.dispose();
try {
consumer.onDetach?.(this);
} catch {}
};
const exitSub = this.onExit(detach);
return { dispose: detach };
}
waitFor(pattern, options) {

@@ -321,2 +354,6 @@ const timeout = options?.timeout ?? 3e4;

}
async [Symbol.asyncDispose]() {
this.close();
await this._exited;
}
_handleExit(info) {

@@ -323,0 +360,0 @@ this._closed = true;

{
"name": "zigpty",
"version": "0.1.6",
"version": "0.2.0",
"repository": "pithings/zigpty",

@@ -14,3 +14,5 @@ "workspaces": [

"exports": {
".": "./dist/index.mjs"
".": "./dist/index.mjs",
"./osc": "./dist/osc/index.mjs",
"./idle": "./dist/idle/index.mjs"
},

@@ -17,0 +19,0 @@ "scripts": {

+178
-8

@@ -48,3 +48,3 @@ # zigpty

The `Terminal` class can be reused across multiple spawns and supports `AsyncDisposable`:
Both `spawn()` and `Terminal` support automatic disposal — `await using` for `spawn()` waits for the process to actually exit, and `using` for `Terminal` closes the PTY synchronously:

@@ -54,3 +54,3 @@ ```ts

await using terminal = new Terminal({
using terminal = new Terminal({
data(term, data) {

@@ -61,5 +61,7 @@ process.stdout.write(data);

const pty = spawn("/bin/sh", ["-c", "echo hello"], { terminal });
await pty.exited;
// terminal.close() called automatically by `await using`
{
await using pty = spawn("/bin/sh", ["-c", "echo hello"], { terminal });
// ...do stuff...
} // pty.close() runs; block awaits process exit before continuing
// terminal.close() runs when the outer scope exits
```

@@ -114,2 +116,3 @@

stats(): IPtyStats | null; // OS-level snapshot (cwd, memory, CPU time)
attach(consumer: IPtyConsumer): IDisposable; // Wire a sink to the data stream
}

@@ -152,4 +155,4 @@ ```

// Terminal provides callback-based data handling and AsyncDisposable cleanup
await using terminal = new Terminal({
// Terminal provides callback-based data handling and `using` cleanup
using terminal = new Terminal({
cols: 100,

@@ -188,2 +191,169 @@ rows: 30,

### `pty.attach(consumer)`
Wire a generic sink to the PTY's data stream. Anything with a `feed(data)` method conforms to `IPtyConsumer` — including the built-in `OSCInspector`, a file logger, an in-memory recorder, a WebSocket forwarder, etc. The consumer is auto-detached when the PTY exits.
```ts
interface IPtyConsumer {
feed(data: string | Buffer): void;
onAttach?(pty: IPty): void; // optional — fires once before the first feed
onDetach?(pty: IPty): void; // optional — fires on dispose or PTY exit
}
```
```ts
const recorder = {
chunks: [] as Buffer[],
feed(data) {
this.chunks.push(typeof data === "string" ? Buffer.from(data) : data);
},
};
const sub = pty.attach(recorder);
// ...
sub.dispose(); // detach early; otherwise auto-detached on PTY exit
```
Multiple consumers per PTY are supported and run independently.
### OSC inspector — `zigpty/osc`
Parse OSC (Operating System Command) escape sequences out of any byte stream — title changes, CWD updates, shell-integration marks (OSC 133/633), progress, notifications, and more. The inspector is a pure-TS byte-fed state machine; sequences split across chunks are stitched back together.
```ts
import { spawn } from "zigpty";
import { OSCInspector, decodeOSC } from "zigpty/osc";
const inspector = new OSCInspector((event) => {
// event = { code: number, payload: string }
const decoded = decodeOSC(event);
switch (decoded.kind) {
case "title":
console.log("title:", decoded.title);
break;
case "cwd":
// Unified across OSC 7, ConEmu 9;9, and iTerm2 1337;CurrentDir=
console.log(`cwd (${decoded.source}):`, decoded.path);
break;
case "shellIntegration":
// OSC 133/633 — command is A/B/C/D (or vscode-specific tokens)
console.log(`${decoded.vendor}/${decoded.command}`, decoded.data);
break;
case "notification":
console.log("notify:", decoded.title, decoded.body);
break;
case "progress":
// ConEmu/Windows Terminal taskbar progress (OSC 9;4)
console.log(`progress: state=${decoded.state} value=${decoded.value}`);
break;
case "mark":
// OSC 1337 SetMark / OSC 9;12 ConEmu prompt-start mark
console.log("prompt mark from", decoded.vendor);
break;
case "hyperlink":
console.log(decoded.action, decoded.uri); // "open"|"close"
break;
// ...attention, clipboard, userVar, remoteHost,
// shellIntegrationVersion, unknown
}
});
const pty = spawn("/bin/bash");
pty.attach(inspector); // OSCInspector implements IPtyConsumer
```
**Stateful inspection** — the inspector maintains an `OSCState` snapshot of the durable, observable state seen so far (title, icon name, cwd, active hyperlink, taskbar progress, remote host, shell-integration version, user vars). State is updated in place before listeners fire, so handlers can read fresh values. Action-like sequences (notifications, marks, clipboard writes, attention requests) don't touch state.
```ts
const inspector = new OSCInspector();
pty.attach(inspector);
inspector.onStateChange((state) => {
// Fires only on sequences that actually mutated state.
console.log("title:", state.title);
console.log("cwd:", state.cwd?.path);
console.log("progress:", state.progress); // undefined after "remove" (state 0)
console.log("hyperlink:", state.hyperlink?.uri); // undefined between links
});
// Or pull synchronously at any time:
inspector.state.title; // string | undefined
inspector.state.userVars?.greeting; // base64-decoded SetUserVar values
```
Specifics: OSC 0 sets both `title` and `iconName`; OSC 1 sets `iconName` only; OSC 2 sets `title` only. `cwd` is unified across OSC 7, OSC 1337 `CurrentDir=`, and OSC 9;9 with a `source` discriminator. `hyperlink` is cleared on OSC 8 close (empty URI). `progress` is cleared when state 0 is reported. `dispose()` clears state.
**Decoded shapes** (`DecodedOSC` union) cover the common codes out of the box:
| Code | `kind`(s) | Notes |
| --------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` / `1` / `2` | `title` | Window / tab / icon title. C0 control bytes stripped. |
| `7` | `cwd` (`source: "osc7"`) | `<scheme>://<host>/<path>`. Path is percent-decoded; `host`/`scheme`/`local` exposed. |
| `8` | `hyperlink` | `action: "open" \| "close"`; `id`, `uri`, and `params`. Empty URI = close. |
| `9` | `progress` / `cwd` / `mark` / `notification` | `9;4;…` progress, `9;9;…` ConEmu/WT CWD report, `9;12` prompt mark, `9;<text>` iTerm2 Growl-style notification. |
| `52` | `clipboard` | Set, query (`?`), or `clear` (Pd not base64). Multi-char `Pc` exposed via `selections[]`. |
| `99` | `notification` (`vendor: "kitty"`) | Title / body / phase (`close`, `alive`, `icon`, …); honors `i=` (id), `u=` (urgency), `d=0` (partial chunk), `e=1` (base64 payload). |
| `133` | `shellIntegration` (`vendor: "vt"`) | FinalTerm A/B/C/D. `D` parses exit code + `err=`; `A`/`C` parse kitty extras into `params`. |
| `633` | `shellIntegration` (`vendor: "vscode"`) | A/B/C/D/E/P/EnvSingleStart/EnvSingleEntry/EnvSingleEnd. Applies VSCode `\\`/`\xNN` unescaping. |
| `777` | `notification` (`vendor: "rxvt"`) | `notify;<title>;<body>` from the urxvt-perl extension. |
| `1337` | `attention` / `cwd` / `mark` / `userVar` / `remoteHost` / `clipboard` / `shellIntegrationVersion` | iTerm2: `RequestAttention` (`yes`/`no`/`once`/`fireworks`), `CurrentDir=`, `SetMark`, `SetUserVar=`, `RemoteHost=`, `Copy=`, `ShellIntegrationVersion=`. |
| _other_ | `unknown` | Raw `{code, payload}` preserved. |
**Adding custom decoders** — use `createOSCDecoder()` to register handlers for new codes (or override built-ins). The returned function is typed as `DecodedOSC | <your custom kinds>`:
```ts
import { createOSCDecoder } from "zigpty/osc";
const decode = createOSCDecoder({
// OSC 50 — terminal font (xterm), not handled by built-ins
50: (payload) => ({ kind: "font" as const, value: payload }),
// OSC 1338 — your custom vendor code
1338: (payload) => ({ kind: "vendor-x" as const, raw: payload }),
});
const inspector = new OSCInspector((event) => {
const d = decode(event);
// d: DecodedOSC | { kind: "font"; ... } | { kind: "vendor-x"; ... }
});
```
The built-in registry is exposed as `builtinOSCDecoders: Record<number, OSCDecoderFn<DecodedOSC>>` if you want to inspect or reuse individual decoders.
### Idle detector — `zigpty/idle`
Implicit terminal-attention detection. Watches the PTY's output stream and emits an `idle` event when a burst of activity stops — typically meaning an interactive agent (Claude Code, aider, a REPL, …) is done streaming and waiting for input. Tuned to suppress the obvious false positives: the startup banner flood, tiny status-bar updates, and pure ANSI redraws.
```ts
import { spawn } from "zigpty";
import { IdleDetector } from "zigpty/idle";
const detector = new IdleDetector((event) => {
if (event.type === "active") console.log("agent started producing output");
if (event.type === "idle") console.log("agent likely waiting for input");
});
const pty = spawn("claude", []);
pty.attach(detector); // IdleDetector implements IPtyConsumer
```
How it filters false positives:
| Knob | Default | What it does |
| ----------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `graceMs` | `1500` | Significant bytes arriving within this window after `attach` are silently absorbed. Hides the shell-init / prompt-render flood that always happens right when a PTY opens. |
| `activeThreshold` | `512` | Minimum significant bytes in a single burst (gaps shorter than `quietMs`) before `active` fires. Status-bar pokes and cursor-blink redraws never accumulate enough to count. |
| `quietMs` | `750` | Time with no significant bytes before transitioning `active` → `idle`. Tuned for streaming agents that emit chunks every 50-200ms. |
"Significant bytes" excludes ANSI/CSI/OSC escape sequences and other C0 control characters — only user-visible content counts toward the threshold, so heavily colored output doesn't masquerade as text and a pure spinner redraw contributes very few bytes per cycle.
`IdleDetector` has the same shape as `OSCInspector`: pass a listener (or `.on()` later), `.feed()` raw bytes if you're driving it yourself, and `.dispose()` to clean up. Events carry the burst `bytes` count and transition `durationMs` if you want to introspect output:
```ts
type IdleEvent = {
type: "active" | "idle";
bytes: number; // significant bytes accumulated for the output burst
durationMs: number; // how long the previous state lasted
};
```
### `hasNative`

@@ -305,3 +475,3 @@

Requires [Zig](https://ziglang.org/) 0.15.1+.
Requires [Zig](https://ziglang.org/) 0.16.0+.

@@ -308,0 +478,0 @@ ```sh

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet