@trygocode/notify 0.3.4 → 0.5.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 +156 -9
- package/assets/gocode-icon.ico +0 -0
- package/assets/gocode-icon.png +0 -0
- package/dist/src/cli.js +28 -1
- package/dist/src/cursor.js +113 -7
- 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/opencode.js +64 -15
- package/dist/src/settings.js +9 -0
- package/dist/src/version.js +1 -1
- package/package.json +5 -2
|
@@ -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/opencode.js
CHANGED
|
@@ -8,14 +8,20 @@
|
|
|
8
8
|
// "command": ["npx","-y","@trygocode/notify","mcp"],
|
|
9
9
|
// "enabled": true }
|
|
10
10
|
// `command` is a single ARRAY (binary + args), plus `type` + `enabled`.
|
|
11
|
-
// 2. WRITE
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
11
|
+
// 2. WRITE an event plugin to `<config-dir>/plugin/gocode-notify.js` that
|
|
12
|
+
// subscribes to TWO event classes:
|
|
13
|
+
// a) END-OF-TURN — `session.idle` (legacy) + `session.status` (modern),
|
|
14
|
+
// the OpenCode equivalent of Cursor `stop` / Claude `Stop`. Fires the
|
|
15
|
+
// SHARED `on-stop` dispatcher → a `finished` ping.
|
|
16
|
+
// b) QUESTION — `permission.asked` (the "asking now" edge), the OpenCode
|
|
17
|
+
// equivalent of Claude's `Notification` hook. Fires a plain
|
|
18
|
+
// `awaiting_input` ping ("Agent needs you") so a real question shows as
|
|
19
|
+
// a QUESTION, not a misleading `finished`. (Cursor has NO such event;
|
|
20
|
+
// OpenCode genuinely exposes it via permission events. We do NOT listen
|
|
21
|
+
// to permission.updated/replied — those also fire after the user
|
|
22
|
+
// answers and would re-ping a resolved question.)
|
|
23
|
+
// Each child is detached + unref'd and errors are swallowed (`|| true`), so a
|
|
24
|
+
// notification/push failure can never block the session.
|
|
19
25
|
//
|
|
20
26
|
// The on-demand rule/skill (Claude SKILL.md / Cursor rule) is SKIPPED for
|
|
21
27
|
// OpenCode: OpenCode has no auto-loaded standalone per-file rule mechanism (the
|
|
@@ -72,6 +78,21 @@ export const OPENCODE_MCP_ENTRY = {
|
|
|
72
78
|
// resolved version forever and never auto-updates (npm/cli#6664). Pinning
|
|
73
79
|
// `@latest` makes the hook always fetch the newest publish so users self-update.
|
|
74
80
|
export const OPENCODE_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --source opencode --dedupe-key opencode-idle || true";
|
|
81
|
+
/**
|
|
82
|
+
* The shell command the plugin fires when OpenCode raises a PERMISSION request —
|
|
83
|
+
* the OpenCode equivalent of "the agent needs the user" (Claude's `Notification`
|
|
84
|
+
* hook / the Cursor AskQuestion hook). Unlike Cursor, OpenCode genuinely exposes
|
|
85
|
+
* this signal: the `permission.asked` event fires the instant the agent pauses to
|
|
86
|
+
* ask the user to approve/answer something. We map it to a plain `awaiting_input`
|
|
87
|
+
* ping ("Agent needs you") — the SAME kind Claude Code fires — so a real question
|
|
88
|
+
* surfaces as a QUESTION notification, not a misleading `finished`. (We listen to
|
|
89
|
+
* `permission.asked` ONLY, never `permission.updated`/`permission.replied`, which
|
|
90
|
+
* also fire AFTER the user answers and would re-ping a resolved question.)
|
|
91
|
+
*
|
|
92
|
+
* Ends in `|| true` so a failed push can never block the session; carries a
|
|
93
|
+
* distinct `--dedupe-key` so it coalesces with itself but not with the idle ping.
|
|
94
|
+
*/
|
|
95
|
+
export const OPENCODE_ASK_COMMAND = 'npx -y @trygocode/notify@latest send --kind awaiting_input --source opencode --title "Agent needs you" --dedupe-key opencode-permission || true';
|
|
75
96
|
/**
|
|
76
97
|
* Substrings that together identify our plugin file as OURS. Used to keep
|
|
77
98
|
* uninstall surgical: we only delete the plugin file when BOTH markers are
|
|
@@ -157,13 +178,15 @@ import { spawn } from "node:child_process";
|
|
|
157
178
|
// Per-session last-fire timestamps so idle+status for the SAME turn coalesce.
|
|
158
179
|
const lastFiredAt = new Map();
|
|
159
180
|
const DEDUPE_MS = 4000;
|
|
181
|
+
// Separate per-session debounce for permission/question events so a rapid burst
|
|
182
|
+
// of permission.asked events for one prompt coalesces into a single ping, while
|
|
183
|
+
// staying INDEPENDENT of the end-of-turn debounce (a question must not be
|
|
184
|
+
// suppressed by a recent finished ping — different event class).
|
|
185
|
+
const lastAskedAt = new Map();
|
|
160
186
|
|
|
161
|
-
function fire() {
|
|
187
|
+
function fire(command) {
|
|
162
188
|
try {
|
|
163
|
-
const child = spawn(
|
|
164
|
-
${JSON.stringify(OPENCODE_STOP_COMMAND)},
|
|
165
|
-
{ shell: true, detached: true, stdio: "ignore" },
|
|
166
|
-
);
|
|
189
|
+
const child = spawn(command, { shell: true, detached: true, stdio: "ignore" });
|
|
167
190
|
child.unref();
|
|
168
191
|
} catch {
|
|
169
192
|
// never block the session on a notification failure
|
|
@@ -195,13 +218,39 @@ function maybeFire(sessionID) {
|
|
|
195
218
|
const prev = lastFiredAt.get(key) ?? 0;
|
|
196
219
|
if (now - prev < DEDUPE_MS) return; // coalesce idle+status for one turn
|
|
197
220
|
lastFiredAt.set(key, now);
|
|
198
|
-
fire();
|
|
221
|
+
fire(${JSON.stringify(OPENCODE_STOP_COMMAND)});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The agent paused to ask the user (permission.asked). Fire an "Agent needs
|
|
225
|
+
// you" question ping. Debounced separately from the end-of-turn ping so a burst
|
|
226
|
+
// of asks for one prompt coalesces into one.
|
|
227
|
+
function maybeAsk(sessionID) {
|
|
228
|
+
const key = sessionID || "_";
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
const prev = lastAskedAt.get(key) ?? 0;
|
|
231
|
+
if (now - prev < DEDUPE_MS) return;
|
|
232
|
+
lastAskedAt.set(key, now);
|
|
233
|
+
fire(${JSON.stringify(OPENCODE_ASK_COMMAND)});
|
|
199
234
|
}
|
|
200
235
|
|
|
201
236
|
export const GocodeNotify = async () => ({
|
|
202
237
|
event: async ({ event }) => {
|
|
203
238
|
if (!event) return;
|
|
204
239
|
const props = event.properties ?? {};
|
|
240
|
+
// QUESTION signal — the agent is waiting on the user (OpenCode's real
|
|
241
|
+
// "needs you" event; Cursor has no equivalent). Fired BEFORE the idle/status
|
|
242
|
+
// checks so a permission request is never misread as a plain end-of-turn.
|
|
243
|
+
//
|
|
244
|
+
// We listen ONLY to \`permission.asked\` — the moment the agent RAISES a
|
|
245
|
+
// request. We deliberately do NOT listen to \`permission.updated\` /
|
|
246
|
+
// \`permission.replied\`: those are state transitions that ALSO fire AFTER the
|
|
247
|
+
// user answers, which would re-emit "Agent needs you" once the question is
|
|
248
|
+
// already resolved — exactly the false-notification class this fix exists to
|
|
249
|
+
// avoid. \`permission.asked\` is the unambiguous "asking now" edge.
|
|
250
|
+
if (event.type === "permission.asked") {
|
|
251
|
+
maybeAsk(props.sessionID);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
205
254
|
if (event.type === "session.idle") {
|
|
206
255
|
maybeFire(props.sessionID);
|
|
207
256
|
return;
|
|
@@ -332,7 +381,7 @@ export async function writeOpenCodeConfig(runtime, opts) {
|
|
|
332
381
|
runtime: name,
|
|
333
382
|
written,
|
|
334
383
|
skipped: false,
|
|
335
|
-
detail: "merged mcp.gocode-notify entry; wrote
|
|
384
|
+
detail: "merged mcp.gocode-notify entry; wrote end-of-turn + permission(question) plugin (rule skipped — OpenCode has no standalone rule file)",
|
|
336
385
|
};
|
|
337
386
|
}
|
|
338
387
|
catch (err) {
|
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.5.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.5.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",
|