diff --git a/AGENTS.md b/AGENTS.md index 267bb18..ac3cb9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,14 +144,14 @@ The native loader (`napi.ts`) tries glibc first, falls back to musl on Linux. On The pure Zig library (`lib.zig`) is exposed as the `"zigpty"` module in `build.zig`: -| Function | Signature | Description | -| ---------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forkPty` | `(ForkOptions) !ForkResult` | Fork process with PTY (forkpty + signal handling + execvpe) | -| `openPty` | `(cols, rows) !OpenResult` | Open bare PTY pair | -| `resize` | `(fd, cols, rows, x_pixel, y_pixel) !void` | Resize PTY (ioctl TIOCSWINSZ) | -| `getProcessName` | `(fd, buf) ?[]const u8` | Foreground process name via /proc | +| Function | Signature | Description | +| ---------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `forkPty` | `(ForkOptions) !ForkResult` | Fork process with PTY (forkpty + signal handling + execvpe) | +| `openPty` | `(cols, rows) !OpenResult` | Open bare PTY pair | +| `resize` | `(fd, cols, rows, x_pixel, y_pixel) !void` | Resize PTY (ioctl TIOCSWINSZ) | +| `getProcessName` | `(fd, buf) ?[]const u8` | Foreground process name via /proc | | `getStats` | `(pid, allocator, cwd_buf) ?Stats` | Aggregate rss + cpu across the leader process and every transitive descendant (BFS by ppid). Linux: snapshots `/proc//stat` rows once, BFS from leader by linear ppid scan. macOS: `proc_listpids(PROC_ALL_PIDS)` + `proc_pidinfo(PROC_PIDT_SHORTBSDINFO)` for the parent walk, `PROC_PIDTASKINFO` for marked pids. Returns leader cwd + totals + per-child array (caller must `stats.deinit(allocator)`). | -| `waitForExit` | `(pid) ExitInfo` | Blocking wait for child exit (call from background thread) | +| `waitForExit` | `(pid) ExitInfo` | Blocking wait for child exit (call from background thread) | Types: `ForkOptions`, `ForkResult`, `OpenResult`, `ExitInfo`, `Stats`, `ChildStats`, `PtyError`, `Fd`, `Pid` diff --git a/README.md b/README.md index 5f8eada..ec9ae2d 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ interface IPty { close(): void; waitFor(pattern: string, options?: { timeout?: number }): Promise; stats(): IPtyStats | null; // OS-level snapshot (cwd, memory, CPU time) + attach(consumer: IPtyConsumer): IDisposable; // Wire a sink to the data stream } ``` @@ -180,6 +181,115 @@ await pty.exited; Options: `{ timeout?: number }` — default 30 seconds. Throws if the pattern is not found within the timeout. +### `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 +``` + +**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"`) | `:///`. 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;` 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;;<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. + ### `hasNative` Boolean — `true` when native Zig PTY bindings loaded successfully, `false` when running in pipe fallback mode. diff --git a/build.config.ts b/build.config.ts index c4bf0a4..03945ef 100644 --- a/build.config.ts +++ b/build.config.ts @@ -5,7 +5,7 @@ export default defineBuildConfig({ entries: [ { type: "bundle", - input: "./src/index.ts", + input: ["./src/index.ts", "./src/osc/index.ts"], }, ], // hooks: { diff --git a/package.json b/package.json index d8aae2a..ae165c2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ ], "type": "module", "exports": { - ".": "./dist/index.mjs" + ".": "./dist/index.mjs", + "./osc": "./dist/osc/index.mjs" }, "scripts": { "build": "zig build --release && obuild", diff --git a/scripts/osc-demo.ts b/scripts/osc-demo.ts new file mode 100644 index 0000000..00a4be0 --- /dev/null +++ b/scripts/osc-demo.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env bun +/** + * OSC / escape-sequence demo — emits OS attention & notification signals. + * + * Cross-platform replacement for osc-demo.sh. Run directly via bun or via + * node ≥22.6 with --experimental-strip-types. The output is identical to the + * bash version so the OSC inspector can parse the same sequences anywhere. + * + * bun scripts/osc-demo.ts + * DELAY=0.05 bun scripts/osc-demo.ts + */ +import * as os from "node:os"; + +const DELAY = Number(process.env.DELAY ?? "1"); +const sleep = (s: number) => new Promise<void>((r) => setTimeout(r, s * 1000)); +const out = (s: string) => process.stdout.write(s); + +out("=== Window Title ===\n"); +out("\x1b]0;zigpty demo — window title\x07"); +out("\x1b]2;zigpty demo — title only\x07"); +out("\x1b]1;zigpty-tab\x07"); + +out("=== Current Working Directory (OSC 7) ===\n"); +out(`\x1b]7;file://${os.hostname()}${process.cwd()}\x07`); + +out("=== Shell Integration Marks (OSC 133) ===\n"); +out("\x1b]133;A\x07"); +out("\x1b]133;B\x07"); +out("\x1b]133;C\x07"); +out("\x1b]133;D;0\x07"); + +out("=== VS Code Shell Integration (OSC 633) ===\n"); +out("\x1b]633;A\x07"); +out("\x1b]633;B\x07"); +out("\x1b]633;E;echo hello\x07"); +out("\x1b]633;C\x07"); +out("hello\n"); +out("\x1b]633;D;0\x07"); +out(`\x1b]633;P;Cwd=${process.cwd()}\x07`); + +out("=== BEL (Bell) ===\n"); +out("\x07"); + +out("=== Urgent / Window Attention ===\n"); +out("\x1b]1337;RequestAttention=yes\x07"); +out("\x1b]1337;RequestAttention=fireworks\x07"); +out("\x1b]777;urgency;push\x1b\\"); + +out("=== Desktop Notifications ===\n"); +out("\x1b]9;Build finished\x07"); +out("\x1b]99;i=1:p=title;Build Status\x1b\\"); +out("\x1b]99;i=1:p=body;All tests passed\x1b\\"); +out("\x1b]99;i=1:p=done\x1b\\"); +out("\x1b]777;notify;Build;Tests passed\x1b\\"); +out("\x1b]1337;notify;title=Build;body=Tests passed\x07"); +if (process.env.TMUX) { + out("\x1bPtmux;\x1b\x1b]9;Build finished (via tmux)\x07\x1b\\"); +} + +out("=== Progress Indicators ===\n"); +out("\x1b]9;4;3;0\x1b\\"); +await sleep(0.5); +for (const pct of [0, 10, 25, 50, 75, 90, 100]) { + out(`\x1b]9;4;1;${pct}\x1b\\`); + await sleep(0.3); +} +out("\x1b]9;4;2;100\x1b\\"); +await sleep(0.5); +out("\x1b]9;4;4;75\x1b\\"); +await sleep(0.5); +out("\x1b]9;4;0;0\x1b\\"); +await sleep(DELAY); + +out("=== Done ===\n"); diff --git a/scripts/osc-inspect.ts b/scripts/osc-inspect.ts new file mode 100644 index 0000000..7cefbcf --- /dev/null +++ b/scripts/osc-inspect.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env bun +/** + * Demo: drive scripts/osc-demo.ts through a PTY, parse OSC sequences with + * the native inspector, and pretty-print each decoded event. + * + * bun scripts/osc-inspect.ts + * bun scripts/osc-inspect.ts --raw # only the raw {code,payload} + * bun scripts/osc-inspect.ts -- some-script # inspect a different script + * + * Cross-platform: the demo script is TypeScript, spawned via whatever + * runtime is executing this file (bun directly; node ≥22.6 with + * --experimental-strip-types added automatically). + */ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "../src/index.ts"; +import { OSCInspector, createOSCDecoder, type DecodedOSC } from "../src/osc/index.ts"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const raw = argv.includes("--raw"); +const positional = argv.filter((a) => !a.startsWith("--")); +const script = positional[0] ?? path.join(here, "osc-demo.ts"); + +// Custom decoder demonstrates extensibility — handle a fake vendor code in +// addition to all built-ins. Anything not in `custom` falls back to the +// built-in decoders, then to `{ kind: "unknown" }`. +const decode = createOSCDecoder({ + 1338: (payload) => ({ kind: "demo-vendor" as const, payload }), +}); + +const inspector = new OSCInspector((event) => { + if (raw) { + process.stdout.write(`OSC ${event.code}\t${JSON.stringify(event.payload)}\n`); + return; + } + const decoded = decode(event); + process.stdout.write(format(decoded, event.code) + "\n"); +}); + +const { file, args } = resolveRunner(script); + +const pty = spawn(file, args, { + cols: 120, + rows: 40, + env: { ...process.env, DELAY: "0.05", TERM: "xterm-256color" } as Record<string, string>, +}); + +// Attach the inspector to the pty: equivalent to `pty.onData(d => inspector.feed(d))` +// but the inspector is automatically detached when the pty exits. +pty.attach(inspector); + +const code = await pty.exited; +process.exit(code); + +/** + * Pick the runtime that should execute the demo script. + * + * - `.sh` → bash (legacy support) + * - `.ts`/`.mts`/`.cts` → current runtime: bun runs TS natively; node needs + * `--experimental-strip-types` (added on node ≥22.6). + * - everything else → handed to the current runtime as-is. + */ +function resolveRunner(scriptPath: string): { file: string; args: string[] } { + const ext = path.extname(scriptPath).toLowerCase(); + if (ext === ".sh") { + return { file: process.platform === "win32" ? "bash.exe" : "/bin/bash", args: [scriptPath] }; + } + const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; + const isTs = ext === ".ts" || ext === ".mts" || ext === ".cts"; + const args = isTs && !isBun ? ["--experimental-strip-types", scriptPath] : [scriptPath]; + return { file: process.execPath, args }; +} + +type Decoded = DecodedOSC | { kind: "demo-vendor"; payload: string }; + +function format(d: Decoded, code: number): string { + switch (d.kind) { + case "title": + return `[title ${d.code}] ${d.title}`; + case "cwd": + return `[cwd:${d.source}] ${d.path}${d.host ? ` @${d.host}` : ""}`; + case "shellIntegration": + return `[${d.vendor}-integration ${d.command}]${d.data ? ` ${d.data}` : ""}`; + case "notification": { + const parts = [d.title, d.body].filter(Boolean).join(" — "); + return `[notify:${d.vendor}] ${parts || d.raw}`; + } + case "progress": + return `[progress] state=${d.state}${d.value !== undefined ? ` value=${d.value}` : ""}`; + case "attention": + return `[attention] ${d.raw}`; + case "hyperlink": + return d.action === "close" + ? `[hyperlink:close]` + : `[hyperlink${d.id ? ` id=${d.id}` : ""}] ${d.uri}`; + case "clipboard": + if (d.query) return `[clipboard:${d.selection}] ?`; + if (d.clear) return `[clipboard:${d.selection}] <clear>`; + return `[clipboard:${d.selection}] ${d.data ?? ""}`; + case "mark": + return `[mark:${d.vendor}]`; + case "userVar": + return `[userVar:${d.vendor}] ${d.name}=${d.value}`; + case "remoteHost": + return `[remoteHost:${d.vendor}] ${d.user ? `${d.user}@` : ""}${d.host}`; + case "shellIntegrationVersion": + return `[shellIntegrationVersion:${d.vendor}] ${d.version}`; + case "demo-vendor": + return `[demo-vendor] ${d.payload}`; + case "unknown": + return `[osc ${code}] ${d.payload}`; + } +} diff --git a/src/attach.test.ts b/src/attach.test.ts new file mode 100644 index 0000000..194f8bf --- /dev/null +++ b/src/attach.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from "vitest"; +import { BasePty } from "./pty/_base.ts"; +import { OSCInspector } from "./osc/index.ts"; +import type { IPty, IPtyConsumer, IPtyStats } from "./pty/types.ts"; + +/** Minimal concrete BasePty used to drive attach() in isolation. */ +class TestPty extends BasePty { + constructor() { + super(80, 24); + this.pid = 1; + } + emit(data: string | Buffer): void { + for (const l of this._dataListeners) l(data); + } + triggerExit(info = { exitCode: 0, signal: 0 }): void { + this._handleExit(info); + } + get process(): string { + return "test"; + } + write(): void {} + resize(): void {} + clear(): void {} + kill(): void {} + pause(): void {} + resume(): void {} + close(): void {} + stats(): IPtyStats | null { + return null; + } +} + +describe("BasePty.attach", () => { + it("forwards data chunks to the consumer's feed()", () => { + const pty = new TestPty(); + const feed = vi.fn(); + pty.attach({ feed }); + + pty.emit("hello"); + pty.emit(Buffer.from(" world")); + + expect(feed).toHaveBeenCalledTimes(2); + expect(feed).toHaveBeenNthCalledWith(1, "hello"); + expect(feed).toHaveBeenNthCalledWith(2, Buffer.from(" world")); + }); + + it("calls onAttach with the pty before any feed", () => { + const pty = new TestPty(); + const calls: string[] = []; + const consumer: IPtyConsumer = { + feed: () => calls.push("feed"), + onAttach: (p) => { + calls.push("attach"); + expect(p).toBe(pty); + }, + }; + pty.attach(consumer); + pty.emit("x"); + expect(calls).toEqual(["attach", "feed"]); + }); + + it("dispose() detaches and calls onDetach", () => { + const pty = new TestPty(); + const feed = vi.fn(); + const onDetach = vi.fn(); + const disp = pty.attach({ feed, onDetach }); + + pty.emit("a"); + disp.dispose(); + pty.emit("b"); + + expect(feed).toHaveBeenCalledTimes(1); + expect(onDetach).toHaveBeenCalledTimes(1); + expect(onDetach).toHaveBeenCalledWith(pty); + }); + + it("dispose() is idempotent — onDetach fires only once", () => { + const pty = new TestPty(); + const onDetach = vi.fn(); + const disp = pty.attach({ feed: () => {}, onDetach }); + + disp.dispose(); + disp.dispose(); + disp.dispose(); + expect(onDetach).toHaveBeenCalledTimes(1); + }); + + it("auto-detaches when the PTY exits — onDetach fires once, no more feed", () => { + const pty = new TestPty(); + const feed = vi.fn(); + const onDetach = vi.fn(); + pty.attach({ feed, onDetach }); + + pty.emit("before"); + pty.triggerExit({ exitCode: 0, signal: 0 }); + pty.emit("after"); // exit clears _dataListeners; no-op + + expect(feed).toHaveBeenCalledTimes(1); + expect(feed).toHaveBeenCalledWith("before"); + expect(onDetach).toHaveBeenCalledTimes(1); + }); + + it("explicit dispose after auto-detach does not double-fire onDetach", () => { + const pty = new TestPty(); + const onDetach = vi.fn(); + const disp = pty.attach({ feed: () => {}, onDetach }); + pty.triggerExit(); + disp.dispose(); + expect(onDetach).toHaveBeenCalledTimes(1); + }); + + it("supports multiple consumers independently", () => { + const pty = new TestPty(); + const a = vi.fn(); + const b = vi.fn(); + const dispA = pty.attach({ feed: a }); + pty.attach({ feed: b }); + + pty.emit("x"); + dispA.dispose(); + pty.emit("y"); + + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(2); + }); + + it("swallows errors thrown by feed() so the data path stays healthy", () => { + const pty = new TestPty(); + const good = vi.fn(); + pty.attach({ + feed: () => { + throw new Error("boom"); + }, + }); + pty.attach({ feed: good }); + + expect(() => pty.emit("x")).not.toThrow(); + expect(good).toHaveBeenCalledWith("x"); + }); + + it("OSCInspector conforms to IPtyConsumer and parses attached output", () => { + const pty = new TestPty(); + const events: number[] = []; + const inspector = new OSCInspector((e) => events.push(e.code)); + + // Compile-time check: OSCInspector is assignable to IPtyConsumer + const _typecheck: IPtyConsumer = inspector; + void _typecheck; + + pty.attach(inspector); + pty.emit("\x1b]0;title\x07\x1b]133;A\x07"); + expect(events).toEqual([0, 133]); + + // Auto-detach on exit; subsequent emits do nothing. + pty.triggerExit(); + pty.emit("\x1b]9;x\x07"); + expect(events).toEqual([0, 133]); + }); + + it("attach() returns IDisposable from IPty interface", () => { + const pty: IPty = new TestPty(); + const disp = pty.attach({ feed: () => {} }); + expect(typeof disp.dispose).toBe("function"); + disp.dispose(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 9c8ed3d..5992622 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export type { IEvent, IOpenResult, IPty, + IPtyConsumer, IPtyOpenOptions, IPtyOptions, } from "./pty/types.ts"; diff --git a/src/osc/decode.ts b/src/osc/decode.ts new file mode 100644 index 0000000..4597f19 --- /dev/null +++ b/src/osc/decode.ts @@ -0,0 +1,422 @@ +import { Buffer } from "node:buffer"; +import type { + CustomDecodedOSC, + DecodedOSC, + OSCDecoderFn, + OSCDecoderMap, + OSCEvent, +} from "./types.ts"; + +// --- Shared helpers --- + +// Strip C0 (0x00-0x1F) and DEL (0x7F). Real terminals do this for titles. +function stripControls(s: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional. + return s.replace(/[\x00-\x1f\x7f]/g, ""); +} + +function safeBase64Decode(s: string): string { + try { + return Buffer.from(s, "base64").toString("utf8"); + } catch { + return s; + } +} + +// Strip a single surrounding pair of `"`. Used by ConEmu `OSC 9;9;"path"`. +function unquote(s: string): string { + 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}$/; + +// VSCode escape rules from `__vsc_escape_value` in shellIntegration-bash.sh: +// `\\` → `\`, `\xNN` → byte NN. Applied to vscode E/P/EnvSingleEntry values. +function vscodeUnescape(s: string): string { + let out = ""; + let i = 0; + while (i < s.length) { + const c = s.charCodeAt(i); + if (c === 0x5c && i + 1 < s.length) { + const n = s.charCodeAt(i + 1); + if (n === 0x5c) { + out += "\\"; + i += 2; + continue; + } + if (n === 0x78 && 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; +} + +// --- Built-in decoders --- + +const titleDecoder: OSCDecoderFn<DecodedOSC> = (payload, event) => ({ + kind: "title", + code: event.code as 0 | 1 | 2, + title: stripControls(payload), +}); + +const cwdDecoder: OSCDecoderFn<DecodedOSC> = (payload) => { + // `<scheme>://<host>/<path>` — VTE's vte-urlencode-cwd is the de-facto spec. + // Path is RFC 3986 percent-encoded; we decode it for callers. + const m = /^([a-zA-Z][a-zA-Z0-9+.\-]*):\/\/([^/]*)(\/.*)?$/.exec(payload); + if (m) { + const scheme = m[1]!; + const host = m[2] || undefined; + const rawPath = m[3] ?? ""; + let path: string; + 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: "vt" | "vscode"): OSCDecoderFn<DecodedOSC> => + (payload) => { + // OSC 133/633 ; <command> [; <data...>] + const semi = payload.indexOf(";"); + const command = semi >= 0 ? payload.slice(0, semi) : payload; + const data = semi >= 0 ? payload.slice(semi + 1) : ""; + const out: DecodedOSC = { kind: "shellIntegration", vendor, command, data }; + + if (vendor === "vt") { + // OSC 133 ; D [; <exitCode>] [; err=<value>] + 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("err=".length); + } + } + // OSC 133 ; A|C [; key=val[;key=val]...] — kitty extensions. + if ((command === "A" || command === "C") && data) { + const params: Record<string, string> = {}; + 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; + } + + // vendor === "vscode" — OSC 633 + if (command === "D" && data) { + // OSC 633 ; D [; <exitCode>] + if (/^\d+$/.test(data)) out.exitCode = Number(data); + } else if (command === "P") { + // OSC 633 ; P ; <Key>=<Value> + const eq = data.indexOf("="); + if (eq >= 0) { + out.key = data.slice(0, eq); + out.value = vscodeUnescape(data.slice(eq + 1)); + } + } else if (command === "E") { + // OSC 633 ; E ; <commandLine> [; <nonce>] + const parts = data.split(";"); + out.commandLine = vscodeUnescape(parts[0] ?? ""); + if (parts.length > 1) out.nonce = parts[parts.length - 1]; + } else if (command === "EnvSingleStart") { + // OSC 633 ; EnvSingleStart ; <index> ; <nonce> + 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") { + // OSC 633 ; EnvSingleEntry ; <key> ; <value> ; <nonce> + const parts = data.split(";"); + if (parts[0]) out.key = parts[0]; + if (parts[1] !== undefined) out.value = vscodeUnescape(parts[1]); + if (parts[2]) out.nonce = parts[2]; + } else if (command === "EnvSingleEnd") { + out.nonce = data; + } + return out; + }; + +// ConEmu sub-commands that are well-defined enough to recognize as "not iTerm". +const CONEMU_SUBCMDS = new Set(["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]); + +const osc9Decoder: OSCDecoderFn<DecodedOSC> = (payload, event) => { + // ConEmu / Windows Terminal use `9;<sub>;<args>`. Sub-command numbers + // documented at https://conemu.github.io/en/AnsiEscapeCodes.html + const semi = payload.indexOf(";"); + const head = semi >= 0 ? payload.slice(0, semi) : payload; + const rest = semi >= 0 ? payload.slice(semi + 1) : ""; + + // `9;4;<state>[;<value>]` — taskbar progress. + if (head === "4") { + const parts = rest.split(";"); + const state = Number(parts[0] ?? 0); + const valueRaw = parts[1]; + const out: DecodedOSC = { kind: "progress", state }; + if (valueRaw !== undefined && valueRaw !== "") out.value = Number(valueRaw); + return out; + } + // `9;9;<cwd>` — Windows Terminal / ConEmu CWD report (NOT a notification). + // See https://learn.microsoft.com/en-us/windows/terminal/tutorials/new-tab-same-directory + if (head === "9") { + return { kind: "cwd", source: "conemu", path: unquote(rest) }; + } + // `9;12` — ConEmu prompt-start marker (shell-integration). + if (head === "12") { + return { kind: "mark", vendor: "conemu", raw: payload }; + } + // Any other recognized ConEmu sub-command — surface as unknown, not as + // an iTerm notification (the previous fallback misclassified these). + if (CONEMU_SUBCMDS.has(head)) { + return { kind: "unknown", code: event.code, payload }; + } + // `9;<text>` with non-numeric head — iTerm2 Growl-style notification. + return { kind: "notification", vendor: "iterm", body: payload, raw: payload }; +}; + +const osc99Decoder: OSCDecoderFn<DecodedOSC> = (payload) => { + // OSC 99: kitty desktop notifications. + // Format: `<k=v[:k=v]*>;<value>` + // See https://sw.kovidgoyal.net/kitty/desktop-notifications/ + const semi = payload.indexOf(";"); + const meta = semi >= 0 ? payload.slice(0, semi) : payload; + const rawValue = semi >= 0 ? payload.slice(semi + 1) : ""; + const fields: Record<string, string> = {}; + 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"; // title | body | close | alive | icon | buttons | ? + const value = fields.e === "1" ? safeBase64Decode(rawValue) : rawValue; + + const out: DecodedOSC = { 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) as 0 | 1 | 2; + } + // d defaults to 1 (complete). Only flag the chunk if explicitly d=0. + 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: OSCDecoderFn<DecodedOSC> = (payload, event) => { + // iTerm2 OSC 1337 — see https://iterm2.com/documentation-escape-codes.html + // Form: `<Command>[=<arg>]` (CamelCase command, optional `=value`). + 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 : undefined; + 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("CurrentDir=".length) }; + } + if (payload.startsWith("SetUserVar=")) { + // SetUserVar=<name>=<base64> + const rest = payload.slice("SetUserVar=".length); + 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("RemoteHost=".length); + 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("ShellIntegrationVersion=".length), + raw: payload, + }; + } + if (payload.startsWith("Copy=")) { + // Copy=<selection>:<base64> + const rest = payload.slice("Copy=".length); + 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: OSCDecoderFn<DecodedOSC> = (payload, event) => { + // urxvt-perl extension dispatcher. The shipped `notify`/`osc-notify` + // extension uses `notify;<title>;<body>`. + if (payload.startsWith("notify;")) { + const parts = payload.slice("notify;".length).split(";"); + return { + kind: "notification", + vendor: "rxvt", + title: parts[0] ?? "", + body: parts.slice(1).join(";"), + raw: payload, + }; + } + return { kind: "unknown", code: event.code, payload }; +}; + +// OSC 8: `8 ; params ; URI`. Empty URI closes the active hyperlink. +// params is `key=value[:key=value]*`, conventionally including `id=<n>`. +const hyperlinkDecoder: OSCDecoderFn<DecodedOSC> = (payload) => { + const semi = payload.indexOf(";"); + const paramStr = semi >= 0 ? payload.slice(0, semi) : ""; + const uri = semi >= 0 ? payload.slice(semi + 1) : ""; + const params: Record<string, string> = {}; + 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 !== undefined + ? { kind: "hyperlink", action, uri, id: params.id, params } + : { kind: "hyperlink", action, uri, params }; +}; + +// OSC 52: clipboard. Payload is `Pc ; Pd`. +// Pc is zero or more of `c` (clipboard), `p` (primary), `q` (secondary), +// `s` (select alias), or digits `0..7` (cut buffers). Multi-char values like +// `cs` are valid. Empty Pc defaults to `s0`. +// Pd is base64 data, `?` to query, or anything else to clear the clipboard. +const clipboardDecoder: OSCDecoderFn<DecodedOSC> = (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 }; +}; + +/** + * 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. + */ +export const builtinOSCDecoders: OSCDecoderMap<DecodedOSC> = { + 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, +}; + +/** + * 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"; ... } + * ``` + */ +export function createOSCDecoder(): (event: OSCEvent) => DecodedOSC; +export function createOSCDecoder<Map extends Record<number, OSCDecoderFn<unknown>>>( + custom: Map, +): (event: OSCEvent) => DecodedOSC | CustomDecodedOSC<Map>; +export function createOSCDecoder( + custom?: Record<number, OSCDecoderFn<unknown>>, +): (event: OSCEvent) => unknown { + if (!custom) return decodeBuiltin; + return (event) => { + const fn = custom[event.code]; + if (fn) return fn(event.payload, event); + return decodeBuiltin(event); + }; +} + +function decodeBuiltin(event: OSCEvent): DecodedOSC { + const fn = builtinOSCDecoders[event.code]; + if (fn) return fn(event.payload, event); + return { kind: "unknown", code: event.code, payload: event.payload }; +} + +/** + * 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). + */ +export const decodeOSC: (event: OSCEvent) => DecodedOSC = decodeBuiltin; diff --git a/src/osc/index.ts b/src/osc/index.ts new file mode 100644 index 0000000..3f7a395 --- /dev/null +++ b/src/osc/index.ts @@ -0,0 +1,17 @@ +/** + * OSC (Operating System Command) parsing & decoding. + * + * - {@link OSCInspector} — byte-fed state machine; `feed(data)` + `on(listener)` + * - {@link decodeOSC} / {@link createOSCDecoder} — turn raw events into typed shapes + * - {@link builtinOSCDecoders} — registry of well-known codes (extensible) + */ +export { OSCInspector } from "./inspector.ts"; +export { builtinOSCDecoders, createOSCDecoder, decodeOSC } from "./decode.ts"; +export type { + CustomDecodedOSC, + DecodedOSC, + OSCDecoderFn, + OSCDecoderMap, + OSCEvent, + OSCListener, +} from "./types.ts"; diff --git a/src/osc/inspector.test.ts b/src/osc/inspector.test.ts new file mode 100644 index 0000000..d1af629 --- /dev/null +++ b/src/osc/inspector.test.ts @@ -0,0 +1,562 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + builtinOSCDecoders, + createOSCDecoder, + decodeOSC, + OSCInspector, + type DecodedOSC, + type OSCEvent, +} from "./index.ts"; + +describe("OSCInspector", () => { + it("parses a BEL-terminated title sequence", () => { + const events: OSCEvent[] = []; + const i = new OSCInspector((e) => events.push(e)); + i.feed("\x1b]0;hello\x07"); + expect(events).toEqual([{ code: 0, payload: "hello" }]); + i.dispose(); + }); + + it("parses an ST-terminated notification sequence", () => { + const events: OSCEvent[] = []; + const i = new OSCInspector((e) => events.push(e)); + i.feed("\x1b]9;4;1;42\x1b\\"); + expect(events).toEqual([{ code: 9, payload: "4;1;42" }]); + i.dispose(); + }); + + it("stitches sequences split across feed calls", () => { + const events: OSCEvent[] = []; + const i = new OSCInspector((e) => events.push(e)); + i.feed("\x1b]1337;Reque"); + expect(events).toHaveLength(0); + i.feed("stAttention=fireworks\x07"); + expect(events).toEqual([{ code: 1337, payload: "RequestAttention=fireworks" }]); + i.dispose(); + }); + + it("emits multiple sequences from a single chunk", () => { + const events: OSCEvent[] = []; + const i = new OSCInspector((e) => events.push(e)); + i.feed("noise\x1b]133;A\x07inter\x1b]7;file:///tmp\x07tail"); + expect(events).toEqual([ + { code: 133, payload: "A" }, + { code: 7, payload: "file:///tmp" }, + ]); + i.dispose(); + }); + + it("accepts Buffer and Uint8Array input", () => { + const events: OSCEvent[] = []; + const i = new OSCInspector((e) => events.push(e)); + i.feed(Buffer.from("\x1b]2;buf\x07", "utf8")); + i.feed(new Uint8Array(Buffer.from("\x1b]2;u8\x07", "utf8"))); + expect(events).toEqual([ + { code: 2, payload: "buf" }, + { code: 2, payload: "u8" }, + ]); + i.dispose(); + }); + + it("ignores non-OSC escape sequences", () => { + const events: OSCEvent[] = []; + const i = new OSCInspector((e) => events.push(e)); + i.feed("plain text \x1b[31mred\x1b[0m and \x1b]0;t\x07"); + expect(events).toEqual([{ code: 0, payload: "t" }]); + i.dispose(); + }); + + it("supports multiple listeners and unsubscribe", () => { + const a: OSCEvent[] = []; + const b: OSCEvent[] = []; + const i = new OSCInspector(); + i.on((e) => a.push(e)); + const off = i.on((e) => b.push(e)); + i.feed("\x1b]0;one\x07"); + off(); + i.feed("\x1b]0;two\x07"); + expect(a).toHaveLength(2); + expect(b).toHaveLength(1); + expect(b[0]).toEqual({ code: 0, payload: "one" }); + i.dispose(); + }); + + it("does not throw when a listener throws", () => { + const i = new OSCInspector(() => { + throw new Error("boom"); + }); + expect(() => i.feed("\x1b]0;x\x07")).not.toThrow(); + i.dispose(); + }); + + it("CAN (0x18) cancels an in-flight OSC sequence", () => { + const events: OSCEvent[] = []; + const i = new OSCInspector((e) => events.push(e)); + i.feed("\x1b]0;abort\x18\x1b]1;ok\x07"); + expect(events).toEqual([{ code: 1, payload: "ok" }]); + i.dispose(); + }); + + it("SUB (0x1a) cancels an in-flight OSC sequence", () => { + const events: OSCEvent[] = []; + const i = new OSCInspector((e) => events.push(e)); + i.feed("\x1b]0;abort\x1a\x1b]2;ok\x07"); + expect(events).toEqual([{ code: 2, payload: "ok" }]); + i.dispose(); + }); + + it("recovers when a stray ESC inside payload is followed by ESC ]", () => { + const events: OSCEvent[] = []; + const i = new OSCInspector((e) => events.push(e)); + // ESC ] 0 ; abort ESC <not-backslash-not-ESC> => abort; the next ESC ] starts a new OSC. + i.feed("\x1b]0;abort\x1b\x1b]1;ok\x07"); + expect(events).toEqual([{ code: 1, payload: "ok" }]); + i.dispose(); + }); +}); + +describe("decodeOSC", () => { + it("decodes window-title codes 0/1/2", () => { + expect(decodeOSC({ code: 0, payload: "t" })).toMatchObject({ kind: "title", code: 0 }); + expect(decodeOSC({ code: 2, payload: "t" })).toMatchObject({ kind: "title", code: 2 }); + }); + + it("strips embedded C0 control bytes from titles", () => { + expect(decodeOSC({ code: 2, payload: "hi\x05there\x01" })).toEqual({ + kind: "title", + code: 2, + title: "hithere", + }); + }); + + it("decodes OSC 7 cwd uri with percent-decoded path", () => { + expect(decodeOSC({ code: 7, payload: "file://host/var/log" })).toEqual({ + kind: "cwd", + source: "osc7", + uri: "file://host/var/log", + scheme: "file", + host: "host", + path: "/var/log", + local: false, + }); + expect(decodeOSC({ code: 7, payload: "file://localhost/tmp/with%20space" })).toEqual({ + kind: "cwd", + source: "osc7", + uri: "file://localhost/tmp/with%20space", + scheme: "file", + host: "localhost", + path: "/tmp/with space", + local: true, + }); + expect(decodeOSC({ code: 7, payload: "file:///etc" })).toMatchObject({ + kind: "cwd", + source: "osc7", + host: undefined, + path: "/etc", + local: true, + }); + }); + + it("falls back to raw path when OSC 7 percent-decoding fails", () => { + expect(decodeOSC({ code: 7, payload: "file:///bad%ZZ" })).toMatchObject({ + kind: "cwd", + source: "osc7", + path: "/bad%ZZ", + }); + }); + + it("decodes OSC 133 shell integration commands", () => { + expect(decodeOSC({ code: 133, payload: "A" })).toEqual({ + kind: "shellIntegration", + vendor: "vt", + command: "A", + data: "", + }); + expect(decodeOSC({ code: 133, payload: "D;0" })).toMatchObject({ + kind: "shellIntegration", + vendor: "vt", + command: "D", + data: "0", + exitCode: 0, + }); + expect(decodeOSC({ code: 133, payload: "D;127;err=" })).toMatchObject({ + kind: "shellIntegration", + command: "D", + exitCode: 127, + err: "", + }); + expect(decodeOSC({ code: 133, payload: "A;redraw=0;special_key=1" })).toMatchObject({ + kind: "shellIntegration", + command: "A", + params: { redraw: "0", special_key: "1" }, + }); + }); + + it("decodes OSC 633 vscode integration", () => { + expect(decodeOSC({ code: 633, payload: "E;echo hello" })).toMatchObject({ + kind: "shellIntegration", + vendor: "vscode", + command: "E", + commandLine: "echo hello", + }); + expect(decodeOSC({ code: 633, payload: "E;echo hi\\x3bworld;nonce123" })).toMatchObject({ + kind: "shellIntegration", + command: "E", + commandLine: "echo hi;world", + nonce: "nonce123", + }); + expect(decodeOSC({ code: 633, payload: "P;Cwd=/home/foo" })).toMatchObject({ + kind: "shellIntegration", + command: "P", + key: "Cwd", + value: "/home/foo", + }); + expect(decodeOSC({ code: 633, payload: "D;0" })).toMatchObject({ + kind: "shellIntegration", + command: "D", + exitCode: 0, + }); + expect( + decodeOSC({ code: 633, payload: "EnvSingleEntry;PATH;/usr/bin\\x3a/bin;abc" }), + ).toMatchObject({ + kind: "shellIntegration", + command: "EnvSingleEntry", + key: "PATH", + value: "/usr/bin:/bin", + nonce: "abc", + }); + expect(decodeOSC({ code: 633, payload: "EnvSingleStart;0;abc" })).toMatchObject({ + kind: "shellIntegration", + command: "EnvSingleStart", + index: 0, + nonce: "abc", + }); + expect(decodeOSC({ code: 633, payload: "EnvSingleEnd;abc" })).toMatchObject({ + kind: "shellIntegration", + command: "EnvSingleEnd", + nonce: "abc", + }); + }); + + it("decodes OSC 9 progress (state;value) and omits value for state 0/3", () => { + expect(decodeOSC({ code: 9, payload: "4;1;75" })).toEqual({ + kind: "progress", + state: 1, + value: 75, + }); + expect(decodeOSC({ code: 9, payload: "4;0" })).toEqual({ kind: "progress", state: 0 }); + expect(decodeOSC({ code: 9, payload: "4;3" })).toEqual({ kind: "progress", state: 3 }); + }); + + it("decodes OSC 9;9 as ConEmu/Windows-Terminal CWD (not a notification)", () => { + expect(decodeOSC({ code: 9, payload: "9;C:\\Users\\dev" })).toEqual({ + kind: "cwd", + source: "conemu", + path: "C:\\Users\\dev", + }); + // ConEmu sometimes quotes the path + expect(decodeOSC({ code: 9, payload: '9;"C:\\Users\\dev"' })).toEqual({ + kind: "cwd", + source: "conemu", + path: "C:\\Users\\dev", + }); + }); + + it("decodes OSC 9;12 as ConEmu prompt mark", () => { + expect(decodeOSC({ code: 9, payload: "12" })).toEqual({ + kind: "mark", + vendor: "conemu", + raw: "12", + }); + }); + + it("classifies unknown ConEmu sub-commands as unknown, not iTerm notifications", () => { + expect(decodeOSC({ code: 9, payload: "3;tab title" })).toEqual({ + kind: "unknown", + code: 9, + payload: "3;tab title", + }); + expect(decodeOSC({ code: 9, payload: "11;hi" })).toEqual({ + kind: "unknown", + code: 9, + payload: "11;hi", + }); + }); + + it("decodes OSC 9 with non-digit head as iTerm notification", () => { + expect(decodeOSC({ code: 9, payload: "Build done" })).toMatchObject({ + kind: "notification", + vendor: "iterm", + body: "Build done", + }); + }); + + it("decodes OSC 99 kitty notification chunks", () => { + expect(decodeOSC({ code: 99, payload: "i=abc:p=title;Build" })).toMatchObject({ + kind: "notification", + vendor: "kitty", + id: "abc", + title: "Build", + }); + expect(decodeOSC({ code: 99, payload: "i=abc:p=body;ok" })).toMatchObject({ + kind: "notification", + vendor: "kitty", + id: "abc", + body: "ok", + }); + // d=0 means more chunks pending + expect(decodeOSC({ code: 99, payload: "i=abc:d=0:p=body;part" })).toMatchObject({ + kind: "notification", + vendor: "kitty", + partial: true, + }); + // urgency + expect(decodeOSC({ code: 99, payload: "i=abc:u=2;Critical" })).toMatchObject({ + kind: "notification", + vendor: "kitty", + urgency: 2, + title: "Critical", + }); + // base64-encoded payload (e=1) + const base64 = Buffer.from("héllo", "utf8").toString("base64"); + expect(decodeOSC({ code: 99, payload: `i=x:e=1:p=title;${base64}` })).toMatchObject({ + kind: "notification", + title: "héllo", + }); + // close phase (non-payload-bearing) + expect(decodeOSC({ code: 99, payload: "i=x:p=close" })).toMatchObject({ + kind: "notification", + vendor: "kitty", + phase: "close", + }); + }); + + it("decodes OSC 1337 RequestAttention variants", () => { + expect(decodeOSC({ code: 1337, payload: "RequestAttention" })).toMatchObject({ + kind: "attention", + vendor: "iterm", + action: "request", + value: "yes", + }); + expect(decodeOSC({ code: 1337, payload: "RequestAttention=yes" })).toMatchObject({ + kind: "attention", + action: "request", + value: "yes", + }); + expect(decodeOSC({ code: 1337, payload: "RequestAttention=no" })).toMatchObject({ + kind: "attention", + action: "cancel", + value: "no", + }); + expect(decodeOSC({ code: 1337, payload: "RequestAttention=fireworks" })).toMatchObject({ + kind: "attention", + action: "request", + effect: "fireworks", + value: "fireworks", + }); + expect(decodeOSC({ code: 1337, payload: "RequestAttention=once" })).toMatchObject({ + kind: "attention", + action: "request", + effect: "once", + value: "once", + }); + expect(decodeOSC({ code: 1337, payload: "RequestAttentionFoo=yes" })).toEqual({ + kind: "unknown", + code: 1337, + payload: "RequestAttentionFoo=yes", + }); + }); + + it("decodes OSC 1337 CurrentDir / SetMark / RemoteHost / SetUserVar / Copy / ShellIntegrationVersion", () => { + expect(decodeOSC({ code: 1337, payload: "CurrentDir=/home/foo" })).toEqual({ + kind: "cwd", + source: "iterm", + path: "/home/foo", + }); + expect(decodeOSC({ code: 1337, payload: "SetMark" })).toEqual({ + kind: "mark", + vendor: "iterm", + raw: "SetMark", + }); + expect(decodeOSC({ code: 1337, payload: "RemoteHost=alice@example.com" })).toEqual({ + kind: "remoteHost", + vendor: "iterm", + user: "alice", + host: "example.com", + raw: "RemoteHost=alice@example.com", + }); + const base64 = Buffer.from("hello", "utf8").toString("base64"); + expect(decodeOSC({ code: 1337, payload: `SetUserVar=greeting=${base64}` })).toEqual({ + kind: "userVar", + vendor: "iterm", + name: "greeting", + value: "hello", + raw: `SetUserVar=greeting=${base64}`, + }); + expect(decodeOSC({ code: 1337, payload: `Copy=:${base64}` })).toEqual({ + kind: "clipboard", + selection: "", + selections: [], + data: base64, + }); + expect(decodeOSC({ code: 1337, payload: "ShellIntegrationVersion=5" })).toEqual({ + kind: "shellIntegrationVersion", + vendor: "iterm", + version: "5", + raw: "ShellIntegrationVersion=5", + }); + }); + + it("decodes OSC 777 notify; rxvt-perl extension", () => { + expect(decodeOSC({ code: 777, payload: "notify;Build;ok" })).toMatchObject({ + kind: "notification", + vendor: "rxvt", + title: "Build", + body: "ok", + }); + // Unknown 777 prefixes (e.g. fictitious `urgency`) fall to unknown. + expect(decodeOSC({ code: 777, payload: "urgency;push" })).toEqual({ + kind: "unknown", + code: 777, + payload: "urgency;push", + }); + }); + + it("decodes OSC 8 hyperlink open with id param", () => { + expect(decodeOSC({ code: 8, payload: "id=42:foo=bar;https://example.com" })).toEqual({ + kind: "hyperlink", + action: "open", + uri: "https://example.com", + id: "42", + params: { id: "42", foo: "bar" }, + }); + }); + + it("decodes OSC 8 hyperlink close (empty uri) as action=close", () => { + expect(decodeOSC({ code: 8, payload: ";" })).toEqual({ + kind: "hyperlink", + action: "close", + uri: "", + params: {}, + }); + }); + + it("decodes OSC 52 clipboard set, query, and clear", () => { + expect(decodeOSC({ code: 52, payload: "c;aGVsbG8=" })).toEqual({ + kind: "clipboard", + selection: "c", + selections: ["c"], + data: "aGVsbG8=", + }); + expect(decodeOSC({ code: 52, payload: "p;?" })).toEqual({ + kind: "clipboard", + selection: "p", + selections: ["p"], + query: true, + }); + // Multi-char selection + expect(decodeOSC({ code: 52, payload: "cs;aGk=" })).toMatchObject({ + selection: "cs", + selections: ["c", "s"], + data: "aGk=", + }); + // Pd not base64, not `?` → clear + expect(decodeOSC({ code: 52, payload: "c;not!base64" })).toEqual({ + kind: "clipboard", + selection: "c", + selections: ["c"], + clear: true, + }); + // Empty Pc — defaults to s0 per spec; we surface as empty selection/selections + expect(decodeOSC({ code: 52, payload: ";aGk=" })).toMatchObject({ + selection: "", + selections: [], + data: "aGk=", + }); + }); + + it("returns unknown for unrecognized codes", () => { + expect(decodeOSC({ code: 4242, payload: "x" })).toEqual({ + kind: "unknown", + code: 4242, + payload: "x", + }); + }); +}); + +describe("createOSCDecoder", () => { + it("falls back to built-ins when no customs provided", () => { + const decode = createOSCDecoder(); + expect(decode({ code: 0, payload: "t" })).toMatchObject({ kind: "title", code: 0 }); + expect(decode({ code: 4242, payload: "x" })).toEqual({ + kind: "unknown", + code: 4242, + payload: "x", + }); + }); + + it("dispatches custom decoders for arbitrary codes (including unknown)", () => { + const decode = createOSCDecoder({ + 50: (p) => ({ kind: "screen-mode" as const, mode: p }), + 9999: (p, e) => ({ kind: "vendorX" as const, code: e.code, raw: p }), + }); + + expect(decode({ code: 50, payload: "fullscreen" })).toEqual({ + kind: "screen-mode", + mode: "fullscreen", + }); + expect(decode({ code: 9999, payload: "abc" })).toEqual({ + kind: "vendorX", + code: 9999, + raw: "abc", + }); + }); + + it("custom decoders override built-ins", () => { + const decode = createOSCDecoder({ + 133: (p) => ({ kind: "my-shell" as const, raw: p }), + }); + expect(decode({ code: 133, payload: "A" })).toEqual({ kind: "my-shell", raw: "A" }); + // Other codes still hit built-ins + expect(decode({ code: 0, payload: "t" })).toMatchObject({ kind: "title" }); + }); + + it("custom decoders compose with built-in fallback for un-handled codes", () => { + const decode = createOSCDecoder({ + 50: (p) => ({ kind: "screen-mode" as const, mode: p }), + }); + expect(decode({ code: 7, payload: "file:///tmp" })).toMatchObject({ kind: "cwd" }); + expect(decode({ code: 4242, payload: "x" })).toEqual({ + kind: "unknown", + code: 4242, + payload: "x", + }); + }); + + it("typed result is DecodedOSC | T", () => { + const decode = createOSCDecoder({ + 50: (p) => ({ kind: "screen-mode" as const, mode: p }), + }); + const result = decode({ code: 50, payload: "x" }); + expectTypeOf(result).toEqualTypeOf<DecodedOSC | { kind: "screen-mode"; mode: string }>(); + }); +}); + +describe("builtinOSCDecoders", () => { + it("exposes every well-known code", () => { + const codes = Object.keys(builtinOSCDecoders) + .map(Number) + .sort((a, b) => a - b); + expect(codes).toEqual([0, 1, 2, 7, 8, 9, 52, 99, 133, 633, 777, 1337]); + }); + + it("individual decoders are callable for reuse", () => { + const titleDecoder = builtinOSCDecoders[0]!; + expect(titleDecoder("hi", { code: 0, payload: "hi" })).toEqual({ + kind: "title", + code: 0, + title: "hi", + }); + }); +}); diff --git a/src/osc/inspector.ts b/src/osc/inspector.ts new file mode 100644 index 0000000..5b8ca8d --- /dev/null +++ b/src/osc/inspector.ts @@ -0,0 +1,145 @@ +import { Buffer } from "node:buffer"; +import type { IPtyConsumer } from "../pty/types.ts"; +import type { OSCEvent, OSCListener } from "./types.ts"; + +const MAX_PAYLOAD = 4096; + +const Ground = 0; +const Esc = 1; +const Osc = 2; +const OscSt = 3; +type State = 0 | 1 | 2 | 3; + +/** + * 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); + * ``` + */ +export class OSCInspector implements IPtyConsumer { + private _state: State = Ground; + private _buf = Buffer.allocUnsafe(MAX_PAYLOAD); + private _len = 0; + private _overflow = false; + private _listeners: OSCListener[] = []; + + constructor(listener?: OSCListener) { + if (listener) this._listeners.push(listener); + } + + /** Subscribe to OSC events. Returns a disposer. */ + on(listener: OSCListener): () => void { + this._listeners.push(listener); + return () => { + const idx = this._listeners.indexOf(listener); + if (idx >= 0) this._listeners.splice(idx, 1); + }; + } + + /** Feed bytes into the parser. Accepts string (utf-8), Buffer, or Uint8Array. */ + feed(data: string | Buffer | Uint8Array): void { + 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]!); + } + + /** Drop all listeners and reset parser state. */ + dispose(): void { + this._listeners.length = 0; + this._state = Ground; + this._len = 0; + this._overflow = false; + } + + private _feedByte(b: number): void { + // CAN (0x18) and SUB (0x1a) cancel any in-progress escape sequence. + if (b === 0x18 || b === 0x1a) { + this._state = Ground; + this._len = 0; + this._overflow = false; + return; + } + switch (this._state) { + case Ground: + if (b === 0x1b) this._state = Esc; + return; + case Esc: + if (b === 0x5d) { + this._state = Osc; + this._len = 0; + this._overflow = false; + } else if (b !== 0x1b) { + this._state = Ground; + } + return; + case Osc: + if (b === 0x07) { + this._finish(); + } else if (b === 0x1b) { + 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 === 0x5c) { + this._finish(); + } else if (b === 0x1b) { + // Stray ESC — abort current sequence but treat this ESC as the start + // of a potential next one (so ESC ] re-enters OSC cleanly). + this._state = Esc; + this._len = 0; + this._overflow = false; + } else { + this._state = Ground; + this._len = 0; + this._overflow = false; + } + return; + } + } + + private _finish(): void { + 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: OSCEvent = { code, payload }; + for (const l of this._listeners) { + try { + l(event); + } catch { + // Swallow listener errors — never let one break parsing. + } + } + } + this._state = Ground; + this._len = 0; + this._overflow = false; + } +} diff --git a/src/osc/types.ts b/src/osc/types.ts new file mode 100644 index 0000000..507f1b0 --- /dev/null +++ b/src/osc/types.ts @@ -0,0 +1,136 @@ +/** Raw OSC event emitted by the parser. */ +export 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. */ +export 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. */ +export type OSCListener = (event: OSCEvent) => void; + +/** Signature for an OSC decoder. `payload` is split out for ergonomics. */ +export type OSCDecoderFn<T> = (payload: string, event: OSCEvent) => T; + +/** A map of OSC code → decoder. Used by both built-ins and custom decoders. */ +export type OSCDecoderMap<T> = Record<number, OSCDecoderFn<T>>; + +/** Union of return types from a custom decoder map — used to type the result of {@link createOSCDecoder}. */ +export type CustomDecodedOSC<Map> = Map[keyof Map] extends (...args: never) => infer R ? R : never; diff --git a/src/pty/_base.ts b/src/pty/_base.ts index 917debb..0274cb5 100644 --- a/src/pty/_base.ts +++ b/src/pty/_base.ts @@ -1,4 +1,4 @@ -import type { IDisposable, IEvent, IPty, IPtyOptions, IPtyStats } from "./types.ts"; +import type { IDisposable, IEvent, IPty, IPtyConsumer, IPtyOptions, IPtyStats } from "./types.ts"; import { Terminal } from "../terminal.ts"; import type { TerminalOptions } from "../terminal.ts"; @@ -70,6 +70,54 @@ export abstract class BasePty implements IPty { }; } + attach(consumer: IPtyConsumer): IDisposable { + consumer.onAttach?.(this); + + const feed = (data: string | Buffer) => { + try { + consumer.feed(data); + } catch { + // Swallow consumer errors — never let one break the pty data path. + } + }; + + let dataSub: IDisposable | undefined; + let terminalListener: ((data: string) => void) | undefined; + if (this._terminal) { + // When a Terminal is attached, it may own the underlying data stream + // (Unix). Listen through the Terminal to keep attach() consistent across + // platform implementations. + 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 { + // Swallow consumer errors during teardown. + } + }; + + const exitSub = this.onExit(detach); + + return { dispose: detach }; + } + waitFor(pattern: string, options?: { timeout?: number }): Promise<string> { const timeout = options?.timeout ?? 30_000; const terminal = this._terminal; diff --git a/src/pty/types.ts b/src/pty/types.ts index 13b82b7..23ed711 100644 --- a/src/pty/types.ts +++ b/src/pty/types.ts @@ -8,6 +8,23 @@ export 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). + */ +export 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; +} + export interface IPtyChildStats { /** Process ID. */ pid: number; @@ -79,6 +96,12 @@ export interface IPty { 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; } export interface IPtyOpenOptions { diff --git a/src/spawn.test.ts b/src/spawn.test.ts index 583c0c6..9953bab 100644 --- a/src/spawn.test.ts +++ b/src/spawn.test.ts @@ -41,12 +41,12 @@ describe("spawn", () => { it.skipIf(isWindows)("exit callback keeps event loop alive (issue #4)", async () => { const prebuildsDir = path.resolve(__dirname, "..", "prebuilds"); const script = ` - const fs = require("node:fs"); const path = require("node:path"); const dir = ${JSON.stringify(prebuildsDir)}; - const candidate = fs.readdirSync(dir).find((f) => - f.startsWith("zigpty." + process.platform + "-" + process.arch) && f.endsWith(".node")); - const native = require(path.join(dir, candidate)); + const base = "zigpty." + process.platform + "-" + process.arch; + let native; + try { native = require(path.join(dir, base + ".node")); } + catch { native = require(path.join(dir, base + "-musl.node")); } native.fork("true", [], [], process.cwd(), 80, 24, -1, -1, true, (info) => { process.stdout.write("EXITED:" + info.exitCode); }); diff --git a/src/terminal.test.ts b/src/terminal.test.ts index ec5a7d0..b2dac43 100644 --- a/src/terminal.test.ts +++ b/src/terminal.test.ts @@ -1,6 +1,7 @@ import { platform } from "node:os"; import { describe, expect, it } from "vitest"; import { Terminal, spawn } from "./index.ts"; +import { OSCInspector } from "./osc/index.ts"; const isWindows = platform() === "win32"; const shell = isWindows ? "cmd.exe" : "/bin/sh"; @@ -171,6 +172,25 @@ describe("spawn with terminal option", () => { expect(exitInfo.exitCode).toBe(7); }); + it("should allow attached consumers to observe terminal-backed PTY data", async () => { + const events: Array<{ code: number; payload: string }> = []; + const pty = spawn( + process.execPath, + ["-e", "setTimeout(() => process.stdout.write('\\x1b]0;hi\\x07'), 50)"], + { + terminal: { + data() {}, + }, + }, + ); + + pty.attach(new OSCInspector((event) => events.push(event))); + await pty.exited; + await new Promise((r) => setTimeout(r, 50)); + + expect(events).toContainEqual({ code: 0, payload: "hi" }); + }); + it("should write data via terminal callback", async () => { const exe = isWindows ? "cmd.exe" : "/bin/cat";