···212212 */
213213const initCommandRegistry = () => {
214214 // Handle batch command registrations (from preload batching)
215215+ //
216216+ // Merge-preserve pattern: a later publish with partial metadata (e.g. the
217217+ // minimal {name, source} publish from tile-ipc.ts's tile:command:register)
218218+ // must NOT erase rich fields that an earlier publish carried (accepts,
219219+ // produces, description, params). Previously we overwrote unconditionally,
220220+ // which caused metadata from the preload's cmd:register publish to be
221221+ // erased when a tile also sent the minimal tile:command:register path.
215222 api.pubsub.subscribe('cmd:register-batch', (msg) => {
216223 if (!msg.commands || !Array.isArray(msg.commands)) return;
217224218225 log('ext:cmd', 'cmd:register-batch received:', msg.commands.length, 'commands');
219226220227 for (const cmd of msg.commands) {
228228+ const existing = commandRegistry.get(cmd.name) || {};
221229 const entry = {
222230 name: cmd.name,
223223- description: cmd.description || '',
224224- source: cmd.source,
225225- // Scope: 'global' (app-wide), 'window' (target window), 'page' (page content)
226226- scope: cmd.scope || 'global',
227227- // Required major modes for command availability (empty = available in all modes)
228228- modes: cmd.modes || [],
229229- // Whether command has a canExecute guard
230230- hasCanExecute: cmd.hasCanExecute || false,
231231- // Connector metadata for chaining
232232- accepts: cmd.accepts || [],
233233- produces: cmd.produces || [],
234234- // Parameter definitions for completions
235235- params: cmd.params || []
231231+ description: cmd.description || existing.description || '',
232232+ source: cmd.source || existing.source,
233233+ scope: cmd.scope || existing.scope || 'global',
234234+ modes: cmd.modes || existing.modes || [],
235235+ hasCanExecute: cmd.hasCanExecute || existing.hasCanExecute || false,
236236+ // Connector metadata for chaining — preserve richer existing entry
237237+ // when the incoming publish lacks it (minimal publishes carry empty
238238+ // arrays; we want the previously-registered full metadata to win).
239239+ accepts: (cmd.accepts && cmd.accepts.length > 0) ? cmd.accepts : (existing.accepts || []),
240240+ produces: (cmd.produces && cmd.produces.length > 0) ? cmd.produces : (existing.produces || []),
241241+ params: (cmd.params && cmd.params.length > 0) ? cmd.params : (existing.params || []),
236242 };
237237- // Preserve noun routing metadata for proxy dispatch
238238- if (cmd._nounName) entry._nounName = cmd._nounName;
239239- if (cmd._nounCapability) entry._nounCapability = cmd._nounCapability;
243243+ if (cmd._nounName || existing._nounName) entry._nounName = cmd._nounName || existing._nounName;
244244+ if (cmd._nounCapability || existing._nounCapability) entry._nounCapability = cmd._nounCapability || existing._nounCapability;
240245 commandRegistry.set(cmd.name, entry);
241246 liveRegisteredCommands.add(cmd.name);
242247 }
243248 }, api.scopes.GLOBAL);
244249245250 // Handle individual command registrations from extensions
251251+ // Merge-preserve pattern: see cmd:register-batch above for rationale.
246252 api.pubsub.subscribe('cmd:register', (msg) => {
247253 log('ext:cmd', 'cmd:register received:', msg.name);
254254+ const existing = commandRegistry.get(msg.name) || {};
248255 const entry = {
249256 name: msg.name,
250250- description: msg.description || '',
251251- source: msg.source,
252252- // Scope: 'global' (app-wide), 'window' (target window), 'page' (page content)
253253- scope: msg.scope || 'global',
254254- // Required major modes for command availability
255255- modes: msg.modes || [],
256256- // Whether command has a canExecute guard
257257- hasCanExecute: msg.hasCanExecute || false,
258258- // Connector metadata for chaining
259259- accepts: msg.accepts || [], // MIME types this command accepts as input
260260- produces: msg.produces || [], // MIME types this command produces as output
261261- // Parameter definitions for completions
262262- params: msg.params || []
257257+ description: msg.description || existing.description || '',
258258+ source: msg.source || existing.source,
259259+ scope: msg.scope || existing.scope || 'global',
260260+ modes: msg.modes || existing.modes || [],
261261+ hasCanExecute: msg.hasCanExecute || existing.hasCanExecute || false,
262262+ accepts: (msg.accepts && msg.accepts.length > 0) ? msg.accepts : (existing.accepts || []),
263263+ produces: (msg.produces && msg.produces.length > 0) ? msg.produces : (existing.produces || []),
264264+ params: (msg.params && msg.params.length > 0) ? msg.params : (existing.params || []),
263265 };
264264- // Preserve noun routing metadata for proxy dispatch
265265- if (msg._nounName) entry._nounName = msg._nounName;
266266- if (msg._nounCapability) entry._nounCapability = msg._nounCapability;
266266+ if (msg._nounName || existing._nounName) entry._nounName = msg._nounName || existing._nounName;
267267+ if (msg._nounCapability || existing._nounCapability) entry._nounCapability = msg._nounCapability || existing._nounCapability;
267268 commandRegistry.set(msg.name, entry);
268269 liveRegisteredCommands.add(msg.name);
269270 }, api.scopes.GLOBAL);
+23-6
backend/electron/tile-ipc.ts
···546546 handleViolation(grant, 'commands', 'command:register', 'commands not granted', args.token);
547547 return;
548548 }
549549- // Capability gate + token-tile linkage only. Previously this also
550550- // published a minimal cmd:register-batch (name+source) — but that
551551- // overwrote the full-metadata entry from the preload's own cmd:register
552552- // publish, erasing accepts/produces/description. The preload is now the
553553- // single source of truth for cmd registry metadata (see tile-preload.cts
554554- // api.commands.register object-form publish).
549549+550550+ // Publish a minimal cmd:register-batch so downstream subscribers know
551551+ // the tile claims ownership of this command name (used by the cmd
552552+ // panel's query-backed registry for lazy/external tiles that don't
553553+ // go through registerLazyTile at startup — e.g. `example`).
554554+ //
555555+ // This publish does NOT carry metadata (accepts/produces/description)
556556+ // — those come from either (a) registerLazyTile at startup for built-in
557557+ // tiles, or (b) the preload's own full cmd:register publish that
558558+ // fires after this send. cmd:register-batch and cmd:register are
559559+ // separate topics in the resident, and for a given command name the
560560+ // full publish arrives second and overwrites the minimal.
561561+ publish(
562562+ `peek://${args.tileId}/background`,
563563+ scopes.GLOBAL,
564564+ 'cmd:register-batch',
565565+ {
566566+ commands: [{
567567+ name: args.name,
568568+ source: `peek://${args.tileId}/background`,
569569+ }],
570570+ }
571571+ );
555572 });
556573557574 ipcMain.on('tile:command:result', (_event, args: {
+20-20
backend/electron/tile-preload.cts
···308308 name,
309309 });
310310311311- // When called with the object form, also publish cmd:register so the
312312- // cmd panel picks up this command even though the manifest may only
313313- // have declared metadata (matches the v1 preload flow).
314314- if (typeof nameOrCommand === 'object') {
315315- ipcRenderer.send('tile:pubsub:publish', {
316316- token: tileToken,
311311+ // Publish cmd:register so the resident (and live panel) picks up this
312312+ // command. Emit for BOTH string-form (name+handler, no metadata) and
313313+ // object-form (name+metadata) registrations — string-form entries still
314314+ // need to appear in the registry so features-manager-style callers are
315315+ // visible to the cmd panel, even if they carry no accepts/produces.
316316+ ipcRenderer.send('tile:pubsub:publish', {
317317+ token: tileToken,
318318+ source: sourceAddress,
319319+ scope: 3, // GLOBAL
320320+ topic: 'cmd:register',
321321+ data: {
322322+ name,
323323+ description,
317324 source: sourceAddress,
318318- scope: 3, // GLOBAL
319319- topic: 'cmd:register',
320320- data: {
321321- name,
322322- description,
323323- source: sourceAddress,
324324- scope: scope || 'global',
325325- modes: modes || [],
326326- accepts: accepts || [],
327327- produces: produces || [],
328328- params: params || [],
329329- },
330330- });
331331- }
325325+ scope: scope || 'global',
326326+ modes: modes || [],
327327+ accepts: accepts || [],
328328+ produces: produces || [],
329329+ params: params || [],
330330+ },
331331+ });
332332333333 // Listen for command execution via pubsub relay (`pubsub:cmd:execute:{name}`).
334334 // Subscribe so main process forwards the topic to us.