@trygocode/notify 0.3.4 → 0.4.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
@@ -1,12 +1,14 @@
1
1
  # @trygocode/notify
2
2
 
3
- Free phone notifications for any coding agent — **Cursor, Claude Code, OpenCode,
4
- or any Ralph/Homer loop** — delivered to your phone via the
5
- [GoCode](https://oh.jeltechsolutions.com) app + FCM.
3
+ Free phone **and branded desktop** notifications for any coding agent — **Cursor,
4
+ Claude Code, OpenCode, or any Ralph/Homer loop** — delivered to your phone via the
5
+ [GoCode](https://oh.jeltechsolutions.com) app + FCM, and to your computer as a
6
+ native **"GoCode"** banner.
6
7
 
7
- You get an automatic push when your agent **finishes a turn**, **goes idle
8
+ You get an automatic notification when your agent **finishes a turn**, **goes idle
8
9
  waiting for you**, **errors out**, or when an overnight **loop completes / halts**
9
- so you can walk away and let your phone tell you when it needs you.
10
+ on your phone AND on the computer you're working on (on by default) so you can
11
+ walk away and let either device tell you when it needs you.
10
12
 
11
13
  ## Contents
12
14
 
@@ -157,6 +159,69 @@ never get double-pinged.
157
159
  > `setup`/`setup --force`. `send` and `test` remain callable on their own for
158
160
  > scripts and power users.
159
161
 
162
+ ## Desktop notifications (branded, on by default)
163
+
164
+ GoCode Notify also raises a **real, branded "GoCode" banner on the computer you're
165
+ working on** — the same instant your phone gets pinged — so you're told whether
166
+ you're at your desk or away. It's **ON by default** once the package is installed
167
+ and is controlled by the **same server-synced settings** as the phone push, so a
168
+ toggle in the app or the terminal flips the computer banner everywhere.
169
+
170
+ **Word-for-word identical to the phone.** The banner reads exactly the same as the
171
+ push you'd get on your phone — same status emoji, IDE/source label, project, and
172
+ per-kind copy (e.g. `✅ Cursor · openhandapp — Agent finished`). The wording is a
173
+ faithful mirror of the server's notification decoration and the Flutter client's,
174
+ pinned by a cross-check test (`notify_copy.test.ts`) so the three surfaces can't
175
+ drift.
176
+
177
+ **Click to open your IDE (macOS).** Clicking the banner opens the **IDE window for
178
+ that project** — the same editor that raised the turn (Cursor / VS Code), focused
179
+ on the project you were working in. This is best-effort: when the IDE/project
180
+ can't be named the banner is simply display-only (never a dead or mis-routed
181
+ click). It opens the project *window*, not a specific past chat — IDEs don't
182
+ expose reliable per-chat deep links.
183
+
184
+ | OS | How it's shown | Branded as |
185
+ |---|---|---|
186
+ | **macOS** | A one-time `osacompile`d `GoCode.app` helper under `~/.gocode/desktop/` raises the banner via the system | **"GoCode"** + the GoCode icon |
187
+ | **Windows** | A WinRT `ToastGeneric` toast bound to a one-time Start-Menu shortcut carrying the `GoCode.Notify` AppUserModelID | **"GoCode"** |
188
+ | **Linux** | `notify-send -a GoCode -i <icon>` | **"GoCode"** + icon |
189
+
190
+ **Zero new dependencies.** It uses only tools that ship with the OS (`osacompile`/
191
+ `osascript`/`sips` on macOS, PowerShell + WinRT on Windows, `notify-send` on Linux)
192
+ — so the **one-line install does not change at all**. The first banner builds a tiny
193
+ branded helper once and caches it; every banner after that just fires it.
194
+
195
+ ### Control it
196
+
197
+ From the **GoCode app** (Settings → **GoCode Notify** → *Desktop notifications*),
198
+ or from the terminal:
199
+
200
+ ```bash
201
+ gocode-notify config set desktop.enabled false # silence the computer banner
202
+ gocode-notify config set desktop.sound false # keep the banner, drop the sound
203
+ ```
204
+
205
+ The phone push is unaffected — `desktop.*` only governs the local banner.
206
+
207
+ ### Try it right now
208
+
209
+ ```bash
210
+ # Fire a branded test banner on THIS computer (independent of the phone push):
211
+ gocode-notify test --desktop
212
+ ```
213
+
214
+ You should see a **"GoCode"** notification appear on your screen. On a headless
215
+ box / CI / SSH session where a banner makes no sense, set
216
+ `GOCODE_NOTIFY_NO_DESKTOP=1` to hard-disable local banners for that machine.
217
+
218
+ > **macOS branding note.** A bare `osascript` banner is attributed to "Script
219
+ > Editor". To brand it "GoCode", the package compiles a minimal AppleScript app
220
+ > bundle (`~/.gocode/desktop/GoCode.app`) once with `osacompile` (ships with
221
+ > every Mac — no Xcode/Swift) and drops the GoCode icon into it via `sips`. If
222
+ > that one-time setup ever fails, the banner still fires (unbranded) rather than
223
+ > blocking your turn.
224
+
160
225
  ## Hand off to your server — `launch` / `autopilot`
161
226
 
162
227
  > **New in 0.2.0.** Notify can now do more than ping your phone — it can **hand a
@@ -344,6 +409,8 @@ Valid keys (mirror of the canonical schema):
344
409
  | `commit_message.mode` | `auto` \| `ai` \| `deterministic` | Commit-message strategy |
345
410
  | `commit_message.command` | string \| null | Explicit local summariser command |
346
411
  | `commit_message.max_diff_bytes` | int > 0 | Diff byte cap fed to the summariser (default 61440) |
412
+ | `desktop.enabled` | bool | Branded desktop banner on this computer (**ON by default**) |
413
+ | `desktop.sound` | bool | Play a sound with the desktop banner (default true) |
347
414
 
348
415
  ```bash
349
416
  # Examples
@@ -440,6 +507,34 @@ npm publish, real-device E2E), see
440
507
 
441
508
  ## Changelog
442
509
 
510
+ ### 0.4.0
511
+
512
+ - **New: branded desktop notifications (on by default).** Every event that pings
513
+ your phone now also raises a native, **GoCode-branded** banner on the computer
514
+ you're working on — macOS (a one-time `osacompile`d `GoCode.app` helper +
515
+ `sips` icon), Windows (a WinRT `ToastGeneric` toast bound to a one-time
516
+ Start-Menu shortcut with the `GoCode.Notify` AppUserModelID), and Linux
517
+ (`notify-send -a GoCode`). **Zero new dependencies** — OS-native tools only,
518
+ so the one-line install is unchanged. Controlled by the same server-synced
519
+ settings as the phone push via the new `desktop.enabled` / `desktop.sound`
520
+ keys (both default ON); toggle from the app or `gocode-notify config set
521
+ desktop.enabled false`. Try it with `gocode-notify test --desktop`. Hard-
522
+ disable per machine with `GOCODE_NOTIFY_NO_DESKTOP=1` (headless / CI / SSH).
523
+ Best-effort + non-blocking: a failed banner never blocks or fails your turn.
524
+ - **Word-for-word match with the phone push.** The desktop banner now reads
525
+ EXACTLY like the phone notification — same status emoji + IDE/source label +
526
+ project + per-kind title/body (e.g. `✅ Cursor · openhandapp — Agent
527
+ finished`). New `src/notify_copy.ts` mirrors the server's `_decorated_title` /
528
+ `_decorated_body` + `_DEFAULT_TITLES` / `_DEFAULT_BODIES` and the Flutter
529
+ client's `decorateNotificationTitle` / `sourceLabelFor`, pinned by a
530
+ cross-check test so the CLI/server/app surfaces can't drift.
531
+ - **Click to open the IDE window for the project (macOS).** Clicking the banner
532
+ opens the IDE that raised the turn (Cursor / VS Code) focused on the project
533
+ directory. Implemented zero-dependency: the branded AppleScript applet handles
534
+ the click re-run (documented macOS behaviour) and `open -a <ide> <project>`.
535
+ Best-effort — display-only when the IDE/project can't be named. Opens the
536
+ project *window*, not a specific chat (IDEs lack reliable per-chat deep links).
537
+
443
538
  ### 0.3.0
444
539
 
445
540
  - **New: Cursor `stop.status` → kind mapping (T-CUR1/T-CUR4).** The `on-stop`
@@ -510,6 +605,9 @@ Zero runtime dependencies beyond the MCP SDK (Node built-in `fetch`/`fs`/
510
605
  | `src/setup.ts` | Installer orchestration (pair → detect → write configs) |
511
606
  | `src/claude.ts` / `src/cursor.ts` / `src/opencode.ts` | Per-client config writers (hooks + MCP + rule/skill); hooks call the `on-stop` dispatcher. OpenCode uses a `session.idle` plugin instead of a hooks file |
512
607
  | `src/send.ts` / `src/login.ts` / `src/mcp.ts` | Core send, pairing, and MCP server (incl. `gocode_launch_autopilot`) |
608
+ | `src/desktop_notify.ts` | Branded native desktop banners (macOS `GoCode.app` / Windows WinRT toast / Linux `notify-send`); zero-dep, best-effort; macOS click-through opens the IDE window |
609
+ | `src/notify_copy.ts` | Banner wording mirror — reproduces the server/Flutter title/body decoration so the desktop banner reads word-for-word like the phone |
610
+ | `assets/gocode-icon.{png,ico}` | Bundled GoCode icon used to brand desktop banners |
513
611
  | `src/launch.ts` | Shared `launch()` core behind the `launch`/`autopilot` command + the MCP tool |
514
612
  | `src/push.ts` | Auto-push flow behind the `on-stop` dispatcher — git add/commit/push, FF-only |
515
613
  | `src/commit_message.ts` | AI commit-message resolution chain + deterministic fallback |
Binary file
Binary file
package/dist/src/cli.js CHANGED
@@ -21,6 +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
25
  import { gatherDoctor, formatDoctor } from "./doctor.js";
25
26
  /** Subcommands the finished CLI will expose (see PRD §4.1). */
26
27
  export const COMMANDS = [
@@ -88,7 +89,7 @@ export function printHelp() {
88
89
  "Commands:",
89
90
  " login Pair this machine with your GoCode account",
90
91
  " send Send a single push notification",
91
- " test Send a canned test push",
92
+ " test Send a canned test push (add --desktop for a local banner)",
92
93
  " setup Pair + detect runtimes + write configs",
93
94
  " status Report credentials / server / detected runtimes",
94
95
  " doctor Self-diagnostic checklist with exact fix commands",
@@ -313,6 +314,32 @@ export async function cmdTest(args, deps = {}) {
313
314
  const flags = parseFlags(args);
314
315
  const agent = isAgentDriven(flags);
315
316
  const sink = deps.sink ?? stdoutSink;
317
+ // `--desktop`: fire a BRANDED local desktop banner so the user can verify the
318
+ // computer-notification path (and its GoCode branding) works during install,
319
+ // independent of the phone push (GOCODE_NOTIFY_DESKTOP PRD §3). Unlike the
320
+ // fire-and-forget hook path, this interactive probe reports the real outcome
321
+ // and exits non-zero on failure so the human/agent sees it.
322
+ if (flagBool(flags, "desktop")) {
323
+ const desk = await notifyDesktop({
324
+ title: "GoCode test",
325
+ body: "If you can read this banner, GoCode desktop notifications work.",
326
+ kind: "finished",
327
+ sound: true,
328
+ }, { home: deps.home, timeoutMs: deps.timeoutMs, timestamp: deps.timestamp });
329
+ if (desk.ok) {
330
+ if (agent)
331
+ sink({ step: "test-desktop", ok: true, detail: `desktop banner shown (${desk.platform})` });
332
+ else
333
+ console.log(`✓ Desktop banner shown (${desk.platform}). Look for the GoCode notification on your screen.`);
334
+ return 0;
335
+ }
336
+ const detail = `desktop banner not shown (${desk.error ?? "unknown"})`;
337
+ if (agent)
338
+ sink({ step: "test-desktop", ok: false, detail });
339
+ else
340
+ console.error(`gocode-notify test --desktop: ${detail}.`);
341
+ return 1;
342
+ }
316
343
  const server = await resolveServerUrl(flagString(flags, "server"), deps);
317
344
  const sendOpts = {
318
345
  home: deps.home,
@@ -0,0 +1,584 @@
1
+ // `desktop_notify` — BRANDED native desktop notifications on the dev machine
2
+ // (GOCODE_NOTIFY_DESKTOP PRD §2). The companion to the phone push: when the
3
+ // agent finishes a turn / errors / a loop completes, the SAME event that pings
4
+ // your phone via FCM also raises a real OS banner on the computer you're sitting
5
+ // at — so you get told whether you're at your desk or away.
6
+ //
7
+ // SCOPE OF THIS MODULE: take a resolved {title, body, kind, sound} and raise ONE
8
+ // native banner on whatever OS we're on, BRANDED as "GoCode" with the GoCode
9
+ // icon. It owns the per-platform mechanics + the one-time branding setup; the
10
+ // DECISION of whether to fire (settings gating) lives in `on_stop.ts` / the CLI,
11
+ // exactly like the phone `send` path.
12
+ //
13
+ // Contract (mirrors `send.ts` — PRD §0.5 "Never block the agent"):
14
+ // - Hard timeout (default 5s) — a wedged osascript/PowerShell can never hang a
15
+ // hook's turn. Best-effort: NEVER throws, NEVER rejects; always resolves to a
16
+ // {@link DesktopResult}. A failed banner must never block or fail the agent.
17
+ // - Failures are appended to `~/.gocode/notify.log` (shared with the sender).
18
+ //
19
+ // BRANDING — the whole point of this module (zero new npm deps, OS tools only):
20
+ // • macOS → we `osacompile` a tiny "GoCode.app" AppleScript bundle ONCE into
21
+ // `~/.gocode/desktop/GoCode.app`, drop the bundled GoCode `.icns`/PNG into
22
+ // it, and invoke its `applet` binary. macOS attributes the banner to the
23
+ // SENDING app bundle, so the notification shows "GoCode" + our icon (a bare
24
+ // `osascript display notification` would show "Script Editor"). `osacompile`
25
+ // ships with every macOS — no Xcode, no Swift, no Homebrew.
26
+ // • Windows → we ensure a Start-Menu `.lnk` shortcut carrying our
27
+ // AppUserModelID ("GoCode.Notify") exists ONCE, then raise a WinRT
28
+ // `ToastGeneric` toast bound to that AUMID via inline PowerShell. Windows
29
+ // keys the toast's app name + icon off the AUMID's shortcut, so the toast
30
+ // reads "GoCode". PowerShell + WinRT ship with Windows 10/11 — no deps.
31
+ // • Linux → `notify-send -a GoCode -i <icon>` (the `-a` app-name + `-i` icon
32
+ // flags brand it). libnotify is present on essentially every desktop distro.
33
+ //
34
+ // Zero runtime deps — Node built-ins only (child_process / fs / os / path / url),
35
+ // matching the package's zero-dependency rule.
36
+ import { spawn } from "node:child_process";
37
+ import { promises as fs } from "node:fs";
38
+ import path from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { appendLog } from "./send.js";
41
+ import { gocodeDir } from "./creds.js";
42
+ /** Default banner timeout (PRD §2.4: 5s hard cap, same as the phone `send`). */
43
+ export const DEFAULT_DESKTOP_TIMEOUT_MS = 5000;
44
+ /** The Windows AppUserModelID the branded toast binds to (PRD §2.3). */
45
+ export const WINDOWS_AUMID = "GoCode.Notify";
46
+ /** The app/bundle display name every platform brands the banner with. */
47
+ export const APP_NAME = "GoCode";
48
+ /**
49
+ * Version of the compiled macOS helper bundle. BUMP this whenever the applet's
50
+ * AppleScript changes (e.g. the click handler added in 0.5.0) so an existing
51
+ * install's stale `GoCode.app` is recompiled once instead of silently reused.
52
+ * Tracked via a `~/.gocode/desktop/.bundle-v<N>` marker file.
53
+ */
54
+ export const MAC_BUNDLE_VERSION = 2;
55
+ /**
56
+ * Env var that hard-disables ALL desktop banners regardless of settings — a
57
+ * machine-level opt-out for headless servers, CI, SSH sessions, or any box where
58
+ * a native banner makes no sense. Treated as truthy for any value other than the
59
+ * usual falsy strings. Checked first thing in {@link notifyDesktop} so it can
60
+ * never spawn a banner process. (The test suite also sets it so unit tests never
61
+ * raise real OS banners.)
62
+ */
63
+ export const DISABLE_DESKTOP_ENV = "GOCODE_NOTIFY_NO_DESKTOP";
64
+ /** True when {@link DISABLE_DESKTOP_ENV} is set to a truthy value. */
65
+ export function desktopDisabledByEnv(env = process.env) {
66
+ const raw = env[DISABLE_DESKTOP_ENV];
67
+ if (raw == null)
68
+ return false;
69
+ const v = raw.trim().toLowerCase();
70
+ return v !== "" && v !== "0" && v !== "false" && v !== "no" && v !== "off";
71
+ }
72
+ /**
73
+ * Default OS-command runner: spawn `cmd args`, optionally feed `input` on stdin,
74
+ * capture stdout/stderr, and resolve with the exit code. NEVER rejects — a spawn
75
+ * error (binary missing) resolves to a non-zero code so the flow treats it as a
76
+ * skip, not a throw. A hard timeout kills a wedged child so a hook never hangs.
77
+ */
78
+ export const defaultRunner = (cmd, args, opts) => new Promise((resolve) => {
79
+ let settled = false;
80
+ const finish = (r) => {
81
+ if (settled)
82
+ return;
83
+ settled = true;
84
+ resolve(r);
85
+ };
86
+ let child;
87
+ try {
88
+ child = spawn(cmd, args, {
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
91
+ });
92
+ }
93
+ catch (err) {
94
+ finish({ code: 127, stdout: "", stderr: String(err) });
95
+ return;
96
+ }
97
+ let stdout = "";
98
+ let stderr = "";
99
+ child.stdout?.setEncoding("utf8");
100
+ child.stderr?.setEncoding("utf8");
101
+ child.stdout?.on("data", (c) => (stdout += c));
102
+ child.stderr?.on("data", (c) => (stderr += c));
103
+ const timer = setTimeout(() => {
104
+ try {
105
+ child.kill("SIGKILL");
106
+ }
107
+ catch {
108
+ // ignore
109
+ }
110
+ finish({ code: 124, stdout, stderr: stderr || "timeout" });
111
+ }, opts.timeoutMs);
112
+ child.on("error", (err) => {
113
+ clearTimeout(timer);
114
+ finish({ code: 127, stdout, stderr: stderr || String(err) });
115
+ });
116
+ child.on("close", (code) => {
117
+ clearTimeout(timer);
118
+ finish({ code: code ?? 1, stdout, stderr });
119
+ });
120
+ if (opts.input !== undefined) {
121
+ try {
122
+ child.stdin?.end(opts.input);
123
+ }
124
+ catch {
125
+ // a broken pipe must not throw
126
+ }
127
+ }
128
+ else {
129
+ try {
130
+ child.stdin?.end();
131
+ }
132
+ catch {
133
+ // ignore
134
+ }
135
+ }
136
+ });
137
+ /** Directory under `~/.gocode/` holding the branding helper (the .app / marker). */
138
+ export function desktopDir(opts) {
139
+ return path.join(gocodeDir(opts), "desktop");
140
+ }
141
+ /**
142
+ * Resolve the bundled GoCode icon shipped in the package's `assets/` dir. The
143
+ * compiled module lives at `dist/src/desktop_notify.js`, so `assets/` is two
144
+ * levels up (`dist/src/.. /.. /assets`). Returns the platform-appropriate file
145
+ * (PNG everywhere; `.ico` is only meaningful to Windows but we ship PNG too).
146
+ * Returns undefined when the asset can't be located (then we brand by name only).
147
+ */
148
+ export function bundledIconPath(ext = "png") {
149
+ try {
150
+ const here = path.dirname(fileURLToPath(import.meta.url)); // dist/src
151
+ const candidate = path.resolve(here, "..", "..", "assets", `gocode-icon.${ext}`);
152
+ return candidate;
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ }
158
+ /** Escape a string for safe embedding inside an AppleScript double-quoted literal. */
159
+ export function escapeAppleScript(s) {
160
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
161
+ }
162
+ /** Escape a string for safe embedding inside a PowerShell single-quoted literal. */
163
+ export function escapePowerShellSingleQuoted(s) {
164
+ return s.replace(/'/g, "''");
165
+ }
166
+ /** Escape text for safe embedding inside toast XML (the 5 XML entities). */
167
+ export function escapeXml(s) {
168
+ return s
169
+ .replace(/&/g, "&amp;")
170
+ .replace(/</g, "&lt;")
171
+ .replace(/>/g, "&gt;")
172
+ .replace(/"/g, "&quot;")
173
+ .replace(/'/g, "&apos;");
174
+ }
175
+ /**
176
+ * Raise ONE branded native desktop banner for `payload`. Best-effort + total:
177
+ * NEVER throws, NEVER rejects; always resolves to a {@link DesktopResult}, and
178
+ * the caller can treat any result as "exit 0". Branding setup is one-time and
179
+ * cached; if it fails the banner still fires unbranded (degrade, never block).
180
+ */
181
+ export async function notifyDesktop(payload, opts = {}) {
182
+ const platform = (opts.platform ?? process.platform);
183
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_DESKTOP_TIMEOUT_MS;
184
+ const run = opts.run ?? defaultRunner;
185
+ const logLine = async (line) => {
186
+ try {
187
+ if (opts.log)
188
+ await opts.log(line);
189
+ else
190
+ await appendLog(`DESKTOP: ${line}`, { home: opts.home, timestamp: opts.timestamp });
191
+ }
192
+ catch {
193
+ // logging must never block the flow
194
+ }
195
+ };
196
+ // Machine-level hard opt-out (headless / CI / SSH). Never spawn anything.
197
+ // Honour a per-call `opts.env` override too (consistency with the runner,
198
+ // which merges opts.env into the child env) — falling back to process.env.
199
+ if (desktopDisabledByEnv({ ...process.env, ...(opts.env ?? {}) })) {
200
+ await logLine(`disabled via ${DISABLE_DESKTOP_ENV} — skipped`);
201
+ return { ok: false, platform: "unsupported", error: `disabled via ${DISABLE_DESKTOP_ENV}` };
202
+ }
203
+ const title = (payload.title ?? APP_NAME).trim() || APP_NAME;
204
+ const body = (payload.body ?? "").trim();
205
+ const sound = payload.sound !== false; // default ON (PRD §2.1)
206
+ const click = payload.click;
207
+ try {
208
+ if (platform === "darwin") {
209
+ return await notifyMac({ title, body, sound, click }, { ...opts, run, timeoutMs, log: logLine });
210
+ }
211
+ if (platform === "win32") {
212
+ return await notifyWindows({ title, body, sound, click }, { ...opts, run, timeoutMs, log: logLine });
213
+ }
214
+ if (platform === "linux") {
215
+ return await notifyLinux({ title, body, sound, click }, { ...opts, run, timeoutMs, log: logLine });
216
+ }
217
+ await logLine(`no desktop banner path for platform "${platform}" — skipped`);
218
+ return { ok: false, platform: "unsupported", error: `unsupported platform ${platform}` };
219
+ }
220
+ catch (err) {
221
+ // Defence in depth: this module must never throw. Surface the real message
222
+ // (platform-specific integrations are fragile — keep the breadcrumb).
223
+ const msg = err instanceof Error ? err.message : String(err);
224
+ await logLine(`unexpected error on ${platform} — treated as no-op: ${msg}`);
225
+ return { ok: false, platform: "unsupported", error: `unexpected error: ${msg}` };
226
+ }
227
+ }
228
+ // ── macOS ────────────────────────────────────────────────────────────────────
229
+ /** Path to the branded helper app bundle under `~/.gocode/desktop/GoCode.app`. */
230
+ export function macAppPath(opts) {
231
+ return path.join(desktopDir(opts), "GoCode.app");
232
+ }
233
+ /**
234
+ * Path to the click-target state file the applet reads when a banner is clicked
235
+ * (PRD §3.6). Holds a single `"<app>\t<projectPath>"` line written just before
236
+ * the most recent banner is posted; the applet's click handler opens that IDE.
237
+ * Single-file (last-write-wins) is fine: the user clicks the banner they just
238
+ * saw, and a banner only stays clickable while it's the latest.
239
+ */
240
+ export function macClickTargetPath(opts) {
241
+ return path.join(desktopDir(opts), "click-target.tsv");
242
+ }
243
+ /**
244
+ * Ensure the branded `GoCode.app` AppleScript bundle exists under
245
+ * `~/.gocode/desktop/`. Compiled ONCE with `osacompile` (ships with macOS), then
246
+ * the bundled GoCode icon is dropped in as `applet.icns` so the banner shows our
247
+ * icon + "GoCode". Idempotent: a second call no-ops when the bundle is present.
248
+ *
249
+ * The bundle's AppleScript reads the banner text from env vars
250
+ * (GOCODE_NOTIFY_TITLE / GOCODE_NOTIFY_MESSAGE / GOCODE_NOTIFY_SOUND) so we never
251
+ * recompile per banner — we just set env + invoke the `applet` binary directly
252
+ * (the only invocation that actually works headless; `open -a` silently no-ops
253
+ * for AppleScript applets).
254
+ *
255
+ * CLICK-THROUGH (PRD §3.6): clicking a banner posted by an AppleScript applet
256
+ * sends a fresh `run` event back to THIS applet (documented macOS behaviour). We
257
+ * distinguish the two run modes by the presence of GOCODE_NOTIFY_TITLE:
258
+ * • env var SET → we're posting a banner (the normal invoke).
259
+ * • env var EMPTY → we were re-run by a notification CLICK → read the last
260
+ * click-target file and `open -a <ide> <projectPath>` to focus the IDE.
261
+ * This keeps click-through ZERO-dependency (pure osacompile/AppleScript) — no
262
+ * Swift, no signed binary, no extra npm dep.
263
+ *
264
+ * Returns the applet binary path on success, or undefined when branding could
265
+ * not be set up (caller then falls back to a plain unbranded `osascript`).
266
+ */
267
+ export async function ensureMacBrandedApp(ctx) {
268
+ const appPath = macAppPath(ctx);
269
+ const appletBin = path.join(appPath, "Contents", "MacOS", "applet");
270
+ const versionMarker = path.join(desktopDir(ctx), `.bundle-v${MAC_BUNDLE_VERSION}`);
271
+ try {
272
+ // Already built AND at the CURRENT bundle version? → reuse. The version
273
+ // marker lets a package update (e.g. adding the click handler) force a
274
+ // one-time recompile of a stale bundle from an older install.
275
+ await fs.access(appletBin);
276
+ await fs.access(versionMarker);
277
+ return appletBin;
278
+ }
279
+ catch {
280
+ // need to (re)build it — missing applet OR stale/absent version marker
281
+ }
282
+ try {
283
+ await fs.mkdir(desktopDir(ctx), { recursive: true, mode: 0o700 });
284
+ // The applet reads the banner text from the environment so we compile ONCE.
285
+ // The same applet handles a notification CLICK (a second `run` with NO env)
286
+ // by opening the IDE recorded in the click-target file (see header).
287
+ const clickFile = macClickTargetPath(ctx);
288
+ const script = [
289
+ 'on run',
290
+ ' set theTitle to (system attribute "GOCODE_NOTIFY_TITLE")',
291
+ ' if theTitle is "" then',
292
+ ' -- No banner env → this run came from a notification CLICK. Open the',
293
+ ' -- IDE recorded by the last post (format: "<app>\\t<projectPath>").',
294
+ ' my handleClick()',
295
+ ' return',
296
+ ' end if',
297
+ ' set theMsg to (system attribute "GOCODE_NOTIFY_MESSAGE")',
298
+ ' set theSound to (system attribute "GOCODE_NOTIFY_SOUND")',
299
+ ' if theSound is "1" then',
300
+ ' display notification theMsg with title theTitle sound name "Glass"',
301
+ ' else',
302
+ ' display notification theMsg with title theTitle',
303
+ ' end if',
304
+ 'end run',
305
+ '',
306
+ 'on handleClick()',
307
+ ' try',
308
+ ` set p to "${escapeAppleScript(clickFile)}"`,
309
+ ' set f to POSIX file p',
310
+ ' set raw to (read f as «class utf8»)',
311
+ ' if raw is "" then return',
312
+ ' set AppleScript\'s text item delimiters to tab',
313
+ ' set parts to text items of raw',
314
+ ' set AppleScript\'s text item delimiters to ""',
315
+ ' if (count of parts) < 2 then return',
316
+ ' set ideApp to item 1 of parts',
317
+ ' set projPath to item 2 of parts',
318
+ ' -- strip any trailing newline from projPath',
319
+ ' if projPath ends with linefeed then set projPath to text 1 thru -2 of projPath',
320
+ ' if projPath ends with return then set projPath to text 1 thru -2 of projPath',
321
+ ' do shell script "open -a " & quoted form of ideApp & " " & quoted form of projPath',
322
+ ' end try',
323
+ 'end handleClick',
324
+ ].join("\n");
325
+ const scptPath = path.join(desktopDir(ctx), "gocode-notify.applescript");
326
+ await fs.writeFile(scptPath, script, "utf8");
327
+ // Remove any stale/partial bundle so osacompile writes a clean one.
328
+ await fs.rm(appPath, { recursive: true, force: true }).catch(() => { });
329
+ const compiled = await ctx.run("osacompile", ["-o", appPath, scptPath], {
330
+ timeoutMs: ctx.timeoutMs,
331
+ });
332
+ if (compiled.code !== 0) {
333
+ await ctx.log(`osacompile failed (code ${compiled.code}): ${compiled.stderr.trim()} — falling back to unbranded`);
334
+ return undefined;
335
+ }
336
+ // Drop the GoCode icon into the bundle so the banner is branded with it.
337
+ // The applet's icon file is Contents/Resources/applet.icns. We convert the
338
+ // bundled PNG → icns via `sips` (ships with macOS); if that fails the banner
339
+ // is still branded by NAME ("GoCode") which is the important part.
340
+ await brandMacIcon(appPath, ctx).catch(() => { });
341
+ // Stamp the version marker so we don't recompile until the bundle changes.
342
+ // Clear any older `.bundle-v*` markers first so the dir doesn't accumulate.
343
+ try {
344
+ const dir = desktopDir(ctx);
345
+ for (const entry of await fs.readdir(dir)) {
346
+ if (entry.startsWith(".bundle-v")) {
347
+ await fs.rm(path.join(dir, entry), { force: true }).catch(() => { });
348
+ }
349
+ }
350
+ await fs.writeFile(versionMarker, "", { encoding: "utf8", mode: 0o600 });
351
+ }
352
+ catch {
353
+ // marker is an optimization — a failed write just means we recompile next
354
+ // time (correct, just slightly wasteful). Never fail the banner over it.
355
+ }
356
+ return appletBin;
357
+ }
358
+ catch (err) {
359
+ await ctx.log(`branded app setup error: ${err instanceof Error ? err.message : String(err)} — falling back to unbranded`);
360
+ return undefined;
361
+ }
362
+ }
363
+ /** Best-effort: convert the bundled PNG to icns and replace the applet icon. */
364
+ async function brandMacIcon(appPath, ctx) {
365
+ const png = bundledIconPath("png");
366
+ if (!png)
367
+ return;
368
+ try {
369
+ await fs.access(png);
370
+ }
371
+ catch {
372
+ return; // asset missing — keep the default applet icon (still name-branded)
373
+ }
374
+ const icnsTarget = path.join(appPath, "Contents", "Resources", "applet.icns");
375
+ const res = await ctx.run("sips", ["-s", "format", "icns", png, "--out", icnsTarget], {
376
+ timeoutMs: ctx.timeoutMs,
377
+ });
378
+ if (res.code !== 0) {
379
+ await ctx.log(`icon conversion skipped (sips code ${res.code}) — banner is name-branded only`);
380
+ }
381
+ }
382
+ /**
383
+ * Write (or clear) the click-target file the applet reads on a banner click
384
+ * (PRD §3.6). When `click` is set we write a single tab-separated line
385
+ * `"<app>\t<projectPath>"`; when it's undefined we truncate the file to empty so
386
+ * a click on this banner no-ops rather than re-opening a STALE target from a
387
+ * previous banner. Best-effort — the caller swallows any error.
388
+ */
389
+ export async function writeMacClickTarget(click, opts) {
390
+ const file = macClickTargetPath(opts);
391
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
392
+ // Strip tabs/newlines from fields so the single-line TSV format can't be
393
+ // broken by a weird app name or path (paths with tabs are not a thing we hit,
394
+ // but defend anyway). Empty content when no target → click no-ops.
395
+ const content = click
396
+ ? `${oneLine(click.app)}\t${oneLine(click.projectPath)}\n`
397
+ : "";
398
+ await fs.writeFile(file, content, { encoding: "utf8", mode: 0o600 });
399
+ }
400
+ /** Collapse tabs/newlines to spaces so a value is safe in the single-line TSV. */
401
+ function oneLine(s) {
402
+ return s.replace(/[\t\r\n]+/g, " ").trim();
403
+ }
404
+ async function notifyMac(banner, ctx) {
405
+ // Branded path: invoke the compiled GoCode.app applet so the banner is
406
+ // attributed to "GoCode" (+ our icon) instead of "Script Editor".
407
+ if (!ctx.skipBranding) {
408
+ const applet = await ensureMacBrandedApp(ctx);
409
+ if (applet) {
410
+ // Record the click target BEFORE posting, so a click on the banner we're
411
+ // about to show opens the right IDE/project (last-write-wins — the user
412
+ // clicks the banner they just saw). We ONLY write when THIS banner has a
413
+ // click target: a later display-only banner (no IDE source, e.g. a webhook
414
+ // ping) must NOT clear an earlier clickable banner's target, or clicking
415
+ // that still-visible earlier banner would no-op. Best-effort: a failed
416
+ // write just means the click no-ops (the banner still shows). (macOS only
417
+ // surfaces one process-wide applet, so this is a single shared file; the
418
+ // window between two clickable banners that target DIFFERENT projects is
419
+ // the known limit of the zero-dep applet approach — see header.)
420
+ if (banner.click) {
421
+ await writeMacClickTarget(banner.click, ctx).catch(() => { });
422
+ }
423
+ // The compiled applet reads the banner text from the environment (via
424
+ // AppleScript `system attribute`), so we pass it as env vars rather than
425
+ // recompiling per banner.
426
+ const res = await ctx.run(applet, [], {
427
+ timeoutMs: ctx.timeoutMs,
428
+ env: {
429
+ ...(ctx.env ?? {}),
430
+ GOCODE_NOTIFY_TITLE: banner.title,
431
+ GOCODE_NOTIFY_MESSAGE: banner.body,
432
+ GOCODE_NOTIFY_SOUND: banner.sound ? "1" : "0",
433
+ },
434
+ });
435
+ if (res.code === 0) {
436
+ await ctx.log(`branded banner shown (${banner.title})`);
437
+ return { ok: true, platform: "darwin" };
438
+ }
439
+ await ctx.log(`branded applet failed (code ${res.code}): ${res.stderr.trim()} — falling back to osascript`);
440
+ }
441
+ }
442
+ // Fallback: plain unbranded osascript (shows "Script Editor" but still works).
443
+ const t = escapeAppleScript(banner.title);
444
+ const b = escapeAppleScript(banner.body);
445
+ const soundClause = banner.sound ? ' sound name "Glass"' : "";
446
+ const script = `display notification "${b}" with title "${t}"${soundClause}`;
447
+ const res = await ctx.run("osascript", ["-e", script], { timeoutMs: ctx.timeoutMs });
448
+ if (res.code === 0) {
449
+ await ctx.log(`unbranded banner shown (${banner.title})`);
450
+ return { ok: true, platform: "darwin" };
451
+ }
452
+ await ctx.log(`osascript failed (code ${res.code}): ${res.stderr.trim()}`);
453
+ return { ok: false, platform: "darwin", error: res.stderr.trim() || `osascript exit ${res.code}` };
454
+ }
455
+ // ── Windows ──────────────────────────────────────────────────────────────────
456
+ /**
457
+ * Build the inline PowerShell that (1) ensures a Start-Menu shortcut carrying
458
+ * our AUMID exists so Windows brands the toast "GoCode", then (2) raises a WinRT
459
+ * ToastGeneric toast bound to that AUMID. Pure PowerShell + WinRT — no modules.
460
+ */
461
+ export function buildWindowsToastScript(banner, opts = {}) {
462
+ const aumid = opts.aumid ?? WINDOWS_AUMID;
463
+ const title = escapeXml(banner.title);
464
+ const body = escapeXml(banner.body);
465
+ const iconLine = opts.iconPath
466
+ ? `<image placement="appLogoOverride" hint-crop="circle" src="${escapeXml(opts.iconPath)}"/>`
467
+ : "";
468
+ const audioLine = banner.sound ? "" : '<audio silent="true"/>';
469
+ // The shortcut-ensure block creates a .lnk in the user's Start Menu with the
470
+ // System.AppUserModel.ID property = our AUMID (required for an unpackaged app
471
+ // to brand a toast). Idempotent: skipped when the shortcut already exists.
472
+ return [
473
+ "$ErrorActionPreference = 'Stop'",
474
+ `$AppId = '${escapePowerShellSingleQuoted(aumid)}'`,
475
+ "$AppName = 'GoCode'",
476
+ "$startMenu = [Environment]::GetFolderPath('Programs')",
477
+ "$lnk = Join-Path $startMenu 'GoCode.lnk'",
478
+ // Ensure a Start-Menu shortcut whose AppUserModelID = our AUMID exists. An
479
+ // unpackaged app MUST have such a shortcut for Windows to brand its toast
480
+ // (name + icon) off the AUMID. Done once; skipped when already present.
481
+ "if (-not (Test-Path $lnk)) {",
482
+ " try {",
483
+ " $ws = New-Object -ComObject WScript.Shell",
484
+ " $s = $ws.CreateShortcut($lnk)",
485
+ " $s.TargetPath = (Join-Path $env:SystemRoot 'System32\\\\cmd.exe')",
486
+ " $s.Arguments = '/c rem GoCode Notify'",
487
+ " $s.IconLocation = (Join-Path $env:SystemRoot 'System32\\\\cmd.exe') + ',0'",
488
+ " $s.Save()",
489
+ // Stamp System.AppUserModel.ID onto the shortcut via the Windows property
490
+ // store (the ONLY thing that actually links the .lnk to our AUMID).
491
+ " $code = @'",
492
+ "using System;",
493
+ "using System.Runtime.InteropServices;",
494
+ "public static class GoCodeLnk {",
495
+ " [ComImport, Guid(\"00021401-0000-0000-C000-000000000046\")] public class CShellLink {}",
496
+ " [ComImport, Guid(\"000214F9-0000-0000-C000-000000000046\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
497
+ " public interface IShellLinkW {",
498
+ " void GetPath([Out] System.Text.StringBuilder f, int c, IntPtr d, int g); void GetIDList(out IntPtr p); void SetIDList(IntPtr p);",
499
+ " void GetDescription(System.Text.StringBuilder n, int c); void SetDescription(string n); void GetWorkingDirectory(System.Text.StringBuilder d, int c);",
500
+ " void SetWorkingDirectory(string d); void GetArguments(System.Text.StringBuilder a, int c); void SetArguments(string a);",
501
+ " void GetHotkey(out short h); void SetHotkey(short h); void GetShowCmd(out int s); void SetShowCmd(int s);",
502
+ " void GetIconLocation(System.Text.StringBuilder p, int c, out int i); void SetIconLocation(string p, int i);",
503
+ " void SetRelativePath(string p, int r); void Resolve(IntPtr h, int f); void SetPath(string p);",
504
+ " }",
505
+ " [ComImport, Guid(\"45e2b4ae-b1c3-11d0-b92f-00a0c90312e1\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
506
+ " public interface IShellLinkDataList { void AddDataBlock(IntPtr d); void CopyDataBlock(uint s, out IntPtr d); void RemoveDataBlock(uint s); void GetFlags(out uint f); void SetFlags(uint f); }",
507
+ " [StructLayout(LayoutKind.Sequential)] public struct PROPERTYKEY { public Guid fmtid; public uint pid; }",
508
+ " [ComImport, Guid(\"886d8eeb-8cf2-4446-8d02-cdba1dbdcf99\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]",
509
+ " public interface IPropertyStore { void GetCount(out uint c); void GetAt(uint i, out PROPERTYKEY k); void GetValue(ref PROPERTYKEY k, out PROPVARIANT v); void SetValue(ref PROPERTYKEY k, ref PROPVARIANT v); void Commit(); }",
510
+ " [StructLayout(LayoutKind.Explicit)] public struct PROPVARIANT { [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p; }",
511
+ " [DllImport(\"ole32.dll\")] public static extern int PropVariantClear(ref PROPVARIANT pv);",
512
+ " [DllImport(\"shell32.dll\", CharSet=CharSet.Unicode)] public static extern IntPtr SHStrDupW(string s, out IntPtr o);",
513
+ " public static void SetAumid(string lnk, string aumid) {",
514
+ " var link = (IShellLinkW)new CShellLink(); var pf = (System.Runtime.InteropServices.ComTypes.IPersistFile)link; pf.Load(lnk, 0);",
515
+ " var ps = (IPropertyStore)link; var key = new PROPERTYKEY { fmtid = new Guid(\"9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3\"), pid = 5 };",
516
+ " var pv = new PROPVARIANT { vt = 31 }; IntPtr str; SHStrDupW(aumid, out str); pv.p = str;",
517
+ " ps.SetValue(ref key, ref pv); ps.Commit(); pf.Save(lnk, true); PropVariantClear(ref pv);",
518
+ " }",
519
+ "}",
520
+ "'@",
521
+ " Add-Type -TypeDefinition $code -Language CSharp | Out-Null",
522
+ " [GoCodeLnk]::SetAumid($lnk, $AppId)",
523
+ " } catch {}",
524
+ "}",
525
+ "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null",
526
+ "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] | Out-Null",
527
+ "$template = @\"",
528
+ "<toast>",
529
+ " <visual>",
530
+ ' <binding template="ToastGeneric">',
531
+ ` <text>${title}</text>`,
532
+ ` <text>${body}</text>`,
533
+ ` ${iconLine}`,
534
+ " </binding>",
535
+ " </visual>",
536
+ ` ${audioLine}`,
537
+ "</toast>",
538
+ "\"@",
539
+ "$xml = New-Object Windows.Data.Xml.Dom.XmlDocument",
540
+ "$xml.LoadXml($template)",
541
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
542
+ "$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AppId)",
543
+ "$notifier.Show($toast)",
544
+ ].join("\n");
545
+ }
546
+ async function notifyWindows(banner, ctx) {
547
+ const iconPath = bundledIconPath("png");
548
+ const script = buildWindowsToastScript(banner, {
549
+ aumid: WINDOWS_AUMID,
550
+ iconPath: ctx.skipBranding ? undefined : iconPath,
551
+ });
552
+ // Run via powershell with stdin so we never hit cmd-line length / quoting limits.
553
+ const res = await ctx.run("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"], { input: script, timeoutMs: ctx.timeoutMs });
554
+ if (res.code === 0) {
555
+ await ctx.log(`toast shown (${banner.title})`);
556
+ return { ok: true, platform: "win32" };
557
+ }
558
+ await ctx.log(`powershell toast failed (code ${res.code}): ${res.stderr.trim()}`);
559
+ return { ok: false, platform: "win32", error: res.stderr.trim() || `powershell exit ${res.code}` };
560
+ }
561
+ // ── Linux ────────────────────────────────────────────────────────────────────
562
+ async function notifyLinux(banner, ctx) {
563
+ const icon = bundledIconPath("png");
564
+ const args = ["-a", APP_NAME];
565
+ if (icon && !ctx.skipBranding)
566
+ args.push("-i", icon);
567
+ // Best-effort sound control: there is no portable notify-send sound flag, but
568
+ // the freedesktop `suppress-sound` hint is honoured by GNOME/KDE daemons, so
569
+ // map `sound:false` → suppress. (Daemons that ignore the hint just play their
570
+ // default — no portable way to force-silence those, hence "best effort".)
571
+ if (!banner.sound)
572
+ args.push("-h", "int:suppress-sound:1");
573
+ // notify-send positional args: <summary> [body]
574
+ args.push(banner.title);
575
+ if (banner.body)
576
+ args.push(banner.body);
577
+ const res = await ctx.run("notify-send", args, { timeoutMs: ctx.timeoutMs });
578
+ if (res.code === 0) {
579
+ await ctx.log(`notify-send banner shown (${banner.title})`);
580
+ return { ok: true, platform: "linux" };
581
+ }
582
+ await ctx.log(`notify-send failed (code ${res.code}): ${res.stderr.trim()}`);
583
+ return { ok: false, platform: "linux", error: res.stderr.trim() || `notify-send exit ${res.code}` };
584
+ }
@@ -0,0 +1,189 @@
1
+ // `notify_copy` — the ONE place the desktop banner's wording is built so it
2
+ // reads WORD-FOR-WORD identical to the phone push (GOCODE_NOTIFY_DESKTOP PRD
3
+ // §3 "same title/body as the phone"). This is a faithful port of the server's
4
+ // decoration (`dispatcher._decorated_title` / `_decorated_body` +
5
+ // `notify_routes._DEFAULT_TITLES` / `_DEFAULT_BODIES`) AND the Flutter client's
6
+ // `decorateNotificationTitle` / `sourceLabelFor` (`push_notification_service.dart`
7
+ // / `firebase_push_notification_service.dart`). All three MUST agree — there is a
8
+ // cross-check test (`notify_copy.test.ts`) that pins these tables against the
9
+ // server/Flutter strings so a future edit on one side can't silently drift.
10
+ //
11
+ // Why mirror server-side copy in the CLI at all? The desktop banner fires
12
+ // LOCALLY (it never round-trips the server — that would add latency + a failure
13
+ // mode to a best-effort banner), so the CLI must reproduce the exact string the
14
+ // server would have rendered for the phone. Keeping it in its own tiny module
15
+ // (rather than inline in `on_stop.ts`) makes the "single source of truth, pinned
16
+ // by a test" contract explicit.
17
+ //
18
+ // Zero runtime deps — pure string logic, no imports.
19
+ /**
20
+ * Max display length for a project / chat name folded into the title or body.
21
+ * Mirrors the server's `_NOTIFICATION_NAME_MAX_LEN` and the Flutter client's
22
+ * `kNotificationNameMaxLen` so a long name truncates to the SAME string the
23
+ * phone shows.
24
+ */
25
+ export const NOTIFICATION_NAME_MAX_LEN = 40;
26
+ /**
27
+ * Per-kind status emoji prefixed onto the decorated title. Mirrors the server's
28
+ * `_STATUS_EMOJI` and the Flutter client's `_statusEmojiFor`. Unknown kinds get
29
+ * no emoji (fall through), exactly like the server.
30
+ */
31
+ const STATUS_EMOJI = {
32
+ finished: "✅",
33
+ loop_completed: "✅",
34
+ error: "❌",
35
+ awaiting_input: "❓",
36
+ ralph_waiting: "❓",
37
+ loop_halted: "⚠️",
38
+ };
39
+ /**
40
+ * Per-kind DEFAULT title used when the caller supplies no explicit title — the
41
+ * funnel/stop-hook path always omits a title, so this is what the phone shows.
42
+ * Mirrors the server's `notify_routes._DEFAULT_TITLES` (chat kinds) +
43
+ * `dispatcher.LOOP_KIND_COPY` (loop kinds) EXACTLY. Keep these strings in sync
44
+ * with the server — the cross-check test pins them.
45
+ */
46
+ export const DEFAULT_TITLES = {
47
+ finished: "Agent finished",
48
+ error: "Agent hit an error",
49
+ awaiting_input: "Agent needs you",
50
+ loop_completed: "Autopilot finished",
51
+ loop_halted: "Autopilot halted — needs you",
52
+ };
53
+ /**
54
+ * Per-kind DEFAULT body used when the caller supplies no explicit body. Mirrors
55
+ * the server's `notify_routes._DEFAULT_BODIES` + `dispatcher.LOOP_KIND_COPY`.
56
+ */
57
+ export const DEFAULT_BODIES = {
58
+ finished: "Your coding run finished — tap to review.",
59
+ error: "Your coding run hit an error — tap to see what happened.",
60
+ awaiting_input: "Your coding run is waiting on you — tap to respond.",
61
+ loop_completed: "Your Autopilot loop finished — tap to review.",
62
+ loop_halted: "Your Autopilot loop stopped and needs your input — tap to resolve.",
63
+ };
64
+ /** Loop kinds whose decorated title carries the "Autopilot" prefix (no project). */
65
+ const AUTOPILOT_KINDS = new Set(["loop_completed", "loop_halted", "ralph_waiting"]);
66
+ /**
67
+ * Human-readable source label for the title prefix. `"cursor"` → `"Cursor"`,
68
+ * `"claude_code"`/`"claude"` → `"Claude"`. Mirrors the Flutter client's
69
+ * `sourceLabelFor` and the server's `_source_label` EXACTLY: a known token maps
70
+ * to its brand; `"manual"`/`"app"`/`"cli"` return null (no prefix); any other
71
+ * token is title-cased defensively. Returns null when there is no label.
72
+ */
73
+ export function sourceLabelFor(source) {
74
+ if (source == null)
75
+ return undefined;
76
+ const s = source.trim().toLowerCase();
77
+ if (s === "")
78
+ return undefined;
79
+ switch (s) {
80
+ case "gocode":
81
+ return "GoCode";
82
+ case "cursor":
83
+ return "Cursor";
84
+ case "claude_code":
85
+ case "claude":
86
+ return "Claude";
87
+ case "opencode":
88
+ return "OpenCode";
89
+ case "ralph":
90
+ return "Ralph";
91
+ case "homer":
92
+ return "Homer";
93
+ // In-app origins carry no brand prefix (mirror Flutter/`_source_label`).
94
+ case "manual":
95
+ case "app":
96
+ case "cli":
97
+ return undefined;
98
+ default: {
99
+ // Title-case an unknown token so a new server source still renders
100
+ // something sensible (mirrors Flutter's defensive default + the server).
101
+ const t = source.trim();
102
+ return t.charAt(0).toUpperCase() + t.slice(1);
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Trim + cap a project / chat name at {@link NOTIFICATION_NAME_MAX_LEN},
108
+ * appending an ellipsis when truncated. Returns undefined when the name is
109
+ * empty/whitespace so a blank field never folds an empty segment into the
110
+ * title/body. Mirrors the server's `_truncate_notification_name` and the
111
+ * client's `truncateNotificationName`.
112
+ */
113
+ export function truncateNotificationName(name) {
114
+ if (name == null)
115
+ return undefined;
116
+ const n = name.trim();
117
+ if (n === "")
118
+ return undefined;
119
+ if (n.length <= NOTIFICATION_NAME_MAX_LEN)
120
+ return n;
121
+ // Reserve one char for the ellipsis so the result length equals the cap.
122
+ return n.slice(0, NOTIFICATION_NAME_MAX_LEN - 1) + "…";
123
+ }
124
+ /**
125
+ * Build the OS-rendered banner title — WORD-FOR-WORD identical to the phone push.
126
+ * Format: `<emoji> <source label> · <project> · Autopilot — <title>`, omitting
127
+ * any empty segment. A faithful port of the server's `_decorated_title` and the
128
+ * Flutter client's `decorateNotificationTitle` (the project segment is suppressed
129
+ * for autopilot/loop kinds, exactly like both). Idempotent on the emoji.
130
+ */
131
+ export function decorateTitle(input) {
132
+ const kind = input.kind;
133
+ const autopilot = AUTOPILOT_KINDS.has(kind);
134
+ const rawTitle = input.title != null && input.title.trim() !== ""
135
+ ? input.title
136
+ : DEFAULT_TITLES[kind] ?? "";
137
+ const emoji = STATUS_EMOJI[kind];
138
+ const label = sourceLabelFor(input.source);
139
+ // Project is folded into the title for IDE/funnel pushes, but NOT for
140
+ // autopilot pushes (whose title already reads "<tool> · Autopilot — …").
141
+ const projectLabel = autopilot ? undefined : truncateNotificationName(input.project);
142
+ const prefixParts = [];
143
+ if (label)
144
+ prefixParts.push(label);
145
+ if (projectLabel)
146
+ prefixParts.push(projectLabel);
147
+ if (autopilot)
148
+ prefixParts.push("Autopilot");
149
+ const prefix = prefixParts.join(" · ");
150
+ // Defend against a caller that already prepended the emoji to the raw title.
151
+ let base = rawTitle.replace(/^\s+/, "");
152
+ if (emoji && base.startsWith(emoji)) {
153
+ base = base.slice(emoji.length).replace(/^\s+/, "");
154
+ }
155
+ const emojiS = emoji ?? "";
156
+ if (prefix) {
157
+ const left = `${emojiS} ${prefix}`.trim();
158
+ if (base === "")
159
+ return left;
160
+ return `${left} — ${base}`;
161
+ }
162
+ // No source/autopilot prefix.
163
+ if (base === "")
164
+ return emojiS;
165
+ return emojiS ? `${emojiS} ${base}`.trim() : base;
166
+ }
167
+ /**
168
+ * Build the OS-rendered banner body — matching the phone push. Folds the project
169
+ * (+ optional chat title) into the body: `<project> · <body>` or
170
+ * `<project> › <chat> · <body>`. A faithful port of the server's `_decorated_body`
171
+ * and the client's `decorateNotificationBody`. When no body is supplied the
172
+ * per-kind {@link DEFAULT_BODIES} is used so the banner is never body-less (the
173
+ * phone is never title-only either).
174
+ */
175
+ export function decorateBody(input) {
176
+ const raw = input.body != null && input.body.trim() !== ""
177
+ ? input.body
178
+ : DEFAULT_BODIES[input.kind] ?? "";
179
+ const base = raw.trim() !== "" ? raw.trim() : undefined;
180
+ const proj = truncateNotificationName(input.project);
181
+ const conv = truncateNotificationName(input.chat);
182
+ const contextParts = [proj, conv].filter((p) => p != null);
183
+ if (contextParts.length === 0)
184
+ return base ?? "";
185
+ const context = contextParts.join(" › ");
186
+ if (base == null)
187
+ return context;
188
+ return `${context} · ${base}`;
189
+ }
@@ -33,6 +33,8 @@ import { deriveRepoIdentity } from "./repo_key.js";
33
33
  import { pushOnStop, } from "./push.js";
34
34
  import { appendLog, send } from "./send.js";
35
35
  import { checkDedupLock } from "./dedup_lock.js";
36
+ import { notifyDesktop, } from "./desktop_notify.js";
37
+ import { decorateTitle, decorateBody } from "./notify_copy.js";
36
38
  /**
37
39
  * Parse the Cursor `stop` hook stdin JSON and extract the `status` field.
38
40
  * Best-effort: returns `undefined` on absent/empty/unparseable input or when the
@@ -118,6 +120,74 @@ export function cursorStopStatusToKind(status) {
118
120
  export function shouldSuppressAbortedStop(source, status) {
119
121
  return source === "cursor" && status === "aborted";
120
122
  }
123
+ /**
124
+ * Human-readable label for a notification kind, used in the desktop banner body
125
+ * (PRD §3). Mirrors the phone notification's tone so the computer banner reads
126
+ * the same. Falls back to the raw kind for any future/unknown value.
127
+ */
128
+ export function kindLabel(kind) {
129
+ switch (kind) {
130
+ case "finished":
131
+ return "Finished";
132
+ case "error":
133
+ return "Error";
134
+ case "awaiting_input":
135
+ return "Needs your input";
136
+ case "loop_completed":
137
+ return "Autopilot finished";
138
+ case "loop_halted":
139
+ return "Autopilot halted — needs you";
140
+ case "ralph_waiting":
141
+ return "Autopilot waiting";
142
+ default:
143
+ return String(kind);
144
+ }
145
+ }
146
+ /**
147
+ * Map a notification `source` (the IDE that raised the turn) to the macOS app
148
+ * name `open -a <app>` should launch, and the Windows process name. Returns
149
+ * undefined for non-IDE / unknown sources so the banner is display-only rather
150
+ * than opening the wrong thing. We intentionally cover ONLY the editors that run
151
+ * a stop hook (Cursor / Claude Code / OpenCode) — Autopilot/Ralph/Homer loops
152
+ * have no local IDE window to focus, and `manual`/webhook sources aren't IDEs.
153
+ */
154
+ export function ideAppNameFor(source) {
155
+ if (!source)
156
+ return undefined;
157
+ switch (source.trim().toLowerCase()) {
158
+ case "cursor":
159
+ return "Cursor";
160
+ case "claude":
161
+ case "claude_code":
162
+ // Claude Code is a CLI inside a terminal/VS Code; "Visual Studio Code" is
163
+ // the closest reliable window to focus when it runs inside VS Code. When
164
+ // it runs in a bare terminal there is no stable app to open, so callers
165
+ // fall back gracefully (the click just no-ops rather than mis-opening).
166
+ return "Visual Studio Code";
167
+ case "opencode":
168
+ return "Visual Studio Code";
169
+ default:
170
+ return undefined;
171
+ }
172
+ }
173
+ /**
174
+ * Build the best-effort click target for the desktop banner (PRD §3.6): clicking
175
+ * the banner opens the IDE window for the project at `cwd`. Returns undefined
176
+ * when we can't name an IDE for `source` (then the banner is display-only — a
177
+ * graceful no-click, never a dead/mis-routed click). The `cwd` is the project
178
+ * directory the agent ran in, so `open -a <ide> <cwd>` focuses (or opens) that
179
+ * IDE on the right project window. `ideChatId` is carried for future use (deep
180
+ * link to the exact chat) but not required to open the window.
181
+ */
182
+ export function clickTarget(source, cwd, _ideChatId) {
183
+ const app = ideAppNameFor(source);
184
+ if (!app)
185
+ return undefined;
186
+ const projectPath = cwd && cwd.trim() !== "" ? cwd : undefined;
187
+ if (!projectPath)
188
+ return undefined;
189
+ return { app, projectPath };
190
+ }
121
191
  /** Slice the merged settings down to what the push flow consumes. */
122
192
  function toPushSettings(settings) {
123
193
  return {
@@ -493,6 +563,46 @@ export async function onStop(opts = {}) {
493
563
  timeoutMs: opts.timeoutMs,
494
564
  timestamp: opts.timestamp,
495
565
  }));
566
+ const desktopImpl = opts.notifyDesktopImpl ?? notifyDesktop;
567
+ /**
568
+ * Fire a branded local desktop banner for this turn (PRD §3) when
569
+ * `settings.desktop.enabled` is true. Best-effort + total: never throws, and
570
+ * the result is purely informational (the phone push is the source of truth).
571
+ * Gated on the SAME server-synced settings as the phone push so a toggle on
572
+ * the app or terminal flips the computer banner too. Returns undefined when
573
+ * desktop is disabled (so the result field is omitted).
574
+ */
575
+ const fireDesktop = async (settings, banner, source, ideChatId) => {
576
+ if (settings.desktop?.enabled === false)
577
+ return undefined;
578
+ if (opts.dryRun) {
579
+ await logLine(`dry-run: would show desktop banner "${banner.title ?? ""}"`);
580
+ return undefined;
581
+ }
582
+ try {
583
+ const res = await desktopImpl({
584
+ ...banner,
585
+ sound: settings.desktop?.sound !== false,
586
+ // Best-effort click target (PRD §3.6): clicking the banner opens the
587
+ // IDE window for THIS project (the agent's cwd), the same IDE that
588
+ // raised the turn. Omitted when we can't name the IDE or the path —
589
+ // then the banner is display-only (graceful, never a dead click).
590
+ click: clickTarget(source, cwd, ideChatId),
591
+ }, {
592
+ home: opts.home,
593
+ timeoutMs: opts.timeoutMs,
594
+ timestamp: opts.timestamp,
595
+ });
596
+ await logLine(`desktop banner → ${res.ok ? "shown" : "failed"} (${res.platform})`);
597
+ return res;
598
+ }
599
+ catch (err) {
600
+ // desktop banner must never block the turn — but leave a breadcrumb so a
601
+ // throwing (injected/future) impl is diagnosable from the notify log.
602
+ await logLine(`desktop banner threw — ignored: ${err instanceof Error ? err.message : String(err)}`);
603
+ return undefined;
604
+ }
605
+ };
496
606
  try {
497
607
  // ── Step 0: Autopilot-owns-turn gate (T-N7 / PRD §3.2). ──
498
608
  // When an Autopilot loop has marked that it owns this turn, it sends its OWN
@@ -564,7 +674,26 @@ export async function onStop(opts = {}) {
564
674
  home: opts.home,
565
675
  });
566
676
  await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
567
- return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
677
+ // Branded desktop banner mirroring the push notification (PRD §3). Only
678
+ // fire when the push flow actually notified the phone (i.e. it pushed,
679
+ // hit a conflict, or was rejected) — not on no-op outcomes (disabled /
680
+ // not-a-repo / clean-tree / protected-skip) where the phone got nothing.
681
+ let desktop;
682
+ if (push.notified === true) {
683
+ const project = projectLabel(repo, cwd);
684
+ const isError = push.outcome === "conflict-aborted" || push.outcome === "push-rejected";
685
+ const pushKind = isError ? "error" : "finished";
686
+ // Decorate WORD-FOR-WORD like the phone push (PRD §3): the auto-push
687
+ // path leaves `title` to the server default per kind, and folds the
688
+ // project into title + body exactly as `dispatcher._decorated_*` does.
689
+ const rawBody = push.detail || (isError ? "Auto-push needs you" : `Pushed to ${push.branch ?? "branch"}`);
690
+ desktop = await fireDesktop(settings, {
691
+ title: decorateTitle({ kind: pushKind, source, project }),
692
+ body: decorateBody({ kind: pushKind, body: rawBody, project }),
693
+ kind: pushKind,
694
+ }, source);
695
+ }
696
+ return { mode: "push", settingsSource: resolved.source, push, desktop, repo, detail: push.detail };
568
697
  }
569
698
  // ── Step 3b: auto-push off → the plain notification (legacy flow). ──
570
699
  // Derive the notification kind from the Cursor stop hook's stdin JSON (T-CUR1
@@ -622,9 +751,27 @@ export async function onStop(opts = {}) {
622
751
  const chatTitle = await chatTitleFromHookStdin(opts.hookStdin);
623
752
  if (chatTitle)
624
753
  payload.chat = chatTitle;
625
- const sent = await sendImpl(payload);
754
+ // Fire the phone push and the branded desktop banner in parallel (PRD §3):
755
+ // the computer gets told the same instant the phone does, and a slow banner
756
+ // never delays the phone send. The banner reuses the SAME project label,
757
+ // kind, and (when present) chat title that the phone notification carries.
758
+ // Decorate the banner WORD-FOR-WORD like the phone push (PRD §3): same
759
+ // emoji + source label + project folded into the title, and the per-kind
760
+ // default body with project (+ chat title) folded in — exactly what the
761
+ // server's `_decorated_title`/`_decorated_body` produce for this push. The
762
+ // CLI omits an explicit title (like the phone send) so the per-kind default
763
+ // ("Agent finished" / …) flows through identically on both surfaces.
764
+ const desktopBanner = {
765
+ title: decorateTitle({ kind: sendKind, source, project }),
766
+ body: decorateBody({ kind: sendKind, project, chat: chatTitle }),
767
+ kind: sendKind,
768
+ };
769
+ const [sent, desktop] = await Promise.all([
770
+ sendImpl(payload),
771
+ fireDesktop(settings, desktopBanner, source, ideChatId),
772
+ ]);
626
773
  await logLine(`send path → ${sendKind} ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
627
- return { mode: "send", settingsSource: resolved.source, send: sent, repo };
774
+ return { mode: "send", settingsSource: resolved.source, send: sent, desktop, repo };
628
775
  }
629
776
  catch (err) {
630
777
  // Defence in depth: the dispatcher must never throw. Degrade to a logged
@@ -47,6 +47,12 @@ export const DEFAULT_NOTIFY_SETTINGS = {
47
47
  command: null,
48
48
  max_diff_bytes: 61440,
49
49
  },
50
+ // Branded desktop banners ON by default (PRD §3): installing the package opts
51
+ // you into computer notifications; flip `desktop.enabled false` to silence.
52
+ desktop: {
53
+ enabled: true,
54
+ sound: true,
55
+ },
50
56
  };
51
57
  /**
52
58
  * Every dotted setting key the CLI accepts in `config set <key> <value>`, with
@@ -74,6 +80,9 @@ export const KEY_SPECS = {
74
80
  "commit_message.mode": { kind: "enum", enumValues: ["auto", "ai", "deterministic"] },
75
81
  "commit_message.command": { kind: "string_or_null" },
76
82
  "commit_message.max_diff_bytes": { kind: "int_pos" },
83
+ // Branded desktop banners (GOCODE_NOTIFY_DESKTOP PRD §3) — ON by default.
84
+ "desktop.enabled": { kind: "boolean" },
85
+ "desktop.sound": { kind: "boolean" },
77
86
  };
78
87
  /** Sorted list of every valid `config set` key — for help text + error messages. */
79
88
  export function validKeys() {
@@ -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.3.4";
2
+ export const VERSION = "0.4.0";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.3.4",
4
- "description": "Free phone notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
3
+ "version": "0.4.0",
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",
7
7
  "bin": {
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/src",
12
+ "assets",
12
13
  "snippets",
13
14
  "README.md"
14
15
  ],
@@ -26,6 +27,8 @@
26
27
  "notifications",
27
28
  "push",
28
29
  "fcm",
30
+ "desktop-notifications",
31
+ "toast",
29
32
  "mcp",
30
33
  "cli",
31
34
  "claude-code",