@openvcs/sdk 0.2.18-nightly.20260412.10 → 0.3.0-beta.34

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,30 +1,47 @@
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). */
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;
8
19
  /** Runs a registered action handler by id. */
9
20
  export declare function runRegisteredAction(actionId: string, ...args: unknown[]): Promise<unknown>;
10
21
  export interface MenuHandle {
11
22
  id: string;
12
23
  addItem(item: MenubarItem): void;
13
- addSeparator(beforeAction?: string): void;
24
+ addSeparator(beforeAction?: string, afterAction?: string): void;
14
25
  removeItem(actionId: string): void;
15
26
  hideItem(actionId: string): void;
16
27
  showItem(actionId: string): void;
17
28
  }
18
29
  /** Returns a menu by id, or null when it does not exist. */
19
30
  export declare function getMenu(menuId: string): MenuHandle | null;
20
- /** Returns a menu by id, creating it if needed. */
21
- export declare function getOrCreateMenu(menuId: string, label: string): MenuHandle | null;
22
- /** Creates a menu at a specific position. */
23
- export declare function createMenu(menuId: string, label: string, options?: MenubarMenuOptions): MenuHandle | null;
24
- /** Adds one item to a menu. */
31
+ /** Returns a menu by id, creating it if needed.
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.
35
+ */
36
+ export declare function getOrCreateMenu(menuId: string, label: string, options: MenubarMenuOptions & {
37
+ surface: MenuSurface;
38
+ }): MenuHandle | null;
39
+ /** Creates a menu at a specific position (alias for getOrCreateMenu). */
40
+ export declare const createMenu: typeof getOrCreateMenu;
41
+ /** Adds one item to a menu, silently no-op if the menu does not exist. */
25
42
  export declare function addMenuItem(menuId: string, item: MenubarItem): void;
26
- /** Adds one separator to a menu. */
27
- 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;
28
45
  /** Removes one menu from the registry. */
29
46
  export declare function removeMenu(menuId: string): void;
30
47
  /** Hides one menu from the registry. */
@@ -2,10 +2,13 @@
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.createMenu = void 0;
6
+ exports.requireActionId = requireActionId;
7
+ exports.resetMenuRegistry = resetMenuRegistry;
8
+ exports.hasRegisteredAction = hasRegisteredAction;
5
9
  exports.runRegisteredAction = runRegisteredAction;
6
10
  exports.getMenu = getMenu;
7
11
  exports.getOrCreateMenu = getOrCreateMenu;
8
- exports.createMenu = createMenu;
9
12
  exports.addMenuItem = addMenuItem;
10
13
  exports.addMenuSeparator = addMenuSeparator;
11
14
  exports.removeMenu = removeMenu;
@@ -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;
@@ -66,16 +88,17 @@ function placeMenuId(menuId, options) {
66
88
  menuOrder.push(id);
67
89
  }
68
90
  /** Ensures a menu record exists for one id. */
69
- function ensureStoredMenu(menuId, label, options) {
91
+ function ensureStoredMenu(menuId, label, options, surface) {
70
92
  const id = normalizeMenuId(menuId);
71
93
  const safeLabel = String(label || '').trim() || id;
72
94
  let menu = menus.get(id);
73
95
  if (!menu) {
74
- menu = { id, label: safeLabel, items: [] };
96
+ menu = { id, label: safeLabel, surface, items: [] };
75
97
  menus.set(id, menu);
76
98
  }
77
99
  else {
78
100
  menu.label = safeLabel;
101
+ menu.surface = surface;
79
102
  }
80
103
  placeMenuId(id, options);
81
104
  return menu;
@@ -148,6 +171,7 @@ function serializeMenus() {
148
171
  id: menu.id,
149
172
  label: menu.label,
150
173
  order: index + 1,
174
+ surface: menu.surface,
151
175
  elements: menu.items
152
176
  .map((item) => serializeMenuItem(item))
153
177
  .filter((item) => Boolean(item)),
@@ -155,6 +179,13 @@ function serializeMenus() {
155
179
  })
156
180
  .filter((menu) => Boolean(menu));
157
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
+ }
158
189
  /** Runs a registered action handler by id. */
159
190
  async function runRegisteredAction(actionId, ...args) {
160
191
  const id = String(actionId || '').trim();
@@ -175,7 +206,11 @@ function createMenuHandle(menuId) {
175
206
  const action = String(item?.action || '').trim();
176
207
  if (!label || !action)
177
208
  return;
178
- const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
209
+ let menu = getStoredMenu(this.id);
210
+ if (!menu) {
211
+ // Default to menubar for internal menu handle operations.
212
+ menu = ensureStoredMenu(this.id, this.id, {}, 'menubar');
213
+ }
179
214
  insertMenuItem(menu, {
180
215
  kind: 'button',
181
216
  id: action,
@@ -184,14 +219,18 @@ function createMenuHandle(menuId) {
184
219
  action,
185
220
  }, item.before, item.after);
186
221
  },
187
- addSeparator(beforeAction) {
188
- const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
222
+ addSeparator(beforeAction, afterAction) {
223
+ let menu = getStoredMenu(this.id);
224
+ if (!menu) {
225
+ // Default to menubar for internal menu handle operations.
226
+ menu = ensureStoredMenu(this.id, this.id, {}, 'menubar');
227
+ }
189
228
  insertMenuItem(menu, {
190
229
  kind: 'separator',
191
230
  id: allocateSyntheticId(`${menu.id}-separator`),
192
231
  label: 'Separator',
193
232
  content: '—',
194
- }, beforeAction);
233
+ }, beforeAction, afterAction);
195
234
  },
196
235
  removeItem(actionId) {
197
236
  const menu = getStoredMenu(this.id);
@@ -225,23 +264,30 @@ function getMenu(menuId) {
225
264
  return null;
226
265
  return createMenuHandle(stored.id);
227
266
  }
228
- /** Returns a menu by id, creating it if needed. */
229
- function getOrCreateMenu(menuId, label) {
230
- const stored = ensureStoredMenu(menuId, label);
267
+ /** Returns a menu by id, creating it if needed.
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.
271
+ */
272
+ function getOrCreateMenu(menuId, label, options) {
273
+ const { surface, ...restOptions } = options;
274
+ const stored = ensureStoredMenu(menuId, label, restOptions, surface);
231
275
  return createMenuHandle(stored.id);
232
276
  }
233
- /** Creates a menu at a specific position. */
234
- function createMenu(menuId, label, options) {
235
- const stored = ensureStoredMenu(menuId, label, options);
236
- return createMenuHandle(stored.id);
237
- }
238
- /** Adds one item to a menu. */
277
+ /** Creates a menu at a specific position (alias for getOrCreateMenu). */
278
+ exports.createMenu = getOrCreateMenu;
279
+ /** Adds one item to a menu, silently no-op if the menu does not exist. */
239
280
  function addMenuItem(menuId, item) {
240
- createMenuHandle(menuId).addItem(item);
281
+ const handle = createMenuHandle(menuId);
282
+ if (!getStoredMenu(menuId))
283
+ return;
284
+ handle.addItem(item);
241
285
  }
242
- /** Adds one separator to a menu. */
243
- function addMenuSeparator(menuId, beforeAction) {
244
- 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);
245
291
  }
246
292
  /** Removes one menu from the registry. */
247
293
  function removeMenu(menuId) {
@@ -279,7 +325,10 @@ function invoke(cmd, args) {
279
325
  /** Emits a notification when the host helper is available. */
280
326
  function notify(msg) {
281
327
  const openvcs = getOpenVCS();
282
- openvcs?.notify(msg);
328
+ if (!openvcs) {
329
+ throw new Error('OpenVCS host is not available in this runtime');
330
+ }
331
+ openvcs.notify(msg);
283
332
  }
284
333
  /** Builds SDK delegates from the local menu/action registries. */
285
334
  function createMenuPluginDelegates() {
@@ -288,11 +337,7 @@ function createMenuPluginDelegates() {
288
337
  return serializeMenus();
289
338
  },
290
339
  async 'plugin.handle_action'(params) {
291
- const actionId = String(params?.action_id || params?.id || '').trim();
292
- if (actionId) {
293
- return await runRegisteredAction(actionId, params?.payload);
294
- }
295
- return null;
340
+ return await runRegisteredAction(requireActionId(params), params?.payload);
296
341
  },
297
342
  };
298
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
  }
@@ -77,6 +77,8 @@ export declare abstract class VcsDelegateBase<TDeps, TContext = PluginRuntimeCon
77
77
  writeMergeResult(_params: VcsTypes.VcsWriteMergeResultParams, _context: TContext): VcsHandlerResult<null>;
78
78
  /** Handles `vcs.stage_patch`. */
79
79
  stagePatch(_params: VcsTypes.VcsStagePatchParams, _context: TContext): VcsHandlerResult<null>;
80
+ /** Handles `vcs.stage_paths`. */
81
+ stagePaths(_params: VcsTypes.VcsStagePathsParams, _context: TContext): VcsHandlerResult<null>;
80
82
  /** Handles `vcs.discard_paths`. */
81
83
  discardPaths(_params: VcsTypes.VcsDiscardPathsParams, _context: TContext): VcsHandlerResult<null>;
82
84
  /** Handles `vcs.apply_reverse_patch`. */
@@ -157,6 +157,10 @@ class VcsDelegateBase {
157
157
  stagePatch(_params, _context) {
158
158
  return this.unimplemented('stagePatch');
159
159
  }
160
+ /** Handles `vcs.stage_paths`. */
161
+ stagePaths(_params, _context) {
162
+ return this.unimplemented('stagePaths');
163
+ }
160
164
  /** Handles `vcs.discard_paths`. */
161
165
  discardPaths(_params, _context) {
162
166
  return this.unimplemented('discardPaths');
@@ -30,6 +30,7 @@ export type VcsDelegateBindings<TContext> = {
30
30
  checkoutConflictSide: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.checkout_conflict_side']>;
31
31
  writeMergeResult: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.write_merge_result']>;
32
32
  stagePatch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_patch']>;
33
+ stagePaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_paths']>;
33
34
  discardPaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.discard_paths']>;
34
35
  applyReversePatch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.apply_reverse_patch']>;
35
36
  deleteBranch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.delete_branch']>;
@@ -85,6 +86,7 @@ export declare const VCS_DELEGATE_METHOD_MAPPINGS: {
85
86
  readonly checkoutConflictSide: "vcs.checkout_conflict_side";
86
87
  readonly writeMergeResult: "vcs.write_merge_result";
87
88
  readonly stagePatch: "vcs.stage_patch";
89
+ readonly stagePaths: "vcs.stage_paths";
88
90
  readonly discardPaths: "vcs.discard_paths";
89
91
  readonly applyReversePatch: "vcs.apply_reverse_patch";
90
92
  readonly deleteBranch: "vcs.delete_branch";
@@ -33,6 +33,7 @@ exports.VCS_DELEGATE_METHOD_MAPPINGS = {
33
33
  checkoutConflictSide: 'vcs.checkout_conflict_side',
34
34
  writeMergeResult: 'vcs.write_merge_result',
35
35
  stagePatch: 'vcs.stage_patch',
36
+ stagePaths: 'vcs.stage_paths',
36
37
  discardPaths: 'vcs.discard_paths',
37
38
  applyReversePatch: 'vcs.apply_reverse_patch',
38
39
  deleteBranch: 'vcs.delete_branch',
@@ -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. */
@@ -15,15 +15,46 @@ export interface PluginInitializeResult {
15
15
  }
16
16
  /** Describes one optional override returned by a custom initialize handler. */
17
17
  export type PluginInitializeOverride = Partial<PluginInitializeResult>;
18
+ /** Describes the UI surface targeted by one plugin menu definition. */
19
+ export type PluginMenuSurface = 'menubar' | 'settings';
20
+ /** Describes one static text element contributed to a plugin menu. */
21
+ export interface PluginMenuTextElement {
22
+ /** Stores the serialized element kind. */
23
+ type: 'text';
24
+ /** Stores the stable plugin-local element id. */
25
+ id: string;
26
+ /** Stores the rendered text content. */
27
+ content: string;
28
+ }
29
+ /** Describes one button element contributed to a plugin menu. */
30
+ export interface PluginMenuButtonElement {
31
+ /** Stores the serialized element kind. */
32
+ type: 'button';
33
+ /** Stores the stable action id dispatched back to the plugin. */
34
+ id: string;
35
+ /** Stores the user-visible button label. */
36
+ label: string;
37
+ }
38
+ /** Describes one renderable plugin menu element. */
39
+ export type PluginMenuElement = PluginMenuTextElement | PluginMenuButtonElement;
18
40
  /** Describes one plugin-contributed menu definition. */
19
- export type PluginMenuDefinition = Record<string, unknown>;
41
+ export interface PluginMenuDefinition {
42
+ /** Stores the stable plugin-local menu id. */
43
+ id: string;
44
+ /** Stores the user-visible menu label. */
45
+ label: string;
46
+ /** Stores the display ordering hint assigned by the runtime. */
47
+ order: number;
48
+ /** Stores the target UI surface for the menu. */
49
+ surface: PluginMenuSurface;
50
+ /** Stores the renderable elements contributed under the menu. */
51
+ elements: PluginMenuElement[];
52
+ }
20
53
  /** Describes one plugin settings value payload. */
21
54
  export type PluginSettingsValue = Record<string, unknown>;
22
55
  /** Describes the params shape for plugin action handling. */
23
56
  export interface PluginHandleActionParams extends RequestParams {
24
- /** Stores the action id selected by the user when provided by the transport. */
25
- id?: string;
26
- /** Stores the action id selected by the user. */
57
+ /** Stores the action id selected by the user. Runtime handlers require a non-empty string. */
27
58
  action_id?: string;
28
59
  /** Stores an optional payload supplied by the triggering UI. */
29
60
  payload?: Record<string, unknown>;
@@ -261,6 +261,11 @@ export interface VcsStagePatchParams extends VcsSessionParams {
261
261
  /** Stores the textual patch content. */
262
262
  patch: string;
263
263
  }
264
+ /** Describes params for staging repository-relative paths into the index. */
265
+ export interface VcsStagePathsParams extends VcsSessionParams {
266
+ /** Stores the paths to stage. */
267
+ paths?: string[];
268
+ }
264
269
  /** Describes params for discarding path changes. */
265
270
  export interface VcsDiscardPathsParams extends VcsSessionParams {
266
271
  /** Stores the paths to discard. */
@@ -424,6 +429,8 @@ export interface VcsDelegates<TContext = unknown> {
424
429
  'vcs.write_merge_result'?: RpcMethodHandler<VcsWriteMergeResultParams, null, TContext>;
425
430
  /** Handles `vcs.stage_patch`. */
426
431
  'vcs.stage_patch'?: RpcMethodHandler<VcsStagePatchParams, null, TContext>;
432
+ /** Handles `vcs.stage_paths`. */
433
+ 'vcs.stage_paths'?: RpcMethodHandler<VcsStagePathsParams, null, TContext>;
427
434
  /** Handles `vcs.discard_paths`. */
428
435
  'vcs.discard_paths'?: RpcMethodHandler<VcsDiscardPathsParams, null, TContext>;
429
436
  /** Handles `vcs.apply_reverse_patch`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/sdk",
3
- "version": "0.2.18-nightly.20260412.10",
3
+ "version": "0.3.0-beta.34",
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,17 @@ 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). */
18
+ type MenuSurface = 'menubar' | 'settings';
15
19
 
16
20
  type OpenVCSGlobal = typeof globalThis & {
17
21
  OpenVCS?: {
@@ -26,6 +30,7 @@ interface StoredMenuItem {
26
30
  label: string;
27
31
  title?: string;
28
32
  content?: string;
33
+ /** Action id dispatched back to the plugin when the user activates this item. */
29
34
  action?: string;
30
35
  hidden?: boolean;
31
36
  }
@@ -34,28 +39,39 @@ interface StoredMenuState {
34
39
  id: string;
35
40
  label: string;
36
41
  hidden?: boolean;
42
+ /** Surface target for rendering ('menubar' or 'settings'), must be explicitly provided. */
43
+ surface: MenuSurface;
37
44
  items: StoredMenuItem[];
38
45
  }
39
46
 
40
- interface SerializedMenuItem {
41
- type: 'button' | 'text';
42
- id: string;
43
- label?: string;
44
- content?: string;
45
- }
46
-
47
- interface SerializedMenuDefinition {
48
- id: string;
49
- label: string;
50
- order: number;
51
- elements: SerializedMenuItem[];
52
- }
53
-
54
47
  const menus = new Map<string, StoredMenuState>();
55
48
  const menuOrder: string[] = [];
56
49
  const actionHandlers = new Map<string, (...args: unknown[]) => unknown>();
57
50
  let syntheticId = 0;
58
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
+
59
75
  /** Returns the host-side OpenVCS helper, when the environment provides one. */
60
76
  function getOpenVCS() {
61
77
  return (globalThis as OpenVCSGlobal).OpenVCS;
@@ -115,17 +131,19 @@ function placeMenuId(menuId: string, options?: MenubarMenuOptions): void {
115
131
  function ensureStoredMenu(
116
132
  menuId: string,
117
133
  label: string,
118
- options?: MenubarMenuOptions,
134
+ options: MenubarMenuOptions,
135
+ surface: MenuSurface,
119
136
  ): StoredMenuState {
120
137
  const id = normalizeMenuId(menuId);
121
138
  const safeLabel = String(label || '').trim() || id;
122
139
  let menu = menus.get(id);
123
140
 
124
141
  if (!menu) {
125
- menu = { id, label: safeLabel, items: [] };
142
+ menu = { id, label: safeLabel, surface, items: [] };
126
143
  menus.set(id, menu);
127
144
  } else {
128
145
  menu.label = safeLabel;
146
+ menu.surface = surface;
129
147
  }
130
148
 
131
149
  placeMenuId(id, options);
@@ -171,7 +189,7 @@ function insertMenuItem(menu: StoredMenuState, item: StoredMenuItem, before?: st
171
189
  }
172
190
 
173
191
  /** Converts one stored item into a serializable menu payload element. */
174
- function serializeMenuItem(item: StoredMenuItem): SerializedMenuItem | null {
192
+ function serializeMenuItem(item: StoredMenuItem): PluginMenuElement | null {
175
193
  if (item.hidden) return null;
176
194
 
177
195
  if (item.kind === 'separator') {
@@ -198,7 +216,7 @@ function serializeMenuItem(item: StoredMenuItem): SerializedMenuItem | null {
198
216
  }
199
217
 
200
218
  /** Serializes the local registry into plugin menu payloads. */
201
- function serializeMenus(): SerializedMenuDefinition[] {
219
+ function serializeMenus(): PluginMenuDefinition[] {
202
220
  return menuOrder
203
221
  .map((menuId, index) => {
204
222
  const menu = menus.get(menuId);
@@ -207,12 +225,20 @@ function serializeMenus(): SerializedMenuDefinition[] {
207
225
  id: menu.id,
208
226
  label: menu.label,
209
227
  order: index + 1,
228
+ surface: menu.surface,
210
229
  elements: menu.items
211
230
  .map((item) => serializeMenuItem(item))
212
- .filter((item): item is SerializedMenuItem => Boolean(item)),
231
+ .filter((item): item is PluginMenuElement => Boolean(item)),
213
232
  };
214
233
  })
215
- .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);
216
242
  }
217
243
 
218
244
  /** Runs a registered action handler by id. */
@@ -229,7 +255,7 @@ export async function runRegisteredAction(actionId: string, ...args: unknown[]):
229
255
  export interface MenuHandle {
230
256
  id: string;
231
257
  addItem(item: MenubarItem): void;
232
- addSeparator(beforeAction?: string): void;
258
+ addSeparator(beforeAction?: string, afterAction?: string): void;
233
259
  removeItem(actionId: string): void;
234
260
  hideItem(actionId: string): void;
235
261
  showItem(actionId: string): void;
@@ -246,7 +272,11 @@ function createMenuHandle(menuId: string): MenuHandle {
246
272
  const action = String(item?.action || '').trim();
247
273
  if (!label || !action) return;
248
274
 
249
- const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
275
+ let menu = getStoredMenu(this.id);
276
+ if (!menu) {
277
+ // Default to menubar for internal menu handle operations.
278
+ menu = ensureStoredMenu(this.id, this.id, {}, 'menubar');
279
+ }
250
280
  insertMenuItem(menu, {
251
281
  kind: 'button',
252
282
  id: action,
@@ -255,14 +285,18 @@ function createMenuHandle(menuId: string): MenuHandle {
255
285
  action,
256
286
  }, item.before, item.after);
257
287
  },
258
- addSeparator(beforeAction?: string) {
259
- const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
288
+ addSeparator(beforeAction?: string, afterAction?: string) {
289
+ let menu = getStoredMenu(this.id);
290
+ if (!menu) {
291
+ // Default to menubar for internal menu handle operations.
292
+ menu = ensureStoredMenu(this.id, this.id, {}, 'menubar');
293
+ }
260
294
  insertMenuItem(menu, {
261
295
  kind: 'separator',
262
296
  id: allocateSyntheticId(`${menu.id}-separator`),
263
297
  label: 'Separator',
264
298
  content: '—',
265
- }, beforeAction);
299
+ }, beforeAction, afterAction);
266
300
  },
267
301
  removeItem(actionId: string) {
268
302
  const menu = getStoredMenu(this.id);
@@ -292,26 +326,35 @@ export function getMenu(menuId: string): MenuHandle | null {
292
326
  return createMenuHandle(stored.id);
293
327
  }
294
328
 
295
- /** Returns a menu by id, creating it if needed. */
296
- export function getOrCreateMenu(menuId: string, label: string): MenuHandle | null {
297
- const stored = ensureStoredMenu(menuId, label);
329
+ /** Returns a menu by id, creating it if needed.
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.
333
+ */
334
+ export function getOrCreateMenu(
335
+ menuId: string,
336
+ label: string,
337
+ options: MenubarMenuOptions & { surface: MenuSurface },
338
+ ): MenuHandle | null {
339
+ const { surface, ...restOptions } = options;
340
+ const stored = ensureStoredMenu(menuId, label, restOptions, surface);
298
341
  return createMenuHandle(stored.id);
299
342
  }
300
343
 
301
- /** Creates a menu at a specific position. */
302
- export function createMenu(menuId: string, label: string, options?: MenubarMenuOptions): MenuHandle | null {
303
- const stored = ensureStoredMenu(menuId, label, options);
304
- return createMenuHandle(stored.id);
305
- }
344
+ /** Creates a menu at a specific position (alias for getOrCreateMenu). */
345
+ export const createMenu = getOrCreateMenu;
306
346
 
307
- /** Adds one item to a menu. */
347
+ /** Adds one item to a menu, silently no-op if the menu does not exist. */
308
348
  export function addMenuItem(menuId: string, item: MenubarItem): void {
309
- createMenuHandle(menuId).addItem(item);
349
+ const handle = createMenuHandle(menuId);
350
+ if (!getStoredMenu(menuId)) return;
351
+ handle.addItem(item);
310
352
  }
311
353
 
312
- /** Adds one separator to a menu. */
313
- export function addMenuSeparator(menuId: string, beforeAction?: string): void {
314
- 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);
315
358
  }
316
359
 
317
360
  /** Removes one menu from the registry. */
@@ -352,21 +395,20 @@ export function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T> {
352
395
  /** Emits a notification when the host helper is available. */
353
396
  export function notify(msg: string): void {
354
397
  const openvcs = getOpenVCS();
355
- openvcs?.notify(msg);
398
+ if (!openvcs) {
399
+ throw new Error('OpenVCS host is not available in this runtime');
400
+ }
401
+ openvcs.notify(msg);
356
402
  }
357
403
 
358
404
  /** Builds SDK delegates from the local menu/action registries. */
359
405
  export function createMenuPluginDelegates(): PluginDelegates<PluginRuntimeContext> {
360
406
  return {
361
407
  async 'plugin.get_menus'(): Promise<PluginMenuDefinition[]> {
362
- return serializeMenus() as unknown as PluginMenuDefinition[];
408
+ return serializeMenus();
363
409
  },
364
410
  async 'plugin.handle_action'(params: PluginHandleActionParams): Promise<unknown> {
365
- const actionId = String(params?.action_id || params?.id || '').trim();
366
- if (actionId) {
367
- return await runRegisteredAction(actionId, params?.payload);
368
- }
369
- return null;
411
+ return await runRegisteredAction(requireActionId(params), params?.payload);
370
412
  },
371
413
  };
372
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) {
@@ -303,6 +303,14 @@ export abstract class VcsDelegateBase<
303
303
  return this.unimplemented('stagePatch');
304
304
  }
305
305
 
306
+ /** Handles `vcs.stage_paths`. */
307
+ stagePaths(
308
+ _params: VcsTypes.VcsStagePathsParams,
309
+ _context: TContext,
310
+ ): VcsHandlerResult<null> {
311
+ return this.unimplemented('stagePaths');
312
+ }
313
+
306
314
  /** Handles `vcs.discard_paths`. */
307
315
  discardPaths(
308
316
  _params: VcsTypes.VcsDiscardPathsParams,
@@ -53,6 +53,7 @@ export type VcsDelegateBindings<TContext> = {
53
53
  VcsTypes.VcsDelegates<TContext>['vcs.write_merge_result']
54
54
  >;
55
55
  stagePatch: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_patch']>;
56
+ stagePaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.stage_paths']>;
56
57
  discardPaths: NonNullable<VcsTypes.VcsDelegates<TContext>['vcs.discard_paths']>;
57
58
  applyReversePatch: NonNullable<
58
59
  VcsTypes.VcsDelegates<TContext>['vcs.apply_reverse_patch']
@@ -122,6 +123,7 @@ export const VCS_DELEGATE_METHOD_MAPPINGS = {
122
123
  checkoutConflictSide: 'vcs.checkout_conflict_side',
123
124
  writeMergeResult: 'vcs.write_merge_result',
124
125
  stagePatch: 'vcs.stage_patch',
126
+ stagePaths: 'vcs.stage_paths',
125
127
  discardPaths: 'vcs.discard_paths',
126
128
  applyReversePatch: 'vcs.apply_reverse_patch',
127
129
  deleteBranch: 'vcs.delete_branch',
@@ -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. */
@@ -22,17 +22,52 @@ export interface PluginInitializeResult {
22
22
  /** Describes one optional override returned by a custom initialize handler. */
23
23
  export type PluginInitializeOverride = Partial<PluginInitializeResult>;
24
24
 
25
+ /** Describes the UI surface targeted by one plugin menu definition. */
26
+ export type PluginMenuSurface = 'menubar' | 'settings';
27
+
28
+ /** Describes one static text element contributed to a plugin menu. */
29
+ export interface PluginMenuTextElement {
30
+ /** Stores the serialized element kind. */
31
+ type: 'text';
32
+ /** Stores the stable plugin-local element id. */
33
+ id: string;
34
+ /** Stores the rendered text content. */
35
+ content: string;
36
+ }
37
+
38
+ /** Describes one button element contributed to a plugin menu. */
39
+ export interface PluginMenuButtonElement {
40
+ /** Stores the serialized element kind. */
41
+ type: 'button';
42
+ /** Stores the stable action id dispatched back to the plugin. */
43
+ id: string;
44
+ /** Stores the user-visible button label. */
45
+ label: string;
46
+ }
47
+
48
+ /** Describes one renderable plugin menu element. */
49
+ export type PluginMenuElement = PluginMenuTextElement | PluginMenuButtonElement;
50
+
25
51
  /** Describes one plugin-contributed menu definition. */
26
- export type PluginMenuDefinition = Record<string, unknown>;
52
+ export interface PluginMenuDefinition {
53
+ /** Stores the stable plugin-local menu id. */
54
+ id: string;
55
+ /** Stores the user-visible menu label. */
56
+ label: string;
57
+ /** Stores the display ordering hint assigned by the runtime. */
58
+ order: number;
59
+ /** Stores the target UI surface for the menu. */
60
+ surface: PluginMenuSurface;
61
+ /** Stores the renderable elements contributed under the menu. */
62
+ elements: PluginMenuElement[];
63
+ }
27
64
 
28
65
  /** Describes one plugin settings value payload. */
29
66
  export type PluginSettingsValue = Record<string, unknown>;
30
67
 
31
68
  /** Describes the params shape for plugin action handling. */
32
69
  export interface PluginHandleActionParams extends RequestParams {
33
- /** Stores the action id selected by the user when provided by the transport. */
34
- id?: string;
35
- /** Stores the action id selected by the user. */
70
+ /** Stores the action id selected by the user. Runtime handlers require a non-empty string. */
36
71
  action_id?: string;
37
72
  /** Stores an optional payload supplied by the triggering UI. */
38
73
  payload?: Record<string, unknown>;
@@ -298,6 +298,12 @@ export interface VcsStagePatchParams extends VcsSessionParams {
298
298
  patch: string;
299
299
  }
300
300
 
301
+ /** Describes params for staging repository-relative paths into the index. */
302
+ export interface VcsStagePathsParams extends VcsSessionParams {
303
+ /** Stores the paths to stage. */
304
+ paths?: string[];
305
+ }
306
+
301
307
  /** Describes params for discarding path changes. */
302
308
  export interface VcsDiscardPathsParams extends VcsSessionParams {
303
309
  /** Stores the paths to discard. */
@@ -518,6 +524,8 @@ export interface VcsDelegates<TContext = unknown> {
518
524
  >;
519
525
  /** Handles `vcs.stage_patch`. */
520
526
  'vcs.stage_patch'?: RpcMethodHandler<VcsStagePatchParams, null, TContext>;
527
+ /** Handles `vcs.stage_paths`. */
528
+ 'vcs.stage_paths'?: RpcMethodHandler<VcsStagePathsParams, null, TContext>;
521
529
  /** Handles `vcs.discard_paths`. */
522
530
  'vcs.discard_paths'?: RpcMethodHandler<VcsDiscardPathsParams, null, TContext>;
523
531
  /** Handles `vcs.apply_reverse_patch`. */
@@ -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
+ });