| status | in-force |
|---|---|
| summary | How FunctionStore tools ship quick-launch commands: the FNSCommand authoring module, the registry integration, and every trap paid for while building it. |
| since | e45e207 2026-08-21 |
| skill | fns-command-registration |
The definitive map of the FNS_CommandRegistry integration in this project: what the pieces are, how to author commands, why it is shaped this way, and every trap we paid for while building it (2026-08-21). Read this before touching anything command-related.
The wire contract itself lives in the TDXLPP repo:
docs/fns-command-registry.md. This document is the FunctionStore-side
implementation guide.
The authoring recipe, the hard rules (permanent ids, caps, main-thread
handlers), the ten traps and the health check live in
.claude/skills/fns-command-registration/SKILL.md — load /fns-command-registration
before writing or debugging a command. This document keeps the map and the
reasoning: what the pieces are, why the scheme is shaped this way, and what
the shape costs.
| Piece | Where | Responsibility |
|---|---|---|
| FNS_CommandRegistry | Master /FNSTools/FNS_CommandRegistry (a store package since v3.1.1), promoted to /sys, reached as op.FNS_COMMANDREGISTRY; the launcher companion carries the same released artifact (packaging/launcher_mirror.json) |
The registry itself. Row corrected 2026-09-08: it read "we NEVER ship it", which held until v3.1.1 |
| FNSCommand module | Master: FNSTools/CustomParTools/QuickExt/ExtUtils/FNSCommand.py |
fns_command decorator + announce(). THE single source — every copy in the project file-syncs this one file |
| FNSCommandAnnouncer | Child of master ExtUtils → rides all full-ExtUtils clones + the kit | The ONE lifecycle implementation: announces its grandparent tool, guarded |
| Full ExtUtils clones | QuickExt users' own components (and /project1 tests) |
Live-clone the QuickExt master; carry module + announcer + NoNode. Toolkit tools left this shape on 2026-09-14 (docs/ExtUtilsSlimMigration.md) |
| Slim ExtUtils clones | Toolkit tool roots, nested tools, their FNS_About boxes | Live-clone QuickExt/ExtUtilsMinimal: module, CustomParHelper, the par-exec DATs and extAutoInit; no announcer, so each command owner announces itself from onInitTD |
Slim ExtUtils copies (incl /sys) |
Registry hosts, FNS_ConfigHost | Mostly unlinked copies (deliberately trimmed); carry a file-synced FNSCommand DAT — no announcer |
| FNS_CommandKit | /FNSTools/FNS_CommandKit, released to modules/release/ |
Third-party drop-in: module copy + clone-linked announcer + thin Announce() delegate + README |
| extutils_distributor | FNSTools/CustomParTools/QuickExt/extutils_distributor.py |
Fleet police: survey() / rollout(apply=True) — clone links, dock repair, slim module presence, /sys scan |
| PI release scrub | CompReleaseManager.prepare() |
On release: clears file/syncfile on any syncfile-marked DAT, severs clones reaching outside the candidate |
- Tools: decorators plus a deferred
FNSCommand.announce(self.ownerComp)fromonInitTD(slim ExtUtils, no announcer). Components on the full ExtUtils still get the announcer's automatic lifecycle. - Registry-family exts (
ConfigRegistryExt,PaneTypeRegistryExt): the deliberate exception. Their hosts carry slim ExtUtils (no announcer), and only the/sysGLOBAL may register — they keep their own legs with an_isCommandOwner()guard comparingself.ownerComp is op.FNS_<NAME>REGISTRY(identity via shortcut, NOT path — this survived the registries being relocated to/sys/FNS_Registries/mid-session). - FNS_CommandKit: contains the SAME announcer (clone-linked to the
master; severed + baked on release).
FNSCommandKitExtis a one-line delegate soop('FNS_CommandKit').Announce()works.
- Announces
ownerComp.parent().parent()— announcer → container (ExtUtils or kit) → tool. Identical geometry in both homes. - Declares-commands check: only announces a parent that actually has
decorated promoted methods or a promoted
FnsCommands(). FNS_About boxes and passive hosts are silently skipped and never tagged (an emptyRegister()acts as an unregister; tagging everything is rescan noise). - No unregister on destroy — the COMP dies on every clone resync and project-save strip; registrations must survive both. Dead owners are pruned lazily by the registry.
- Everything guarded: no registry in the session = silent no-op.
Why clone-distributed instead of vendored? The contract blesses
vendoring ("the _fns_command attribute is the contract, any copy is
compatible forever"), and we started there. We migrated to distribution
for one source of truth: a contract addition (like builtin in 1.4.0)
lands in one file and reaches every copy by file-sync. The honest cost:
the distribution machinery has real failure modes (see Traps) that
vendored copies never had. The trade was accepted deliberately.
Why an auto-announcer instead of explicit lifecycle code? It removed
a ~15-line triplet from 39 exts and makes internal authoring one-touch.
The honest cost: registration is invisible in the tool's own code —
debugging requires knowing about the announcer. A middle option exists
and remains open: a FNSCommand.install(self) one-liner in each ext's
onInitTD (explicit, greppable, no announcer needed internally). We
chose the announcer; if its invisibility ever costs a real debugging
session, switch then — install() can be added, double-announce is
idempotent, and the announcer degrades to a no-op during migration. The
kit should KEEP the announcer regardless (zero-touch is its whole pitch).
Why doesn't the kit have its own lifecycle class? It briefly did — that was divergence, and it was unified onto the shared announcer the same day. One lifecycle implementation, everywhere.
-
Live in-session registry: 1.11.0, FNS_CommandRegistry 3.2.1 in v3.2.15 (multi-instance labels, below). 1.10.0 made the
toolfield the package's PUBLIC name, its COMP name with a leadingFNS_removed, so a consumer's sorted list readsAutosaverather than burying every FNS tool under "F"; see PublicToolNames.md. 1.9.0 added canonical-id arbitration; the decorator harvest carriescanonicalsince 2026-09-02. -
hidden=(1.3.0) andbuiltin=(1.4.0) are stamped in our metadata already but ride the wire only once the utility 0.16.0 companion is injected. Forward-compatible: nothing to change on our side. -
Ids are permanent: launcher curation, history, and user presets key on
tool#id. Renaming a shipped id (or the tool COMP) orphans all three. Treat every shipped id as public API. Thetoolhalf is the public name, which is as permanent as an id for the same reason: 1.10.0 moved every FNS key once, on purpose, while the installed base was a day old. There is no second free move. -
Multi-instance tools (registry 1.11.0, 2026-09-15): two optional promoted hooks on an owner COMP.
FnsInstance()labels one copy of a tool that lives as several (evaluated perCommands()build, sent asinstance, at most 32 chars);FnsToolName()pins the public tool name when copies have different COMP names, so curation keyed ontool#idreaches every copy. Canonical arbitration now keeps every copy of the winning tool. Preset targets accepttool#id@instanceto pin one copy (see CommandCuration.md); favourites and overrides deliberately stay per capability. FNS_CommandPalette titles such rowslabel · instance. The launcher's side is the same contract. -
Caps: 24 commands/tool, 6 params/command, menus ≤ 16 entries.
-
Live state chips (registry ≥ 1.6.0, adopted 2026-08-22): declare
state='Parname'(custom par on the owner) orstate={'method': 'GetX'}(promoted no-arg method — for inverse pars, child-widget values, computed state). Evaluated at QUERY time insideCommands()— always fresh, no re-announce needed. Values: bool → ON/OFF chip, number/str → value chip. Params may declarecurrentfor prompt prefill; a single-param command withstatereuses it implicitly (SetVolume, SetInterval work this way). ~24 of our commands carry state. Design rationale:docs/CommandStateProposal.md. -
Surfaces and capabilities (registry ≥ 1.7.0, master ported 2026-08-31) — two optional decorator fields, both pure metadata, both ignored by any older registry (the port is backward-safe in either direction, which is why it could land here before the launcher ships):
surface=names the consumer surfaces a command wants to appear on: a token or list of tokens (lowercase alnum/underscore/dash, ≤24 chars, max 8). Absent = exactly today's behaviour (quick-launch only). Known tokens:quick,session(the launcher's Current-view companion bar),context-menu(its session right-click menu). The registry validates SHAPE only, never the value — consumers ignore tokens they do not serve, so new surfaces are additive and cost nothing to declare early.capability=marks the command as part of a blessed capability: a namespaced id (fns.collect,fns.media-browser,fns.mobile-control; lowercase alnum/dot/underscore/dash, ≤64 chars). A consumer that recognises the id may render rich native UI for the capability's command group; one that does not falls back to generic rendering. Progressive enhancement — never a gate, never a secret.
Adoption is free for any tool: declaring
surface=['session']today costs nothing and surfaces the command in the launcher's Current bar the moment its companion ships. Candidates: CommandRegistryCandidates.md. -
Handlers run synchronously on the main thread — return fast, kick long work off with
run(..., delayFrames=1). A dict withok: Falsemarks the run failed in the palette footer.
-
Built-in commands ship inside the registry master (2026-09-02):
FNS_CommandRegistry/FNS_BuiltinCommandsholds two ordinary command owners,TD_Dialogs(22) andTD_Session(16), brought over from TDXLPP'sTDX_BuiltinCommands. Each carries a dockedExtUtilsMinimal(the slim shape: FNSCommand and CustomParHelper file-synced to the masters, no NoNode, no announcer -- the owners announce from their own deferred init), importsFNSCommandthe FNSTools way, and wraps the decorator locally to brandbuiltin=Trueand derive a canonical idtd.<group>.<kebab-method>(td.session.save-project). Three things make the version gate real for them: (1) the wrapper declares the canonical id; (2)_packageVersionreads the OWNER'sPkgversion, so the container and both owners carry an expression-boundPkgversion(parent().par.Pkgversion) that resolves to the registry package's version; (3) the harvest path now copiescanonicalfrom the decorator metadata -- it never had since 1.9.0, so no decorated command could be arbitrated (Commands()inserts the field only when the spec carries it). The runtime/syscopy is a full copy of the master, so_sanitizeSysCopystripsFNS_BuiltinCommandsfrom it: a second owner set under/syswould announce the same commands from a pathRescanToolscan never rediscover and, with canonical ids, shadow the master's set on a boot-order coin toss. Consumer side (FNS_CommandPalette):builtinrows carry aTDbadge in their own hue, rank after tool commands in>and the untyped list, and are excluded from?-- the wire contract's consumer rules. TDXLPP must mirror two of these for cross-project arbitration to work: the canonical derivation rule in itsTDX_BuiltinCommandswrapper and the harvest fix in its registry copy; until then its built-ins carry no canonical id and simply coexist with ours (path#idkeeps them apart). Launcher state verified 2026-09-02 (TDXLPP checkout, read-only): its registry copy is 1.7.0 with nocanonical,_arbitrateorShadowed; itsFNSCommand.fns_commandhas nocanonical=; its built-ins are stillpartial(fns_command, builtin=True). So with both companions in one project our 1.9.0 wins promotion and their built-ins register unarbitrated: 38 duplicate rows in both palettes until they mirror (a) registry 1.9.0 + thecanonicalkw, (b) the wrapper, (c) aPkgversionon their owners. Briefed to the TDXLPP agent session the same day. Curation is two stores by design: the launcher keeps favourites, hidden/shown overrides and presets in itsconfig.jsonkeyedtool#id(shared with its own TDXLUPalette tab in TD's Palette Browser,palette_tabs.rs set_favorite), our palette kept its own on pars (keyedtool#idsince the identity switch that day). Their contract permits it ("consumers can curate differently"). SUPERSEDED 2026-09-02: the owner directed one shared file; the contract isCommandCuration.md. Their agent's reply the same day: registry 1.9.0 taken verbatim, the 38 canonical ids verified equal as sets, and later the same day the owners'Pkgversionlanded and was verified in their live session (40 commands, 38 builtin, 38 canonical, Shadowed() empty there). FNS_About caveat:_packageVersionfalls back to an FNS_About child'sPkgversion; their utility's FNS_About carries an emptyVersionpar and noPkgversion, so that path would have yielded ''. They bound the owners to the utility's extension constant instead, guarded byextensionsReadywith a'0.0.0'fallback so a half-initialised project loses arbitration rather than winning on a garbage value. Consequence: where both companions load, the launcher's built-in set (0.23.0) wins and ours (0.1.0) is shadowed -- deterministic, and by version arithmetic, not by intent. Mirror rule, revised the same day on the owner's direction: the launcher carries our RELEASED FNS_CommandRegistry package verbatim -- registry,FNS_BuiltinCommandsandFNS_Aboutin one artifact -- and drops its ownTDX_BuiltinCommandsfork, so there is one registry and one built-in set to keep identical rather than two files and a fork. The package was brought to the preferred shape first (anFNS_Aboutchild,Pkgversion0.1.0 authoritative, the owner mirroring by expression; the palette got the same), then released through PI tomodules/release/, read back into a cooking-disabled container and walked: no PI tags, no file bindings into this repo, the nested external-tox reference severed, every source compiling.modules/release/is ignored by git, sopackaging/launcher_mirror.jsonrecords the artifact's version, size and sha256 at release, andscripts/check_launcher_mirror.pycompares the launcher's copy (utility/TDXLauncherUtility/FNS_CommandRegistry.tox) to that record, keepsFNSCommand.pybyte-identical for the launcher's own tools, and flags the old two-file shape while it still exists; exit 1 on any of it, to be run from either repo before a release. In a project where both companions load, the two registries are the same version and the two built-in sets the same package version, so promotion and arbitration both fall to the incumbent rule: identical content, no race. The FNS_About caveat is fixed on their side (same day): their utility's FNS_About is at the master shape withPkgversion0.23.0authoritative and the legacy emptyVersionpar destroyed; the chain is parameter-to-parameter (utility par bound to the child, read-only -- the registry-family shape -- thenparent().par. Pkgversiondown to both owners), the extension-constant guard is gone, and their release script now asserts the code constant equals the authority (fatal on a parse miss, because their prior check skipped silently when its regex stopped matching) and runs our mirror checker whenever this repo sits beside theirs, failing the release on drift. Left open by them as a decision, not a drive-by: giving their built-in owners a docked ExtUtilsMinimal so the owner sets are the same shape; arbitration does not depend on it. And a fair objection that comparing a companion version (0.23.0) with our package version (0.1.0) is a category error -- arbitration by version is a deterministic tie-break, not a quality signal; an explicit precedence field would make the outcome intentional (a 1.10.0 proposal, owner's call). On curation: standardise identity ontool#id(done on our side the same day), then optionally a shared no-owner file under the user palette folder with per-entry merge, tombstones for deletes, mtime reload and a schema version -- pending the owner. Decided the same day: built; the contract isCommandCuration.md. -
Re-release 0.1.1 (2026-09-02). The 0.1.0 artifact carried five documentation annotations; on the launcher's import their TDAnnotate internals raised
'td.annotateCOMP' object has no attribute 'Editing'and cascaded past 5,800 errors. The cooking-disabled read-back above could not see that: extensions never initialise there, so it proves the tox parses, not that it works. The annotations are destroyed on the master, apre_releasehook strips any future ones from the staged copy, and the artifact is now verified by loading it ONCE into a cooking-enabled container in the project, waiting a few frames, and requiringerrors(recurse=True) == [], no script errors, andop.FNS_COMMANDREGISTRYstill resolving to the incumbent (sameREGISTRY_VERSION, so no promotion; measured: 0 errors after 1,271 frames, 141 commands before, during and after).packaging/launcher_mirror.jsonrecords 0.1.1 (88,022 bytes). The launcher's re-import recipe, as it is actually done there (their agent, 2026-09-02, one step paid for with a TD crash): (1) destroy the old master, (2) let frames pass, (3)loadToxthe new artifact -- destroying the promoted registry's master and loading its replacement in the SAME call crashed TD mid-teardown while the/sysincumbent still pointed at it; the same two operations a few frames apart work every time. Then keep it opaque: their Embody honourstdn_excludeonly on a DIRECT child of the TDN boundary (the registry is one), strategy tags such aspythat ride in on the owner DATs must be stripped on arrival or their Embody externalises them (that is how 0.1.0 forked), andexternaltoxpoints at the byte-identical repo copy with the toggle OFF so nothing ever reloads or re-saves the file the checker hashes.
Moved to /fns-command-registration (the skill) so they load exactly when
someone is about to trip over them. Ten traps, the extutils_distributor
health check, the saving regime, and the "my command doesn't show" checklist.