@pi-archimedes/image-paste 2.10.3 → 2.11.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.
@@ -1,27 +1,72 @@
1
- import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
 
7
- import { isConfigEnabled, loadConfig, updateConfig } from "@pi-archimedes/core/settings-io";
7
+ import { diag } from "@pi-archimedes/core/log";
8
+ import {
9
+ isConfigEnabled,
10
+ loadConfig,
11
+ updateConfig,
12
+ writeAtomicPreservingSymlinks,
13
+ } from "@pi-archimedes/core/settings-io";
8
14
 
9
15
  const NAMESPACE = "archimedes.imagePaste";
10
16
 
17
+ /** pi's native clipboard action, the one key this package takes over. */
18
+ const UNBIND_KEY = "app.clipboard.pasteImage";
19
+
20
+ /** pi's published keybindings schema — the `$schema` value the suite writes. */
21
+ const PI_KEYBINDINGS_SCHEMA_URL = "https://pi.dev/schemas/keybindings.schema.json";
22
+
11
23
  const CONFIRM_TITLE = "First run";
24
+ // Wording is measured, not assumed: on Pi 1.2.0 the two bindings do NOT both
25
+ // run. `app.clipboard.pasteImage` is not on pi's reserved-keybinding list, so
26
+ // the extension shortcut wins (`runner.js:467-474`) and `handleInput` returns
27
+ // before the built-in's paste branch (`custom-editor.js:63-70`) — what the user
28
+ // gets without this file is one paste (ours) plus a standing `[Extension
29
+ // issues]` line, not two pastes.
12
30
  const CONFIRM_MESSAGE =
13
31
  "~/.pi/agent/keybindings.json is missing. Without it, Pi's built-in " +
14
32
  "app.clipboard.pasteImage binding (Ctrl+V on Linux/macOS, Alt+V on Windows) " +
15
- "double-fires with image-paste's Ctrl+V handler. Create the file now with the " +
33
+ "shares the paste key with image-paste's handler: image-paste's handler wins, " +
34
+ "Pi never runs its own action, and every session starts with an " +
35
+ "[Extension issues] shortcut-conflict line. Create the file now with the " +
16
36
  "docs snippet? It clears the built-in binding (/reload applies it).";
17
37
 
18
- /** Exactly the snippet from packages/image-paste/README.md → "Paste shortcuts". */
19
- const SNIPPET_JSON = '{ "app.clipboard.pasteImage": [] }';
38
+ /**
39
+ * Exactly the snippet from packages/image-paste/README.md → "Paste shortcuts".
40
+ *
41
+ * `$schema` points at pi's published keybindings schema. Read it as
42
+ * forward-compatibility and editor tooling, not as validation: pi 1.2.0 ships
43
+ * `KeybindingsSchema` (`dist/core/keybindings-schema.js:13`) with nothing in the
44
+ * shipped `dist` importing it, and what it validates at startup is *theme* JSON
45
+ * (`setThemeJsonValidator`, `dist/main.js:452`). What matters for us is that the
46
+ * loader ignores the key — `toKeybindingsConfig` does
47
+ * `if (key === "$schema") continue;` (`dist/core/keybindings.js:247-248`) — so
48
+ * the unbinding still applies, and the schema itself declares
49
+ * `"$schema": { "type": "string" }` (verified against the live
50
+ * https://pi.dev/schemas/keybindings.schema.json, whose `$id` is that URL).
51
+ */
52
+ const SNIPPET_JSON =
53
+ `{ "$schema": "${PI_KEYBINDINGS_SCHEMA_URL}", "${UNBIND_KEY}": [] }`;
20
54
 
21
55
  export const CREATED_NOTIFY = "Created ~/.pi/agent/keybindings.json — reloading now";
22
56
  /** Fallback wording for runtimes whose event ctx has no reload (Pi 0.85.1). */
23
57
  export const CREATED_RELOAD_HINT =
24
58
  "Created ~/.pi/agent/keybindings.json — run /reload to apply";
59
+ /**
60
+ * The restore twin of the two texts above. Same flow (see
61
+ * {@link reloadAfterKeybindingChange}), different verb: saying "Created
62
+ * ~/.pi/agent/keybindings.json" immediately after emptying that file would be
63
+ * false, and this suite tells the user what actually happened.
64
+ */
65
+ export const RESTORED_NOTIFY =
66
+ "Restored pi's native clipboard paste — app.clipboard.pasteImage removed from ~/.pi/agent/keybindings.json, reloading now";
67
+ /** Restore fallback for a ctx whose event context has no `reload`. */
68
+ export const RESTORED_RELOAD_HINT =
69
+ "Restored pi's native clipboard paste — run /reload to apply";
25
70
 
26
71
  interface PromptConfig {
27
72
  keybindingsPromptDone: boolean;
@@ -29,10 +74,95 @@ interface PromptConfig {
29
74
 
30
75
  const PROMPT_DEFAULTS: PromptConfig = { keybindingsPromptDone: false };
31
76
 
77
+ /** `archimedes.imagePaste`, the keys this module reads. */
78
+ interface ImagePasteConfig {
79
+ suppressNativePaste: boolean;
80
+ }
81
+
82
+ /**
83
+ * Default `true` = today's behaviour: the suite keeps the paste key and pi's
84
+ * native action stays unbound. Only an explicit `false` asks for the key back
85
+ * (strict comparison — `"false"` and `0` are not `false`, same rule as
86
+ * `isConfigEnabled`).
87
+ */
88
+ const IMAGE_PASTE_DEFAULTS: ImagePasteConfig = { suppressNativePaste: true };
89
+
90
+ /**
91
+ * Whether the suite may claim pi's paste key(s) for itself this session.
92
+ *
93
+ * `true` (the default) is today's behaviour: image-paste registers the paste
94
+ * shortcuts and pi's native `app.clipboard.pasteImage` stays unbound. `false` —
95
+ * the strict boolean, same rule as {@link runNativePasteRestore} — asks for the
96
+ * key back, and then BOTH halves of the promise apply: the file restore removes
97
+ * our unbinding from `keybindings.json`, AND `registerImagePaste` registers no
98
+ * shortcut, so the restored native binding is actually reachable.
99
+ *
100
+ * Why the second half is needed (pi 1.2.0, measured): `app.clipboard.pasteImage`
101
+ * is not in `RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS` (`dist/core/
102
+ * extensions/runner.js:9-27`), so `restrictOverride === false` and
103
+ * `ExtensionRunner.getShortcuts` keeps the extension shortcut and prints "…
104
+ * Using `<ext>`" (`dist/core/extensions/runner.js:467-474`); and
105
+ * `CustomEditor.handleInput` consults `onExtensionShortcut` before its
106
+ * `app.clipboard.pasteImage` branch and returns (`dist/modes/interactive/
107
+ * components/custom-editor.js:63-70`). While we hold the shortcut, an empty
108
+ * keybinding entry therefore changes nothing except adding a shortcut-conflict
109
+ * diagnostic — the key only comes back when we stop claiming it.
110
+ *
111
+ * Read per call, never cached at module scope: pi caches the extension factory
112
+ * (`dist/core/extensions/loader.js:461-489`) but re-invokes it per session, and
113
+ * the composed suite calls the registration from its own `session_start`
114
+ * handler, so a module-level flag would freeze the first session's answer.
115
+ */
116
+ export function suiteMayClaimPasteKey(): boolean {
117
+ return loadConfig<ImagePasteConfig>(NAMESPACE, IMAGE_PASTE_DEFAULTS).suppressNativePaste !== false;
118
+ }
119
+
32
120
  function messageOf(error: unknown): string {
33
121
  return error instanceof Error ? error.message : String(error);
34
122
  }
35
123
 
124
+ /**
125
+ * The reload path shared by the first-run offer and the restore: notify, then
126
+ * call `ExtensionCommandContext.reload` if this runtime puts it on the ctx
127
+ * (Pi 0.85.1 attaches it to the command context only, so the event ctx may have
128
+ * no `reload` — then the user is told to run `/reload` instead).
129
+ *
130
+ * `reload()` must be the LAST use of `ctx` — the reload invalidates this
131
+ * extension instance. A rejected reload is swallowed: the TUI shows its own
132
+ * "Reload failed" status and the session continues on the still-valid ctx.
133
+ *
134
+ * `opts.notify` (default `true`) suppresses BOTH messages, for a caller running
135
+ * on a UI-less ctx: the texts are UI messages and `runNativePasteRestore`'
136
+ * contract is to stay silent when `ctx.hasUI` is false. The reload itself is
137
+ * unaffected — a host that exposes `reload` still gets the file re-read.
138
+ *
139
+ * @returns `true` iff a reload was available and completed.
140
+ */
141
+ async function reloadAfterKeybindingChange(
142
+ ctx: ExtensionContext,
143
+ notifyText: string,
144
+ hintText: string,
145
+ opts?: { notify?: boolean },
146
+ ): Promise<boolean> {
147
+ const shouldNotify = opts?.notify ?? true;
148
+ // Cast: reload() is typed on ExtensionCommandContext; the session_start
149
+ // handler receives the base ExtensionContext, hence the presence guard.
150
+ const reload = (ctx as ExtensionCommandContext).reload;
151
+ if (typeof reload !== "function") {
152
+ if (shouldNotify) ctx.ui.notify(hintText, "info");
153
+ return false;
154
+ }
155
+ if (shouldNotify) ctx.ui.notify(notifyText, "info");
156
+ try {
157
+ await reload();
158
+ return true;
159
+ } catch {
160
+ // Swallowed on purpose: the TUI shows its own "Reload failed" status, and
161
+ // the file change is already persisted, so a manual /reload heals it.
162
+ return false;
163
+ }
164
+ }
165
+
36
166
  /**
37
167
  * Persist `keybindingsPromptDone: true` via updateConfig: optimistic re-read
38
168
  * check + bounded retry, so a concurrent write to settings.json (e.g. another
@@ -60,7 +190,10 @@ function markPromptDone(ctx: ExtensionContext): void {
60
190
  *
61
191
  * On the first TUI session, if `~/.pi/agent/keybindings.json` does not exist,
62
192
  * offer to create it with the docs snippet so Pi's built-in clipboard paste
63
- * doesn't double-fire with image-paste's Ctrl+V handler. Accepting writes the
193
+ * doesn't share the key with an action that would otherwise win — with both
194
+ * bindings live image-paste's handler is the one that runs, pi's own action
195
+ * never fires, and the session starts with a `[Extension issues]`
196
+ * shortcut-conflict line. Accepting writes the
64
197
  * file (atomic tmp+rename, pre-rename existence re-check) and then sets the
65
198
  * one-shot flag and, when the runtime exposes `reload()` on the session ctx,
66
199
  * reloads the TUI (the exact /reload flow: re-reads keybindings.json and
@@ -70,6 +203,13 @@ function markPromptDone(ctx: ExtensionContext): void {
70
203
  * resolves `false` either way) sets the flag without touching the file. A
71
204
  * failed file write leaves the flag unset, so the offer self-heals next session.
72
205
  *
206
+ * **Precondition (gate 1b, issue #133):** the offer only exists for an install
207
+ * that is going to hold the paste key. While `suppressNativePaste` is `false`
208
+ * the suite registers no paste shortcut and the restore hands the key back, so
209
+ * there is nothing to fix and the offer stays silent — without consuming
210
+ * `keybindingsPromptDone`, because the setting is reversible and closing it again
211
+ * must still be met by the offer. See the invariant comment at the gate.
212
+ *
73
213
  * **Concurrent-session edge:** if the user runs `/new` or `/reload` while the
74
214
  * confirm dialog is still open, `ctx.ui.confirm` resolves `false` (the TUI
75
215
  * tears down). This counts as a decline — the flag is set and the offer will
@@ -78,8 +218,8 @@ function markPromptDone(ctx: ExtensionContext): void {
78
218
  *
79
219
  * **Return contract:** resolves `true` iff the offer triggered a successful
80
220
  * `ctx.reload()` (the successful-accept path on a ctx that exposes `reload()`
81
- * and the reload completed). Every other outcome — the gates (1–4), decline,
82
- * a failed file write, the file-exists race, a base-shape ctx without
221
+ * and the reload completed). Every other outcome — the gates (1, 1b, 2–4),
222
+ * decline, a failed file write, the file-exists race, a base-shape ctx without
83
223
  * `reload` (manual /reload hint), and a reload that itself failed (the TUI
84
224
  * shows its own "Reload failed" and the session continues on the still-valid
85
225
  * ctx) — resolves `false`. The meta factory uses this to sequence the
@@ -98,6 +238,59 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<boolean
98
238
  return false;
99
239
  }
100
240
 
241
+ // Gate 1b: the suite must be the one holding the paste key, or there is
242
+ // nothing to offer.
243
+ //
244
+ // The subject of this whole flow is the file that unbinds pi's
245
+ // `app.clipboard.pasteImage` so our shortcut can own the key. With
246
+ // `archimedes.imagePaste.suppressNativePaste: false` the install has decided
247
+ // the opposite (ADR 0030's amendments): `registerImagePaste` registers no
248
+ // paste shortcut at all (the gate in packages/image-paste/src/index.ts) and
249
+ // `runNativePasteRestore` takes the unbinding back out of the file below. Offering to create it anyway asked
250
+ // permission for a file this install does not want, left `ctrl+v` bound to
251
+ // nobody (pi's action cleared by the file we just wrote, our handler not
252
+ // registered to take the key), and handed the next session's restore an
253
+ // artefact to empty — so "Created ~/.pi/agent/keybindings.json" and "Restored
254
+ // pi's native clipboard paste" both described one file, one session apart.
255
+ // Issue #133.
256
+ //
257
+ // WHY HERE: it is a settings read from the same namespace as gate 1, and both
258
+ // answer "does this install want this flow at all", so the two configuration
259
+ // preconditions sit together; the position also puts it before gate 4 stats
260
+ // keybindings.json, and that half is asserted — gate 4's check IS
261
+ // `existsSync(keybindingsPath)` and the spy over `node:fs`'s `existsSync` shows
262
+ // a hatch-off skip never names the path (moving this check after gate 4 fails
263
+ // that test). Gate 3's flag read is skipped by the same position but is not
264
+ // observable from outside — this gate reads settings.json itself, as gate 1
265
+ // already did — so nothing claims to prove that half. Inserting it as a
266
+ // numbered gate 2 was rejected: the existing gate numbers are cited outside this
267
+ // file — `docs/features/first-run-keybinding-offer.md`, ADR 0030's amendment
268
+ // ("gate 4 of the first-run offer refuses to touch a file that is already
269
+ // there") and the comments in `keybinding-offer.test.ts` — and renumbering would
270
+ // silently point every one of those citations at the wrong check. Gate 1 keeps
271
+ // first place (the ADR 0012 exception), and gate 2 keeps its documented reason
272
+ // for not consuming the flag in non-TUI modes.
273
+ //
274
+ // It must NOT consume `keybindingsPromptDone`. The hatch is reversible: the
275
+ // user who sets `suppressNativePaste` back to `true` has to meet the offer in
276
+ // a later session, which is the whole value of an escape hatch you can undo.
277
+ // Like gate 2's non-TUI skip, this one leaves the offer armed.
278
+ //
279
+ // INVARIANT (mirrored at the head of `runNativePasteRestore`): for a given
280
+ // configuration the two flows are mutually exclusive — the offer acts only
281
+ // when `suiteMayClaimPasteKey()` is true, the restore only when it is false.
282
+ // So no session can both create the file and hand it back, and one run can
283
+ // never tell the user "Created …" and "Restored …" about the same file. What
284
+ // this does NOT forbid is the sequenced pair across a settings change: accept
285
+ // the offer today, set `suppressNativePaste: false` tomorrow, and the restore
286
+ // gives the key back — that is the hatch doing what it says, not a breach. It
287
+ // forbids the #133 shape, where both fired under one unchanged setting.
288
+ // Pinned by the "the invariant" describe in `keybinding-offer.test.ts`, which
289
+ // drives both entry points in the order `meta/src/index.ts` runs them.
290
+ if (!suiteMayClaimPasteKey()) {
291
+ return false;
292
+ }
293
+
101
294
  // Gate 2: interactive TUI only. Deliberate: the flag is NOT consumed in
102
295
  // non-TUI modes, so a later TUI session still gets the offer.
103
296
  if (ctx.mode !== "tui") {
@@ -163,36 +356,546 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<boolean
163
356
  // write, per the design).
164
357
  markPromptDone(ctx);
165
358
 
166
- // Auto-apply: when the runtime exposes `reload()` on this ctx, run the
167
- // exact /reload TUI flow (re-reads keybindings.json + re-binds extension
168
- // shortcuts) so the cleared built-in binding applies immediately. Pi
169
- // 0.85.1 attaches `reload()` ONLY to the command context — the base
170
- // context that event handlers (like this session_start handler) receive
171
- // has no `reload` property, so auto-reload activates only on Pi versions
172
- // that add it to the event ctx; otherwise the user is told to run
173
- // /reload. reload() must be the LAST use of ctx — the reload invalidates
174
- // this extension instance. If the reload itself fails, the TUI shows its
175
- // own "Reload failed" status; the file + flag are already persisted, so a
176
- // manual /reload heals it and the offer is not repeated (flag gate).
177
- // Cast: reload() is typed on ExtensionCommandContext; the session_start
178
- // handler receives the base ExtensionContext, hence the presence guard.
179
- const reload = (ctx as ExtensionCommandContext).reload;
180
- if (typeof reload === "function") {
181
- ctx.ui.notify(CREATED_NOTIFY, "info");
182
- try {
183
- await reload();
184
- // The reload completed: the reloaded session runs the flow again (where
185
- // the offer is a no-op — keybindings.json now exists), so report that a
186
- // reload was triggered and the caller can skip the onboarding on this
187
- // now-stale ctx.
188
- return true;
189
- } catch {
190
- // Swallowed on purpose: the TUI shows its own "Reload failed" status;
191
- // the offer never throws. The reload FAILED, so the session continues on
192
- // the still-valid ctx → report `false` (the onboarding still runs).
193
- return false;
359
+ // Auto-apply goes through the shared reload path
360
+ // ({@link reloadAfterKeybindingChange}): notify, then reload() when this
361
+ // runtime puts it on the ctx. `true` means a reload was triggered, so the
362
+ // caller skips the onboarding on the now-stale ctx; a failed reload is
363
+ // swallowed there (the TUI shows its own "Reload failed"), and the offer
364
+ // never throws.
365
+ return reloadAfterKeybindingChange(ctx, CREATED_NOTIFY, CREATED_RELOAD_HINT);
366
+ }
367
+
368
+ // ── restoreNativePaste — ADR 0030, Amendment (2026-10-13) ───────────────────
369
+ //
370
+ // "Declining to suppress" cannot help the user who already accepted the
371
+ // first-run offer: they hold `~/.pi/agent/keybindings.json` containing our
372
+ // unbinding, which is exactly why pi's native `app.clipboard.pasteImage` is
373
+ // dead. So `archimedes.imagePaste.suppressNativePaste: false` has to give the
374
+ // key back in TWO halves: delete our own artefact from that file — the narrow
375
+ // exception to gate 4's "never rewrite an existing user keybindings file" rule —
376
+ // and stop claiming the key, which `registerImagePaste` does from the same
377
+ // setting (`suiteMayClaimPasteKey`). Removing the unbinding alone is not enough:
378
+ // while our shortcut is registered pi keeps it and the restored binding never
379
+ // runs. See
380
+ // docs/decisions/0030-image-paste-superset-not-supplement.md § Amendment.
381
+
382
+ /**
383
+ * Why a file was left alone. The five are NOT the same five files, and the
384
+ * warning no longer pretends otherwise (issue #127): `binding-not-empty` and an
385
+ * `extra-key` file that also still carries an empty `app.clipboard.pasteImage`
386
+ * hold an entry the user can delete, while foreign-keys-only /
387
+ * `$schema`-only files hold none, and the three unreadable shapes cannot be
388
+ * inspected at all. `manualEdit` branches on this — and on
389
+ * {@link pasteEntryFacts}, because which entry (if any) is in there is a fact
390
+ * the reason alone does not carry.
391
+ */
392
+ export type UntouchedReason =
393
+ /** Valid JSON that is not an object (an array, a string, a number…). */
394
+ | "not-an-object"
395
+ /** Any key we did not write, including a `$schema` with another URL. */
396
+ | "extra-key"
397
+ /** `app.clipboard.pasteImage` present with a value that is not `[]`. */
398
+ | "binding-not-empty"
399
+ /** The file does not parse. */
400
+ | "unparsable"
401
+ /** The file exists but cannot be read (EISDIR, EACCES…). */
402
+ | "unreadable";
403
+
404
+ /** What {@link restoreNativePaste} did to `keybindings.json`. */
405
+ export type RestoreNativePasteStatus =
406
+ /** No file at all — nothing to restore, nothing written. */
407
+ | { kind: "missing"; path: string }
408
+ /** The file already parses to `{}` — reported WITHOUT rewriting it. */
409
+ | { kind: "already-restored"; path: string }
410
+ /** Our artefact was removed and `{}` written; pi must re-read the file. */
411
+ | { kind: "restored"; path: string; reloadRequired: boolean }
412
+ /** Left byte-for-byte alone; `manualEdit` is what to do about it — for the
413
+ * refusals that hold one, the entry to remove; for the rest, no errand. */
414
+ | { kind: "untouched"; path: string; reason: UntouchedReason; manualEdit: string }
415
+ /**
416
+ * The rewrite itself failed (EACCES, EISDIR, ENOSPC…); the file is intact.
417
+ * A symlink LOOP is NOT in this case: `existsSync` follows links, so a loop —
418
+ * like a dangling link — reads as `"missing"` and the write is never
419
+ * attempted. Verified by test.
420
+ */
421
+ | { kind: "failed"; path: string; error: string; manualEdit: string };
422
+
423
+ /**
424
+ * What {@link runNativePasteRestore} did — the restoration statuses plus
425
+ * `"suppressed"`, meaning the settings did not ask for the key back.
426
+ */
427
+ export type NativePasteRestoreOutcome =
428
+ | RestoreNativePasteStatus
429
+ | { kind: "suppressed" };
430
+
431
+ /**
432
+ * The once-per-process notify guard. It lives on `globalThis`, NOT in module
433
+ * scope, and module scope cannot work here: pi re-evaluates the extension
434
+ * module on every load — `loadExtensionModule()` builds a fresh jiti with
435
+ * **`moduleCache: false`** each time it imports an extension (pi 1.2.0
436
+ * `dist/core/extensions/loader.js:477-481`, `moduleCache: false` at `:478`, the
437
+ * import at `:481`), and `ctx.reload()` clears the cached factory first
438
+ * (`dist/core/resource-loader.js:359` → `clearExtensionCache()`, defined at
439
+ * `dist/core/extensions/loader.js:89-93`). So a `let` at module scope is
440
+ * re-created on `/reload` and the user would be re-warned about the same
441
+ * untouched file.
442
+ *
443
+ * Both codebases already use this exact technique for this exact reason: pi
444
+ * keeps its theme instance on `globalThis` under a `Symbol.for` key — "Use
445
+ * globalThis to share theme across module loaders (node + jiti in dev mode)"
446
+ * (`dist/modes/interactive/theme/theme.js:500-517`; comment `:502`, key `:503`,
447
+ * read `:509`, writes `:516-517`) — and our own deleted `packages/core/src/bus.ts`
448
+ * was a `globalThis` singleton for the same reason.
449
+ */
450
+ const RESTORE_NOTIFIED_KEY = Symbol.for("pi-archimedes:image-paste:restore-notified");
451
+
452
+ /** Spend the guard: `true` the first time per process, `false` afterwards. */
453
+ function claimRestoreNotice(): boolean {
454
+ const g = globalThis as Record<symbol, unknown>;
455
+ if (g[RESTORE_NOTIFIED_KEY] === true) return false;
456
+ g[RESTORE_NOTIFIED_KEY] = true;
457
+ return true;
458
+ }
459
+
460
+ /**
461
+ * pi's pre-rename name for the paste action — `KEYBINDING_NAME_MIGRATIONS.pasteImage`
462
+ * (`dist/core/keybindings.js:225` in pi 1.2.0, and the same entry in 0.87.0),
463
+ * applied by `migrateKeybindingsConfig` (`:254-269`) before the bindings are
464
+ * built. So `"pasteImage": []` unbinds `app.clipboard.pasteImage` exactly as the
465
+ * current spelling does; measured on both versions, pi resolves the action to
466
+ * `[]` for that file.
467
+ *
468
+ * We still never rewrite such a file — the accept-set is only what this suite
469
+ * has actually written, which is the modern name — but a refusal that calls that
470
+ * file one "pi's own paste answers" is describing the opposite of what pi does
471
+ * with it. pi's startup migration rewrites legacy names on disk
472
+ * (`migrations.js:139-157`) before extensions load, and its own `catch {}` means
473
+ * a read-only agent dir leaves the legacy file in place; `keybindings.reload()`
474
+ * on `/reload` (`dist/modes/interactive/interactive-mode.js:5406`) re-reads
475
+ * without migrating, so a file replaced by a dotfiles sync mid-session reaches
476
+ * us un-migrated either way.
477
+ */
478
+ const LEGACY_UNBIND_KEY = "pasteImage";
479
+
480
+ /** Which entry of a file we read keeps pi's paste action switched off. */
481
+ type PasteBlocker =
482
+ /** Nothing in the file unbinds the action, under either name. */
483
+ | "none"
484
+ /** `[]` under the name this suite writes. */
485
+ | "current"
486
+ /** `[]` under pi's pre-rename name (`{@link LEGACY_UNBIND_KEY}`). */
487
+ | "legacy";
488
+
489
+ /**
490
+ * What a readable file does to the paste key, as one value, so the guidance and
491
+ * the reason cannot be derived from different facts.
492
+ */
493
+ interface PasteEntryFacts {
494
+ blocker: PasteBlocker;
495
+ /**
496
+ * `[]` under the old name, sitting behind a modern entry that pi prefers: the
497
+ * migrator drops the legacy entry while the modern key exists
498
+ * (`keybindings.js:260-264`), so the old name is invisible today and takes the
499
+ * binding back the moment the modern entry is deleted — i.e. right after the
500
+ * user does what the warning tells them to do.
501
+ */
502
+ legacyBehindModern: boolean;
503
+ }
504
+
505
+ /** The facts for a file we never got far enough with to inspect. */
506
+ const UNSEEN_FILE_FACTS: PasteEntryFacts = { blocker: "none", legacyBehindModern: false };
507
+
508
+ function isEmptyBinding(value: unknown): boolean {
509
+ return Array.isArray(value) && value.length === 0;
510
+ }
511
+
512
+ /** The facts {@link guidanceLine} needs, read off the parsed object. */
513
+ function pasteEntryFacts(obj: Record<string, unknown>): PasteEntryFacts {
514
+ const current = isEmptyBinding(obj[UNBIND_KEY]);
515
+ const legacy = isEmptyBinding(obj[LEGACY_UNBIND_KEY]);
516
+ return {
517
+ // The modern name wins when both are present, because pi drops the legacy
518
+ // entry in that case — so it decides which entry is the blocker.
519
+ blocker: current ? "current" : legacy ? "legacy" : "none",
520
+ legacyBehindModern: !current && legacy && UNBIND_KEY in obj,
521
+ };
522
+ }
523
+
524
+ /**
525
+ * The instruction for the one refusal that really does hold OUR unbinding: the
526
+ * entry is in there, removing it is the fix, and until it goes nothing answers
527
+ * the key (the file empties it and `suppressNativePaste: false` took our
528
+ * shortcut). Shared by the `extra-key`-with-unbinding refusal and by `failed`,
529
+ * whose file is our artefact.
530
+ */
531
+ function removeOurEntryGuidance(path: string): string {
532
+ return (
533
+ `To give the paste key back to pi, remove the "${UNBIND_KEY}" entry from ` +
534
+ `${path} and run /reload. ` +
535
+ "image-paste is not binding the paste key either (suppressNativePaste is off), " +
536
+ "so that key does nothing until you do."
537
+ );
538
+ }
539
+
540
+ /**
541
+ * The same refusal for the same dead key, reached through pi's old name for the
542
+ * action: an entry IS there and removing it IS the fix, but it is spelled
543
+ * `pasteImage`, so naming the modern key would send the user looking for a line
544
+ * that is not in the file. It is not our artefact, so it is not named as ours
545
+ * and never rewritten — only described.
546
+ */
547
+ function removeLegacyEntryGuidance(path: string): string {
548
+ return (
549
+ `To give the paste key back to pi, remove the "${LEGACY_UNBIND_KEY}" entry from ` +
550
+ `${path} and run /reload — that is the old name for "${UNBIND_KEY}", and pi ` +
551
+ "still reads it as an unbinding of the paste action. image-paste is not " +
552
+ "binding the paste key either (suppressNativePaste is off), so that key does " +
553
+ "nothing until you do."
554
+ );
555
+ }
556
+
557
+ /**
558
+ * The guidance line for a file the extension must not touch, naming the path and
559
+ * saying only what this file actually supports (issue #127, and the PR #129
560
+ * review of the first split).
561
+ *
562
+ * One warning used to cover every refusal, and it ordered the user to "remove the
563
+ * `app.clipboard.pasteImage` entry" — true only when such an entry is in there.
564
+ * For a file of foreign keys, a `$schema`-only file, and the three shapes we
565
+ * cannot read, that instruction sends someone hunting a line that is not in the
566
+ * file, in a file that needs no edit at all. So the wording branches:
567
+ *
568
+ * * `binding-not-empty` — an entry IS there, and removing it is still how the key
569
+ * gets back to pi, but it is the user's own binding, so it is named as theirs
570
+ * and the key is not called dead (their binding answers it). If pi's OLD name
571
+ * sits behind it with `[]`, the sentence says to clear that one too: pi drops
572
+ * the legacy entry only while the modern key exists, so deleting just the
573
+ * modern one would hand the key straight back to the old unbinding.
574
+ * * `extra-key` holding our `[]` — the original wording, and the case where
575
+ * "that key does nothing until you do" is true of a file we wrote.
576
+ * * `extra-key` holding `[]` under pi's old name — the same dead key and the same
577
+ * fix, but the entry is named the way the file spells it.
578
+ * * `extra-key` with neither — nothing of ours to clean up, nothing to edit, and
579
+ * pi answers the key on its own default binding, so no reload is asked for
580
+ * either. No claim about who wrote the file: `$schema` with pi's published URL
581
+ * is byte-for-byte what the offer writes, and it is also what a hand-written
582
+ * file can carry, so authorship is not a fact the refusal can see.
583
+ * * the three unreadable shapes — the file is named and the cause is named, and
584
+ * what happens next is stated rather than left unknown: pi throws away a file
585
+ * it cannot use and every action keeps its default
586
+ * (`loadRawConfig` → `undefined` → `{}` user bindings,
587
+ * `dist/core/keybindings.js:285-297`), which means pi's own paste IS answering
588
+ * the key and none of the file's other bindings are in force. Asking the user
589
+ * to go read a file pi is ignoring would leave them looking in the wrong place.
590
+ *
591
+ * {@link pasteEntryFacts} is the one value carrying what the reason alone cannot:
592
+ * `extra-key` covers "an unbinding beside a foreign key" and "no unbinding at
593
+ * all", which need opposite instructions, and it does not say which spelling of
594
+ * the action the file uses. The unreadable shapes get {@link UNSEEN_FILE_FACTS}.
595
+ */
596
+ function guidanceLine(path: string, reason: UntouchedReason, facts: PasteEntryFacts): string {
597
+ switch (reason) {
598
+ case "binding-not-empty":
599
+ return (
600
+ `The "${UNBIND_KEY}" binding in ${path} is yours, not something this suite ` +
601
+ "wrote, so it is yours to hand back: remove that entry and run /reload. " +
602
+ "image-paste is not binding the paste key either (suppressNativePaste is off)." +
603
+ (facts.legacyBehindModern
604
+ ? ` The file also carries the old name "${LEGACY_UNBIND_KEY}", which pi reads ` +
605
+ "as the same action, so clear that entry too — once the entry above is gone " +
606
+ "it is the one keeping the paste key unbound."
607
+ : "")
608
+ );
609
+ case "extra-key":
610
+ if (facts.blocker === "current") return removeOurEntryGuidance(path);
611
+ if (facts.blocker === "legacy") return removeLegacyEntryGuidance(path);
612
+ return (
613
+ `Nothing in ${path} is ours to clean up and nothing in it clears pi's ` +
614
+ "paste binding, so there is nothing for you to edit — pi's own paste " +
615
+ "answers that key on its default binding. image-paste registers no paste " +
616
+ "shortcut of its own while suppressNativePaste is off."
617
+ );
618
+ case "unreadable":
619
+ case "unparsable":
620
+ case "not-an-object": {
621
+ const cause =
622
+ reason === "unreadable"
623
+ ? "it could not be read"
624
+ : reason === "unparsable"
625
+ ? "it does not parse as JSON"
626
+ : "it is not a JSON object";
627
+ return (
628
+ `The file is left exactly as it is; ${cause}, and pi treats a file it ` +
629
+ "cannot use as if it held no bindings at all — every action keeps its " +
630
+ "default, so pi's own paste is answering the key and nothing else in that " +
631
+ `file is in force. Please check ${path} yourself, and run /reload once it ` +
632
+ "is readable again. image-paste registers no paste shortcut of its own " +
633
+ "while suppressNativePaste is off."
634
+ );
194
635
  }
195
636
  }
196
- ctx.ui.notify(CREATED_RELOAD_HINT, "info");
197
- return false;
198
637
  }
638
+
639
+ /** The guidance for a file we read, from the same read that gives the reason. */
640
+ function guidanceForReadFile(
641
+ path: string,
642
+ obj: Record<string, unknown>,
643
+ reason: UntouchedReason,
644
+ ): string {
645
+ return guidanceLine(path, reason, pasteEntryFacts(obj));
646
+ }
647
+
648
+ /** The guidance for a file we never got far enough with to inspect. */
649
+ function guidanceForUnreadableFile(path: string, reason: UntouchedReason): string {
650
+ return guidanceLine(path, reason, UNSEEN_FILE_FACTS);
651
+ }
652
+
653
+ function agentKeybindingsPath(): string {
654
+ return join(getAgentDir(), "keybindings.json");
655
+ }
656
+
657
+ /**
658
+ * True only for the artefact this suite has written, in either of its two
659
+ * shapes:
660
+ *
661
+ * * before PR #123 — `{ "app.clipboard.pasteImage": [] }`
662
+ * * since PR #123 — `{ "$schema": "https://pi.dev/schemas/keybindings.schema.json",
663
+ * "app.clipboard.pasteImage": [] }`
664
+ *
665
+ * **Accept-set decision (the amendment asks for it explicitly):** the `$schema`
666
+ * + `app.clipboard.pasteImage: []` pair IS accepted, because that is literally
667
+ * what the suite writes today — refusing it would leave every current install
668
+ * unable to give the key back without a hand edit. `$schema` counts as ours
669
+ * only with pi's published URL, which keeps the set closed without widening it.
670
+ * Nothing else is ever accepted: not a third key, not a different `$schema`,
671
+ * not a non-empty binding — and not pi's old name for the action either
672
+ * ({@link LEGACY_UNBIND_KEY}). A file holding `"pasteImage": []` clears the
673
+ * paste key just as effectively, but this suite never wrote that spelling, so it
674
+ * is refused (and explained) rather than rewritten.
675
+ */
676
+ function isSuiteOwnedUnbinding(obj: Record<string, unknown>): boolean {
677
+ if (!isEmptyBinding(obj[UNBIND_KEY])) return false;
678
+ const keys = Object.keys(obj);
679
+ if (keys.length === 1) return true;
680
+ return (
681
+ keys.length === 2 &&
682
+ keys.every((key) => key === UNBIND_KEY || key === "$schema") &&
683
+ obj["$schema"] === PI_KEYBINDINGS_SCHEMA_URL
684
+ );
685
+ }
686
+
687
+ /**
688
+ * Remove the suite's `app.clipboard.pasteImage` unbinding from
689
+ * `~/.pi/agent/keybindings.json` and return what it did. **It never logs and
690
+ * never notifies** — the caller surfaces the outcome (see
691
+ * {@link runNativePasteRestore}), so one run per session cannot produce two
692
+ * messages and a headless caller stays silent on its own.
693
+ *
694
+ * Rules (ADR 0030 amendment):
695
+ *
696
+ * * Only ever acts on the suite's own artefact, and the write only ever
697
+ * *removes* the key — the result is `{}`.
698
+ * * Anything else — extra keys, a non-empty binding, a foreign `$schema`, pi's
699
+ * old name for the action, a file that does not parse or cannot be read — is
700
+ * left byte-for-byte alone, and the returned `"untouched"` status carries the
701
+ * path plus the guidance that refusal warrants ({@link guidanceLine}) — the
702
+ * entry to remove only when an entry really is in there, spelled the way the
703
+ * file spells it.
704
+ * * Idempotent by content: `{}` is reported as `"already-restored"` **without a
705
+ * rewrite**, so the flag cannot touch a user file on every session start.
706
+ * * Writes atomically through {@link writeAtomicPreservingSymlinks}, so a
707
+ * `keybindings.json` that is a symlink to somewhere else stays a symlink
708
+ * (the offer's own tmp+rename-onto-path is deliberately NOT reused — it only
709
+ * ever *creates* a missing file, where link preservation cannot matter). A
710
+ * DANGLING link never reaches that helper either: `existsSync` follows the
711
+ * link, so the status is `"missing"` and nothing is written through it.
712
+ * * Never throws: a failed write comes back as `"failed"`.
713
+ */
714
+ export function restoreNativePaste(): RestoreNativePasteStatus {
715
+ const path = agentKeybindingsPath();
716
+ if (!existsSync(path)) return { kind: "missing", path };
717
+
718
+ let raw: string;
719
+ try {
720
+ raw = readFileSync(path, "utf-8");
721
+ } catch {
722
+ return {
723
+ kind: "untouched",
724
+ path,
725
+ reason: "unreadable",
726
+ manualEdit: guidanceForUnreadableFile(path, "unreadable"),
727
+ };
728
+ }
729
+
730
+ let parsed: unknown;
731
+ try {
732
+ parsed = JSON.parse(raw);
733
+ } catch {
734
+ return {
735
+ kind: "untouched",
736
+ path,
737
+ reason: "unparsable",
738
+ manualEdit: guidanceForUnreadableFile(path, "unparsable"),
739
+ };
740
+ }
741
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
742
+ return {
743
+ kind: "untouched",
744
+ path,
745
+ reason: "not-an-object",
746
+ manualEdit: guidanceForUnreadableFile(path, "not-an-object"),
747
+ };
748
+ }
749
+
750
+ const obj = parsed as Record<string, unknown>;
751
+ // Already restored: report it and leave the bytes (and their mtime) alone.
752
+ if (Object.keys(obj).length === 0) return { kind: "already-restored", path };
753
+
754
+ if (!isSuiteOwnedUnbinding(obj)) {
755
+ // Root cause, for the log: the binding itself being someone else's beats
756
+ // "there is a key we did not write" (a foreign `$schema` alongside a real
757
+ // `[]` unbinding is the extra key, not a live binding). The legacy spelling
758
+ // is an extra key by this test — pi renames it later, and `guidanceLine`
759
+ // says so to the user.
760
+ const reason: UntouchedReason =
761
+ UNBIND_KEY in obj && !isEmptyBinding(obj[UNBIND_KEY]) ? "binding-not-empty" : "extra-key";
762
+ return {
763
+ kind: "untouched",
764
+ path,
765
+ reason,
766
+ manualEdit: guidanceForReadFile(path, obj, reason),
767
+ };
768
+ }
769
+
770
+ try {
771
+ writeAtomicPreservingSymlinks(path, "{}");
772
+ } catch (error) {
773
+ // The file IS our artefact — that is the only way to reach a write — so the
774
+ // unbinding really is in there and removing it really is the fix.
775
+ return {
776
+ kind: "failed",
777
+ path,
778
+ error: messageOf(error),
779
+ manualEdit: removeOurEntryGuidance(path),
780
+ };
781
+ }
782
+ return { kind: "restored", path, reloadRequired: true };
783
+ }
784
+
785
+ /** One-line detail for `diag` — path and key included, never the console. */
786
+ function describeRestoreStatus(status: RestoreNativePasteStatus): string {
787
+ switch (status.kind) {
788
+ case "missing":
789
+ return `restoreNativePaste: nothing to restore — ${status.path} does not exist`;
790
+ case "already-restored":
791
+ return `restoreNativePaste: already restored — ${status.path} holds no keys (no rewrite)`;
792
+ case "restored":
793
+ return (
794
+ `restoreNativePaste: restored — removed "${UNBIND_KEY}" from ${status.path}, ` +
795
+ `wrote {} (reloadRequired=${status.reloadRequired})`
796
+ );
797
+ case "untouched":
798
+ return (
799
+ `restoreNativePaste: untouched (${status.reason}) — ${status.path} was left as-is. ` +
800
+ status.manualEdit
801
+ );
802
+ case "failed":
803
+ return `restoreNativePaste: failed — ${status.path}: ${status.error}. ${status.manualEdit}`;
804
+ }
805
+ }
806
+
807
+ /**
808
+ * The `session_start` call site: read the setting, restore if asked, and surface
809
+ * the outcome. Runs from whichever path loaded the package — image-paste's own
810
+ * `session_start` registration for standalone installs, or meta's composed
811
+ * `session_start` for the suite — and from each exactly once per session.
812
+ * Handlers stay registered at the top level of the registering factory, never
813
+ * nested (AGENTS.md).
814
+ *
815
+ * Surfacing rule: the actionable "this file is yours" outcome gets one
816
+ * `ctx.ui.notify(…, "warning")` **per process** — the guard lives on
817
+ * `globalThis` (see {@link RESTORE_NOTIFIED_KEY}) because a module-level flag
818
+ * would be reset by `/reload`. `diag("image-paste", …)` carries the detail on
819
+ * every outcome and both may fire, but `diag` cannot be the only channel: it is
820
+ * a no-op unless `PI_ARCHIMEDES_LOG` names a file, so a default install would be
821
+ * silent while pi's paste stayed dead. No notify at all when `ctx.hasUI` is
822
+ * false — on ANY outcome, including a successful restore, whose info and
823
+ * `/reload`-hint texts are UI messages too — and a headless run does not spend
824
+ * the guard. Nothing here uses
825
+ * `console.*` (`docs/features/ui.md`, guarded by
826
+ * `packages/core/src/log-discipline.test.ts`).
827
+ *
828
+ * A successful restore goes through the existing reload path so pi re-reads
829
+ * `keybindings.json`; on a ctx without `reload` the returned status says
830
+ * `/reload` is required. Never throws — a session start must not fail because a
831
+ * keybinding file is awkward.
832
+ */
833
+ export async function runNativePasteRestore(
834
+ ctx: ExtensionContext,
835
+ ): Promise<NativePasteRestoreOutcome> {
836
+ // Same predicate the shortcut gate reads, so the file restore and the
837
+ // registration can never disagree about what the user asked for.
838
+ //
839
+ // INVARIANT (mirrored at gate 1b of `offerKeybindingFix`): for a given
840
+ // configuration only one of the two paste-key flows acts — this one runs only
841
+ // when the suite will NOT claim the key, the offer only when it will. So no
842
+ // session creates `keybindings.json` and then empties it, and one run cannot
843
+ // say both "Created …" and "Restored …" about one file. A user who changes the
844
+ // setting between sessions legitimately gets *Created* first and *Restored*
845
+ // later: that is the reversible hatch, not the breach this forbids.
846
+ if (suiteMayClaimPasteKey()) {
847
+ return { kind: "suppressed" };
848
+ }
849
+
850
+ const status = restoreNativePaste();
851
+ diag(
852
+ "image-paste",
853
+ `[archimedes:restore-native-paste] ${describeRestoreStatus(status)}`,
854
+ );
855
+
856
+ if (status.kind === "untouched" || status.kind === "failed") {
857
+ // "We did not touch it" — one actionable warning naming the path, spent at
858
+ // most once per process. What it asks of the user comes from the status
859
+ // itself (`manualEdit`, i.e. {@link guidanceLine}), because the refusals are
860
+ // not the same files: only a file that still holds an unbinding of the paste
861
+ // action has anything to delete, and issue #127 is the warning that used to
862
+ // order all of them to go look for one.
863
+ if (ctx.hasUI && claimRestoreNotice()) {
864
+ const failure =
865
+ status.kind === "failed" ? `The write failed (${status.error}). ` : "";
866
+ // `manualEdit` carries the second half of the promise too — that with the
867
+ // flag off image-paste registers NO paste shortcut — worded per refusal:
868
+ // "that key does nothing until you do" only where the file really does
869
+ // leave the key unbound (our `[]`, or pi's old name for it), "pi's own
870
+ // paste answers that key" where it does not, and "pi is running on its
871
+ // defaults, check the file" where it cannot be read at all. `describeRestoreStatus`
872
+ // feeds the SAME `manualEdit` to `diag`, so the log and the warning cannot
873
+ // tell two stories about one file — asserted, not merely intended, by the
874
+ // `keybinding-offer.test.ts` case that names the mutant it guards.
875
+ ctx.ui.notify(
876
+ `image-paste did not change ${status.path}. ${failure}${status.manualEdit}`,
877
+ "warning",
878
+ );
879
+ }
880
+ return status;
881
+ }
882
+
883
+ if (status.kind === "restored") {
884
+ // Last use of ctx: reload() invalidates this extension instance.
885
+ // `notify: ctx.hasUI` keeps the silence promise for a UI-less ctx: the
886
+ // restore still runs and `diag` still records it, but no UI message is
887
+ // pushed at a host that said it has no UI.
888
+ const reloaded = await reloadAfterKeybindingChange(
889
+ ctx,
890
+ RESTORED_NOTIFY,
891
+ RESTORED_RELOAD_HINT,
892
+ { notify: ctx.hasUI },
893
+ );
894
+ return { ...status, reloadRequired: !reloaded };
895
+ }
896
+
897
+ // "missing" / "already-restored": nothing happened and nothing is asked of
898
+ // the user, so no notify — the log line above is the record.
899
+ return status;
900
+ }
901
+