@qping/plugin-bus 0.1.0 → 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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Action registry types.
3
+ *
4
+ * A plugin registers its full action set once at startup via `plugin.actions([...])`. Search
5
+ * items and the detail page only reference actions by id; the parameters an action needs are
6
+ * produced inside its own `execute`, never carried on the item.
7
+ */
8
+ import type { PluginHostEnv } from "./hostEnv.ts";
9
+ /** An i18n message key plus the English fallback the host uses when the key is missing. */
10
+ export type LocalizedText = {
11
+ key: string;
12
+ defaultValue: string;
13
+ };
14
+ /** Keys the host deliberately permits for action shortcuts. */
15
+ export declare const Key: {
16
+ readonly Enter: "Enter";
17
+ readonly Tab: "Tab";
18
+ readonly Space: "Space";
19
+ readonly Delete: "Delete";
20
+ readonly Backspace: "Backspace";
21
+ readonly Escape: "Escape";
22
+ readonly Left: "Left";
23
+ readonly Right: "Right";
24
+ readonly Up: "Up";
25
+ readonly Down: "Down";
26
+ readonly A: "A";
27
+ readonly B: "B";
28
+ readonly C: "C";
29
+ readonly D: "D";
30
+ readonly E: "E";
31
+ readonly F: "F";
32
+ readonly G: "G";
33
+ readonly H: "H";
34
+ readonly I: "I";
35
+ readonly J: "J";
36
+ readonly K: "K";
37
+ readonly L: "L";
38
+ readonly M: "M";
39
+ readonly N: "N";
40
+ readonly O: "O";
41
+ readonly P: "P";
42
+ readonly Q: "Q";
43
+ readonly R: "R";
44
+ readonly S: "S";
45
+ readonly T: "T";
46
+ readonly U: "U";
47
+ readonly V: "V";
48
+ readonly W: "W";
49
+ readonly X: "X";
50
+ readonly Y: "Y";
51
+ readonly Z: "Z";
52
+ readonly D0: "D0";
53
+ readonly D1: "D1";
54
+ readonly D2: "D2";
55
+ readonly D3: "D3";
56
+ readonly D4: "D4";
57
+ readonly D5: "D5";
58
+ readonly D6: "D6";
59
+ readonly D7: "D7";
60
+ readonly D8: "D8";
61
+ readonly D9: "D9";
62
+ readonly F1: "F1";
63
+ readonly F2: "F2";
64
+ readonly F3: "F3";
65
+ readonly F4: "F4";
66
+ readonly F5: "F5";
67
+ readonly F6: "F6";
68
+ readonly F7: "F7";
69
+ readonly F8: "F8";
70
+ readonly F9: "F9";
71
+ readonly F10: "F10";
72
+ readonly F11: "F11";
73
+ readonly F12: "F12";
74
+ };
75
+ export type HotkeyKey = (typeof Key)[keyof typeof Key];
76
+ /** Permitted modifier combinations, e.g. `Modifiers.ControlShift`. */
77
+ export declare const Modifiers: {
78
+ readonly None: 0;
79
+ readonly Control: 1;
80
+ readonly Alt: 2;
81
+ readonly ControlAlt: 3;
82
+ readonly Shift: 4;
83
+ readonly ControlShift: 5;
84
+ readonly AltShift: 6;
85
+ readonly ControlAltShift: 7;
86
+ };
87
+ export type HotkeyModifiers = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
88
+ export type Hotkey = {
89
+ key: HotkeyKey;
90
+ modifiers?: HotkeyModifiers;
91
+ };
92
+ /** Actions the host itself can carry out. Anything else belongs in `execute`. */
93
+ export declare const HostAction: {
94
+ readonly Copy: "copy";
95
+ readonly CopyAndPaste: "copyAndPaste";
96
+ readonly AddClipboardHistory: "addClipboardHistory";
97
+ readonly Execute: "execute";
98
+ readonly OpenInExplorer: "openInExplorer";
99
+ readonly OpenInBrowser: "openInBrowser";
100
+ readonly OpenPlugin: "openPlugin";
101
+ readonly Run: "run";
102
+ readonly Kill: "kill";
103
+ };
104
+ export type HostActionKind = (typeof HostAction)[keyof typeof HostAction];
105
+ /** Command spec for {@link HostAction.Run}. */
106
+ export type RunSpec = {
107
+ name?: string;
108
+ command: string;
109
+ args?: string;
110
+ workingDirectory?: string;
111
+ runAsAdmin?: boolean;
112
+ isBashScript?: boolean;
113
+ scripts?: string | string[];
114
+ };
115
+ /**
116
+ * What the host should do, with the parameters that kind actually needs. The discriminated union
117
+ * is the point: `{ kind: HostAction.Copy, path }` does not compile.
118
+ */
119
+ export type HostActionRequest = {
120
+ kind: typeof HostAction.Copy;
121
+ text: string;
122
+ } | {
123
+ kind: typeof HostAction.CopyAndPaste;
124
+ text: string;
125
+ } | {
126
+ kind: typeof HostAction.AddClipboardHistory;
127
+ texts: string[];
128
+ } | {
129
+ kind: typeof HostAction.Execute;
130
+ path: string;
131
+ args?: string;
132
+ runAsAdmin?: boolean;
133
+ } | {
134
+ kind: typeof HostAction.OpenInExplorer;
135
+ path: string;
136
+ } | {
137
+ kind: typeof HostAction.OpenInBrowser;
138
+ url: string | string[];
139
+ } | {
140
+ kind: typeof HostAction.OpenPlugin;
141
+ pluginId: string;
142
+ } | {
143
+ kind: typeof HostAction.Run;
144
+ command: RunSpec;
145
+ } | {
146
+ kind: typeof HostAction.Kill;
147
+ pid: number;
148
+ };
149
+ /** Opens a web detail page. `page` defaults to the entry declared in plugin.json. */
150
+ export type DetailRequest = {
151
+ page?: string;
152
+ title?: string;
153
+ initialState?: unknown;
154
+ };
155
+ /**
156
+ * The result of running an action. Every field is optional and they combine: opening an IDE and
157
+ * closing the search window is `{ host: {...}, close: true }`.
158
+ */
159
+ export type ActionOutcome = {
160
+ /** Run a host-side action (clipboard, process launch, browser, ...). */
161
+ host?: HostActionRequest;
162
+ /** Hand off to the detail page; arrives as `host.event.detailAction` with this payload. */
163
+ web?: {
164
+ payload?: unknown;
165
+ };
166
+ /** Open a web detail page. */
167
+ detail?: DetailRequest;
168
+ /** Status bar text. */
169
+ message?: LocalizedText;
170
+ /** Close the search window afterwards. Defaults to false. */
171
+ close?: boolean;
172
+ /** Refresh the current search results afterwards. Defaults to false. */
173
+ refresh?: boolean;
174
+ };
175
+ /**
176
+ * What an action sees when it runs. `item` is the original object returned by `search()`,
177
+ * including fields the host never saw — the SDK keeps it so actions do not have to re-derive
178
+ * their data from the item id.
179
+ */
180
+ export type ActionContext<TItem = unknown> = PluginHostEnv & {
181
+ actionId: string;
182
+ itemId: string;
183
+ query: string;
184
+ item?: TItem;
185
+ };
186
+ /**
187
+ * One registered action. `hotkey` is optional: without it the first registered action gets Enter
188
+ * and the rest are click-only, matching search result items.
189
+ */
190
+ export type ActionDefinition<TItem = any> = {
191
+ id: string;
192
+ title: LocalizedText;
193
+ description?: LocalizedText;
194
+ hotkey?: Hotkey;
195
+ execute: (context: ActionContext<TItem>) => ActionOutcome | void | Promise<ActionOutcome | void>;
196
+ };
197
+ /** The registry shape sent to the host in the initialize response (no `execute`). */
198
+ export type ActionManifestEntry = {
199
+ id: string;
200
+ title: LocalizedText;
201
+ description?: LocalizedText;
202
+ hotkey?: Hotkey;
203
+ };
204
+ export declare function toActionManifest(definition: ActionDefinition): ActionManifestEntry;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * v3 Node SDK bootstrap entry. Reads the bootstrap line from stdin (pipePath\ttoken), connects to
3
+ * the named pipe, completes bus.handshake (presenting the token and receiving bound identity),
4
+ * and starts a HandlerRouter stamped with that identity.
5
+ *
6
+ * This mirrors the C# NodeProcessController's spawn contract: the host writes one line to the
7
+ * Node process's stdin — "<pipePath>\t<token>" — then waits for the Node side to connect the pipe
8
+ * and complete handshake before promoting the session to Ready.
9
+ */
10
+ import { NodeTransport } from "./transport.ts";
11
+ import { HandlerRouter } from "./router.ts";
12
+ export interface PluginHandlers {
13
+ [route: string]: (payload: any, context: {
14
+ sessionId: string;
15
+ }) => Promise<any> | any;
16
+ }
17
+ export interface PluginRuntime {
18
+ transport: NodeTransport;
19
+ router: HandlerRouter;
20
+ close(): Promise<void>;
21
+ }
22
+ /**
23
+ * Connects to the host pipe (reading the bootstrap line from stdin), completes handshake, and
24
+ * returns a runtime whose router dispatches inbound plugin.call.* requests to the given handlers.
25
+ */
26
+ export declare function runPlugin(handlers: PluginHandlers): Promise<PluginRuntime>;
27
+ /**
28
+ * Sends bus.handshake with the bootstrap token and waits for the host response that binds
29
+ * plugin/entry/session/endpoint identity. Rejects on HandshakeFailed / ProtocolMismatch / timeout.
30
+ */
31
+ export declare function completeHandshake(transport: NodeTransport, token: string, timeoutMs?: number): Promise<{
32
+ pluginId: string;
33
+ entryId: string;
34
+ sessionId: string;
35
+ endpointId: string;
36
+ }>;
@@ -110,12 +110,69 @@ var init_framing = __esm({
110
110
 
111
111
  // src/bootstrap.ts
112
112
  import readline from "node:readline/promises";
113
+ import { randomBytes as randomBytes2 } from "node:crypto";
113
114
 
114
115
  // src/transport.ts
115
116
  init_framing();
116
117
  import { connect as netConnect } from "node:net";
117
118
 
118
119
  // src/protocol.ts
120
+ var MessageKind = {
121
+ Request: "request",
122
+ Response: "response",
123
+ Event: "event"
124
+ };
125
+ var ErrorCode = {
126
+ ProtocolMismatch: "ProtocolMismatch",
127
+ HandshakeFailed: "HandshakeFailed",
128
+ CapabilityNotDeclared: "CapabilityNotDeclared",
129
+ CapabilityDenied: "CapabilityDenied",
130
+ InvalidPayload: "InvalidPayload",
131
+ MessageTooLarge: "MessageTooLarge",
132
+ RouteNotFound: "RouteNotFound",
133
+ RequestTimeout: "RequestTimeout",
134
+ TooManyRequests: "TooManyRequests",
135
+ TransportDisconnected: "TransportDisconnected",
136
+ PluginUnavailable: "PluginUnavailable",
137
+ InternalError: "InternalError",
138
+ Cancelled: "Cancelled",
139
+ RateLimited: "RateLimited"
140
+ };
141
+ var ProtocolVersion = "3.0";
142
+ var EndpointIds = {
143
+ NodeMain: "node-main",
144
+ Host: "host"
145
+ };
146
+ var Routes = {
147
+ Bus: {
148
+ Handshake: "bus.handshake",
149
+ Ping: "bus.ping",
150
+ Cancel: "bus.cancel",
151
+ Subscribe: "bus.subscribe",
152
+ Unsubscribe: "bus.unsubscribe"
153
+ },
154
+ Prefix: {
155
+ PluginCall: "plugin.call.",
156
+ HostCall: "host.call.",
157
+ PluginEvent: "plugin.event.",
158
+ HostEvent: "host.event.",
159
+ Diagnostics: "diagnostics."
160
+ },
161
+ PluginCall: {
162
+ Initialize: "plugin.call.initialize",
163
+ Search: "plugin.call.search",
164
+ InvokeAction: "plugin.call.invokeAction"
165
+ },
166
+ HostEvent: {
167
+ Initialize: "host.event.initialize",
168
+ Search: "host.event.search",
169
+ Key: "host.event.key",
170
+ DetailAction: "host.event.detailAction",
171
+ LanguageChanged: "host.event.languageChanged",
172
+ ThemeChanged: "host.event.themeChanged",
173
+ InputActionCaptured: "host.event.inputActionCaptured"
174
+ }
175
+ };
119
176
  function canonicalStringify(value) {
120
177
  return JSON.stringify(stripNulls(value));
121
178
  }
@@ -141,6 +198,9 @@ var NodeTransport = class {
141
198
  closed = false;
142
199
  onMessage(handler) {
143
200
  this.messageHandlers.add(handler);
201
+ return () => {
202
+ this.messageHandlers.delete(handler);
203
+ };
144
204
  }
145
205
  onDisconnect(handler) {
146
206
  this.disconnectHandlers.add(handler);
@@ -207,14 +267,37 @@ var NodeTransport = class {
207
267
  };
208
268
 
209
269
  // src/router.ts
270
+ import { AsyncLocalStorage } from "node:async_hooks";
210
271
  import { randomBytes } from "node:crypto";
272
+ var requestScope = new AsyncLocalStorage();
273
+ var DefaultHostCallTimeoutMs = 3e4;
274
+ function remainingTimeoutMs() {
275
+ const scope = requestScope.getStore();
276
+ if (!scope || scope.deadlineMs == null) {
277
+ return void 0;
278
+ }
279
+ return scope.deadlineMs - Date.now();
280
+ }
281
+ function resolveHostCallTimeoutMs(explicit) {
282
+ const remaining = remainingTimeoutMs();
283
+ if (explicit != null) {
284
+ return remaining == null ? explicit : Math.min(explicit, remaining);
285
+ }
286
+ return remaining ?? DefaultHostCallTimeoutMs;
287
+ }
288
+ function deadlineFromTimeoutMs(timeoutMs) {
289
+ if (typeof timeoutMs !== "number" || timeoutMs <= 0) {
290
+ return null;
291
+ }
292
+ return Date.now() + timeoutMs;
293
+ }
211
294
  var HandlerRouter = class {
212
295
  handlers = /* @__PURE__ */ new Map();
213
296
  pendingHostCalls = /* @__PURE__ */ new Map();
214
297
  pluginId = "p";
215
298
  entryId = "e";
216
299
  sessionId = "s";
217
- endpointId = "node-main";
300
+ endpointId = EndpointIds.NodeMain;
218
301
  /** Injected transport send fn; tests can override `router.send` directly. */
219
302
  send;
220
303
  constructor(deps) {
@@ -232,43 +315,51 @@ var HandlerRouter = class {
232
315
  }
233
316
  /** Dispatches an inbound request/response. Returns once handled. */
234
317
  async dispatch(env) {
235
- if (env.kind === "response") {
318
+ if (env.kind === MessageKind.Response) {
236
319
  this.handleHostResponse(env);
237
320
  return;
238
321
  }
239
- if (env.kind !== "request") return;
240
- if (env.route === "bus.ping") {
322
+ if (env.kind !== MessageKind.Request) return;
323
+ if (env.route === Routes.Bus.Ping) {
241
324
  this.send(this.responseFor(env, { ok: true }));
242
325
  return;
243
326
  }
244
327
  const handler = this.handlers.get(env.route);
245
328
  if (!handler) {
246
- this.send(this.errorResponseFor(env, "RouteNotFound", `route '${env.route}' has no handler`));
329
+ this.send(this.errorResponseFor(env, ErrorCode.RouteNotFound, `route '${env.route}' has no handler`));
247
330
  return;
248
331
  }
332
+ const deadlineMs = deadlineFromTimeoutMs(env.timeoutMs);
249
333
  try {
250
- const result = await handler(env.payload);
334
+ const result = await requestScope.run(
335
+ { deadlineMs },
336
+ () => handler(env.payload, { sessionId: env.sessionId })
337
+ );
251
338
  this.send(this.responseFor(env, result ?? {}));
252
339
  } catch (err) {
253
340
  const message = err instanceof Error ? err.message : String(err);
254
- this.send(this.errorResponseFor(env, "InternalError", message));
341
+ this.send(this.errorResponseFor(env, ErrorCode.InternalError, message));
255
342
  }
256
343
  }
257
344
  /** Calls a host.call.* capability and resolves with the response payload. */
258
- callHost(route, payload, timeoutMs = 3e4) {
345
+ callHost(route, payload, timeoutMs) {
346
+ const effectiveTimeoutMs = resolveHostCallTimeoutMs(timeoutMs);
347
+ if (effectiveTimeoutMs <= 0) {
348
+ return Promise.reject(new Error(`host call ${route} timed out (no time remaining)`));
349
+ }
259
350
  return new Promise((resolve, reject) => {
260
351
  const id = randomBytesHex();
261
352
  const req = {
262
- version: "3.0",
353
+ version: ProtocolVersion,
263
354
  id,
264
355
  traceId: id,
265
356
  sessionId: this.sessionId,
266
357
  pluginId: this.pluginId,
267
358
  entryId: this.entryId,
268
359
  endpointId: this.endpointId,
269
- kind: "request",
360
+ kind: MessageKind.Request,
270
361
  route,
271
- timeoutMs,
362
+ timeoutMs: effectiveTimeoutMs,
272
363
  payload
273
364
  };
274
365
  const pending = { resolve, reject, route };
@@ -276,9 +367,9 @@ var HandlerRouter = class {
276
367
  const timer = setTimeout(() => {
277
368
  if (this.pendingHostCalls.has(id)) {
278
369
  this.pendingHostCalls.delete(id);
279
- reject(new Error(`host call ${route} timed out after ${timeoutMs}ms`));
370
+ reject(new Error(`host call ${route} timed out after ${effectiveTimeoutMs}ms`));
280
371
  }
281
- }, timeoutMs);
372
+ }, effectiveTimeoutMs);
282
373
  const origResolve = pending.resolve;
283
374
  const origReject = pending.reject;
284
375
  pending.resolve = (v) => {
@@ -305,7 +396,7 @@ var HandlerRouter = class {
305
396
  }
306
397
  responseFor(req, payload) {
307
398
  return {
308
- version: "3.0",
399
+ version: ProtocolVersion,
309
400
  id: randomBytesHex(),
310
401
  correlationId: req.id,
311
402
  traceId: req.traceId,
@@ -313,7 +404,7 @@ var HandlerRouter = class {
313
404
  pluginId: req.pluginId,
314
405
  entryId: req.entryId,
315
406
  endpointId: this.endpointId,
316
- kind: "response",
407
+ kind: MessageKind.Response,
317
408
  route: req.route,
318
409
  payload
319
410
  };
@@ -331,12 +422,29 @@ function randomBytesHex() {
331
422
  }
332
423
 
333
424
  // src/bootstrap.ts
425
+ var SUPPORTED_VERSIONS = [ProtocolVersion];
334
426
  async function runPlugin(handlers) {
335
427
  const { pipePath, token } = await readBootstrapLine();
336
428
  const transport = new NodeTransport();
337
429
  await transport.connect(pipePath);
430
+ const identity = await completeHandshake(transport, token);
338
431
  const router = new HandlerRouter({ send: (env) => transport.send(env) });
432
+ router.setIdentity(identity);
433
+ const HOST_LOST_MS = 15e3;
434
+ let lastPingAt = Date.now();
435
+ const watchdog = setInterval(() => {
436
+ if (Date.now() - lastPingAt > HOST_LOST_MS) {
437
+ clearInterval(watchdog);
438
+ process.exit(1);
439
+ }
440
+ }, 1e3);
441
+ watchdog.unref?.();
442
+ transport.onDisconnect(() => {
443
+ clearInterval(watchdog);
444
+ process.exit(1);
445
+ });
339
446
  transport.onMessage((env) => {
447
+ if (env.route === Routes.Bus.Ping) lastPingAt = Date.now();
340
448
  router.dispatch(env);
341
449
  });
342
450
  for (const [route, handler] of Object.entries(handlers)) {
@@ -345,7 +453,10 @@ async function runPlugin(handlers) {
345
453
  return {
346
454
  transport,
347
455
  router,
348
- close: () => transport.close()
456
+ close: async () => {
457
+ clearInterval(watchdog);
458
+ await transport.close();
459
+ }
349
460
  };
350
461
  }
351
462
  async function readBootstrapLine() {
@@ -364,6 +475,53 @@ async function readBootstrapLine() {
364
475
  rl.close();
365
476
  }
366
477
  }
478
+ async function completeHandshake(transport, token, timeoutMs = 1e4) {
479
+ const id = randomBytes2(16).toString("hex");
480
+ const req = {
481
+ version: ProtocolVersion,
482
+ id,
483
+ traceId: id,
484
+ sessionId: "",
485
+ pluginId: "",
486
+ entryId: "",
487
+ endpointId: EndpointIds.NodeMain,
488
+ kind: MessageKind.Request,
489
+ route: Routes.Bus.Handshake,
490
+ timeoutMs,
491
+ payload: {
492
+ version: ProtocolVersion,
493
+ supportedVersions: SUPPORTED_VERSIONS,
494
+ token
495
+ }
496
+ };
497
+ return new Promise((resolve, reject) => {
498
+ const timer = setTimeout(() => {
499
+ unsubscribe();
500
+ reject(new Error(`bus.handshake timed out after ${timeoutMs}ms`));
501
+ }, timeoutMs);
502
+ const unsubscribe = transport.onMessage((env) => {
503
+ if (env.kind !== MessageKind.Response || env.correlationId !== id) return;
504
+ clearTimeout(timer);
505
+ unsubscribe();
506
+ if (env.error) {
507
+ reject(new Error(`${env.error.code}: ${env.error.message}`));
508
+ return;
509
+ }
510
+ const p = env.payload ?? {};
511
+ const pluginId = String(p.pluginId ?? "");
512
+ const entryId = String(p.entryId ?? "");
513
+ const sessionId = String(p.sessionId ?? "");
514
+ const endpointId = String(p.endpointId ?? EndpointIds.NodeMain);
515
+ if (!pluginId || !entryId || !sessionId) {
516
+ reject(new Error("bus.handshake success response missing bound identity"));
517
+ return;
518
+ }
519
+ resolve({ pluginId, entryId, sessionId, endpointId });
520
+ });
521
+ transport.send(req);
522
+ });
523
+ }
367
524
  export {
525
+ completeHandshake,
368
526
  runPlugin
369
527
  };
package/dist/dev.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Notifies a running MyTools instance that a development plugin was rebuilt.
3
+ * The request is retried once after a short delay when the pipe is not yet available.
4
+ */
5
+ export declare function requestDevelopmentPluginRefresh(pluginId: string): Promise<void>;
package/dist/dev.mjs ADDED
@@ -0,0 +1,80 @@
1
+ // src/developmentRefresh.ts
2
+ import { createConnection } from "node:net";
3
+ var DEVELOPMENT_REFRESH_PIPE_PATH = "\\\\.\\pipe\\MyTools.DevelopmentPlugins.Refresh";
4
+ var DEFAULT_RETRY_DELAY_MS = 250;
5
+ var DEFAULT_MAX_ATTEMPTS = 2;
6
+ var DEFAULT_REQUEST_TIMEOUT_MS = 2e3;
7
+ var VALID_PLUGIN_ID = /^[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/;
8
+ async function requestDevelopmentPluginRefreshWithOptions(pluginId, options = {}) {
9
+ if (!VALID_PLUGIN_ID.test(pluginId)) {
10
+ throw new TypeError(`Invalid MyTools plugin ID: ${pluginId}`);
11
+ }
12
+ const pipePath = options.pipePath ?? DEVELOPMENT_REFRESH_PIPE_PATH;
13
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
14
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
15
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
16
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
17
+ throw new RangeError("maxAttempts must be a positive integer");
18
+ }
19
+ if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
20
+ throw new RangeError("retryDelayMs must be a non-negative number");
21
+ }
22
+ if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
23
+ throw new RangeError("requestTimeoutMs must be a positive number");
24
+ }
25
+ let lastError;
26
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
27
+ try {
28
+ await sendRefreshRequest(pipePath, pluginId, requestTimeoutMs);
29
+ return;
30
+ } catch (error) {
31
+ lastError = error;
32
+ if (attempt < maxAttempts) {
33
+ await delay(retryDelayMs);
34
+ }
35
+ }
36
+ }
37
+ throw new Error(
38
+ `Failed to request MyTools refresh for ${pluginId} after ${maxAttempts} attempts`,
39
+ { cause: lastError }
40
+ );
41
+ }
42
+ function sendRefreshRequest(pipePath, pluginId, requestTimeoutMs) {
43
+ return new Promise((resolve, reject) => {
44
+ const socket = createConnection(pipePath);
45
+ let settled = false;
46
+ let timeout;
47
+ const settle = (error) => {
48
+ if (settled) return;
49
+ settled = true;
50
+ if (timeout) clearTimeout(timeout);
51
+ if (error) reject(error);
52
+ else resolve();
53
+ };
54
+ socket.once("connect", () => {
55
+ socket.end(`${pluginId}
56
+ `, () => {
57
+ settle();
58
+ });
59
+ });
60
+ socket.once("error", (error) => {
61
+ socket.destroy();
62
+ settle(error);
63
+ });
64
+ timeout = setTimeout(() => {
65
+ socket.destroy();
66
+ settle(new Error(`Development refresh request timed out after ${requestTimeoutMs}ms`));
67
+ }, requestTimeoutMs);
68
+ });
69
+ }
70
+ function delay(milliseconds) {
71
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
72
+ }
73
+
74
+ // src/dev.ts
75
+ function requestDevelopmentPluginRefresh(pluginId) {
76
+ return requestDevelopmentPluginRefreshWithOptions(pluginId);
77
+ }
78
+ export {
79
+ requestDevelopmentPluginRefresh
80
+ };
@@ -0,0 +1,9 @@
1
+ export declare const DEVELOPMENT_REFRESH_PIPE_PATH = "\\\\.\\pipe\\MyTools.DevelopmentPlugins.Refresh";
2
+ export type DevelopmentRefreshRequestOptions = {
3
+ pipePath?: string;
4
+ retryDelayMs?: number;
5
+ maxAttempts?: number;
6
+ requestTimeoutMs?: number;
7
+ };
8
+ /** Internal implementation with injectable timing and endpoint for protocol tests. */
9
+ export declare function requestDevelopmentPluginRefreshWithOptions(pluginId: string, options?: DevelopmentRefreshRequestOptions): Promise<void>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Length-prefixed framing for the v3 named-pipe transport, mirroring the C# FrameCodec/FrameDecoder.
3
+ * Wire format: [4-byte little-endian unsigned length][UTF-8 JSON payload].
4
+ *
5
+ * The incremental decoder handles fragmented, sticky and truncated streams, and rejects an oversize
6
+ * length prefix as fatal *before* allocating the payload buffer (so a malicious/buggy peer cannot
7
+ * force a huge allocation). After a fatal error the decoder stays dead.
8
+ */
9
+ export declare const MAX_FRAME_BYTES: number;
10
+ export declare const PREFIX_BYTES = 4;
11
+ /** Encodes a raw payload buffer into a length-prefixed frame. */
12
+ export declare function encodeFrame(payload: Buffer): Buffer;
13
+ /** Encodes a UTF-8 JSON string into a length-prefixed frame. */
14
+ export declare function encodeFrameString(json: string): Buffer;
15
+ export interface FrameFeedResult {
16
+ hasFrame: boolean;
17
+ payload: Buffer;
18
+ isFatal: boolean;
19
+ }
20
+ /**
21
+ * Incremental length-prefixed frame decoder. Feed byte chunks (fragmented/sticky/partial) and get
22
+ * back one complete payload at a time. Leftover bytes from a chunk that contained more than one
23
+ * frame are buffered internally and surfaced by subsequent feeds (including an empty buffer).
24
+ */
25
+ export declare class FrameDecoder {
26
+ private prefixBuf;
27
+ private prefixFilled;
28
+ private payload;
29
+ private payloadFilled;
30
+ private payloadLength;
31
+ private fatal;
32
+ private pending;
33
+ feed(chunk: Buffer): FrameFeedResult;
34
+ private bufferLeftover;
35
+ private reset;
36
+ }
@@ -0,0 +1,9 @@
1
+ /** Environment the host stamps onto every plugin.call payload. */
2
+ export type PluginTheme = "light" | "dark";
3
+ export type PluginHostEnv = {
4
+ locale: string;
5
+ fallbackLocale: string;
6
+ theme: PluginTheme;
7
+ };
8
+ export declare function asTheme(value: unknown): PluginTheme;
9
+ export declare function asHostEnv(payload: any): PluginHostEnv;
@@ -0,0 +1,2 @@
1
+ /** Render a host-formatted shortcut as the same sequence of keycaps used by the desktop action bar. */
2
+ export declare function renderHotkeyKeycaps(element: HTMLElement, hotkey: string): void;