@trygocode/notify 0.3.1 → 0.3.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/claude.js +7 -2
- package/dist/src/cursor.js +9 -1
- package/dist/src/on_stop.js +132 -5
- package/dist/src/opencode.js +4 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/dist/src/claude.js
CHANGED
|
@@ -56,9 +56,14 @@ export const MCP_SERVER_ENTRY = {
|
|
|
56
56
|
* We intentionally register ONLY `Stop` + `Notification`. `SubagentStop` is NOT
|
|
57
57
|
* here on purpose — see {@link SUBAGENT_STOP_EVENT}.
|
|
58
58
|
*/
|
|
59
|
+
// `@latest` is REQUIRED: bare `npx -y @trygocode/notify` caches the
|
|
60
|
+
// first-resolved version forever and never auto-updates (npm/cli#6664). Pinning
|
|
61
|
+
// `@latest` makes every hook fire the newest published version, so users get our
|
|
62
|
+
// fixes without ever clearing their npx cache by hand. Matchers below key on the
|
|
63
|
+
// `@trygocode/notify` substring, which `@latest` preserves.
|
|
59
64
|
export const CLAUDE_HOOK_COMMANDS = {
|
|
60
|
-
Stop: 'npx -y @trygocode/notify on-stop --source claude_code --dedupe-key "$CLAUDE_SESSION_ID-stop" || true',
|
|
61
|
-
Notification: 'npx -y @trygocode/notify send --kind awaiting_input --source claude_code --title "Agent needs you" --dedupe-key "$CLAUDE_SESSION_ID-notify" || true',
|
|
65
|
+
Stop: 'npx -y @trygocode/notify@latest on-stop --source claude_code --dedupe-key "$CLAUDE_SESSION_ID-stop" || true',
|
|
66
|
+
Notification: 'npx -y @trygocode/notify@latest send --kind awaiting_input --source claude_code --title "Agent needs you" --dedupe-key "$CLAUDE_SESSION_ID-notify" || true',
|
|
62
67
|
};
|
|
63
68
|
/**
|
|
64
69
|
* The Claude event we DELIBERATELY do not register and actively scrub on every
|
package/dist/src/cursor.js
CHANGED
|
@@ -48,7 +48,15 @@ export const MCP_SERVER_ENTRY = {
|
|
|
48
48
|
* both old and new commands contain the {@link HOOK_MARKERS} tokens, so an old
|
|
49
49
|
* install still upgrades idempotently and uninstalls cleanly (PRD §2.2).
|
|
50
50
|
*/
|
|
51
|
-
|
|
51
|
+
// `@latest` is REQUIRED, not cosmetic: bare `npx -y @trygocode/notify` caches the
|
|
52
|
+
// first-resolved version FOREVER and never picks up a newer publish (documented
|
|
53
|
+
// npx behaviour — npm/cli#6664; the whole `clear-npx-cache` package exists for
|
|
54
|
+
// this). Without `@latest`, a user who installed an old version would keep
|
|
55
|
+
// running it after we ship a fix, with no way to update short of wiping
|
|
56
|
+
// ~/.npm/_npx by hand. Pinning `@latest` forces npx to resolve the newest
|
|
57
|
+
// version on every turn, so users self-update seamlessly. (Matchers below key on
|
|
58
|
+
// the `@trygocode/notify` substring, which `@latest` preserves.)
|
|
59
|
+
export const CURSOR_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --source cursor --dedupe-key cursor-stop || true";
|
|
52
60
|
/**
|
|
53
61
|
* Substrings that together identify a `stop` hook entry as OURS. Used for
|
|
54
62
|
* idempotent merge (replace, don't duplicate) and for surgical uninstall (remove
|
package/dist/src/on_stop.js
CHANGED
|
@@ -94,8 +94,16 @@ function toPushSettings(settings) {
|
|
|
94
94
|
*/
|
|
95
95
|
export function projectLabel(repo, cwd) {
|
|
96
96
|
const label = repo?.repo_label?.trim();
|
|
97
|
-
|
|
97
|
+
// The user wants the plain project/folder name (e.g. "alerc8"), not the
|
|
98
|
+
// `owner/repo` form. `repo_label` from a git origin is `owner/repo`, and the
|
|
99
|
+
// `local:` fallback already strips the prefix in deriveRepoIdentity — so take
|
|
100
|
+
// the LAST path segment as the display name. Guard against a trailing slash.
|
|
101
|
+
if (label) {
|
|
102
|
+
const lastSeg = label.replace(/\/+$/, "").split("/").pop()?.trim();
|
|
103
|
+
if (lastSeg)
|
|
104
|
+
return lastSeg;
|
|
98
105
|
return label;
|
|
106
|
+
}
|
|
99
107
|
// Fall back to the cwd basename — but SKIP tool/config dot-dirs. The stop hook
|
|
100
108
|
// can run with a cwd inside `.cursor` (or `.git`, `.vscode`, …), whose basename
|
|
101
109
|
// would otherwise become the project name and render as "Cursor · .cursor"
|
|
@@ -115,6 +123,66 @@ export function projectLabel(repo, cwd) {
|
|
|
115
123
|
}
|
|
116
124
|
return undefined;
|
|
117
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The project root the stop hook should reason about. Cursor (and Claude) run
|
|
128
|
+
* stop hooks with `process.cwd()` set to the IDE's hook/config dir (e.g.
|
|
129
|
+
* `~/.cursor`), NOT the user's open project — so deriving the repo from the bare
|
|
130
|
+
* cwd yields ".cursor" instead of the real repo (the bug in the 2026-06-12
|
|
131
|
+
* screenshot). The hook stdin, however, carries the real workspace root. Prefer
|
|
132
|
+
* it (mirrors what `@trygocode/sync` already does), falling back to the cwd only
|
|
133
|
+
* when the stdin omits it.
|
|
134
|
+
*
|
|
135
|
+
* Accepts the same field spellings sync consumes:
|
|
136
|
+
* `workspace_path` / `workspaceRoots[0]` / `workspace_roots[0]` / `cwd` /
|
|
137
|
+
* `workspaceFolders[0].path|uri`. Returns undefined when nothing usable is
|
|
138
|
+
* present, so the caller keeps the process cwd.
|
|
139
|
+
*/
|
|
140
|
+
export function workspaceRootFromHookStdin(hookStdin) {
|
|
141
|
+
if (!hookStdin || hookStdin.trim() === "")
|
|
142
|
+
return undefined;
|
|
143
|
+
let p;
|
|
144
|
+
try {
|
|
145
|
+
p = JSON.parse(hookStdin);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
const str = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
151
|
+
const firstOf = (v) => {
|
|
152
|
+
if (Array.isArray(v) && v.length > 0) {
|
|
153
|
+
const f = v[0];
|
|
154
|
+
if (typeof f === "string")
|
|
155
|
+
return str(f);
|
|
156
|
+
if (f && typeof f === "object") {
|
|
157
|
+
const o = f;
|
|
158
|
+
// VS Code/Cursor sometimes use {path} or {uri: "file:///…"}.
|
|
159
|
+
return str(o.path) ?? str(o.fsPath) ?? fromFileUri(str(o.uri));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
};
|
|
164
|
+
return (str(p.workspace_path) ??
|
|
165
|
+
str(p.workspacePath) ??
|
|
166
|
+
firstOf(p.workspaceRoots) ??
|
|
167
|
+
firstOf(p.workspace_roots) ??
|
|
168
|
+
firstOf(p.workspaceFolders) ??
|
|
169
|
+
str(p.cwd) ??
|
|
170
|
+
undefined);
|
|
171
|
+
}
|
|
172
|
+
/** Convert a `file:///abs/path` URI to a plain path; passthrough otherwise. */
|
|
173
|
+
function fromFileUri(uri) {
|
|
174
|
+
if (!uri)
|
|
175
|
+
return undefined;
|
|
176
|
+
if (uri.startsWith("file://")) {
|
|
177
|
+
try {
|
|
178
|
+
return decodeURIComponent(new URL(uri).pathname) || undefined;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return uri;
|
|
185
|
+
}
|
|
118
186
|
/**
|
|
119
187
|
* Derive the SAME stable `external_chat_id` that `@trygocode/sync` assigns to a
|
|
120
188
|
* synced transcript, so a per-turn notification can deep-link straight to that
|
|
@@ -170,7 +238,17 @@ export function ideChatIdFromHookStdin(source, cwd, hookStdin) {
|
|
|
170
238
|
const ideSessionId = str(p.conversation_id, p.conversationId, p.session_id, p.sessionId, sessionFromPath);
|
|
171
239
|
if (!ideSessionId)
|
|
172
240
|
return undefined;
|
|
173
|
-
|
|
241
|
+
// CRITICAL parity: the deep-link id hashes the workspace, and gocode-sync hashes
|
|
242
|
+
// the SAME workspace when it stores the chat — they MUST agree or a tapped push
|
|
243
|
+
// hits "Chat not found". gocode-sync resolves the workspace from Cursor's REAL
|
|
244
|
+
// field `workspace_roots` (snake_case), so we MUST read it here too. Reuse
|
|
245
|
+
// workspaceRootFromHookStdin (which already prefers workspace_roots and every
|
|
246
|
+
// spelling) so the two packages can never drift. Fall back to camelCase / cwd
|
|
247
|
+
// for older Cursor builds, then the process cwd.
|
|
248
|
+
const workspacePath = workspaceRootFromHookStdin(hookStdin) ??
|
|
249
|
+
str(p.workspace_path, Array.isArray(p.workspace_roots)
|
|
250
|
+
? p.workspace_roots[0]
|
|
251
|
+
: undefined, Array.isArray(p.workspaceRoots) ? p.workspaceRoots[0] : undefined, p.cwd, cwd);
|
|
174
252
|
if (!workspacePath)
|
|
175
253
|
return undefined;
|
|
176
254
|
// The capture side stores `source: "cursor" | "claude_code"`; the hook passes
|
|
@@ -211,6 +289,49 @@ export async function chatTitleFromHookStdin(hookStdin) {
|
|
|
211
289
|
catch {
|
|
212
290
|
return undefined;
|
|
213
291
|
}
|
|
292
|
+
// Prefer the IDE's OWN assigned chat name when the transcript carries one, so
|
|
293
|
+
// the push names the chat exactly as the user sees it in the sidebar (rather
|
|
294
|
+
// than a synthesized first-line title). Verified empirically (2026-06-12):
|
|
295
|
+
// - Claude Code writes `{"type":"ai-title","aiTitle":"<sidebar name>"}`
|
|
296
|
+
// records, refined over the chat's life — so take the LAST one.
|
|
297
|
+
// - Cursor agent transcripts carry NO title record (only role/message), so
|
|
298
|
+
// this scan no-ops for Cursor and we fall through to synthesis below.
|
|
299
|
+
// Defensive: also accept a legacy Claude `{"type":"summary","summary":"…"}`
|
|
300
|
+
// record so an older/newer format degrades gracefully instead of throwing.
|
|
301
|
+
{
|
|
302
|
+
let assigned;
|
|
303
|
+
for (const line of raw.split("\n")) {
|
|
304
|
+
const t = line.trim();
|
|
305
|
+
if (!t || (!t.includes("ai-title") && !t.includes("summary")))
|
|
306
|
+
continue;
|
|
307
|
+
let rec;
|
|
308
|
+
try {
|
|
309
|
+
rec = JSON.parse(t);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const type = String(rec.type ?? "");
|
|
315
|
+
let cand;
|
|
316
|
+
if (type === "ai-title")
|
|
317
|
+
cand = rec.aiTitle ?? rec.ai_title;
|
|
318
|
+
else if (type === "summary")
|
|
319
|
+
cand = rec.summary;
|
|
320
|
+
if (typeof cand === "string" && cand.trim() !== "") {
|
|
321
|
+
assigned = cand.trim(); // keep scanning → last assigned title wins
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (assigned) {
|
|
325
|
+
if (assigned.length > 80)
|
|
326
|
+
assigned = assigned.slice(0, 79).trimEnd() + "…";
|
|
327
|
+
return assigned;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Synthesis fallback (no IDE-assigned title): name the chat after the LAST
|
|
331
|
+
// user prompt — the one that JUST finished — not the first prompt of the
|
|
332
|
+
// conversation. This reminds the user where they left off (per 2026-06-12
|
|
333
|
+
// request). We keep the last good title and return it after the full scan.
|
|
334
|
+
let lastTitle;
|
|
214
335
|
for (const line of raw.split("\n")) {
|
|
215
336
|
const t = line.trim();
|
|
216
337
|
if (!t)
|
|
@@ -247,6 +368,7 @@ export async function chatTitleFromHookStdin(hookStdin) {
|
|
|
247
368
|
content = content
|
|
248
369
|
.replace(/<timestamp(?:\s[^>]*)?>[\s\S]*?<\/timestamp>/gi, "")
|
|
249
370
|
.replace(/<image_files(?:\s[^>]*)?>[\s\S]*?<\/image_files>/gi, "")
|
|
371
|
+
.replace(/<open_and_recently_viewed_files(?:\s[^>]*)?>[\s\S]*?<\/open_and_recently_viewed_files>/gi, "")
|
|
250
372
|
.replace(/\[Image\](?!\()\s*/g, "")
|
|
251
373
|
.replace(/<\/?user_query(?:\s[^>]*)?>/gi, "");
|
|
252
374
|
const firstLine = content
|
|
@@ -260,9 +382,9 @@ export async function chatTitleFromHookStdin(hookStdin) {
|
|
|
260
382
|
continue;
|
|
261
383
|
if (title.length > 80)
|
|
262
384
|
title = title.slice(0, 79).trimEnd() + "…";
|
|
263
|
-
|
|
385
|
+
lastTitle = title; // keep scanning → LAST user prompt wins
|
|
264
386
|
}
|
|
265
|
-
return
|
|
387
|
+
return lastTitle;
|
|
266
388
|
}
|
|
267
389
|
/**
|
|
268
390
|
* Env var an Autopilot (Ralph/Homer) loop exports to mark that IT owns the
|
|
@@ -303,7 +425,12 @@ export function autopilotOwnsTurn(env = process.env) {
|
|
|
303
425
|
*/
|
|
304
426
|
export async function onStop(opts = {}) {
|
|
305
427
|
const source = opts.source ?? "unknown";
|
|
306
|
-
|
|
428
|
+
// Effective project root: prefer the workspace the hook stdin names (Cursor &
|
|
429
|
+
// Claude run hooks from the IDE's config dir, so process.cwd() is e.g.
|
|
430
|
+
// `~/.cursor` — using it makes the project show as ".cursor"). An explicit
|
|
431
|
+
// --cwd flag still wins (tests / manual invocations). Falls back to cwd.
|
|
432
|
+
const rawCwd = opts.cwd ?? process.cwd();
|
|
433
|
+
const cwd = opts.cwd ?? workspaceRootFromHookStdin(opts.hookStdin) ?? rawCwd;
|
|
307
434
|
const deriveRepo = opts.deriveRepo ?? deriveRepoIdentity;
|
|
308
435
|
const resolveSettings = opts.resolveSettings ?? resolveNotifySettings;
|
|
309
436
|
const logLine = async (line) => {
|
package/dist/src/opencode.js
CHANGED
|
@@ -68,7 +68,10 @@ export const OPENCODE_MCP_ENTRY = {
|
|
|
68
68
|
* Claude/Cursor. Ends in `|| true` so a failure can never block the session, and
|
|
69
69
|
* carries a `--dedupe-key` so overlapping triggers coalesce server-side.
|
|
70
70
|
*/
|
|
71
|
-
|
|
71
|
+
// `@latest` is REQUIRED: bare `npx -y @trygocode/notify` caches the first
|
|
72
|
+
// resolved version forever and never auto-updates (npm/cli#6664). Pinning
|
|
73
|
+
// `@latest` makes the hook always fetch the newest publish so users self-update.
|
|
74
|
+
export const OPENCODE_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --source opencode --dedupe-key opencode-idle || true";
|
|
72
75
|
/**
|
|
73
76
|
* Substrings that together identify our plugin file as OURS. Used to keep
|
|
74
77
|
* uninstall surgical: we only delete the plugin file when BOTH markers are
|
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.3.
|
|
2
|
+
export const VERSION = "0.3.3";
|
package/package.json
CHANGED