closeout

Changelog

Every release of closeout, newest first, from its CHANGELOG.md — what changed and the pull request it came from.

0.6.0

Minor Changes

  • #662 ff4159d Thanks @ofri-peretz! - Terminal restore now covers all three things design R4 promises: raw mode off, alternate screen left, cursor shown — last, on every exit path.

    • alternateScreen(stream) enters the alternate screen (ESC[?1049h) and registers leaving it in the restore phase; the returned function leaves it early. rawMode(input) turns raw mode on and registers turning it off; an input that was already raw belongs to somebody else and is left alone, now and at exit. Both sit beside hideCursor, run their undo at most once, and write nothing to a non-TTY. They register on the process-wide instance, or on one install() returned when passed as a second argument, and tree-shake away from programs that do not import them. closeout/cursor exports the leaf forms and ENTER_ALTERNATE_SCREEN / LEAVE_ALTERNATE_SCREEN.
    • Fixed: a process whose shutdown was waiting on a handler that holds nothing in the event loop could leave through 'exit' before the deadline without ever running the restore phase. Node's 'exit' now invokes every phase the shutdown had not reached yet, each still exactly once.

Patch Changes

  • #729 6d8aadf Thanks @ofri-peretz! - Two fixes, and some code no input could reach is removed.

    closeout/exit-hook no longer calls asynchronous hooks on a synchronous exit. On process.exit() or PM2's shutdown message it printed exit-hook's notice that asynchronous tasks "will not run", then called every asyncExitHook callback anyway and abandoned the promise. Any code a hook ran before its first await therefore ran under closeout and not under exit-hook. Only the synchronous hooks run on that path now, as they do upstream.

    A handler registered with a phase that is not flush, release or restore is refused with TypeError: closeout: no phase <name>. Before, a misspelled phase from untyped code, such as onExit(unlock, 'Restore'), was accepted, never counted and never run. count() refuses the same names.

    closeout check no longer carries a "(replaces …)" helper it never called, or a filter that could never drop a row, because it loads one plugin into an emptied registry. A fallback exit code for a signal outside the five closeout listens on is gone, because there is no such signal. closeout/exit-hook also drops a per-event once-guard that duplicated the registry's own, plus two fields of a record no code read.

  • #670 5e635b0 Thanks @ofri-peretz! - Fixed: a second signal during shutdown killed the process before the terminal was restored. A second Ctrl-C, or a SIGTERM that arrived while a handler was still running, left the terminal in raw mode, on the alternate screen, with the cursor hidden. A second trigger now waits for the shutdown already running, so the terminal is restored first and the process dies of the first signal. The deadline still bounds the wait.

  • #662 ff4159d Thanks @ofri-peretz! - Fixed: a process whose shutdown was waiting on a handler that holds nothing in the event loop exited 0 instead of leaving the way its trigger decided. SIGTERM through install() now dies of SIGTERM, an uncaught throw exits 1 and prints the error, and closeout/exit-hook exits 143 on SIGTERM, as exit-hook does. The deadline now keeps the event loop alive until the shutdown finishes or times out, on every trigger except 'beforeExit'. When it times out, the breach report names the handler that hung.

  • #674 e9f45d8 Thanks @ofri-peretz! - README: family header, badges, install, migrating, the family table.

    Every package README now opens the same way — lockup, tagline, one badge row in one order (npm version, downloads, Quality Gate, the package's own coverage, OpenSSF Scorecard, unpacked size, dependencies, types, Node, licence, npm provenance), a row of compatibility badges read from the graded baseline — and carries the same sections in the same order: Install for npm, pnpm, yarn and bun, Quick start, Migrating as a before/after diff, Compatibility, Benchmarks, For agents, API, and a generated table of the nine packages. Links are absolute, so they work on npm as well as GitHub.

0.5.4

Patch Changes

  • #627 4a629a9 Thanks @ofri-peretz! - Lint with every published Interlace ESLint plugin, and fix what the upgrade surfaced.

    • caique: the inquirer theme merge skips __proto__, constructor and prototype keys, so a theme object cannot swap the merged object's prototype.
    • burgee: last-element reads use .at(-1).
    • burgee, closeout, flagstaff, roundel: helpers that capture nothing from their enclosing function move to module scope.
    • seniority: suppression comments name the no-dynamic-require rule that now reports the config loader's dynamic require.

    No public API or output changes.

0.5.3

Patch Changes

  • #604 0e7b1e8 Thanks @ofri-peretz! - Each README links to its migration guides under the docs link: "Migrating from: chalk", "ora · log-update · boxen · cli-table3", and so on. That puts a path from the npm page to the guide for the library you are replacing. No code changes.

0.5.2

Patch Changes

  • #518 866b972 Thanks @ofri-peretz! - README weight lines now count every incumbent with a graded drop-in: terminal-link and term-img (paratext), exit-hook (closeout), @inquirer/core (caique).

  • #474 1955419 Thanks @ofri-peretz! - burgee plugins can hook two more stages. parse runs before the command is resolved: it receives argv and may return a replacement, which is how an alias plugin maps d to deploy. shutdown runs once as the program exits, whether the command succeeded or failed. The family schema.json shipped in every package now describes both stages.

  • #492 1b002e0 Thanks @ofri-peretz! - The README no longer prints an overrides recipe: an override points an incumbent's name at closeout's root, which is closeout's own API, so exit-hook and restore-cursor never linked through it. Each drop-in — now including closeout/signal-exit, graded 134 / 135 by signal-exit's own suite — is swapped by import.

  • #519 77ff1cb Thanks @ofri-peretz! - The drop-ins now export their incumbents' type names, so a TypeScript program migrates by its import alone: roundel/chalk gains chalk's Color, ForegroundColor, BackgroundColor, Modifiers and Options; flagstaff/ora gains Spinner, PrefixTextGenerator and SuffixTextGenerator; flagstaff/boxen gains Options, CustomBorderStyle and Boxes; flagstaff/log-update, linegauge, linegauge/wrap and closeout/exit-hook gain Options; burgee/yargs/parser gains Arguments, Options and Configuration. Types only — no runtime bytes.

0.5.1

Patch Changes

  • #508 1aae1e2 Thanks @ofri-peretz! - <package> --help and --version answer instead of crashing. The bin took its first argument as the plugin file to import, so roundel --help failed with Cannot find module '…/--help' and exit 1. -h/--help now print usage and exit 0, -V/--version print the version and exit 0, and any other flag where the plugin file belongs is a usage error, exit 2.

0.5.0

Minor Changes

  • #507 b8e97dc Thanks @ofri-peretz! - Runs on Node 20 and 22, not just 24+: engines.node is now ^20.19.0 || >=22.13.0. Those are the first releases where require(esm) loads without a warning, so the CommonJS require() path keeps working. Every package's test suite runs on exactly 20.19.0 and 22.13.0, on Linux, macOS and Windows. caique's prompts no longer call Promise.withResolvers, which Node 20 doesn't have.

Patch Changes

  • #505 9800b43 Thanks @ofri-peretz! - Docs: the Benchmarks section's weight ceiling is re-measured against a fresh install of each incumbent's latest release (cosmiconfig 10.0.1, slice-ansi 9.0.1, which 7.0.0, dotenv 18.0.3, …) instead of the copies hoisted in this workspace, and names incumbents that were measured but left out of the ceiling as exactly that.

0.4.2

Patch Changes

  • #501 a4a5c43 Thanks @ofri-peretz! - The README's exit-hook / restore-cursor override examples resolve to the current release (^0.4).

0.4.1

Patch Changes

  • #494 f7f6d4b Thanks @ofri-peretz! - Each package's homepage and README docs link now point at its own documentation site, https://<package>.interlace.tools, instead of a page on burgee's site. The old burgee.interlace.tools/docs/packages/<package> URLs answer with a 301 to the new host, so nothing already linked breaks. closeout's README override example also resolves to the current release again (npm:closeout@^0.4; the 0.4.0 release left it at ^0.3).

0.4.0

Minor Changes

  • #459 69563d1 Thanks @ofri-peretz! - closeout/signal-exit and closeout/signal-exit/signals — the drop-in path for signal-exit 4 (198.9 M/wk), graded 134 / 135 by signal-exit's own test suite, the same case its own package fails. onExit, load, unload and signals, at 4,797 B against the incumbent's 10,995 B. CommonJS on purpose — the suite re-evaluates the module under a changed process — and importable by name from ESM.

Patch Changes

  • #480 2dc573f Thanks @ofri-peretz! - README corrections: paratext shows terminal-link at its measured 8 / 10 (was the stale 0 / 10 floor); linegauge's and closeout's npm: override examples resolve to the current release instead of 0.2 / 0.1.

0.3.2

Patch Changes

  • #465 acf98f3 Thanks @ofri-peretz! - Each README now opens with the incumbent it replaces and the agent surface it serves (--json, an agent event, or a static projection), so npm shows both above the fold. README text only; no code changed.

0.3.1

Patch Changes

  • #454 b4584e7 Thanks @ofri-peretz! - Every package's npm homepage now points at its page on the docs site, https://burgee.interlace.tools/docs/packages/<name>, and each README links it under the header. The keywords add what people and models search for: burgee gains cli-framework, argument-parser, subcommands, json-schema, mcp-server, model-context-protocol, ai-agent, llm, shell-completion, typescript, zero-dependency, commander-alternative and yargs-alternative; the other eight gain agent, ai-agent, non-tty, json and zero-dependency where the package does that — zero-dependency only on the six that install nothing at all.

    burgee's README gains a short FAQ (commander alternative, agent use, MCP, dependencies) and states the compatibility counts the oracle holds — 1,360 / 1,360 of commander's tests and 804 / 804 of yargs' — where it had said 1,215 and 1,185. caique's README no longer calls a released package pre-release.

  • #442 bdaf364 Thanks @ofri-peretz! - Every package now lists plugin, plugins and extensible in its npm keywords, because every package takes plugins through one shared contract.

    A plugin is a plain object, validated against the schema.json that ships in every package, and checked with the package's own check command. Each package reads its own key and ignores the rest, so one object can extend any subset of the family. The plugins page has a nine-layer example that every package's check accepts in CI.

  • #435 7888524 Thanks @ofri-peretz! - schema.json now describes every plugin host in the family.

    The one schema each package ships as its plugin contract used to cover only four hosts: roundel's tokens, flagstaff's glyphs, spinners, borders and components, paratext's capabilities, and linegauge's widths. Five hosts validated their keys in their own code, but the file an author (or a model) writes against said nothing about them. It now describes all of them:

    • bellpull resolvers, including the absolute-path rule on paths
    • caique widgets
    • closeout handlers, including the phases a plugin may use
    • seniority sources, including the rank bounds
    • burgee commands, hooks and enforce

    Where the schema can express a rule, it gives the same verdict as the host's own validator, and a test holds the two together. Function-valued fields (static, run, read, handler) are described and required, but not typed, because JSON Schema can't say "function".

    flagstaff now validates a plugin against only its own keys, not the whole family schema. It no longer refuses a plugin over another host's key, which lets one plugin object contribute to several hosts. Its entry points are also 4.7–5.9 KB lighter for it.

  • #445 dac303e Thanks @ofri-peretz! - Every package now declares sideEffects truthfully, so bundlers can drop what you don't import.

    Six packages declared nothing, so no bundler could drop any of their modules. A named import from the root now bundles to the same bytes as the same import from its subpath:

    importbeforeafter
    import { explain } from 'seniority'2,939 B1,067 B
    import { decide } from 'caique'1,235 B734 B
    import { strip } from 'linegauge'1,102 B940 B
    import { once } from 'closeout'353 B235 B

    flagstaff and roundel used to declare false, but each ships a check command whose file runs when loaded. Each now lists that file, which is the true statement. paratext also lists the two modules that register its built-in capabilities when they load.

0.3.0

Minor Changes

  • #421 db3c59e Thanks @ofri-peretz! - Every plugin host has a check command.

    npx linegauge check ./my-widths.mjs
    npx burgee check ./my-plugin.mjs --json

    PRINCIPLES 7 asks three things of an extension surface: the plugin is data validated against one published schema, there is a check command that shows it every way it can be seen, and the bar is measured. The first was built in all nine hosts; the second existed in flagstaff alone. So an author writing a plugin for any other host found out what it did by shipping it into a program — and a surface nobody can check is a surface nobody outside this repository can write against.

    Each command validates, registers, and shows what the host does with the plugin, in the host's own terms: linegauge measures each code point before and after the override, paratext shows a capability's encode and its fallback, roundel each token and what it replaced, caique each widget's static projection rendered with its own sample. burgee's returns a document rather than printing one, so burgee check --json is the form an agent that just wrote a plugin reads.

    They share one contract with the author, held identically across all nine:

    • a readable report, contribution by contribution, with ok as the last line;
    • a refusal with a code from the family's vocabulary and a fix, exit 1;
    • E_NO_CONTRIBUTION for a plugin that contributes nothing to this host — the schema allows unknown keys so one object registers everywhere, which makes a misspelled key silent, and this is how that typo tells on itself;
    • exit 2 with no file.

    Each host also gains an eval case measuring the one-turn claim, proved to discriminate before it was committed: green against a correct plugin, red against the same plugin with one field broken.

Patch Changes

  • #430 4d1b2b3 Thanks @ofri-peretz! - check now reports every refusal with its code and its fix, wherever it was raised.

    Some plugin files register themselves on import: they call register() at the top of the module and export the result. Until now, when such a file was refused, the error was thrown inside check's import(), before the only try that turns a PluginError into E_PLUGIN_SCHEMA: … plus a fix: line. The author got the bare message on stderr, with no code and no fix. Now the whole of check runs inside that one handler, so every refusal comes out the same way on every host.

0.2.1

Patch Changes

  • #373 a1f1d40 Thanks @ofri-peretz! - Stage 2's artifact is now spec.md, the name Anthropic's AI-Native SDLC playbook gives it, so the source comments and README sections that cite a package's own design document point at spec.md rather than design.md.

    No behaviour changes. The published tarballs do move, by two bytes per surviving reference — design.md is nine characters and spec.md is seven — so the four packages carrying a weight band were re-measured against it: linegauge 83,538 to 83,536; paratext 66,343 to 66,341; closeout 84,455 to 84,453; bellpull 86,113 to 86,107.

0.2.0

Minor Changes

  • #316 c8acb28 Thanks @ofri-peretz! - Shutdown is now bounded on every path out of a program, and a breach says which handler did not come back.

    The deadline reports. When the clock expires the process still leaves — with the code it was already leaving with — and the report names every handler that had not returned, by the caller's label, the function's own name, or (anonymous). A plugin's handlers are named "<plugin>:<handler>". run() resolves with that report ({ path, signal, code, error, timedOut, unfinished }), and install({ onTimeout }) is where the line goes; the default writes it to stderr. A hang used to be silence; it is now a diagnosable event with a name in it.

    Infinity and 0 are refused at install() / createRegistry() — not at the shutdown they would have ruined — with a USAGE-class error carrying a fix. One waits forever; the other gives no asynchronous handler a turn. Both reintroduce the failure the package exists to remove.

    Three more doors. beforeExit, uncaughtException and unhandledRejection now run the handlers, and SIGNALS gains SIGQUIT and SIGBREAK. No incumbent in this layer listens for a throw or a rejection, which is why a CLI that crashes mid-render leaves the cursor hidden. A program with its own crash handler keeps deciding what happens next — closeout stands its own listener down and counts before exiting, exactly as it already did for signals.

    One record, three renderings. A handler is handed { path, signal, code, error }, where path is 'exit' | 'beforeExit' | 'signal' | 'uncaught' | 'rejection', and reportToJson() and reportToEvent() project that same value for a --json line and an agent event.

    once(fn) arrives as closeout/once: onetime + mimic-fn — 262 M downloads a week between them — in 441 B with no dependency, preserving name, length and this. closeout/cursor is the other new leaf, 666 B, for a program that only needs to put a cursor back.

    The exit code is the program's. It is captured at the trigger, before any handler runs, so a handler that sets exitCode = 0 on its way past cannot turn a process.exit(3) or a SIGTERM into a success.

    Existing callers are unaffected: onExit(handler) and onExit(handler, 'flush') both still work, and the handler's argument gained fields rather than losing any. The published bundle got smaller — comments are now stripped from dist/, 46,066 B down to 20,417 B — and every entry point is on a byte ratchet.

  • #303 fc640dd Thanks @ofri-peretz! - Two graded drop-in paths: closeout/exit-hook and closeout/restore-cursor.

    Both are new subpath exports, and both are graded by the incumbent's own unedited test suite through compat-oracle, with --control — the same suite run against the incumbent itself — proving the gate first: exit-hook@5.1.0 21 / 21 against a 21 / 21 control, and restore-cursor@5.1.0 6 / 6 against a 6 / 6 control. overrides: { "exit-hook": "npm:closeout@^0.1" } and the same for restore-cursor now resolve.

    closeout/exit-hook keeps the incumbent's per-hook { wait } bound rather than imposing closeout's own 2 000 ms deadline — the incumbent's own suite registers a hook with wait: 2000, and a drop-in that silently tightens a caller's timeout is not a drop-in. closeout's bounded shutdown stays in onExit().

    Nothing a caller already imports changed. Internally the signal wiring moved from index.ts to install.ts and the guarded globalThis.process lookup to ambient.ts, so the two façades can reach them without importing the package's own entry; index.ts re-exports every name it exported before.

  • #294 3f92a60 Thanks @ofri-peretz! - Shutdown order is now data rather than registration order, and closeout/plugin hosts handlers.

    A handler declares a phase — flush, release (the default) or restore — and PHASES declares the sequence. Phases run in sequence, so an async handler in flush settles before release begins; handlers inside one phase run together, in registration order. Terminal restore moves into restore and is therefore last, always.

    This closes a failure that registration order could not: the cursor's restore was registered by whichever renderer hid the cursor, usually the moment it first drew, so anything registered afterwards ran after the terminal had already been handed back — cleaning up nothing it was registered to clean up. An order that depends on import order is not an order.

    closeout/plugin is the new subpath (plugin-contract R5a): register() keeps a plugin's handlers and ignores every other layer's keys, attach(registry) wires each into its phase, and contributions() projects the whole shutdown sequence without running any of it. A plugin may use flush or release and not restore — R5a says a plugin's cleanup runs "never after" terminal restore, and that is enforced at the door rather than asserted in prose. closeout/schema.json is exported too, because it is the specifier this package's own E_PLUGIN_SCHEMA fix names.

    Past the deadline, later phases are still run — they are only no longer waited for. A handler that hangs in flush does not get to decide that the cursor stays hidden.

    Existing callers are unaffected: onExit(handler) still works and lands in release, which is before restore.

  • #332 3ea38c3 Thanks @ofri-peretz! - closeout re-raises a signal instead of exiting 128 + n, so a process killed by Ctrl-C now really dies of SIGINT rather than exiting 130. WIFSIGNALED is false for the old behaviour and true for the new one, and that difference is visible to everything upstream of the program: a shell knows to print ^C and re-raise into its own job control, make stops a parallel build instead of carrying on, a CI runner marks a job cancelled rather than failed, and a supervisor decides whether to restart. 128 + n is the number a shell reports afterwards; it was never a status a process could set for itself.

    The package used to argue the opposite in a comment — that re-raising "re-enters this listener", and that exiting explicitly is what a caller who owns main wants. The first half was already answered by the code around it: closeout removes its own listener before it counts, which is the same unload()-then-process.kill(process.pid, sig) shape signal-exit uses, and signal-exit is closeout's declared incumbent for this surface. The second half is a preference the incumbent does not share.

    The stand-down guard is unchanged and now covers one more thing: the re-raise happens only when no other listener remains, so a program with its own SIGINT handler still gets the cleanup, still decides what happens next, and gets exactly one delivery for one Ctrl-C. A runtime that refuses to raise a given signal at itself — SIGHUP is ENOSYS on Windows — falls back to the POSIX code, because a shutdown that will not go is the one failure this package is named for.

    ProcessLike now requires kill(pid, signal) and pid. This is a breaking change to that type for anyone calling install({ process }) with a hand-written double, and it is deliberate: optional members would have let a fake quietly take the exit path, which is how the missing re-raise survived exit-hook's 21-case suite and restore-cursor's 6-case suite intact. Both suites still pass 21 / 21 and 6 / 6.

    closeout/exit-hook is unchanged and stays faithful to its incumbent: it exits 128 + n and listens on SIGINT and SIGTERM only — no SIGHUP — because exit-hook@5.1.0 registers exactly beforeExit, SIGINT, SIGTERM, exit and message, and its own suite grades the exit codes. Use closeout's onExit rather than the drop-in when a closing terminal has to reach your cleanup; closeout's own wiring has covered SIGHUP all along.

Patch Changes

  • #339 f295630 Thanks @ofri-peretz! - schema.json constrains token names, because it was promising something no host honours.

    tokens was described as any name to a #rrggbb colour. roundel's validate() accepts ten semantic names — error, warn, ok, hint, muted, command, flag, value, heading, ground — and throws on everything else. So a plugin author doing exactly what their own E_PLUGIN_SCHEMA error tells them, comparing their object against roundel/schema.json, got a green from the schema and "accent" is not a token from register(). Measured 2026-09-16 with { accent: '[#336699](https://github.com/ofri-peretz/burgee/issues/336699)' }.

    The schema now carries propertyNames.enum, and scripts/plugin-contract-lock.test.ts pins the enum and the runtime set to each other from both sides, so neither can grow a name the other does not know.

    Every host ships a byte-identical copy of this file (plugin-schema-lock.test.ts asserts it), which is why nine packages are listed. Only the key roundel owns is constrained: describing widgets, handlers, sources, resolvers or commands in a file all eight hosts share is what made flagstaff start validating caique's key last time (PluginError: plugin.widgets.later: expected object, got boolean), and those stay in plugin-schema-lock's UNDESCRIBED list with that reason.

    linegauge is in the list for a different change: ceilings.json's R9 block now records the bar as D1's tree-inclusive ceiling — 83,538 against 170,342, a ratio of 0.4904 — and keeps the superseded get-east-asian-width bar beside it with the count of entries that cleared it.

0.1.0

Minor Changes

  • #227 cf637a8 Thanks @ofri-peretz! - closeout runs your exit handlers exactly once, on every path

    The package existed as a reserved name exporting a string constant. It now does the job its description has always claimed.

    A program leaves by several doors — returning from main, process.exit, SIGINT, SIGTERM, SIGHUP — and a handler registered on 'exit' alone catches one of them. That is why Ctrl-C so often leaves a hidden cursor, a half-written file, or a lock nobody released.

    • onExit(handler) — runs once, on every path, and returns the function that unregisters it. Two signals, or a signal and the 'exit' behind it, are one shutdown.
    • hideCursor(stream) — hides the cursor and registers the restore in the same call, so the two cannot drift apart. Returns the show function, which also unregisters. Idempotent, and silent on a non-TTY, because escape sequences in a pipe corrupt the output the pipe carries.
    • A deadline, two seconds by default. A handler awaiting something that never resolves turns Ctrl-C into a process the user kills twice, and the second one is SIGKILL with no cleanup at all. Abandoning a slow handler is the better trade.
    • createRegistry and install({ process }) — the shutdown logic with no process attached, and the wiring pointed at one that is not the global. Every case in the suite runs without a real signal.

    One handler's failure is its own: a throw is reported and the rest still run. Shutdown is the worst place for an exception to short-circuit a loop, because the handler that restores the terminal is usually registered last.

    Importing the package attaches nothing — the process-wide instance installs on first use.

    Still to come: raw mode and alternate-screen restore, and the graded drop-in paths for signal-exit, exit-hook and restore-cursor.

Patch Changes

  • #245 9818135 Thanks @ofri-peretz! - A program that installed its own handler for a signal keeps it. closeout ran its cleanup and then exited unconditionally, which overrules a program that asked to own SIGINT — one that wants to finish a request and exit 7, or ignore Ctrl-C entirely. It now stands its own listener down, and leaves only when no other listener remains. Cleanup is unchanged: it still runs on every path, which is not what was being deferred.

On this page