pi-superwhisper-paste 0.1.4 → 0.1.14

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,71 @@ 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.14] - 2026-06-05
8
+
9
+ ### Changed
10
+
11
+ - Re-add `Code` / `Cursor` host processes to the clipboard-owner denylist so editor-hosted clipboard updates are blocked again during debugging.
12
+
13
+ ## [0.1.13] - 2026-06-05
14
+
15
+ ### Fixed
16
+
17
+ - Remove `Code.exe` / `Cursor.exe` from the terminal-owner denylist so editor-hosted Superwhisper clipboard updates are not blocked just because Pi is running inside Code/Cursor.
18
+
19
+ ## [0.1.12] - 2026-06-05
20
+
21
+ ### Changed
22
+
23
+ - Add an explicit `v0.1.12` marker to the status text so stale package loads are visible during live debugging.
24
+
25
+ ## [0.1.11] - 2026-06-05
26
+
27
+ ### Fixed
28
+
29
+ - Broaden the terminal clipboard-owner denylist with `OpenConsole` / explicit `.exe` variants.
30
+ - Surface the last clipboard decision in the Pi status text (`blocked ...`, `... via ...`) so real-machine owner mismatches can be diagnosed quickly.
31
+
32
+ ## [0.1.10] - 2026-06-05
33
+
34
+ ### Fixed
35
+
36
+ - Fix the Windows PowerShell clipboard-owner probe script so the owner-aware bridge works in real Pi sessions instead of failing on multiline `Add-Type` syntax.
37
+
38
+ ## [0.1.9] - 2026-06-05
39
+
40
+ ### Fixed
41
+
42
+ - Replace the strict Superwhisper owner allowlist with a terminal-owner denylist so unknown Superwhisper owner names still auto-paste while terminal/CLI clipboard owners stay blocked.
43
+
44
+ ## [0.1.8] - 2026-06-05
45
+
46
+ ### Fixed
47
+
48
+ - Filter clipboard updates by clipboard-owner process so Pi auto-pastes only when the clipboard owner looks like Superwhisper.
49
+ - Keep local terminal copy suppression as a fallback for forwarded copy shortcuts while blocking mouse/right-click terminal copies via owner filtering.
50
+
51
+ ## [0.1.7] - 2026-06-05
52
+
53
+ ### Fixed
54
+
55
+ - Suppress auto-paste for a short window after local terminal copy shortcuts so copying inside the active CLI no longer pastes into Pi.
56
+ - Refresh the clipboard baseline after local copy intent so the bridge keeps waiting for the next Superwhisper clipboard update.
57
+
58
+ ## [0.1.6] - 2026-06-04
59
+
60
+ ### Changed
61
+
62
+ - Added `version:check` PR guard support: package script + `scripts/check-version-bump.mjs`.
63
+ - Added CI verification that publishable changes must bump `package.json` and update `CHANGELOG.md` in the same PR.
64
+
65
+ ### Fixed
66
+
67
+ - Truncate clipboard text in PowerShell before writing stdout so huge clipboards no longer exceed Node `maxBuffer` on extension load.
68
+ - Treat clipboard read failures (including `maxBuffer`) as empty reads so `session_start` never surfaces as an extension error.
69
+
70
+ ## [0.1.5] - 2026-06-03
71
+
7
72
  ## [0.1.4] - 2026-06-03
8
73
 
9
74
  ### Changed
package/README.md CHANGED
@@ -50,6 +50,8 @@ pi install git:github.com/eiei114/pi-superwhisper-paste
50
50
  - Default-on after the extension loads.
51
51
  - Slash commands: `/sw-paste:on` and `/sw-paste:off`.
52
52
  - Windows clipboard polling with active-tab gating.
53
+ - Brief copy-suppression window so local terminal copy actions do not get auto-pasted back into Pi.
54
+ - Clipboard-owner filtering so terminal/CLI-owned clipboard updates stay blocked by default.
53
55
 
54
56
  See [CHANGELOG.md](CHANGELOG.md) for version history.
55
57
 
package/docs/examples.md CHANGED
@@ -28,6 +28,8 @@ Run these commands inside Pi's input box:
28
28
  | `PI_SUPERWHISPER_PASTE` | `on` | Set to `off`, `0`, `false`, or `no` to disable by default. |
29
29
  | `PI_SUPERWHISPER_PASTE_INTERVAL_MS` | `800` | Clipboard polling interval. |
30
30
  | `PI_SUPERWHISPER_PASTE_MAX_CHARS` | `8000` | Maximum clipboard text length to paste. |
31
+ | `PI_SUPERWHISPER_PASTE_IGNORE_COPY_MS` | `1500` | Ignore clipboard changes briefly after local terminal copy shortcuts. |
32
+ | `PI_SUPERWHISPER_PASTE_OWNER_DENYLIST` | `windowsterminal,...` | Comma-separated clipboard owner process/path substrings blocked from auto-paste. |
31
33
 
32
34
  ## Expected workflow
33
35
 
@@ -40,3 +42,7 @@ Run these commands inside Pi's input box:
40
42
  When multiple Pi tabs are open, only the tab that most recently had terminal focus or keyboard input should paste the new clipboard text.
41
43
 
42
44
  The bridge does not wait for Pi's agent turn to become idle. If Pi is thinking, the active tab should still receive the next Superwhisper clipboard update.
45
+
46
+ When you copy text inside the active CLI, the bridge now suppresses auto-paste briefly and refreshes the clipboard baseline so the copied text is not inserted into Pi.
47
+
48
+ The bridge also inspects the Windows clipboard owner and blocks auto-paste when the owner looks like a terminal/CLI app.
@@ -6,19 +6,53 @@ import { join } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
 
8
8
  const execFileAsync = promisify(execFile);
9
+ const EXTENSION_VERSION = "0.1.14";
9
10
 
10
11
  const STATUS_KEY = "superwhisper-paste";
11
12
  const DEFAULT_INTERVAL_MS = 800;
12
13
  const DEFAULT_MAX_CHARS = 8000;
14
+ const DEFAULT_IGNORE_COPY_MS = 1500;
15
+ const BASELINE_REFRESH_DELAY_MS = 120;
16
+ const DEFAULT_OWNER_DENYLIST = [
17
+ "windowsterminal",
18
+ "windowsterminal.exe",
19
+ "windows terminal",
20
+ "openconsole",
21
+ "openconsole.exe",
22
+ "wezterm",
23
+ "alacritty",
24
+ "mintty",
25
+ "conhost",
26
+ "cmd.exe",
27
+ "code",
28
+ "code.exe",
29
+ "powershell",
30
+ "pwsh",
31
+ "bash",
32
+ "cursor",
33
+ "cursor.exe",
34
+ "wsl",
35
+ ];
13
36
  const ACTIVE_STATE_FILE = "pi-superwhisper-paste-active.json";
14
37
  const FOCUS_IN = "\x1b[I";
15
38
  const FOCUS_OUT = "\x1b[O";
16
39
  const ENABLE_FOCUS_REPORTING = "\x1b[?1004h";
17
40
  const DISABLE_FOCUS_REPORTING = "\x1b[?1004l";
18
41
  const INSTANCE_ID = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
42
+ const CTRL_C = "\x03";
43
+ const CTRL_INSERT = "\x1b[2;5~";
44
+ const CTRL_SHIFT_INSERT = "\x1b[2;6~";
45
+ const CTRL_SHIFT_C_CSI_U = "\x1b[99;6u";
46
+ const CTRL_SHIFT_UPPER_C_CSI_U = "\x1b[67;6u";
19
47
 
20
48
  type BridgeMode = "off" | "on";
21
49
 
50
+ type ClipboardSnapshot = {
51
+ text?: string;
52
+ ownerProcessName?: string;
53
+ ownerProcessPath?: string;
54
+ };
55
+
22
56
  type PiEditorUi = {
23
57
  setStatus(key: string, value: string | undefined): void;
24
58
  notify(message: string, level?: "info" | "warning" | "error" | "success"): void;
@@ -42,11 +76,13 @@ type SessionHandle = {
42
76
  type BridgeState = {
43
77
  mode: BridgeMode;
44
78
  interval?: NodeJS.Timeout;
79
+ baselineRefreshTimeout?: NodeJS.Timeout;
45
80
  generation: number;
46
81
  session?: SessionHandle;
47
82
  inFlightGeneration?: number;
48
83
  activatingGeneration?: number;
49
84
  terminalFocused: boolean;
85
+ suppressPasteUntil?: number;
50
86
  unsubscribeTerminalInput?: () => void;
51
87
  lastClipboard?: string;
52
88
  lastPasted?: string;
@@ -59,33 +95,70 @@ const state: BridgeState = {
59
95
  terminalFocused: true,
60
96
  };
61
97
 
98
+ /** Resolves bridge on/off from `PI_SUPERWHISPER_PASTE` (default on). */
62
99
  function defaultMode(): BridgeMode {
63
100
  const raw = (process.env.PI_SUPERWHISPER_PASTE ?? "on").trim().toLowerCase();
64
101
  return ["0", "false", "no", "off"].includes(raw) ? "off" : "on";
65
102
  }
66
103
 
104
+ /** Poll interval from env or default. */
67
105
  function intervalMs(): number {
68
106
  const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_INTERVAL_MS);
69
107
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_INTERVAL_MS;
70
108
  }
71
109
 
110
+ /** Max pasted characters from env or default. */
72
111
  function maxChars(): number {
73
112
  const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_MAX_CHARS);
74
113
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CHARS;
75
114
  }
76
115
 
116
+ /** Ignore clipboard changes briefly after local copy shortcuts. */
117
+ function ignoreCopyMs(): number {
118
+ const parsed = Number(process.env.PI_SUPERWHISPER_PASTE_IGNORE_COPY_MS);
119
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_IGNORE_COPY_MS;
120
+ }
121
+
122
+ /** Process-name/path substrings denied from triggering auto-paste. */
123
+ function ownerDenylist(): string[] {
124
+ const raw = process.env.PI_SUPERWHISPER_PASTE_OWNER_DENYLIST;
125
+ if (!raw) return DEFAULT_OWNER_DENYLIST;
126
+
127
+ const tokens = raw
128
+ .split(",")
129
+ .map((token) => token.trim().toLowerCase())
130
+ .filter(Boolean);
131
+
132
+ return tokens.length > 0 ? tokens : DEFAULT_OWNER_DENYLIST;
133
+ }
134
+
135
+ /** Status bar label for the paste bridge. */
77
136
  function statusText(): string | undefined {
78
137
  if (state.mode === "off") return undefined;
79
138
  const focusText = state.terminalFocused ? "active" : "standby";
80
139
  return state.lastPasteSummary
81
- ? `SW paste: ${focusText} (${state.lastPasteSummary})`
82
- : `SW paste: ${focusText}`;
140
+ ? `SW paste v${EXTENSION_VERSION}: ${focusText} (${state.lastPasteSummary})`
141
+ : `SW paste v${EXTENSION_VERSION}: ${focusText}`;
142
+ }
143
+
144
+ /** Short display name for clipboard owner debugging. */
145
+ function clipboardOwnerSummary(snapshot: ClipboardSnapshot): string {
146
+ const name = snapshot.ownerProcessName?.trim();
147
+ if (name) return name;
148
+
149
+ const rawPath = snapshot.ownerProcessPath?.trim();
150
+ if (!rawPath) return "unknown";
151
+
152
+ const segments = rawPath.split(/[\\/]/).filter(Boolean);
153
+ return segments.at(-1) ?? rawPath;
83
154
  }
84
155
 
156
+ /** True when `ctx`/`generation` still match the active Pi session. */
85
157
  function isCurrentSession(ctx: PiRuntimeContext, generation: number): boolean {
86
158
  return state.session?.generation === generation && state.session.ctx === ctx;
87
159
  }
88
160
 
161
+ /** Runs a UI callback only for the current session; swallows stale-ctx errors. */
89
162
  function safeWithCurrentUi(
90
163
  ctx: PiRuntimeContext,
91
164
  generation: number,
@@ -106,12 +179,14 @@ function safeWithCurrentUi(
106
179
  }
107
180
  }
108
181
 
182
+ /** Updates Pi status bar when session is current. */
109
183
  function setStatus(ctx: PiRuntimeContext, generation: number): boolean {
110
184
  return safeWithCurrentUi(ctx, generation, (ui) => {
111
185
  ui.setStatus(STATUS_KEY, statusText());
112
186
  });
113
187
  }
114
188
 
189
+ /** Shows a Pi notification when session is current. */
115
190
  function notify(
116
191
  ctx: PiRuntimeContext,
117
192
  generation: number,
@@ -123,12 +198,14 @@ function notify(
123
198
  });
124
199
  }
125
200
 
201
+ /** Pastes text into the editor when session is current. */
126
202
  function pasteToEditor(ctx: PiRuntimeContext, generation: number, text: string): boolean {
127
203
  return safeWithCurrentUi(ctx, generation, (ui) => {
128
204
  ui.pasteToEditor(text);
129
205
  });
130
206
  }
131
207
 
208
+ /** Reads editor text when session is current. */
132
209
  function getEditorText(ctx: PiRuntimeContext, generation: number): string | undefined {
133
210
  if (!isCurrentSession(ctx, generation)) return undefined;
134
211
 
@@ -141,6 +218,7 @@ function getEditorText(ctx: PiRuntimeContext, generation: number): string | unde
141
218
  }
142
219
  }
143
220
 
221
+ /** Records last paste and refreshes status after insert. */
144
222
  function markPasted(ctx: PiRuntimeContext, generation: number, text: string): void {
145
223
  if (!isCurrentSession(ctx, generation)) return;
146
224
  state.lastPasted = text;
@@ -149,31 +227,85 @@ function markPasted(ctx: PiRuntimeContext, generation: number, text: string): vo
149
227
  setStatus(ctx, generation);
150
228
  }
151
229
 
152
- async function readClipboardText(): Promise<string | undefined> {
153
- const script = [
230
+ /** Builds a PowerShell command that reads clipboard text truncated to `limit` characters. */
231
+ function clipboardPowerShellScript(limit: number): string {
232
+ return [
154
233
  "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
234
+ "$signature = @\"",
235
+ "using System;",
236
+ "using System.Runtime.InteropServices;",
237
+ "public static class ClipboardOwnerNative {",
238
+ " [DllImport(\"user32.dll\")] public static extern IntPtr GetClipboardOwner();",
239
+ " [DllImport(\"user32.dll\")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);",
240
+ "}",
241
+ "\"@",
242
+ "Add-Type -TypeDefinition $signature -ErrorAction SilentlyContinue",
243
+ `$limit = ${limit}`,
155
244
  "$text = Get-Clipboard -Raw -Format Text -ErrorAction SilentlyContinue",
156
- "if ($null -ne $text) { [Console]::Out.Write($text) }",
157
- ].join("; ");
158
-
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
- );
245
+ "$ownerProcessName = $null",
246
+ "$ownerProcessPath = $null",
247
+ "try {",
248
+ " $ownerWindow = [ClipboardOwnerNative]::GetClipboardOwner()",
249
+ " if ($ownerWindow -ne [IntPtr]::Zero) {",
250
+ " $ownerPid = 0",
251
+ " [void][ClipboardOwnerNative]::GetWindowThreadProcessId($ownerWindow, [ref]$ownerPid)",
252
+ " if ($ownerPid -gt 0) {",
253
+ " $ownerProcess = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue",
254
+ " if ($null -ne $ownerProcess) {",
255
+ " $ownerProcessName = $ownerProcess.ProcessName",
256
+ " $ownerProcessPath = $ownerProcess.Path",
257
+ " }",
258
+ " }",
259
+ " }",
260
+ "} catch {}",
261
+ "if ($null -ne $text) {",
262
+ " if ($text.Length -gt $limit) { $text = $text.Substring(0, $limit) }",
263
+ "}",
264
+ "$result = @{ text = $text; ownerProcessName = $ownerProcessName; ownerProcessPath = $ownerProcessPath }",
265
+ "[Console]::Out.Write(($result | ConvertTo-Json -Compress))",
266
+ ].join("\n");
267
+ }
268
+
269
+ /** Reads clipboard text and owner metadata via PowerShell. */
270
+ async function readClipboardSnapshot(): Promise<ClipboardSnapshot | undefined> {
271
+ const limit = maxChars();
169
272
 
170
- return typeof result.stdout === "string" ? result.stdout : undefined;
273
+ try {
274
+ const result = await execFileAsync(
275
+ "powershell.exe",
276
+ ["-NoProfile", "-Command", clipboardPowerShellScript(limit)],
277
+ {
278
+ encoding: "utf8",
279
+ maxBuffer: limit * 4 + 1024,
280
+ timeout: 2500,
281
+ windowsHide: true,
282
+ },
283
+ );
284
+
285
+ const stdout = typeof result.stdout === "string" ? result.stdout : undefined;
286
+ if (!stdout) return undefined;
287
+
288
+ const snapshot = JSON.parse(stdout) as ClipboardSnapshot;
289
+ const text = typeof snapshot.text === "string" ? snapshot.text : undefined;
290
+ return {
291
+ text: text && text.length > limit ? text.slice(0, limit) : text,
292
+ ownerProcessName:
293
+ typeof snapshot.ownerProcessName === "string" ? snapshot.ownerProcessName : undefined,
294
+ ownerProcessPath:
295
+ typeof snapshot.ownerProcessPath === "string" ? snapshot.ownerProcessPath : undefined,
296
+ };
297
+ } catch {
298
+ // Oversized clipboard, PowerShell failure, or maxBuffer must not break extension load.
299
+ return undefined;
300
+ }
171
301
  }
172
302
 
303
+ /** Temp file path for cross-tab active-instance claim. */
173
304
  function activeStatePath(): string {
174
305
  return join(tmpdir(), ACTIVE_STATE_FILE);
175
306
  }
176
307
 
308
+ /** Writes this Pi instance as the active tab for paste gating. */
177
309
  async function claimActiveTab(): Promise<void> {
178
310
  const claim = {
179
311
  instanceId: INSTANCE_ID,
@@ -188,6 +320,7 @@ async function claimActiveTab(): Promise<void> {
188
320
  }
189
321
  }
190
322
 
323
+ /** True when this instance owns the active-tab claim and has focus. */
191
324
  async function isActiveTab(): Promise<boolean> {
192
325
  if (!state.terminalFocused) return false;
193
326
 
@@ -201,15 +334,32 @@ async function isActiveTab(): Promise<boolean> {
201
334
  }
202
335
  }
203
336
 
337
+ /** Seeds `lastClipboard` without pasting (focus/tab activation). */
204
338
  async function refreshClipboardBaseline(
205
339
  ctx: PiRuntimeContext,
206
340
  generation: number,
207
341
  ): Promise<void> {
208
- const text = await readClipboardText();
342
+ const text = (await readClipboardSnapshot())?.text;
209
343
  if (!isCurrentSession(ctx, generation)) return;
210
344
  if (text !== undefined) state.lastClipboard = text;
211
345
  }
212
346
 
347
+ /** Best-effort delayed baseline refresh after local copy shortcuts. */
348
+ function scheduleClipboardBaselineRefresh(ctx: PiRuntimeContext, generation: number): void {
349
+ if (!isCurrentSession(ctx, generation)) return;
350
+
351
+ if (state.baselineRefreshTimeout) {
352
+ clearTimeout(state.baselineRefreshTimeout);
353
+ state.baselineRefreshTimeout = undefined;
354
+ }
355
+
356
+ state.baselineRefreshTimeout = setTimeout(() => {
357
+ state.baselineRefreshTimeout = undefined;
358
+ if (isCurrentSession(ctx, generation)) void refreshClipboardBaseline(ctx, generation);
359
+ }, BASELINE_REFRESH_DELAY_MS);
360
+ }
361
+
362
+ /** Marks tab focused, refreshes clipboard baseline, claims active tab. */
213
363
  async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<void> {
214
364
  if (!isCurrentSession(ctx, generation) || state.activatingGeneration !== undefined) return;
215
365
 
@@ -227,6 +377,7 @@ async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<v
227
377
  }
228
378
  }
229
379
 
380
+ /** Claims active tab when user types in the terminal. */
230
381
  async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): Promise<void> {
231
382
  if (!isCurrentSession(ctx, generation)) return;
232
383
  state.terminalFocused = true;
@@ -235,6 +386,39 @@ async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): P
235
386
  setStatus(ctx, generation);
236
387
  }
237
388
 
389
+ /** True while recent local copy shortcuts should suppress bridge pastes. */
390
+ function shouldSuppressPasteForRecentCopy(): boolean {
391
+ return (state.suppressPasteUntil ?? 0) > Date.now();
392
+ }
393
+
394
+ /** Detects common terminal copy shortcuts forwarded to Pi. */
395
+ function isLocalCopyShortcut(data: string): boolean {
396
+ return [
397
+ CTRL_C,
398
+ CTRL_INSERT,
399
+ CTRL_SHIFT_INSERT,
400
+ CTRL_SHIFT_C_CSI_U,
401
+ CTRL_SHIFT_UPPER_C_CSI_U,
402
+ ].includes(data);
403
+ }
404
+
405
+ /** Suppresses auto-paste briefly after local terminal copy intent. */
406
+ function suppressPasteAfterLocalCopy(ctx: PiRuntimeContext, generation: number): void {
407
+ if (!isCurrentSession(ctx, generation)) return;
408
+ state.suppressPasteUntil = Date.now() + ignoreCopyMs();
409
+ state.lastPasteSummary = "blocked recent copy";
410
+ void markActiveFromInput(ctx, generation);
411
+ scheduleClipboardBaselineRefresh(ctx, generation);
412
+ }
413
+
414
+ /** True when clipboard owner metadata does not look like a terminal copy source. */
415
+ function shouldAcceptClipboardSource(snapshot: ClipboardSnapshot): boolean {
416
+ const owner = `${snapshot.ownerProcessName ?? ""}\n${snapshot.ownerProcessPath ?? ""}`.toLowerCase();
417
+ if (!owner.trim()) return true;
418
+ return !ownerDenylist().some((token) => owner.includes(token));
419
+ }
420
+
421
+ /** Whether clipboard text should be auto-pasted into the editor. */
238
422
  function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): boolean {
239
423
  if (!text.trim()) return false;
240
424
  if (text.length > maxChars()) return false;
@@ -247,6 +431,7 @@ function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): b
247
431
  return true;
248
432
  }
249
433
 
434
+ /** Poll handler: paste new clipboard text when gates pass. */
250
435
  async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number): Promise<void> {
251
436
  if (!isCurrentSession(ctx, generation)) return;
252
437
  if (
@@ -262,17 +447,35 @@ async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number):
262
447
 
263
448
  state.inFlightGeneration = generation;
264
449
  try {
265
- const text = await readClipboardText();
450
+ const snapshot = await readClipboardSnapshot();
451
+ const text = snapshot?.text;
266
452
  if (!isCurrentSession(ctx, generation)) return;
267
453
  if (text === undefined || text === state.lastClipboard) return;
268
454
 
269
455
  state.lastClipboard = text;
456
+ if (!snapshot) {
457
+ state.lastPasteSummary = "clipboard read failed";
458
+ setStatus(ctx, generation);
459
+ return;
460
+ }
461
+ if (!shouldAcceptClipboardSource(snapshot)) {
462
+ state.lastPasteSummary = `blocked ${clipboardOwnerSummary(snapshot)}`;
463
+ setStatus(ctx, generation);
464
+ return;
465
+ }
466
+ if (shouldSuppressPasteForRecentCopy()) {
467
+ state.lastPasteSummary = `blocked recent copy (${clipboardOwnerSummary(snapshot)})`;
468
+ setStatus(ctx, generation);
469
+ return;
470
+ }
270
471
  if (!shouldPaste(text, ctx, generation)) return;
271
472
  if (!(await isActiveTab())) return;
272
473
  if (!isCurrentSession(ctx, generation)) return;
273
474
 
274
475
  if (!pasteToEditor(ctx, generation, text)) return;
275
476
  markPasted(ctx, generation, text);
477
+ state.lastPasteSummary = `${text.length} chars via ${clipboardOwnerSummary(snapshot)}`;
478
+ setStatus(ctx, generation);
276
479
  } catch {
277
480
  // Clipboard polling should stay quiet while the user is typing.
278
481
  } finally {
@@ -280,6 +483,7 @@ async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number):
280
483
  }
281
484
  }
282
485
 
486
+ /** Enables terminal focus reporting and input hooks for this session. */
283
487
  function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number): void {
284
488
  if (!isCurrentSession(ctx, generation)) return;
285
489
  if (state.unsubscribeTerminalInput) return;
@@ -306,6 +510,11 @@ function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number):
306
510
  return { consume: true };
307
511
  }
308
512
 
513
+ if (isLocalCopyShortcut(data)) {
514
+ suppressPasteAfterLocalCopy(ctx, generation);
515
+ return undefined;
516
+ }
517
+
309
518
  if (data.length > 0) {
310
519
  void markActiveFromInput(ctx, generation);
311
520
  }
@@ -315,6 +524,7 @@ function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number):
315
524
  });
316
525
  }
317
526
 
527
+ /** Disables focus reporting and removes terminal input subscription. */
318
528
  function teardownTerminalFocusTracking(): void {
319
529
  try {
320
530
  state.unsubscribeTerminalInput?.();
@@ -330,16 +540,23 @@ function teardownTerminalFocusTracking(): void {
330
540
  }
331
541
  }
332
542
 
543
+ /** Clears poll interval and terminal hooks for the current session. */
333
544
  function teardownSessionResources(): void {
334
545
  if (state.interval) {
335
546
  clearInterval(state.interval);
336
547
  state.interval = undefined;
337
548
  }
549
+ if (state.baselineRefreshTimeout) {
550
+ clearTimeout(state.baselineRefreshTimeout);
551
+ state.baselineRefreshTimeout = undefined;
552
+ }
338
553
  teardownTerminalFocusTracking();
339
554
  state.inFlightGeneration = undefined;
340
555
  state.activatingGeneration = undefined;
556
+ state.suppressPasteUntil = undefined;
341
557
  }
342
558
 
559
+ /** Starts a new session generation and tears down the previous one. */
343
560
  function beginSession(ctx: PiRuntimeContext): number {
344
561
  teardownSessionResources();
345
562
  const generation = state.generation + 1;
@@ -348,17 +565,20 @@ function beginSession(ctx: PiRuntimeContext): number {
348
565
  return generation;
349
566
  }
350
567
 
568
+ /** Returns current session generation, beginning one if needed. */
351
569
  function ensureCurrentSession(ctx: PiRuntimeContext): number {
352
570
  if (state.session?.ctx === ctx) return state.session.generation;
353
571
  return beginSession(ctx);
354
572
  }
355
573
 
574
+ /** Invalidates the current session and releases resources. */
356
575
  function endSession(): void {
357
576
  teardownSessionResources();
358
577
  state.generation += 1;
359
578
  state.session = undefined;
360
579
  }
361
580
 
581
+ /** Starts clipboard polling and focus tracking when mode is on. */
362
582
  function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
363
583
  if (!isCurrentSession(ctx, generation)) return;
364
584
 
@@ -371,21 +591,24 @@ function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
371
591
  }, intervalMs());
372
592
  }
373
593
 
594
+ /** Stops polling and clears bridge status. */
374
595
  function stopPolling(ctx: PiRuntimeContext, generation: number): void {
375
596
  teardownSessionResources();
376
597
  state.mode = "off";
377
598
  setStatus(ctx, generation);
378
599
  }
379
600
 
601
+ /** Baselines clipboard then starts polling (session_start path). */
380
602
  async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<void> {
381
- const text = await readClipboardText();
603
+ const text = (await readClipboardSnapshot())?.text;
382
604
  if (!isCurrentSession(ctx, generation)) return;
383
605
  state.lastClipboard = text;
384
606
  ensurePolling(ctx, generation);
385
607
  }
386
608
 
609
+ /** Enables bridge mode and notifies the user. */
387
610
  async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
388
- const text = await readClipboardText();
611
+ const text = (await readClipboardSnapshot())?.text;
389
612
  if (!isCurrentSession(ctx, generation)) return;
390
613
  state.lastClipboard = text;
391
614
  state.mode = "on";
@@ -393,12 +616,14 @@ async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
393
616
  notify(ctx, generation, "Superwhisper paste bridge: enabled", "info");
394
617
  }
395
618
 
619
+ /** Parses `/sw-paste:on` or `/sw-paste:off` from user input. */
396
620
  function parseControlCommand(text: string): BridgeMode | undefined {
397
621
  const match = text.trim().match(/^\/sw-paste:(on|off)$/i);
398
622
  if (!match) return undefined;
399
623
  return match[1].toLowerCase() as BridgeMode;
400
624
  }
401
625
 
626
+ /** Applies on/off control command for the current session. */
402
627
  async function runControlAction(
403
628
  action: BridgeMode,
404
629
  ctx: PiRuntimeContext,
@@ -413,6 +638,7 @@ async function runControlAction(
413
638
  notify(ctx, generation, "Superwhisper paste bridge: disabled", "info");
414
639
  }
415
640
 
641
+ /** Pi extension entry: Superwhisper clipboard bridge for Windows. */
416
642
  export default function superwhisperPaste(pi: ExtensionAPI) {
417
643
  pi.registerCommand("sw-paste:on", {
418
644
  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.14",
4
4
  "description": "Superwhisper clipboard bridge for Pi on Windows.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,7 +37,8 @@
37
37
  "typecheck": "tsc --noEmit",
38
38
  "test": "node --test tests/*.test.mjs",
39
39
  "ci": "npm run typecheck && npm test && npm run pack:check",
40
- "pack:check": "npm pack --dry-run"
40
+ "pack:check": "npm pack --dry-run",
41
+ "version:check": "node scripts/check-version-bump.mjs"
41
42
  },
42
43
  "pi": {
43
44
  "extensions": [
@@ -56,7 +57,7 @@
56
57
  "@earendil-works/pi-ai": "latest",
57
58
  "@earendil-works/pi-coding-agent": "latest",
58
59
  "@earendil-works/pi-tui": "latest",
59
- "@types/node": "^22.0.0",
60
+ "@types/node": "^25.9.1",
60
61
  "typescript": "^6.0.3"
61
62
  }
62
63
  }