@trygocode/notify 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -529,6 +529,34 @@ npm publish, real-device E2E), see
529
529
 
530
530
  ## Changelog
531
531
 
532
+ ### 0.6.0
533
+
534
+ - **Real native macOS "Allow Notifications" dialog (signed helper).** GoCode now
535
+ ships a tiny **Developer-ID-signed** macOS app bundle (`GoCodeNotifier.app`,
536
+ Team `88DC7KFY7M`) inside the package. On macOS, `gocode-notify permissions`
537
+ (and the post-`setup` nudge) now pop the **real system Allow/Don't-Allow
538
+ permission modal** — the thing a plain `npx`/`osascript` helper can't do —
539
+ instead of only opening System Settings and asking you to hunt for the toggle.
540
+ Once allowed, desktop banners post as a **first-class, authorized** app
541
+ (proper attribution + a real click-to-open-your-IDE action).
542
+ - **How it ships:** the bundle is **pre-signed at build time** by the
543
+ maintainer (`native/macos/build_and_sign.sh`) and committed to `assets/`, so
544
+ end-users need **no Xcode, no Developer ID, no compile step** — the one-line
545
+ install is unchanged. The signature survives the npm pack/extract round-trip.
546
+ - **Two macOS rules it honors (both required for the dialog to appear):** the
547
+ helper is copied to a **stable** location (`~/.gocode/desktop/`) — never
548
+ `/tmp` — and is always launched via **LaunchServices** (`open`), never by
549
+ exec'ing the inner binary (direct-exec auto-denies and the denial sticks).
550
+ - **Hot-path stays fast:** fire-and-forget banners read a cached auth status
551
+ (`~/.gocode/desktop/.auth-status`), so a notification never does a slow live
552
+ permission round-trip. The signed banner is used only once you've granted;
553
+ otherwise it transparently falls back to the legacy applet banner.
554
+ - **Own identity:** the signed helper uses bundle id `com.gocode.notify.helper`
555
+ (distinct from the legacy applet's `com.gocode.notify.desktop`) so it owns
556
+ its own row in System Settings → Notifications.
557
+ - **Linux/Windows unchanged.** This is a macOS-only enhancement; other
558
+ platforms keep their existing branded-banner paths.
559
+
532
560
  ### 0.5.0
533
561
 
534
562
  - **Question detection across ALL IDEs — "Agent needs you" vs "finished".** When
@@ -0,0 +1,37 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!-- CFBundleIdentifier MUST stay stable across rebuilds: macOS keys the user's
6
+ notification-authorization grant off it, so changing it would forget the
7
+ Allow. This is DISTINCT from the legacy applet's `com.gocode.notify.desktop`
8
+ on purpose: the signed helper owns its own identity in System Settings →
9
+ Notifications so a poisoned/denied state on the old shared id can't block
10
+ the signed app's grant. Mirrors HELPER_BUNDLE_ID in mac_helper.ts. -->
11
+ <key>CFBundleIdentifier</key>
12
+ <string>com.gocode.notify.helper</string>
13
+ <key>CFBundleName</key>
14
+ <string>GoCode</string>
15
+ <key>CFBundleDisplayName</key>
16
+ <string>GoCode</string>
17
+ <key>CFBundleExecutable</key>
18
+ <string>GoCodeNotifier</string>
19
+ <key>CFBundlePackageType</key>
20
+ <string>APPL</string>
21
+ <key>CFBundleInfoDictionaryVersion</key>
22
+ <string>6.0</string>
23
+ <key>CFBundleShortVersionString</key>
24
+ <string>1.0.0</string>
25
+ <key>CFBundleVersion</key>
26
+ <string>1</string>
27
+ <!-- LSUIElement: a faceless background helper — no Dock icon, no menu bar. -->
28
+ <key>LSUIElement</key>
29
+ <true/>
30
+ <key>LSMinimumSystemVersion</key>
31
+ <string>11.0</string>
32
+ <key>CFBundleIconFile</key>
33
+ <string>AppIcon</string>
34
+ <key>NSHumanReadableCopyright</key>
35
+ <string>© BIGSMS Pty Ltd</string>
36
+ </dict>
37
+ </plist>
@@ -0,0 +1,128 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>files</key>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ GbhU1RfeSzs8A7x841vrx8S9NpM=
10
+ </data>
11
+ </dict>
12
+ <key>files2</key>
13
+ <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ 80/V6KfdsY5g+nVZNtqimRwukW+CXDnk+bV0NlYa75w=
19
+ </data>
20
+ </dict>
21
+ </dict>
22
+ <key>rules</key>
23
+ <dict>
24
+ <key>^Resources/</key>
25
+ <true/>
26
+ <key>^Resources/.*\.lproj/</key>
27
+ <dict>
28
+ <key>optional</key>
29
+ <true/>
30
+ <key>weight</key>
31
+ <real>1000</real>
32
+ </dict>
33
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
34
+ <dict>
35
+ <key>omit</key>
36
+ <true/>
37
+ <key>weight</key>
38
+ <real>1100</real>
39
+ </dict>
40
+ <key>^Resources/Base\.lproj/</key>
41
+ <dict>
42
+ <key>weight</key>
43
+ <real>1010</real>
44
+ </dict>
45
+ <key>^version.plist$</key>
46
+ <true/>
47
+ </dict>
48
+ <key>rules2</key>
49
+ <dict>
50
+ <key>.*\.dSYM($|/)</key>
51
+ <dict>
52
+ <key>weight</key>
53
+ <real>11</real>
54
+ </dict>
55
+ <key>^(.*/)?\.DS_Store$</key>
56
+ <dict>
57
+ <key>omit</key>
58
+ <true/>
59
+ <key>weight</key>
60
+ <real>2000</real>
61
+ </dict>
62
+ <key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
63
+ <dict>
64
+ <key>nested</key>
65
+ <true/>
66
+ <key>weight</key>
67
+ <real>10</real>
68
+ </dict>
69
+ <key>^.*</key>
70
+ <true/>
71
+ <key>^Info\.plist$</key>
72
+ <dict>
73
+ <key>omit</key>
74
+ <true/>
75
+ <key>weight</key>
76
+ <real>20</real>
77
+ </dict>
78
+ <key>^PkgInfo$</key>
79
+ <dict>
80
+ <key>omit</key>
81
+ <true/>
82
+ <key>weight</key>
83
+ <real>20</real>
84
+ </dict>
85
+ <key>^Resources/</key>
86
+ <dict>
87
+ <key>weight</key>
88
+ <real>20</real>
89
+ </dict>
90
+ <key>^Resources/.*\.lproj/</key>
91
+ <dict>
92
+ <key>optional</key>
93
+ <true/>
94
+ <key>weight</key>
95
+ <real>1000</real>
96
+ </dict>
97
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
98
+ <dict>
99
+ <key>omit</key>
100
+ <true/>
101
+ <key>weight</key>
102
+ <real>1100</real>
103
+ </dict>
104
+ <key>^Resources/Base\.lproj/</key>
105
+ <dict>
106
+ <key>weight</key>
107
+ <real>1010</real>
108
+ </dict>
109
+ <key>^[^/]+$</key>
110
+ <dict>
111
+ <key>nested</key>
112
+ <true/>
113
+ <key>weight</key>
114
+ <real>10</real>
115
+ </dict>
116
+ <key>^embedded\.provisionprofile$</key>
117
+ <dict>
118
+ <key>weight</key>
119
+ <real>20</real>
120
+ </dict>
121
+ <key>^version\.plist$</key>
122
+ <dict>
123
+ <key>weight</key>
124
+ <real>20</real>
125
+ </dict>
126
+ </dict>
127
+ </dict>
128
+ </plist>
package/dist/src/cli.js CHANGED
@@ -21,7 +21,7 @@ import { setup } from "./setup.js";
21
21
  import { uninstall } from "./uninstall.js";
22
22
  import { cmdConfig } from "./config.js";
23
23
  import { onStop } from "./on_stop.js";
24
- import { notifyDesktop } from "./desktop_notify.js";
24
+ import { notifyDesktop, requestDesktopPermission } from "./desktop_notify.js";
25
25
  import { gatherDoctor, formatDoctor } from "./doctor.js";
26
26
  /** Subcommands the finished CLI will expose (see PRD §4.1). */
27
27
  export const COMMANDS = [
@@ -31,6 +31,7 @@ export const COMMANDS = [
31
31
  "setup",
32
32
  "status",
33
33
  "doctor",
34
+ "permissions",
34
35
  "mcp",
35
36
  "uninstall",
36
37
  "config",
@@ -93,6 +94,7 @@ export function printHelp() {
93
94
  " setup Pair + detect runtimes + write configs",
94
95
  " status Report credentials / server / detected runtimes",
95
96
  " doctor Self-diagnostic checklist with exact fix commands",
97
+ " permissions Request macOS desktop-notification permission (opens Settings)",
96
98
  " mcp Run as an MCP server over stdio",
97
99
  " uninstall Remove entries this tool added",
98
100
  " config Get/set Notify settings (get | set <key> <value> | pull)",
@@ -366,6 +368,48 @@ export async function cmdTest(args, deps = {}) {
366
368
  console.error(`gocode-notify test: ${detail}.`);
367
369
  return 1;
368
370
  }
371
+ /**
372
+ * Handle `gocode-notify permissions` (alias `perms`). Makes the OS
373
+ * notification-permission UI appear so the user grants GoCode desktop banners
374
+ * with a click instead of hunting through Settings.
375
+ *
376
+ * On macOS this now pops the REAL native Allow/Deny modal via the Developer-ID-
377
+ * signed helper bundled in the package (see {@link requestDesktopPermission}); if
378
+ * the signed helper is unavailable or you previously denied, it falls back to
379
+ * registering the branded bundle, opening System Settings → Notifications, and
380
+ * firing a priming banner. Always exits 0 — best-effort convenience, never a gate.
381
+ */
382
+ export async function cmdPermissions(args, deps = {}) {
383
+ const flags = parseFlags(args);
384
+ const agent = isAgentDriven(flags);
385
+ const sink = deps.sink ?? stdoutSink;
386
+ const res = await requestDesktopPermission({
387
+ home: deps.home,
388
+ timeoutMs: deps.timeoutMs,
389
+ timestamp: deps.timestamp,
390
+ });
391
+ if (agent) {
392
+ // `ok` reflects whether the NOTIFICATION path actually worked (the priming
393
+ // banner fired) — NOT merely that we opened Settings. On macOS, opening
394
+ // Settings is only a partial remediation; reporting ok:true off it alone
395
+ // would be false-success telemetry for automation consuming the sink.
396
+ sink({
397
+ step: "permissions",
398
+ ok: res.primed,
399
+ detail: res.message,
400
+ });
401
+ }
402
+ else {
403
+ console.log(`🔔 ${res.message}`);
404
+ if (res.platform === "darwin" && !res.primed) {
405
+ // Only show the Settings-hunt hint when we did NOT manage a real grant
406
+ // (e.g. previously denied, or the signed helper wasn't available).
407
+ console.log(" (If no dialog appeared, enable “Allow Notifications” for GoCode in\n" +
408
+ " System Settings → Notifications — it’s now one tap away.)");
409
+ }
410
+ }
411
+ return 0;
412
+ }
369
413
  /**
370
414
  * Handle `gocode-notify status [--server URL]`. Prints a coherent report of
371
415
  * credentials / server reachability / detected runtimes (PRD §4.1, §8). Purely
@@ -462,6 +506,28 @@ export async function cmdSetup(args, deps = {}) {
462
506
  console.log(` ${step.ok ? "✓" : "✗"} ${step.step}: ${step.detail}`);
463
507
  }
464
508
  }
509
+ // On macOS, proactively trigger the notification-permission flow so the user
510
+ // doesn't have to hunt for the GoCode toggle: register the branded bundle,
511
+ // open System Settings → Notifications, and fire a priming banner. Skipped in
512
+ // agent-driven mode (no human to grant it) and when setup didn't succeed.
513
+ // Best-effort — never flips the setup exit code (a permission nudge failing
514
+ // must not make `setup` report failure).
515
+ if (!agent && result.ok && process.platform === "darwin") {
516
+ try {
517
+ const perm = await requestDesktopPermission({
518
+ home: deps.home,
519
+ timeoutMs: deps.timeoutMs,
520
+ });
521
+ console.log("");
522
+ console.log(` 🔔 Desktop notifications: ${perm.message}`);
523
+ }
524
+ catch (err) {
525
+ // Never let a permission nudge fail the setup — but DO leave a breadcrumb
526
+ // so an unexpected throw here isn't completely invisible to the user.
527
+ console.log("");
528
+ console.log(` 🔔 Desktop notifications: couldn't run the permission nudge (${err instanceof Error ? err.message : String(err)}). Run \`gocode-notify permissions\` manually.`);
529
+ }
530
+ }
465
531
  return result.ok ? 0 : 1;
466
532
  }
467
533
  /**
@@ -689,6 +755,8 @@ export async function runAsync(argv) {
689
755
  return cmdStatus(argv.slice(1));
690
756
  if (cmd === "doctor")
691
757
  return cmdDoctor(argv.slice(1));
758
+ if (cmd === "permissions" || cmd === "perms")
759
+ return cmdPermissions(argv.slice(1));
692
760
  if (cmd === "setup")
693
761
  return cmdSetup(argv.slice(1));
694
762
  if (cmd === "mcp")
@@ -39,6 +39,7 @@ import path from "node:path";
39
39
  import { fileURLToPath } from "node:url";
40
40
  import { appendLog } from "./send.js";
41
41
  import { gocodeDir } from "./creds.js";
42
+ import { cachedAuthStatus, notifyViaMacHelper, requestMacPermission, } from "./mac_helper.js";
42
43
  /** Default banner timeout (PRD §2.4: 5s hard cap, same as the phone `send`). */
43
44
  export const DEFAULT_DESKTOP_TIMEOUT_MS = 5000;
44
45
  /** The Windows AppUserModelID the branded toast binds to (PRD §2.3). */
@@ -50,8 +51,22 @@ export const APP_NAME = "GoCode";
50
51
  * AppleScript changes (e.g. the click handler added in 0.5.0) so an existing
51
52
  * install's stale `GoCode.app` is recompiled once instead of silently reused.
52
53
  * Tracked via a `~/.gocode/desktop/.bundle-v<N>` marker file.
54
+ *
55
+ * v3 (2026-06-20): inject a stable CFBundleIdentifier + register with
56
+ * LaunchServices. macOS 15/26 silently DROP notifications from an app bundle
57
+ * with no bundle id (osacompile's default applet has none), which is why
58
+ * banners "succeeded" (osascript exit 0) but never appeared. Bumped so existing
59
+ * id-less bundles get rebuilt once with the fix.
60
+ */
61
+ export const MAC_BUNDLE_VERSION = 3;
62
+ /**
63
+ * Stable CFBundleIdentifier stamped into the compiled `GoCode.app`. macOS keys
64
+ * notification authorization (System Settings → Notifications) off this id, so
65
+ * it MUST be stable across rebuilds for the user's "Allow" grant to persist and
66
+ * for the app to even appear in the Notifications list. Reverse-DNS, matches
67
+ * the GoCode brand namespace.
53
68
  */
54
- export const MAC_BUNDLE_VERSION = 2;
69
+ export const MAC_BUNDLE_ID = "com.gocode.notify.desktop";
55
70
  /**
56
71
  * Env var that hard-disables ALL desktop banners regardless of settings — a
57
72
  * machine-level opt-out for headless servers, CI, SSH sessions, or any box where
@@ -225,6 +240,123 @@ export async function notifyDesktop(payload, opts = {}) {
225
240
  return { ok: false, platform: "unsupported", error: `unexpected error: ${msg}` };
226
241
  }
227
242
  }
243
+ /**
244
+ * Make the OS notification-permission UI appear for GoCode, so the user grants
245
+ * permission with a click instead of hunting through Settings.
246
+ *
247
+ * macOS (since 0.6.0 — the SIGNED helper changed this): we now ship a
248
+ * Developer-ID-signed `GoCodeNotifier.app` in the package, so we CAN pop the real
249
+ * native Allow/Deny MODAL via `UNUserNotificationCenter.requestAuthorization`.
250
+ * The flow is:
251
+ * 1. Try the signed helper's `request-permission` (see mac_helper.ts). On a
252
+ * real grant we're DONE — no Settings hunt, and a confirming banner fires.
253
+ * 2. If the signed helper is UNAVAILABLE (non-macOS publish / missing bundle)
254
+ * or the user previously DENIED (macOS won't re-prompt), fall back to the
255
+ * legacy reliable next-best thing: register the branded applet, open
256
+ * **System Settings → Notifications** (deep-link), and fire a priming
257
+ * banner so the GoCode toggle is one tap away.
258
+ * On Windows/Linux notifications don't gate behind a per-app grant the same
259
+ * way, so this just fires a priming banner and reports success.
260
+ *
261
+ * Best-effort + total: never throws; always resolves to a {@link PermissionResult}.
262
+ */
263
+ export async function requestDesktopPermission(opts = {}) {
264
+ const platform = (opts.platform ?? process.platform);
265
+ const run = opts.run ?? defaultRunner;
266
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_DESKTOP_TIMEOUT_MS;
267
+ const logLine = async (line) => {
268
+ try {
269
+ if (opts.log)
270
+ await opts.log(line);
271
+ else
272
+ await appendLog(`PERMISSION: ${line}`, { home: opts.home, timestamp: opts.timestamp });
273
+ }
274
+ catch {
275
+ // logging must never block
276
+ }
277
+ };
278
+ if (platform !== "darwin") {
279
+ // Prime a banner so the app is registered; no per-app grant gate to open.
280
+ const primed = await notifyDesktop({ title: APP_NAME, body: "GoCode notifications are enabled.", sound: false }, opts);
281
+ const message = primed.ok
282
+ ? "Fired a test banner — desktop notifications are working."
283
+ : `Could not fire a test banner (${primed.error ?? "unknown"}).`;
284
+ // Log the outcome too (parity with the macOS branch) so the `permissions`
285
+ // command leaves a breadcrumb on Linux / unsupported notification setups.
286
+ await logLine(message);
287
+ return {
288
+ platform: primed.platform,
289
+ openedSettings: false,
290
+ primed: primed.ok,
291
+ message,
292
+ };
293
+ }
294
+ // macOS path.
295
+ //
296
+ // PREFERRED (2026-06-20): pop the REAL native Allow/Deny modal via the
297
+ // Developer-ID-SIGNED helper. Unlike the legacy `osacompile` applet (which
298
+ // can't request authorization at all), the signed app calls
299
+ // UNUserNotificationCenter.requestAuthorization, which shows the system modal
300
+ // the first time and persists the user's choice. We try this first; if it
301
+ // genuinely grants (or the user already granted), we're DONE — no Settings
302
+ // hunt needed. If the signed helper is unavailable (non-macOS publish, missing
303
+ // bundle) or the user previously denied, we fall back to the legacy
304
+ // open-Settings + priming-banner nudge below.
305
+ // `skipSignedHelper` (tests / hard opt-out) makes this resolve as unavailable
306
+ // so we go straight to the legacy open-Settings + priming-banner fallback.
307
+ const helper = opts.skipSignedHelper
308
+ ? { ok: false, status: "unknown", unavailable: true }
309
+ : await requestMacPermission({
310
+ home: opts.home,
311
+ run,
312
+ timeoutMs: opts.timeoutMs,
313
+ log: logLine,
314
+ platform: "darwin",
315
+ bundledAppPath: opts.bundledAppPath,
316
+ }, opts.helperVersionTag);
317
+ if (!helper.unavailable && (helper.status === "authorized" || helper.granted === true)) {
318
+ const msg = "Showed the native macOS permission dialog — GoCode desktop notifications are now allowed.";
319
+ await logLine(msg);
320
+ // Also fire a confirming banner so the user immediately sees a real one.
321
+ await notifyDesktop({ title: APP_NAME, body: "Desktop notifications are on. You’ll get a banner when your agent finishes.", sound: true }, opts).catch(() => undefined);
322
+ return { platform: "darwin", openedSettings: false, primed: true, message: msg };
323
+ }
324
+ let openedSettings = false;
325
+ try {
326
+ // Open System Settings straight to the Notifications pane (deep-link). The
327
+ // modern URL; falls back silently if the OS doesn't honour it.
328
+ const opened = await run("open", ["x-apple.systempreferences:com.apple.preference.notifications"], {
329
+ timeoutMs,
330
+ });
331
+ openedSettings = opened.code === 0;
332
+ if (!openedSettings) {
333
+ await logLine(`could not open Notifications settings (open exit ${opened.code})`);
334
+ }
335
+ }
336
+ catch (err) {
337
+ await logLine(`open Settings threw — ignored: ${err instanceof Error ? err.message : String(err)}`);
338
+ }
339
+ // Fire a priming banner — this both verifies the path AND nudges macOS to
340
+ // register the (now bundle-id-stamped) app into the Notifications list.
341
+ const primed = await notifyDesktop({
342
+ title: APP_NAME,
343
+ body: "Tap “Allow” for GoCode in System Settings → Notifications to get desktop alerts.",
344
+ sound: true,
345
+ }, opts);
346
+ // If the signed helper was present but the user had previously DENIED, say so
347
+ // explicitly — they must re-enable it in Settings (macOS won't re-prompt).
348
+ const deniedNote = !helper.unavailable && helper.status === "denied"
349
+ ? "GoCode notifications are currently turned OFF — re-enable them in System Settings → Notifications → GoCode. "
350
+ : "";
351
+ const message = primed.ok
352
+ ? deniedNote +
353
+ (openedSettings
354
+ ? "Opened System Settings → Notifications and fired a test banner. Turn ON “Allow Notifications” for GoCode."
355
+ : "Fired a test banner. Open System Settings → Notifications and turn ON “Allow Notifications” for GoCode.")
356
+ : `${deniedNote}Could not fire the priming banner (${primed.error ?? "unknown"}). Open System Settings → Notifications and enable GoCode manually.`;
357
+ await logLine(message);
358
+ return { platform: "darwin", openedSettings, primed: primed.ok, message };
359
+ }
228
360
  // ── macOS ────────────────────────────────────────────────────────────────────
229
361
  /** Path to the branded helper app bundle under `~/.gocode/desktop/GoCode.app`. */
230
362
  export function macAppPath(opts) {
@@ -338,6 +470,14 @@ export async function ensureMacBrandedApp(ctx) {
338
470
  // bundled PNG → icns via `sips` (ships with macOS); if that fails the banner
339
471
  // is still branded by NAME ("GoCode") which is the important part.
340
472
  await brandMacIcon(appPath, ctx).catch(() => { });
473
+ // CRITICAL (2026-06-20): give the bundle a stable CFBundleIdentifier and
474
+ // register it with LaunchServices. macOS 15/26 silently DROP notifications
475
+ // from a bundle with no identifier — osacompile's default applet ships none,
476
+ // which is why banners reported success but never appeared. This makes the
477
+ // app authorizable + visible in System Settings → Notifications so the OS
478
+ // can persist the user's "Allow" grant. Best-effort: a failure here just
479
+ // means the banner may still be suppressed (degrade, never block).
480
+ await ensureMacBundleIdentity(appPath, ctx).catch(() => { });
341
481
  // Stamp the version marker so we don't recompile until the bundle changes.
342
482
  // Clear any older `.bundle-v*` markers first so the dir doesn't accumulate.
343
483
  try {
@@ -379,6 +519,50 @@ async function brandMacIcon(appPath, ctx) {
379
519
  await ctx.log(`icon conversion skipped (sips code ${res.code}) — banner is name-branded only`);
380
520
  }
381
521
  }
522
+ /**
523
+ * Stamp a stable `CFBundleIdentifier` into the compiled applet's Info.plist and
524
+ * register the bundle with LaunchServices.
525
+ *
526
+ * WHY (2026-06-20): `osacompile` produces an applet whose Info.plist has NO
527
+ * `CFBundleIdentifier`. On macOS 15/26 the notification system keys
528
+ * authorization off the bundle id, so an id-less app is treated as
529
+ * un-authorizable and its `display notification` banners are silently dropped
530
+ * (the `osascript`/applet call still exits 0 — it "posted" — so nothing looked
531
+ * wrong from our side). Stamping a stable id makes the app:
532
+ * 1. appear in System Settings → Notifications (so the user can Allow it), and
533
+ * 2. retain that grant across rebuilds (the id is stable, see {@link MAC_BUNDLE_ID}).
534
+ * We then `lsregister -f` the bundle so the OS picks it up immediately rather
535
+ * than waiting for a Spotlight/LaunchServices sweep.
536
+ *
537
+ * Best-effort + total: never throws (caller wraps in `.catch`), and any failure
538
+ * just leaves the banner possibly-suppressed rather than blocking the hook.
539
+ */
540
+ export async function ensureMacBundleIdentity(appPath, ctx) {
541
+ const plist = path.join(appPath, "Contents", "Info.plist");
542
+ // PlistBuddy ships with every macOS. `Set` updates an existing key; if the
543
+ // key is absent (osacompile's applet has none) `Set` fails, so try `Add`.
544
+ const setRes = await ctx.run("/usr/libexec/PlistBuddy", ["-c", `Set :CFBundleIdentifier ${MAC_BUNDLE_ID}`, plist], { timeoutMs: ctx.timeoutMs });
545
+ if (setRes.code !== 0) {
546
+ const addRes = await ctx.run("/usr/libexec/PlistBuddy", ["-c", `Add :CFBundleIdentifier string ${MAC_BUNDLE_ID}`, plist], { timeoutMs: ctx.timeoutMs });
547
+ if (addRes.code !== 0) {
548
+ await ctx.log(`could not stamp CFBundleIdentifier (PlistBuddy ${addRes.code}): ${addRes.stderr.trim()} — notifications may be suppressed by macOS`);
549
+ // No id → registering is pointless; bail (the banner may still be dropped,
550
+ // but we've logged the actionable reason).
551
+ return;
552
+ }
553
+ }
554
+ // Register the bundle with LaunchServices so the OS recognises it as an app
555
+ // that can post notifications, NOW (not after a background sweep). `-f` forces
556
+ // a (re)register of this exact path.
557
+ const lsregister = "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister";
558
+ const reg = await ctx.run(lsregister, ["-f", appPath], { timeoutMs: ctx.timeoutMs });
559
+ if (reg.code !== 0) {
560
+ await ctx.log(`lsregister returned ${reg.code} — bundle may not appear in Notification settings immediately`);
561
+ }
562
+ else {
563
+ await ctx.log(`stamped CFBundleIdentifier ${MAC_BUNDLE_ID} + registered bundle with LaunchServices`);
564
+ }
565
+ }
382
566
  /**
383
567
  * Write (or clear) the click-target file the applet reads on a banner click
384
568
  * (PRD §3.6). When `click` is set we write a single tab-separated line
@@ -402,6 +586,53 @@ function oneLine(s) {
402
586
  return s.replace(/[\t\r\n]+/g, " ").trim();
403
587
  }
404
588
  async function notifyMac(banner, ctx) {
589
+ // PREFERRED path (2026-06-20): the Developer-ID-SIGNED helper app. It posts a
590
+ // first-class UserNotifications banner from a properly-authorized app and
591
+ // carries a real click action. Only used when the user has actually granted
592
+ // notification permission to the signed helper (status "authorized") — if they
593
+ // haven't (notDetermined/denied), a signed-helper post would silently no-op,
594
+ // so we fall through to the legacy applet which still shows *something*. We do
595
+ // NOT request authorization here (that's the `permissions`/`setup` flow's job,
596
+ // where there's a human to click Allow); a fire-and-forget banner must never
597
+ // pop a modal. Best-effort: any failure falls through to the applet.
598
+ if (!ctx.skipBranding && !ctx.skipSignedHelper) {
599
+ try {
600
+ // Read the CACHED auth status (a cheap file read) — never a live
601
+ // `open`+poll round-trip on the hot fire-and-forget path. The cache is
602
+ // populated by the `permissions`/`setup` flow when the user grants. No
603
+ // cache (or not "authorized") → fall through to the applet.
604
+ const cached = await cachedAuthStatus(ctx);
605
+ if (cached === "authorized") {
606
+ // KNOWN LIMITATION (signed-banner click-through): the signed helper exits
607
+ // ~0.4s after posting, so its in-process click delegate is gone by the
608
+ // time a user actually clicks the banner; a click then relaunches the app
609
+ // with no args (→ a no-op `status` run) and does NOT route to the IDE. We
610
+ // still pass the click target on the notification's userInfo (harmless),
611
+ // but reliable click-to-open remains the legacy applet's job. The signed
612
+ // path's win is the real Allow dialog + correct "GoCode" attribution; if
613
+ // a guaranteed click action matters more than attribution for a given
614
+ // banner, the applet fallback (below) handles the click via click-target.tsv.
615
+ if (banner.click) {
616
+ await writeMacClickTarget(banner.click, ctx).catch(() => { });
617
+ }
618
+ const posted = await notifyViaMacHelper({
619
+ title: banner.title,
620
+ body: banner.body,
621
+ sound: banner.sound,
622
+ clickApp: banner.click?.app,
623
+ clickPath: banner.click?.projectPath,
624
+ }, { home: ctx.home, run: ctx.run, timeoutMs: ctx.timeoutMs, log: ctx.log, platform: "darwin" }, ctx.helperVersionTag);
625
+ if (posted.ok) {
626
+ await ctx.log(`signed-helper banner shown (${banner.title})`);
627
+ return { ok: true, platform: "darwin" };
628
+ }
629
+ await ctx.log(`signed-helper banner failed (${posted.detail ?? posted.status}) — falling back to applet`);
630
+ }
631
+ }
632
+ catch (err) {
633
+ await ctx.log(`signed-helper path errored (${err instanceof Error ? err.message : String(err)}) — falling back to applet`);
634
+ }
635
+ }
405
636
  // Branded path: invoke the compiled GoCode.app applet so the banner is
406
637
  // attributed to "GoCode" (+ our icon) instead of "Script Editor".
407
638
  if (!ctx.skipBranding) {
@@ -193,14 +193,33 @@ export async function gatherDoctor(opts = {}) {
193
193
  // ── 3c. Cursor stop hook ───────────────────────────────────────────────
194
194
  const cursorHooksPath = path.join(home, ".cursor", "hooks.json");
195
195
  const cursorHooks = await readJsonFile(cursorHooksPath);
196
- // Pass the stop array directly (not wrapped in an object) so extractCommands
197
- // can find the {command: ...} entries inside it.
198
- const cursorStopValue = cursorHooks &&
199
- typeof cursorHooks === "object" &&
200
- "stop" in cursorHooks
201
- ? cursorHooks.stop
196
+ // Cursor's hooks.json nests the events under a top-level `hooks` key:
197
+ // { "version": 1, "hooks": { "stop": [ { "command": ... } ] } }
198
+ // Older/loose configs put `stop` at the top level. Scan `stop` from BOTH
199
+ // locations (combining their commands) so the doctor doesn't false-negative
200
+ // on the real nested schema — that bug made it report "hook not found" even
201
+ // when the hook was correctly installed (2026-06-20) — AND so a mixed config
202
+ // carrying a top-level `stop` is still detected even when a nested `hooks`
203
+ // object exists for other events.
204
+ const cursorRoot = cursorHooks && typeof cursorHooks === "object"
205
+ ? cursorHooks
202
206
  : null;
203
- const cursorStopEntries = flattenHookCommands(cursorStopValue);
207
+ const cursorNestedHooks = cursorRoot &&
208
+ "hooks" in cursorRoot &&
209
+ cursorRoot.hooks &&
210
+ typeof cursorRoot.hooks === "object"
211
+ ? cursorRoot.hooks
212
+ : null;
213
+ const cursorStopEntries = [
214
+ // Nested `hooks.stop` (current Cursor schema).
215
+ ...(cursorNestedHooks && "stop" in cursorNestedHooks
216
+ ? flattenHookCommands(cursorNestedHooks.stop)
217
+ : []),
218
+ // Top-level `stop` (older/loose schema).
219
+ ...(cursorRoot && "stop" in cursorRoot
220
+ ? flattenHookCommands(cursorRoot.stop)
221
+ : []),
222
+ ];
204
223
  const cursorStopInstalled = cursorStopEntries.some((c) => c.includes(CURSOR_HOOK_TOKEN));
205
224
  checks.push({
206
225
  key: "hook:cursor-stop",
@@ -0,0 +1,335 @@
1
+ // `mac_helper` — the bridge to the Developer-ID-SIGNED macOS notifier app bundle
2
+ // (`GoCodeNotifier.app`) shipped pre-signed inside the npm tarball under
3
+ // `assets/`. This is the ONE thing the zero-dependency `osacompile` applet could
4
+ // never do: pop the real native "“GoCode” Would Like to Send You Notifications ·
5
+ // Allow / Don't Allow" authorization modal, and post first-class
6
+ // UserNotifications banners from a properly-authorized signed app.
7
+ //
8
+ // WHY A SEPARATE MODULE (and why it's all best-effort):
9
+ // `desktop_notify.ts` owns the legacy unsigned-applet path (always available,
10
+ // posts banners, but CANNOT request authorization). This module owns the
11
+ // SIGNED path. The two TWO HARD macOS rules below were both confirmed
12
+ // empirically (2026-06-20) and are the reason this isn't a one-liner:
13
+ //
14
+ // 1. LAUNCH VIA LaunchServices, NEVER exec the inner binary.
15
+ // Running `…/GoCodeNotifier.app/Contents/MacOS/GoCodeNotifier` directly
16
+ // bypasses LaunchServices and macOS AUTO-DENIES the authorization request
17
+ // (and that denial STICKS). We must launch with `open <app> --args …`.
18
+ // Because `open` discards the child's stdout, the helper writes its JSON
19
+ // result to a file we pass via `GOCODE_RESULT_FILE` and we read THAT.
20
+ //
21
+ // 2. RUN FROM A STABLE LOCATION, NEVER /tmp (or a volatile npm cache).
22
+ // Launching the bundle from `/tmp` yields "Notifications are not allowed
23
+ // for this application". So on first use we COPY the signed bundle out of
24
+ // the package's `assets/` into `~/.gocode/desktop/GoCodeNotifier.app`
25
+ // (stable, user-owned) and run it from there. Copying preserves the
26
+ // embedded code signature (it's just files), so it stays Developer-ID
27
+ // valid. We then `lsregister -f` it so the OS recognises it immediately.
28
+ //
29
+ // CONTRACT: every export is best-effort + total — never throws, always resolves
30
+ // to a structured result. A missing bundle / unsupported platform / wedged child
31
+ // resolves to `{ ok:false, … }` so the caller cleanly falls back to the unsigned
32
+ // applet. This module owns NO gating policy — callers decide whether to use it.
33
+ import { promises as fs } from "node:fs";
34
+ import os from "node:os";
35
+ import path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { desktopDir, defaultRunner } from "./desktop_notify.js";
38
+ import { VERSION } from "./version.js";
39
+ /**
40
+ * Stable CFBundleIdentifier of the SIGNED helper. DISTINCT from the legacy
41
+ * applet's `com.gocode.notify.desktop` on purpose: the signed app owns its own
42
+ * notification-authorization identity in System Settings → Notifications, so a
43
+ * poisoned/denied decision on the old shared id can never block the signed
44
+ * grant. MUST match the CFBundleIdentifier in
45
+ * `native/macos/GoCodeNotifier/Info.plist` + `build_and_sign.sh`.
46
+ */
47
+ export const HELPER_BUNDLE_ID = "com.gocode.notify.helper";
48
+ /** The signed app bundle's name (same in `assets/` and at the runtime location). */
49
+ export const HELPER_APP_NAME = "GoCodeNotifier.app";
50
+ /** The executable inside the bundle (Contents/MacOS/<this>). */
51
+ export const HELPER_EXECUTABLE = "GoCodeNotifier";
52
+ /** Hard cap for a helper invocation. The Swift side also self-times-out. */
53
+ export const DEFAULT_HELPER_TIMEOUT_MS = 30000;
54
+ // ── path resolution ──────────────────────────────────────────────────────────
55
+ /**
56
+ * Resolve the SIGNED `GoCodeNotifier.app` shipped in the package's `assets/` dir.
57
+ * The compiled module lives at `dist/src/mac_helper.js`, so `assets/` is two
58
+ * levels up. Returns the bundle path (which may not exist on a non-macOS publish
59
+ * or a partial install — callers check existence). Mirrors `bundledIconPath`.
60
+ */
61
+ export function bundledHelperAppPath() {
62
+ const here = path.dirname(fileURLToPath(import.meta.url)); // dist/src
63
+ return path.resolve(here, "..", "..", "assets", HELPER_APP_NAME);
64
+ }
65
+ /**
66
+ * The STABLE runtime location we copy the signed bundle to before running it
67
+ * (`~/.gocode/desktop/GoCodeNotifier.app`). Running from here — never `/tmp` and
68
+ * never the volatile npm cache — is REQUIRED for macOS to allow the app to
69
+ * request notification authorization (see module header rule 2).
70
+ */
71
+ export function installedHelperAppPath(opts) {
72
+ return path.join(desktopDir(opts), HELPER_APP_NAME);
73
+ }
74
+ /** The signed helper's executable at the stable runtime location. */
75
+ export function installedHelperBinPath(opts) {
76
+ return path.join(installedHelperAppPath(opts), "Contents", "MacOS", HELPER_EXECUTABLE);
77
+ }
78
+ /** Marker recording which package version's bundle we last installed (for upgrades). */
79
+ function installedMarkerPath(opts) {
80
+ return path.join(desktopDir(opts), ".helper-installed");
81
+ }
82
+ /**
83
+ * Cache file for the last-known authorization status. The fire-and-forget banner
84
+ * path reads THIS (a cheap file read) instead of doing a full `open`+poll
85
+ * round-trip on every notification — a live status query costs ~1s+ which is far
86
+ * too slow for a hook. The cache is written whenever we DO run the helper
87
+ * (`request-permission` / `status`), so it tracks the user's real decision; if
88
+ * the user later flips the toggle in System Settings the cache self-heals the
89
+ * next time the permission/status path runs (and a stale "authorized" only means
90
+ * one wasted signed-post that silently falls through to the applet anyway).
91
+ */
92
+ export function authStatusCachePath(opts) {
93
+ return path.join(desktopDir(opts), ".auth-status");
94
+ }
95
+ /** Persist the last-known auth status (best-effort; failure is non-fatal). */
96
+ async function writeAuthStatusCache(status, opts) {
97
+ try {
98
+ await fs.mkdir(desktopDir(opts), { recursive: true, mode: 0o700 });
99
+ await fs.writeFile(authStatusCachePath(opts), status, { encoding: "utf8", mode: 0o600 });
100
+ }
101
+ catch {
102
+ // cache is an optimization — never fail the flow over it
103
+ }
104
+ }
105
+ /**
106
+ * Read the cached authorization status WITHOUT running the helper (fast). Returns
107
+ * undefined when there is no cache yet (then the banner path uses the applet
108
+ * until the user runs the permission flow once). This is what `notifyMac` calls
109
+ * on the hot path.
110
+ */
111
+ export async function cachedAuthStatus(opts) {
112
+ try {
113
+ const raw = (await fs.readFile(authStatusCachePath(opts), "utf8")).trim();
114
+ return raw ? raw : undefined;
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ // ── ensure the signed bundle is installed at the stable location ──────────────
121
+ /**
122
+ * Copy the package's signed `GoCodeNotifier.app` to the stable runtime location
123
+ * (`~/.gocode/desktop/`) if it isn't there (or is stale for this package
124
+ * version), then register it with LaunchServices so the OS recognises it
125
+ * immediately. Idempotent. Returns the installed bundle path on success, or
126
+ * undefined when the signed bundle isn't available to copy (then the caller
127
+ * falls back to the unsigned applet).
128
+ *
129
+ * Copying preserves the embedded Developer-ID code signature (it's just a tree
130
+ * of files) — `cp -R` keeps the `_CodeSignature` dir + signed Mach-O intact.
131
+ */
132
+ export async function ensureHelperInstalled(opts = {}, versionTag = VERSION) {
133
+ const run = opts.run ?? defaultRunner;
134
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
135
+ const src = opts.bundledAppPath ?? bundledHelperAppPath();
136
+ const dest = installedHelperAppPath(opts);
137
+ const destBin = installedHelperBinPath(opts);
138
+ const marker = installedMarkerPath(opts);
139
+ // The signed bundle must exist in the package to install it.
140
+ try {
141
+ await fs.access(src);
142
+ }
143
+ catch {
144
+ await opts.log?.(`signed helper not bundled at ${src} — using unsigned applet`);
145
+ return undefined;
146
+ }
147
+ // Already installed AND current for this package version? → reuse.
148
+ try {
149
+ await fs.access(destBin);
150
+ const tag = await fs.readFile(marker, "utf8").catch(() => "");
151
+ if (tag.trim() === versionTag)
152
+ return dest;
153
+ }
154
+ catch {
155
+ // need to (re)install — missing bin or stale/absent marker
156
+ }
157
+ try {
158
+ await fs.mkdir(desktopDir(opts), { recursive: true, mode: 0o700 });
159
+ // Remove any stale copy so a partial/older bundle can't linger.
160
+ await fs.rm(dest, { recursive: true, force: true }).catch(() => { });
161
+ // `cp -R` preserves the code signature; Node's fs.cp also works but we use
162
+ // the injectable runner so tests can stub it without touching the disk.
163
+ const cp = await run("cp", ["-R", src, dest], { timeoutMs });
164
+ if (cp.code !== 0) {
165
+ await opts.log?.(`could not copy signed helper (cp ${cp.code}): ${cp.stderr.trim()} — using unsigned applet`);
166
+ return undefined;
167
+ }
168
+ // Register with LaunchServices so the OS picks it up NOW (not after a sweep).
169
+ const lsregister = "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister";
170
+ await run(lsregister, ["-f", dest], { timeoutMs }).catch(() => ({ code: 1, stdout: "", stderr: "" }));
171
+ await fs.writeFile(marker, versionTag, { encoding: "utf8", mode: 0o600 }).catch(() => { });
172
+ return dest;
173
+ }
174
+ catch (err) {
175
+ await opts.log?.(`signed helper install error: ${err instanceof Error ? err.message : String(err)} — using unsigned applet`);
176
+ return undefined;
177
+ }
178
+ }
179
+ // ── run the installed signed helper via LaunchServices ────────────────────────
180
+ /** True on macOS — the only platform the signed helper targets. */
181
+ function isMac(opts) {
182
+ return (opts.platform ?? process.platform) === "darwin";
183
+ }
184
+ /** A result meaning "we couldn't run the signed path; fall back to the applet". */
185
+ function unavailable(detail) {
186
+ return { ok: false, status: "unknown", detail, unavailable: true };
187
+ }
188
+ /**
189
+ * Launch the installed signed helper via `open <app> --args …` (LaunchServices —
190
+ * the ONLY way macOS will let it request authorization), capturing its JSON
191
+ * result through a temp `GOCODE_RESULT_FILE` (because `open` discards the child's
192
+ * stdout). Polls the result file until the helper writes it or we hit the
193
+ * timeout. Best-effort + total: any failure resolves to a structured
194
+ * {@link HelperResult}, never throws.
195
+ *
196
+ * @param command the helper subcommand: "request-permission" | "notify" | "status"
197
+ * @param extraArgs additional `--flag value` pairs for the helper
198
+ */
199
+ export async function runHelper(command, extraArgs, opts = {}, versionTag = VERSION) {
200
+ if (!isMac(opts))
201
+ return unavailable("signed helper is macOS-only");
202
+ const run = opts.run ?? defaultRunner;
203
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
204
+ const appPath = await ensureHelperInstalled(opts, versionTag);
205
+ if (!appPath)
206
+ return unavailable("signed helper not installed");
207
+ // A unique result file per call so concurrent invocations don't clobber.
208
+ const resultFile = path.join(os.tmpdir(), `gocode-helper-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
209
+ // `open -n` launches a FRESH instance via launchd. CRITICAL: a LaunchServices
210
+ // launch does NOT inherit arbitrary env vars from THIS process — env passed to
211
+ // the `open` subprocess is the `open` tool's own env, not the launched app's.
212
+ // The supported way to inject a per-launch env var into the launched app is
213
+ // `open --env NAME=VALUE` (see open(1)); we use that so the Swift helper
214
+ // reliably sees GOCODE_RESULT_FILE and writes its JSON result there. We poll
215
+ // that file rather than `-W` (which can hang for the click window). We ALSO
216
+ // keep the var in the subprocess env as a belt-and-braces fallback for any OS
217
+ // where the flag is unsupported, but `--env` is the load-bearing path.
218
+ const args = [
219
+ "-n",
220
+ "--env",
221
+ `GOCODE_RESULT_FILE=${resultFile}`,
222
+ appPath,
223
+ "--args",
224
+ command,
225
+ ...extraArgs,
226
+ ];
227
+ const launch = await run("open", args, {
228
+ timeoutMs,
229
+ env: { GOCODE_RESULT_FILE: resultFile },
230
+ });
231
+ if (launch.code !== 0) {
232
+ await opts.log?.(`open failed to launch signed helper (code ${launch.code}): ${launch.stderr.trim()}`);
233
+ return unavailable(`open exit ${launch.code}`);
234
+ }
235
+ // Poll for the helper's JSON result. It writes the file as its LAST act, so a
236
+ // present-and-parseable file means the operation finished. Cap polling at the
237
+ // timeout; the Swift side has its own hard self-timeout so this can't run away.
238
+ const deadline = Date.now() + timeoutMs;
239
+ // Track the last "the file exists but I couldn't parse it" reason so a
240
+ // persistent malformed/truncated result is surfaced on timeout instead of a
241
+ // bare "timed out" (which would hide the real cause). A plain ENOENT — the
242
+ // helper simply hasn't written yet — is NOT logged (that's the normal poll).
243
+ let lastParseError;
244
+ // A non-ENOENT read failure (EACCES/ENOTDIR/etc.) — distinct from the normal
245
+ // "not written yet" ENOENT — so a persistent read problem is diagnosable.
246
+ let lastReadError;
247
+ while (Date.now() < deadline) {
248
+ let raw;
249
+ try {
250
+ raw = await fs.readFile(resultFile, "utf8");
251
+ }
252
+ catch (err) {
253
+ // ENOENT is the NORMAL case — the helper hasn't written the file yet, so
254
+ // retry quietly. Any OTHER errno (EACCES, ENOTDIR, an unexpected tmpdir
255
+ // problem) is NOT normal: record it so a persistent read failure surfaces
256
+ // in the timeout detail instead of masquerading as a generic timeout.
257
+ const code = err?.code;
258
+ if (code !== "ENOENT") {
259
+ lastReadError = err instanceof Error ? err.message : String(err);
260
+ }
261
+ await delay(150);
262
+ continue;
263
+ }
264
+ try {
265
+ const parsed = JSON.parse(raw.trim());
266
+ await fs.rm(resultFile, { force: true }).catch(() => { });
267
+ // Refresh the auth-status cache when this run reported an authorization
268
+ // status (request-permission / status). `notify` reports "posted", which
269
+ // we deliberately do NOT cache as an auth status.
270
+ if (command !== "notify" &&
271
+ ["authorized", "denied", "notDetermined", "provisional", "ephemeral"].includes(parsed.status)) {
272
+ await writeAuthStatusCache(parsed.status, opts);
273
+ }
274
+ return parsed;
275
+ }
276
+ catch (err) {
277
+ // The file EXISTS but didn't parse — likely a mid-write truncation, so
278
+ // retry; but remember the reason so a persistent bad result is reported.
279
+ lastParseError = err instanceof Error ? err.message : String(err);
280
+ await delay(150);
281
+ }
282
+ }
283
+ await fs.rm(resultFile, { force: true }).catch(() => { });
284
+ // Surface the most specific known cause in priority order: a malformed result
285
+ // (file existed but never parsed) > a non-ENOENT read failure > bare timeout.
286
+ const cause = lastParseError
287
+ ? `result never parsed (last error: ${lastParseError})`
288
+ : lastReadError
289
+ ? `result file unreadable (last error: ${lastReadError})`
290
+ : undefined;
291
+ const detail = cause ? `helper ${command} ${cause}` : `helper ${command} timed out`;
292
+ await opts.log?.(`signed helper ${command} timed out after ${timeoutMs}ms${cause ? ` — ${cause}` : ""}`);
293
+ return { ok: false, status: "timeout", detail, unavailable: false };
294
+ }
295
+ /** Promise-based sleep (no deps). */
296
+ function delay(ms) {
297
+ return new Promise((r) => setTimeout(r, ms));
298
+ }
299
+ // ── public entry points ───────────────────────────────────────────────────────
300
+ /**
301
+ * Pop the NATIVE macOS notification Allow/Deny dialog for the SIGNED GoCode
302
+ * helper (the first time), or resolve to the persisted decision on later runs.
303
+ * Returns the parsed {@link HelperResult}; `granted:true` / `status:"authorized"`
304
+ * means the user allowed (or had already allowed) GoCode desktop notifications.
305
+ *
306
+ * On a machine where the signed bundle isn't available (non-macOS, or a publish
307
+ * that didn't ship it) this resolves to `{ unavailable:true }` so the caller can
308
+ * fall back to the legacy applet + "open Settings" nudge.
309
+ */
310
+ export async function requestMacPermission(opts = {}, versionTag = VERSION) {
311
+ // Give the dialog a generous window: the user has to read + click.
312
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_HELPER_TIMEOUT_MS;
313
+ return runHelper("request-permission", ["--timeout", String(Math.floor(timeoutMs / 1000))], opts, versionTag);
314
+ }
315
+ /** Read the current authorization status without prompting (read-only). */
316
+ export async function macHelperStatus(opts = {}, versionTag = VERSION) {
317
+ return runHelper("status", [], opts, versionTag);
318
+ }
319
+ /**
320
+ * Post ONE first-class UserNotifications banner via the SIGNED helper (attributed
321
+ * to the authorized "GoCode" app, with a real click action). Falls back to
322
+ * `{ unavailable:true }` when the signed bundle isn't usable, so the caller can
323
+ * use the legacy applet path instead.
324
+ */
325
+ export async function notifyViaMacHelper(input, opts = {}, versionTag = VERSION) {
326
+ const args = [
327
+ "--title", input.title,
328
+ "--body", input.body,
329
+ "--sound", input.sound ? "1" : "0",
330
+ ];
331
+ if (input.clickApp && input.clickPath) {
332
+ args.push("--click-app", input.clickApp, "--click-path", input.clickPath);
333
+ }
334
+ return runHelper("notify", args, opts, versionTag);
335
+ }
package/dist/src/send.js CHANGED
@@ -32,8 +32,25 @@ export const NOTIFY_KINDS = [
32
32
  export function isNotifyKind(value) {
33
33
  return typeof value === "string" && NOTIFY_KINDS.includes(value);
34
34
  }
35
- /** Default request timeout (PRD §4.4: 5s hard cap). */
36
- export const DEFAULT_TIMEOUT_MS = 5000;
35
+ /**
36
+ * Default PER-ATTEMPT request timeout. Bumped 5s → 12s (2026-06-20): a cold
37
+ * self-hosted OpenHands server reached over Tailscale routinely takes 5-8s to
38
+ * answer its first request after idle, so the old 5s cap silently dropped real
39
+ * `finished` / `awaiting_input` pushes (observed in `notify.log`:
40
+ * "timeout after 5000ms"). 12s comfortably clears a cold-start round-trip while
41
+ * still being a hard cap so a wedged server can never hang a hook's turn.
42
+ */
43
+ export const DEFAULT_TIMEOUT_MS = 12000;
44
+ /**
45
+ * How many times to ATTEMPT a send before giving up (1 = no retry). A single
46
+ * cold-start timeout or transient network blip used to mean the user got no
47
+ * notification at all; we now retry transient failures (timeout / network /
48
+ * 5xx) with a short backoff so a slow-but-reachable server still delivers.
49
+ * A 4xx (auth / bad request) is permanent and is NOT retried.
50
+ */
51
+ export const DEFAULT_MAX_ATTEMPTS = 3;
52
+ /** Backoff (ms) applied BEFORE attempt N (index 0 = before the 2nd attempt). */
53
+ export const RETRY_BACKOFF_MS = [500, 1500];
37
54
  /** Rotate `notify.log` once it grows past this (keeps one `.1` backup). */
38
55
  export const MAX_LOG_BYTES = 256 * 1024;
39
56
  /** Absolute path to the failure log. */
@@ -93,13 +110,32 @@ async function failure(reason, opts, extra = {}) {
93
110
  await appendLog(`SEND FAIL: ${reason}`, opts);
94
111
  return { ok: false, error: reason, ...extra };
95
112
  }
113
+ /** Real backoff sleep (cancellable-free; the caller never aborts a backoff). */
114
+ function defaultSleep(ms) {
115
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
+ }
96
117
  /**
97
- * Send one push to `/notify/send`. Resolves (never rejects) to a
98
- * {@link SendResult}; callers may treat ANY result as "exit 0".
118
+ * Send one push to `/notify/send`, retrying transient failures with a short
119
+ * backoff. Resolves (never rejects) to a {@link SendResult}; callers may treat
120
+ * ANY result as "exit 0".
121
+ *
122
+ * Retry policy (2026-06-20): a single cold-start timeout or transient network
123
+ * blip used to drop the user's notification entirely. We now attempt up to
124
+ * {@link DEFAULT_MAX_ATTEMPTS} times, backing off per {@link RETRY_BACKOFF_MS}
125
+ * between attempts. A 4xx (auth / bad-request) is PERMANENT and stops the loop
126
+ * immediately — retrying it would just burn the hook's time budget. Every
127
+ * attempt's failure is logged so `notify.log` shows the full retry trail.
99
128
  */
100
129
  export async function send(payload, opts = {}) {
101
130
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
102
131
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
132
+ // Coerce defensively: a NaN (e.g. a failed CLI numeric parse) must not make
133
+ // `Math.max(1, NaN) === NaN` skip the loop and then throw on `last!.result`.
134
+ const rawAttempts = opts.maxAttempts;
135
+ const maxAttempts = typeof rawAttempts === "number" && Number.isFinite(rawAttempts)
136
+ ? Math.max(1, Math.floor(rawAttempts))
137
+ : DEFAULT_MAX_ATTEMPTS;
138
+ const sleep = opts.sleepImpl ?? defaultSleep;
103
139
  let creds;
104
140
  try {
105
141
  creds = await readCredentials(opts);
@@ -112,41 +148,78 @@ export async function send(payload, opts = {}) {
112
148
  }
113
149
  const server = normalizeServer(opts.server ?? creds.server ?? builtinDefaultServer());
114
150
  const url = `${server}/api/v1/notify/send`;
115
- const controller = new AbortController();
116
- const timer = setTimeout(() => controller.abort(), timeoutMs);
117
- try {
118
- const res = await fetchImpl(url, {
119
- method: "POST",
120
- headers: {
121
- "content-type": "application/json",
122
- authorization: `Bearer ${creds.api_key}`,
123
- },
124
- body: JSON.stringify(buildBody(payload)),
125
- signal: controller.signal,
126
- });
127
- if (!res.ok) {
128
- return failure(`server responded ${res.status} for kind=${payload.kind}`, opts, {
129
- status: res.status,
151
+ const apiKey = creds.api_key;
152
+ const body = JSON.stringify(buildBody(payload));
153
+ /** One attempt — resolves to an {@link AttemptOutcome}, never throws. */
154
+ const attempt = async () => {
155
+ const controller = new AbortController();
156
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
157
+ try {
158
+ const res = await fetchImpl(url, {
159
+ method: "POST",
160
+ headers: {
161
+ "content-type": "application/json",
162
+ authorization: `Bearer ${apiKey}`,
163
+ },
164
+ body,
165
+ signal: controller.signal,
130
166
  });
167
+ if (!res.ok) {
168
+ // 5xx is transient (server hiccup / cold start) → retriable.
169
+ // 4xx is permanent (auth / bad request) → do not retry.
170
+ const retriable = res.status >= 500;
171
+ return {
172
+ result: { ok: false, status: res.status, error: `server responded ${res.status} for kind=${payload.kind}` },
173
+ retriable,
174
+ };
175
+ }
176
+ let sent;
177
+ try {
178
+ const json = (await res.json());
179
+ if (typeof json?.sent === "number")
180
+ sent = json.sent;
181
+ }
182
+ catch {
183
+ // A 2xx with an unparseable body still counts as delivered.
184
+ }
185
+ return { result: { ok: true, status: res.status, sent }, retriable: false };
131
186
  }
132
- let sent;
133
- try {
134
- const json = (await res.json());
135
- if (typeof json?.sent === "number")
136
- sent = json.sent;
187
+ catch (err) {
188
+ // Timeout (aborted) and network errors are both transient → retriable.
189
+ const reason = controller.signal.aborted
190
+ ? `timeout after ${timeoutMs}ms for kind=${payload.kind}`
191
+ : `request failed: ${errMessage(err)}`;
192
+ return { result: { ok: false, error: reason }, retriable: true };
137
193
  }
138
- catch {
139
- // A 2xx with an unparseable body still counts as delivered.
194
+ finally {
195
+ clearTimeout(timer);
140
196
  }
141
- return { ok: true, status: res.status, sent };
142
- }
143
- catch (err) {
144
- const reason = controller.signal.aborted
145
- ? `timeout after ${timeoutMs}ms for kind=${payload.kind}`
146
- : `request failed: ${errMessage(err)}`;
147
- return failure(reason, opts);
148
- }
149
- finally {
150
- clearTimeout(timer);
197
+ };
198
+ let last;
199
+ for (let i = 0; i < maxAttempts; i++) {
200
+ if (i > 0) {
201
+ // Backoff before this retry. Clamp to the last defined step for any
202
+ // attempt count beyond the table so a larger `maxAttempts` still works.
203
+ const backoff = RETRY_BACKOFF_MS[Math.min(i - 1, RETRY_BACKOFF_MS.length - 1)] ?? 0;
204
+ await sleep(backoff);
205
+ }
206
+ last = await attempt();
207
+ if (last.result.ok) {
208
+ // Note the recovery in the log when an earlier attempt had failed, so a
209
+ // "it worked on retry 2" story is visible in notify.log.
210
+ if (i > 0) {
211
+ await appendLog(`SEND OK on attempt ${i + 1}/${maxAttempts} for kind=${payload.kind}`, opts);
212
+ }
213
+ return last.result;
214
+ }
215
+ // Log every failed attempt so the retry trail is diagnosable.
216
+ const willRetry = last.retriable && i < maxAttempts - 1;
217
+ await appendLog(`SEND FAIL (attempt ${i + 1}/${maxAttempts}${willRetry ? ", will retry" : ""}): ${last.result.error}`, opts);
218
+ // Permanent failure (4xx) — stop immediately, retrying can't help.
219
+ if (!last.retriable)
220
+ break;
151
221
  }
222
+ // All attempts exhausted (or a permanent failure). `last` is always set here
223
+ // because the loop runs at least once (maxAttempts >= 1).
224
+ return last.result;
152
225
  }
@@ -1,2 +1,2 @@
1
1
  // Single source of truth for the CLI version. Keep in sync with package.json.
2
- export const VERSION = "0.5.0";
2
+ export const VERSION = "0.6.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Free phone + branded desktop notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
5
5
  "license": "MIT",
6
6
  "type": "module",