pi-superwhisper-paste 0.1.0 → 0.1.2

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,10 +4,32 @@ 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.2] - 2026-06-01
8
+
9
+ ### Fixed
10
+
11
+ - Register `/sw-paste:on` and `/sw-paste:off` as Pi slash commands so they appear in command autocomplete.
12
+
13
+ ### Changed
14
+
15
+ - Clarify that the Superwhisper paste controls are slash-autocomplete commands.
16
+
17
+ ## [0.1.1] - 2026-05-31
18
+
19
+ ### Added
20
+
21
+ - Add automatic GitHub release creation for version bumps on `main`.
22
+ - Add manual and release-published npm publish entrypoints for Trusted Publishing.
23
+
24
+ ### Changed
25
+
26
+ - Align CI and publish workflows with the maintained Pi OSS release pattern.
27
+ - Skip npm publish when the exact package version is already published.
28
+
7
29
  ## [0.1.0] - YYYY-MM-DD
8
30
 
9
31
  ### Added
10
32
 
11
33
  - Initial Superwhisper paste bridge extension for Pi on Windows.
12
34
  - Default-on clipboard watcher with `/sw-paste:on` and `/sw-paste:off` controls.
13
- - CI and npm Trusted Publishing workflow from the Pi extension template.
35
+ - CI and npm Trusted Publishing workflow from the Pi extension template.
package/README.md CHANGED
@@ -36,7 +36,7 @@ This may not be a universal Superwhisper or Pi issue. If Superwhisper already pa
36
36
  - Pastes only into the active Pi tab when multiple tabs are open.
37
37
  - Ignores old clipboard content when switching tabs.
38
38
  - Continues inserting while Pi is thinking or waiting on an agent turn.
39
- - Provides two control commands: `/sw-paste:on` and `/sw-paste:off`.
39
+ - Provides slash-autocomplete control commands: `/sw-paste:on` and `/sw-paste:off`.
40
40
 
41
41
  ## Install
42
42
 
package/docs/release.md CHANGED
@@ -16,15 +16,22 @@ On npmjs.com, configure Trusted Publishing for this package:
16
16
 
17
17
  ```bash
18
18
  npm version patch
19
- git push --follow-tags
19
+ git push
20
20
  ```
21
21
 
22
- The `v*` tag triggers `.github/workflows/publish.yml`, which runs CI and publishes to npm.
22
+ On `main`, `.github/workflows/auto-release.yml` checks `package.json` version. If `v<version>` does not exist yet, it creates the tag, creates the GitHub Release, then explicitly dispatches `.github/workflows/publish.yml` for that tag.
23
+
24
+ The `v*.*.*` tag also triggers `.github/workflows/publish.yml`, which runs CI and publishes to npm when tags are pushed manually.
25
+ Publishing also runs when a GitHub Release is published, and can be run manually from GitHub Actions with `workflow_dispatch`.
26
+
27
+ The workflow skips `name@version` if that exact package version already exists on npm.
23
28
 
24
29
  ## GitHub Actions requirements
25
30
 
26
31
  - `permissions: id-token: write`
32
+ - `permissions: actions: write` on auto-release so it can dispatch `publish.yml`
27
33
  - GitHub-hosted runner
34
+ - Node.js 24, so the release job uses a current npm CLI for Trusted Publishing
28
35
  - No `NPM_TOKEN`
29
36
  - `npm publish` from the configured workflow file
30
37
 
@@ -35,4 +42,4 @@ The `v*` tag triggers `.github/workflows/publish.yml`, which runs CI and publish
35
42
  - [ ] npm Trusted Publisher is configured
36
43
  - [ ] `npm run ci` passes
37
44
  - [ ] `npm pack --dry-run` contains only intended files
38
- - [ ] `CHANGELOG.md` has the release date
45
+ - [ ] `CHANGELOG.md` has the release date
@@ -34,23 +34,28 @@ type PiRuntimeContext = {
34
34
  ui: PiEditorUi;
35
35
  };
36
36
 
37
+ type SessionHandle = {
38
+ generation: number;
39
+ ctx: PiRuntimeContext;
40
+ };
41
+
37
42
  type BridgeState = {
38
43
  mode: BridgeMode;
39
44
  interval?: NodeJS.Timeout;
40
- inFlight: boolean;
41
- activating: boolean;
45
+ generation: number;
46
+ session?: SessionHandle;
47
+ inFlightGeneration?: number;
48
+ activatingGeneration?: number;
42
49
  terminalFocused: boolean;
43
50
  unsubscribeTerminalInput?: () => void;
44
51
  lastClipboard?: string;
45
52
  lastPasted?: string;
46
53
  lastPasteSummary?: string;
47
- ctx?: PiRuntimeContext;
48
54
  };
49
55
 
50
56
  const state: BridgeState = {
51
57
  mode: defaultMode(),
52
- inFlight: false,
53
- activating: false,
58
+ generation: 0,
54
59
  terminalFocused: true,
55
60
  };
56
61
 
@@ -77,15 +82,71 @@ function statusText(): string | undefined {
77
82
  : `SW paste: ${focusText}`;
78
83
  }
79
84
 
80
- function setStatus(ctx: PiRuntimeContext): void {
81
- ctx.ui.setStatus(STATUS_KEY, statusText());
85
+ function isCurrentSession(ctx: PiRuntimeContext, generation: number): boolean {
86
+ return state.session?.generation === generation && state.session.ctx === ctx;
87
+ }
88
+
89
+ function safeWithCurrentUi(
90
+ ctx: PiRuntimeContext,
91
+ generation: number,
92
+ action: (ui: PiEditorUi) => void,
93
+ ): boolean {
94
+ if (!isCurrentSession(ctx, generation)) return false;
95
+
96
+ try {
97
+ const ui = ctx.ui;
98
+ if (!isCurrentSession(ctx, generation)) return false;
99
+ action(ui);
100
+ return true;
101
+ } catch {
102
+ // A Pi ctx can become stale between any callback/await boundary. Treat UI
103
+ // failures as a closed session so the extension never bubbles the runner's
104
+ // stale-ctx guard up as an uncaughtException.
105
+ return false;
106
+ }
107
+ }
108
+
109
+ function setStatus(ctx: PiRuntimeContext, generation: number): boolean {
110
+ return safeWithCurrentUi(ctx, generation, (ui) => {
111
+ ui.setStatus(STATUS_KEY, statusText());
112
+ });
113
+ }
114
+
115
+ function notify(
116
+ ctx: PiRuntimeContext,
117
+ generation: number,
118
+ message: string,
119
+ level?: "info" | "warning" | "error" | "success",
120
+ ): boolean {
121
+ return safeWithCurrentUi(ctx, generation, (ui) => {
122
+ ui.notify(message, level);
123
+ });
124
+ }
125
+
126
+ function pasteToEditor(ctx: PiRuntimeContext, generation: number, text: string): boolean {
127
+ return safeWithCurrentUi(ctx, generation, (ui) => {
128
+ ui.pasteToEditor(text);
129
+ });
82
130
  }
83
131
 
84
- function markPasted(ctx: PiRuntimeContext, text: string): void {
132
+ function getEditorText(ctx: PiRuntimeContext, generation: number): string | undefined {
133
+ if (!isCurrentSession(ctx, generation)) return undefined;
134
+
135
+ try {
136
+ const ui = ctx.ui;
137
+ if (!isCurrentSession(ctx, generation)) return undefined;
138
+ return String(ui.getEditorText?.() ?? "");
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ function markPasted(ctx: PiRuntimeContext, generation: number, text: string): void {
145
+ if (!isCurrentSession(ctx, generation)) return;
85
146
  state.lastPasted = text;
86
147
  state.lastPasteSummary = `${text.length} chars`;
87
148
  // pasteToEditor mutates the editor directly; setStatus nudges Pi to redraw.
88
- setStatus(ctx);
149
+ setStatus(ctx, generation);
89
150
  }
90
151
 
91
152
  async function readClipboardText(): Promise<string | undefined> {
@@ -140,128 +201,196 @@ async function isActiveTab(): Promise<boolean> {
140
201
  }
141
202
  }
142
203
 
143
- async function refreshClipboardBaseline(): Promise<void> {
204
+ async function refreshClipboardBaseline(
205
+ ctx: PiRuntimeContext,
206
+ generation: number,
207
+ ): Promise<void> {
144
208
  const text = await readClipboardText();
209
+ if (!isCurrentSession(ctx, generation)) return;
145
210
  if (text !== undefined) state.lastClipboard = text;
146
211
  }
147
212
 
148
- async function activateTab(ctx: PiRuntimeContext): Promise<void> {
149
- if (state.activating) return;
213
+ async function activateTab(ctx: PiRuntimeContext, generation: number): Promise<void> {
214
+ if (!isCurrentSession(ctx, generation) || state.activatingGeneration !== undefined) return;
150
215
 
151
- state.activating = true;
216
+ state.activatingGeneration = generation;
152
217
  try {
218
+ if (!isCurrentSession(ctx, generation)) return;
153
219
  state.terminalFocused = true;
154
- await refreshClipboardBaseline();
220
+ await refreshClipboardBaseline(ctx, generation);
221
+ if (!isCurrentSession(ctx, generation)) return;
155
222
  await claimActiveTab();
156
- setStatus(ctx);
223
+ if (!isCurrentSession(ctx, generation)) return;
224
+ setStatus(ctx, generation);
157
225
  } finally {
158
- state.activating = false;
226
+ if (state.activatingGeneration === generation) state.activatingGeneration = undefined;
159
227
  }
160
228
  }
161
229
 
162
- async function markActiveFromInput(ctx: PiRuntimeContext): Promise<void> {
230
+ async function markActiveFromInput(ctx: PiRuntimeContext, generation: number): Promise<void> {
231
+ if (!isCurrentSession(ctx, generation)) return;
163
232
  state.terminalFocused = true;
164
233
  await claimActiveTab();
165
- setStatus(ctx);
234
+ if (!isCurrentSession(ctx, generation)) return;
235
+ setStatus(ctx, generation);
166
236
  }
167
237
 
168
- function shouldPaste(text: string, ctx: PiRuntimeContext): boolean {
238
+ function shouldPaste(text: string, ctx: PiRuntimeContext, generation: number): boolean {
169
239
  if (!text.trim()) return false;
170
240
  if (text.length > maxChars()) return false;
171
241
  if (text === state.lastPasted) return false;
172
242
 
173
- const current = String(ctx.ui.getEditorText?.() ?? "");
243
+ const current = getEditorText(ctx, generation);
244
+ if (current === undefined) return false;
174
245
  if (current.endsWith(text)) return false;
175
246
 
176
247
  return true;
177
248
  }
178
249
 
179
- async function pasteClipboardChange(ctx: PiRuntimeContext): Promise<void> {
180
- if (state.inFlight || state.activating || state.mode === "off") return;
250
+ async function pasteClipboardChange(ctx: PiRuntimeContext, generation: number): Promise<void> {
251
+ if (!isCurrentSession(ctx, generation)) return;
252
+ if (
253
+ state.inFlightGeneration !== undefined ||
254
+ state.activatingGeneration === generation ||
255
+ state.mode === "off"
256
+ ) {
257
+ return;
258
+ }
181
259
  if (!ctx.hasUI) return;
182
260
  if (!(await isActiveTab())) return;
261
+ if (!isCurrentSession(ctx, generation)) return;
183
262
 
184
- state.inFlight = true;
263
+ state.inFlightGeneration = generation;
185
264
  try {
186
265
  const text = await readClipboardText();
266
+ if (!isCurrentSession(ctx, generation)) return;
187
267
  if (text === undefined || text === state.lastClipboard) return;
188
268
 
189
269
  state.lastClipboard = text;
190
- if (!shouldPaste(text, ctx)) return;
270
+ if (!shouldPaste(text, ctx, generation)) return;
191
271
  if (!(await isActiveTab())) return;
272
+ if (!isCurrentSession(ctx, generation)) return;
192
273
 
193
- ctx.ui.pasteToEditor(text);
194
- markPasted(ctx, text);
274
+ if (!pasteToEditor(ctx, generation, text)) return;
275
+ markPasted(ctx, generation, text);
195
276
  } catch {
196
277
  // Clipboard polling should stay quiet while the user is typing.
197
278
  } finally {
198
- state.inFlight = false;
279
+ if (state.inFlightGeneration === generation) state.inFlightGeneration = undefined;
199
280
  }
200
281
  }
201
282
 
202
- function setupTerminalFocusTracking(ctx: PiRuntimeContext): void {
283
+ function setupTerminalFocusTracking(ctx: PiRuntimeContext, generation: number): void {
284
+ if (!isCurrentSession(ctx, generation)) return;
203
285
  if (state.unsubscribeTerminalInput) return;
204
286
 
205
- process.stdout.write(ENABLE_FOCUS_REPORTING);
206
- void activateTab(ctx);
287
+ try {
288
+ process.stdout.write(ENABLE_FOCUS_REPORTING);
289
+ } catch {
290
+ // Best-effort terminal focus setup only.
291
+ }
292
+ void activateTab(ctx, generation);
293
+
294
+ safeWithCurrentUi(ctx, generation, (ui) => {
295
+ state.unsubscribeTerminalInput = ui.onTerminalInput((data) => {
296
+ if (!isCurrentSession(ctx, generation)) return undefined;
207
297
 
208
- state.unsubscribeTerminalInput = ctx.ui.onTerminalInput((data) => {
209
- if (data === FOCUS_IN) {
210
- void activateTab(ctx);
211
- return { consume: true };
212
- }
298
+ if (data === FOCUS_IN) {
299
+ void activateTab(ctx, generation);
300
+ return { consume: true };
301
+ }
213
302
 
214
- if (data === FOCUS_OUT) {
215
- state.terminalFocused = false;
216
- setStatus(ctx);
217
- return { consume: true };
218
- }
303
+ if (data === FOCUS_OUT) {
304
+ state.terminalFocused = false;
305
+ setStatus(ctx, generation);
306
+ return { consume: true };
307
+ }
219
308
 
220
- if (data.length > 0) {
221
- void markActiveFromInput(ctx);
222
- }
309
+ if (data.length > 0) {
310
+ void markActiveFromInput(ctx, generation);
311
+ }
223
312
 
224
- return undefined;
313
+ return undefined;
314
+ });
225
315
  });
226
316
  }
227
317
 
228
318
  function teardownTerminalFocusTracking(): void {
229
- state.unsubscribeTerminalInput?.();
319
+ try {
320
+ state.unsubscribeTerminalInput?.();
321
+ } catch {
322
+ // The old subscription may already belong to an invalidated session.
323
+ }
230
324
  state.unsubscribeTerminalInput = undefined;
231
- process.stdout.write(DISABLE_FOCUS_REPORTING);
232
- }
233
-
234
- function ensurePolling(ctx: PiRuntimeContext): void {
235
- state.ctx = ctx;
236
- setupTerminalFocusTracking(ctx);
237
- setStatus(ctx);
238
325
 
239
- if (state.interval) return;
240
- state.interval = setInterval(() => {
241
- if (state.ctx) void pasteClipboardChange(state.ctx);
242
- }, intervalMs());
326
+ try {
327
+ process.stdout.write(DISABLE_FOCUS_REPORTING);
328
+ } catch {
329
+ // Best-effort terminal focus cleanup only.
330
+ }
243
331
  }
244
332
 
245
- function stopPolling(ctx?: PiRuntimeContext): void {
333
+ function teardownSessionResources(): void {
246
334
  if (state.interval) {
247
335
  clearInterval(state.interval);
248
336
  state.interval = undefined;
249
337
  }
250
338
  teardownTerminalFocusTracking();
339
+ state.inFlightGeneration = undefined;
340
+ state.activatingGeneration = undefined;
341
+ }
342
+
343
+ function beginSession(ctx: PiRuntimeContext): number {
344
+ teardownSessionResources();
345
+ const generation = state.generation + 1;
346
+ state.generation = generation;
347
+ state.session = { generation, ctx };
348
+ return generation;
349
+ }
350
+
351
+ function ensureCurrentSession(ctx: PiRuntimeContext): number {
352
+ if (state.session?.ctx === ctx) return state.session.generation;
353
+ return beginSession(ctx);
354
+ }
355
+
356
+ function endSession(): void {
357
+ teardownSessionResources();
358
+ state.generation += 1;
359
+ state.session = undefined;
360
+ }
361
+
362
+ function ensurePolling(ctx: PiRuntimeContext, generation: number): void {
363
+ if (!isCurrentSession(ctx, generation)) return;
364
+
365
+ setupTerminalFocusTracking(ctx, generation);
366
+ setStatus(ctx, generation);
367
+
368
+ if (state.interval) return;
369
+ state.interval = setInterval(() => {
370
+ if (isCurrentSession(ctx, generation)) void pasteClipboardChange(ctx, generation);
371
+ }, intervalMs());
372
+ }
373
+
374
+ function stopPolling(ctx: PiRuntimeContext, generation: number): void {
375
+ teardownSessionResources();
251
376
  state.mode = "off";
252
- if (ctx) setStatus(ctx);
377
+ setStatus(ctx, generation);
253
378
  }
254
379
 
255
- async function startPolling(ctx: PiRuntimeContext): Promise<void> {
256
- state.lastClipboard = await readClipboardText();
257
- ensurePolling(ctx);
380
+ async function startPolling(ctx: PiRuntimeContext, generation: number): Promise<void> {
381
+ const text = await readClipboardText();
382
+ if (!isCurrentSession(ctx, generation)) return;
383
+ state.lastClipboard = text;
384
+ ensurePolling(ctx, generation);
258
385
  }
259
386
 
260
- async function arm(ctx: PiRuntimeContext): Promise<void> {
261
- state.lastClipboard = await readClipboardText();
387
+ async function arm(ctx: PiRuntimeContext, generation: number): Promise<void> {
388
+ const text = await readClipboardText();
389
+ if (!isCurrentSession(ctx, generation)) return;
390
+ state.lastClipboard = text;
262
391
  state.mode = "on";
263
- ensurePolling(ctx);
264
- ctx.ui.notify("Superwhisper paste bridge: enabled", "info");
392
+ ensurePolling(ctx, generation);
393
+ notify(ctx, generation, "Superwhisper paste bridge: enabled", "info");
265
394
  }
266
395
 
267
396
  function parseControlCommand(text: string): BridgeMode | undefined {
@@ -270,32 +399,56 @@ function parseControlCommand(text: string): BridgeMode | undefined {
270
399
  return match[1].toLowerCase() as BridgeMode;
271
400
  }
272
401
 
273
- async function runControlAction(action: BridgeMode, ctx: PiRuntimeContext): Promise<void> {
402
+ async function runControlAction(
403
+ action: BridgeMode,
404
+ ctx: PiRuntimeContext,
405
+ generation: number,
406
+ ): Promise<void> {
274
407
  if (action === "on") {
275
- await arm(ctx);
408
+ await arm(ctx, generation);
276
409
  return;
277
410
  }
278
411
 
279
- stopPolling(ctx);
280
- ctx.ui.notify("Superwhisper paste bridge: disabled", "info");
412
+ stopPolling(ctx, generation);
413
+ notify(ctx, generation, "Superwhisper paste bridge: disabled", "info");
281
414
  }
282
415
 
283
416
  export default function superwhisperPaste(pi: ExtensionAPI) {
417
+ pi.registerCommand("sw-paste:on", {
418
+ description: "Enable the Superwhisper clipboard paste bridge",
419
+ handler: async (_args, ctx) => {
420
+ const runtimeCtx = ctx as PiRuntimeContext;
421
+ const generation = ensureCurrentSession(runtimeCtx);
422
+ await runControlAction("on", runtimeCtx, generation);
423
+ },
424
+ });
425
+
426
+ pi.registerCommand("sw-paste:off", {
427
+ description: "Disable the Superwhisper clipboard paste bridge",
428
+ handler: async (_args, ctx) => {
429
+ const runtimeCtx = ctx as PiRuntimeContext;
430
+ const generation = ensureCurrentSession(runtimeCtx);
431
+ await runControlAction("off", runtimeCtx, generation);
432
+ },
433
+ });
434
+
284
435
  pi.on("session_start", async (_event, ctx) => {
285
436
  const runtimeCtx = ctx as PiRuntimeContext;
286
- state.ctx = runtimeCtx;
287
- if (state.mode !== "off") await startPolling(runtimeCtx);
437
+ const generation = beginSession(runtimeCtx);
438
+ if (state.mode !== "off") await startPolling(runtimeCtx, generation);
288
439
  });
289
440
 
290
441
  pi.on("session_shutdown", () => {
291
- stopPolling();
442
+ endSession();
292
443
  });
293
444
 
294
445
  pi.on("input", async (event, ctx) => {
295
446
  const action = parseControlCommand(event.text);
296
447
  if (!action) return { action: "continue" };
297
448
 
298
- await runControlAction(action, ctx as PiRuntimeContext);
449
+ const runtimeCtx = ctx as PiRuntimeContext;
450
+ const generation = ensureCurrentSession(runtimeCtx);
451
+ await runControlAction(action, runtimeCtx, generation);
299
452
  return { action: "handled" };
300
453
  });
301
- }
454
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-superwhisper-paste",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Pi extension that bridges Superwhisper clipboard output into the Pi TUI editor on Windows.",
5
5
  "type": "module",
6
6
  "license": "MIT",