pi-superwhisper-paste 0.1.5 → 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,13 +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.5] - 2026-06-03
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.
8
64
 
9
65
  ### Fixed
10
66
 
11
67
  - Truncate clipboard text in PowerShell before writing stdout so huge clipboards no longer exceed Node `maxBuffer` on extension load.
12
68
  - Treat clipboard read failures (including `maxBuffer`) as empty reads so `session_start` never surfaces as an extension error.
13
69
 
70
+ ## [0.1.5] - 2026-06-03
71
+
14
72
  ## [0.1.4] - 2026-06-03
15
73
 
16
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;
@@ -77,13 +113,44 @@ function maxChars(): number {
77
113
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CHARS;
78
114
  }
79
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
+
80
135
  /** Status bar label for the paste bridge. */
81
136
  function statusText(): string | undefined {
82
137
  if (state.mode === "off") return undefined;
83
138
  const focusText = state.terminalFocused ? "active" : "standby";
84
139
  return state.lastPasteSummary
85
- ? `SW paste: ${focusText} (${state.lastPasteSummary})`
86
- : `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;
87
154
  }
88
155
 
89
156
  /** True when `ctx`/`generation` still match the active Pi session. */
@@ -164,17 +231,43 @@ function markPasted(ctx: PiRuntimeContext, generation: number, text: string): vo
164
231
  function clipboardPowerShellScript(limit: number): string {
165
232
  return [
166
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",
167
243
  `$limit = ${limit}`,
168
244
  "$text = Get-Clipboard -Raw -Format Text -ErrorAction SilentlyContinue",
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 {}",
169
261
  "if ($null -ne $text) {",
170
262
  " if ($text.Length -gt $limit) { $text = $text.Substring(0, $limit) }",
171
- " [Console]::Out.Write($text)",
172
263
  "}",
173
- ].join("; ");
264
+ "$result = @{ text = $text; ownerProcessName = $ownerProcessName; ownerProcessPath = $ownerProcessPath }",
265
+ "[Console]::Out.Write(($result | ConvertTo-Json -Compress))",
266
+ ].join("\n");
174
267
  }
175
268
 
176
- /** Reads clipboard text via PowerShell; returns undefined on failure or oversize output. */
177
- async function readClipboardText(): Promise<string | undefined> {
269
+ /** Reads clipboard text and owner metadata via PowerShell. */
270
+ async function readClipboardSnapshot(): Promise<ClipboardSnapshot | undefined> {
178
271
  const limit = maxChars();
179
272
 
180
273
  try {
@@ -190,8 +283,17 @@ async function readClipboardText(): Promise<string | undefined> {
190
283
  );
191
284
 
192
285
  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;
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
+ };
195
297
  } catch {
196
298
  // Oversized clipboard, PowerShell failure, or maxBuffer must not break extension load.
197
299
  return undefined;
@@ -237,11 +339,26 @@ async function refreshClipboardBaseline(
237
339
  ctx: PiRuntimeContext,
238
340
  generation: number,
239
341
  ): Promise<void> {
240
- const text = await readClipboardText();
342
+ const text = (await readClipboardSnapshot())?.text;
241
343
  if (!isCurrentSession(ctx, generation)) return;
242
344
  if (text !== undefined) state.lastClipboard = text;
243
345
  }
244
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
+
245
362
  /** Marks tab focused, refreshes clipboard baseline, claims active tab. */
246
363
  async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<void> {
247
364
  if (!isCurrentSession(ctx, generation) || state.activatingGeneration !== undefined) return;
@@ -269,6 +386,38 @@ async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): P
269
386
  setStatus(ctx, generation);
270
387
  }
271
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
+
272
421
  /** Whether clipboard text should be auto-pasted into the editor. */
273
422
  function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): boolean {
274
423
  if (!text.trim()) return false;
@@ -298,17 +447,35 @@ async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number):
298
447
 
299
448
  state.inFlightGeneration = generation;
300
449
  try {
301
- const text = await readClipboardText();
450
+ const snapshot = await readClipboardSnapshot();
451
+ const text = snapshot?.text;
302
452
  if (!isCurrentSession(ctx, generation)) return;
303
453
  if (text === undefined || text === state.lastClipboard) return;
304
454
 
305
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
+ }
306
471
  if (!shouldPaste(text, ctx, generation)) return;
307
472
  if (!(await isActiveTab())) return;
308
473
  if (!isCurrentSession(ctx, generation)) return;
309
474
 
310
475
  if (!pasteToEditor(ctx, generation, text)) return;
311
476
  markPasted(ctx, generation, text);
477
+ state.lastPasteSummary = `${text.length} chars via ${clipboardOwnerSummary(snapshot)}`;
478
+ setStatus(ctx, generation);
312
479
  } catch {
313
480
  // Clipboard polling should stay quiet while the user is typing.
314
481
  } finally {
@@ -343,6 +510,11 @@ function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number):
343
510
  return { consume: true };
344
511
  }
345
512
 
513
+ if (isLocalCopyShortcut(data)) {
514
+ suppressPasteAfterLocalCopy(ctx, generation);
515
+ return undefined;
516
+ }
517
+
346
518
  if (data.length > 0) {
347
519
  void markActiveFromInput(ctx, generation);
348
520
  }
@@ -374,9 +546,14 @@ function teardownSessionResources(): void {
374
546
  clearInterval(state.interval);
375
547
  state.interval = undefined;
376
548
  }
549
+ if (state.baselineRefreshTimeout) {
550
+ clearTimeout(state.baselineRefreshTimeout);
551
+ state.baselineRefreshTimeout = undefined;
552
+ }
377
553
  teardownTerminalFocusTracking();
378
554
  state.inFlightGeneration = undefined;
379
555
  state.activatingGeneration = undefined;
556
+ state.suppressPasteUntil = undefined;
380
557
  }
381
558
 
382
559
  /** Starts a new session generation and tears down the previous one. */
@@ -423,7 +600,7 @@ function stopPolling(ctx: PiRuntimeContext, generation: number): void {
423
600
 
424
601
  /** Baselines clipboard then starts polling (session_start path). */
425
602
  async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<void> {
426
- const text = await readClipboardText();
603
+ const text = (await readClipboardSnapshot())?.text;
427
604
  if (!isCurrentSession(ctx, generation)) return;
428
605
  state.lastClipboard = text;
429
606
  ensurePolling(ctx, generation);
@@ -431,7 +608,7 @@ async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<
431
608
 
432
609
  /** Enables bridge mode and notifies the user. */
433
610
  async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
434
- const text = await readClipboardText();
611
+ const text = (await readClipboardSnapshot())?.text;
435
612
  if (!isCurrentSession(ctx, generation)) return;
436
613
  state.lastClipboard = text;
437
614
  state.mode = "on";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-superwhisper-paste",
3
- "version": "0.1.5",
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
  }