@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 +103 -5
- package/assets/gocode-icon.ico +0 -0
- package/assets/gocode-icon.png +0 -0
- package/dist/src/cli.js +28 -1
- package/dist/src/desktop_notify.js +584 -0
- package/dist/src/notify_copy.js +189 -0
- package/dist/src/on_stop.js +150 -3
- package/dist/src/settings.js +9 -0
- package/dist/src/version.js +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# @trygocode/notify
|
|
2
2
|
|
|
3
|
-
Free phone notifications for any coding agent — **Cursor,
|
|
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
|
|
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
|
-
—
|
|
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, "&")
|
|
170
|
+
.replace(/</g, "<")
|
|
171
|
+
.replace(/>/g, ">")
|
|
172
|
+
.replace(/"/g, """)
|
|
173
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/dist/src/on_stop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/src/settings.js
CHANGED
|
@@ -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() {
|
package/dist/src/version.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|