@trygocode/notify 0.3.1 → 0.3.2

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.
@@ -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
- if (label)
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
@@ -211,6 +279,49 @@ export async function chatTitleFromHookStdin(hookStdin) {
211
279
  catch {
212
280
  return undefined;
213
281
  }
282
+ // Prefer the IDE's OWN assigned chat name when the transcript carries one, so
283
+ // the push names the chat exactly as the user sees it in the sidebar (rather
284
+ // than a synthesized first-line title). Verified empirically (2026-06-12):
285
+ // - Claude Code writes `{"type":"ai-title","aiTitle":"<sidebar name>"}`
286
+ // records, refined over the chat's life — so take the LAST one.
287
+ // - Cursor agent transcripts carry NO title record (only role/message), so
288
+ // this scan no-ops for Cursor and we fall through to synthesis below.
289
+ // Defensive: also accept a legacy Claude `{"type":"summary","summary":"…"}`
290
+ // record so an older/newer format degrades gracefully instead of throwing.
291
+ {
292
+ let assigned;
293
+ for (const line of raw.split("\n")) {
294
+ const t = line.trim();
295
+ if (!t || (!t.includes("ai-title") && !t.includes("summary")))
296
+ continue;
297
+ let rec;
298
+ try {
299
+ rec = JSON.parse(t);
300
+ }
301
+ catch {
302
+ continue;
303
+ }
304
+ const type = String(rec.type ?? "");
305
+ let cand;
306
+ if (type === "ai-title")
307
+ cand = rec.aiTitle ?? rec.ai_title;
308
+ else if (type === "summary")
309
+ cand = rec.summary;
310
+ if (typeof cand === "string" && cand.trim() !== "") {
311
+ assigned = cand.trim(); // keep scanning → last assigned title wins
312
+ }
313
+ }
314
+ if (assigned) {
315
+ if (assigned.length > 80)
316
+ assigned = assigned.slice(0, 79).trimEnd() + "…";
317
+ return assigned;
318
+ }
319
+ }
320
+ // Synthesis fallback (no IDE-assigned title): name the chat after the LAST
321
+ // user prompt — the one that JUST finished — not the first prompt of the
322
+ // conversation. This reminds the user where they left off (per 2026-06-12
323
+ // request). We keep the last good title and return it after the full scan.
324
+ let lastTitle;
214
325
  for (const line of raw.split("\n")) {
215
326
  const t = line.trim();
216
327
  if (!t)
@@ -247,6 +358,7 @@ export async function chatTitleFromHookStdin(hookStdin) {
247
358
  content = content
248
359
  .replace(/<timestamp(?:\s[^>]*)?>[\s\S]*?<\/timestamp>/gi, "")
249
360
  .replace(/<image_files(?:\s[^>]*)?>[\s\S]*?<\/image_files>/gi, "")
361
+ .replace(/<open_and_recently_viewed_files(?:\s[^>]*)?>[\s\S]*?<\/open_and_recently_viewed_files>/gi, "")
250
362
  .replace(/\[Image\](?!\()\s*/g, "")
251
363
  .replace(/<\/?user_query(?:\s[^>]*)?>/gi, "");
252
364
  const firstLine = content
@@ -260,9 +372,9 @@ export async function chatTitleFromHookStdin(hookStdin) {
260
372
  continue;
261
373
  if (title.length > 80)
262
374
  title = title.slice(0, 79).trimEnd() + "…";
263
- return title;
375
+ lastTitle = title; // keep scanning → LAST user prompt wins
264
376
  }
265
- return undefined;
377
+ return lastTitle;
266
378
  }
267
379
  /**
268
380
  * Env var an Autopilot (Ralph/Homer) loop exports to mark that IT owns the
@@ -303,7 +415,12 @@ export function autopilotOwnsTurn(env = process.env) {
303
415
  */
304
416
  export async function onStop(opts = {}) {
305
417
  const source = opts.source ?? "unknown";
306
- const cwd = opts.cwd ?? process.cwd();
418
+ // Effective project root: prefer the workspace the hook stdin names (Cursor &
419
+ // Claude run hooks from the IDE's config dir, so process.cwd() is e.g.
420
+ // `~/.cursor` — using it makes the project show as ".cursor"). An explicit
421
+ // --cwd flag still wins (tests / manual invocations). Falls back to cwd.
422
+ const rawCwd = opts.cwd ?? process.cwd();
423
+ const cwd = opts.cwd ?? workspaceRootFromHookStdin(opts.hookStdin) ?? rawCwd;
307
424
  const deriveRepo = opts.deriveRepo ?? deriveRepoIdentity;
308
425
  const resolveSettings = opts.resolveSettings ?? resolveNotifySettings;
309
426
  const logLine = async (line) => {
@@ -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.1";
2
+ export const VERSION = "0.3.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Free phone notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
5
5
  "license": "MIT",
6
6
  "type": "module",