@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.
- package/README.md +81 -4
- package/assets/GoCodeNotifier.app/Contents/Info.plist +37 -0
- package/assets/GoCodeNotifier.app/Contents/MacOS/GoCodeNotifier +0 -0
- package/assets/GoCodeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/assets/GoCodeNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
- package/dist/src/cli.js +69 -1
- package/dist/src/cursor.js +113 -7
- package/dist/src/desktop_notify.js +232 -1
- package/dist/src/doctor.js +26 -7
- package/dist/src/mac_helper.js +335 -0
- package/dist/src/opencode.js +64 -15
- package/dist/src/send.js +109 -36
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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/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
|
-
/**
|
|
36
|
-
|
|
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
|
|
98
|
-
* {@link SendResult}; callers may treat
|
|
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
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
194
|
+
finally {
|
|
195
|
+
clearTimeout(timer);
|
|
140
196
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
}
|
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.6.0";
|
package/package.json
CHANGED