pi-superwhisper-paste 0.1.4 → 0.1.5

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  This project follows semantic versioning.
6
6
 
7
+ ## [0.1.5] - 2026-06-03
8
+
9
+ ### Fixed
10
+
11
+ - Truncate clipboard text in PowerShell before writing stdout so huge clipboards no longer exceed Node `maxBuffer` on extension load.
12
+ - Treat clipboard read failures (including `maxBuffer`) as empty reads so `session_start` never surfaces as an extension error.
13
+
7
14
  ## [0.1.4] - 2026-06-03
8
15
 
9
16
  ### Changed
@@ -59,21 +59,25 @@ const state: BridgeState = {
59
59
  terminalFocused: true,
60
60
  };
61
61
 
62
+ /** Resolves bridge on/off from `PI_SUPERWHISPER_PASTE` (default on). */
62
63
  function defaultMode(): BridgeMode {
63
64
  const raw = (process.env.PI_SUPERWHISPER_PASTE ?? "on").trim().toLowerCase();
64
65
  return ["0", "false", "no", "off"].includes(raw) ? "off" : "on";
65
66
  }
66
67
 
68
+ /** Poll interval from env or default. */
67
69
  function intervalMs(): number {
68
70
  const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_INTERVAL_MS);
69
71
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_INTERVAL_MS;
70
72
  }
71
73
 
74
+ /** Max pasted characters from env or default. */
72
75
  function maxChars(): number {
73
76
  const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_MAX_CHARS);
74
77
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CHARS;
75
78
  }
76
79
 
80
+ /** Status bar label for the paste bridge. */
77
81
  function statusText(): string | undefined {
78
82
  if (state.mode === "off") return undefined;
79
83
  const focusText = state.terminalFocused ? "active" : "standby";
@@ -82,10 +86,12 @@ function statusText(): string | undefined {
82
86
  : `SW paste: ${focusText}`;
83
87
  }
84
88
 
89
+ /** True when `ctx`/`generation` still match the active Pi session. */
85
90
  function isCurrentSession(ctx: PiRuntimeContext, generation: number): boolean {
86
91
  return state.session?.generation === generation && state.session.ctx === ctx;
87
92
  }
88
93
 
94
+ /** Runs a UI callback only for the current session; swallows stale-ctx errors. */
89
95
  function safeWithCurrentUi(
90
96
  ctx: PiRuntimeContext,
91
97
  generation: number,
@@ -106,12 +112,14 @@ function safeWithCurrentUi(
106
112
  }
107
113
  }
108
114
 
115
+ /** Updates Pi status bar when session is current. */
109
116
  function setStatus(ctx: PiRuntimeContext, generation: number): boolean {
110
117
  return safeWithCurrentUi(ctx, generation, (ui) => {
111
118
  ui.setStatus(STATUS_KEY, statusText());
112
119
  });
113
120
  }
114
121
 
122
+ /** Shows a Pi notification when session is current. */
115
123
  function notify(
116
124
  ctx: PiRuntimeContext,
117
125
  generation: number,
@@ -123,12 +131,14 @@ function notify(
123
131
  });
124
132
  }
125
133
 
134
+ /** Pastes text into the editor when session is current. */
126
135
  function pasteToEditor(ctx: PiRuntimeContext, generation: number, text: string): boolean {
127
136
  return safeWithCurrentUi(ctx, generation, (ui) => {
128
137
  ui.pasteToEditor(text);
129
138
  });
130
139
  }
131
140
 
141
+ /** Reads editor text when session is current. */
132
142
  function getEditorText(ctx: PiRuntimeContext, generation: number): string | undefined {
133
143
  if (!isCurrentSession(ctx, generation)) return undefined;
134
144
 
@@ -141,6 +151,7 @@ function getEditorText(ctx: PiRuntimeContext, generation: number): string | unde
141
151
  }
142
152
  }
143
153
 
154
+ /** Records last paste and refreshes status after insert. */
144
155
  function markPasted(ctx: PiRuntimeContext, generation: number, text: string): void {
145
156
  if (!isCurrentSession(ctx, generation)) return;
146
157
  state.lastPasted = text;
@@ -149,31 +160,50 @@ function markPasted(ctx: PiRuntimeContext, generation: number, text: string): vo
149
160
  setStatus(ctx, generation);
150
161
  }
151
162
 
152
- async function readClipboardText(): Promise<string | undefined> {
153
- const script = [
163
+ /** Builds a PowerShell command that reads clipboard text truncated to `limit` characters. */
164
+ function clipboardPowerShellScript(limit: number): string {
165
+ return [
154
166
  "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
167
+ `$limit = ${limit}`,
155
168
  "$text = Get-Clipboard -Raw -Format Text -ErrorAction SilentlyContinue",
156
- "if ($null -ne $text) { [Console]::Out.Write($text) }",
169
+ "if ($null -ne $text) {",
170
+ " if ($text.Length -gt $limit) { $text = $text.Substring(0, $limit) }",
171
+ " [Console]::Out.Write($text)",
172
+ "}",
157
173
  ].join("; ");
174
+ }
158
175
 
159
- const result = await execFileAsync(
160
- "powershell.exe",
161
- ["-NoProfile", "-Command", script],
162
- {
163
- encoding: "utf8",
164
- maxBuffer: maxChars() * 4,
165
- timeout: 2500,
166
- windowsHide: true,
167
- },
168
- );
176
+ /** Reads clipboard text via PowerShell; returns undefined on failure or oversize output. */
177
+ async function readClipboardText(): Promise<string | undefined> {
178
+ const limit = maxChars();
169
179
 
170
- return typeof result.stdout === "string" ? result.stdout : undefined;
180
+ try {
181
+ const result = await execFileAsync(
182
+ "powershell.exe",
183
+ ["-NoProfile", "-Command", clipboardPowerShellScript(limit)],
184
+ {
185
+ encoding: "utf8",
186
+ maxBuffer: limit * 4 + 1024,
187
+ timeout: 2500,
188
+ windowsHide: true,
189
+ },
190
+ );
191
+
192
+ const stdout = typeof result.stdout === "string" ? result.stdout : undefined;
193
+ if (stdout === undefined) return undefined;
194
+ return stdout.length > limit ? stdout.slice(0, limit) : stdout;
195
+ } catch {
196
+ // Oversized clipboard, PowerShell failure, or maxBuffer must not break extension load.
197
+ return undefined;
198
+ }
171
199
  }
172
200
 
201
+ /** Temp file path for cross-tab active-instance claim. */
173
202
  function activeStatePath(): string {
174
203
  return join(tmpdir(), ACTIVE_STATE_FILE);
175
204
  }
176
205
 
206
+ /** Writes this Pi instance as the active tab for paste gating. */
177
207
  async function claimActiveTab(): Promise<void> {
178
208
  const claim = {
179
209
  instanceId: INSTANCE_ID,
@@ -188,6 +218,7 @@ async function claimActiveTab(): Promise<void> {
188
218
  }
189
219
  }
190
220
 
221
+ /** True when this instance owns the active-tab claim and has focus. */
191
222
  async function isActiveTab(): Promise<boolean> {
192
223
  if (!state.terminalFocused) return false;
193
224
 
@@ -201,6 +232,7 @@ async function isActiveTab(): Promise<boolean> {
201
232
  }
202
233
  }
203
234
 
235
+ /** Seeds `lastClipboard` without pasting (focus/tab activation). */
204
236
  async function refreshClipboardBaseline(
205
237
  ctx: PiRuntimeContext,
206
238
  generation: number,
@@ -210,6 +242,7 @@ async function refreshClipboardBaseline(
210
242
  if (text !== undefined) state.lastClipboard = text;
211
243
  }
212
244
 
245
+ /** Marks tab focused, refreshes clipboard baseline, claims active tab. */
213
246
  async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<void> {
214
247
  if (!isCurrentSession(ctx, generation) || state.activatingGeneration !== undefined) return;
215
248
 
@@ -227,6 +260,7 @@ async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<v
227
260
  }
228
261
  }
229
262
 
263
+ /** Claims active tab when user types in the terminal. */
230
264
  async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): Promise<void> {
231
265
  if (!isCurrentSession(ctx, generation)) return;
232
266
  state.terminalFocused = true;
@@ -235,6 +269,7 @@ async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): P
235
269
  setStatus(ctx, generation);
236
270
  }
237
271
 
272
+ /** Whether clipboard text should be auto-pasted into the editor. */
238
273
  function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): boolean {
239
274
  if (!text.trim()) return false;
240
275
  if (text.length > maxChars()) return false;
@@ -247,6 +282,7 @@ function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): b
247
282
  return true;
248
283
  }
249
284
 
285
+ /** Poll handler: paste new clipboard text when gates pass. */
250
286
  async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number): Promise<void> {
251
287
  if (!isCurrentSession(ctx, generation)) return;
252
288
  if (
@@ -280,6 +316,7 @@ async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number):
280
316
  }
281
317
  }
282
318
 
319
+ /** Enables terminal focus reporting and input hooks for this session. */
283
320
  function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number): void {
284
321
  if (!isCurrentSession(ctx, generation)) return;
285
322
  if (state.unsubscribeTerminalInput) return;
@@ -315,6 +352,7 @@ function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number):
315
352
  });
316
353
  }
317
354
 
355
+ /** Disables focus reporting and removes terminal input subscription. */
318
356
  function teardownTerminalFocusTracking(): void {
319
357
  try {
320
358
  state.unsubscribeTerminalInput?.();
@@ -330,6 +368,7 @@ function teardownTerminalFocusTracking(): void {
330
368
  }
331
369
  }
332
370
 
371
+ /** Clears poll interval and terminal hooks for the current session. */
333
372
  function teardownSessionResources(): void {
334
373
  if (state.interval) {
335
374
  clearInterval(state.interval);
@@ -340,6 +379,7 @@ function teardownSessionResources(): void {
340
379
  state.activatingGeneration = undefined;
341
380
  }
342
381
 
382
+ /** Starts a new session generation and tears down the previous one. */
343
383
  function beginSession(ctx: PiRuntimeContext): number {
344
384
  teardownSessionResources();
345
385
  const generation = state.generation + 1;
@@ -348,17 +388,20 @@ function beginSession(ctx: PiRuntimeContext): number {
348
388
  return generation;
349
389
  }
350
390
 
391
+ /** Returns current session generation, beginning one if needed. */
351
392
  function ensureCurrentSession(ctx: PiRuntimeContext): number {
352
393
  if (state.session?.ctx === ctx) return state.session.generation;
353
394
  return beginSession(ctx);
354
395
  }
355
396
 
397
+ /** Invalidates the current session and releases resources. */
356
398
  function endSession(): void {
357
399
  teardownSessionResources();
358
400
  state.generation += 1;
359
401
  state.session = undefined;
360
402
  }
361
403
 
404
+ /** Starts clipboard polling and focus tracking when mode is on. */
362
405
  function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
363
406
  if (!isCurrentSession(ctx, generation)) return;
364
407
 
@@ -371,12 +414,14 @@ function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
371
414
  }, intervalMs());
372
415
  }
373
416
 
417
+ /** Stops polling and clears bridge status. */
374
418
  function stopPolling(ctx: PiRuntimeContext, generation: number): void {
375
419
  teardownSessionResources();
376
420
  state.mode = "off";
377
421
  setStatus(ctx, generation);
378
422
  }
379
423
 
424
+ /** Baselines clipboard then starts polling (session_start path). */
380
425
  async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<void> {
381
426
  const text = await readClipboardText();
382
427
  if (!isCurrentSession(ctx, generation)) return;
@@ -384,6 +429,7 @@ async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<
384
429
  ensurePolling(ctx, generation);
385
430
  }
386
431
 
432
+ /** Enables bridge mode and notifies the user. */
387
433
  async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
388
434
  const text = await readClipboardText();
389
435
  if (!isCurrentSession(ctx, generation)) return;
@@ -393,12 +439,14 @@ async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
393
439
  notify(ctx, generation, "Superwhisper paste bridge: enabled", "info");
394
440
  }
395
441
 
442
+ /** Parses `/sw-paste:on` or `/sw-paste:off` from user input. */
396
443
  function parseControlCommand(text: string): BridgeMode | undefined {
397
444
  const match = text.trim().match(/^\/sw-paste:(on|off)$/i);
398
445
  if (!match) return undefined;
399
446
  return match[1].toLowerCase() as BridgeMode;
400
447
  }
401
448
 
449
+ /** Applies on/off control command for the current session. */
402
450
  async function runControlAction(
403
451
  action: BridgeMode,
404
452
  ctx: PiRuntimeContext,
@@ -413,6 +461,7 @@ async function runControlAction(
413
461
  notify(ctx, generation, "Superwhisper paste bridge: disabled", "info");
414
462
  }
415
463
 
464
+ /** Pi extension entry: Superwhisper clipboard bridge for Windows. */
416
465
  export default function superwhisperPaste(pi: ExtensionAPI) {
417
466
  pi.registerCommand("sw-paste:on", {
418
467
  description: "Enable the Superwhisper clipboard paste bridge",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-superwhisper-paste",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Superwhisper clipboard bridge for Pi on Windows.",
5
5
  "type": "module",
6
6
  "license": "MIT",