Skip to content

CmdPal JS/TS Extensions - Phase 1: TypeScript SDK - #49321

Open
michaeljolley wants to merge 8 commits into
mainfrom
dev/mjolley/phase-1-cmdpal-ts-sdk
Open

CmdPal JS/TS Extensions - Phase 1: TypeScript SDK#49321
michaeljolley wants to merge 8 commits into
mainfrom
dev/mjolley/phase-1-cmdpal-ts-sdk

Conversation

@michaeljolley

@michaeljolley michaeljolley commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Note

This PR is the first of seven stacked PRs (#49321#49323#49324#49325#49326#49329#49364) that together add JavaScript/TypeScript extension support to Command Palette.

To test the complete implementation, run the branch from PR #49364.

Closes: #48707

What is this effort?

This chain of PRs enables developers to write Command Palette extensions in TypeScript or JavaScript instead of C# via WinRT COM. Extensions run as isolated Node.js processes and communicate with the C# host over JSON-RPC 2.0 using LSP-style Content-Length framing over stdio. From the host's perspective, a JS extension looks like any other ICommandProvider; the translation layers built across these phases handle the bridging.

The seven phases at a glance

PR Phase What it adds
#49321 1: TypeScript SDK The @microsoft/cmdpal-sdk npm package: types, base classes, helpers, and the JSON-RPC runtime that extension authors use to build and test extensions without any C# involvement.
#49323 2: JSON-RPC transport + manifest The C# side of the wire: JsonRpcConnection (framing, request correlation, timeout, dispatch) and JSExtensionManifest (parses package.json to find and validate CmdPal extensions).
#49324 3: Adapters + proxies The C# translation layer that turns JSON-RPC responses into native WinRT interfaces (ICommandProvider, IListPage, IContentPage, etc.), plus all the command/page/icon mapping logic.
#49325 4: Extension service + host wiring Spawns Node.js processes, sends the initialize handshake, owns process lifecycle, discovers extensions from %LOCALAPPDATA%\\Microsoft\\CmdPal\\JSExtensions, and registers everything into the host's DI and command-manager pipeline.
#49326 5: Gallery install/uninstall Adds type: jsonrpc + npm block support to gallery entries so users can install JS extensions via npm install <package>. Handles process teardown before delete and guards against npm not being present.
#49329 6: Docs + parity sample Design spec docs (json-rpc-spec/) and a TypeScript sample extension (SampleJSExtension) that mirrors the C# SamplePagesExtension view-for-view, exercising the full protocol surface. Also fixes the npm install layout so the discovery scanner finds the hoisted package.json.
#49364 7: Protocol additions + dogfooding fixes Adds Details size (small/medium/large) support end-to-end, fixes the image content sample, locks textToSuggest wire serialization, and cleans up the sample extension after dogfooding.

How a JS extension loads, step by step

  1. Startup scan. JsonRpcExtensionService walks JSExtensions/, calling JSExtensionManifest.TryParse on each package.json. A valid extension must have a cmdpal section, a non-empty name, and a resolvable entry point file.

  2. Process spawn. For each valid manifest, JSExtensionWrapper runs node <entrypoint> with stdio redirected. If cmdpal.debug is set in the manifest, --inspect is passed with an auto-assigned port starting at 9229.

  3. Handshake. The wrapper sends an initialize request over the JsonRpcConnection with Content-Length framing. The Node.js side, running the Phase 1 SDK runtime, responds and begins listening for provider requests.

  4. Provider surfacing. JSCommandProviderProxy wraps the connection and implements ICommandProvider. JSExtensionWrapper hands this to a CommandProviderWrapper, which plugs into the same IExtensionService pipeline that WinRT extensions already use. TopLevelCommandManager picks it up from there.

  5. Live requests. Every getTopLevelCommands, invoke, getItems, setSearchText, and similar call becomes a JSON-RPC request on the stdio channel. Responses flow back through the Phase 3 adapter/proxy layer and materialize as native CmdPal types.

  6. Host notifications. The extension can call ExtensionHost.log(), showStatus(), hideStatus(), and copyText() at any time. These arrive as one-way JSON-RPC notifications and are dispatched by JSCommandProviderProxy.

  7. Hot reload. A FileSystemWatcher on the JSExtensions tree fires on *.js changes, debounced by 500 ms and ignoring node_modules. The service tears down the old wrapper, kills the process, and constructs a fresh one.

  8. Shutdown. Each extension receives a dispose notification and has a 2-second grace period before the process tree is forcibly terminated.

What is not yet supported

These gaps are documented in the spec (json-rpc-spec/overview.md) and deferred to a future phase:

  • Per-page lifecycle (OnLoad/OnUnload): no WinRT ABI notification exists.
  • Drag and drop / DataPackage: no CmdPal ABI exists for this yet.
  • Dock bands and parameter pages: GetDockBands and IParameterRun are not exposed over the JSON-RPC protocol.

Introduce the extension-author-facing TypeScript SDK for building
PowerToys Command Palette extensions that run as isolated Node.js
processes and talk to the host over JSON-RPC 2.0 via stdio with
LSP-style Content-Length framing.

This is phase 1 of a stacked series (tracking issue #48707) and adds
source only. No C# host code is touched in this phase. The dist/ output
and node_modules/ are git-ignored.

Includes: the full type surface, base classes, icon helpers, a
JSON-RPC runtime (framing, dispatch, serialization, host bridge, stdio
server), package/tsconfig/eslint/prettier config, a README, and unit
tests (framing round-trip, dispatch, command-result mapping, icons).

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: 3786df5b-8423-47c5-96d2-3601e7120778
@github-actions github-actions Bot added Product-Command Palette Refers to the Command Palette utility Product-New+ Refers to the New+ PowerToys Utility labels Jul 15, 2026
@michaeljolley michaeljolley removed the Product-New+ Refers to the New+ PowerToys Utility label Jul 15, 2026
michaeljolley and others added 2 commits July 15, 2026 10:37
… execution

OpenUrlCommand now opens the URL as an in-process side effect through an injectable UrlOpener and returns a plain dismiss result. The wire CommandResult only serializes args for goToPage/showToast/confirm, and the Phase 1 protocol has no host/openUrl notification, so the URL could not be carried across the boundary and was being dropped.

Cache priming is now lazy: setProvider no longer runs the provider command factories, so a normal startup (provider/getTopLevelCommands and provider/getFallbackCommands) invokes each factory once instead of twice. A guarded primeCaches() still covers the rare invoke-by-id-before-listing path.

Co-authored-by: Copilot App <[email protected]>

Copilot-Session: 3786df5b-8423-47c5-96d2-3601e7120778
Context items (the moreCommands / overflow entries on list items and
command items) could not carry nested sub-commands. The ContextItem type
had no moreCommands field and serialize.ts contextItems() never emitted
sub-commands, so authored nested context menus were dropped on the wire.

Add an optional recursive moreCommands field to ContextItem and serialize
it recursively so the host receives the nested JSON. Pairs with a Phase 3
host-parse change.

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: 4c56d9b0-6728-4dad-b8c7-2d6e9c05f080
michaeljolley and others added 5 commits July 22, 2026 13:05
Document the exported developer surface of @microsoft/cmdpal-sdk so extension
authors get rich hover tooltips, parameter hints, and autocomplete docs. This
adds or expands TSDoc on interfaces, type members, base classes, options bags,
settings, built-in commands, and runtime entry points. Documentation only: no
code, signatures, exports, or runtime behavior changed.

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: 92cf3d8a-6ad5-4c12-b0fb-5db1cd2735ff
Harden the Command Palette TypeScript SDK per the locked wire contract,
keeping every change additive so the integrated host and sample keep working
until later phases upgrade them.

- stdout ownership: claim the protocol stdout before author code runs and
  redirect console.log/info/debug and direct stdout writes to stderr.
- init-failure propagation: track pending/ready/failed init state, answer a
  JSON-RPC error and set a nonzero exit code on failure, reject later requests.
- graceful async disposal: dispose is void or Promise, awaited with a bounded
  host-supplied timeout, preferring process.exitCode over process.exit.
- settings: add a SettingsChanged event and onSave hook fired after update,
  ship a JsonSettingsStore file helper, return a neutral goHome result.
- command-id collisions: hash the full command payload and honor explicit ids,
  warn on duplicate ids within one response.
- status identity: mint a client-side statusId, carry it plus the message text
  in host/showStatus and host/hideStatus, support update by id.
- form identity: add a required formId per serialized form, route submissions by
  (pageId, formId) to the handler captured at serialize time, traverse nested
  tree content, fall back to the first form when a formId is absent.
- bounded registry: scope command registrations per page generation and evict
  retired ids after a refresh.
- CommandResult discriminated union with runtime validation, keeping the wire
  bytes byte-identical (numeric Kind map unchanged).
- toast continuation retained and covered by a shared wire fixture.
- handshake: return protocolVersion and sdkVersion, read host versions, reject
  an incompatible major, treat a missing version as legacy/compatible.
- metadata transport: default provider.frozen to true, serialize frozen and
  accentColor faithfully.
- npm publication: add publishConfig access public; files allowlist ships only
  dist and README.md.
- tests and canonical wire fixtures consumed by later C# phases.

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: fc2b159c-0183-4db6-9323-160526791eef
The ts-sdk production build was green but `npm run typecheck` (and thus
`npm run check`) was red: tsconfig.json type-checks the test files while
vitest/esbuild strip types without checking. Three stale test/fixture
spots did not match the accepted SDK types.

- registry.test.ts: build the list item command as a typed
  IInvokableCommand variable instead of an inline object literal on a
  field typed as the base ICommand, so excess-property checking no longer
  rejects invoke(). The base ICommand and ICommandItem.command stay as is.
- status.test.ts: rewrite the three showStatus/updateStatus call sites and
  their wire assertions to the real ProgressState shape
  (isIndeterminate/progressPercent) that the host bridge forwards verbatim.
- wire-fixtures.build.ts: set the plainText fixture fontFamily to the
  closed FontFamily union value 'monospace' and regenerate the committed
  golden content-plainText.json via the UPDATE_FIXTURES path.

No production SDK types changed and no suppressions were added.

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: 311db8aa-f8db-47bb-943f-5447037946c7
Address the eight grounded findings in the Command Palette TypeScript SDK:

r2-p1-01: add a bootstrap loader that claims the protocol stdout writer and
redirects console/stdout to stderr before dynamically importing the extension
entry, so a stray top-level write cannot corrupt JSON-RPC framing.
r2-p1-02: scope command registrations to their owner (provider generation,
fallback handler, resolved results) and release them on refresh so the id
registry no longer grows without bound.
r2-p1-03: dispose immediately when the disposal timeout is zero or negative.
r2-p1-04: validate the goToPage navigation mode against the known enum set.
r2-p1-05: emit the Adaptive Card isRequired and errorMessage inputs for
required settings.
r2-p1-06: reject a present-but-malformed protocol version while still treating
an absent one as the legacy default.
r2-p1-07: serialize hasMoreItems and support the loadMore continuation so list
pagination works end to end.
r2-p1-08: return the full serialized settings page rather than only its id.

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: 311db8aa-f8db-47bb-943f-5447037946c7
…nt command scopes

Round 3 Phase 1 remediation for the Command Palette TypeScript SDK.

r3-p1-02: Ship an executable bootstrap bin. Add a checked-in bin wrapper
(bin/cmdpal-bootstrap.mjs) that carries the Node shebang and delegates to the
compiled bootstrap, point the package bin at it, include bin in the published
files, and add scripts/verify-pack.mjs (wired into prepublishOnly) to assert
the packed bin is listed and starts with the shebang. A ts-sdk .gitignore
negation keeps the bin tracked despite the repository root [Bb]in/ ignore.

r3-p1-01: Document the bootstrap as the mandatory launch path in the README and
prove the guarantee. The bootstrap claims stdout before dynamically importing
the user entry, so a log emitted at static-import time of the entry cannot reach
the framed protocol stream. Adds fixtures and a bootstrap test covering the
static-import case.

r3-p1-03: Recursively retire descendant command scopes. When an owner command or
page is retired, its nested result commands and child-page scopes are now walked
and unregistered together, so a stale nested or child command id can no longer be
invoked and does not accumulate until process shutdown. Adds registry tests for
both the child-page and nested-result cases.

Co-authored-by: Copilot App <[email protected]>
Copilot-Session: 311db8aa-f8db-47bb-943f-5447037946c7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Product-Command Palette Refers to the Command Palette utility

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: Command Palette JS/TS Extensions

1 participant