@trygocode/notify 0.6.2 → 0.6.3

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/dist/src/cli.js CHANGED
@@ -20,8 +20,11 @@ import { serveStdio } from "./mcp.js";
20
20
  import { setup } from "./setup.js";
21
21
  import { uninstall } from "./uninstall.js";
22
22
  import { cmdConfig } from "./config.js";
23
- import { onStop } from "./on_stop.js";
24
- import { notifyDesktop, requestDesktopPermission } from "./desktop_notify.js";
23
+ import { onStop, clickTarget, projectLabel } from "./on_stop.js";
24
+ import { notifyDesktop, requestDesktopPermission, desktopDisabledByEnv, } from "./desktop_notify.js";
25
+ import { resolveNotifySettings } from "./config.js";
26
+ import { deriveRepoIdentity } from "./repo_key.js";
27
+ import { decorateTitle, decorateBody } from "./notify_copy.js";
25
28
  import { gatherDoctor, formatDoctor } from "./doctor.js";
26
29
  /** Subcommands the finished CLI will expose (see PRD §4.1). */
27
30
  export const COMMANDS = [
@@ -210,6 +213,98 @@ export async function cmdLogin(args, deps = {}) {
210
213
  }
211
214
  return 1;
212
215
  }
216
+ /**
217
+ * Fire a branded desktop banner for a `send` (any kind — notably
218
+ * `awaiting_input` questions and `error`s, which never flow through the
219
+ * `on-stop` finish dispatcher). This is the parity twin of `on_stop.fireDesktop`
220
+ * for the plain-`send` path, so EVERY notification the phone gets can also pop on
221
+ * the computer (PRD §3).
222
+ *
223
+ * Behaviour:
224
+ * - Resolves this repo's identity + merged settings (60s-TTL cache) so the
225
+ * banner is gated on the SAME server-synced `desktop.enabled` as the phone.
226
+ * - Skips silently when desktop is disabled, or when even a best-effort banner
227
+ * can't be built. NEVER throws, NEVER blocks, NEVER affects the exit code
228
+ * (the phone push remains the source of truth).
229
+ * - Decorates title/body WORD-FOR-WORD like the phone push via the shared
230
+ * {@link decorateTitle}/{@link decorateBody}, and reuses the project label +
231
+ * click target (open the IDE for this project) the finish banner uses.
232
+ *
233
+ * An explicit `--title`/`--body` on the send wins over the per-kind default
234
+ * (matching the phone, where an explicit title/body overrides the default copy).
235
+ */
236
+ export async function fireSendDesktop(payload, deps) {
237
+ // Hard machine-level opt-out (headless / CI / SSH / tests): bail BEFORE any
238
+ // settings network call so this path is a true no-op when desktop banners are
239
+ // disabled for the box. Mirrors `notifyDesktop`'s own env check, but earlier —
240
+ // so we never even resolve settings (which would otherwise add a fetch the
241
+ // phone-send unit tests don't expect).
242
+ if (desktopDisabledByEnv())
243
+ return undefined;
244
+ // Resolve repo identity (for the project label + settings scoping + click
245
+ // target). Best-effort: a derive failure → undefined repo → label falls back
246
+ // to the cwd basename, exactly like the finish path.
247
+ const cwd = process.cwd();
248
+ let repo;
249
+ try {
250
+ repo = await deriveRepoIdentity(cwd);
251
+ }
252
+ catch {
253
+ repo = undefined;
254
+ }
255
+ // Resolve merged settings for this repo (fail-safe: if nothing is known the
256
+ // resolver returns conservative defaults, where `desktop` is absent → ON by
257
+ // default, matching the finish banner's `desktop?.enabled !== false`).
258
+ let resolved;
259
+ try {
260
+ resolved = await resolveNotifySettings({
261
+ home: deps.home,
262
+ repo,
263
+ fetchImpl: deps.fetchImpl,
264
+ timeoutMs: deps.timeoutMs,
265
+ });
266
+ }
267
+ catch {
268
+ return undefined;
269
+ }
270
+ const settings = resolved.settings;
271
+ // Gate on the SAME setting as the finish banner (ON by default; only an
272
+ // explicit `false` silences the computer banner).
273
+ if (settings.desktop?.enabled === false)
274
+ return undefined;
275
+ const source = payload.source;
276
+ const project = payload.project ?? projectLabel(repo, cwd);
277
+ // An explicit --title/--body wins (matches the phone send overriding default
278
+ // copy); otherwise the shared decorators produce the per-kind default folded
279
+ // with the source label + project, identical to the phone push.
280
+ const title = payload.title
281
+ ? payload.title
282
+ : decorateTitle({ kind: payload.kind, source, project });
283
+ const body = payload.body
284
+ ? payload.body
285
+ : decorateBody({ kind: payload.kind, project });
286
+ const desktopImpl = deps.notifyDesktopImpl ?? notifyDesktop;
287
+ try {
288
+ return await desktopImpl({
289
+ title,
290
+ body,
291
+ kind: payload.kind,
292
+ sound: settings.desktop?.sound !== false,
293
+ // Clicking the banner opens the IDE window for THIS project (the agent's
294
+ // cwd) — same behaviour as the finish banner. Omitted (display-only)
295
+ // when we can't name the IDE or the path.
296
+ click: clickTarget(source, cwd),
297
+ }, {
298
+ home: deps.home,
299
+ timeoutMs: deps.timeoutMs,
300
+ timestamp: deps.timestamp,
301
+ });
302
+ }
303
+ catch {
304
+ // A desktop banner must never block or fail a send.
305
+ return undefined;
306
+ }
307
+ }
213
308
  /**
214
309
  * Handle `gocode-notify send --kind K [--title T] [--body B] [--source S]
215
310
  * [--project P] [--dedupe-key D] [--server URL]`.
@@ -220,6 +315,10 @@ export async function cmdLogin(args, deps = {}) {
220
315
  * even on network/server failure, so a hook never blocks the agent's turn. On a
221
316
  * delivery failure the payload is queued to the offline outbox; on a successful
222
317
  * send the backlog is flushed opportunistically (server is known reachable).
318
+ *
319
+ * In parallel with the phone push it fires a branded desktop banner via
320
+ * {@link fireSendDesktop} (gated on `desktop.enabled`), so questions/errors
321
+ * raised through `send` pop on the computer too — not just chat finishes.
223
322
  */
224
323
  export async function cmdSend(args, deps = {}) {
225
324
  const flags = parseFlags(args);
@@ -270,7 +369,19 @@ export async function cmdSend(args, deps = {}) {
270
369
  timestamp: deps.timestamp,
271
370
  server,
272
371
  };
273
- const result = await send(payload, sendOpts);
372
+ // Fire the phone push and the branded desktop banner IN PARALLEL so the
373
+ // computer is told the same instant the phone is (PRD §3) — for EVERY kind
374
+ // that flows through `send`, notably `awaiting_input` (questions), which the
375
+ // `on-stop` finish dispatcher never sees. Without this, only chat-FINISH
376
+ // banners popped on the desktop and questions/errors raised via `send` were
377
+ // phone-only. The banner is gated on the SAME server-synced `desktop.enabled`
378
+ // setting as the finish banner, decorated WORD-FOR-WORD identically (same
379
+ // emoji + source label + project), and is best-effort + total (never throws,
380
+ // never blocks, never changes the exit code). See {@link fireSendDesktop}.
381
+ const [result] = await Promise.all([
382
+ send(payload, sendOpts),
383
+ fireSendDesktop(payload, deps).catch(() => undefined),
384
+ ]);
274
385
  if (result.ok) {
275
386
  // Server is reachable → drain any notifications queued while offline.
276
387
  await flush((p) => send(p, sendOpts), deps);
@@ -87,8 +87,20 @@ export async function deriveRepoIdentity(cwd = process.cwd(), git = makeGitRunne
87
87
  const url = res.code === 0 ? res.stdout.trim() : "";
88
88
  const normalized = normalizeRemoteUrl(url);
89
89
  if (!normalized) {
90
- const base = path.basename(path.resolve(cwd)) || "repo";
91
- return { repo_key: `local:${base}`, repo_label: base, synced: false };
90
+ // `path.basename("/")` is "" (the hook can run with cwd = filesystem root,
91
+ // e.g. when the IDE spawns it detached without a project cwd). Keep a STABLE
92
+ // dedup key in that case (`local:repo`) but DON'T surface the meaningless
93
+ // literal "repo" as the user-facing label — leave `repo_label` EMPTY so the
94
+ // display layer (`projectLabel`) falls through to its smarter cwd-walk /
95
+ // omits the project rather than showing "GoCode · repo". (Previously this
96
+ // hard-coded `|| "repo"` for BOTH the key and the label, which is the cause
97
+ // of the "repo" project name users saw on root-cwd hook runs.)
98
+ const base = path.basename(path.resolve(cwd));
99
+ return {
100
+ repo_key: `local:${base || "repo"}`,
101
+ repo_label: base,
102
+ synced: false,
103
+ };
92
104
  }
93
105
  return {
94
106
  repo_key: repoKeyFromNormalized(normalized),
@@ -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.6.2";
2
+ export const VERSION = "0.6.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Free phone + branded desktop notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
5
5
  "license": "MIT",
6
6
  "type": "module",