@openvcs/sdk 0.3.0-nightly.20260423.29 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ export { ModalBuilder } from './modal';
9
9
  export { bootstrapPluginModule, createRegisteredPluginRuntime, } from './registration';
10
10
  export { VcsDelegateBase } from './vcs-delegate-base';
11
11
  export type { VcsDelegateAssignments } from './vcs-delegate-metadata';
12
- export { getMenu, getOrCreateMenu, createMenu, addMenuItem, addMenuSeparator, removeMenu, hideMenu, showMenu, registerAction, invoke, notify, } from './menu';
12
+ export { getMenu, getOrCreateMenu, createMenu, addMenuItem, addMenuSeparator, removeMenu, hideMenu, showMenu, registerAction, resetMenuRegistry, invoke, notify, } from './menu';
13
13
  export type { MenuHandle } from './menu';
14
14
  /** Starts a previously created plugin runtime on process stdio. */
15
15
  export declare function startPluginRuntime(runtime: PluginRuntime, transport?: PluginRuntimeTransport): void;
@@ -2,7 +2,7 @@
2
2
  // Copyright © 2025-2026 OpenVCS Contributors
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
- exports.notify = exports.invoke = exports.registerAction = exports.showMenu = exports.hideMenu = exports.removeMenu = exports.addMenuSeparator = exports.addMenuItem = exports.createMenu = exports.getOrCreateMenu = exports.getMenu = exports.VcsDelegateBase = exports.createRegisteredPluginRuntime = exports.bootstrapPluginModule = exports.ModalBuilder = exports.createHost = exports.createPluginRuntime = exports.pluginError = exports.isPluginFailure = exports.createRuntimeDispatcher = exports.createDefaultPluginDelegates = void 0;
5
+ exports.notify = exports.invoke = exports.resetMenuRegistry = exports.registerAction = exports.showMenu = exports.hideMenu = exports.removeMenu = exports.addMenuSeparator = exports.addMenuItem = exports.createMenu = exports.getOrCreateMenu = exports.getMenu = exports.VcsDelegateBase = exports.createRegisteredPluginRuntime = exports.bootstrapPluginModule = exports.ModalBuilder = exports.createHost = exports.createPluginRuntime = exports.pluginError = exports.isPluginFailure = exports.createRuntimeDispatcher = exports.createDefaultPluginDelegates = void 0;
6
6
  exports.startPluginRuntime = startPluginRuntime;
7
7
  var dispatcher_1 = require("./dispatcher");
8
8
  Object.defineProperty(exports, "createDefaultPluginDelegates", { enumerable: true, get: function () { return dispatcher_1.createDefaultPluginDelegates; } });
@@ -31,6 +31,7 @@ Object.defineProperty(exports, "removeMenu", { enumerable: true, get: function (
31
31
  Object.defineProperty(exports, "hideMenu", { enumerable: true, get: function () { return menu_1.hideMenu; } });
32
32
  Object.defineProperty(exports, "showMenu", { enumerable: true, get: function () { return menu_1.showMenu; } });
33
33
  Object.defineProperty(exports, "registerAction", { enumerable: true, get: function () { return menu_1.registerAction; } });
34
+ Object.defineProperty(exports, "resetMenuRegistry", { enumerable: true, get: function () { return menu_1.resetMenuRegistry; } });
34
35
  Object.defineProperty(exports, "invoke", { enumerable: true, get: function () { return menu_1.invoke; } });
35
36
  Object.defineProperty(exports, "notify", { enumerable: true, get: function () { return menu_1.notify; } });
36
37
  /** Starts a previously created plugin runtime on process stdio. */
@@ -1,17 +1,27 @@
1
1
  import type { MenubarItem } from '../types/menubar.js';
2
- import type { PluginDelegates } from '../types/plugin.js';
2
+ import type { PluginDelegates, PluginHandleActionParams } from '../types/plugin.js';
3
3
  import type { PluginRuntimeContext } from './contracts.js';
4
4
  type MenubarMenuOptions = {
5
5
  before?: string;
6
6
  after?: string;
7
7
  };
8
+ /** UI surface targeted by a menu: 'menubar' (top-level bar) or 'settings' (preferences panel). */
8
9
  type MenuSurface = 'menubar' | 'settings';
10
+ /** Validates and returns the incoming plugin action id. */
11
+ export declare function requireActionId(params: PluginHandleActionParams): string;
12
+ /** Clears all registered menus and action handlers.
13
+ * @internal
14
+ * Called by bootstrap to prevent state leaking between in-process plugin setup runs.
15
+ */
16
+ export declare function resetMenuRegistry(): void;
17
+ /** Returns whether one registered action handler exists. */
18
+ export declare function hasRegisteredAction(actionId: string): boolean;
9
19
  /** Runs a registered action handler by id. */
10
20
  export declare function runRegisteredAction(actionId: string, ...args: unknown[]): Promise<unknown>;
11
21
  export interface MenuHandle {
12
22
  id: string;
13
23
  addItem(item: MenubarItem): void;
14
- addSeparator(beforeAction?: string): void;
24
+ addSeparator(beforeAction?: string, afterAction?: string): void;
15
25
  removeItem(actionId: string): void;
16
26
  hideItem(actionId: string): void;
17
27
  showItem(actionId: string): void;
@@ -19,19 +29,19 @@ export interface MenuHandle {
19
29
  /** Returns a menu by id, or null when it does not exist. */
20
30
  export declare function getMenu(menuId: string): MenuHandle | null;
21
31
  /** Returns a menu by id, creating it if needed.
22
- * @param menuId - Menu identifier
23
- * @param label - User-visible label
24
- * @param options - Surface target ('menubar' or 'settings'), MUST be explicitly provided
32
+ * @param menuId - Menu identifier.
33
+ * @param label - User-visible label.
34
+ * @param options - Placement options, including the required `surface` so the runtime can serialize the menu for the correct host UI surface.
25
35
  */
26
36
  export declare function getOrCreateMenu(menuId: string, label: string, options: MenubarMenuOptions & {
27
37
  surface: MenuSurface;
28
38
  }): MenuHandle | null;
29
39
  /** Creates a menu at a specific position (alias for getOrCreateMenu). */
30
40
  export declare const createMenu: typeof getOrCreateMenu;
31
- /** Adds one item to a menu. */
41
+ /** Adds one item to a menu, silently no-op if the menu does not exist. */
32
42
  export declare function addMenuItem(menuId: string, item: MenubarItem): void;
33
- /** Adds one separator to a menu. */
34
- export declare function addMenuSeparator(menuId: string, beforeAction?: string): void;
43
+ /** Adds one separator to a menu, silently no-op if the menu does not exist. */
44
+ export declare function addMenuSeparator(menuId: string, beforeAction?: string, afterAction?: string): void;
35
45
  /** Removes one menu from the registry. */
36
46
  export declare function removeMenu(menuId: string): void;
37
47
  /** Hides one menu from the registry. */
@@ -3,6 +3,9 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
5
  exports.createMenu = void 0;
6
+ exports.requireActionId = requireActionId;
7
+ exports.resetMenuRegistry = resetMenuRegistry;
8
+ exports.hasRegisteredAction = hasRegisteredAction;
6
9
  exports.runRegisteredAction = runRegisteredAction;
7
10
  exports.getMenu = getMenu;
8
11
  exports.getOrCreateMenu = getOrCreateMenu;
@@ -15,10 +18,29 @@ exports.registerAction = registerAction;
15
18
  exports.invoke = invoke;
16
19
  exports.notify = notify;
17
20
  exports.createMenuPluginDelegates = createMenuPluginDelegates;
21
+ const errors_js_1 = require("./errors.js");
18
22
  const menus = new Map();
19
23
  const menuOrder = [];
20
24
  const actionHandlers = new Map();
21
25
  let syntheticId = 0;
26
+ /** Validates and returns the incoming plugin action id. */
27
+ function requireActionId(params) {
28
+ const actionId = typeof params?.action_id === 'string' ? params.action_id.trim() : '';
29
+ if (!actionId) {
30
+ throw (0, errors_js_1.pluginError)('plugin-invalid-action-id', 'plugin.handle_action requires params.action_id to be a non-empty string');
31
+ }
32
+ return actionId;
33
+ }
34
+ /** Clears all registered menus and action handlers.
35
+ * @internal
36
+ * Called by bootstrap to prevent state leaking between in-process plugin setup runs.
37
+ */
38
+ function resetMenuRegistry() {
39
+ menus.clear();
40
+ menuOrder.splice(0, menuOrder.length);
41
+ actionHandlers.clear();
42
+ syntheticId = 0;
43
+ }
22
44
  /** Returns the host-side OpenVCS helper, when the environment provides one. */
23
45
  function getOpenVCS() {
24
46
  return globalThis.OpenVCS;
@@ -157,6 +179,13 @@ function serializeMenus() {
157
179
  })
158
180
  .filter((menu) => Boolean(menu));
159
181
  }
182
+ /** Returns whether one registered action handler exists. */
183
+ function hasRegisteredAction(actionId) {
184
+ const id = String(actionId || '').trim();
185
+ if (!id)
186
+ return false;
187
+ return actionHandlers.has(id);
188
+ }
160
189
  /** Runs a registered action handler by id. */
161
190
  async function runRegisteredAction(actionId, ...args) {
162
191
  const id = String(actionId || '').trim();
@@ -190,7 +219,7 @@ function createMenuHandle(menuId) {
190
219
  action,
191
220
  }, item.before, item.after);
192
221
  },
193
- addSeparator(beforeAction) {
222
+ addSeparator(beforeAction, afterAction) {
194
223
  let menu = getStoredMenu(this.id);
195
224
  if (!menu) {
196
225
  // Default to menubar for internal menu handle operations.
@@ -201,7 +230,7 @@ function createMenuHandle(menuId) {
201
230
  id: allocateSyntheticId(`${menu.id}-separator`),
202
231
  label: 'Separator',
203
232
  content: '—',
204
- }, beforeAction);
233
+ }, beforeAction, afterAction);
205
234
  },
206
235
  removeItem(actionId) {
207
236
  const menu = getStoredMenu(this.id);
@@ -236,25 +265,29 @@ function getMenu(menuId) {
236
265
  return createMenuHandle(stored.id);
237
266
  }
238
267
  /** Returns a menu by id, creating it if needed.
239
- * @param menuId - Menu identifier
240
- * @param label - User-visible label
241
- * @param options - Surface target ('menubar' or 'settings'), MUST be explicitly provided
268
+ * @param menuId - Menu identifier.
269
+ * @param label - User-visible label.
270
+ * @param options - Placement options, including the required `surface` so the runtime can serialize the menu for the correct host UI surface.
242
271
  */
243
272
  function getOrCreateMenu(menuId, label, options) {
244
- const surface = options.surface;
245
- const { surface: _, ...restOptions } = options;
273
+ const { surface, ...restOptions } = options;
246
274
  const stored = ensureStoredMenu(menuId, label, restOptions, surface);
247
275
  return createMenuHandle(stored.id);
248
276
  }
249
277
  /** Creates a menu at a specific position (alias for getOrCreateMenu). */
250
278
  exports.createMenu = getOrCreateMenu;
251
- /** Adds one item to a menu. */
279
+ /** Adds one item to a menu, silently no-op if the menu does not exist. */
252
280
  function addMenuItem(menuId, item) {
253
- createMenuHandle(menuId).addItem(item);
281
+ const handle = createMenuHandle(menuId);
282
+ if (!getStoredMenu(menuId))
283
+ return;
284
+ handle.addItem(item);
254
285
  }
255
- /** Adds one separator to a menu. */
256
- function addMenuSeparator(menuId, beforeAction) {
257
- createMenuHandle(menuId).addSeparator(beforeAction);
286
+ /** Adds one separator to a menu, silently no-op if the menu does not exist. */
287
+ function addMenuSeparator(menuId, beforeAction, afterAction) {
288
+ if (!getStoredMenu(menuId))
289
+ return;
290
+ createMenuHandle(menuId).addSeparator(beforeAction, afterAction);
258
291
  }
259
292
  /** Removes one menu from the registry. */
260
293
  function removeMenu(menuId) {
@@ -292,7 +325,10 @@ function invoke(cmd, args) {
292
325
  /** Emits a notification when the host helper is available. */
293
326
  function notify(msg) {
294
327
  const openvcs = getOpenVCS();
295
- openvcs?.notify(msg);
328
+ if (!openvcs) {
329
+ throw new Error('OpenVCS host is not available in this runtime');
330
+ }
331
+ openvcs.notify(msg);
296
332
  }
297
333
  /** Builds SDK delegates from the local menu/action registries. */
298
334
  function createMenuPluginDelegates() {
@@ -301,11 +337,7 @@ function createMenuPluginDelegates() {
301
337
  return serializeMenus();
302
338
  },
303
339
  async 'plugin.handle_action'(params) {
304
- const actionId = String(params?.action_id || params?.id || '').trim();
305
- if (actionId) {
306
- return await runRegisteredAction(actionId, params?.payload);
307
- }
308
- return null;
340
+ return await runRegisteredAction(requireActionId(params), params?.payload);
309
341
  },
310
342
  };
311
343
  }
@@ -26,8 +26,8 @@ function createRegisteredPluginRuntime(definition = {}) {
26
26
  ];
27
27
  },
28
28
  'plugin.handle_action': async (params, ctx) => {
29
- const actionId = String(params?.action_id || '').trim();
30
- if (actionId) {
29
+ const actionId = (0, menu_1.requireActionId)(params);
30
+ if ((0, menu_1.hasRegisteredAction)(actionId)) {
31
31
  const result = await (0, menu_1.runRegisteredAction)(actionId, params?.payload);
32
32
  if (result !== null && result !== undefined) {
33
33
  return result;
@@ -36,6 +36,7 @@ function createRegisteredPluginRuntime(definition = {}) {
36
36
  if (explicitHandleAction) {
37
37
  return explicitHandleAction(params, ctx);
38
38
  }
39
+ ctx.host.info(`plugin.handle_action ignored unhandled action_id '${actionId}'`);
39
40
  return null;
40
41
  },
41
42
  },
@@ -54,6 +55,8 @@ async function bootstrapPluginModule(options) {
54
55
  if (typeof onPluginStart !== 'function') {
55
56
  throw new Error(`plugin module '${options.modulePath}' must export OnPluginStart()`);
56
57
  }
58
+ // Reset internal state so repeated in-process setups do not leak menu or action state.
59
+ (0, menu_1.resetMenuRegistry)();
57
60
  try {
58
61
  await onPluginStart();
59
62
  }
@@ -22,8 +22,8 @@ export interface MenubarMenu {
22
22
  id: string;
23
23
  /** Adds an item to this menu. */
24
24
  addItem(item: MenubarItem): void;
25
- /** Adds a separator to this menu. */
26
- addSeparator(beforeAction?: string): void;
25
+ /** Adds a separator to this menu before or after an existing action. */
26
+ addSeparator(beforeAction?: string, afterAction?: string): void;
27
27
  /** Removes an item by action ID. */
28
28
  removeItem(actionId: string): void;
29
29
  /** Hides an item by action ID. */
@@ -54,9 +54,7 @@ export interface PluginMenuDefinition {
54
54
  export type PluginSettingsValue = Record<string, unknown>;
55
55
  /** Describes the params shape for plugin action handling. */
56
56
  export interface PluginHandleActionParams extends RequestParams {
57
- /** Stores the action id selected by the user when provided by the transport. */
58
- id?: string;
59
- /** Stores the action id selected by the user. */
57
+ /** Stores the action id selected by the user. Runtime handlers require a non-empty string. */
60
58
  action_id?: string;
61
59
  /** Stores an optional payload supplied by the triggering UI. */
62
60
  payload?: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/sdk",
3
- "version": "0.3.0-nightly.20260423.29",
3
+ "version": "0.3.0",
4
4
  "description": "OpenVCS SDK CLI for plugin scaffolding and runtime asset builds",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "homepage": "https://openvcs.app/",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^25.3.3",
43
- "typescript": "^6.0.2"
43
+ "typescript": "^6.0.3"
44
44
  },
45
45
  "files": [
46
46
  "src/",
@@ -39,6 +39,7 @@ export {
39
39
  hideMenu,
40
40
  showMenu,
41
41
  registerAction,
42
+ resetMenuRegistry,
42
43
  invoke,
43
44
  notify,
44
45
  } from './menu';
@@ -5,13 +5,16 @@ import type { MenubarItem } from '../types/menubar.js';
5
5
  import type {
6
6
  PluginDelegates,
7
7
  PluginHandleActionParams,
8
+ PluginMenuElement,
8
9
  PluginMenuDefinition,
9
10
  } from '../types/plugin.js';
10
11
 
11
12
  import type { PluginRuntimeContext } from './contracts.js';
13
+ import { pluginError } from './errors.js';
12
14
 
13
15
  type MenubarMenuOptions = { before?: string; after?: string };
14
16
  type MenuEntryKind = 'button' | 'text' | 'separator';
17
+ /** UI surface targeted by a menu: 'menubar' (top-level bar) or 'settings' (preferences panel). */
15
18
  type MenuSurface = 'menubar' | 'settings';
16
19
 
17
20
  type OpenVCSGlobal = typeof globalThis & {
@@ -27,6 +30,7 @@ interface StoredMenuItem {
27
30
  label: string;
28
31
  title?: string;
29
32
  content?: string;
33
+ /** Action id dispatched back to the plugin when the user activates this item. */
30
34
  action?: string;
31
35
  hidden?: boolean;
32
36
  }
@@ -40,26 +44,34 @@ interface StoredMenuState {
40
44
  items: StoredMenuItem[];
41
45
  }
42
46
 
43
- interface SerializedMenuItem {
44
- type: 'button' | 'text';
45
- id: string;
46
- label?: string;
47
- content?: string;
48
- }
49
-
50
- interface SerializedMenuDefinition {
51
- id: string;
52
- label: string;
53
- order: number;
54
- surface: 'menubar' | 'settings';
55
- elements: SerializedMenuItem[];
56
- }
57
-
58
47
  const menus = new Map<string, StoredMenuState>();
59
48
  const menuOrder: string[] = [];
60
49
  const actionHandlers = new Map<string, (...args: unknown[]) => unknown>();
61
50
  let syntheticId = 0;
62
51
 
52
+ /** Validates and returns the incoming plugin action id. */
53
+ export function requireActionId(params: PluginHandleActionParams): string {
54
+ const actionId = typeof params?.action_id === 'string' ? params.action_id.trim() : '';
55
+ if (!actionId) {
56
+ throw pluginError(
57
+ 'plugin-invalid-action-id',
58
+ 'plugin.handle_action requires params.action_id to be a non-empty string',
59
+ );
60
+ }
61
+ return actionId;
62
+ }
63
+
64
+ /** Clears all registered menus and action handlers.
65
+ * @internal
66
+ * Called by bootstrap to prevent state leaking between in-process plugin setup runs.
67
+ */
68
+ export function resetMenuRegistry(): void {
69
+ menus.clear();
70
+ menuOrder.splice(0, menuOrder.length);
71
+ actionHandlers.clear();
72
+ syntheticId = 0;
73
+ }
74
+
63
75
  /** Returns the host-side OpenVCS helper, when the environment provides one. */
64
76
  function getOpenVCS() {
65
77
  return (globalThis as OpenVCSGlobal).OpenVCS;
@@ -177,7 +189,7 @@ function insertMenuItem(menu: StoredMenuState, item: StoredMenuItem, before?: st
177
189
  }
178
190
 
179
191
  /** Converts one stored item into a serializable menu payload element. */
180
- function serializeMenuItem(item: StoredMenuItem): SerializedMenuItem | null {
192
+ function serializeMenuItem(item: StoredMenuItem): PluginMenuElement | null {
181
193
  if (item.hidden) return null;
182
194
 
183
195
  if (item.kind === 'separator') {
@@ -204,7 +216,7 @@ function serializeMenuItem(item: StoredMenuItem): SerializedMenuItem | null {
204
216
  }
205
217
 
206
218
  /** Serializes the local registry into plugin menu payloads. */
207
- function serializeMenus(): SerializedMenuDefinition[] {
219
+ function serializeMenus(): PluginMenuDefinition[] {
208
220
  return menuOrder
209
221
  .map((menuId, index) => {
210
222
  const menu = menus.get(menuId);
@@ -216,10 +228,17 @@ function serializeMenus(): SerializedMenuDefinition[] {
216
228
  surface: menu.surface,
217
229
  elements: menu.items
218
230
  .map((item) => serializeMenuItem(item))
219
- .filter((item): item is SerializedMenuItem => Boolean(item)),
231
+ .filter((item): item is PluginMenuElement => Boolean(item)),
220
232
  };
221
233
  })
222
- .filter((menu): menu is SerializedMenuDefinition => Boolean(menu));
234
+ .filter((menu): menu is PluginMenuDefinition => Boolean(menu));
235
+ }
236
+
237
+ /** Returns whether one registered action handler exists. */
238
+ export function hasRegisteredAction(actionId: string): boolean {
239
+ const id = String(actionId || '').trim();
240
+ if (!id) return false;
241
+ return actionHandlers.has(id);
223
242
  }
224
243
 
225
244
  /** Runs a registered action handler by id. */
@@ -236,7 +255,7 @@ export async function runRegisteredAction(actionId: string, ...args: unknown[]):
236
255
  export interface MenuHandle {
237
256
  id: string;
238
257
  addItem(item: MenubarItem): void;
239
- addSeparator(beforeAction?: string): void;
258
+ addSeparator(beforeAction?: string, afterAction?: string): void;
240
259
  removeItem(actionId: string): void;
241
260
  hideItem(actionId: string): void;
242
261
  showItem(actionId: string): void;
@@ -266,7 +285,7 @@ function createMenuHandle(menuId: string): MenuHandle {
266
285
  action,
267
286
  }, item.before, item.after);
268
287
  },
269
- addSeparator(beforeAction?: string) {
288
+ addSeparator(beforeAction?: string, afterAction?: string) {
270
289
  let menu = getStoredMenu(this.id);
271
290
  if (!menu) {
272
291
  // Default to menubar for internal menu handle operations.
@@ -277,7 +296,7 @@ function createMenuHandle(menuId: string): MenuHandle {
277
296
  id: allocateSyntheticId(`${menu.id}-separator`),
278
297
  label: 'Separator',
279
298
  content: '—',
280
- }, beforeAction);
299
+ }, beforeAction, afterAction);
281
300
  },
282
301
  removeItem(actionId: string) {
283
302
  const menu = getStoredMenu(this.id);
@@ -308,17 +327,16 @@ export function getMenu(menuId: string): MenuHandle | null {
308
327
  }
309
328
 
310
329
  /** Returns a menu by id, creating it if needed.
311
- * @param menuId - Menu identifier
312
- * @param label - User-visible label
313
- * @param options - Surface target ('menubar' or 'settings'), MUST be explicitly provided
330
+ * @param menuId - Menu identifier.
331
+ * @param label - User-visible label.
332
+ * @param options - Placement options, including the required `surface` so the runtime can serialize the menu for the correct host UI surface.
314
333
  */
315
334
  export function getOrCreateMenu(
316
335
  menuId: string,
317
336
  label: string,
318
337
  options: MenubarMenuOptions & { surface: MenuSurface },
319
338
  ): MenuHandle | null {
320
- const surface = options.surface;
321
- const { surface: _, ...restOptions } = options;
339
+ const { surface, ...restOptions } = options;
322
340
  const stored = ensureStoredMenu(menuId, label, restOptions, surface);
323
341
  return createMenuHandle(stored.id);
324
342
  }
@@ -326,14 +344,17 @@ export function getOrCreateMenu(
326
344
  /** Creates a menu at a specific position (alias for getOrCreateMenu). */
327
345
  export const createMenu = getOrCreateMenu;
328
346
 
329
- /** Adds one item to a menu. */
347
+ /** Adds one item to a menu, silently no-op if the menu does not exist. */
330
348
  export function addMenuItem(menuId: string, item: MenubarItem): void {
331
- createMenuHandle(menuId).addItem(item);
349
+ const handle = createMenuHandle(menuId);
350
+ if (!getStoredMenu(menuId)) return;
351
+ handle.addItem(item);
332
352
  }
333
353
 
334
- /** Adds one separator to a menu. */
335
- export function addMenuSeparator(menuId: string, beforeAction?: string): void {
336
- createMenuHandle(menuId).addSeparator(beforeAction);
354
+ /** Adds one separator to a menu, silently no-op if the menu does not exist. */
355
+ export function addMenuSeparator(menuId: string, beforeAction?: string, afterAction?: string): void {
356
+ if (!getStoredMenu(menuId)) return;
357
+ createMenuHandle(menuId).addSeparator(beforeAction, afterAction);
337
358
  }
338
359
 
339
360
  /** Removes one menu from the registry. */
@@ -374,21 +395,20 @@ export function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T> {
374
395
  /** Emits a notification when the host helper is available. */
375
396
  export function notify(msg: string): void {
376
397
  const openvcs = getOpenVCS();
377
- openvcs?.notify(msg);
398
+ if (!openvcs) {
399
+ throw new Error('OpenVCS host is not available in this runtime');
400
+ }
401
+ openvcs.notify(msg);
378
402
  }
379
403
 
380
404
  /** Builds SDK delegates from the local menu/action registries. */
381
405
  export function createMenuPluginDelegates(): PluginDelegates<PluginRuntimeContext> {
382
406
  return {
383
407
  async 'plugin.get_menus'(): Promise<PluginMenuDefinition[]> {
384
- return serializeMenus() as unknown as PluginMenuDefinition[];
408
+ return serializeMenus();
385
409
  },
386
410
  async 'plugin.handle_action'(params: PluginHandleActionParams): Promise<unknown> {
387
- const actionId = String(params?.action_id || params?.id || '').trim();
388
- if (actionId) {
389
- return await runRegisteredAction(actionId, params?.payload);
390
- }
391
- return null;
411
+ return await runRegisteredAction(requireActionId(params), params?.payload);
392
412
  },
393
413
  };
394
414
  }
@@ -16,6 +16,9 @@ import type {
16
16
  import { createPluginRuntime } from './factory';
17
17
  import {
18
18
  createMenuPluginDelegates,
19
+ hasRegisteredAction,
20
+ requireActionId,
21
+ resetMenuRegistry,
19
22
  runRegisteredAction,
20
23
  } from './menu';
21
24
 
@@ -79,8 +82,8 @@ export function createRegisteredPluginRuntime(
79
82
  ];
80
83
  },
81
84
  'plugin.handle_action': async (params, ctx) => {
82
- const actionId = String(params?.action_id || '').trim();
83
- if (actionId) {
85
+ const actionId = requireActionId(params);
86
+ if (hasRegisteredAction(actionId)) {
84
87
  const result = await runRegisteredAction(actionId, params?.payload);
85
88
  if (result !== null && result !== undefined) {
86
89
  return result;
@@ -89,6 +92,7 @@ export function createRegisteredPluginRuntime(
89
92
  if (explicitHandleAction) {
90
93
  return explicitHandleAction(params, ctx);
91
94
  }
95
+ ctx.host.info(`plugin.handle_action ignored unhandled action_id '${actionId}'`);
92
96
  return null;
93
97
  },
94
98
  },
@@ -114,6 +118,9 @@ export async function bootstrapPluginModule(
114
118
  );
115
119
  }
116
120
 
121
+ // Reset internal state so repeated in-process setups do not leak menu or action state.
122
+ resetMenuRegistry();
123
+
117
124
  try {
118
125
  await onPluginStart();
119
126
  } catch (error) {
@@ -23,8 +23,8 @@ export interface MenubarMenu {
23
23
  id: string;
24
24
  /** Adds an item to this menu. */
25
25
  addItem(item: MenubarItem): void;
26
- /** Adds a separator to this menu. */
27
- addSeparator(beforeAction?: string): void;
26
+ /** Adds a separator to this menu before or after an existing action. */
27
+ addSeparator(beforeAction?: string, afterAction?: string): void;
28
28
  /** Removes an item by action ID. */
29
29
  removeItem(actionId: string): void;
30
30
  /** Hides an item by action ID. */
@@ -67,9 +67,7 @@ export type PluginSettingsValue = Record<string, unknown>;
67
67
 
68
68
  /** Describes the params shape for plugin action handling. */
69
69
  export interface PluginHandleActionParams extends RequestParams {
70
- /** Stores the action id selected by the user when provided by the transport. */
71
- id?: string;
72
- /** Stores the action id selected by the user. */
70
+ /** Stores the action id selected by the user. Runtime handlers require a non-empty string. */
73
71
  action_id?: string;
74
72
  /** Stores an optional payload supplied by the triggering UI. */
75
73
  payload?: Record<string, unknown>;
@@ -4,7 +4,21 @@ const test = require("node:test");
4
4
 
5
5
  const {
6
6
  bootstrapPluginModule,
7
- createPluginRuntime,
7
+ createRegisteredPluginRuntime,
8
+ resetMenuRegistry,
9
+ } = require("../lib/runtime");
10
+ const {
11
+ addMenuItem,
12
+ addMenuSeparator,
13
+ createMenu,
14
+ getMenu,
15
+ registerAction,
16
+ removeMenu,
17
+ hideMenu,
18
+ showMenu,
19
+ } = require("../lib/runtime/menu");
20
+ const {
21
+ createMenu: createMenuFromRoot,
8
22
  } = require("../lib/runtime");
9
23
  const {
10
24
  parseFramedMessages,
@@ -20,7 +34,7 @@ function createRuntimeHarness(options) {
20
34
  return true;
21
35
  },
22
36
  };
23
- const runtime = createPluginRuntime(options);
37
+ const runtime = createRegisteredPluginRuntime(options);
24
38
  runtime.start({ stdin, stdout });
25
39
 
26
40
  return {
@@ -233,3 +247,308 @@ test("bootstrapPluginModule rejects when OnPluginStart throws", async () => {
233
247
  /plugin startup failed/
234
248
  );
235
249
  });
250
+
251
+ test("resetMenuRegistry clears all menus and action handlers", () => {
252
+ createMenu("reset-test", "Reset Test", { surface: "menubar" });
253
+ registerAction("reset-test-action", () => "ok");
254
+ assert.notEqual(getMenu("reset-test"), null);
255
+
256
+ resetMenuRegistry();
257
+
258
+ assert.equal(getMenu("reset-test"), null);
259
+ // After reset, addMenuItem/addMenuSeparator must silently no-op for unknown menus.
260
+ });
261
+
262
+ test("addMenuItem silently ignores when menu does not exist", () => {
263
+ resetMenuRegistry();
264
+ // Must not throw.
265
+ addMenuItem("nonexistent-menu", {
266
+ label: "Irrelevant",
267
+ action: "test-action",
268
+ });
269
+ assert.equal(getMenu("nonexistent-menu"), null);
270
+ });
271
+
272
+ test("addMenuSeparator silently ignores when menu does not exist", () => {
273
+ resetMenuRegistry();
274
+ // Must not throw.
275
+ addMenuSeparator("nonexistent-menu");
276
+ assert.equal(getMenu("nonexistent-menu"), null);
277
+ });
278
+
279
+ test("addMenuItem and addMenuSeparator do not implicitly create menus", () => {
280
+ resetMenuRegistry();
281
+ createMenu("existing-menu", "Existing Menu", { surface: "menubar" });
282
+ addMenuItem("existing-menu", {
283
+ label: "Test Item",
284
+ action: "test-action",
285
+ });
286
+ addMenuSeparator("existing-menu");
287
+ assert.notEqual(getMenu("existing-menu"), null);
288
+ // Verify state was not leaked from prior tests.
289
+ assert.equal(getMenu("reset-test"), null);
290
+ removeMenu("existing-menu");
291
+ });
292
+
293
+ test("hideMenu and showMenu toggle menu visibility", () => {
294
+ resetMenuRegistry();
295
+ createMenu("visible-menu", "Visible Menu", { surface: "menubar" });
296
+ addMenuItem("visible-menu", {
297
+ label: "Test Item",
298
+ action: "visibility-action",
299
+ });
300
+
301
+ const menu = getMenu("visible-menu");
302
+ assert.notEqual(menu, null);
303
+ hideMenu("visible-menu");
304
+ assert.notEqual(getMenu("visible-menu"), null);
305
+
306
+ showMenu("visible-menu");
307
+ assert.notEqual(getMenu("visible-menu"), null);
308
+
309
+ removeMenu("visible-menu");
310
+ });
311
+
312
+ test("hideMenu and showMenu affect serialized menus", async () => {
313
+ resetMenuRegistry();
314
+ createMenu("serial-menu", "Serial Menu", { surface: "menubar" });
315
+ addMenuItem("serial-menu", {
316
+ label: "Serial Item",
317
+ action: "serial-action",
318
+ });
319
+
320
+ const harness = createRuntimeHarness({ plugin: {} });
321
+
322
+ hideMenu("serial-menu");
323
+ let hidden = await harness.request({
324
+ jsonrpc: "2.0",
325
+ id: 30,
326
+ method: "plugin.get_menus",
327
+ params: {},
328
+ });
329
+ assert.deepEqual(hidden, [{ jsonrpc: "2.0", id: 30, result: [] }]);
330
+
331
+ showMenu("serial-menu");
332
+ const visible = await harness.request({
333
+ jsonrpc: "2.0",
334
+ id: 31,
335
+ method: "plugin.get_menus",
336
+ params: {},
337
+ });
338
+ assert.deepEqual(visible, [{
339
+ jsonrpc: "2.0",
340
+ id: 31,
341
+ result: [{
342
+ id: "serial-menu",
343
+ label: "Serial Menu",
344
+ order: 1,
345
+ surface: "menubar",
346
+ elements: [{ type: "button", id: "serial-action", label: "Serial Item" }],
347
+ }],
348
+ }]);
349
+ });
350
+
351
+ test("addMenuSeparator supports afterAction positioning", async () => {
352
+ resetMenuRegistry();
353
+ createMenu("separator-menu", "Separator Menu", { surface: "menubar" });
354
+ addMenuItem("separator-menu", {
355
+ label: "First Item",
356
+ action: "first-action",
357
+ });
358
+ addMenuItem("separator-menu", {
359
+ label: "Second Item",
360
+ action: "second-action",
361
+ });
362
+
363
+ addMenuSeparator("separator-menu", undefined, "first-action");
364
+
365
+ const harness = createRuntimeHarness({ plugin: {} });
366
+ const messages = await harness.request({
367
+ jsonrpc: "2.0",
368
+ id: 32,
369
+ method: "plugin.get_menus",
370
+ params: {},
371
+ });
372
+
373
+ assert.deepEqual(messages, [{
374
+ jsonrpc: "2.0",
375
+ id: 32,
376
+ result: [{
377
+ id: "separator-menu",
378
+ label: "Separator Menu",
379
+ order: 1,
380
+ surface: "menubar",
381
+ elements: [
382
+ { type: "button", id: "first-action", label: "First Item" },
383
+ { type: "text", id: "separator-menu-separator-1", content: "—" },
384
+ { type: "button", id: "second-action", label: "Second Item" },
385
+ ],
386
+ }],
387
+ }]);
388
+
389
+ removeMenu("separator-menu");
390
+ });
391
+
392
+ test("plugin.handle_action rejects missing action_id", async () => {
393
+ resetMenuRegistry();
394
+ const harness = createRuntimeHarness({ plugin: {} });
395
+
396
+ const missing = await harness.request({
397
+ jsonrpc: "2.0",
398
+ id: 20,
399
+ method: "plugin.handle_action",
400
+ params: {},
401
+ });
402
+ assert.deepEqual(missing, [
403
+ {
404
+ jsonrpc: "2.0",
405
+ id: 20,
406
+ error: {
407
+ code: -32001,
408
+ message: "plugin.handle_action requires params.action_id to be a non-empty string",
409
+ data: {
410
+ code: "plugin-invalid-action-id",
411
+ message: "plugin.handle_action requires params.action_id to be a non-empty string",
412
+ },
413
+ },
414
+ },
415
+ ]);
416
+ });
417
+
418
+ test("plugin.handle_action rejects empty action_id", async () => {
419
+ resetMenuRegistry();
420
+ const harness = createRuntimeHarness({ plugin: {} });
421
+
422
+ const empty = await harness.request({
423
+ jsonrpc: "2.0",
424
+ id: 21,
425
+ method: "plugin.handle_action",
426
+ params: { action_id: "" },
427
+ });
428
+ assert.deepEqual(empty, [
429
+ {
430
+ jsonrpc: "2.0",
431
+ id: 21,
432
+ error: {
433
+ code: -32001,
434
+ message: "plugin.handle_action requires params.action_id to be a non-empty string",
435
+ data: {
436
+ code: "plugin-invalid-action-id",
437
+ message: "plugin.handle_action requires params.action_id to be a non-empty string",
438
+ },
439
+ },
440
+ },
441
+ ]);
442
+ });
443
+
444
+ test("createMenu is available from runtime root export", () => {
445
+ resetMenuRegistry();
446
+ const menu = createMenuFromRoot("root-export-menu", "Root Export Menu", { surface: "menubar" });
447
+ assert.notEqual(menu, null);
448
+ assert.equal(menu.id, "root-export-menu");
449
+
450
+ resetMenuRegistry();
451
+ });
452
+
453
+ test("plugin.handle_action dispatches only on action_id (not id)", async () => {
454
+ resetMenuRegistry();
455
+ // Register after reset so the handler is present.
456
+ registerAction("test-action-id", (payload) => {
457
+ if (typeof payload === "object" && payload != null) {
458
+ return (payload).message;
459
+ }
460
+ return null;
461
+ });
462
+
463
+ const harness = createRuntimeHarness({
464
+ plugin: {},
465
+ });
466
+
467
+ // Valid dispatch via action_id.
468
+ const valid = await harness.request({
469
+ jsonrpc: "2.0",
470
+ id: 10,
471
+ method: "plugin.handle_action",
472
+ params: { action_id: "test-action-id", payload: { message: "hello" } },
473
+ });
474
+ assert.deepEqual(valid, [
475
+ { jsonrpc: "2.0", id: 10, result: "hello" },
476
+ ]);
477
+
478
+ // `id` is ignored; only `action_id` is valid.
479
+ const wrongField = await harness.request({
480
+ jsonrpc: "2.0",
481
+ id: 12,
482
+ method: "plugin.handle_action",
483
+ params: { id: "test-action-id" },
484
+ });
485
+ assert.deepEqual(wrongField, [
486
+ {
487
+ jsonrpc: "2.0",
488
+ id: 12,
489
+ error: {
490
+ code: -32001,
491
+ message: "plugin.handle_action requires params.action_id to be a non-empty string",
492
+ data: {
493
+ code: "plugin-invalid-action-id",
494
+ message: "plugin.handle_action requires params.action_id to be a non-empty string",
495
+ },
496
+ },
497
+ },
498
+ ]);
499
+ });
500
+
501
+ test("plugin.handle_action logs unhandled actions without explicit handler", async () => {
502
+ resetMenuRegistry();
503
+ const harness = createRuntimeHarness({ plugin: {} });
504
+
505
+ const messages = await harness.request({
506
+ jsonrpc: "2.0",
507
+ id: 13,
508
+ method: "plugin.handle_action",
509
+ params: { action_id: "missing-action" },
510
+ });
511
+
512
+ assert.deepEqual(messages, [
513
+ {
514
+ jsonrpc: "2.0",
515
+ method: "host.log",
516
+ params: {
517
+ level: "info",
518
+ target: "openvcs.plugin",
519
+ message: "plugin.handle_action ignored unhandled action_id 'missing-action'",
520
+ },
521
+ },
522
+ { jsonrpc: "2.0", id: 13, result: null },
523
+ ]);
524
+ });
525
+
526
+ test("bootstrapPluginModule resets menu registry before OnPluginStart", async () => {
527
+ resetMenuRegistry();
528
+ // Pre-populate state that should be cleared.
529
+ createMenu("leak-test", "Leak Test", { surface: "menubar" });
530
+ registerAction("leak-action", () => "leaked");
531
+
532
+ const stdin = new EventEmitter();
533
+ const chunks = [];
534
+ const stdout = {
535
+ write(chunk) {
536
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
537
+ return true;
538
+ },
539
+ };
540
+
541
+ await bootstrapPluginModule({
542
+ modulePath: "./plugin.js",
543
+ transport: { stdin, stdout },
544
+ async importPluginModule() {
545
+ return {
546
+ PluginDefinition: { plugin: {} },
547
+ OnPluginStart() {},
548
+ };
549
+ },
550
+ });
551
+
552
+ // Menu from before bootstrap must not be present.
553
+ assert.equal(getMenu("leak-test"), null);
554
+ });