@trygocode/notify 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 a `session.idle` plugin to `<config-dir>/plugin/gocode-notify.js`.
12
- // OpenCode has NO stop-hook command array; instead a plugin subscribes to
13
- // the `session.idle` event (the OpenCode equivalent of Cursor `stop` /
14
- // Claude `Stop`) and fires the SHARED `on-stop` dispatcher:
15
- // gocode-notify on-stop --source opencode --dedupe-key opencode-idle || true
16
- // The child is detached + unref'd and errors are swallowed (`|| true`), so a
17
- // notification/push failure can never block the session — exactly one ping
18
- // per idle, in lockstep with Claude/Cursor (same `on-stop` dispatcher).
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 session.idle plugin (rule skipped — OpenCode has no standalone rule file)",
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/send.js CHANGED
@@ -32,8 +32,25 @@ export const NOTIFY_KINDS = [
32
32
  export function isNotifyKind(value) {
33
33
  return typeof value === "string" && NOTIFY_KINDS.includes(value);
34
34
  }
35
- /** Default request timeout (PRD §4.4: 5s hard cap). */
36
- export const DEFAULT_TIMEOUT_MS = 5000;
35
+ /**
36
+ * Default PER-ATTEMPT request timeout. Bumped 5s → 12s (2026-06-20): a cold
37
+ * self-hosted OpenHands server reached over Tailscale routinely takes 5-8s to
38
+ * answer its first request after idle, so the old 5s cap silently dropped real
39
+ * `finished` / `awaiting_input` pushes (observed in `notify.log`:
40
+ * "timeout after 5000ms"). 12s comfortably clears a cold-start round-trip while
41
+ * still being a hard cap so a wedged server can never hang a hook's turn.
42
+ */
43
+ export const DEFAULT_TIMEOUT_MS = 12000;
44
+ /**
45
+ * How many times to ATTEMPT a send before giving up (1 = no retry). A single
46
+ * cold-start timeout or transient network blip used to mean the user got no
47
+ * notification at all; we now retry transient failures (timeout / network /
48
+ * 5xx) with a short backoff so a slow-but-reachable server still delivers.
49
+ * A 4xx (auth / bad request) is permanent and is NOT retried.
50
+ */
51
+ export const DEFAULT_MAX_ATTEMPTS = 3;
52
+ /** Backoff (ms) applied BEFORE attempt N (index 0 = before the 2nd attempt). */
53
+ export const RETRY_BACKOFF_MS = [500, 1500];
37
54
  /** Rotate `notify.log` once it grows past this (keeps one `.1` backup). */
38
55
  export const MAX_LOG_BYTES = 256 * 1024;
39
56
  /** Absolute path to the failure log. */
@@ -93,13 +110,32 @@ async function failure(reason, opts, extra = {}) {
93
110
  await appendLog(`SEND FAIL: ${reason}`, opts);
94
111
  return { ok: false, error: reason, ...extra };
95
112
  }
113
+ /** Real backoff sleep (cancellable-free; the caller never aborts a backoff). */
114
+ function defaultSleep(ms) {
115
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
+ }
96
117
  /**
97
- * Send one push to `/notify/send`. Resolves (never rejects) to a
98
- * {@link SendResult}; callers may treat ANY result as "exit 0".
118
+ * Send one push to `/notify/send`, retrying transient failures with a short
119
+ * backoff. Resolves (never rejects) to a {@link SendResult}; callers may treat
120
+ * ANY result as "exit 0".
121
+ *
122
+ * Retry policy (2026-06-20): a single cold-start timeout or transient network
123
+ * blip used to drop the user's notification entirely. We now attempt up to
124
+ * {@link DEFAULT_MAX_ATTEMPTS} times, backing off per {@link RETRY_BACKOFF_MS}
125
+ * between attempts. A 4xx (auth / bad-request) is PERMANENT and stops the loop
126
+ * immediately — retrying it would just burn the hook's time budget. Every
127
+ * attempt's failure is logged so `notify.log` shows the full retry trail.
99
128
  */
100
129
  export async function send(payload, opts = {}) {
101
130
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
102
131
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
132
+ // Coerce defensively: a NaN (e.g. a failed CLI numeric parse) must not make
133
+ // `Math.max(1, NaN) === NaN` skip the loop and then throw on `last!.result`.
134
+ const rawAttempts = opts.maxAttempts;
135
+ const maxAttempts = typeof rawAttempts === "number" && Number.isFinite(rawAttempts)
136
+ ? Math.max(1, Math.floor(rawAttempts))
137
+ : DEFAULT_MAX_ATTEMPTS;
138
+ const sleep = opts.sleepImpl ?? defaultSleep;
103
139
  let creds;
104
140
  try {
105
141
  creds = await readCredentials(opts);
@@ -112,41 +148,78 @@ export async function send(payload, opts = {}) {
112
148
  }
113
149
  const server = normalizeServer(opts.server ?? creds.server ?? builtinDefaultServer());
114
150
  const url = `${server}/api/v1/notify/send`;
115
- const controller = new AbortController();
116
- const timer = setTimeout(() => controller.abort(), timeoutMs);
117
- try {
118
- const res = await fetchImpl(url, {
119
- method: "POST",
120
- headers: {
121
- "content-type": "application/json",
122
- authorization: `Bearer ${creds.api_key}`,
123
- },
124
- body: JSON.stringify(buildBody(payload)),
125
- signal: controller.signal,
126
- });
127
- if (!res.ok) {
128
- return failure(`server responded ${res.status} for kind=${payload.kind}`, opts, {
129
- status: res.status,
151
+ const apiKey = creds.api_key;
152
+ const body = JSON.stringify(buildBody(payload));
153
+ /** One attempt — resolves to an {@link AttemptOutcome}, never throws. */
154
+ const attempt = async () => {
155
+ const controller = new AbortController();
156
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
157
+ try {
158
+ const res = await fetchImpl(url, {
159
+ method: "POST",
160
+ headers: {
161
+ "content-type": "application/json",
162
+ authorization: `Bearer ${apiKey}`,
163
+ },
164
+ body,
165
+ signal: controller.signal,
130
166
  });
167
+ if (!res.ok) {
168
+ // 5xx is transient (server hiccup / cold start) → retriable.
169
+ // 4xx is permanent (auth / bad request) → do not retry.
170
+ const retriable = res.status >= 500;
171
+ return {
172
+ result: { ok: false, status: res.status, error: `server responded ${res.status} for kind=${payload.kind}` },
173
+ retriable,
174
+ };
175
+ }
176
+ let sent;
177
+ try {
178
+ const json = (await res.json());
179
+ if (typeof json?.sent === "number")
180
+ sent = json.sent;
181
+ }
182
+ catch {
183
+ // A 2xx with an unparseable body still counts as delivered.
184
+ }
185
+ return { result: { ok: true, status: res.status, sent }, retriable: false };
131
186
  }
132
- let sent;
133
- try {
134
- const json = (await res.json());
135
- if (typeof json?.sent === "number")
136
- sent = json.sent;
187
+ catch (err) {
188
+ // Timeout (aborted) and network errors are both transient → retriable.
189
+ const reason = controller.signal.aborted
190
+ ? `timeout after ${timeoutMs}ms for kind=${payload.kind}`
191
+ : `request failed: ${errMessage(err)}`;
192
+ return { result: { ok: false, error: reason }, retriable: true };
137
193
  }
138
- catch {
139
- // A 2xx with an unparseable body still counts as delivered.
194
+ finally {
195
+ clearTimeout(timer);
140
196
  }
141
- return { ok: true, status: res.status, sent };
142
- }
143
- catch (err) {
144
- const reason = controller.signal.aborted
145
- ? `timeout after ${timeoutMs}ms for kind=${payload.kind}`
146
- : `request failed: ${errMessage(err)}`;
147
- return failure(reason, opts);
148
- }
149
- finally {
150
- clearTimeout(timer);
197
+ };
198
+ let last;
199
+ for (let i = 0; i < maxAttempts; i++) {
200
+ if (i > 0) {
201
+ // Backoff before this retry. Clamp to the last defined step for any
202
+ // attempt count beyond the table so a larger `maxAttempts` still works.
203
+ const backoff = RETRY_BACKOFF_MS[Math.min(i - 1, RETRY_BACKOFF_MS.length - 1)] ?? 0;
204
+ await sleep(backoff);
205
+ }
206
+ last = await attempt();
207
+ if (last.result.ok) {
208
+ // Note the recovery in the log when an earlier attempt had failed, so a
209
+ // "it worked on retry 2" story is visible in notify.log.
210
+ if (i > 0) {
211
+ await appendLog(`SEND OK on attempt ${i + 1}/${maxAttempts} for kind=${payload.kind}`, opts);
212
+ }
213
+ return last.result;
214
+ }
215
+ // Log every failed attempt so the retry trail is diagnosable.
216
+ const willRetry = last.retriable && i < maxAttempts - 1;
217
+ await appendLog(`SEND FAIL (attempt ${i + 1}/${maxAttempts}${willRetry ? ", will retry" : ""}): ${last.result.error}`, opts);
218
+ // Permanent failure (4xx) — stop immediately, retrying can't help.
219
+ if (!last.retriable)
220
+ break;
151
221
  }
222
+ // All attempts exhausted (or a permanent failure). `last` is always set here
223
+ // because the loop runs at least once (maxAttempts >= 1).
224
+ return last.result;
152
225
  }
@@ -1,2 +1,2 @@
1
1
  // Single source of truth for the CLI version. Keep in sync with package.json.
2
- export const VERSION = "0.4.0";
2
+ export const VERSION = "0.6.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Free phone + branded desktop notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
5
5
  "license": "MIT",
6
6
  "type": "module",