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
ff4159dThanks @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 therestorephase; 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 besidehideCursor, run their undo at most once, and write nothing to a non-TTY. They register on the process-wide instance, or on oneinstall()returned when passed as a second argument, and tree-shake away from programs that do not import them.closeout/cursorexports the leaf forms andENTER_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 therestorephase. Node's'exit'now invokes every phase the shutdown had not reached yet, each still exactly once.
Patch Changes
-
#729
6d8aadfThanks @ofri-peretz! - Two fixes, and some code no input could reach is removed.closeout/exit-hookno longer calls asynchronous hooks on a synchronous exit. Onprocess.exit()or PM2'sshutdownmessage it printed exit-hook's notice that asynchronous tasks "will not run", then called everyasyncExitHookcallback anyway and abandoned the promise. Any code a hook ran before its firstawaittherefore 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,releaseorrestoreis refused withTypeError: closeout: no phase <name>. Before, a misspelled phase from untyped code, such asonExit(unlock, 'Restore'), was accepted, never counted and never run.count()refuses the same names.closeout checkno 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-hookalso drops a per-event once-guard that duplicated the registry's own, plus two fields of a record no code read. -
#670
5e635b0Thanks @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
ff4159dThanks @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 throughinstall()now dies of SIGTERM, an uncaught throw exits 1 and prints the error, andcloseout/exit-hookexits 143 on SIGTERM, asexit-hookdoes. 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
e9f45d8Thanks @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
4a629a9Thanks @ofri-peretz! - Lint with every published Interlace ESLint plugin, and fix what the upgrade surfaced.- caique: the inquirer theme merge skips
__proto__,constructorandprototypekeys, 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-requirerule that now reports the config loader's dynamicrequire.
No public API or output changes.
- caique: the inquirer theme merge skips
0.5.3
Patch Changes
- #604
0e7b1e8Thanks @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
866b972Thanks @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
1955419Thanks @ofri-peretz! - burgee plugins can hook two more stages.parseruns before the command is resolved: it receives argv and may return a replacement, which is how an alias plugin mapsdtodeploy.shutdownruns once as the program exits, whether the command succeeded or failed. The familyschema.jsonshipped in every package now describes both stages. -
#492
1b002e0Thanks @ofri-peretz! - The README no longer prints anoverridesrecipe: an override points an incumbent's name at closeout's root, which is closeout's own API, soexit-hookandrestore-cursornever linked through it. Each drop-in — now includingcloseout/signal-exit, graded 134 / 135 by signal-exit's own suite — is swapped by import. -
#519
77ff1cbThanks @ofri-peretz! - The drop-ins now export their incumbents' type names, so a TypeScript program migrates by its import alone:roundel/chalkgains chalk'sColor,ForegroundColor,BackgroundColor,ModifiersandOptions;flagstaff/oragainsSpinner,PrefixTextGeneratorandSuffixTextGenerator;flagstaff/boxengainsOptions,CustomBorderStyleandBoxes;flagstaff/log-update,linegauge,linegauge/wrapandcloseout/exit-hookgainOptions;burgee/yargs/parsergainsArguments,OptionsandConfiguration. Types only — no runtime bytes.
0.5.1
Patch Changes
- #508
1aae1e2Thanks @ofri-peretz! -<package> --helpand--versionanswer instead of crashing. The bin took its first argument as the plugin file to import, soroundel --helpfailed withCannot find module '…/--help'and exit 1.-h/--helpnow print usage and exit 0,-V/--versionprint 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
b8e97dcThanks @ofri-peretz! - Runs on Node 20 and 22, not just 24+:engines.nodeis now^20.19.0 || >=22.13.0. Those are the first releases whererequire(esm)loads without a warning, so the CommonJSrequire()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 callPromise.withResolvers, which Node 20 doesn't have.
Patch Changes
- #505
9800b43Thanks @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
a4a5c43Thanks @ofri-peretz! - The README's exit-hook / restore-cursor override examples resolve to the current release (^0.4).
0.4.1
Patch Changes
- #494
f7f6d4bThanks @ofri-peretz! - Each package'shomepageand README docs link now point at its own documentation site,https://<package>.interlace.tools, instead of a page on burgee's site. The oldburgee.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
69563d1Thanks @ofri-peretz! -closeout/signal-exitandcloseout/signal-exit/signals— the drop-in path forsignal-exit4 (198.9 M/wk), graded 134 / 135 by signal-exit's own test suite, the same case its own package fails.onExit,load,unloadandsignals, at 4,797 B against the incumbent's 10,995 B. CommonJS on purpose — the suite re-evaluates the module under a changedprocess— and importable by name from ESM.
Patch Changes
- #480
2dc573fThanks @ofri-peretz! - README corrections: paratext shows terminal-link at its measured 8 / 10 (was the stale 0 / 10 floor); linegauge's and closeout'snpm:override examples resolve to the current release instead of 0.2 / 0.1.
0.3.2
Patch Changes
- #465
acf98f3Thanks @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
b4584e7Thanks @ofri-peretz! - Every package's npmhomepagenow 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:burgeegainscli-framework,argument-parser,subcommands,json-schema,mcp-server,model-context-protocol,ai-agent,llm,shell-completion,typescript,zero-dependency,commander-alternativeandyargs-alternative; the other eight gainagent,ai-agent,non-tty,jsonandzero-dependencywhere the package does that —zero-dependencyonly 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
bdaf364Thanks @ofri-peretz! - Every package now listsplugin,pluginsandextensiblein its npm keywords, because every package takes plugins through one shared contract.A plugin is a plain object, validated against the
schema.jsonthat ships in every package, and checked with the package's owncheckcommand. 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'scheckaccepts in CI. -
#435
7888524Thanks @ofri-peretz! -schema.jsonnow 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'sglyphs,spinners,bordersandcomponents, paratext'scapabilities, and linegauge'swidths. 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 onpaths - caique
widgets - closeout
handlers, including the phases a plugin may use - seniority
sources, including the rank bounds - burgee
commands,hooksandenforce
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.
- bellpull
-
#445
dac303eThanks @ofri-peretz! - Every package now declaressideEffectstruthfully, 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:
import before after import { explain } from 'seniority'2,939 B 1,067 B import { decide } from 'caique'1,235 B 734 B import { strip } from 'linegauge'1,102 B 940 B import { once } from 'closeout'353 B 235 B flagstaff and roundel used to declare
false, but each ships acheckcommand 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
db3c59eThanks @ofri-peretz! - Every plugin host has acheckcommand.npx linegauge check ./my-widths.mjs npx burgee check ./my-plugin.mjs --jsonPRINCIPLES 7 asks three things of an extension surface: the plugin is data validated against one published schema, there is a
checkcommand 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 inflagstaffalone. 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
encodeand itsfallback, 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, soburgee check --jsonis 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
okas the last line; - a refusal with a code from the family's vocabulary and a
fix, exit 1; E_NO_CONTRIBUTIONfor 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.
- a readable report, contribution by contribution, with
Patch Changes
-
#430
4d1b2b3Thanks @ofri-peretz! -checknow 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 insidecheck'simport(), before the onlytrythat turns aPluginErrorintoE_PLUGIN_SCHEMA: …plus afix:line. The author got the bare message on stderr, with no code and no fix. Now the whole ofcheckruns inside that one handler, so every refusal comes out the same way on every host.
0.2.1
Patch Changes
-
#373
a1f1d40Thanks @ofri-peretz! - Stage 2's artifact is nowspec.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 atspec.mdrather thandesign.md.No behaviour changes. The published tarballs do move, by two bytes per surviving reference —
design.mdis nine characters andspec.mdis 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
c8acb28Thanks @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 ownname, or(anonymous). A plugin's handlers are named"<plugin>:<handler>".run()resolves with that report ({ path, signal, code, error, timedOut, unfinished }), andinstall({ 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.Infinityand0are refused atinstall()/createRegistry()— not at the shutdown they would have ruined — with aUSAGE-class error carrying afix. One waits forever; the other gives no asynchronous handler a turn. Both reintroduce the failure the package exists to remove.Three more doors.
beforeExit,uncaughtExceptionandunhandledRejectionnow run the handlers, andSIGNALSgainsSIGQUITandSIGBREAK. 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 }, wherepathis'exit' | 'beforeExit' | 'signal' | 'uncaught' | 'rejection', andreportToJson()andreportToEvent()project that same value for a--jsonline and an agent event.once(fn)arrives ascloseout/once:onetime+mimic-fn— 262 M downloads a week between them — in 441 B with no dependency, preservingname,lengthandthis.closeout/cursoris 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 = 0on its way past cannot turn aprocess.exit(3)or a SIGTERM into a success.Existing callers are unaffected:
onExit(handler)andonExit(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 fromdist/, 46,066 B down to 20,417 B — and every entry point is on a byte ratchet. -
#303
fc640ddThanks @ofri-peretz! - Two graded drop-in paths:closeout/exit-hookandcloseout/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.021 / 21 against a 21 / 21 control, andrestore-cursor@5.1.06 / 6 against a 6 / 6 control.overrides: { "exit-hook": "npm:closeout@^0.1" }and the same forrestore-cursornow resolve.closeout/exit-hookkeeps 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 withwait: 2000, and a drop-in that silently tightens a caller's timeout is not a drop-in. closeout's bounded shutdown stays inonExit().Nothing a caller already imports changed. Internally the signal wiring moved from
index.tstoinstall.tsand the guardedglobalThis.processlookup toambient.ts, so the two façades can reach them without importing the package's own entry;index.tsre-exports every name it exported before. -
#294
3f92a60Thanks @ofri-peretz! - Shutdown order is now data rather than registration order, andcloseout/pluginhostshandlers.A handler declares a phase —
flush,release(the default) orrestore— andPHASESdeclares the sequence. Phases run in sequence, so an async handler influshsettles beforereleasebegins; handlers inside one phase run together, in registration order. Terminal restore moves intorestoreand 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/pluginis the new subpath (plugin-contractR5a):register()keeps a plugin'shandlersand ignores every other layer's keys,attach(registry)wires each into its phase, andcontributions()projects the whole shutdown sequence without running any of it. A plugin may useflushorreleaseand notrestore— 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.jsonis exported too, because it is the specifier this package's ownE_PLUGIN_SCHEMAfix names.Past the deadline, later phases are still run — they are only no longer waited for. A handler that hangs in
flushdoes not get to decide that the cursor stays hidden.Existing callers are unaffected:
onExit(handler)still works and lands inrelease, which is beforerestore. -
#332
3ea38c3Thanks @ofri-peretz! -closeoutre-raises a signal instead of exiting128 + n, so a process killed by Ctrl-C now really dies of SIGINT rather than exiting 130.WIFSIGNALEDis 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^Cand re-raise into its own job control,makestops 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
mainwants. The first half was already answered by the code around it: closeout removes its own listener before it counts, which is the sameunload()-then-process.kill(process.pid, sig)shapesignal-exituses, andsignal-exitis 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
SIGINThandler 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 isENOSYSon Windows — falls back to the POSIX code, because a shutdown that will not go is the one failure this package is named for.ProcessLikenow requireskill(pid, signal)andpid. This is a breaking change to that type for anyone callinginstall({ 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 survivedexit-hook's 21-case suite andrestore-cursor's 6-case suite intact. Both suites still pass 21 / 21 and 6 / 6.closeout/exit-hookis unchanged and stays faithful to its incumbent: it exits128 + nand listens on SIGINT and SIGTERM only — no SIGHUP — becauseexit-hook@5.1.0registers exactlybeforeExit,SIGINT,SIGTERM,exitandmessage, and its own suite grades the exit codes. Use closeout'sonExitrather 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
f295630Thanks @ofri-peretz! -schema.jsonconstrains token names, because it was promising something no host honours.tokenswas described as any name to a#rrggbbcolour.roundel'svalidate()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 ownE_PLUGIN_SCHEMAerror tells them, comparing their object againstroundel/schema.json, got a green from the schema and"accent" is not a tokenfromregister(). Measured 2026-09-16 with{ accent: '[#336699](https://github.com/ofri-peretz/burgee/issues/336699)' }.The schema now carries
propertyNames.enum, andscripts/plugin-contract-lock.test.tspins 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.tsasserts it), which is why nine packages are listed. Only the keyroundelowns is constrained: describingwidgets,handlers,sources,resolversorcommandsin 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 inplugin-schema-lock'sUNDESCRIBEDlist with that reason.linegaugeis 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 supersededget-east-asian-widthbar beside it with the count of entries that cleared it.
0.1.0
Minor Changes
-
#227
cf637a8Thanks @ofri-peretz! - closeout runs your exit handlers exactly once, on every pathThe 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.
createRegistryandinstall({ 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-hookandrestore-cursor.
Patch Changes
- #245
9818135Thanks @ofri-peretz! - A program that installed its own handler for a signal keeps it.closeoutran 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.